From 297823c29139270356682da67109688ea87fcdf5 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Sun, 29 Mar 2026 06:02:20 +0000 Subject: [PATCH] feat(cli): add actor context remove, export, and import commands Implement three new actor context subcommands as specified in the v3 CLI specification: - `agents actor context remove [--yes|-y] (--all|-a|)`: Remove a named context or all contexts with interactive confirmation. Supports --all for bulk removal and --yes to skip prompts. - `agents actor context export (--output|-o) `: Export a named context to JSON or YAML (detected from file extension). Includes sha256 integrity checksum in output metadata. - `agents actor context import [--update] (--input|-i) []`: Import a context from JSON or YAML. Name is inferred from file metadata if omitted. Refuses to overwrite existing contexts unless --update is specified. All commands: - Support 6 output formats (json, yaml, plain, table, rich, color) via the shared format_output framework - Use ContextManager for persistence (messages, metadata, state, global_context) - Follow existing error handling patterns (typer.Exit for user errors) - Are wired as `agents actor context` subcommand group via add_typer New files: - src/cleveragents/cli/commands/actor_context.py (command module) - features/actor_context_cmds.feature (Behave BDD scenarios) - features/steps/actor_context_cmds_steps.py (BDD step definitions) - robot/actor_context_export_import.robot (Robot integration test) ISSUES CLOSED: #869 --- features/actor_context_cmds.feature | 100 ++++ features/steps/actor_context_cmds_steps.py | 451 ++++++++++++++++++ robot/actor_context_export_import.robot | 121 +++++ src/cleveragents/cli/commands/actor.py | 7 + .../cli/commands/actor_context.py | 446 +++++++++++++++++ 5 files changed, 1125 insertions(+) create mode 100644 features/actor_context_cmds.feature create mode 100644 features/steps/actor_context_cmds_steps.py create mode 100644 robot/actor_context_export_import.robot create mode 100644 src/cleveragents/cli/commands/actor_context.py diff --git a/features/actor_context_cmds.feature b/features/actor_context_cmds.feature new file mode 100644 index 000000000..6412514b2 --- /dev/null +++ b/features/actor_context_cmds.feature @@ -0,0 +1,100 @@ +Feature: Actor context 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 + + Background: + Given a temporary context directory for actor context tests + + # ── context remove ───────────────────────────────────────── + + Scenario: Remove a named actor context + Given an actor context named "docs" exists + When I run actor context remove "docs" with --yes + Then the actor context remove command should succeed + And the context "docs" should no longer exist + + Scenario: Remove all actor contexts + Given an actor context named "docs" exists + And an actor context named "notes" exists + When I run actor context remove --all with --yes + Then the actor context remove command should succeed + And no actor contexts should remain + + Scenario: Remove non-existent context fails + When I run actor context remove "nonexistent" with --yes + Then the actor context remove command should fail with exit code 1 + + Scenario: Remove requires NAME or --all + When I run actor context remove without name or all + Then the actor context remove command should fail with exit code 1 + + Scenario: Remove rejects NAME with --all + When I run actor context remove "docs" with --all + Then the actor context remove command should fail with exit code 1 + + Scenario: Remove outputs JSON format + Given an actor context named "docs" exists + When I run actor context remove "docs" with --yes and format "json" + Then the actor context remove command should succeed + And the output should contain valid JSON with key "context_removed" + + # ── context export ───────────────────────────────────────── + + Scenario: Export a named actor context to JSON + Given an actor context named "docs" exists with messages + When I run actor context export "docs" to a JSON file + Then the actor context export command should succeed + And the exported file should exist and contain valid JSON + And the exported JSON should contain key "messages" + + Scenario: Export a named actor context to YAML + Given an actor context named "docs" exists with messages + When I run actor context export "docs" to a YAML file + Then the actor context export command should succeed + And the exported YAML file should exist and be valid + + Scenario: Export non-existent context fails + When I run actor context export "nonexistent" to a JSON file + Then the actor context export command should fail with exit code 1 + + Scenario: Export outputs JSON format metadata + Given an actor context named "docs" exists with messages + When I run actor context export "docs" to a JSON file with format "json" + Then the actor context export command should succeed + And the output should contain valid JSON with key "context_export" + + # ── context import ───────────────────────────────────────── + + Scenario: Import a context from a JSON file + Given a valid context JSON file named "imported-ctx.json" + When I run actor context import from that file as "imported-ctx" + Then the actor context import command should succeed + And the context "imported-ctx" should exist + + Scenario: Import infers name from file metadata + Given a valid context JSON file with context_name "auto-named" + When I run actor context import from that file without a name + Then the actor context import command should succeed + And the context "auto-named" should exist + + Scenario: Import refuses to overwrite without --update + Given an actor context named "existing" exists + And a valid context JSON file named "existing.json" + When I run actor context import from that file as "existing" without update + Then the actor context import command should fail with exit code 1 + + Scenario: Import with --update replaces existing context + Given an actor context named "existing" exists + And a valid context JSON file named "existing.json" + When I run actor context import from that file as "existing" with --update + Then the actor context import command should succeed + And the context "existing" should exist + + Scenario: Export then import round-trip preserves data + Given an actor context named "roundtrip" exists with messages + When I export the context "roundtrip" to a JSON file + And I remove the context "roundtrip" + 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 diff --git a/features/steps/actor_context_cmds_steps.py b/features/steps/actor_context_cmds_steps.py new file mode 100644 index 000000000..abf94c9ab --- /dev/null +++ b/features/steps/actor_context_cmds_steps.py @@ -0,0 +1,451 @@ +# pyright: reportRedeclaration=false +"""Step definitions for actor context remove/export/import commands.""" + +from __future__ import annotations + +import json +import shutil +import tempfile +from pathlib import Path + +from behave import given, then, when +from typer.testing import CliRunner + +from cleveragents.cli.commands.actor_context import app as actor_context_app +from cleveragents.reactive.context_manager import ContextManager + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a temporary context directory for actor context tests") +def step_temp_context_dir(context): + context.tmp_dir = Path(tempfile.mkdtemp()) + context.context_dir = context.tmp_dir / "contexts" + context.context_dir.mkdir(parents=True, exist_ok=True) + context.runner = CliRunner() + context._cleanup_handlers: list = getattr(context, "_cleanup_handlers", []) + context._cleanup_handlers.append( + lambda: shutil.rmtree(context.tmp_dir, ignore_errors=True) + ) + # Will store original messages for round-trip checks + context.original_messages = None + context.export_file = None + + +# --------------------------------------------------------------------------- +# Givens — context creation helpers +# --------------------------------------------------------------------------- + + +@given('an actor context named "{name}" exists') +def step_context_exists(context, name): + mgr = ContextManager(name, context.context_dir) + mgr.add_message("system", f"Initial message for {name}") + + +@given('an actor context named "{name}" exists with messages') +def step_context_exists_with_messages(context, name): + mgr = ContextManager(name, context.context_dir) + mgr.add_message("user", "Hello, how are you?") + mgr.add_message("assistant", "I'm doing well, thanks for asking!") + mgr.add_message("user", "Great, let's get started.") + context.original_messages = list(mgr.messages) + + +@given('a valid context JSON file named "{filename}"') +def step_create_json_file(context, filename): + data = { + "context_name": filename.replace(".json", ""), + "messages": [ + { + "role": "user", + "content": "test message", + "timestamp": "2026-01-01T00:00:00", + "metadata": {}, + }, + ], + "metadata": {"context_name": filename.replace(".json", "")}, + "state": {}, + "global_context": {}, + } + context.import_file = context.tmp_dir / filename + context.import_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +@given('a valid context JSON file with context_name "{name}"') +def step_create_json_file_with_name(context, name): + data = { + "context_name": name, + "messages": [ + { + "role": "user", + "content": "auto-named test", + "timestamp": "2026-01-01T00:00:00", + "metadata": {}, + }, + ], + "metadata": {"context_name": name}, + "state": {}, + "global_context": {}, + } + context.import_file = context.tmp_dir / f"{name}.json" + context.import_file.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +# --------------------------------------------------------------------------- +# When — remove +# --------------------------------------------------------------------------- + + +@when('I run actor context remove "{name}" with --yes') +def step_remove_named_yes(context, name): + context.result = context.runner.invoke( + actor_context_app, + ["remove", name, "--yes", "--context-dir", str(context.context_dir)], + ) + + +@when("I run actor context remove --all with --yes") +def step_remove_all_yes(context): + context.result = context.runner.invoke( + actor_context_app, + ["remove", "--all", "--yes", "--context-dir", str(context.context_dir)], + ) + + +@when('I run actor context remove "{name}" with --yes and format "{fmt}"') +def step_remove_named_format(context, name, fmt): + context.result = context.runner.invoke( + actor_context_app, + [ + "remove", + name, + "--yes", + "--context-dir", + str(context.context_dir), + "--format", + fmt, + ], + ) + + +@when("I run actor context remove without name or all") +def step_remove_no_args(context): + context.result = context.runner.invoke( + actor_context_app, + ["remove", "--context-dir", str(context.context_dir)], + ) + + +@when('I run actor context remove "{name}" with --all') +def step_remove_name_and_all(context, name): + context.result = context.runner.invoke( + actor_context_app, + ["remove", name, "--all", "--context-dir", str(context.context_dir)], + ) + + +# --------------------------------------------------------------------------- +# When — export +# --------------------------------------------------------------------------- + + +@when('I run actor context export "{name}" to a JSON file') +def step_export_json(context, name): + context.export_file = context.tmp_dir / f"{name}-export.json" + context.result = context.runner.invoke( + actor_context_app, + [ + "export", + name, + "--output", + str(context.export_file), + "--context-dir", + str(context.context_dir), + ], + ) + + +@when('I run actor context export "{name}" to a YAML file') +def step_export_yaml(context, name): + context.export_file = context.tmp_dir / f"{name}-export.yaml" + context.result = context.runner.invoke( + actor_context_app, + [ + "export", + name, + "--output", + str(context.export_file), + "--context-dir", + str(context.context_dir), + ], + ) + + +@when('I run actor context export "{name}" to a JSON file with format "{fmt}"') +def step_export_json_format(context, name, fmt): + context.export_file = context.tmp_dir / f"{name}-export.json" + context.result = context.runner.invoke( + actor_context_app, + [ + "export", + name, + "--output", + str(context.export_file), + "--context-dir", + str(context.context_dir), + "--format", + fmt, + ], + ) + + +# --------------------------------------------------------------------------- +# When — import +# --------------------------------------------------------------------------- + + +@when('I run actor context import from that file as "{name}"') +def step_import_named(context, name): + context.result = context.runner.invoke( + actor_context_app, + [ + "import", + name, + "--input", + str(context.import_file), + "--context-dir", + str(context.context_dir), + ], + ) + + +@when("I run actor context import from that file without a name") +def step_import_no_name(context): + context.result = context.runner.invoke( + actor_context_app, + [ + "import", + "--input", + str(context.import_file), + "--context-dir", + str(context.context_dir), + ], + ) + + +@when('I run actor context import from that file as "{name}" without update') +def step_import_no_update(context, name): + context.result = context.runner.invoke( + actor_context_app, + [ + "import", + name, + "--input", + str(context.import_file), + "--context-dir", + str(context.context_dir), + ], + ) + + +@when('I run actor context import from that file as "{name}" with --update') +def step_import_with_update(context, name): + context.result = context.runner.invoke( + actor_context_app, + [ + "import", + name, + "--input", + str(context.import_file), + "--update", + "--context-dir", + str(context.context_dir), + ], + ) + + +# --------------------------------------------------------------------------- +# When — round-trip helpers +# --------------------------------------------------------------------------- + + +@when('I export the context "{name}" to a JSON file') +def step_roundtrip_export(context, name): + context.export_file = context.tmp_dir / f"{name}-roundtrip.json" + context.result = context.runner.invoke( + actor_context_app, + [ + "export", + name, + "--output", + str(context.export_file), + "--context-dir", + str(context.context_dir), + ], + ) + assert context.result.exit_code == 0, context.result.output + + +@when('I remove the context "{name}"') +def step_roundtrip_remove(context, name): + context.result = context.runner.invoke( + actor_context_app, + ["remove", name, "--yes", "--context-dir", str(context.context_dir)], + ) + assert context.result.exit_code == 0, context.result.output + + +@when('I import the context from that JSON file as "{name}"') +def step_roundtrip_import(context, name): + context.result = context.runner.invoke( + actor_context_app, + [ + "import", + name, + "--input", + str(context.export_file), + "--context-dir", + str(context.context_dir), + ], + ) + assert context.result.exit_code == 0, context.result.output + + +# --------------------------------------------------------------------------- +# Then — success / failure +# --------------------------------------------------------------------------- + + +@then("the actor context remove command should succeed") +def step_remove_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 export command should succeed") +def step_export_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 import command should succeed") +def step_import_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 remove command should fail with exit code 1") +def step_remove_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 export command should fail with exit code 1") +def step_export_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 import command should fail with exit code 1") +def step_import_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 — state assertions +# --------------------------------------------------------------------------- + + +@then('the context "{name}" should no longer exist') +def step_context_gone(context, name): + ctx_path = context.context_dir / name + assert not ctx_path.exists(), f"Context dir {ctx_path} still exists" + + +@then("no actor contexts should remain") +def step_no_contexts(context): + remaining = [d for d in context.context_dir.iterdir() if d.is_dir()] + assert len(remaining) == 0, ( + f"Expected 0 contexts, found {len(remaining)}: {remaining}" + ) + + +@then('the context "{name}" should exist') +def step_context_exists_check(context, name): + mgr = ContextManager(name, context.context_dir) + assert mgr.exists(), f"Context '{name}' does not exist at {mgr.context_dir}" + + +# --------------------------------------------------------------------------- +# Then — output assertions +# --------------------------------------------------------------------------- + + +@then('the output should contain valid JSON with key "{key}"') +def step_output_json_key(context, key): + output = context.result.output + data = json.loads(output) + assert key in data, f"Key '{key}' not found in JSON output: {data.keys()}" + + +@then("the exported file should exist and contain valid JSON") +def step_exported_json_valid(context): + assert context.export_file.exists(), f"Export file {context.export_file} not found" + data = json.loads(context.export_file.read_text(encoding="utf-8")) + assert isinstance(data, dict) + + +@then('the exported JSON should contain key "{key}"') +def step_exported_json_key(context, key): + data = json.loads(context.export_file.read_text(encoding="utf-8")) + assert key in data, f"Key '{key}' not found in exported JSON: {data.keys()}" + + +@then("the exported YAML file should exist and be valid") +def step_exported_yaml_valid(context): + import yaml + + assert context.export_file.exists(), f"Export file {context.export_file} not found" + data = yaml.safe_load(context.export_file.read_text(encoding="utf-8")) + assert isinstance(data, dict) + + +@then("the imported context should have the same messages as the original") +def step_roundtrip_messages(context): + assert context.original_messages is not None, "No original messages saved" + mgr = ContextManager("roundtrip", context.context_dir) + imported = mgr.messages + + # Compare content and role, ignoring timestamp differences + assert len(imported) == len(context.original_messages), ( + f"Message count mismatch: {len(imported)} vs {len(context.original_messages)}" + ) + for orig, imp in zip(context.original_messages, imported, strict=True): + assert orig["role"] == imp["role"], ( + f"Role mismatch: {orig['role']} vs {imp['role']}" + ) + assert orig["content"] == imp["content"], ( + f"Content mismatch: {orig['content']!r} vs {imp['content']!r}" + ) diff --git a/robot/actor_context_export_import.robot b/robot/actor_context_export_import.robot new file mode 100644 index 000000000..fe2d97068 --- /dev/null +++ b/robot/actor_context_export_import.robot @@ -0,0 +1,121 @@ +*** Settings *** +Documentation Integration test for actor context export-then-import round-trip +Library Process +Library OperatingSystem +Library String +Library DateTime +Library Collections +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${TEST_CTX_DIR} ${EMPTY} +${EXPORT_FILE} ${EMPTY} +${CONTEXT_NAME} roundtrip-robot + +*** Test Cases *** +Export Then Import Round-Trip Preserves Context + [Documentation] Create a context, export it, delete it, import it, and verify data integrity. + + # 1. Create a named context by running actor with --context + ${ctx_dir} = Set Variable ${TEMP}/actor_ctx + Set Suite Variable ${TEST_CTX_DIR} ${ctx_dir} + Create Directory ${ctx_dir} + + # Create context data manually via Python helper + ${result} = Run Process ${PYTHON} -c + ... from cleveragents.reactive.context_manager import ContextManager; mgr \= ContextManager("${CONTEXT_NAME}", "${ctx_dir}"); mgr.add_message("user", "Hello from Robot"); mgr.add_message("assistant", "Hello Robot!"); print("created") + Log Create stdout: ${result.stdout} + Log Create stderr: ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} created + + # 2. Export the context to JSON + ${export_path} = Set Variable ${TEMP}/exported-context.json + Set Suite Variable ${EXPORT_FILE} ${export_path} + + ${result} = Run Process ${PYTHON} -m cleveragents actor context export + ... ${CONTEXT_NAME} --output ${export_path} --context-dir ${ctx_dir} + Log Export stdout: ${result.stdout} + Log Export stderr: ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + File Should Exist ${export_path} + + # 3. Remove the original context + ${result} = Run Process ${PYTHON} -m cleveragents actor context remove + ... ${CONTEXT_NAME} --yes --context-dir ${ctx_dir} + Log Remove stdout: ${result.stdout} + Log Remove stderr: ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Directory Should Not Exist ${ctx_dir}/${CONTEXT_NAME} + + # 4. Import the context back + ${result} = Run Process ${PYTHON} -m cleveragents actor context import + ... ${CONTEXT_NAME} --input ${export_path} --context-dir ${ctx_dir} + Log Import stdout: ${result.stdout} + Log Import stderr: ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + + # 5. Verify the round-trip preserved messages + ${result} = Run Process ${PYTHON} -c + ... import json; from cleveragents.reactive.context_manager import ContextManager; mgr \= ContextManager("${CONTEXT_NAME}", "${ctx_dir}"); msgs \= mgr.messages; assert len(msgs) \=\= 2, f"Expected 2 messages, got {len(msgs)}"; assert msgs[0]["content"] \=\= "Hello from Robot"; assert msgs[1]["content"] \=\= "Hello Robot!"; print("verified") + Log Verify stdout: ${result.stdout} + Log Verify stderr: ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} verified + +Export With JSON Format Flag Shows Structured Output + [Documentation] Verify --format json produces machine-readable output. + + ${ctx_dir} = Set Variable ${TEMP}/actor_ctx_fmt + Create Directory ${ctx_dir} + + # Create context + ${result} = Run Process ${PYTHON} -c + ... from cleveragents.reactive.context_manager import ContextManager; mgr \= ContextManager("fmt-test", "${ctx_dir}"); mgr.add_message("user", "test"); print("ok") + Should Be Equal As Integers ${result.rc} 0 + + # Export with --format json + ${export_path} = Set Variable ${TEMP}/fmt-export.json + ${result} = Run Process ${PYTHON} -m cleveragents actor context export + ... fmt-test --output ${export_path} --context-dir ${ctx_dir} --format json + Log Export stdout: ${result.stdout} + Log Export stderr: ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} context_export + +Import Without Update Fails For Existing Context + [Documentation] Verify importing into an existing context without --update fails. + + ${ctx_dir} = Set Variable ${TEMP}/actor_ctx_noupdate + Create Directory ${ctx_dir} + + # Create existing context + ${result} = Run Process ${PYTHON} -c + ... from cleveragents.reactive.context_manager import ContextManager; mgr \= ContextManager("existing", "${ctx_dir}"); mgr.add_message("user", "original"); print("ok") + Should Be Equal As Integers ${result.rc} 0 + + # Create import file + ${import_path} = Set Variable ${TEMP}/existing-import.json + ${result} = Run Process ${PYTHON} -c + ... import json; data \= {"context_name": "existing", "messages": [{"role": "user", "content": "new", "timestamp": "2026-01-01", "metadata": {}}], "metadata": {}, "state": {}, "global_context": {}}; open("${import_path}", "w").write(json.dumps(data)); print("ok") + Should Be Equal As Integers ${result.rc} 0 + + # Import without --update should fail + ${result} = Run Process ${PYTHON} -m cleveragents actor context import + ... existing --input ${import_path} --context-dir ${ctx_dir} + Should Be Equal As Integers ${result.rc} 1 + +*** Keywords *** +Setup Test Environment + [Documentation] Create test environment + common.Setup Test Environment + ${temp} = Evaluate tempfile.mkdtemp() modules=tempfile + Set Suite Variable ${TEMP} ${temp} + Create Directory ${TEMP} + Log Test environment created at: ${TEMP} + +Cleanup Test Environment + [Documentation] Clean up test environment + Run Keyword If '${TEMP}' != '${EMPTY}' Remove Directory ${TEMP} recursive=True diff --git a/src/cleveragents/cli/commands/actor.py b/src/cleveragents/cli/commands/actor.py index 1268ed747..db90aab92 100644 --- a/src/cleveragents/cli/commands/actor.py +++ b/src/cleveragents/cli/commands/actor.py @@ -18,6 +18,7 @@ from cleveragents.application.container import get_container from cleveragents.cli.commands._resolve_actor import ( resolve_config_files as _resolve_config_files, ) +from cleveragents.cli.commands.actor_context import app as actor_context_app from cleveragents.cli.formatting import OutputFormat, format_output from cleveragents.core.exceptions import ( BusinessRuleViolation, @@ -706,3 +707,9 @@ def set_default( except (ValidationError, NotFoundError, BusinessRuleViolation) as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Abort() from exc + + +# --------------------------------------------------------------------------- +# Sub-command groups +# --------------------------------------------------------------------------- +app.add_typer(actor_context_app, name="context") diff --git a/src/cleveragents/cli/commands/actor_context.py b/src/cleveragents/cli/commands/actor_context.py new file mode 100644 index 000000000..948a2ff21 --- /dev/null +++ b/src/cleveragents/cli/commands/actor_context.py @@ -0,0 +1,446 @@ +"""Actor-scoped context management commands. + +Implements ``agents actor context remove``, ``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 :class:`~cleveragents.reactive.context_manager.ContextManager` +persistence layer. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Annotated, Any + +import typer +import yaml +from rich.console import Console +from rich.panel import Panel + +from cleveragents.cli.formatting import OutputFormat, format_output +from cleveragents.reactive.context_manager import ContextManager + +app = typer.Typer( + help="Manage manual contexts for actor runs.", +) +console = Console() + +_FORMAT_HELP = "Output format: json, yaml, plain, table, rich, or color (default: rich)" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _default_context_base(context_dir: Path | None) -> Path: + """Return the base directory where named contexts are stored.""" + if context_dir is not None: + return context_dir + return Path.home() / ".cleveragents" / "context" + + +def _list_context_names(base: Path) -> list[str]: + """Return sorted list of context names present under *base*.""" + if not base.exists(): + return [] + return sorted(d.name for d in base.iterdir() if d.is_dir()) + + +def _context_size_kb(ctx_mgr: ContextManager) -> float: + """Estimate total file size of the context directory in KB.""" + total = 0 + if ctx_mgr.context_dir.exists(): + for f in ctx_mgr.context_dir.rglob("*"): + if f.is_file(): + total += f.stat().st_size + return round(total / 1024, 1) + + +def _file_checksum(path: Path) -> str: + """Return ``sha256:`` checksum for a file.""" + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(8192), b""): + h.update(chunk) + digest = h.hexdigest() + return f"sha256:{digest[:4]}...{digest[-4:]}" + + +def _render_output( + data: dict[str, Any], + fmt: str, + rich_panels: list[tuple[str, str]] | None = None, + ok_message: str = "", +) -> None: + """Emit command output in the requested format. + + For ``rich`` format the *rich_panels* list of ``(title, body)`` pairs + are rendered via :class:`rich.panel.Panel`. For all other formats the + flat *data* dict is passed through :func:`format_output`. + """ + if fmt == OutputFormat.RICH.value and rich_panels: + for title, body in rich_panels: + console.print(Panel(body, title=title, expand=False)) + if ok_message: + console.print(f"[green]✓ OK[/green] {ok_message}") + return + + # Machine-readable / non-rich + console.print(format_output(data, fmt)) + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +@app.command("remove") +def context_remove( + name: Annotated[ + str | None, + typer.Argument(help="Context name to remove"), + ] = None, + all_contexts: Annotated[ + bool, + typer.Option("--all", "-a", help="Remove all contexts"), + ] = False, + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip confirmation prompt"), + ] = False, + 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: + """Remove a named actor context or all actor contexts. + + Context data is **not recoverable** after removal. Use ``--yes`` to + skip the interactive confirmation prompt. + + Examples:: + + agents actor context remove docs + agents actor context remove --all --yes + """ + import shutil + + if name and all_contexts: + typer.echo("Error: Cannot specify NAME when using --all", err=True) + raise typer.Exit(code=1) + + if not name and not all_contexts: + typer.echo("Error: Must specify NAME or use --all", err=True) + raise typer.Exit(code=1) + + base = _default_context_base(context_dir) + + if all_contexts: + names = _list_context_names(base) + if not names: + typer.echo("No contexts found to remove.") + return + + if not yes: + typer.echo(f"Found {len(names)} context(s) to remove:") + for cname in names: + typer.echo(f" - {cname}") + if not typer.confirm("Remove all?"): + typer.echo("Remove cancelled.") + return + + for cname in names: + shutil.rmtree(base / cname) + + data: dict[str, Any] = { + "context_removed": { + "context": "all", + "status": "removed", + "count": len(names), + }, + "stats": { + "remaining_size_kb": 0, + }, + } + panels = [ + ( + "Context Removed", + ( + f"[bold]Context:[/bold] all ({len(names)} removed)\n" + f"[bold]Status:[/bold] removed" + ), + ), + ( + "Stats", + "[bold]Remaining Size:[/bold] 0 KB", + ), + ] + _render_output(data, fmt, rich_panels=panels, ok_message="Context updated") + return + + # Single context removal + assert name is not None + target = base / name + if not target.exists(): + typer.echo(f"Error: Context '{name}' does not exist.", err=True) + raise typer.Exit(code=1) + + ctx_mgr = ContextManager(name, context_dir) + + if not yes and not typer.confirm(f"Remove context '{name}'?"): + typer.echo("Remove cancelled.") + return + + ctx_mgr.delete() + + # Compute remaining size across all contexts + remaining_total = 0.0 + for other_name in _list_context_names(base): + other_mgr = ContextManager(other_name, context_dir) + remaining_total += _context_size_kb(other_mgr) + + data = { + "context_removed": { + "context": name, + "status": "removed", + }, + "stats": { + "remaining_size_kb": remaining_total, + }, + } + panels = [ + ( + "Context Removed", + (f"[bold]Context:[/bold] {name}\n[bold]Status:[/bold] removed"), + ), + ( + "Stats", + (f"[bold]Remaining Size:[/bold] {remaining_total} KB"), + ), + ] + _render_output(data, fmt, rich_panels=panels, ok_message="Context updated") + + +@app.command("export") +def context_export( + name: Annotated[ + str, + typer.Argument(help="Context name to export"), + ], + output: Annotated[ + Path, + typer.Option( + "--output", + "-o", + help="Output file path (JSON or YAML)", + resolve_path=True, + ), + ] = ..., # type: ignore[assignment] + 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: + """Export a named actor context to a JSON or YAML file. + + The exported file contains messages, metadata, state, and + global_context and can later be re-imported with + ``agents actor context import``. + + Examples:: + + agents actor context export docs --output /tmp/docs-context.json + agents actor context export docs -o ctx.yaml --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) + + # Build export payload + export_data: dict[str, Any] = { + "context_name": ctx_mgr.context_name, + "messages": ctx_mgr.messages, + "metadata": ctx_mgr.metadata, + "state": ctx_mgr.state, + "global_context": ctx_mgr.global_context, + } + + # Determine serialisation format from file extension + output.parent.mkdir(parents=True, exist_ok=True) + suffix = output.suffix.lower() + if suffix in (".yaml", ".yml"): + with open(output, "w", encoding="utf-8") as fh: + yaml.dump(export_data, fh, default_flow_style=False, sort_keys=False) + else: + with open(output, "w", encoding="utf-8") as fh: + json.dump(export_data, fh, indent=2) + + items = len(ctx_mgr.messages) + size_kb = round(output.stat().st_size / 1024, 1) + checksum = _file_checksum(output) + + data: dict[str, Any] = { + "context_export": { + "context": name, + "output": str(output), + "items": items, + "size_kb": size_kb, + }, + "integrity": { + "checksum": checksum, + "compressed": False, + }, + } + panels = [ + ( + "Context Export", + ( + f"[bold]Context:[/bold] {name}\n" + f"[bold]Output:[/bold] {output}\n" + f"[bold]Items:[/bold] {items}\n" + f"[bold]Size:[/bold] {size_kb} KB" + ), + ), + ( + "Integrity", + (f"[bold]Checksum:[/bold] {checksum}\n[bold]Compressed:[/bold] no"), + ), + ] + _render_output(data, fmt, rich_panels=panels, ok_message="Export completed") + + +@app.command("import") +def context_import( + name: Annotated[ + str | None, + typer.Argument( + help="Name for the imported context (inferred from file if omitted)" + ), + ] = None, + input_file: Annotated[ + Path, + typer.Option( + "--input", + "-i", + help="Input file path (JSON or YAML)", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + resolve_path=True, + ), + ] = ..., # type: ignore[assignment] + update: Annotated[ + bool, + typer.Option("--update", help="Replace existing context with same name"), + ] = False, + 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: + """Import a context from a JSON or YAML file. + + If ``NAME`` is omitted the context name is inferred from the + ``context_name`` key inside the file. Use ``--update`` to replace + an existing context with the same name. + + Examples:: + + agents actor context import docs --input /tmp/docs-context.json + agents actor context import --input ctx.yaml --update + """ + # Parse input file (JSON or YAML) + text = input_file.read_text(encoding="utf-8") + suffix = input_file.suffix.lower() + if suffix in (".yaml", ".yml"): + file_data: dict[str, Any] = yaml.safe_load(text) or {} + else: + try: + file_data = json.loads(text) + except json.JSONDecodeError: + # Fallback to YAML + file_data = yaml.safe_load(text) or {} + + if not isinstance(file_data, dict): + typer.echo("Error: Import file must contain a JSON/YAML object.", err=True) + raise typer.Exit(code=1) + + # Determine context name + resolved_name = name or file_data.get("context_name") + if not resolved_name: + # Infer from filename (stem) + resolved_name = input_file.stem + + ctx_mgr = ContextManager(resolved_name, context_dir) + + if ctx_mgr.exists() and not update: + typer.echo( + f"Error: Context '{resolved_name}' already exists. " + "Use --update to replace.", + err=True, + ) + raise typer.Exit(code=1) + + strategy = "replace" if ctx_mgr.exists() else "create" + + # Import via ContextManager + ctx_mgr.import_context(input_file) + + items = len(ctx_mgr.messages) + + data: dict[str, Any] = { + "context_import": { + "context": resolved_name, + "input": str(input_file), + "items": items, + }, + "merge": { + "strategy": strategy, + "conflicts": 0, + }, + } + panels = [ + ( + "Context Import", + ( + f"[bold]Context:[/bold] {resolved_name}\n" + f"[bold]Input:[/bold] {input_file}\n" + f"[bold]Items:[/bold] {items}" + ), + ), + ( + "Merge", + (f"[bold]Strategy:[/bold] {strategy}\n[bold]Conflicts:[/bold] 0"), + ), + ] + _render_output(data, fmt, rich_panels=panels, ok_message="Import completed") -- 2.52.0