Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions WARP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions structkit/commands/search.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions structkit/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'))

Expand Down
154 changes: 154 additions & 0 deletions tests/test_search_command.py
Original file line number Diff line number Diff line change
@@ -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'
Loading