diff --git a/src/main.py b/src/main.py index f6ce922..364fa4f 100644 --- a/src/main.py +++ b/src/main.py @@ -32,8 +32,10 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser('manifest', help='print the current Python workspace manifest') subparsers.add_parser('parity-audit', help='compare the Python workspace against the local ignored TypeScript archive when available') subparsers.add_parser('setup-report', help='render the startup/prefetch setup report') - subparsers.add_parser('command-graph', help='show command graph segmentation') - subparsers.add_parser('tool-pool', help='show assembled tool pool with default settings') + command_graph_parser = subparsers.add_parser('command-graph', help='show command graph segmentation') + command_graph_parser.add_argument('--output-format', choices=['text', 'json'], default='text') + tool_pool_parser = subparsers.add_parser('tool-pool', help='show assembled tool pool with default settings') + tool_pool_parser.add_argument('--output-format', choices=['text', 'json'], default='text') subparsers.add_parser('bootstrap-graph', help='show the mirrored bootstrap/runtime graph stages') list_parser = subparsers.add_parser('subsystems', help='list the current Python modules in the workspace') list_parser.add_argument('--limit', type=int, default=32) @@ -197,10 +199,35 @@ def main(argv: list[str] | None = None) -> int: print(run_setup().as_markdown()) return 0 if args.command == 'command-graph': - print(build_command_graph().as_markdown()) + graph = build_command_graph() + if args.output_format == 'json': + import json + envelope = { + 'builtins_count': len(graph.builtins), + 'plugin_like_count': len(graph.plugin_like), + 'skill_like_count': len(graph.skill_like), + 'total_count': len(graph.flattened()), + 'builtins': [{'name': m.name, 'source_hint': m.source_hint} for m in graph.builtins], + 'plugin_like': [{'name': m.name, 'source_hint': m.source_hint} for m in graph.plugin_like], + 'skill_like': [{'name': m.name, 'source_hint': m.source_hint} for m in graph.skill_like], + } + print(json.dumps(envelope)) + else: + print(graph.as_markdown()) return 0 if args.command == 'tool-pool': - print(assemble_tool_pool().as_markdown()) + pool = assemble_tool_pool() + if args.output_format == 'json': + import json + envelope = { + 'simple_mode': pool.simple_mode, + 'include_mcp': pool.include_mcp, + 'tool_count': len(pool.tools), + 'tools': [{'name': t.name, 'source_hint': t.source_hint} for t in pool.tools], + } + print(json.dumps(envelope)) + else: + print(pool.as_markdown()) return 0 if args.command == 'bootstrap-graph': print(build_bootstrap_graph().as_markdown()) diff --git a/tests/test_command_graph_tool_pool_output_format.py b/tests/test_command_graph_tool_pool_output_format.py new file mode 100644 index 0000000..4695e3a --- /dev/null +++ b/tests/test_command_graph_tool_pool_output_format.py @@ -0,0 +1,70 @@ +"""Tests for --output-format on command-graph and tool-pool (ROADMAP #169). + +Diagnostic inventory surfaces now speak the CLI family's JSON 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)) + + +def _run(args: list[str]) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, '-m', 'src.main', *args], + cwd=Path(__file__).resolve().parent.parent, + capture_output=True, + text=True, + ) + + +class TestCommandGraphOutputFormat: + def test_command_graph_json(self) -> None: + result = _run(['command-graph', '--output-format', 'json']) + assert result.returncode == 0, result.stderr + + envelope = json.loads(result.stdout) + assert 'builtins_count' in envelope + assert 'plugin_like_count' in envelope + assert 'skill_like_count' in envelope + assert 'total_count' in envelope + assert envelope['total_count'] == ( + envelope['builtins_count'] + envelope['plugin_like_count'] + envelope['skill_like_count'] + ) + assert isinstance(envelope['builtins'], list) + if envelope['builtins']: + assert set(envelope['builtins'][0].keys()) == {'name', 'source_hint'} + + def test_command_graph_text_backward_compat(self) -> None: + result = _run(['command-graph']) + assert result.returncode == 0 + assert '# Command Graph' in result.stdout + assert 'Builtins:' in result.stdout + # Not JSON + assert not result.stdout.strip().startswith('{') + + +class TestToolPoolOutputFormat: + def test_tool_pool_json(self) -> None: + result = _run(['tool-pool', '--output-format', 'json']) + assert result.returncode == 0, result.stderr + + envelope = json.loads(result.stdout) + assert 'simple_mode' in envelope + assert 'include_mcp' in envelope + assert 'tool_count' in envelope + assert 'tools' in envelope + assert envelope['tool_count'] == len(envelope['tools']) + if envelope['tools']: + assert set(envelope['tools'][0].keys()) == {'name', 'source_hint'} + + def test_tool_pool_text_backward_compat(self) -> None: + result = _run(['tool-pool']) + assert result.returncode == 0 + assert '# Tool Pool' in result.stdout + assert 'Simple mode:' in result.stdout + assert not result.stdout.strip().startswith('{')