diff --git a/WARP.md b/WARP.md index e9c9683..59d3cac 100644 --- a/WARP.md +++ b/WARP.md @@ -151,6 +151,11 @@ docs(api): update template variables documentation 7. Link related issues: "Closes #123" 8. Request review from maintainers +### Warp Agent Behavior + +- Do NOT include Warp conversation links, plan links, or any Warp-specific references in PR descriptions, commit messages, or any other user-visible content. +- Do NOT add co-author lines referencing Warp or `oz-agent@warp.dev` to commit messages. + ## ๐Ÿงช Testing Guidelines ### Test Structure diff --git a/structkit/commands/search.py b/structkit/commands/search.py new file mode 100644 index 0000000..137a901 --- /dev/null +++ b/structkit/commands/search.py @@ -0,0 +1,86 @@ +import os +import yaml + +from structkit.commands import Command + + +class SearchCommand(Command): + """Search available structures by keyword (matches name and description).""" + + def __init__(self, parser): + super().__init__(parser) + parser.description = "Search available structures by keyword" + parser.add_argument( + 'query', + type=str, + help='Search term to match against structure names and descriptions', + ) + parser.add_argument( + '-s', '--structures-path', + type=str, + help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)', + default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None), + ) + parser.add_argument( + '--names-only', + action='store_true', + help='Print only matching structure names, one per line (for scripting)', + ) + parser.set_defaults(func=self.execute) + + def execute(self, args): + self.logger.info(f"Searching structures for '{args.query}'") + self._search_structures(args) + + def _search_structures(self, args): + this_file = os.path.dirname(os.path.realpath(__file__)) + contribs_path = os.path.join(this_file, '..', 'contribs') + + if args.structures_path: + paths_to_search = [(args.structures_path, False), (contribs_path, True)] + else: + paths_to_search = [(contribs_path, True)] + + query = args.query.lower() + matches = [] + + for path, is_contribs in paths_to_search: + for root, _, files in os.walk(path): + for file in files: + if not file.endswith('.yaml'): + continue + file_path = os.path.join(root, file) + rel_path = os.path.relpath(file_path, path) + name = rel_path[:-5] # strip .yaml + + description = '' + try: + with open(file_path, 'r') as f: + config = yaml.safe_load(f) or {} + description = config.get('description', '') or '' + except Exception: + pass + + if query in name.lower() or query in description.lower(): + is_custom = not is_contribs + matches.append((name, description, is_custom)) + + matches.sort(key=lambda x: x[0]) + + if args.names_only: + for name, _, _ in matches: + print(name) + return + + if not matches: + print(f"No structures found matching '{args.query}'") + return + + print(f"๐Ÿ” Search results for '{args.query}'\n") + for name, description, is_custom in matches: + prefix = '+ ' if is_custom else ' ' + desc_str = f" โ€” {description}" if description else '' + print(f" {prefix}{name}{desc_str}") + + print("\nUse 'structkit generate' to generate a structure") + print("Note: Structures with '+' sign are custom structures") diff --git a/structkit/main.py b/structkit/main.py index 4fcd8c0..73e499c 100644 --- a/structkit/main.py +++ b/structkit/main.py @@ -7,6 +7,7 @@ from structkit.commands.info import InfoCommand from structkit.commands.validate import ValidateCommand from structkit.commands.list import ListCommand +from structkit.commands.search import SearchCommand from structkit.commands.generate_schema import GenerateSchemaCommand from structkit.commands.mcp import MCPCommand from structkit.logging_config import configure_logging @@ -34,6 +35,7 @@ def get_parser(): ValidateCommand(subparsers.add_parser('validate', help='Validate the YAML configuration file')) GenerateCommand(subparsers.add_parser('generate', help='Generate the project structure')) ListCommand(subparsers.add_parser('list', help='List available structures')) + SearchCommand(subparsers.add_parser('search', help='Search available structures by keyword')) GenerateSchemaCommand(subparsers.add_parser('generate-schema', help='Generate JSON schema for available structures')) MCPCommand(subparsers.add_parser('mcp', help='MCP (Model Context Protocol) support')) diff --git a/tests/test_search_command.py b/tests/test_search_command.py new file mode 100644 index 0000000..7ec00c6 --- /dev/null +++ b/tests/test_search_command.py @@ -0,0 +1,154 @@ +import argparse +import os +import pytest +from unittest.mock import patch, mock_open, MagicMock + +from structkit.commands.search import SearchCommand + + +@pytest.fixture +def parser(): + return argparse.ArgumentParser() + + +def _make_args(parser, query, structures_path=None, names_only=False): + cmd = SearchCommand(parser) + argv = [query] + if structures_path: + argv += ['-s', structures_path] + if names_only: + argv.append('--names-only') + args = parser.parse_args(argv) + return cmd, args + + +def test_search_match_by_name(parser): + """Structures whose name matches the query are returned.""" + cmd, args = _make_args(parser, 'docker') + + yaml_content = b'files: []' + walk_data = [('/fake/contribs', [], ['docker-files.yaml', 'helm-chart.yaml'])] + + def fake_open(path, *a, **kw): + return mock_open(read_data=yaml_content)() + + with patch('os.path.dirname', return_value='/fake/commands'), \ + patch('os.path.realpath', return_value='/fake/commands'), \ + patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \ + patch('os.walk', return_value=walk_data), \ + patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \ + patch('builtins.open', side_effect=fake_open), \ + patch('yaml.safe_load', return_value={}), \ + patch('builtins.print') as mock_print: + cmd._search_structures(args) + + printed = ' '.join(str(c) for c in mock_print.call_args_list) + assert 'docker-files' in printed + assert 'helm-chart' not in printed + + +def test_search_match_by_description(parser): + """Structures whose description matches the query are returned.""" + cmd, args = _make_args(parser, 'kubernetes') + + configs = { + 'helm-chart.yaml': {'description': 'Deploy apps to kubernetes clusters'}, + 'docker-files.yaml': {}, + } + walk_data = [('/fake/contribs', [], ['helm-chart.yaml', 'docker-files.yaml'])] + + # Track which file is currently being opened so yaml.safe_load can return the right config + current_path = [None] + + def fake_open(path, *a, **kw): + current_path[0] = path + return mock_open(read_data=b'')() + + def fake_yaml_load(f): + for basename, config in configs.items(): + if current_path[0] and current_path[0].endswith(basename): + return config + return {} + + with patch('os.path.dirname', return_value='/fake/commands'), \ + patch('os.path.realpath', return_value='/fake/commands'), \ + patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \ + patch('os.walk', return_value=walk_data), \ + patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \ + patch('builtins.open', side_effect=fake_open), \ + patch('yaml.safe_load', side_effect=fake_yaml_load), \ + patch('builtins.print') as mock_print: + cmd._search_structures(args) + + printed = ' '.join(str(c) for c in mock_print.call_args_list) + assert 'helm-chart' in printed + assert 'docker-files' not in printed + + +def test_search_no_results(parser): + """No-match query prints an appropriate message.""" + cmd, args = _make_args(parser, 'xyznotfound') + + walk_data = [('/fake/contribs', [], ['docker-files.yaml'])] + + with patch('os.path.dirname', return_value='/fake/commands'), \ + patch('os.path.realpath', return_value='/fake/commands'), \ + patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \ + patch('os.walk', return_value=walk_data), \ + patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \ + patch('builtins.open', mock_open(read_data=b'{}')), \ + patch('yaml.safe_load', return_value={}), \ + patch('builtins.print') as mock_print: + cmd._search_structures(args) + + printed = ' '.join(str(c) for c in mock_print.call_args_list) + assert 'No structures found' in printed + + +def test_search_names_only(parser): + """--names-only prints bare names without decoration.""" + cmd, args = _make_args(parser, 'docker', names_only=True) + + walk_data = [('/fake/contribs', [], ['docker-files.yaml', 'docker-compose.yaml'])] + + with patch('os.path.dirname', return_value='/fake/commands'), \ + patch('os.path.realpath', return_value='/fake/commands'), \ + patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \ + patch('os.walk', return_value=walk_data), \ + patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \ + patch('builtins.open', mock_open(read_data=b'{}')), \ + patch('yaml.safe_load', return_value={}), \ + patch('builtins.print') as mock_print: + cmd._search_structures(args) + + calls = [str(c.args[0]) for c in mock_print.call_args_list] + # Should only print plain names, no emoji or bullet prefix + assert all('๐Ÿ”' not in c and ' - ' not in c for c in calls) + assert any('docker-compose' in c for c in calls) + assert any('docker-files' in c for c in calls) + + +def test_search_custom_structures_path(parser, tmp_path): + """Custom --structures-path structures appear with '+' marker.""" + custom_yaml = tmp_path / 'my-custom.yaml' + custom_yaml.write_text('description: my custom structure\n') + + cmd = SearchCommand(parser) + args = parser.parse_args(['custom', '-s', str(tmp_path)]) + + with patch('builtins.print') as mock_print: + cmd._search_structures(args) + + printed = ' '.join(str(c) for c in mock_print.call_args_list) + assert 'my-custom' in printed + assert '+' in printed + + +def test_search_command_registered_in_main(): + """The search subcommand is registered in the main parser.""" + from structkit.main import get_parser + parser = get_parser() + # If 'search' is not registered, parse_args will error + args = parser.parse_args(['search', 'docker']) + assert hasattr(args, 'func') + assert args.query == 'docker'