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

Closed
brent.edwards wants to merge 1 commits from feature/m8-tui-persona-export-import into master
9 changed files with 828 additions and 5 deletions
+5
View File
@@ -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.
@@ -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
# ---------------------------------------------------------------------------
@@ -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}"
+10
View File
@@ -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
@@ -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"
+25
View File
@@ -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
+177
View File
@@ -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 <name>.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}")
+6
View File
@@ -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
+46 -5
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,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 <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:
if not tokens:
return "Usage: /persona:export <name> [path]"
if len(tokens) > 2:
return "Usage: /persona:export <name> [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 <path>"
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":