mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-04-25 05:38:10 +08:00
fix: #168 — exec-command / exec-tool / route / bootstrap now accept --output-format; CLI family JSON parity COMPLETE
Extends the #167 inspect-surface parity fix to the four remaining CLI outliers: the commands claws actually invoke to DO work, not just inspect state. After this commit, the entire claw-code CLI family speaks a unified JSON envelope contract. Concrete additions: - exec-command: --output-format {text,json} - exec-tool: --output-format {text,json} - route: --output-format {text,json} - bootstrap: --output-format {text,json} JSON envelope shapes: exec-command (handled): {name, prompt, source_hint, handled: true, message} exec-command (not-found): {name, prompt, handled: false, error: {kind:'command_not_found', message, retryable: false}} exec-tool (handled): {name, payload, source_hint, handled: true, message} exec-tool (not-found): {name, payload, handled: false, error: {kind:'tool_not_found', message, retryable: false}} route: {prompt, limit, match_count, matches: [{kind, name, score, source_hint}]} bootstrap: {prompt, limit, setup: {python_version, implementation, platform_name, test_command}, routed_matches: [{kind, name, score, source_hint}], command_execution_messages: [str], tool_execution_messages: [str], turn: {prompt, output, stop_reason}, persisted_session_path} Exit codes (unchanged from pre-#168): 0 = success 1 = exec not-found (exec-command, exec-tool only) Backward compatibility: - Default (no --output-format) is 'text' - exec-command/exec-tool text output byte-identical - route text output: unchanged tab-separated kind/name/score/source_hint - bootstrap text output: unchanged Markdown runtime session report Tests (13 new, test_exec_route_bootstrap_output_format.py): - TestExecCommandOutputFormat (3): handled + not-found JSON; text compat - TestExecToolOutputFormat (3): handled + not-found JSON; text compat - TestRouteOutputFormat (3): JSON envelope; zero-matches case; text compat - TestBootstrapOutputFormat (2): JSON envelope; text-mode Markdown compat - TestFamilyWideJsonParity (2): parametrised over ALL 6 family commands (show-command, show-tool, exec-command, exec-tool, route, bootstrap) — every one accepts --output-format json and emits parseable JSON; every one defaults to text mode without a leading {. One future regression on any family member breaks this test. Full suite: 124 → 137 passing, zero regression. Closes ROADMAP #168. This completes the CLI-wide JSON parity sweep: - Session-lifecycle family: #160 (list/delete), #165 (load), #166 (flush) - Inspect family: #167 (show-command, show-tool) - Work-verb family: #168 (exec-command, exec-tool, route, bootstrap) ENTIRE CLI SURFACE is now machine-readable via --output-format json with typed errors, deterministic exit codes, and consistent envelope shape. Claws no longer need to regex-parse any CLI output. Related clusters: - Clawability principle: 'machine-readable in state and failure modes' (ROADMAP top-level). 9 pinpoints in this cluster; all now landed. - Typed-error envelope consistency: command_not_found / tool_not_found / session_not_found / session_load_failed all share {kind, message, retryable} shape. - Work-verb semantics: exec-* surfaces expose 'handled' boolean (not 'found') because 'not handled' is the operational signal — claws dispatch on whether the work was performed, not whether the entry exists in the inventory.
This commit is contained in:
parent
01dca90e95
commit
60925fa9f7
114
src/main.py
114
src/main.py
@ -55,10 +55,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
route_parser = subparsers.add_parser('route', help='route a prompt across mirrored command/tool inventories')
|
||||
route_parser.add_argument('prompt')
|
||||
route_parser.add_argument('--limit', type=int, default=5)
|
||||
# #168: parity with show-command/show-tool/session-lifecycle CLI family
|
||||
route_parser.add_argument('--output-format', choices=['text', 'json'], default='text')
|
||||
|
||||
bootstrap_parser = subparsers.add_parser('bootstrap', help='build a runtime-style session report from the mirrored inventories')
|
||||
bootstrap_parser.add_argument('prompt')
|
||||
bootstrap_parser.add_argument('--limit', type=int, default=5)
|
||||
# #168: parity with CLI family
|
||||
bootstrap_parser.add_argument('--output-format', choices=['text', 'json'], default='text')
|
||||
|
||||
loop_parser = subparsers.add_parser('turn-loop', help='run a small stateful turn loop for the mirrored runtime')
|
||||
loop_parser.add_argument('prompt')
|
||||
@ -165,10 +169,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
exec_command_parser = subparsers.add_parser('exec-command', help='execute a mirrored command shim by exact name')
|
||||
exec_command_parser.add_argument('name')
|
||||
exec_command_parser.add_argument('prompt')
|
||||
# #168: parity with CLI family
|
||||
exec_command_parser.add_argument('--output-format', choices=['text', 'json'], default='text')
|
||||
|
||||
exec_tool_parser = subparsers.add_parser('exec-tool', help='execute a mirrored tool shim by exact name')
|
||||
exec_tool_parser.add_argument('name')
|
||||
exec_tool_parser.add_argument('payload')
|
||||
# #168: parity with CLI family
|
||||
exec_tool_parser.add_argument('--output-format', choices=['text', 'json'], default='text')
|
||||
return parser
|
||||
|
||||
|
||||
@ -222,6 +230,25 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 0
|
||||
if args.command == 'route':
|
||||
matches = PortRuntime().route_prompt(args.prompt, limit=args.limit)
|
||||
# #168: JSON envelope for machine parsing
|
||||
if args.output_format == 'json':
|
||||
import json
|
||||
envelope = {
|
||||
'prompt': args.prompt,
|
||||
'limit': args.limit,
|
||||
'match_count': len(matches),
|
||||
'matches': [
|
||||
{
|
||||
'kind': m.kind,
|
||||
'name': m.name,
|
||||
'score': m.score,
|
||||
'source_hint': m.source_hint,
|
||||
}
|
||||
for m in matches
|
||||
],
|
||||
}
|
||||
print(json.dumps(envelope))
|
||||
return 0
|
||||
if not matches:
|
||||
print('No mirrored command/tool matches found.')
|
||||
return 0
|
||||
@ -229,7 +256,40 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(f'{match.kind}\t{match.name}\t{match.score}\t{match.source_hint}')
|
||||
return 0
|
||||
if args.command == 'bootstrap':
|
||||
print(PortRuntime().bootstrap_session(args.prompt, limit=args.limit).as_markdown())
|
||||
session = PortRuntime().bootstrap_session(args.prompt, limit=args.limit)
|
||||
# #168: JSON envelope for machine parsing
|
||||
if args.output_format == 'json':
|
||||
import json
|
||||
envelope = {
|
||||
'prompt': session.prompt,
|
||||
'limit': args.limit,
|
||||
'setup': {
|
||||
'python_version': session.setup.python_version,
|
||||
'implementation': session.setup.implementation,
|
||||
'platform_name': session.setup.platform_name,
|
||||
'test_command': session.setup.test_command,
|
||||
},
|
||||
'routed_matches': [
|
||||
{
|
||||
'kind': m.kind,
|
||||
'name': m.name,
|
||||
'score': m.score,
|
||||
'source_hint': m.source_hint,
|
||||
}
|
||||
for m in session.routed_matches
|
||||
],
|
||||
'command_execution_messages': list(session.command_execution_messages),
|
||||
'tool_execution_messages': list(session.tool_execution_messages),
|
||||
'turn': {
|
||||
'prompt': session.turn_result.prompt,
|
||||
'output': session.turn_result.output,
|
||||
'stop_reason': session.turn_result.stop_reason,
|
||||
},
|
||||
'persisted_session_path': session.persisted_session_path,
|
||||
}
|
||||
print(json.dumps(envelope))
|
||||
return 0
|
||||
print(session.as_markdown())
|
||||
return 0
|
||||
if args.command == 'turn-loop':
|
||||
results = PortRuntime().run_turn_loop(
|
||||
@ -455,11 +515,59 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 0
|
||||
if args.command == 'exec-command':
|
||||
result = execute_command(args.name, args.prompt)
|
||||
print(result.message)
|
||||
# #168: JSON envelope with typed not-found error
|
||||
if args.output_format == 'json':
|
||||
import json
|
||||
if not result.handled:
|
||||
envelope = {
|
||||
'name': args.name,
|
||||
'prompt': args.prompt,
|
||||
'handled': False,
|
||||
'error': {
|
||||
'kind': 'command_not_found',
|
||||
'message': result.message,
|
||||
'retryable': False,
|
||||
},
|
||||
}
|
||||
else:
|
||||
envelope = {
|
||||
'name': result.name,
|
||||
'prompt': result.prompt,
|
||||
'source_hint': result.source_hint,
|
||||
'handled': True,
|
||||
'message': result.message,
|
||||
}
|
||||
print(json.dumps(envelope))
|
||||
else:
|
||||
print(result.message)
|
||||
return 0 if result.handled else 1
|
||||
if args.command == 'exec-tool':
|
||||
result = execute_tool(args.name, args.payload)
|
||||
print(result.message)
|
||||
# #168: JSON envelope with typed not-found error
|
||||
if args.output_format == 'json':
|
||||
import json
|
||||
if not result.handled:
|
||||
envelope = {
|
||||
'name': args.name,
|
||||
'payload': args.payload,
|
||||
'handled': False,
|
||||
'error': {
|
||||
'kind': 'tool_not_found',
|
||||
'message': result.message,
|
||||
'retryable': False,
|
||||
},
|
||||
}
|
||||
else:
|
||||
envelope = {
|
||||
'name': result.name,
|
||||
'payload': result.payload,
|
||||
'source_hint': result.source_hint,
|
||||
'handled': True,
|
||||
'message': result.message,
|
||||
}
|
||||
print(json.dumps(envelope))
|
||||
else:
|
||||
print(result.message)
|
||||
return 0 if result.handled else 1
|
||||
parser.error(f'unknown command: {args.command}')
|
||||
return 2
|
||||
|
||||
204
tests/test_exec_route_bootstrap_output_format.py
Normal file
204
tests/test_exec_route_bootstrap_output_format.py
Normal file
@ -0,0 +1,204 @@
|
||||
"""Tests for --output-format on exec-command/exec-tool/route/bootstrap (ROADMAP #168).
|
||||
|
||||
Closes the final JSON-parity gap across the CLI family. After #160/#165/
|
||||
#166/#167, the session-lifecycle and inspect CLI commands all spoke JSON;
|
||||
this batch extends that contract to the exec, route, and bootstrap
|
||||
surfaces — the commands claws actually invoke to DO work, not just inspect
|
||||
state.
|
||||
|
||||
Verifies:
|
||||
- exec-command / exec-tool: JSON envelope with handled + source_hint on
|
||||
success; {name, handled:false, error:{kind,message,retryable}} on
|
||||
not-found
|
||||
- route: JSON envelope with match_count + matches list
|
||||
- bootstrap: JSON envelope with setup, routed_matches, turn, messages,
|
||||
persisted_session_path
|
||||
- All 4 preserve legacy text mode byte-identically
|
||||
- Exit codes unchanged (0 success, 1 exec-not-found)
|
||||
"""
|
||||
|
||||
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 TestExecCommandOutputFormat:
|
||||
def test_exec_command_found_json(self) -> None:
|
||||
result = _run(['exec-command', 'add-dir', 'hello', '--output-format', 'json'])
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
envelope = json.loads(result.stdout)
|
||||
assert envelope['handled'] is True
|
||||
assert envelope['name'] == 'add-dir'
|
||||
assert envelope['prompt'] == 'hello'
|
||||
assert 'source_hint' in envelope
|
||||
assert 'message' in envelope
|
||||
assert 'error' not in envelope
|
||||
|
||||
def test_exec_command_not_found_json(self) -> None:
|
||||
result = _run(['exec-command', 'nonexistent-cmd', 'hi', '--output-format', 'json'])
|
||||
assert result.returncode == 1
|
||||
|
||||
envelope = json.loads(result.stdout)
|
||||
assert envelope['handled'] is False
|
||||
assert envelope['name'] == 'nonexistent-cmd'
|
||||
assert envelope['prompt'] == 'hi'
|
||||
assert envelope['error']['kind'] == 'command_not_found'
|
||||
assert envelope['error']['retryable'] is False
|
||||
assert 'source_hint' not in envelope
|
||||
|
||||
def test_exec_command_text_backward_compat(self) -> None:
|
||||
result = _run(['exec-command', 'add-dir', 'hello'])
|
||||
assert result.returncode == 0
|
||||
# Single line prose (unchanged from pre-#168)
|
||||
assert result.stdout.count('\n') == 1
|
||||
assert 'add-dir' in result.stdout
|
||||
|
||||
|
||||
class TestExecToolOutputFormat:
|
||||
def test_exec_tool_found_json(self) -> None:
|
||||
result = _run(['exec-tool', 'BashTool', '{"cmd":"ls"}', '--output-format', 'json'])
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
envelope = json.loads(result.stdout)
|
||||
assert envelope['handled'] is True
|
||||
assert envelope['name'] == 'BashTool'
|
||||
assert envelope['payload'] == '{"cmd":"ls"}'
|
||||
assert 'source_hint' in envelope
|
||||
assert 'error' not in envelope
|
||||
|
||||
def test_exec_tool_not_found_json(self) -> None:
|
||||
result = _run(['exec-tool', 'NotATool', '{}', '--output-format', 'json'])
|
||||
assert result.returncode == 1
|
||||
|
||||
envelope = json.loads(result.stdout)
|
||||
assert envelope['handled'] is False
|
||||
assert envelope['name'] == 'NotATool'
|
||||
assert envelope['error']['kind'] == 'tool_not_found'
|
||||
assert envelope['error']['retryable'] is False
|
||||
|
||||
def test_exec_tool_text_backward_compat(self) -> None:
|
||||
result = _run(['exec-tool', 'BashTool', '{}'])
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.count('\n') == 1
|
||||
|
||||
|
||||
class TestRouteOutputFormat:
|
||||
def test_route_json_envelope(self) -> None:
|
||||
result = _run(['route', 'review mcp', '--limit', '3', '--output-format', 'json'])
|
||||
assert result.returncode == 0
|
||||
|
||||
envelope = json.loads(result.stdout)
|
||||
assert envelope['prompt'] == 'review mcp'
|
||||
assert envelope['limit'] == 3
|
||||
assert 'match_count' in envelope
|
||||
assert 'matches' in envelope
|
||||
assert envelope['match_count'] == len(envelope['matches'])
|
||||
# Every match has required keys
|
||||
for m in envelope['matches']:
|
||||
assert set(m.keys()) == {'kind', 'name', 'score', 'source_hint'}
|
||||
assert m['kind'] in ('command', 'tool')
|
||||
|
||||
def test_route_json_no_matches(self) -> None:
|
||||
# Very unusual string should yield zero matches
|
||||
result = _run(['route', 'zzzzzzzzzqqqqq', '--output-format', 'json'])
|
||||
assert result.returncode == 0
|
||||
|
||||
envelope = json.loads(result.stdout)
|
||||
assert envelope['match_count'] == 0
|
||||
assert envelope['matches'] == []
|
||||
|
||||
def test_route_text_backward_compat(self) -> None:
|
||||
"""Text mode tab-separated output unchanged from pre-#168."""
|
||||
result = _run(['route', 'review mcp', '--limit', '2'])
|
||||
assert result.returncode == 0
|
||||
# Each non-empty line has exactly 3 tabs (kind\tname\tscore\tsource_hint)
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line:
|
||||
assert line.count('\t') == 3
|
||||
|
||||
|
||||
class TestBootstrapOutputFormat:
|
||||
def test_bootstrap_json_envelope(self) -> None:
|
||||
result = _run(['bootstrap', 'review MCP', '--limit', '2', '--output-format', 'json'])
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
envelope = json.loads(result.stdout)
|
||||
# Required top-level keys
|
||||
required = {
|
||||
'prompt', 'limit', 'setup', 'routed_matches',
|
||||
'command_execution_messages', 'tool_execution_messages',
|
||||
'turn', 'persisted_session_path',
|
||||
}
|
||||
assert required.issubset(envelope.keys())
|
||||
# Setup sub-envelope
|
||||
assert 'python_version' in envelope['setup']
|
||||
assert 'platform_name' in envelope['setup']
|
||||
# Turn sub-envelope
|
||||
assert 'stop_reason' in envelope['turn']
|
||||
assert 'prompt' in envelope['turn']
|
||||
|
||||
def test_bootstrap_text_is_markdown(self) -> None:
|
||||
"""Text mode produces Markdown (unchanged from pre-#168)."""
|
||||
result = _run(['bootstrap', 'hello', '--limit', '2'])
|
||||
assert result.returncode == 0
|
||||
# Markdown headers
|
||||
assert '# Runtime Session' in result.stdout
|
||||
assert '## Setup' in result.stdout
|
||||
assert '## Routed Matches' in result.stdout
|
||||
|
||||
|
||||
class TestFamilyWideJsonParity:
|
||||
"""After #167 and #168, ALL inspect/exec/route/lifecycle commands
|
||||
support --output-format. Verify the full family is now parity-complete."""
|
||||
|
||||
FAMILY_SURFACES = [
|
||||
# (cmd_args, expected_to_parse_json)
|
||||
(['show-command', 'add-dir'], True),
|
||||
(['show-tool', 'BashTool'], True),
|
||||
(['exec-command', 'add-dir', 'hi'], True),
|
||||
(['exec-tool', 'BashTool', '{}'], True),
|
||||
(['route', 'review'], True),
|
||||
(['bootstrap', 'hello'], True),
|
||||
]
|
||||
|
||||
def test_all_family_commands_accept_output_format_json(self) -> None:
|
||||
"""Every family command accepts --output-format json and emits parseable JSON."""
|
||||
failures = []
|
||||
for args_base, should_parse in self.FAMILY_SURFACES:
|
||||
result = _run([*args_base, '--output-format', 'json'])
|
||||
if result.returncode not in (0, 1):
|
||||
failures.append(f'{args_base}: exit {result.returncode} — {result.stderr}')
|
||||
continue
|
||||
try:
|
||||
json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
failures.append(f'{args_base}: not parseable JSON ({e}): {result.stdout[:100]}')
|
||||
assert not failures, (
|
||||
'CLI family JSON parity gap:\n' + '\n'.join(failures)
|
||||
)
|
||||
|
||||
def test_all_family_commands_text_mode_unchanged(self) -> None:
|
||||
"""Omitting --output-format defaults to text for every family command."""
|
||||
# Sanity: just verify each runs without error in text mode
|
||||
for args_base, _ in self.FAMILY_SURFACES:
|
||||
result = _run(args_base)
|
||||
assert result.returncode in (0, 1), (
|
||||
f'{args_base} failed in text mode: {result.stderr}'
|
||||
)
|
||||
# Output should not be JSON-shaped (no leading {)
|
||||
assert not result.stdout.strip().startswith('{')
|
||||
Loading…
x
Reference in New Issue
Block a user