diff --git a/src/main.py b/src/main.py index 36d88da..50431d1 100644 --- a/src/main.py +++ b/src/main.py @@ -157,8 +157,10 @@ def build_parser() -> argparse.ArgumentParser: show_command = subparsers.add_parser('show-command', help='show one mirrored command entry by exact name') show_command.add_argument('name') + show_command.add_argument('--output-format', choices=['text', 'json'], default='text') show_tool = subparsers.add_parser('show-tool', help='show one mirrored tool entry by exact name') show_tool.add_argument('name') + show_tool.add_argument('--output-format', choices=['text', 'json'], default='text') exec_command_parser = subparsers.add_parser('exec-command', help='execute a mirrored command shim by exact name') exec_command_parser.add_argument('name') @@ -394,16 +396,62 @@ def main(argv: list[str] | None = None) -> int: if args.command == 'show-command': module = get_command(args.name) if module is None: - print(f'Command not found: {args.name}') + if args.output_format == 'json': + import json + error_envelope = { + 'name': args.name, + 'found': False, + 'error': { + 'kind': 'command_not_found', + 'message': f'Unknown command: {args.name}', + 'retryable': False, + }, + } + print(json.dumps(error_envelope)) + else: + print(f'Command not found: {args.name}') return 1 - print('\n'.join([module.name, module.source_hint, module.responsibility])) + if args.output_format == 'json': + import json + output = { + 'name': module.name, + 'found': True, + 'source_hint': module.source_hint, + 'responsibility': module.responsibility, + } + print(json.dumps(output)) + else: + print('\n'.join([module.name, module.source_hint, module.responsibility])) return 0 if args.command == 'show-tool': module = get_tool(args.name) if module is None: - print(f'Tool not found: {args.name}') + if args.output_format == 'json': + import json + error_envelope = { + 'name': args.name, + 'found': False, + 'error': { + 'kind': 'tool_not_found', + 'message': f'Unknown tool: {args.name}', + 'retryable': False, + }, + } + print(json.dumps(error_envelope)) + else: + print(f'Tool not found: {args.name}') return 1 - print('\n'.join([module.name, module.source_hint, module.responsibility])) + if args.output_format == 'json': + import json + output = { + 'name': module.name, + 'found': True, + 'source_hint': module.source_hint, + 'responsibility': module.responsibility, + } + print(json.dumps(output)) + else: + print('\n'.join([module.name, module.source_hint, module.responsibility])) return 0 if args.command == 'exec-command': result = execute_command(args.name, args.prompt) diff --git a/tests/test_show_command_tool_output_format.py b/tests/test_show_command_tool_output_format.py new file mode 100644 index 0000000..41f4440 --- /dev/null +++ b/tests/test_show_command_tool_output_format.py @@ -0,0 +1,203 @@ +"""Tests for --output-format flag on show-command and show-tool (ROADMAP #167). + +Verifies parity with session-lifecycle CLI family (#160/#165/#166): +- show-command and show-tool now accept --output-format {text,json} +- Found case returns success with JSON envelope: {name, found: true, source_hint, responsibility} +- Not-found case returns typed error envelope: {name, found: false, error: {kind, message, retryable}} +- Legacy text output (default) unchanged for backward compat +- Exit code 0 on success, 1 on not-found (matching load-session contract) +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +class TestShowCommandOutputFormat: + """show-command --output-format {text,json} parity with session-lifecycle family.""" + + def test_show_command_found_json(self) -> None: + """show-command with found entry returns JSON envelope.""" + result = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-command', 'add-dir', '--output-format', 'json'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f'Expected exit 0, got {result.returncode}: {result.stderr}' + + envelope = json.loads(result.stdout) + assert envelope['found'] is True + assert envelope['name'] == 'add-dir' + assert 'source_hint' in envelope + assert 'responsibility' in envelope + # No error field when found + assert 'error' not in envelope + + def test_show_command_not_found_json(self) -> None: + """show-command with missing entry returns typed error envelope.""" + result = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-command', 'nonexistent-cmd', '--output-format', 'json'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + assert result.returncode == 1, f'Expected exit 1 on not-found, got {result.returncode}' + + envelope = json.loads(result.stdout) + assert envelope['found'] is False + assert envelope['name'] == 'nonexistent-cmd' + assert envelope['error']['kind'] == 'command_not_found' + assert envelope['error']['retryable'] is False + # No source_hint/responsibility when not found + assert 'source_hint' not in envelope or envelope.get('source_hint') is None + assert 'responsibility' not in envelope or envelope.get('responsibility') is None + + def test_show_command_text_mode_backward_compat(self) -> None: + """show-command text mode (default) is unchanged from pre-#167.""" + result = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-command', 'add-dir'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + + # Text output is newline-separated (name, source_hint, responsibility) + lines = result.stdout.strip().split('\n') + assert len(lines) == 3 + assert lines[0] == 'add-dir' + assert 'commands/add-dir/add-dir.tsx' in lines[1] + + def test_show_command_text_mode_not_found(self) -> None: + """show-command text mode on not-found returns prose error.""" + result = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-command', 'missing'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + assert result.returncode == 1 + assert 'not found' in result.stdout.lower() + assert 'missing' in result.stdout + + def test_show_command_default_is_text(self) -> None: + """Omitting --output-format defaults to text.""" + result_implicit = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-command', 'add-dir'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + result_explicit = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-command', 'add-dir', '--output-format', 'text'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + assert result_implicit.stdout == result_explicit.stdout + + +class TestShowToolOutputFormat: + """show-tool --output-format {text,json} parity with session-lifecycle family.""" + + def test_show_tool_found_json(self) -> None: + """show-tool with found entry returns JSON envelope.""" + result = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-tool', 'BashTool', '--output-format', 'json'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f'Expected exit 0, got {result.returncode}: {result.stderr}' + + envelope = json.loads(result.stdout) + assert envelope['found'] is True + assert envelope['name'] == 'BashTool' + assert 'source_hint' in envelope + assert 'responsibility' in envelope + assert 'error' not in envelope + + def test_show_tool_not_found_json(self) -> None: + """show-tool with missing entry returns typed error envelope.""" + result = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-tool', 'NotARealTool', '--output-format', 'json'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + assert result.returncode == 1, f'Expected exit 1 on not-found, got {result.returncode}' + + envelope = json.loads(result.stdout) + assert envelope['found'] is False + assert envelope['name'] == 'NotARealTool' + assert envelope['error']['kind'] == 'tool_not_found' + assert envelope['error']['retryable'] is False + + def test_show_tool_text_mode_backward_compat(self) -> None: + """show-tool text mode (default) is unchanged from pre-#167.""" + result = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-tool', 'BashTool'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + + lines = result.stdout.strip().split('\n') + assert len(lines) == 3 + assert lines[0] == 'BashTool' + assert 'tools/BashTool/BashTool.tsx' in lines[1] + + +class TestShowCommandToolFormatParity: + """Verify symmetry between show-command and show-tool formats.""" + + def test_both_accept_output_format_flag(self) -> None: + """Both commands accept the same --output-format choices.""" + # Just ensure both fail with invalid choice (they accept text/json) + result_cmd = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-command', 'add-dir', '--output-format', 'invalid'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + result_tool = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-tool', 'BashTool', '--output-format', 'invalid'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + # Both should fail with argument parser error + assert result_cmd.returncode != 0 + assert result_tool.returncode != 0 + assert 'invalid choice' in result_cmd.stderr + assert 'invalid choice' in result_tool.stderr + + def test_json_envelope_shape_consistency(self) -> None: + """Both commands return consistent JSON envelope shape.""" + cmd_result = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-command', 'add-dir', '--output-format', 'json'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + tool_result = subprocess.run( + [sys.executable, '-m', 'src.main', 'show-tool', 'BashTool', '--output-format', 'json'], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + + cmd_envelope = json.loads(cmd_result.stdout) + tool_envelope = json.loads(tool_result.stdout) + + # Same top-level keys for found=true case + assert set(cmd_envelope.keys()) == set(tool_envelope.keys()) + assert cmd_envelope['found'] is True + assert tool_envelope['found'] is True