mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-04-25 13:44:06 +08:00
Closes the inspect-capability parity gap: show-command and show-tool were
the only discovery/inspection CLI commands lacking --output-format support,
making them outliers in the ecosystem that already had unified JSON
contracts across list-sessions, load-session, delete-session, and
flush-transcript (#160/#165/#166).
Concrete additions:
- show-command: --output-format {text,json}
- show-tool: --output-format {text,json}
JSON envelope shape (found case):
{name, found: true, source_hint, responsibility}
JSON envelope shape (not-found case):
{name, found: false, error: {kind:'command_not_found'|'tool_not_found',
message, retryable: false}}
Exit codes:
0 = success
1 = not found
Backward compatibility:
- Default (no --output-format) is 'text' (unchanged)
- Text output byte-identical to pre-#167 (three newline-separated lines)
Tests (10 new, test_show_command_tool_output_format.py):
- TestShowCommandOutputFormat (5): found + not-found in JSON; text mode
backward compat; text is default
- TestShowToolOutputFormat (3): found + not-found in JSON; text mode
backward compat
- TestShowCommandToolFormatParity (2): both accept same flag choices;
consistent JSON envelope shape
Full suite: 114 → 124 passing, zero regression.
Closes ROADMAP #167.
Why this matters:
Before: Claws calling show-command/show-tool had to parse human-readable
prose output via regex, with no structured error signal.
After: Same envelope contract as load-session and friends: JSON-first,
typed errors, machine-parseable.
Related clusters:
- Session-lifecycle CLI parity family (#160, #165, #166, #167)
- Machine-readable error contracts (same vein as #162 atomicity + #164
cancellation state-safety: structured boundaries for orchestration)
204 lines
8.5 KiB
Python
204 lines
8.5 KiB
Python
"""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
|