feat(tui): implement persona export/import (YAML format) #1338
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user