fix(cli): implement missing actor context list, show, and clear commands #10913
@@ -1,7 +1,7 @@
|
||||
Feature: Actor context clear, remove, export, and import commands
|
||||
Feature: Actor context list, clear, show, remove, export, and import commands
|
||||
As a CleverAgents user
|
||||
I want to manage actor contexts via the CLI
|
||||
So that I can remove, export, and import conversation contexts
|
||||
So that I can inspect, filter, remove, export, and import conversation contexts
|
||||
|
||||
Background:
|
||||
Given a temporary context directory for actor context tests
|
||||
@@ -135,3 +135,65 @@ Feature: Actor context clear, remove, export, and import commands
|
||||
And I import the context from that JSON file as "roundtrip"
|
||||
Then the context "roundtrip" should exist
|
||||
And the imported context should have the same messages as the original
|
||||
|
||||
# ── context list ────────────────────────────────────────────
|
||||
|
||||
Scenario: List all actor contexts
|
||||
Given an actor context named "docs" exists with messages
|
||||
And an actor context named "notes" exists with messages
|
||||
When I run actor context list
|
||||
Then the actor context list command should succeed
|
||||
|
||||
Scenario: List actor contexts with matching regex filter
|
||||
Given an actor context named "docs" exists with messages
|
||||
And an actor context named "docs_backup" exists with messages
|
||||
And an actor context named "notes" exists with messages
|
||||
When I run actor context list "docs"
|
||||
Then the actor context list command should succeed
|
||||
|
||||
Scenario: List empty when regex filter matches nothing
|
||||
Given an actor context named "docs" exists with messages
|
||||
And an actor context named "notes" exists with messages
|
||||
When I run actor context list "^zzz_"
|
||||
Then the actor context list command should succeed
|
||||
And the output should contain valid JSON with key "context_list"
|
||||
|
||||
Scenario: List actor contexts with invalid regex fails
|
||||
Given an actor context named "docs" exists with messages
|
||||
When I run actor context list "[invalid("
|
||||
Then the actor context list command should fail with exit code 1
|
||||
|
||||
Scenario: List empty when no contexts exist
|
||||
When I run actor context list
|
||||
Then the actor context list command should succeed
|
||||
|
||||
Scenario: List outputs JSON format
|
||||
Given an actor context named "docs" exists with messages
|
||||
And an actor context named "notes" exists with messages
|
||||
When I run actor context list --format json
|
||||
Then the actor context list command should succeed
|
||||
And the output should contain valid JSON with key "context_list"
|
||||
And the output JSON key "context_list" should contain keys "count, contexts"
|
||||
|
||||
Scenario: List outputs YAML format
|
||||
Given an actor context named "docs" exists with messages
|
||||
When I run actor context list --format yaml
|
||||
Then the actor context list command should succeed
|
||||
|
||||
# ── context show ────────────────────────────────────────────
|
||||
|
||||
Scenario: Show a named actor context
|
||||
Given an actor context named "docs" exists with messages
|
||||
When I run actor context show "docs"
|
||||
Then the actor context show command should succeed
|
||||
|
||||
Scenario: Show a named actor context outputs JSON format
|
||||
Given an actor context named "docs" exists with messages
|
||||
When I run actor context show "docs" --format json
|
||||
Then the actor context show command should succeed
|
||||
And the output should contain valid JSON with key "context_show"
|
||||
And the output JSON key "context_show" should contain keys "name, message_count, total_size_kb, estimated_tokens, created_at"
|
||||
|
||||
Scenario: Show a non-existent context fails
|
||||
When I run actor context show "nonexistent"
|
||||
Then the actor context show command should fail with exit code 1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# pyright: reportRedeclaration=false
|
||||
"""Step definitions for actor context remove/export/import commands."""
|
||||
"""Step definitions for actor context list, clear, show, remove, export, and import commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -576,3 +576,115 @@ def step_roundtrip_messages(context):
|
||||
assert orig["content"] == imp["content"], (
|
||||
f"Content mismatch: {orig['content']!r} vs {imp['content']!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When — list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("I run actor context list")
|
||||
def step_list_all(context):
|
||||
context.result = context.runner.invoke(
|
||||
actor_context_app,
|
||||
["list", "--context-dir", str(context.context_dir)],
|
||||
)
|
||||
|
||||
|
||||
@when('I run actor context list "{regex}"')
|
||||
def step_list_regex(context, regex):
|
||||
context.result = context.runner.invoke(
|
||||
actor_context_app,
|
||||
[
|
||||
"list",
|
||||
regex,
|
||||
"--context-dir",
|
||||
str(context.context_dir),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@when("I run actor context list --format {fmt}")
|
||||
def step_list_format(context, fmt):
|
||||
context.result = context.runner.invoke(
|
||||
actor_context_app,
|
||||
[
|
||||
"list",
|
||||
"--context-dir",
|
||||
str(context.context_dir),
|
||||
"--format",
|
||||
fmt,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When — show
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when('I run actor context show "{name}"')
|
||||
def step_show(context, name):
|
||||
args = [
|
||||
"show",
|
||||
name,
|
||||
"--context-dir",
|
||||
str(context.context_dir),
|
||||
]
|
||||
context.result = context.runner.invoke(actor_context_app, args)
|
||||
|
||||
|
||||
@when('I run actor context show "{name}" --format {fmt}')
|
||||
def step_show_format(context, name, fmt):
|
||||
context.result = context.runner.invoke(
|
||||
actor_context_app,
|
||||
[
|
||||
"show",
|
||||
name,
|
||||
"--context-dir",
|
||||
str(context.context_dir),
|
||||
"--format",
|
||||
fmt,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then — list / show success and failure assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the actor context list command should succeed")
|
||||
def step_list_success(context):
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Expected exit 0, got {context.result.exit_code}.\n"
|
||||
f"stdout: {context.result.output}\n"
|
||||
f"stderr: {getattr(context.result, 'stderr', '')}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor context list command should fail with exit code 1")
|
||||
def step_list_fail(context):
|
||||
assert context.result.exit_code == 1, (
|
||||
f"Expected exit 1, got {context.result.exit_code}.\n"
|
||||
f"stdout: {context.result.output}\n"
|
||||
f"stderr: {getattr(context.result, 'stderr', '')}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor context show command should succeed")
|
||||
def step_show_success(context):
|
||||
assert context.result.exit_code == 0, (
|
||||
f"Expected exit 0, got {context.result.exit_code}.\n"
|
||||
f"stdout: {context.result.output}\n"
|
||||
f"stderr: {getattr(context.result, 'stderr', '')}"
|
||||
)
|
||||
|
||||
|
||||
@then("the actor context show command should fail with exit code 1")
|
||||
def step_show_fail(context):
|
||||
assert context.result.exit_code == 1, (
|
||||
f"Expected exit 1, got {context.result.exit_code}.\n"
|
||||
f"stdout: {context.result.output}\n"
|
||||
f"stderr: {getattr(context.result, 'stderr', '')}"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Actor-scoped context management commands.
|
||||
|
||||
Implements ``agents actor context clear``, ``agents actor context remove``,
|
||||
Implements ``agents actor context clear``, ``agents actor context list``,
|
||||
``agents actor context remove``, ``agents actor context show``,
|
||||
``agents actor context export``, and ``agents actor context import`` per the
|
||||
|
|
||||
v3 specification. These commands manage named conversation contexts stored
|
||||
under ``~/.cleveragents/context/`` using the
|
||||
@@ -12,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
@@ -380,6 +382,210 @@ def context_clear(
|
||||
_render_output(data, fmt, rich_panels=panels, ok_message="Context cleared")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New: list / show commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def context_list(
|
||||
regex: Annotated[
|
||||
str | None,
|
||||
typer.Argument(
|
||||
help="Optional regex pattern to filter context names",
|
||||
),
|
||||
] = None,
|
||||
context_dir: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--context-dir",
|
||||
help="Directory where contexts are stored",
|
||||
resolve_path=True,
|
||||
),
|
||||
] = None,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""List actor contexts with an optional regex filter.
|
||||
|
||||
When a REGEX pattern is supplied only context names matching the pattern
|
||||
are listed. Output can be rendered as a rich table, JSON, YAML, plain
|
||||
text, color, or table format.
|
||||
|
||||
Examples::
|
||||
|
||||
agents actor context list
|
||||
agents actor context list "docs"
|
||||
agents actor context list "^proj_" --format json
|
||||
"""
|
||||
base = _default_context_base(context_dir)
|
||||
all_names = _list_context_names(base)
|
||||
|
||||
if regex is not None:
|
||||
try:
|
||||
pattern = re.compile(regex)
|
||||
except re.error as exc:
|
||||
typer.echo(f"Error: Invalid regex: {exc}", err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
names = [n for n in all_names if pattern.search(n)]
|
||||
else:
|
||||
names = list(all_names)
|
||||
|
||||
if not names and regex is not None:
|
||||
data: dict[str, Any] = {
|
||||
"context_list": {
|
||||
"count": 0,
|
||||
"contexts": [],
|
||||
"filter": regex,
|
||||
}
|
||||
}
|
||||
console.print(format_output(data, fmt))
|
||||
return
|
||||
|
||||
if not names:
|
||||
typer.echo("No contexts found.")
|
||||
return
|
||||
|
||||
# Build per-context summary data
|
||||
context_entries: list[dict[str, Any]] = []
|
||||
for cname in names:
|
||||
ctx_mgr = ContextManager(cname, context_dir)
|
||||
size_kb = _context_size_kb(ctx_mgr)
|
||||
msg_count = len(ctx_mgr.messages)
|
||||
created_at = ctx_mgr.metadata.get("created_at", "")
|
||||
context_entries.append(
|
||||
{
|
||||
"name": cname,
|
||||
"messages": msg_count,
|
||||
"size_kb": round(size_kb, 1),
|
||||
"created_at": created_at,
|
||||
}
|
||||
)
|
||||
|
||||
table_data: dict[str, Any] = {
|
||||
"context_list": {
|
||||
"count": len(names),
|
||||
"contexts": context_entries,
|
||||
**({"filter": regex} if regex is not None else {}),
|
||||
}
|
||||
}
|
||||
|
||||
if fmt == OutputFormat.RICH.value or fmt == OutputFormat.TABLE.value:
|
||||
from rich.table import Table as RichTable
|
||||
|
||||
table = RichTable(title=f"Actor Contexts ({len(names)} total)")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Messages", style="green")
|
||||
table.add_column("Size (KB)", style="magenta")
|
||||
table.add_column("Created", style="yellow")
|
||||
|
||||
for entry in context_entries:
|
||||
table.add_row(
|
||||
entry["name"],
|
||||
str(entry["messages"]),
|
||||
f"{entry['size_kb']}",
|
||||
entry["created_at"] or "",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
elif fmt == OutputFormat.PLAIN.value or fmt == OutputFormat.COLOR.value:
|
||||
for cname in names:
|
||||
typer.echo(cname)
|
||||
else:
|
||||
# json / yaml
|
||||
console.print(format_output(table_data, fmt))
|
||||
|
||||
|
||||
@app.command("show")
|
||||
def context_show(
|
||||
name: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Context name to show"),
|
||||
],
|
||||
context_dir: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--context-dir",
|
||||
help="Directory where contexts are stored",
|
||||
resolve_path=True,
|
||||
),
|
||||
] = None,
|
||||
fmt: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help=_FORMAT_HELP),
|
||||
] = "rich",
|
||||
) -> None:
|
||||
"""Show a summary of a named actor context.
|
||||
|
||||
Displays message count, total storage size, estimated token usage, and
|
||||
creation timestamp for the given context. Output supports rich, JSON,
|
||||
YAML, plain, table, and color formats.
|
||||
|
||||
Examples::
|
||||
|
||||
agents actor context show docs
|
||||
agents actor context show docs --format json
|
||||
"""
|
||||
base = _default_context_base(context_dir)
|
||||
if not (base / name).exists():
|
||||
typer.echo(f"Error: Context '{name}' does not exist.", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
ctx_mgr = ContextManager(name, context_dir)
|
||||
size_kb = _context_size_kb(ctx_mgr)
|
||||
msg_count = len(ctx_mgr.messages)
|
||||
created_at = ctx_mgr.metadata.get("created_at", "")
|
||||
|
||||
# Estimate tokens: rough rule of thumb is ~4 chars per token
|
||||
total_chars = sum(len(str(msg.get("content", ""))) for msg in ctx_mgr.messages)
|
||||
estimated_tokens = round(total_chars / 4)
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"context_show": {
|
||||
"name": name,
|
||||
"message_count": msg_count,
|
||||
"total_size_kb": round(size_kb, 1),
|
||||
"estimated_tokens": estimated_tokens,
|
||||
"created_at": created_at,
|
||||
}
|
||||
}
|
||||
|
||||
if fmt == OutputFormat.RICH.value:
|
||||
panels = [
|
||||
(
|
||||
f"Context: {name}",
|
||||
(
|
||||
f"[bold]Messages:[/bold] {msg_count}\n"
|
||||
f"[bold]Size:[/bold] {round(size_kb, 1)} KB\n"
|
||||
f"[bold]Est. Tokens:[/bold] {estimated_tokens}\n"
|
||||
f"[bold]Created:[/bold] {created_at}"
|
||||
),
|
||||
)
|
||||
]
|
||||
_render_output(data, fmt, rich_panels=panels, ok_message="Context summary")
|
||||
elif fmt == OutputFormat.TABLE.value:
|
||||
from rich.table import Table as RichTable
|
||||
|
||||
table = RichTable(title=f"Context Summary: {name}")
|
||||
table.add_column("Metric", style="cyan")
|
||||
table.add_column("Value", style="green")
|
||||
table.add_row("Messages", str(msg_count))
|
||||
table.add_row("Size (KB)", f"{round(size_kb, 1)}")
|
||||
table.add_row("Est. Tokens", str(estimated_tokens))
|
||||
table.add_row("Created", created_at)
|
||||
console.print(table)
|
||||
elif fmt == OutputFormat.PLAIN.value or fmt == OutputFormat.COLOR.value:
|
||||
typer.echo(f"Context: {name}")
|
||||
typer.echo(f"Messages: {msg_count}")
|
||||
typer.echo(f"Size: {round(size_kb, 1)} KB")
|
||||
typer.echo(f"Est. Tokens: {estimated_tokens}")
|
||||
typer.echo(f"Created: {created_at}")
|
||||
else:
|
||||
console.print(format_output(data, fmt))
|
||||
|
||||
|
||||
@app.command("export")
|
||||
def context_export(
|
||||
name: Annotated[
|
||||
|
||||
Reference in New Issue
Block a user
Suggestion: The module-level docstring on line 1 references only
remove,export, andimportcommands. It should be updated to mention the three newlist,show, andclearcommands for completeness.