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..1f3d59017 --- /dev/null +++ b/features/steps/tui_persona_export_import_steps.py @@ -0,0 +1,364 @@ +"""Step definitions for tui_persona_export_import.feature. + +Covers persona export/import via: +- TuiCommandRouter (space-separated and colon-style aliases) +- PersonaRegistry.export_persona / import_persona +- CLI commands (agents persona export / agents persona import) +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch + +import yaml +from behave import given, then, when +from behave.runner import Context + +from cleveragents.tui.commands import TuiCommandRouter +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.schema import Persona +from cleveragents.tui.persona.state import PersonaState + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a temporary persona registry for export/import tests") +def step_fresh_registry(context: Context) -> None: + temp_dir = Path(tempfile.mkdtemp()) + context.exp_temp_dir = temp_dir + context.exp_registry = PersonaRegistry(config_dir=temp_dir) + context.exp_registry.ensure_dirs() + context.add_cleanup(lambda: shutil.rmtree(str(temp_dir), ignore_errors=True)) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_router(context: Context) -> TuiCommandRouter: + """Build a TuiCommandRouter backed by the test registry.""" + state = PersonaState(registry=context.exp_registry) + return TuiCommandRouter( + persona_registry=context.exp_registry, + persona_state=state, + ) + + +def _enter_temp_workdir(context: Context) -> Path: + """Create a temp working directory and chdir into it.""" + work_dir = Path(tempfile.mkdtemp()) + context.add_cleanup(lambda: shutil.rmtree(str(work_dir), ignore_errors=True)) + original = os.getcwd() + os.chdir(str(work_dir)) + context.add_cleanup(lambda: os.chdir(original)) + context.exp_work_dir = work_dir + return work_dir + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given('I save a persona "{name}" with actor "{actor}" in the export registry') +def step_save_persona(context: Context, name: str, actor: str) -> None: + persona = Persona(name=name, actor=actor) + context.exp_registry.save(persona) + + +@given( + 'I save a described persona "{name}" actor "{actor}" description "{desc}"' + " in the export registry" +) +def step_save_persona_with_desc( + context: Context, name: str, actor: str, desc: str +) -> None: + persona = Persona(name=name, actor=actor, description=desc) + context.exp_registry.save(persona) + + +@given('I have a valid persona YAML file "{filename}" in a temp working directory') +def step_create_import_yaml(context: Context, filename: str) -> None: + work_dir = _enter_temp_workdir(context) + persona_data = { + "name": "imported-cli-test", + "actor": "local/mock-actor", + "description": "CLI import test persona", + } + (work_dir / filename).write_text( + yaml.safe_dump(persona_data, default_flow_style=False), encoding="utf-8" + ) + context.exp_import_filename = filename + + +@given('I create a file "{filename}" in a temp working directory') +def step_create_existing_file(context: Context, filename: str) -> None: + if not hasattr(context, "exp_work_dir"): + _enter_temp_workdir(context) + (context.exp_work_dir / filename).write_text("placeholder", encoding="utf-8") + + +# --------------------------------------------------------------------------- +# When steps — TUI router +# --------------------------------------------------------------------------- + + +@when('I call the TUI router with "{command}" in a temp working directory') +def step_call_router_in_tempdir(context: Context, command: str) -> None: + _enter_temp_workdir(context) + router = _make_router(context) + context.exp_router_result = router.handle(command, session_id="test-session") + + +@when('I call the TUI router with "{command}" in that working directory') +def step_call_router_in_existing_workdir(context: Context, command: str) -> None: + # Working directory already set by a previous Given step + router = _make_router(context) + context.exp_router_result = router.handle(command, session_id="test-session") + + +# --------------------------------------------------------------------------- +# When steps — registry round-trip +# --------------------------------------------------------------------------- + + +@when( + 'I export persona "{name}" to "{filename}" via the registry' + " in a temp working directory" +) +def step_export_via_registry(context: Context, name: str, filename: str) -> None: + _enter_temp_workdir(context) + context.exp_export_error = None + try: + context.exp_registry.export_persona(name, Path(filename)) + context.exp_export_filename = filename + except Exception as exc: + context.exp_export_error = exc + + +@when('I delete persona "{name}" from the export registry') +def step_delete_from_registry(context: Context, name: str) -> None: + context.exp_registry.delete(name) + + +@when('I import persona from "{filename}" via the registry in that working directory') +def step_import_via_registry(context: Context, filename: str) -> None: + context.exp_import_error = None + try: + context.exp_imported = context.exp_registry.import_persona(Path(filename)) + except Exception as exc: + context.exp_import_error = exc + context.exp_imported = None + + +# --------------------------------------------------------------------------- +# When steps — CLI export +# --------------------------------------------------------------------------- + + +@when( + 'I invoke the CLI persona export command for "{name}"' + ' with output "{filename}" in a temp working directory' +) +def step_cli_export(context: Context, name: str, filename: str) -> None: + from typer.testing import CliRunner + + from cleveragents.cli.commands.persona import _reset_registry, app + + _enter_temp_workdir(context) + _reset_registry() + + runner = CliRunner() + with patch( + "cleveragents.cli.commands.persona._get_registry", + return_value=context.exp_registry, + ): + result = runner.invoke( + app, + ["export", name, "--output", filename], + ) + context.exp_cli_result = result + context.exp_cli_filename = filename + + +@when( + 'I invoke the CLI persona export command for "{name}"' + ' with output "{filename}" without force' +) +def step_cli_export_no_force(context: Context, name: str, filename: str) -> None: + from typer.testing import CliRunner + + from cleveragents.cli.commands.persona import _reset_registry, app + + _reset_registry() + + runner = CliRunner() + with patch( + "cleveragents.cli.commands.persona._get_registry", + return_value=context.exp_registry, + ): + result = runner.invoke( + app, + ["export", name, "--output", filename], + ) + context.exp_cli_result = result + + +# --------------------------------------------------------------------------- +# When steps — CLI import +# --------------------------------------------------------------------------- + + +@when( + 'I invoke the CLI persona import command with input "{filename}"' + " in a temp working directory" +) +def step_cli_import(context: Context, filename: str) -> None: + from typer.testing import CliRunner + + from cleveragents.cli.commands.persona import _reset_registry, app + + # Working directory may already be set by a previous Given step + if not hasattr(context, "exp_work_dir"): + _enter_temp_workdir(context) + _reset_registry() + + runner = CliRunner() + with patch( + "cleveragents.cli.commands.persona._get_registry", + return_value=context.exp_registry, + ): + result = runner.invoke( + app, + ["import", "--input", str(context.exp_work_dir / filename)], + ) + context.exp_cli_result = result + + +# --------------------------------------------------------------------------- +# Then steps — TUI router assertions +# --------------------------------------------------------------------------- + + +@then('the TUI router result should start with "{prefix}"') +def step_router_result_starts_with(context: Context, prefix: str) -> None: + result = context.exp_router_result + assert result.startswith(prefix), ( + f"Expected result to start with {prefix!r}, got {result!r}" + ) + + +@then('the TUI router result should be "{expected}"') +def step_router_result_exact(context: Context, expected: str) -> None: + result = context.exp_router_result + assert result == expected, f"Expected {expected!r}, got {result!r}" + + +@then('the default export file "{filename}" should exist in the working directory') +def step_default_export_file_exists(context: Context, filename: str) -> None: + path = context.exp_work_dir / filename + assert path.exists(), f"Expected {path} to exist" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(data, dict) + assert "name" in data + + +@then('the export file "{filename}" should exist in the working directory') +def step_export_file_exists(context: Context, filename: str) -> None: + path = context.exp_work_dir / filename + assert path.exists(), f"Expected {path} to exist" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(data, dict) + assert "name" in data + + +@then("the imported persona should be saved in the export registry") +def step_imported_saved_in_registry(context: Context) -> None: + persona = context.exp_registry.get("imported-cli-test") + assert persona is not None, "Expected persona 'imported-cli-test' to be in registry" + + +# --------------------------------------------------------------------------- +# Then steps — round-trip assertions +# --------------------------------------------------------------------------- + + +@then('the re-imported persona name should be "{name}"') +def step_reimported_name(context: Context, name: str) -> None: + assert context.exp_import_error is None, ( + f"Import failed: {context.exp_import_error!r}" + ) + assert context.exp_imported is not None + assert context.exp_imported.name == name + + +@then('the re-imported persona actor should be "{actor}"') +def step_reimported_actor(context: Context, actor: str) -> None: + assert context.exp_imported is not None + assert context.exp_imported.actor == actor + + +@then('the re-imported persona description should be "{desc}"') +def step_reimported_description(context: Context, desc: str) -> None: + assert context.exp_imported is not None + assert context.exp_imported.description == desc + + +# --------------------------------------------------------------------------- +# Then steps — CLI assertions +# --------------------------------------------------------------------------- + + +@then("the CLI export should succeed") +def step_cli_export_success(context: Context) -> None: + result = context.exp_cli_result + assert result.exit_code == 0, ( + f"Expected exit code 0, got {result.exit_code}. Output:\n{result.output}" + ) + + +@then('the file "{filename}" should exist and contain valid persona YAML') +def step_cli_file_exists_valid(context: Context, filename: str) -> None: + path = context.exp_work_dir / filename + assert path.exists(), f"Expected {path} to exist" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + assert isinstance(data, dict), f"Expected dict, got {type(data)}" + assert "name" in data + assert "actor" in data + + +@then("the CLI export should fail with exit code 1") +def step_cli_export_fail(context: Context) -> None: + result = context.exp_cli_result + assert result.exit_code == 1, ( + f"Expected exit code 1, got {result.exit_code}. Output:\n{result.output}" + ) + + +@then("the CLI import should succeed") +def step_cli_import_success(context: Context) -> None: + result = context.exp_cli_result + assert result.exit_code == 0, ( + f"Expected exit code 0, got {result.exit_code}. Output:\n{result.output}" + ) + + +@then('the persona "{name}" should exist in the export registry') +def step_persona_in_registry(context: Context, name: str) -> None: + persona = context.exp_registry.get(name) + assert persona is not None, f"Expected persona '{name}' to be in registry" + + +@then("the CLI import should fail with exit code 1") +def step_cli_import_fail(context: Context) -> None: + result = context.exp_cli_result + assert result.exit_code == 1, ( + f"Expected exit code 1, got {result.exit_code}. Output:\n{result.output}" + ) diff --git a/features/tui_persona_export_import.feature b/features/tui_persona_export_import.feature new file mode 100644 index 000000000..5e1d00b09 --- /dev/null +++ b/features/tui_persona_export_import.feature @@ -0,0 +1,96 @@ +Feature: Persona export/import in YAML format + Persona configurations can be exported to YAML files and imported back, + preserving all fields including actor, base_arguments, scoped_projects, + scoped_plans, and argument_presets. + + Background: + Given a temporary persona registry for export/import tests + + # ---------- TUI command router: export ---------- + + Scenario: TUI router exports persona with default filename + Given I save a persona "dev" with actor "local/mock-actor" in the export registry + When I call the TUI router with "persona export dev" in a temp working directory + Then the TUI router result should start with "Exported persona 'dev' to" + And the default export file "dev.yaml" should exist in the working directory + + Scenario: TUI router exports persona with explicit filename + Given I save a persona "dev" with actor "local/mock-actor" in the export registry + When I call the TUI router with "persona export dev custom.yaml" in a temp working directory + Then the TUI router result should start with "Exported persona 'dev' to" + And the export file "custom.yaml" should exist in the working directory + + Scenario: TUI router export returns error for missing persona + When I call the TUI router with "persona export nonexistent" in a temp working directory + Then the TUI router result should start with "Export error:" + + Scenario: TUI router export returns usage when no name given + When I call the TUI router with "persona export" in a temp working directory + Then the TUI router result should be "Usage: /persona export [path]" + + # ---------- TUI command router: import ---------- + + Scenario: TUI router imports persona from YAML file + Given I have a valid persona YAML file "to_import.yaml" in a temp working directory + When I call the TUI router with "persona import to_import.yaml" in that working directory + Then the TUI router result should start with "Imported persona '" + And the imported persona should be saved in the export registry + + Scenario: TUI router import returns error for missing file + When I call the TUI router with "persona import missing.yaml" in a temp working directory + Then the TUI router result should start with "Import error:" + + Scenario: TUI router import returns usage when no path given + When I call the TUI router with "persona import" in a temp working directory + Then the TUI router result should be "Usage: /persona import " + + # ---------- Colon-style alias routing ---------- + + Scenario: Colon-style persona:export alias is routed correctly + Given I save a persona "dev" with actor "local/mock-actor" in the export registry + When I call the TUI router with "persona:export dev" in a temp working directory + Then the TUI router result should start with "Exported persona 'dev' to" + + Scenario: Colon-style persona:import alias is routed correctly + Given I have a valid persona YAML file "to_import.yaml" in a temp working directory + When I call the TUI router with "persona:import to_import.yaml" in that working directory + Then the TUI router result should start with "Imported persona '" + + # ---------- Round-trip: export then import ---------- + + Scenario: Round-trip export then import preserves persona data + Given I save a described persona "roundtrip" actor "local/mock-actor" description "Round-trip test" in the export registry + When I export persona "roundtrip" to "roundtrip.yaml" via the registry in a temp working directory + And I delete persona "roundtrip" from the export registry + And I import persona from "roundtrip.yaml" via the registry in that working directory + Then the re-imported persona name should be "roundtrip" + And the re-imported persona actor should be "local/mock-actor" + And the re-imported persona description should be "Round-trip test" + + # ---------- CLI commands ---------- + + Scenario: CLI persona export command writes YAML file + Given I save a persona "clipersona" with actor "local/mock-actor" in the export registry + When I invoke the CLI persona export command for "clipersona" with output "cli_out.yaml" in a temp working directory + Then the CLI export should succeed + And the file "cli_out.yaml" should exist and contain valid persona YAML + + Scenario: CLI persona export command fails for missing persona + When I invoke the CLI persona export command for "ghost" with output "ghost.yaml" in a temp working directory + Then the CLI export should fail with exit code 1 + + Scenario: CLI persona export command fails when file exists without force + Given I save a persona "existing" with actor "local/mock-actor" in the export registry + And I create a file "existing.yaml" in a temp working directory + When I invoke the CLI persona export command for "existing" with output "existing.yaml" without force + Then the CLI export should fail with exit code 1 + + Scenario: CLI persona import command reads YAML and saves persona + Given I have a valid persona YAML file "import_cli.yaml" in a temp working directory + When I invoke the CLI persona import command with input "import_cli.yaml" in a temp working directory + Then the CLI import should succeed + And the persona "imported-cli-test" should exist in the export registry + + Scenario: CLI persona import command fails for missing file + When I invoke the CLI persona import command with input "no_such_file.yaml" in a temp working directory + Then the CLI import should fail with exit code 1 diff --git a/src/cleveragents/cli/commands/persona.py b/src/cleveragents/cli/commands/persona.py new file mode 100644 index 000000000..89ca43255 --- /dev/null +++ b/src/cleveragents/cli/commands/persona.py @@ -0,0 +1,169 @@ +"""CLI commands for persona export/import (YAML format). + +Provides ``agents persona export`` and ``agents persona import`` commands +that serialise/deserialise TUI persona configurations to/from YAML files. + +The canonical persona schema and registry live in +``cleveragents.tui.persona.*``; this module is a thin CLI adapter. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Annotated + +import typer +import yaml +from pydantic import ValidationError +from rich.console import Console +from rich.panel import Panel + +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.schema import Persona + +app = typer.Typer(help="Manage TUI personas.") +console = Console() + +_log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Module-level registry accessor (patchable in tests) +# --------------------------------------------------------------------------- +_registry: PersonaRegistry | None = None + + +def _get_registry() -> PersonaRegistry: + """Return the shared PersonaRegistry instance.""" + global _registry + if _registry is not None: + return _registry + _registry = PersonaRegistry() + return _registry + + +def _reset_registry() -> None: + """Reset the module-level registry (used by tests).""" + global _registry + _registry = None + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +@app.command("export") +def export_persona( + name: Annotated[ + str, + typer.Argument(help="Name of the persona to export"), + ], + output: Annotated[ + Path | None, + typer.Option( + "--output", + "-o", + help="Output YAML file path (default: .yaml in current directory)", + ), + ] = None, + force: Annotated[ + bool, + typer.Option("--force", help="Overwrite existing output file"), + ] = False, +) -> None: + """Export a persona configuration to a YAML file. + + The exported file contains all persona fields: actor, base_arguments, + scoped_projects, scoped_plans, argument_presets, and metadata. + + Examples: + agents persona export mydev + agents persona export mydev -o mydev-backup.yaml + agents persona export mydev -o mydev-backup.yaml --force + """ + output_path = output if output is not None else Path(f"{name}.yaml") + + if output_path.exists() and not force: + console.print( + f"[red]File already exists:[/red] {output_path}\nUse --force to overwrite." + ) + raise typer.Exit(1) + + try: + registry = _get_registry() + persona = registry.get(name) + if persona is None: + console.print(f"[red]Error:[/red] Persona not found: {name}") + raise typer.Exit(1) + payload = persona.model_dump(mode="json", exclude_none=True) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + yaml.safe_dump(payload, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + details = ( + f"[bold]Persona:[/bold] {name}\n" + f"[bold]Output:[/bold] {output_path.resolve()}" + ) + console.print(Panel(details, title="Persona Exported", expand=False)) + console.print("[green]✓ OK[/green] Persona exported") + except typer.Exit: + raise + except OSError as exc: + _log.debug("persona export failed", exc_info=True) + console.print(f"[red]Error:[/red] Could not write file: {exc}") + raise typer.Exit(1) from exc + + +@app.command("import") +def import_persona( + input_file: Annotated[ + Path, + typer.Option( + "--input", + "-i", + help="Input YAML file path", + ), + ], +) -> None: + """Import a persona configuration from a YAML file. + + The file must contain a valid persona definition with at minimum + ``name`` and ``actor`` fields. If a persona with the same name + already exists it will be overwritten. + + Examples: + agents persona import -i mydev.yaml + agents persona import --input /path/to/persona.yaml + """ + if not input_file.exists(): + console.print(f"[red]File not found:[/red] {input_file}") + raise typer.Exit(1) + + try: + raw = yaml.safe_load(input_file.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + console.print( + "[red]Error:[/red] Invalid persona file: expected a YAML mapping" + ) + raise typer.Exit(1) + persona = Persona.model_validate(raw) + registry = _get_registry() + registry.save(persona) + details = ( + f"[bold]Persona:[/bold] {persona.name}\n" + f"[bold]Actor:[/bold] {persona.actor}\n" + f"[bold]Description:[/bold] {persona.description or '(none)'}" + ) + console.print(Panel(details, title="Persona Imported", expand=False)) + console.print("[green]✓ OK[/green] Persona imported") + except typer.Exit: + raise + except (yaml.YAMLError, ValidationError) as exc: + console.print(f"[red]Error:[/red] Invalid persona file: {exc}") + raise typer.Exit(1) from exc + except OSError as exc: + _log.debug("persona import failed", exc_info=True) + console.print(f"[red]Error:[/red] Could not read file: {exc}") + raise typer.Exit(1) from exc diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index 7afcb56c1..665be3bef 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, @@ -168,6 +169,11 @@ def _register_subcommands() -> None: name="session", help="Manage interactive sessions", ) + app.add_typer( + persona.app, + name="persona", + help="Export and import TUI persona configurations (YAML)", + ) app.add_typer( config.app, name="config", @@ -680,6 +686,7 @@ def main(args: list[str] | None = None) -> int: "cleanup", # Garbage collection and cleanup "config", # Configuration management "session", # Session management + "persona", # Persona export/import "tool", # Tool registry management "validation", # Validation management "auto-debug", # Auto-debug commands diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index 1134e995e..3100d45c6 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,6 +23,11 @@ class TuiCommandRouter: tokens = raw.strip().split() if not tokens: return "Empty command" + # Support colon-style aliases: "persona:export" → ["persona", "export"] + first = tokens[0] + if ":" in first: + parts = first.split(":", 1) + tokens = parts + tokens[1:] if tokens[0] == "persona": return self._persona_command(tokens[1:], session_id=session_id) if tokens[0] == "session": @@ -39,8 +45,35 @@ 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: + """Handle /persona export [path].""" + if not tokens: + return "Usage: /persona export [path]" + name = tokens[0] + output_path = Path(tokens[1]) if len(tokens) > 1 else Path(f"{name}.yaml") + try: + result = self.persona_registry.export_persona(name, output_path) + return f"Exported persona '{name}' to {result}" + except ValueError as exc: + return f"Export error: {exc}" + + def _persona_import(self, tokens: list[str]) -> str: + """Handle /persona import .""" + if not tokens: + return "Usage: /persona import " + input_path = Path(tokens[0]) + try: + persona = self.persona_registry.import_persona(input_path) + return f"Imported persona '{persona.name}'" + except (ValueError, OSError) as exc: + return f"Import error: {exc}" + @staticmethod def _session_command(tokens: list[str], *, session_id: str) -> str: if not tokens or tokens[0] == "show":