feat(tui): implement persona export/import (YAML format) #1338

Closed
freemo wants to merge 1 commits from feature/m8-tui-persona-export into master
5 changed files with 669 additions and 0 deletions
@@ -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}"
)
@@ -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 <name> [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 <path>"
# ---------- 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
+169
View File
@@ -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: <name>.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
+7
View File
@@ -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
+33
View File
@@ -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 <name>"
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 <name> [path]."""
if not tokens:
return "Usage: /persona export <name> [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 <path>."""
if not tokens:
return "Usage: /persona import <path>"
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":