diff --git a/features/actor_context_cmds.feature b/features/actor_context_cmds.feature index 2e716f9bd..e6e891aca 100644 --- a/features/actor_context_cmds.feature +++ b/features/actor_context_cmds.feature @@ -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 diff --git a/features/steps/actor_context_cmds_steps.py b/features/steps/actor_context_cmds_steps.py index 16925aa17..0150e4dca 100644 --- a/features/steps/actor_context_cmds_steps.py +++ b/features/steps/actor_context_cmds_steps.py @@ -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', '')}" + ) diff --git a/src/cleveragents/cli/commands/actor_context.py b/src/cleveragents/cli/commands/actor_context.py index c90d33e49..758939318 100644 --- a/src/cleveragents/cli/commands/actor_context.py +++ b/src/cleveragents/cli/commands/actor_context.py @@ -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[