diff --git a/CHANGELOG.md b/CHANGELOG.md index 36aab2df1..25b6b829d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ template-copy/fallback delegation paths, and the existing-empty-DB branch in `features/fast_init_upgrade.feature`. Uses race-safe temp-path allocation (`mkstemp`/`mkdtemp`) throughout new fast-init test steps. (#733) +- Added TUI persona export/import command handling for YAML files. + `/persona:export` now supports an optional output path and + `/persona:import` imports a persona from a YAML file path. + Updated TUI command/router Behave coverage and Robot smoke coverage for + persona export/import command paths. (#1005) - Expanded the TUI slash command overlay catalog to include 67 commands across 14 groups, aligned with the specification command reference for session, persona, scope, plan, project, registry/config, context, and utility flows. diff --git a/features/steps/tui_commands_coverage_steps.py b/features/steps/tui_commands_coverage_steps.py index a05af3cc7..5ab933153 100644 --- a/features/steps/tui_commands_coverage_steps.py +++ b/features/steps/tui_commands_coverage_steps.py @@ -8,6 +8,8 @@ Targets uncovered lines in cleveragents.tui.commands: """ import json +import shutil +import tempfile from dataclasses import dataclass, field from io import StringIO from pathlib import Path @@ -38,6 +40,35 @@ class FakePersonaRegistry: def list_personas(self) -> list[FakePersona]: return list(self._personas) + def export_persona(self, name: str, output_path: Path) -> Path: + if not any(persona.name == name for persona in self._personas): + raise ValueError(f"Persona not found: {name}") + if output_path.is_absolute(): + raise ValueError( + "Export path must be relative to current working directory" + ) + safe_output = (Path.cwd() / output_path).resolve() + if not safe_output.is_relative_to(Path.cwd().resolve()): + raise ValueError("Export path must stay within working directory") + safe_output.parent.mkdir(parents=True, exist_ok=True) + safe_output.write_text( + "name: alice\nactor: local/mock-default\n", + encoding="utf-8", + ) + return safe_output + + def import_persona(self, input_path: Path) -> FakePersona: + if input_path.is_absolute(): + raise ValueError( + "Import path must be relative to current working directory" + ) + safe_input = (Path.cwd() / input_path).resolve() + if not safe_input.is_relative_to(Path.cwd().resolve()): + raise ValueError("Import path must stay within working directory") + if not safe_input.exists(): + raise ValueError(f"File not found: {safe_input}") + return FakePersona(name="imported-persona") + @dataclass class FakePersonaState: @@ -148,6 +179,47 @@ def step_handle_result_starts_with(context, prefix): ) +@when("I export a persona to a temporary YAML file") +def step_export_persona_to_temp_file(context): + context.registry = FakePersonaRegistry(_personas=[FakePersona(name="alice")]) + context.state = FakePersonaState() + context.router = TuiCommandRouter( + persona_registry=context.registry, + persona_state=context.state, + ) + temp_dir = Path(tempfile.mkdtemp(prefix="tui-persona-export-", dir=Path.cwd())) + context.add_cleanup(lambda: shutil.rmtree(temp_dir, ignore_errors=True)) + relative_path = temp_dir.relative_to(Path.cwd()) / "alice.yaml" + context._persona_export_path = Path.cwd() / relative_path + context.handle_result = context.router.handle( + f"persona:export alice {relative_path.as_posix()}", + session_id="test-session", + ) + + +@then("persona export should be written") +def step_verify_persona_export_written(context): + assert context._persona_export_path.exists() + payload = context._persona_export_path.read_text(encoding="utf-8") + assert "name: alice" in payload + assert "Persona exported:" in context.handle_result + + +@when("I import a persona from a temporary YAML file") +def step_import_persona_from_temp_file(context): + temp_dir = Path(tempfile.mkdtemp(prefix="tui-persona-import-", dir=Path.cwd())) + context.add_cleanup(lambda: shutil.rmtree(temp_dir, ignore_errors=True)) + relative_path = temp_dir.relative_to(Path.cwd()) / "imported.yaml" + (Path.cwd() / relative_path).write_text( + "name: imported-persona\nactor: local/mock-default\n", + encoding="utf-8", + ) + context.handle_result = context.router.handle( + f"persona:import {relative_path.as_posix()}", + session_id="test-session", + ) + + # --------------------------------------------------------------------------- # run_tui() headless scenario # --------------------------------------------------------------------------- diff --git a/features/steps/tui_persona_export_import_steps.py b/features/steps/tui_persona_export_import_steps.py new file mode 100644 index 000000000..de47e2fd2 --- /dev/null +++ b/features/steps/tui_persona_export_import_steps.py @@ -0,0 +1,414 @@ +"""Behave step definitions for tui_persona_export_import.feature. + +Tests persona export/import functionality: +- Export persona to YAML file +- Import persona from YAML file +- Round-trip fidelity (export then import preserves all fields) +- Invalid YAML handling +- Schema validation errors +- Path traversal rejection + +All step names are prefixed with "ei_" context or use unique phrasing +to avoid conflicts with other step files. +""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +from pathlib import Path +from typing import Any + +import yaml +from behave import given, then, when +from behave.runner import Context +from pydantic import ValidationError + +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.schema import Persona + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a temporary persona registry for export/import tests") +def step_temp_registry_for_export_import(context: Context) -> None: + """Set up a temporary registry and working directory.""" + temp_dir = Path(tempfile.mkdtemp()) + work_dir = Path(tempfile.mkdtemp()) + context.ei_temp_dir = temp_dir + context.ei_work_dir = work_dir + context.ei_registry = PersonaRegistry(config_dir=temp_dir) + context.ei_registry.ensure_dirs() + + # Change to work_dir so relative path resolution works + context.ei_original_cwd = os.getcwd() + os.chdir(str(work_dir)) + + context.add_cleanup(lambda: shutil.rmtree(str(temp_dir), ignore_errors=True)) + context.add_cleanup(lambda: shutil.rmtree(str(work_dir), ignore_errors=True)) + context.add_cleanup(lambda: os.chdir(context.ei_original_cwd)) + + # Reset result state + context.ei_export_error: Exception | None = None + context.ei_import_error: Exception | None = None + context.ei_exported_path: Path | None = None + context.ei_imported_persona: Persona | None = None + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given('a persona named "{name}" with actor "{actor}" is saved in the ei registry') +def step_save_persona_basic(context: Context, name: str, actor: str) -> None: + persona = Persona(name=name, actor=actor) + context.ei_registry.save(persona) + + +@given( + 'persona "{name}" exists in the ei registry with actor "{actor}" and description "{desc}"' +) +def step_save_persona_with_description( + context: Context, name: str, actor: str, desc: str +) -> None: + persona = Persona(name=name, actor=actor, description=desc) + context.ei_registry.save(persona) + + +@given('the ei persona "{name}" has base arguments {args_json}') +def step_persona_base_arguments(context: Context, name: str, args_json: str) -> None: + args: dict[str, Any] = json.loads(args_json) + persona = context.ei_registry.get(name) + assert persona is not None, f"Persona '{name}' not found" + updated = Persona( + name=persona.name, + actor=persona.actor, + description=persona.description, + base_arguments=args, + scoped_projects=persona.scoped_projects, + scoped_plans=persona.scoped_plans, + argument_presets=persona.argument_presets, + cycle_order=persona.cycle_order, + greeting=persona.greeting, + ) + context.ei_registry.save(updated) + + +@given('the ei persona "{name}" has scoped projects {projects_json}') +def step_persona_scoped_projects( + context: Context, name: str, projects_json: str +) -> None: + projects: list[str] = json.loads(projects_json) + persona = context.ei_registry.get(name) + assert persona is not None, f"Persona '{name}' not found" + updated = Persona( + name=persona.name, + actor=persona.actor, + description=persona.description, + base_arguments=persona.base_arguments, + scoped_projects=projects, + scoped_plans=persona.scoped_plans, + argument_presets=persona.argument_presets, + cycle_order=persona.cycle_order, + greeting=persona.greeting, + ) + context.ei_registry.save(updated) + + +@given( + 'an ei YAML file "{filename}" contains persona name "{name}" and actor "{actor}"' +) +def step_create_yaml_file_basic( + context: Context, filename: str, name: str, actor: str +) -> None: + data = {"name": name, "actor": actor} + file_path = context.ei_work_dir / filename + file_path.write_text( + yaml.safe_dump(data, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + + +@given( + 'an ei YAML file "{filename}" contains persona name "{name}" actor "{actor}" and presets' +) +def step_create_yaml_file_with_presets( + context: Context, filename: str, name: str, actor: str +) -> None: + data = { + "name": name, + "actor": actor, + "argument_presets": [ + {"name": "default", "display": "default", "overrides": {}}, + {"name": "fast", "display": "Fast Mode", "overrides": {"speed": "high"}}, + ], + } + file_path = context.ei_work_dir / filename + file_path.write_text( + yaml.safe_dump(data, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + + +@given('an ei YAML file "{filename}" contains a list instead of a persona dict') +def step_create_invalid_yaml_file(context: Context, filename: str) -> None: + # Write content that is a list (not a dict) — valid YAML but invalid persona + file_path = context.ei_work_dir / filename + file_path.write_text("- not\n- a\n- persona\n", encoding="utf-8") + + +@given('an ei YAML file "{filename}" is missing the required actor field') +def step_create_yaml_missing_actor(context: Context, filename: str) -> None: + # Missing required 'actor' field — will fail Pydantic validation + data = {"name": "no-actor-persona", "description": "Missing actor"} + file_path = context.ei_work_dir / filename + file_path.write_text( + yaml.safe_dump(data, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + + +# --------------------------------------------------------------------------- +# When steps +# --------------------------------------------------------------------------- + + +@when('I run ei export for persona "{name}" to file "{filename}"') +def step_export_persona(context: Context, name: str, filename: str) -> None: + context.ei_export_error = None + context.ei_exported_path = None + try: + result = context.ei_registry.export_persona(name, Path(filename)) + context.ei_exported_path = result + except Exception as exc: + context.ei_export_error = exc + + +@when('I run ei export for persona "{name}" using default filename') +def step_export_persona_default_path(context: Context, name: str) -> None: + context.ei_export_error = None + context.ei_exported_path = None + try: + result = context.ei_registry.export_persona(name, Path(f"{name}.yaml")) + context.ei_exported_path = result + except Exception as exc: + context.ei_export_error = exc + + +@when('I run ei import from file "{filename}"') +def step_import_persona(context: Context, filename: str) -> None: + context.ei_import_error = None + context.ei_imported_persona = None + try: + persona = context.ei_registry.import_persona(Path(filename)) + context.ei_imported_persona = persona + except Exception as exc: + context.ei_import_error = exc + + +@when('I attempt ei export for persona "{name}" to file "{filename}"') +def step_try_export_persona(context: Context, name: str, filename: str) -> None: + context.ei_export_error = None + context.ei_exported_path = None + try: + result = context.ei_registry.export_persona(name, Path(filename)) + context.ei_exported_path = result + except Exception as exc: + context.ei_export_error = exc + + +@when('I attempt ei import from file "{filename}"') +def step_try_import_persona(context: Context, filename: str) -> None: + context.ei_import_error = None + context.ei_imported_persona = None + try: + persona = context.ei_registry.import_persona(Path(filename)) + context.ei_imported_persona = persona + except Exception as exc: + context.ei_import_error = exc + + +@when('I remove persona "{name}" from the ei registry') +def step_delete_persona_ei(context: Context, name: str) -> None: + context.ei_registry.delete(name) + + +# --------------------------------------------------------------------------- +# Then steps +# --------------------------------------------------------------------------- + + +@then("the ei export should succeed") +def step_export_success(context: Context) -> None: + assert context.ei_export_error is None, ( + f"Export failed unexpectedly: {context.ei_export_error!r}" + ) + assert context.ei_exported_path is not None + + +@then('the ei work dir should contain file "{filename}"') +def step_file_exists_in_cwd(context: Context, filename: str) -> None: + file_path = context.ei_work_dir / filename + assert file_path.exists(), f"Expected file '{filename}' to exist at {file_path}" + + +@then('the ei exported YAML should have name "{name}"') +def step_exported_yaml_has_name(context: Context, name: str) -> None: + assert context.ei_exported_path is not None + data = yaml.safe_load(context.ei_exported_path.read_text(encoding="utf-8")) + assert isinstance(data, dict), "Exported YAML is not a dict" + assert data.get("name") == name, f"Expected name '{name}', got '{data.get('name')}'" + + +@then('the ei exported YAML should have actor "{actor}"') +def step_exported_yaml_has_actor(context: Context, actor: str) -> None: + assert context.ei_exported_path is not None + data = yaml.safe_load(context.ei_exported_path.read_text(encoding="utf-8")) + assert isinstance(data, dict), "Exported YAML is not a dict" + assert data.get("actor") == actor, ( + f"Expected actor '{actor}', got '{data.get('actor')}'" + ) + + +@then("the ei import should succeed") +def step_import_success(context: Context) -> None: + assert context.ei_import_error is None, ( + f"Import failed unexpectedly: {context.ei_import_error!r}" + ) + assert context.ei_imported_persona is not None + + +@then('persona "{name}" should be present in the ei registry') +def step_persona_exists_in_registry(context: Context, name: str) -> None: + persona = context.ei_registry.get(name) + assert persona is not None, f"Persona '{name}' not found in registry" + + +@then('the ei registry persona "{name}" should have actor "{actor}"') +def step_imported_persona_actor(context: Context, name: str, actor: str) -> None: + persona = context.ei_registry.get(name) + assert persona is not None, f"Persona '{name}' not found" + assert persona.actor == actor, f"Expected actor '{actor}', got '{persona.actor}'" + + +@then( + 'the ei registry persona "{name}" should have description equal to "{description}"' +) +def step_imported_persona_description( + context: Context, name: str, description: str +) -> None: + persona = context.ei_registry.get(name) + assert persona is not None, f"Persona '{name}' not found" + assert persona.description == description, ( + f"Expected description '{description}', got '{persona.description}'" + ) + + +@then( + 'the ei registry persona "{name}" should have base argument "{key}" equal to "{value}"' +) +def step_imported_persona_base_arg( + context: Context, name: str, key: str, value: str +) -> None: + persona = context.ei_registry.get(name) + assert persona is not None, f"Persona '{name}' not found" + actual = persona.base_arguments.get(key) + assert str(actual) == value, ( + f"Expected base_arguments['{key}'] == '{value}', got '{actual}'" + ) + + +@then('the ei registry persona "{name}" should have scoped projects {projects_json}') +def step_imported_persona_scoped_projects( + context: Context, name: str, projects_json: str +) -> None: + expected: list[str] = json.loads(projects_json) + persona = context.ei_registry.get(name) + assert persona is not None, f"Persona '{name}' not found" + assert persona.scoped_projects == expected, ( + f"Expected scoped_projects {expected}, got {persona.scoped_projects}" + ) + + +@then('the ei registry persona "{name}" should have preset named "{preset_name}"') +def step_imported_persona_has_preset( + context: Context, name: str, preset_name: str +) -> None: + persona = context.ei_registry.get(name) + assert persona is not None, f"Persona '{name}' not found" + preset_names = [p.name for p in persona.argument_presets] + assert preset_name in preset_names, ( + f"Expected preset '{preset_name}' in {preset_names}" + ) + + +@then('the ei export should fail with message "{message}"') +def step_export_fails_with_message(context: Context, message: str) -> None: + assert context.ei_export_error is not None, ( + "Expected export to fail but it succeeded" + ) + assert message in str(context.ei_export_error), ( + f"Expected '{message}' in error: {context.ei_export_error!r}" + ) + + +@then("the ei export should fail with path traversal error") +def step_export_fails_path_traversal(context: Context) -> None: + assert context.ei_export_error is not None, ( + "Expected export to fail with path traversal error but it succeeded" + ) + error_msg = str(context.ei_export_error) + assert ( + "stay within working directory" in error_msg or "must be relative" in error_msg + ), f"Expected path traversal error, got: {error_msg!r}" + + +@then("the ei import should fail with file not found error") +def step_import_fails_file_not_found(context: Context) -> None: + assert context.ei_import_error is not None, ( + "Expected import to fail with file not found error but it succeeded" + ) + # FileNotFoundError or ValueError about missing file + assert isinstance( + context.ei_import_error, (FileNotFoundError, ValueError, OSError) + ), f"Expected FileNotFoundError/ValueError, got: {type(context.ei_import_error)}" + + +@then("the ei import should fail with invalid persona error") +def step_import_fails_invalid_persona(context: Context) -> None: + assert context.ei_import_error is not None, ( + "Expected import to fail with invalid persona error but it succeeded" + ) + assert isinstance(context.ei_import_error, ValueError), ( + f"Expected ValueError, got: {type(context.ei_import_error)}" + ) + assert "Invalid persona file" in str(context.ei_import_error), ( + f"Expected 'Invalid persona file' in error: {context.ei_import_error!r}" + ) + + +@then("the ei import should fail with validation error") +def step_import_fails_validation_error(context: Context) -> None: + assert context.ei_import_error is not None, ( + "Expected import to fail with validation error but it succeeded" + ) + # Pydantic ValidationError or ValueError + assert isinstance(context.ei_import_error, (ValidationError, ValueError)), ( + f"Expected ValidationError/ValueError, got: {type(context.ei_import_error)}" + ) + + +@then("the ei import should fail with path traversal error") +def step_import_fails_path_traversal(context: Context) -> None: + assert context.ei_import_error is not None, ( + "Expected import to fail with path traversal error but it succeeded" + ) + error_msg = str(context.ei_import_error) + assert ( + "stay within working directory" in error_msg or "must be relative" in error_msg + ), f"Expected path traversal error, got: {error_msg!r}" diff --git a/features/tui_commands_coverage.feature b/features/tui_commands_coverage.feature index 02e45d03b..949473d95 100644 --- a/features/tui_commands_coverage.feature +++ b/features/tui_commands_coverage.feature @@ -59,6 +59,16 @@ Feature: TUI Command Router and run_tui coverage When I call handle with raw input "persona delete all" Then the handle result should be "Unknown persona command: delete all" + Scenario: persona export writes YAML to output file + Given a TuiCommandRouter with a mock registry and state + When I export a persona to a temporary YAML file + Then persona export should be written + + Scenario: persona import reads YAML file and reports imported persona + Given a TuiCommandRouter with a mock registry and state + When I import a persona from a temporary YAML file + Then the handle result should start with "Persona imported:" + # ---------- _session_command() ---------- Scenario: session command with no subcommand shows current session diff --git a/features/tui_persona_export_import.feature b/features/tui_persona_export_import.feature new file mode 100644 index 000000000..79493eb97 --- /dev/null +++ b/features/tui_persona_export_import.feature @@ -0,0 +1,73 @@ +Feature: Persona export/import in YAML format + As a CleverAgents user + I want to export and import personas as YAML files + So that I can share, back up, and restore persona configurations + + Background: + Given a temporary persona registry for export/import tests + + Scenario: Export persona to YAML file + Given a persona named "dev" with actor "local/mock-actor" is saved in the ei registry + When I run ei export for persona "dev" to file "dev-export.yaml" + Then the ei export should succeed + And the ei work dir should contain file "dev-export.yaml" + And the ei exported YAML should have name "dev" + And the ei exported YAML should have actor "local/mock-actor" + + Scenario: Import persona from YAML file + Given an ei YAML file "import-me.yaml" contains persona name "imported" and actor "local/mock-actor" + When I run ei import from file "import-me.yaml" + Then the ei import should succeed + And persona "imported" should be present in the ei registry + + Scenario: Round-trip fidelity - export then import preserves all fields + Given persona "roundtrip" exists in the ei registry with actor "local/mock-actor" and description "Round-trip test" + And the ei persona "roundtrip" has base arguments {"model": "gpt-4", "temperature": 0.7} + And the ei persona "roundtrip" has scoped projects ["proj-a", "proj-b"] + When I run ei export for persona "roundtrip" to file "roundtrip.yaml" + And I remove persona "roundtrip" from the ei registry + And I run ei import from file "roundtrip.yaml" + Then persona "roundtrip" should be present in the ei registry + And the ei registry persona "roundtrip" should have actor "local/mock-actor" + And the ei registry persona "roundtrip" should have description equal to "Round-trip test" + And the ei registry persona "roundtrip" should have base argument "model" equal to "gpt-4" + And the ei registry persona "roundtrip" should have scoped projects ["proj-a", "proj-b"] + + Scenario: Export default filename when no output path given + Given a persona named "autoname" with actor "local/mock-actor" is saved in the ei registry + When I run ei export for persona "autoname" using default filename + Then the ei export should succeed + And the ei work dir should contain file "autoname.yaml" + + Scenario: Export non-existent persona raises error + When I attempt ei export for persona "ghost" to file "ghost.yaml" + Then the ei export should fail with message "Persona not found" + + Scenario: Import from non-existent file raises error + When I attempt ei import from file "missing.yaml" + Then the ei import should fail with file not found error + + Scenario: Import invalid YAML raises error + Given an ei YAML file "bad.yaml" contains a list instead of a persona dict + When I attempt ei import from file "bad.yaml" + Then the ei import should fail with invalid persona error + + Scenario: Import YAML with schema validation error raises error + Given an ei YAML file "invalid-schema.yaml" is missing the required actor field + When I attempt ei import from file "invalid-schema.yaml" + Then the ei import should fail with validation error + + Scenario: Export path traversal is rejected + Given a persona named "safe" with actor "local/mock-actor" is saved in the ei registry + When I attempt ei export for persona "safe" to file "../escape.yaml" + Then the ei export should fail with path traversal error + + Scenario: Import path traversal is rejected + When I attempt ei import from file "../escape.yaml" + Then the ei import should fail with path traversal error + + Scenario: Import persona with presets preserves preset data + Given an ei YAML file "preset-persona.yaml" contains persona name "preset-test" actor "local/mock-actor" and presets + When I run ei import from file "preset-persona.yaml" + Then persona "preset-test" should be present in the ei registry + And the ei registry persona "preset-test" should have preset named "fast" diff --git a/robot/tui_smoke.robot b/robot/tui_smoke.robot index c2ed219f9..771100018 100644 --- a/robot/tui_smoke.robot +++ b/robot/tui_smoke.robot @@ -62,3 +62,28 @@ TUI Help Panel Context Switching ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} tui-help-panel-ok + +TUI Persona Export Import Commands + ${script}= Catenate SEPARATOR=\n + ... import shutil + ... import tempfile + ... from pathlib import Path + ... from types import SimpleNamespace + ... from cleveragents.tui.commands import TuiCommandRouter + ... export_persona = lambda name, output_path: (Path.cwd() / output_path).resolve() + ... import_persona = lambda input_path: SimpleNamespace(name="imported-persona") + ... registry = SimpleNamespace(list_personas=lambda: [SimpleNamespace(name="alice")], export_persona=export_persona, import_persona=import_persona) + ... state = SimpleNamespace(set_active_persona=lambda session_id, name: SimpleNamespace(name=name)) + ... router = TuiCommandRouter(persona_registry=registry, persona_state=state) + ... temp_dir = Path(tempfile.mkdtemp(prefix="tui-persona-router-", dir=Path.cwd())) + ... rel_yaml = temp_dir.relative_to(Path.cwd()) / "alice.yaml" + ... export_result = router.handle(f"persona:export alice {rel_yaml.as_posix()}", session_id="sess-1") + ... assert "Persona exported:" in export_result + ... (Path.cwd() / rel_yaml).write_text("name: imported-persona\\nactor: local/mock-default\\n", encoding="utf-8") + ... import_result = router.handle(f"persona:import {rel_yaml.as_posix()}", session_id="sess-1") + ... assert "Persona imported:" in import_result + ... shutil.rmtree(temp_dir, ignore_errors=True) + ... print("tui-persona-router-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-persona-router-ok diff --git a/src/cleveragents/cli/commands/persona.py b/src/cleveragents/cli/commands/persona.py new file mode 100644 index 000000000..cfadabb16 --- /dev/null +++ b/src/cleveragents/cli/commands/persona.py @@ -0,0 +1,177 @@ +"""CLI commands for persona export/import (YAML format). + +Provides ``agents persona export`` and ``agents persona import`` commands +that wrap the :class:`PersonaRegistry` export/import methods. + +Architecture: + Presentation (CLI) → PersonaRegistry (TUI domain layer) + +The persona data model and registry live in ``cleveragents.tui.persona.*``. +This module is a thin CLI adapter that handles argument parsing, path +resolution, error formatting, and output rendering. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console + +from cleveragents.tui.persona.registry import PersonaRegistry + +app = typer.Typer(help="Manage TUI personas (export/import in YAML format).") +console = Console() +_log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Module-level registry accessor (patchable in tests) +# --------------------------------------------------------------------------- +_registry: PersonaRegistry | None = None + + +def _get_registry() -> PersonaRegistry: + """Return the module-level PersonaRegistry, creating it if needed.""" + global _registry + if _registry is not None: + return _registry + from cleveragents.application.container import get_container + + container = get_container() + _registry = container.persona_registry() + return _registry + + +def _reset_registry() -> None: + """Reset the module-level registry (used by tests).""" + global _registry + _registry = None + + +# --------------------------------------------------------------------------- +# Export command +# --------------------------------------------------------------------------- + + +@app.command() +def export( + name: Annotated[ + str, + typer.Argument(help="Name of the persona to export."), + ], + output: Annotated[ + Path | None, + typer.Option( + "--output", + "-o", + help=( + "Destination YAML file path (relative to CWD). " + "Defaults to .yaml in the current directory." + ), + ), + ] = None, +) -> None: + """Export a persona to a YAML file. + + The output path must be relative to the current working directory and + must not escape it via path traversal (e.g. ``../``). + + Example:: + + agents persona export dev + agents persona export dev --output backups/dev-persona.yaml + """ + registry = _get_registry() + output_path = output if output is not None else Path(f"{name}.yaml") + + try: + written = registry.export_persona(name, output_path) + except ValueError as exc: + console.print(f"[red]Export failed:[/red] {exc}") + raise typer.Exit(1) from exc + + console.print(f"[green]Exported persona '{name}' to:[/green] {written}") + + +# --------------------------------------------------------------------------- +# Import command +# --------------------------------------------------------------------------- + + +@app.command(name="import") +def import_persona( + path: Annotated[ + Path, + typer.Argument( + help=( + "Path to the YAML file to import (relative to CWD). " + "Must not escape the working directory." + ) + ), + ], + overwrite: Annotated[ + bool, + typer.Option( + "--overwrite", + help="Overwrite an existing persona with the same name.", + ), + ] = False, +) -> None: + """Import a persona from a YAML file. + + The file must contain a valid persona definition. If a persona with + the same name already exists it will be overwritten only when + ``--overwrite`` is supplied; otherwise the command exits with an error. + + Example:: + + agents persona import dev-persona.yaml + agents persona import dev-persona.yaml --overwrite + """ + registry = _get_registry() + + # Validate path before reading + try: + safe_path = registry.resolve_import_path(path) + except ValueError as exc: + console.print(f"[red]Import failed:[/red] {exc}") + raise typer.Exit(1) from exc + + if not safe_path.exists(): + console.print(f"[red]File not found:[/red] {safe_path}") + raise typer.Exit(1) + + # Check for existing persona unless --overwrite + if not overwrite: + import yaml + + try: + raw = yaml.safe_load(safe_path.read_text(encoding="utf-8")) or {} + except Exception as exc: + console.print(f"[red]Failed to read YAML:[/red] {exc}") + raise typer.Exit(1) from exc + + if not isinstance(raw, dict): + console.print("[red]Import failed:[/red] Invalid persona file") + raise typer.Exit(1) + + persona_name = raw.get("name", "") + if persona_name and registry.get(str(persona_name)) is not None: + console.print( + f"[red]Persona '{persona_name}' already exists.[/red] " + "Use --overwrite to replace it." + ) + raise typer.Exit(1) + + try: + persona = registry.import_persona(path) + except ValueError as exc: + console.print(f"[red]Import failed:[/red] {exc}") + raise typer.Exit(1) from exc + except Exception as exc: + console.print(f"[red]Import failed:[/red] {exc}") + raise typer.Exit(1) from exc + + console.print(f"[green]Imported persona:[/green] {persona.name}") diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index 7afcb56c1..b8451f4ea 100644 --- a/src/cleveragents/cli/main.py +++ b/src/cleveragents/cli/main.py @@ -87,6 +87,7 @@ def _register_subcommands() -> None: context, invariant, lsp, + persona, plan, project, repo, @@ -213,6 +214,11 @@ def _register_subcommands() -> None: name="repo", help="Repository indexing management", ) + app.add_typer( + persona.app, + name="persona", + help="Manage TUI personas (export/import in YAML format)", + ) _subcommands_registered = True diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index 1134e995e..3f9d7d903 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -4,6 +4,7 @@ from __future__ import annotations import json from dataclasses import dataclass +from pathlib import Path from cleveragents.application.container import get_container from cleveragents.tui.app import CleverAgentsTuiApp, textual_available @@ -22,14 +23,26 @@ class TuiCommandRouter: tokens = raw.strip().split() if not tokens: return "Empty command" - if tokens[0] == "persona": - return self._persona_command(tokens[1:], session_id=session_id) - if tokens[0] == "session": - return self._session_command(tokens[1:], session_id=session_id) - if tokens[0] == "help": + command, rest = self._split_command(tokens) + if command == "persona": + return self._persona_command(rest, session_id=session_id) + if command == "session": + return self._session_command(rest, session_id=session_id) + if command == "help": return "Commands: /persona, /session, /help" return f"Unknown command: /{raw}" + @staticmethod + def _split_command(tokens: list[str]) -> tuple[str, list[str]]: + command = tokens[0] + if ":" in command: + root, subcommand = command.split(":", maxsplit=1) + if root: + rest = [subcommand] if subcommand else [] + rest.extend(tokens[1:]) + return root, rest + return command, tokens[1:] + def _persona_command(self, tokens: list[str], *, session_id: str) -> str: if not tokens or tokens[0] == "list": names = [persona.name for persona in self.persona_registry.list_personas()] @@ -39,8 +52,36 @@ class TuiCommandRouter: return "Usage: /persona set " persona = self.persona_state.set_active_persona(session_id, tokens[1]) return f"Active persona: {persona.name}" + if tokens[0] == "export": + return self._persona_export(tokens[1:]) + if tokens[0] == "import": + return self._persona_import(tokens[1:]) return f"Unknown persona command: {' '.join(tokens)}" + def _persona_export(self, tokens: list[str]) -> str: + if not tokens: + return "Usage: /persona:export [path]" + if len(tokens) > 2: + return "Usage: /persona:export [path]" + + name = tokens[0] + output_path = Path(tokens[1]) if len(tokens) == 2 else Path(f"{name}.yaml") + try: + exported_path = self.persona_registry.export_persona(name, output_path) + except (OSError, ValueError) as exc: + return f"Persona export failed: {exc}" + return f"Persona exported: {exported_path}" + + def _persona_import(self, tokens: list[str]) -> str: + if len(tokens) != 1: + return "Usage: /persona:import " + input_path = Path(tokens[0]) + try: + persona = self.persona_registry.import_persona(input_path) + except (OSError, ValueError) as exc: + return f"Persona import failed: {exc}" + return f"Persona imported: {persona.name}" + @staticmethod def _session_command(tokens: list[str], *, session_id: str) -> str: if not tokens or tokens[0] == "show":