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