fix(cli): add Read-Only and Writes columns to tool list output #1476 #1509

Merged
HAL9000 merged 3 commits from fix/1476-tool-list-cols into master 2026-05-30 08:01:16 +00:00
4 changed files with 104 additions and 12 deletions
+8
View File
@@ -6,6 +6,14 @@ Changed `wf10_batch.robot` to be less likely to create files, and
`plan_generation_graph.robot` to give more test answers.
## [Unreleased]
- **fix(cli): add Read-Only and Writes columns to tool list output** (#1476): Rewrote
`list_tools()` in `src/cleveragents/cli/commands/tool.py` to render exactly the 5
spec-required columns (Name, Type, Source, Read-Only, Writes), removed the legacy
Description and Timeout columns, computes read-only/writes status from `capability`
metadata (rendering `✓`/`—`), and added a Summary panel showing Total, Tools,
Validations, Read-Only, Writes, and Namespaces counts. Adds Behave BDD tests in
`features/tool_cli.feature` verifying correct column names, capability rendering, and
Summary panel presence.
- **Fix actor compiler to read LSP bindings from typed `lsp_binding` field** (#1488): Fixed
`_extract_lsp_bindings()` in `actor/compiler.py` to read from `node.lsp_binding` (the typed
`NodeLspBinding` Pydantic field on `NodeDefinition`) as the primary path, with
+18
View File
@@ -195,6 +195,13 @@ def step_no_mocked_tools(context: Context) -> None:
context.mock_service.list_tools.return_value = []
@given("there is a mocked read-only tool in the registry")
def step_mocked_read_only_tool(context: Context) -> None:
tool = _make_mock_tool("local/read-only-tool", "tool", "custom")
tool["capability"] = {"read_only": True, "writes": False, "checkpointable": False}
context.mock_service.list_tools.return_value = [tool]
@given('there is a mocked tool with name "{name}"')
def step_mocked_tool_exists(context: Context, name: str) -> None:
context.mock_service.get_tool.return_value = _make_mock_tool(name)
@@ -632,6 +639,17 @@ def step_tool_list_empty(context: Context) -> None:
assert "No tools found" in context.tool_result.output
@then("the Rich tool list output contains all five spec columns")
def step_rich_tool_list_columns(context: Context) -> None:
assert context.tool_result is not None
assert context.tool_result.exit_code == 0
output = context.tool_result.output
for col in ("Name", "Type", "Source", "Read-Only", "Writes"):
assert col in output, (
f"Expected column '{col}' in Rich table output. Got: {output}"
)
@then("the tool CLI should show the tool details")
def step_tool_show_details(context: Context) -> None:
assert context.tool_result is not None
+20
View File
@@ -113,6 +113,26 @@ Feature: Tool and Validation CLI commands
When I run tool CLI list with format "table"
Then the tool CLI list should succeed with results
Scenario: Rich table list has exactly the five spec columns
Given there are mocked tools in the registry
When I run tool CLI list
Then the Rich tool list output contains all five spec columns
Scenario: Rich table list renders checkmark for read-only capability
Given there is a mocked read-only tool in the registry
When I run tool CLI list
Then the tool CLI output should contain ""
Scenario: Rich table list renders dash for non-read-only capability
Given there are mocked tools in the registry
When I run tool CLI list
Then the tool CLI output should contain ""
Scenario: Rich table list shows a Summary panel
Given there are mocked tools in the registry
When I run tool CLI list
Then the tool CLI output should contain "Summary"
# Tool show command tests
Scenario: Show tool by name
Given there is a mocked tool with name "local/test-tool"
+58 -12
View File
1
@@ -38,7 +38,7 @@ code: |
|----------------------------|------------------------------------------|
| ``Config file error`` | File not found or not readable |
| ``Schema validation error``| YAML does not match Tool schema |
| ``Duplicate tool`` | Tool name already registered |
| ``Duplicate tool`` | Tool name already registered |
Based on implementation_plan.md -- Task C1.tool.cli.
"""
@@ -416,29 +416,75 @@ def list_tools(
console.print(format_output(data, fmt))
return
# Rich table
# Rich table — spec-compliant: Name, Type, Source, Read-Only, Writes
Outdated
Review

[RUNTIME BUG] These two add_column() calls are added but add_row() below (lines ~428-434) still only passes 5 values for what is now a 7-column table. Rich assigns values positionally, so every column after Name will display the wrong data.

Required changes:

  1. Remove Description and Timeout columns (not in spec)
  2. Reorder to match spec: Name, Type, Source, Read-Only, Writes
  3. Add Read-Only/Writes computation logic with "✓" / "—" rendering
  4. Update add_row() to pass exactly 5 values matching the 5 columns
**[RUNTIME BUG]** These two `add_column()` calls are added but `add_row()` below (lines ~428-434) still only passes 5 values for what is now a 7-column table. Rich assigns values positionally, so every column after Name will display the wrong data. **Required changes:** 1. Remove `Description` and `Timeout` columns (not in spec) 2. Reorder to match spec: Name, Type, Source, Read-Only, Writes 3. Add Read-Only/Writes computation logic with `"✓"` / `"—"` rendering 4. Update `add_row()` to pass exactly 5 values matching the 5 columns
table = Table(title=f"Tools ({len(tools)} total)")
table.add_column("Name", style="cyan")
table.add_column("Type", style="blue")
table.add_column("Source", style="magenta")
table.add_column("Description", style="dim")
table.add_column("Timeout", justify="right")
table.add_column("Read-Only", justify="center", style="green")
table.add_column("Writes", justify="center", style="yellow")
read_only_count: int = 0
writes_count: int = 0
tool_count: int = 0
validation_count: int = 0
namespaces: set[str] = set()
for tool in tools:
spec = _tool_spec_dict(tool)
desc = str(spec.get("description", ""))
if len(desc) > 40:
desc = desc[:37] + "..."
name_val = str(spec.get("name", ""))
tool_type_val = str(spec.get("tool_type", "tool"))
source_val = str(spec.get("source", ""))
# Determine Read-Only / Writes from capability metadata
capability = spec.get("capability", {})
if isinstance(capability, dict):
is_read_only: bool = capability.get("read_only", False)
has_writes: bool = capability.get("writes", False)
Outdated
Review

Suggestion: Add Behave unit tests in features/ for list_tools() Rich rendering - covering 5 correct columns, checkmark/dash rendering from capability dict values, and Summary panel counts. This is a hard blocker per issue #1476 acceptance criteria.

Suggestion: Add Behave unit tests in features/ for list_tools() Rich rendering - covering 5 correct columns, checkmark/dash rendering from capability dict values, and Summary panel counts. This is a hard blocker per issue #1476 acceptance criteria.
else:
is_read_only = False
has_writes = False
read_only_str: str = "\u2713" if is_read_only else "\u2014"
writes_str: str = "\u2713" if has_writes else "\u2014"
table.add_row(
str(spec.get("name", "")),
str(spec.get("tool_type", "tool")),
str(spec.get("source", "")),
desc,
str(spec.get("timeout", 300)),
name_val,
tool_type_val,
source_val,
read_only_str,
writes_str,
)
# Accumulate summary statistics
namespaces.add(name_val.split("/", 1)[0] if "/" in name_val else name_val)
if tool_type_val == "validation":
validation_count += 1
else:
tool_count += 1
if is_read_only:
read_only_count += 1
if has_writes:
writes_count += 1
console.print(table)
# Summary panel
summary_lines = [
f" Total: {len(tools)}",
f" Tools: {tool_count}",
f" Validations: {validation_count}",
f" Read-Only: {read_only_count}",
f" Writes: {writes_count}",
Outdated
Review

Note: Summary panel includes Read-Only and Writes counts beyond spec Total/Tools/Validations/Namespaces. Useful enhancement but worth documenting as spec departure.

Note: Summary panel includes Read-Only and Writes counts beyond spec Total/Tools/Validations/Namespaces. Useful enhancement but worth documenting as spec departure.
f" Namespaces: {len(namespaces)}",
]
summary_panel = Panel(
"\n".join(summary_lines),
title="Summary",
expand=False,
)
console.print(summary_panel)
console.print(f"\n\u2713 OK {len(tools)} tools listed\n")
except CleverAgentsError as exc:
console.print(f"[red]Error:[/red] {exc.message}")
raise typer.Abort() from exc