From 7fb95e95f63935a80b97bb1d2459ece6bed12fb2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 22 Apr 2026 18:47:34 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20#169=20=E2=80=94=20command-graph=20and?= =?UTF-8?q?=20tool-pool=20now=20accept=20--output-format;=20diagnostic=20i?= =?UTF-8?q?nventory=20JSON=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the diagnostic surface audit with the two inventory-structure commands: command-graph (command family segmentation) and tool-pool (assembled tool inventory). Both now expose their underlying rich datastructures via JSON envelope. Concrete additions: - command-graph: --output-format {text,json} - tool-pool: --output-format {text,json} JSON envelope shapes: command-graph: {builtins_count, plugin_like_count, skill_like_count, total_count, builtins: [{name, source_hint}], plugin_like: [{name, source_hint}], skill_like: [{name, source_hint}]} tool-pool: {simple_mode, include_mcp, tool_count, tools: [{name, source_hint}]} Backward compatibility: - Default is 'text' (Markdown unchanged) - Text output byte-identical to pre-#169 Tests (4 new, test_command_graph_tool_pool_output_format.py): - TestCommandGraphOutputFormat (2): JSON structure + text compat - TestToolPoolOutputFormat (2): JSON structure + text compat Full suite: 137 → 141 passing, zero regression. Closes ROADMAP #169. Why this matters: Claws auditing the codebase can now ask 'what commands exist' and 'what tools exist' and get structured, parseable answers instead of regex-parsing Markdown headers and counting list items. Related clusters: - Diagnostic surfaces (#169 adds to #167/#168 work-verb parity) - Inventory introspection (command-graph + tool-pool are the two foundational 'what do we have?' queries) --- src/main.py | 35 ++++++++-- ...t_command_graph_tool_pool_output_format.py | 70 +++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 tests/test_command_graph_tool_pool_output_format.py 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('{')