From c02f3842ae3119500ea0853531fc38ad1f6ba266 Mon Sep 17 00:00:00 2001 From: Aditya Chhabra Date: Mon, 16 Mar 2026 13:39:42 +0000 Subject: [PATCH 1/3] feat(tui): implement persona system and reference/command input modes - Add `agents tui` command wiring and Textual app scaffolding for interactive TUI startup. - Implement TUI persona schema/registry/state with local YAML persistence and per-session persona binding. - Add three input-mode flows: Normal (`@` references), Command (`/` slash commands), and Shell (`!` passthrough). - Introduce TUI widgets/overlays for prompt, persona bar, reference picker, and slash command interactions. - Extend REPL command routing to support persona/session commands and reference/shell dispatch behavior. - Add test coverage: Behave features + step definitions, Robot TUI smoke test, and ASV fuzzy-reference benchmark. ISSUES CLOSED: #695 --- CHANGELOG.md | 8 + asv/benchmarks/tui_reference_fuzzy_bench.py | 23 + features/repl_input_modes.feature | 64 +++ features/steps/repl_input_modes_steps.py | 95 ++++ features/steps/tui_input_modes_steps.py | 42 ++ features/steps/tui_persona_system_steps.py | 105 ++++ features/tui_input_modes.feature | 16 + features/tui_persona_system.feature | 27 + pyproject.toml | 3 + robot/tui_smoke.robot | 10 + src/cleveragents/application/container.py | 26 + src/cleveragents/cli/commands/plan.py | 11 + src/cleveragents/cli/commands/repl.py | 483 +++++++++++++++++- src/cleveragents/cli/commands/tui.py | 25 + src/cleveragents/cli/main.py | 8 + src/cleveragents/cli/persona.py | 177 +++++++ src/cleveragents/tui/__init__.py | 11 + src/cleveragents/tui/app.py | 179 +++++++ src/cleveragents/tui/cleveragents.tcss | 44 ++ src/cleveragents/tui/commands.py | 73 +++ src/cleveragents/tui/input/__init__.py | 22 + src/cleveragents/tui/input/modes.py | 80 +++ .../tui/input/reference_parser.py | 156 ++++++ src/cleveragents/tui/input/shell_exec.py | 44 ++ src/cleveragents/tui/persona/__init__.py | 12 + src/cleveragents/tui/persona/registry.py | 136 +++++ src/cleveragents/tui/persona/schema.py | 85 +++ src/cleveragents/tui/persona/state.py | 69 +++ src/cleveragents/tui/search/__init__.py | 9 + src/cleveragents/tui/search/fuzzy.py | 57 +++ src/cleveragents/tui/widgets/__init__.py | 14 + src/cleveragents/tui/widgets/persona_bar.py | 38 ++ src/cleveragents/tui/widgets/prompt.py | 40 ++ .../tui/widgets/reference_picker.py | 37 ++ .../tui/widgets/slash_command_overlay.py | 39 ++ 35 files changed, 2266 insertions(+), 2 deletions(-) create mode 100644 asv/benchmarks/tui_reference_fuzzy_bench.py create mode 100644 features/repl_input_modes.feature create mode 100644 features/steps/repl_input_modes_steps.py create mode 100644 features/steps/tui_input_modes_steps.py create mode 100644 features/steps/tui_persona_system_steps.py create mode 100644 features/tui_input_modes.feature create mode 100644 features/tui_persona_system.feature create mode 100644 robot/tui_smoke.robot create mode 100644 src/cleveragents/cli/commands/tui.py create mode 100644 src/cleveragents/cli/persona.py create mode 100644 src/cleveragents/tui/__init__.py create mode 100644 src/cleveragents/tui/app.py create mode 100644 src/cleveragents/tui/cleveragents.tcss create mode 100644 src/cleveragents/tui/commands.py create mode 100644 src/cleveragents/tui/input/__init__.py create mode 100644 src/cleveragents/tui/input/modes.py create mode 100644 src/cleveragents/tui/input/reference_parser.py create mode 100644 src/cleveragents/tui/input/shell_exec.py create mode 100644 src/cleveragents/tui/persona/__init__.py create mode 100644 src/cleveragents/tui/persona/registry.py create mode 100644 src/cleveragents/tui/persona/schema.py create mode 100644 src/cleveragents/tui/persona/state.py create mode 100644 src/cleveragents/tui/search/__init__.py create mode 100644 src/cleveragents/tui/search/fuzzy.py create mode 100644 src/cleveragents/tui/widgets/__init__.py create mode 100644 src/cleveragents/tui/widgets/persona_bar.py create mode 100644 src/cleveragents/tui/widgets/prompt.py create mode 100644 src/cleveragents/tui/widgets/reference_picker.py create mode 100644 src/cleveragents/tui/widgets/slash_command_overlay.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c394528d..b90b0ee90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,14 @@ outcomes while tracebacks/unexpected internal failures still fail. - Runtime enforcement of `response_format` in provider invocation remains planned and tracked via TODO comments in runtime code. (#650) +- Added interactive TUI persona and input-mode support with a dedicated + `agents tui` entry point and Textual app scaffolding. Personas are now + managed as local YAML configs with per-session binding/state, and input + handling supports Normal (`@` references), Command (`/` slash commands), + and Shell (`!` passthrough) flows with picker/overlay components and fuzzy + matching primitives. Includes Behave feature coverage for TUI persona/input + behavior, Robot TUI smoke validation, and an ASV fuzzy-reference benchmark. + (#695) - Added four CLI-based integration test cases to M5 E2E verification suite for v3.4.0 milestone acceptance criteria validation. Tests exercise `project create`, `resource add git-checkout`, `project link-resource`, and diff --git a/asv/benchmarks/tui_reference_fuzzy_bench.py b/asv/benchmarks/tui_reference_fuzzy_bench.py new file mode 100644 index 000000000..e8497ac8d --- /dev/null +++ b/asv/benchmarks/tui_reference_fuzzy_bench.py @@ -0,0 +1,23 @@ +"""ASV benchmark for TUI fuzzy reference ranking.""" + +from __future__ import annotations + +from typing import ClassVar + +from cleveragents.tui.search.fuzzy import rank_candidates + + +class TuiReferenceFuzzyBench: + """Bench weighted fuzzy ranking on a medium candidate set.""" + + params: ClassVar[list[int]] = [1_000, 10_000] + param_names: ClassVar[list[str]] = ["candidate_count"] + + def setup(self, candidate_count: int) -> None: + self.values = [ + f"src/module_{idx}/file_{idx}.py" for idx in range(candidate_count) + ] + + def time_rank_candidates(self, candidate_count: int) -> None: + del candidate_count + rank_candidates("module_42/file_42", self.values, limit=20) diff --git a/features/repl_input_modes.feature b/features/repl_input_modes.feature new file mode 100644 index 000000000..2f75d8305 --- /dev/null +++ b/features/repl_input_modes.feature @@ -0,0 +1,64 @@ +Feature: REPL input modes and persona controls + The interactive REPL supports slash commands, shell passthrough, and + inline @ reference expansion. + + Scenario: Slash persona commands create and activate personas + Given a temporary REPL config directory + When I run the REPL with input lines + | line | + | /persona create dev --actor local/mock-default | + | /persona list | + | /persona set dev | + | /persona list | + Then the REPL mode output should contain "Created persona: dev" + And the REPL mode output should contain "Active persona: dev" + And the REPL mode output should contain "dev -> local/mock-default" + + Scenario: Shell mode runs host shell commands + Given a temporary REPL config directory + When I run the REPL with input lines + | line | + | !printf hello | + Then the REPL mode output should contain "hello" + + Scenario: @ references are expanded with canonical category prefixes + When I expand REPL references for "info @README.md" + Then the expanded REPL line should contain "@file:README.md" + + Scenario: Slash persona shorthand switches active persona + Given a temporary REPL config directory + When I run the REPL with input lines + | line | + | /persona create reviewer --actor local/mock-default | + | /persona reviewer | + Then the REPL mode output should contain "Active persona: reviewer" + + Scenario: Persona export import and delete workflow + Given a temporary REPL config directory + And a temporary persona export path + When I run the REPL with input lines + | line | + | /persona create dev --actor local/mock-default | + | /persona export dev {export_path} | + | /persona delete dev | + | /persona import {export_path} | + | /persona set dev | + Then the REPL mode output should contain "Exported persona:" + And the REPL mode output should contain "Deleted persona: dev" + And the REPL mode output should contain "Imported persona: dev" + And the REPL mode output should contain "Active persona: dev" + + Scenario: Persona binding is independent per REPL session + Given a temporary REPL config directory + When I run the REPL with input lines + | line | + | /persona create dev --actor local/mock-default | + | /persona create reviewer --actor local/mock-default | + | /session new work | + | /session switch work | + | /persona set dev | + | /session switch default | + | /persona set reviewer | + | /session list | + Then the REPL mode output should contain "work (persona=dev)" + And the REPL mode output should contain "default (persona=reviewer)" diff --git a/features/steps/repl_input_modes_steps.py b/features/steps/repl_input_modes_steps.py new file mode 100644 index 000000000..6285f995a --- /dev/null +++ b/features/steps/repl_input_modes_steps.py @@ -0,0 +1,95 @@ +"""Behave steps for REPL input mode behavior.""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.cli.commands.repl import _expand_references, run_repl + + +class _FakeInput: + """Feed predefined lines and then terminate REPL with EOF.""" + + def __init__(self, lines: list[str]) -> None: + self._lines = list(lines) + self._idx = 0 + + def __call__(self, prompt: str = "") -> str: + if self._idx >= len(self._lines): + raise EOFError + line = self._lines[self._idx] + self._idx += 1 + return line + + +def _run_repl_capture(lines: list[str], config_dir: Path) -> tuple[int, str]: + from io import StringIO + + from rich.console import Console + + output = StringIO() + console = Console(file=output, no_color=True, width=180) + fake_input = _FakeInput(lines) + with ( + patch("cleveragents.cli.commands.repl._console", console), + patch("builtins.input", fake_input), + patch.dict(os.environ, {"CLEVERAGENTS_CONFIG_DIR": str(config_dir)}), + ): + code = run_repl(no_history=True) + return code, output.getvalue() + + +@given("a temporary REPL config directory") +def step_temp_repl_config(context: Context) -> None: + config_dir = Path(tempfile.mkdtemp()) + context.repl_mode_config_dir = config_dir + context.add_cleanup(lambda: shutil.rmtree(str(config_dir), ignore_errors=True)) + + +@given("a temporary persona export path") +def step_temp_persona_export_path(context: Context) -> None: + export_path = Path(tempfile.mkdtemp()) / "persona-export.yaml" + context.repl_mode_export_path = export_path + context.add_cleanup( + lambda: shutil.rmtree(str(export_path.parent), ignore_errors=True) + ) + + +@when("I run the REPL with input lines") +def step_run_repl_lines(context: Context) -> None: + export_path = getattr(context, "repl_mode_export_path", None) + lines = [] + for row in context.table: + line = row["line"] + if export_path is not None: + line = line.format(export_path=str(export_path)) + lines.append(line) + code, output = _run_repl_capture(lines, context.repl_mode_config_dir) + context.repl_mode_code = code + context.repl_mode_output = output + + +@then('the REPL mode output should contain "{text}"') +def step_output_contains(context: Context, text: str) -> None: + assert text in context.repl_mode_output, ( + f"Expected {text!r} in output, got:\n{context.repl_mode_output}" + ) + + +@when('I expand REPL references for "{line}"') +def step_expand_references(context: Context, line: str) -> None: + context.expanded_repl_line = _expand_references(line) + + +@then('the expanded REPL line should contain "{text}"') +def step_expanded_contains(context: Context, text: str) -> None: + assert text in context.expanded_repl_line, ( + f"Expected {text!r} in expanded line: {context.expanded_repl_line}" + ) diff --git a/features/steps/tui_input_modes_steps.py b/features/steps/tui_input_modes_steps.py new file mode 100644 index 000000000..a1949509c --- /dev/null +++ b/features/steps/tui_input_modes_steps.py @@ -0,0 +1,42 @@ +"""Behave steps for TUI input mode router.""" + +from __future__ import annotations + +from behave import then, when +from behave.runner import Context + +from cleveragents.tui.input.modes import InputModeRouter +from cleveragents.tui.input.reference_parser import parse_references + + +@when('I parse TUI references for "{line}"') +def step_parse_references(context: Context, line: str) -> None: + context.tui_ref_result = parse_references(line) + + +@then('the parsed line should include "{token}"') +def step_parsed_contains(context: Context, token: str) -> None: + assert token in context.tui_ref_result.expanded_line + + +@when('I route TUI input "{line}"') +def step_route_input(context: Context, line: str) -> None: + router = InputModeRouter(command_handler=lambda raw: f"ok:/{raw}") + context.tui_mode_result = router.process(line) + + +@then('the TUI mode should be "{mode}"') +def step_mode_equals(context: Context, mode: str) -> None: + assert context.tui_mode_result.mode.value == mode + + +@then('the TUI command result should be "{value}"') +def step_command_result(context: Context, value: str) -> None: + assert context.tui_mode_result.command_result == value + + +@then('the TUI shell stdout should contain "{value}"') +def step_shell_stdout(context: Context, value: str) -> None: + shell = context.tui_mode_result.shell_result + assert shell is not None + assert value in shell.stdout diff --git a/features/steps/tui_persona_system_steps.py b/features/steps/tui_persona_system_steps.py new file mode 100644 index 000000000..1253d50e4 --- /dev/null +++ b/features/steps/tui_persona_system_steps.py @@ -0,0 +1,105 @@ +"""Behave steps for TUI persona system.""" + +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.schema import Persona +from cleveragents.tui.persona.state import PersonaState + + +def _registry_for_temp_dir(path: Path) -> PersonaRegistry: + return PersonaRegistry(config_dir=path) + + +@given("a temporary TUI persona registry") +def step_temp_registry(context: Context) -> None: + temp_dir = Path(tempfile.mkdtemp()) + context.tui_persona_dir = temp_dir + context.tui_registry = _registry_for_temp_dir(temp_dir) + context.add_cleanup(lambda: shutil.rmtree(str(temp_dir), ignore_errors=True)) + + +@when('I build a TUI persona named "{name}" with actor "{actor}"') +def step_build_persona(context: Context, name: str, actor: str) -> None: + context.tui_persona = Persona(name=name, actor=actor) + + +@then("the persona should include exactly one default preset") +def step_default_preset(context: Context) -> None: + defaults = [ + item for item in context.tui_persona.argument_presets if item.name == "default" + ] + assert len(defaults) == 1 + + +@when('I save a TUI persona named "{name}" with actor "{actor}"') +def step_save_persona(context: Context, name: str, actor: str) -> None: + persona = Persona(name=name, actor=actor) + context.tui_registry.save(persona) + + +@given('I save a TUI persona named "{name}" with actor "{actor}"') +def step_given_save_persona(context: Context, name: str, actor: str) -> None: + step_save_persona(context, name, actor) + + +@given( + 'I save a TUI persona named "{name}" with actor "{actor}" and cycle order {cycle:d}' +) +def step_save_persona_cycle( + context: Context, name: str, actor: str, cycle: int +) -> None: + persona = Persona(name=name, actor=actor, cycle_order=cycle) + context.tui_registry.save(persona) + + +@when( + 'I try to save a TUI persona named "{name}" with actor "{actor}" and cycle order {cycle:d}' +) +def step_save_persona_cycle_fail( + context: Context, name: str, actor: str, cycle: int +) -> None: + context.tui_cycle_error = None + try: + context.tui_registry.save(Persona(name=name, actor=actor, cycle_order=cycle)) + except Exception as exc: + context.tui_cycle_error = exc + + +@then('loading persona "{name}" should succeed') +def step_loading_persona(context: Context, name: str) -> None: + context.tui_loaded_persona = context.tui_registry.get(name) + assert context.tui_loaded_persona is not None + + +@then('the loaded persona actor should be "{actor}"') +def step_loaded_actor(context: Context, actor: str) -> None: + assert context.tui_loaded_persona.actor == actor + + +@then("saving the second persona should fail with cycle order error") +def step_cycle_error(context: Context) -> None: + assert context.tui_cycle_error is not None + assert "cycle_order" in str(context.tui_cycle_error) + + +@when('I set active persona to "{persona_name}" for session "{session_id}"') +def step_set_active_persona( + context: Context, persona_name: str, session_id: str +) -> None: + if not hasattr(context, "tui_state"): + context.tui_state = PersonaState(registry=context.tui_registry) + context.tui_state.set_active_persona(session_id, persona_name) + + +@then('active persona for session "{session_id}" should be "{persona_name}"') +def step_active_persona(context: Context, session_id: str, persona_name: str) -> None: + persona = context.tui_state.active_persona(session_id) + assert persona.name == persona_name diff --git a/features/tui_input_modes.feature b/features/tui_input_modes.feature new file mode 100644 index 000000000..a12bb8d3e --- /dev/null +++ b/features/tui_input_modes.feature @@ -0,0 +1,16 @@ +Feature: TUI input modes + TUI prompt supports normal reference mode, slash command mode, and shell mode. + + Scenario: Normal mode expands @ references + When I parse TUI references for "inspect @README.md" + Then the parsed line should include "@resource:README.md" + + Scenario: Command mode dispatches slash command handler + When I route TUI input "/help" + Then the TUI mode should be "command" + And the TUI command result should be "ok:/help" + + Scenario: Shell mode runs host command + When I route TUI input "!printf hello" + Then the TUI mode should be "shell" + And the TUI shell stdout should contain "hello" diff --git a/features/tui_persona_system.feature b/features/tui_persona_system.feature new file mode 100644 index 000000000..be421b5b9 --- /dev/null +++ b/features/tui_persona_system.feature @@ -0,0 +1,27 @@ +Feature: TUI persona system + Personas are TUI-only YAML records with per-session binding. + + Scenario: Persona schema auto-creates default preset + When I build a TUI persona named "dev" with actor "local/mock-default" + Then the persona should include exactly one default preset + + Scenario: Persona registry saves and loads YAML records + Given a temporary TUI persona registry + When I save a TUI persona named "reviewer" with actor "local/mock-default" + Then loading persona "reviewer" should succeed + And the loaded persona actor should be "local/mock-default" + + Scenario: Persona registry validates duplicate cycle order + Given a temporary TUI persona registry + And I save a TUI persona named "a" with actor "local/mock-default" and cycle order 1 + When I try to save a TUI persona named "b" with actor "local/mock-default" and cycle order 1 + Then saving the second persona should fail with cycle order error + + Scenario: Persona state is bound per session + Given a temporary TUI persona registry + And I save a TUI persona named "dev" with actor "local/mock-default" + And I save a TUI persona named "reviewer" with actor "local/mock-default" + When I set active persona to "dev" for session "s1" + And I set active persona to "reviewer" for session "s2" + Then active persona for session "s1" should be "dev" + And active persona for session "s2" should be "reviewer" diff --git a/pyproject.toml b/pyproject.toml index 6d7f95a1a..ad642ab01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,9 @@ dependencies = [ ] [project.optional-dependencies] +tui = [ + "textual>=1.0.0,<2.0.0", +] dev = [ # Code formatting and linting "ruff>=0.15.0,<0.16.0", diff --git a/robot/tui_smoke.robot b/robot/tui_smoke.robot new file mode 100644 index 000000000..0e229afd2 --- /dev/null +++ b/robot/tui_smoke.robot @@ -0,0 +1,10 @@ +*** Settings *** +Library Process +Library String + +*** Test Cases *** +TUI Headless Startup Check Returns JSON + ${result}= Run Process ${PYTHON} -m cleveragents tui --headless shell=False stderr=STDOUT env:CLEVERAGENTS_DATABASE_URL=sqlite:///:memory: + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} textual_available + Should Contain ${result.stdout} default_persona diff --git a/src/cleveragents/application/container.py b/src/cleveragents/application/container.py index edf1ea736..3120c93fe 100644 --- a/src/cleveragents/application/container.py +++ b/src/cleveragents/application/container.py @@ -4,6 +4,7 @@ Based on ADR-003 (Dependency Injection Framework). Uses dependency-injector for managing service instances. """ +from dataclasses import dataclass from pathlib import Path import structlog @@ -99,6 +100,21 @@ from cleveragents.shared.redaction import redact_value _logger = structlog.get_logger(__name__) +@dataclass(slots=True) +class TuiServiceAdapters: + """Lightweight adapter bundle for TUI-facing read operations. + + The TUI is presentation-layer only; this keeps integration explicit + without introducing TUI concepts into domain models. + """ + + actor_service: ActorService + session_service: PersistentSessionService + project_service: ProjectService + resource_registry_service: ResourceRegistryService + plan_service: PlanService + + def get_ai_provider( settings: Settings | None = None, provider_registry: ProviderRegistry | None = None, @@ -513,6 +529,16 @@ class Container(containers.DeclarativeContainer): event_bus=event_bus, ) + # TUI adapter bundle (presentation-layer only composition) + tui_adapters = providers.Factory( + TuiServiceAdapters, + actor_service=actor_service, + session_service=session_service, + project_service=project_service, + resource_registry_service=resource_registry_service, + plan_service=plan_service, + ) + # Metrics Emitter - structured metric emission (Forgejo #579) metrics_emitter = providers.Singleton( MetricsEmitter.from_settings, diff --git a/src/cleveragents/cli/commands/plan.py b/src/cleveragents/cli/commands/plan.py index a27e4a155..9f16ff103 100644 --- a/src/cleveragents/cli/commands/plan.py +++ b/src/cleveragents/cli/commands/plan.py @@ -930,6 +930,11 @@ def _lifecycle_apply_with_id(plan_id: str, fmt: str = "rich") -> None: except PlanNotReadyError as e: console.print(f"[red]Plan not ready:[/red] {e}") raise typer.Abort() from e + except ValueError as e: + # Provider-resolution failures (e.g. missing API key/config) should be + # reported as a controlled CLI error instead of bubbling to a 500. + console.print(f"[red]Execution Error:[/red] {e}") + raise typer.Abort() from e except CleverAgentsError as e: console.print(f"[red]Error:[/red] {e.message}") raise typer.Abort() from e @@ -1898,6 +1903,12 @@ def execute_plan( except PlanNotReadyError as e: console.print(f"[red]Plan not ready:[/red] {e}") raise typer.Abort() from e + except ValueError as e: + # Provider/config resolution errors can occur during strategize/execute + # when required model credentials are unavailable in the environment. + # Surface as a controlled CLI abort instead of an INTERNAL 500. + console.print(f"[red]Execution Error:[/red] {e}") + raise typer.Abort() from e except CleverAgentsError as e: console.print(f"[red]Error:[/red] {e.message}") raise typer.Abort() from e diff --git a/src/cleveragents/cli/commands/repl.py b/src/cleveragents/cli/commands/repl.py index df63c50f6..d7d09f3da 100644 --- a/src/cleveragents/cli/commands/repl.py +++ b/src/cleveragents/cli/commands/repl.py @@ -7,17 +7,23 @@ prompt context showing the active project/plan. from __future__ import annotations +import difflib import os import readline import shlex +import subprocess import sys from collections.abc import Sequence +from dataclasses import dataclass from pathlib import Path from typing import Annotated import typer +import yaml from rich.console import Console +from cleveragents.cli.persona import PersonaConfig, PersonaRegistry + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- @@ -26,6 +32,7 @@ _DEFAULT_HISTORY_DIR = Path.home() / ".cleveragents" _DEFAULT_HISTORY_PATH = _DEFAULT_HISTORY_DIR / "history" _MULTILINE_CONTINUATION = "\\" _LAST_COMMAND_TOKEN = "!!" +_MAX_REFERENCE_FILES = 300 # The subset of first-position CLI commands exposed inside the REPL. # Mirrors the ``valid_cmds`` list in ``main.py`` minus the flags. @@ -55,9 +62,23 @@ _REPL_COMMANDS: list[str] = [ "apply", "context-load", "context-add", + "tui", ] _BUILTIN_COMMANDS: list[str] = [":help", ":exit", ":quit"] +_SLASH_COMMANDS: list[str] = [ + "/persona", + "/persona list", + "/persona set", + "/persona create", + "/persona pick", + "/persona delete", + "/persona export", + "/persona import", + "/session list", + "/session new", + "/session switch", +] _console = Console() @@ -66,6 +87,18 @@ _console = Console() # --------------------------------------------------------------------------- +@dataclass(slots=True) +class _ReplSessionState: + name: str + active_persona: str + + +def _compose_prompt(base_prompt: str, session_state: _ReplSessionState) -> str: + """Compose prompt with session/persona status.""" + core = base_prompt.removesuffix("> ").rstrip() + return f"{core} [{session_state.name}:{session_state.active_persona}]> " + + def _get_prompt_context() -> str: """Return a prompt string reflecting the active project/plan. @@ -104,7 +137,7 @@ def _save_history(history_path: Path) -> None: def _setup_completer() -> None: """Install a tab-completer for known commands and built-ins.""" - all_tokens = sorted(set(_REPL_COMMANDS + _BUILTIN_COMMANDS)) + all_tokens = sorted(set(_REPL_COMMANDS + _BUILTIN_COMMANDS + _SLASH_COMMANDS)) def completer(text: str, state: int) -> str | None: matches = [t for t in all_tokens if t.startswith(text)] @@ -141,6 +174,16 @@ def _print_repl_help() -> None: _console.print(" :exit / :quit Exit the REPL") _console.print(" !! Repeat the last command") _console.print("") + _console.print("[underline]Input modes[/underline]") + _console.print(" @reference Inline reference expansion and fuzzy matching") + _console.print(" /command Slash command mode") + _console.print(" !shell cmd Shell command passthrough") + _console.print(" Full-screen UI: run [bold]agents tui[/bold]") + _console.print("") + _console.print("[underline]Slash commands[/underline]") + for cmd in _SLASH_COMMANDS: + _console.print(f" {cmd}") + _console.print("") _console.print("[underline]CLI commands[/underline]") for cmd in _REPL_COMMANDS: _console.print(f" {cmd}") @@ -148,6 +191,408 @@ def _print_repl_help() -> None: _console.print("Use [bold]\\\\[/bold] at end of line for multi-line input.") +def _build_reference_catalog() -> dict[str, list[str]]: + """Build lightweight catalog for @ reference expansion.""" + cwd = Path.cwd() + files: list[str] = [] + for path in cwd.rglob("*"): + if len(files) >= _MAX_REFERENCE_FILES: + break + if path.is_file(): + try: + files.append(str(path.relative_to(cwd).as_posix())) + except ValueError: + files.append(path.as_posix()) + + actors: set[str] = set() + actor_examples = cwd / "examples" / "actors" + if actor_examples.is_dir(): + for file in actor_examples.glob("*.y*ml"): + actors.add(f"local/{file.stem}") + + tools: set[str] = set() + tool_examples = cwd / "examples" / "tools" + if tool_examples.is_dir(): + for file in tool_examples.glob("*.y*ml"): + tools.add(f"local/{file.stem}") + + skills: set[str] = set() + skill_examples = cwd / "examples" / "skills" + if skill_examples.is_dir(): + for item in skill_examples.iterdir(): + if item.is_dir(): + skills.add(f"local/{item.name}") + elif item.suffix in {".yaml", ".yml"}: + skills.add(f"local/{item.stem}") + + project_env = os.environ.get("CLEVERAGENTS_PROJECT", "") + plan_env = os.environ.get("CLEVERAGENTS_PLAN", "") + + return { + "file": sorted(files), + "actor": sorted(actors), + "tool": sorted(tools), + "skill": sorted(skills), + "project": [project_env] if project_env else [], + "plan": [plan_env] if plan_env else [], + } + + +def _best_fuzzy_match(query: str, options: list[str]) -> str | None: + """Return the best match using prefix/substring/fuzzy ordering.""" + if not query or not options: + return None + for option in options: + if option.startswith(query): + return option + for option in options: + if query in option: + return option + matches = difflib.get_close_matches(query, options, n=1, cutoff=0.4) + if matches: + return matches[0] + return None + + +def _find_reference_candidates( + query: str, options: list[str], limit: int = 5 +) -> list[str]: + """Return top fuzzy candidates for preview/display.""" + if not query: + return options[:limit] + prefix = [value for value in options if value.startswith(query)] + contains = [value for value in options if query in value and value not in prefix] + fuzzy = [ + value + for value in difflib.get_close_matches(query, options, n=limit, cutoff=0.4) + if value not in prefix and value not in contains + ] + return (prefix + contains + fuzzy)[:limit] + + +def _expand_references(line: str) -> str: + """Expand inline @ tokens to canonical references when possible.""" + catalog = _build_reference_catalog() + tokens = line.split() + expanded_tokens: list[str] = [] + + for token in tokens: + if token.startswith("\\@"): + expanded_tokens.append(token[1:]) + continue + if not token.startswith("@") or len(token) == 1: + expanded_tokens.append(token) + continue + + raw = token[1:] + category = "" + query = raw + if ":" in raw: + maybe_category, rest = raw.split(":", 1) + if maybe_category in catalog: + category = maybe_category + query = rest + + if category: + candidate = _best_fuzzy_match(query, catalog.get(category, [])) + if candidate: + expanded_tokens.append(f"@{category}:{candidate}") + continue + expanded_tokens.append(token) + continue + + best_category = "" + best_value = "" + for candidate_category, values in catalog.items(): + candidate = _best_fuzzy_match(query, values) + if candidate: + best_category = candidate_category + best_value = candidate + break + if best_category: + expanded_tokens.append(f"@{best_category}:{best_value}") + else: + expanded_tokens.append(token) + + return " ".join(expanded_tokens) + + +def _reference_suggestions(line: str) -> list[str]: + """Return lightweight suggestion hints for unresolved @ tokens.""" + catalog = _build_reference_catalog() + for token in line.split(): + if not token.startswith("@") or token.startswith("\\@"): + continue + raw = token[1:] + if not raw: + continue + if ":" in raw: + category, query = raw.split(":", 1) + if category in catalog: + candidates = _find_reference_candidates( + query, catalog[category], limit=5 + ) + return [f"@{category}:{value}" for value in candidates] + else: + merged: list[str] = [] + for category, values in catalog.items(): + for value in _find_reference_candidates(raw, values, limit=2): + merged.append(f"@{category}:{value}") + return merged[:5] + return [] + + +def _run_shell_command(command_text: str) -> int: + """Execute a shell command and print output.""" + if not command_text.strip(): + _console.print("[yellow]Shell mode requires a command after '!'.[/yellow]") + return 2 + proc = subprocess.run(command_text, shell=True, text=True, capture_output=True) # nosec B602 + if proc.stdout: + _console.print(proc.stdout.rstrip("\n")) + if proc.stderr: + _console.print(f"[red]{proc.stderr.rstrip()}[/red]") + return proc.returncode + + +def _print_persona_list( + registry: PersonaRegistry, + sessions: dict[str, _ReplSessionState], + current_session: str, +) -> None: + personas = registry.list_personas() + if not personas: + _console.print("[yellow]No personas found.[/yellow]") + return + active = sessions[current_session].active_persona + _console.print("[bold]Personas[/bold]") + for persona in personas: + marker = "*" if persona.name == active else " " + _console.print(f" {marker} {persona.name} -> {persona.actor}") + + +def _parse_persona_create(tokens: list[str]) -> PersonaConfig: + if len(tokens) < 4: + raise ValueError("Usage: /persona create --actor ") + name = tokens[2] + actor = "" + description = "" + icon = "" + greeting = "" + cycle_order = 0 + idx = 3 + while idx < len(tokens): + current = tokens[idx] + if current == "--actor" and idx + 1 < len(tokens): + actor = tokens[idx + 1] + idx += 2 + continue + if current == "--description" and idx + 1 < len(tokens): + description = tokens[idx + 1] + idx += 2 + continue + if current == "--icon" and idx + 1 < len(tokens): + icon = tokens[idx + 1] + idx += 2 + continue + if current == "--greeting" and idx + 1 < len(tokens): + greeting = tokens[idx + 1] + idx += 2 + continue + if current == "--cycle-order" and idx + 1 < len(tokens): + try: + cycle_order = int(tokens[idx + 1]) + except ValueError as exc: + raise ValueError("--cycle-order must be an integer") from exc + idx += 2 + continue + idx += 1 + if not actor: + raise ValueError("Missing --actor for /persona create") + return PersonaConfig( + name=name, + actor=actor, + description=description, + icon=icon, + greeting=greeting, + cycle_order=cycle_order, + ) + + +def _handle_slash_command( + slash_text: str, + *, + registry: PersonaRegistry, + sessions: dict[str, _ReplSessionState], + current_session: str, +) -> tuple[int, str]: + """Handle slash commands and return (exit_code, current_session).""" + try: + tokens = shlex.split(slash_text) + except ValueError as exc: + _console.print(f"[red]Parse error:[/red] {exc}") + return 2, current_session + if not tokens: + _console.print("[yellow]Empty slash command.[/yellow]") + return 2, current_session + + if tokens[0] == "persona": + subcommands = {"list", "set", "create", "pick", "delete", "export", "import"} + if len(tokens) == 2 and tokens[1] not in subcommands: + tokens = ["persona", "set", tokens[1]] + if len(tokens) == 1 or (len(tokens) > 1 and tokens[1] == "list"): + _print_persona_list(registry, sessions, current_session) + return 0, current_session + if tokens[1] == "set": + if len(tokens) < 3: + _console.print("[red]Usage:[/red] /persona set ") + return 2, current_session + target = tokens[2] + persona = registry.get_persona(target) + if persona is None: + _console.print(f"[red]Persona not found:[/red] {target}") + return 1, current_session + sessions[current_session].active_persona = persona.name + registry.set_active_persona_name(persona.name) + _console.print(f"[green]Active persona:[/green] {persona.name}") + return 0, current_session + if tokens[1] == "create": + try: + persona = _parse_persona_create(tokens) + except ValueError as exc: + _console.print(f"[red]{exc}[/red]") + return 2, current_session + registry.save_persona(persona) + _console.print(f"[green]Created persona:[/green] {persona.name}") + return 0, current_session + if tokens[1] == "pick": + personas = registry.list_personas() + if not personas: + _console.print("[yellow]No personas available.[/yellow]") + return 1, current_session + if len(tokens) > 2: + query = tokens[2] + names = [persona.name for persona in personas] + narrowed = _find_reference_candidates(query, names, limit=len(names)) + personas = [persona for persona in personas if persona.name in narrowed] + if not personas: + _console.print(f"[yellow]No personas matched '{query}'.[/yellow]") + return 1, current_session + _console.print("[bold]Persona picker[/bold]") + for idx, persona in enumerate(personas, start=1): + _console.print(f" {idx}. {persona.name} ({persona.actor})") + selection = input("Select persona number: ").strip() + try: + selected_idx = int(selection) + except ValueError: + _console.print("[red]Invalid selection.[/red]") + return 2, current_session + if selected_idx < 1 or selected_idx > len(personas): + _console.print("[red]Selection out of range.[/red]") + return 2, current_session + selected = personas[selected_idx - 1] + sessions[current_session].active_persona = selected.name + registry.set_active_persona_name(selected.name) + _console.print(f"[green]Active persona:[/green] {selected.name}") + return 0, current_session + if tokens[1] == "delete": + if len(tokens) < 3: + _console.print("[red]Usage:[/red] /persona delete ") + return 2, current_session + target = tokens[2] + if target == "default": + _console.print("[red]Cannot delete default persona.[/red]") + return 1, current_session + deleted = registry.delete_persona(target) + if not deleted: + _console.print(f"[red]Persona not found:[/red] {target}") + return 1, current_session + if sessions[current_session].active_persona == target: + sessions[current_session].active_persona = "default" + registry.set_active_persona_name("default") + _console.print(f"[green]Deleted persona:[/green] {target}") + return 0, current_session + if tokens[1] == "export": + if len(tokens) < 3: + _console.print("[red]Usage:[/red] /persona export [path]") + return 2, current_session + target = tokens[2] + persona = registry.get_persona(target) + if persona is None: + _console.print(f"[red]Persona not found:[/red] {target}") + return 1, current_session + output_path = ( + Path(tokens[3]) if len(tokens) > 3 else Path.cwd() / f"{target}.yaml" + ) + payload = persona.model_dump(mode="json", exclude_none=True) + output_path.write_text( + yaml.safe_dump(payload, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + _console.print(f"[green]Exported persona:[/green] {output_path}") + return 0, current_session + if tokens[1] == "import": + if len(tokens) < 3: + _console.print("[red]Usage:[/red] /persona import ") + return 2, current_session + input_path = Path(tokens[2]) + if not input_path.exists(): + _console.print(f"[red]File not found:[/red] {input_path}") + return 1, current_session + + raw = yaml.safe_load(input_path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + _console.print("[red]Invalid persona YAML.[/red]") + return 1, current_session + try: + persona = PersonaConfig.model_validate(raw) + except Exception as exc: + _console.print(f"[red]Persona validation failed:[/red] {exc}") + return 1, current_session + registry.save_persona(persona) + _console.print(f"[green]Imported persona:[/green] {persona.name}") + return 0, current_session + _console.print(f"[red]Unknown persona command:[/red] {' '.join(tokens)}") + return 2, current_session + + if tokens[0] == "session": + if len(tokens) == 1 or (len(tokens) > 1 and tokens[1] == "list"): + _console.print("[bold]REPL Sessions[/bold]") + for name, state in sessions.items(): + marker = "*" if name == current_session else " " + _console.print(f" {marker} {name} (persona={state.active_persona})") + return 0, current_session + if tokens[1] == "new": + if len(tokens) < 3: + _console.print("[red]Usage:[/red] /session new ") + return 2, current_session + new_name = tokens[2] + if new_name in sessions: + _console.print(f"[red]Session already exists:[/red] {new_name}") + return 1, current_session + sessions[new_name] = _ReplSessionState( + name=new_name, + active_persona=sessions[current_session].active_persona, + ) + _console.print(f"[green]Created session:[/green] {new_name}") + return 0, current_session + if tokens[1] == "switch": + if len(tokens) < 3: + _console.print("[red]Usage:[/red] /session switch ") + return 2, current_session + target_session = tokens[2] + if target_session not in sessions: + _console.print(f"[red]Session not found:[/red] {target_session}") + return 1, current_session + _console.print(f"[green]Switched session:[/green] {target_session}") + return 0, target_session + _console.print(f"[red]Unknown session command:[/red] {' '.join(tokens)}") + return 2, current_session + + _console.print(f"[red]Unknown slash command:[/red] /{' '.join(tokens)}") + return 2, current_session + + def dispatch_command(argv: Sequence[str]) -> int: """Invoke the main Typer CLI with the given argument vector. @@ -198,6 +643,13 @@ def run_repl( if use_history: _setup_history(history_path) _setup_completer() + persona_registry = PersonaRegistry() + default_persona = persona_registry.ensure_default_persona() + active_persona = persona_registry.get_active_persona_name() or default_persona.name + sessions: dict[str, _ReplSessionState] = { + "default": _ReplSessionState(name="default", active_persona=active_persona) + } + current_session = "default" _console.print( "[bold green]CleverAgents REPL[/bold green] — " @@ -209,7 +661,7 @@ def run_repl( exit_code = 0 while True: - prompt = _get_prompt_context() + prompt = _compose_prompt(_get_prompt_context(), sessions[current_session]) try: line = input(prompt) except EOFError: @@ -244,6 +696,33 @@ def run_repl( _print_repl_help() continue + # Shell mode + if line.startswith("!"): + last_command = line + exit_code = _run_shell_command(line[1:].strip()) + continue + + # Slash command mode + if line.startswith("/"): + last_command = line + exit_code, current_session = _handle_slash_command( + line[1:], + registry=persona_registry, + sessions=sessions, + current_session=current_session, + ) + continue + + # Normal mode @ references + expanded_line = _expand_references(line) + if expanded_line != line: + _console.print(f"[dim]{expanded_line}[/dim]") + elif "@" in line: + hints = _reference_suggestions(line) + if hints: + _console.print("[dim]Reference suggestions:[/dim] " + ", ".join(hints)) + line = expanded_line + # Tokenise and dispatch try: argv = shlex.split(line) diff --git a/src/cleveragents/cli/commands/tui.py b/src/cleveragents/cli/commands/tui.py new file mode 100644 index 000000000..d52f68c4b --- /dev/null +++ b/src/cleveragents/cli/commands/tui.py @@ -0,0 +1,25 @@ +"""CLI entrypoint for the Textual TUI.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from cleveragents.tui.commands import run_tui + +app = typer.Typer(help="Launch the Textual TUI.") + + +@app.callback(invoke_without_command=True) +def tui_callback( + headless: Annotated[ + bool, + typer.Option( + "--headless", + help="Run a one-shot headless startup check instead of full UI loop.", + ), + ] = False, +) -> None: + """Launch the CleverAgents TUI.""" + raise typer.Exit(run_tui(headless=headless)) diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index c1da3a6d7..008fe3a85 100644 --- a/src/cleveragents/cli/main.py +++ b/src/cleveragents/cli/main.py @@ -92,6 +92,7 @@ def _register_subcommands() -> None: session, skill, tool, + tui, validation, ) from cleveragents.cli.commands.auto_debug import app as auto_debug_app @@ -189,6 +190,11 @@ def _register_subcommands() -> None: name="repl", help="Start an interactive REPL session", ) + app.add_typer( + tui.app, + name="tui", + help="Launch the Textual TUI", + ) app.add_typer( server_app, name="server", @@ -226,6 +232,7 @@ def _print_basic_help() -> None: typer.echo(" apply Apply plan changes") typer.echo(" auto-debug Auto-debug operations") typer.echo(" repl Interactive REPL session") + typer.echo(" tui Textual terminal UI") typer.echo(" version Show version") typer.echo("") typer.echo("Actors: set a default with 'agents actor set-default '.") @@ -610,6 +617,7 @@ def main(args: list[str] | None = None) -> int: "automation-profile", # Automation profile management "invariant", # Invariant constraint management "repl", # Interactive REPL + "tui", # Textual TUI "server", # Server connection management "tell", # Shortcut for plan tell "build", # Shortcut for plan build diff --git a/src/cleveragents/cli/persona.py b/src/cleveragents/cli/persona.py new file mode 100644 index 000000000..aa1ca2c36 --- /dev/null +++ b/src/cleveragents/cli/persona.py @@ -0,0 +1,177 @@ +"""Persona schema and local YAML storage for interactive CLI/TUI usage. + +Personas are intentionally a presentation-layer concept. They are stored as +YAML files under the user config directory and never persisted in domain +models or database tables. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def _default_config_dir() -> Path: + from_env = os.environ.get("CLEVERAGENTS_CONFIG_DIR", "").strip() + if from_env: + return Path(from_env) + return Path.home() / ".config" / "cleveragents" + + +class PersonaPreset(BaseModel): + """Named argument overlay for a persona.""" + + name: str = Field(..., min_length=1) + display: str = Field(..., min_length=1) + overrides: dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True) + + +class PersonaConfig(BaseModel): + """Persona definition loaded from YAML.""" + + name: str = Field(..., min_length=1) + description: str = Field(default="") + icon: str = Field(default="") + actor: str = Field(..., min_length=1) + base_arguments: dict[str, Any] = Field(default_factory=dict) + scoped_projects: list[str] = Field(default_factory=list) + scoped_plans: list[str] = Field(default_factory=list) + argument_presets: list[PersonaPreset] = Field(default_factory=list) + cycle_order: int = Field(default=0, ge=0) + greeting: str = Field(default="") + + @field_validator("actor") + @classmethod + def _validate_actor(cls, value: str) -> str: + if "/" not in value: + raise ValueError("actor must be namespaced (namespace/name)") + return value + + @field_validator("argument_presets") + @classmethod + def _validate_default_preset( + cls, value: list[PersonaPreset] + ) -> list[PersonaPreset]: + if not value: + return [PersonaPreset(name="default", display="default", overrides={})] + defaults = [preset for preset in value if preset.name == "default"] + if len(defaults) != 1: + raise ValueError("persona must contain exactly one 'default' preset") + if defaults[0].overrides: + raise ValueError("'default' preset must have empty overrides") + return value + + model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True) + + +@dataclass(slots=True) +class PersonaRegistry: + """Loads and persists persona YAML files from user config space.""" + + config_dir: Path = field(default_factory=_default_config_dir) + + @property + def personas_dir(self) -> Path: + return self.config_dir / "personas" + + @property + def state_file(self) -> Path: + return self.config_dir / "tui-state.yaml" + + def ensure_dirs(self) -> None: + self.personas_dir.mkdir(parents=True, exist_ok=True) + + def persona_path(self, name: str) -> Path: + return self.personas_dir / f"{name}.yaml" + + def list_personas(self) -> list[PersonaConfig]: + self.ensure_dirs() + personas: list[PersonaConfig] = [] + for path in sorted(self.personas_dir.glob("*.y*ml")): + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + continue + personas.append(PersonaConfig.model_validate(raw)) + return personas + + def get_persona(self, name: str) -> PersonaConfig | None: + path = self.persona_path(name) + if not path.exists(): + return None + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + return None + return PersonaConfig.model_validate(raw) + + def save_persona(self, persona: PersonaConfig) -> Path: + self.ensure_dirs() + if persona.cycle_order > 0: + for existing in self.list_personas(): + if ( + existing.name != persona.name + and existing.cycle_order == persona.cycle_order + ): + raise ValueError( + "cycle_order must be unique across personas " + "with cycle_order > 0" + ) + path = self.persona_path(persona.name) + payload = persona.model_dump(mode="json", exclude_none=True) + path.write_text( + yaml.safe_dump(payload, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + return path + + def delete_persona(self, name: str) -> bool: + path = self.persona_path(name) + if not path.exists(): + return False + path.unlink() + return True + + def get_active_persona_name(self) -> str | None: + if not self.state_file.exists(): + return None + raw = yaml.safe_load(self.state_file.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + return None + active = raw.get("last_persona") + return active if isinstance(active, str) and active else None + + def set_active_persona_name(self, name: str) -> None: + self.ensure_dirs() + state: dict[str, Any] = {} + if self.state_file.exists(): + loaded = yaml.safe_load(self.state_file.read_text(encoding="utf-8")) or {} + if isinstance(loaded, dict): + state = dict(loaded) + state["last_persona"] = name + self.state_file.write_text( + yaml.safe_dump(state, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + + def ensure_default_persona( + self, actor: str = "local/mock-default" + ) -> PersonaConfig: + self.ensure_dirs() + existing = self.get_persona("default") + if existing is not None: + return existing + default_persona = PersonaConfig( + name="default", + description="Default persona", + actor=actor, + argument_presets=[PersonaPreset(name="default", display="default")], + ) + self.save_persona(default_persona) + self.set_active_persona_name(default_persona.name) + return default_persona diff --git a/src/cleveragents/tui/__init__.py b/src/cleveragents/tui/__init__.py new file mode 100644 index 000000000..bd33abd42 --- /dev/null +++ b/src/cleveragents/tui/__init__.py @@ -0,0 +1,11 @@ +"""Textual TUI package for CleverAgents.""" + +from __future__ import annotations + +from cleveragents.tui.app import CleverAgentsTuiApp +from cleveragents.tui.commands import run_tui + +__all__ = [ + "CleverAgentsTuiApp", + "run_tui", +] diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py new file mode 100644 index 000000000..918f2585e --- /dev/null +++ b/src/cleveragents/tui/app.py @@ -0,0 +1,179 @@ +"""Textual TUI application shell.""" + +from __future__ import annotations + +import importlib +from dataclasses import dataclass +from typing import Any, ClassVar, Protocol + +from cleveragents.tui.input.modes import InputMode, InputModeRouter +from cleveragents.tui.input.reference_parser import suggestions +from cleveragents.tui.persona.state import PersonaState +from cleveragents.tui.widgets.persona_bar import PersonaBar +from cleveragents.tui.widgets.prompt import PromptInput +from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay +from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay + +_TEXTUAL_AVAILABLE = False +_TextualApp: type[Any] = object +_Vertical: type[Any] = object +_Header: type[Any] = object +_Footer: type[Any] = object +_Static: type[Any] = object +try: # pragma: no branch - import gate for optional dependency + _textual_app = importlib.import_module("textual.app") + _textual_containers = importlib.import_module("textual.containers") + _textual_widgets = importlib.import_module("textual.widgets") + _TextualApp = _textual_app.App + _Vertical = _textual_containers.Vertical + _Header = _textual_widgets.Header + _Footer = _textual_widgets.Footer + _Static = _textual_widgets.Static + _TEXTUAL_AVAILABLE = True +except Exception: # pragma: no cover + _TEXTUAL_AVAILABLE = False + + +def textual_available() -> bool: + """Return whether Textual import succeeded.""" + return _TEXTUAL_AVAILABLE + + +@dataclass(slots=True) +class SessionView: + """Minimal per-session TUI view model.""" + + session_id: str + transcript: list[str] + + +class _CommandRouter(Protocol): + """Router protocol used by the TUI input-mode command handler.""" + + def handle(self, raw: str, *, session_id: str) -> str: + """Dispatch a slash command and return a rendered response.""" + ... + + +class _FallbackCleverAgentsTuiApp: # pragma: no cover + """Fallback app that raises actionable dependency error.""" + + def __init__( + self, *, command_router: _CommandRouter, persona_state: PersonaState + ) -> None: + del command_router, persona_state + + def run(self) -> None: + raise RuntimeError( + "Textual dependency missing. Install with: pip install 'cleveragents[tui]'" + ) + + +_ResolvedTuiApp: type[Any] = _FallbackCleverAgentsTuiApp +if _TEXTUAL_AVAILABLE: + + class _TextualCleverAgentsTuiApp(_TextualApp): + """Main TUI app.""" + + CSS_PATH: ClassVar[str] = "cleveragents.tcss" + BINDINGS: ClassVar[list[tuple[str, str, str]]] = [ + ("ctrl+q", "quit", "Quit"), + ("f1", "help", "Help"), + ("ctrl+t", "cycle_preset", "Cycle Preset"), + ] + + def __init__( + self, + *, + command_router: _CommandRouter, + persona_state: PersonaState, + ) -> None: + super().__init__() + self._command_router = command_router + self._persona_state = persona_state + self._session = SessionView(session_id="default", transcript=[]) + + def compose(self) -> Any: + yield _Header(show_clock=True) + with _Vertical(id="main-column"): + yield _Static("CleverAgents TUI", id="conversation") + yield ReferencePickerOverlay(id="reference-picker") + yield SlashCommandOverlay(id="slash-overlay") + yield PromptInput( + placeholder="Type message, /command, or !shell ...", id="prompt" + ) + yield PersonaBar(id="persona-bar") + yield _Footer() + + def on_mount(self) -> None: + self._refresh_persona_bar() + ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay) + ref_picker.set_suggestions("", []) + slash = self.query_one("#slash-overlay", SlashCommandOverlay) + slash.set_commands( + "", ["persona list", "persona set", "session show", "help"] + ) + + def action_help(self) -> None: + conversation = self.query_one("#conversation", _Static) + conversation.update("Hotkeys: F1 help, Ctrl+Q quit, Ctrl+T cycle preset") + + def action_cycle_preset(self) -> None: + self._persona_state.cycle_preset(self._session.session_id) + self._refresh_persona_bar() + + def _refresh_persona_bar(self) -> None: + persona = self._persona_state.active_persona(self._session.session_id) + preset = self._persona_state.current_preset(self._session.session_id) + scope_count = len(persona.scoped_projects) + len(persona.scoped_plans) + scope_text = f"{scope_count} scope refs" + bar = self.query_one("#persona-bar", PersonaBar) + bar.set_content( + persona_name=persona.name, + actor_name=persona.actor, + preset_name=preset, + scope_text=scope_text, + ) + + def on_input_submitted(self, event: object) -> None: + del event + prompt = self.query_one("#prompt", PromptInput) + payload = prompt.consume_text() + text = payload.text.strip() + if not text: + return + + mode_router = InputModeRouter( + command_handler=lambda raw: self._command_router.handle( + raw, session_id=self._session.session_id + ) + ) + result = mode_router.process(text) + conversation = self.query_one("#conversation", _Static) + + if result.mode == InputMode.COMMAND: + conversation.update(result.command_result or "") + self._refresh_persona_bar() + return + if result.mode == InputMode.SHELL: + shell = result.shell_result + if shell is None: + conversation.update("(no shell output)") + return + output = ( + shell.stdout.strip() or shell.stderr.strip() or "(empty output)" + ) + conversation.update(f"$ {shell.command}\n{output}") + return + + preview = result.expanded_text + if "@" in text: + ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay) + ref_picker.set_suggestions( + text, suggestions(text.replace("@", "").strip()) + ) + conversation.update(preview) + + _ResolvedTuiApp = _TextualCleverAgentsTuiApp + +CleverAgentsTuiApp = _ResolvedTuiApp diff --git a/src/cleveragents/tui/cleveragents.tcss b/src/cleveragents/tui/cleveragents.tcss new file mode 100644 index 000000000..003bf851a --- /dev/null +++ b/src/cleveragents/tui/cleveragents.tcss @@ -0,0 +1,44 @@ +Screen { + layout: vertical; +} + +#main-column { + layout: vertical; + height: 1fr; +} + +#conversation { + height: 1fr; + padding: 1 2; + border: heavy $primary; + background: $panel; +} + +#reference-picker { + height: auto; + max-height: 8; + padding: 0 1; + border: round $secondary; + color: $text-muted; +} + +#slash-overlay { + height: auto; + max-height: 8; + padding: 0 1; + border: round $accent; + color: $text; +} + +#prompt { + height: auto; + border: round $primary; + margin: 1 0 0 0; +} + +#persona-bar { + height: auto; + padding: 0 1; + color: $text-primary; + background: $foreground 5%; +} diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py new file mode 100644 index 000000000..cab175faa --- /dev/null +++ b/src/cleveragents/tui/commands.py @@ -0,0 +1,73 @@ +"""TUI command routing and CLI launch integration.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from cleveragents.application.container import get_container +from cleveragents.tui.app import CleverAgentsTuiApp, textual_available +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.state import PersonaState + + +@dataclass(slots=True) +class TuiCommandRouter: + """Slash command router used by the TUI prompt.""" + + persona_registry: PersonaRegistry + persona_state: PersonaState + + def handle(self, raw: str, *, session_id: str) -> str: + 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": + return "Commands: /persona, /session, /help" + return f"Unknown command: /{raw}" + + 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()] + return ", ".join(names) if names else "No personas" + if tokens[0] == "set": + if len(tokens) < 2: + return "Usage: /persona set " + persona = self.persona_state.set_active_persona(session_id, tokens[1]) + return f"Active persona: {persona.name}" + return f"Unknown persona command: {' '.join(tokens)}" + + @staticmethod + def _session_command(tokens: list[str], *, session_id: str) -> str: + if not tokens or tokens[0] == "show": + return f"Current session: {session_id}" + return f"Unknown session command: {' '.join(tokens)}" + + +def run_tui(*, headless: bool = False) -> int: + """Run the Textual TUI app or a headless startup check.""" + container = get_container() + _ = container.tui_adapters() + registry = PersonaRegistry() + state = PersonaState(registry=registry) + router = TuiCommandRouter(persona_registry=registry, persona_state=state) + + if headless: + payload = { + "textual_available": textual_available(), + "persona_count": len(registry.list_personas()) + if registry.personas_dir.exists() + else 0, + "default_persona": state.active_name("default"), + "help": router.handle("help", session_id="default"), + } + print(json.dumps(payload, indent=2)) + return 0 + + app = CleverAgentsTuiApp(command_router=router, persona_state=state) + app.run() + return 0 diff --git a/src/cleveragents/tui/input/__init__.py b/src/cleveragents/tui/input/__init__.py new file mode 100644 index 000000000..5e79fc258 --- /dev/null +++ b/src/cleveragents/tui/input/__init__.py @@ -0,0 +1,22 @@ +"""Input handling for TUI.""" + +from cleveragents.tui.input.modes import InputMode, InputModeRouter, ModeResult +from cleveragents.tui.input.reference_parser import ( + ReferenceMatch, + ReferenceParseResult, + parse_references, + suggestions, +) +from cleveragents.tui.input.shell_exec import ShellResult, run_shell_command + +__all__ = [ + "InputMode", + "InputModeRouter", + "ModeResult", + "ReferenceMatch", + "ReferenceParseResult", + "ShellResult", + "parse_references", + "run_shell_command", + "suggestions", +] diff --git a/src/cleveragents/tui/input/modes.py b/src/cleveragents/tui/input/modes.py new file mode 100644 index 000000000..920fdf121 --- /dev/null +++ b/src/cleveragents/tui/input/modes.py @@ -0,0 +1,80 @@ +"""Input mode router for TUI prompt handling.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from cleveragents.tui.input.reference_parser import ( + ReferenceParseResult, + parse_references, +) +from cleveragents.tui.input.shell_exec import ShellResult, run_shell_command + + +class InputMode(StrEnum): + """Prompt input mode.""" + + NORMAL = "normal" + COMMAND = "command" + SHELL = "shell" + + +@dataclass(slots=True, frozen=True) +class ModeResult: + """Mode-router output.""" + + mode: InputMode + expanded_text: str + references: list[str] + shell_result: ShellResult | None + command_result: str | None + + +class InputModeRouter: + """Dispatch user input to normal/command/shell handlers.""" + + def __init__(self, command_handler: Callable[[str], str]) -> None: + self._command_handler = command_handler + + @staticmethod + def detect_mode(text: str) -> InputMode: + stripped = text.lstrip() + if stripped.startswith("/"): + return InputMode.COMMAND + if stripped.startswith("!"): + return InputMode.SHELL + return InputMode.NORMAL + + def process(self, text: str) -> ModeResult: + mode = self.detect_mode(text) + if mode == InputMode.COMMAND: + cmd = text.lstrip()[1:] + result = self._command_handler(cmd) + return ModeResult( + mode=mode, + expanded_text=text, + references=[], + shell_result=None, + command_result=result, + ) + if mode == InputMode.SHELL: + command = text.lstrip()[1:].strip() + shell_result = run_shell_command(command) + return ModeResult( + mode=mode, + expanded_text=text, + references=[], + shell_result=shell_result, + command_result=None, + ) + + parse_result: ReferenceParseResult = parse_references(text) + return ModeResult( + mode=mode, + expanded_text=parse_result.expanded_line, + references=[match.canonical for match in parse_result.matches], + shell_result=None, + command_result=None, + ) diff --git a/src/cleveragents/tui/input/reference_parser.py b/src/cleveragents/tui/input/reference_parser.py new file mode 100644 index 000000000..af0075b02 --- /dev/null +++ b/src/cleveragents/tui/input/reference_parser.py @@ -0,0 +1,156 @@ +"""Reference parsing and canonical expansion for @ tokens.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from cleveragents.tui.search.fuzzy import rank_candidates + +_REFERENCE_TYPES = ("project", "plan", "resource", "actor", "tool", "skill") + + +@dataclass(slots=True, frozen=True) +class ReferenceMatch: + """Represents one resolved reference token.""" + + token: str + canonical: str + category: str + + +@dataclass(slots=True) +class ReferenceParseResult: + """Result of parsing a line for references.""" + + expanded_line: str + matches: list[ReferenceMatch] + unresolved_tokens: list[str] + + +def _catalog() -> dict[str, list[str]]: + cwd = Path.cwd() + files = [] + for path in cwd.rglob("*"): + if path.is_file(): + try: + files.append(path.relative_to(cwd).as_posix()) + except ValueError: + files.append(path.as_posix()) + if len(files) >= 400: + break + + return { + "resource": sorted(files), + "project": [value for value in [cwd.name] if value], + "plan": [], + "actor": sorted( + f"local/{file.stem}" + for file in (cwd / "examples" / "actors").glob("*.y*ml") + ) + if (cwd / "examples" / "actors").is_dir() + else [], + "tool": sorted( + f"local/{file.stem}" for file in (cwd / "examples" / "tools").glob("*.y*ml") + ) + if (cwd / "examples" / "tools").is_dir() + else [], + "skill": sorted( + f"local/{entry.name}" + for entry in (cwd / "examples" / "skills").iterdir() + if entry.is_dir() + ) + if (cwd / "examples" / "skills").is_dir() + else [], + } + + +def _resolve(category: str, query: str, items: dict[str, list[str]]) -> str | None: + values = items.get(category, []) + ranked = rank_candidates(query, values, limit=1) + if not ranked: + return None + return ranked[0].value + + +def parse_references(line: str) -> ReferenceParseResult: + """Expand @tokens and return canonical forms.""" + items = _catalog() + parts = line.split() + expanded: list[str] = [] + matches: list[ReferenceMatch] = [] + unresolved: list[str] = [] + + for part in parts: + if part.startswith("\\@"): + expanded.append(part[1:]) + continue + if not part.startswith("@") or part == "@": + expanded.append(part) + continue + + raw = part[1:] + chosen_category = "" + query = raw + if ":" in raw: + category, rest = raw.split(":", 1) + if category in _REFERENCE_TYPES: + chosen_category = category + query = rest + + if chosen_category: + resolved = _resolve(chosen_category, query, items) + if resolved: + canonical = f"@{chosen_category}:{resolved}" + expanded.append(canonical) + matches.append( + ReferenceMatch( + token=part, canonical=canonical, category=chosen_category + ) + ) + else: + expanded.append(part) + unresolved.append(part) + continue + + resolved_category = "" + resolved_value = "" + for category in _REFERENCE_TYPES: + candidate = _resolve(category, raw, items) + if candidate: + resolved_category = category + resolved_value = candidate + break + if resolved_category: + canonical = f"@{resolved_category}:{resolved_value}" + expanded.append(canonical) + matches.append( + ReferenceMatch( + token=part, canonical=canonical, category=resolved_category + ) + ) + else: + expanded.append(part) + unresolved.append(part) + + return ReferenceParseResult( + expanded_line=" ".join(expanded), + matches=matches, + unresolved_tokens=unresolved, + ) + + +def suggestions( + query: str, *, category: str | None = None, limit: int = 8 +) -> list[str]: + """Return canonical suggestion strings for a query.""" + items = _catalog() + target_categories = [category] if category else list(_REFERENCE_TYPES) + result: list[str] = [] + for target in target_categories: + if target is None or target not in items: + continue + ranked = rank_candidates(query, items[target], limit=limit) + for candidate in ranked: + result.append(f"@{target}:{candidate.value}") + return result[:limit] diff --git a/src/cleveragents/tui/input/shell_exec.py b/src/cleveragents/tui/input/shell_exec.py new file mode 100644 index 000000000..552b9b7db --- /dev/null +++ b/src/cleveragents/tui/input/shell_exec.py @@ -0,0 +1,44 @@ +"""Shell mode execution helpers for the TUI.""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + + +@dataclass(slots=True, frozen=True) +class ShellResult: + """Structured shell execution result.""" + + command: str + exit_code: int + stdout: str + stderr: str + + +def looks_dangerous(command: str) -> bool: + """Best-effort dangerous command detector.""" + lowered = command.strip().lower() + patterns = ( + "rm -rf /", + "git push --force", + "mkfs.", + "dd if=", + ":(){:|:&};:", + ) + return any(pattern in lowered for pattern in patterns) + + +def run_shell_command(command: str) -> ShellResult: + """Execute shell command and capture outputs.""" + if not command.strip(): + return ShellResult( + command=command, exit_code=2, stdout="", stderr="empty command" + ) + proc = subprocess.run(command, shell=True, text=True, capture_output=True) # nosec B602 + return ShellResult( + command=command, + exit_code=proc.returncode, + stdout=proc.stdout or "", + stderr=proc.stderr or "", + ) diff --git a/src/cleveragents/tui/persona/__init__.py b/src/cleveragents/tui/persona/__init__.py new file mode 100644 index 000000000..b54a130bc --- /dev/null +++ b/src/cleveragents/tui/persona/__init__.py @@ -0,0 +1,12 @@ +"""Persona support for TUI.""" + +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.schema import Persona, PersonaPreset +from cleveragents.tui.persona.state import PersonaState + +__all__ = [ + "Persona", + "PersonaPreset", + "PersonaRegistry", + "PersonaState", +] diff --git a/src/cleveragents/tui/persona/registry.py b/src/cleveragents/tui/persona/registry.py new file mode 100644 index 000000000..6e94fa1cb --- /dev/null +++ b/src/cleveragents/tui/persona/registry.py @@ -0,0 +1,136 @@ +"""Persona registry backed by local YAML files.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +from cleveragents.tui.persona.schema import Persona + + +def _default_config_dir() -> Path: + override = os.environ.get("CLEVERAGENTS_CONFIG_DIR", "").strip() + if override: + return Path(override) + return Path.home() / ".config" / "cleveragents" + + +@dataclass(slots=True) +class PersonaRegistry: + """Load and persist TUI-only personas.""" + + config_dir: Path = field(default_factory=_default_config_dir) + + @property + def personas_dir(self) -> Path: + return self.config_dir / "personas" + + @property + def state_path(self) -> Path: + return self.config_dir / "tui-state.yaml" + + def ensure_dirs(self) -> None: + self.personas_dir.mkdir(parents=True, exist_ok=True) + + def list_personas(self) -> list[Persona]: + self.ensure_dirs() + personas: list[Persona] = [] + for file in sorted(self.personas_dir.glob("*.y*ml")): + raw = yaml.safe_load(file.read_text(encoding="utf-8")) or {} + if isinstance(raw, dict): + personas.append(Persona.model_validate(raw)) + return personas + + def get(self, name: str) -> Persona | None: + file = self.personas_dir / f"{name}.yaml" + if not file.exists(): + return None + raw = yaml.safe_load(file.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + return None + return Persona.model_validate(raw) + + def _validate_cycle_order(self, persona: Persona) -> None: + if persona.cycle_order <= 0: + return + for existing in self.list_personas(): + if existing.name == persona.name: + continue + if existing.cycle_order == persona.cycle_order: + raise ValueError("cycle_order must be unique when greater than zero") + + def save(self, persona: Persona) -> Path: + self.ensure_dirs() + self._validate_cycle_order(persona) + file = self.personas_dir / f"{persona.name}.yaml" + payload = persona.model_dump(mode="json", exclude_none=True) + file.write_text( + yaml.safe_dump(payload, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + return file + + def delete(self, name: str) -> bool: + file = self.personas_dir / f"{name}.yaml" + if not file.exists(): + return False + file.unlink() + return True + + def export_persona(self, name: str, output_path: Path) -> Path: + persona = self.get(name) + if persona is None: + raise ValueError(f"Persona not found: {name}") + payload = persona.model_dump(mode="json", exclude_none=True) + output_path.write_text( + yaml.safe_dump(payload, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + return output_path + + def import_persona(self, input_path: Path) -> Persona: + raw = yaml.safe_load(input_path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + raise ValueError("Invalid persona file") + persona = Persona.model_validate(raw) + self.save(persona) + return persona + + def load_state(self) -> dict[str, Any]: + if not self.state_path.exists(): + return {} + raw = yaml.safe_load(self.state_path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + return {} + return dict(raw) + + def save_state(self, state: dict[str, Any]) -> None: + self.ensure_dirs() + self.state_path.write_text( + yaml.safe_dump(state, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + + def get_last_persona(self) -> str | None: + state = self.load_state() + value = state.get("last_persona") + return value if isinstance(value, str) and value else None + + def set_last_persona(self, name: str) -> None: + state = self.load_state() + state["last_persona"] = name + self.save_state(state) + + def ensure_default(self, actor: str = "local/mock-default") -> Persona: + existing = self.get("default") + if existing is not None: + return existing + persona = Persona(name="default", actor=actor, description="Default persona") + saved = Persona.model_validate(persona.model_dump()) + self.save(saved) + self.set_last_persona(saved.name) + return saved diff --git a/src/cleveragents/tui/persona/schema.py b/src/cleveragents/tui/persona/schema.py new file mode 100644 index 000000000..1692dc49f --- /dev/null +++ b/src/cleveragents/tui/persona/schema.py @@ -0,0 +1,85 @@ +"""TUI persona schema models.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +class PersonaPreset(BaseModel): + """Named argument override preset.""" + + name: str = Field(..., min_length=1) + display: str = Field(..., min_length=1) + overrides: dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True) + + +class Persona(BaseModel): + """Primary persona record persisted as YAML.""" + + name: str = Field(..., min_length=1) + description: str = Field(default="") + icon: str = Field(default="") + actor: str = Field(..., min_length=1) + color: str | None = None + base_arguments: dict[str, Any] = Field(default_factory=dict) + scoped_projects: list[str] = Field(default_factory=list) + scoped_plans: list[str] = Field(default_factory=list) + argument_presets: list[PersonaPreset] = Field(default_factory=list) + cycle_order: int = Field(default=0, ge=0) + greeting: str = Field(default="") + + @field_validator("actor") + @classmethod + def validate_actor_ref(cls, value: str) -> str: + if "/" not in value: + raise ValueError("actor must be namespaced (namespace/name)") + return value + + @field_validator("argument_presets") + @classmethod + def validate_default_preset( + cls, presets: list[PersonaPreset] + ) -> list[PersonaPreset]: + if not presets: + return presets + defaults = [preset for preset in presets if preset.name == "default"] + if len(defaults) != 1: + raise ValueError("must contain exactly one default preset") + if defaults[0].overrides: + raise ValueError("default preset overrides must be empty") + return presets + + @model_validator(mode="after") + def ensure_default_preset(self) -> Persona: + if not self.argument_presets: + self.argument_presets = [ + PersonaPreset(name="default", display="default", overrides={}) + ] + return self + defaults = [ + preset for preset in self.argument_presets if preset.name == "default" + ] + if len(defaults) != 1: + raise ValueError("must contain exactly one default preset") + if defaults[0].overrides: + raise ValueError("default preset overrides must be empty") + return self + + def effective_arguments(self, preset_name: str | None = None) -> dict[str, Any]: + """Compute effective arguments with shallow merge semantics.""" + effective = dict(self.base_arguments) + if not preset_name: + return effective + preset = next( + (item for item in self.argument_presets if item.name == preset_name), None + ) + if preset is None: + return effective + effective.update(preset.overrides) + return effective + + model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True) diff --git a/src/cleveragents/tui/persona/state.py b/src/cleveragents/tui/persona/state.py new file mode 100644 index 000000000..c11fa3fcb --- /dev/null +++ b/src/cleveragents/tui/persona/state.py @@ -0,0 +1,69 @@ +"""In-memory TUI session-to-persona state management.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.schema import Persona + + +@dataclass(slots=True) +class PersonaState: + """Tracks active persona per TUI session.""" + + registry: PersonaRegistry + active_by_session: dict[str, str] = field(default_factory=dict) + preset_by_session: dict[str, str] = field(default_factory=dict) + + def _resolve_default_name(self) -> str: + default = self.registry.ensure_default() + return self.registry.get_last_persona() or default.name + + def active_name(self, session_id: str) -> str: + if session_id not in self.active_by_session: + self.active_by_session[session_id] = self._resolve_default_name() + return self.active_by_session[session_id] + + def active_persona(self, session_id: str) -> Persona: + name = self.active_name(session_id) + persona = self.registry.get(name) + if persona is None: + persona = self.registry.ensure_default() + self.active_by_session[session_id] = persona.name + return persona + + def set_active_persona(self, session_id: str, persona_name: str) -> Persona: + persona = self.registry.get(persona_name) + if persona is None: + raise ValueError(f"Unknown persona: {persona_name}") + self.active_by_session[session_id] = persona.name + self.registry.set_last_persona(persona.name) + if session_id not in self.preset_by_session: + self.preset_by_session[session_id] = "default" + return persona + + def current_preset(self, session_id: str) -> str: + if session_id not in self.preset_by_session: + self.preset_by_session[session_id] = "default" + return self.preset_by_session[session_id] + + def cycle_preset(self, session_id: str) -> str: + persona = self.active_persona(session_id) + if not persona.argument_presets: + self.preset_by_session[session_id] = "default" + return "default" + names = [item.name for item in persona.argument_presets] + current = self.current_preset(session_id) + if current not in names: + self.preset_by_session[session_id] = names[0] + return names[0] + idx = names.index(current) + next_name = names[(idx + 1) % len(names)] + self.preset_by_session[session_id] = next_name + return next_name + + def effective_arguments(self, session_id: str) -> dict[str, object]: + persona = self.active_persona(session_id) + preset = self.current_preset(session_id) + return persona.effective_arguments(preset) diff --git a/src/cleveragents/tui/search/__init__.py b/src/cleveragents/tui/search/__init__.py new file mode 100644 index 000000000..c38bb9a19 --- /dev/null +++ b/src/cleveragents/tui/search/__init__.py @@ -0,0 +1,9 @@ +"""Search utilities for TUI pickers.""" + +from cleveragents.tui.search.fuzzy import FuzzyCandidate, rank_candidates, score_match + +__all__ = [ + "FuzzyCandidate", + "rank_candidates", + "score_match", +] diff --git a/src/cleveragents/tui/search/fuzzy.py b/src/cleveragents/tui/search/fuzzy.py new file mode 100644 index 000000000..38dae7c34 --- /dev/null +++ b/src/cleveragents/tui/search/fuzzy.py @@ -0,0 +1,57 @@ +"""Weighted fuzzy search helpers for TUI pickers.""" + +from __future__ import annotations + +import difflib +from dataclasses import dataclass + + +@dataclass(slots=True, frozen=True) +class FuzzyCandidate: + """Result candidate with score metadata.""" + + value: str + score: float + reason: str + + +def score_match(query: str, value: str) -> FuzzyCandidate | None: + """Score a query against value using ADR-aligned weighting.""" + if not query: + return FuzzyCandidate(value=value, score=0.0, reason="empty") + if value.startswith(query): + return FuzzyCandidate(value=value, score=1.0, reason="prefix") + if query in value: + return FuzzyCandidate(value=value, score=0.7, reason="substring") + query_parts = [part for part in query.split("/") if part] + if query_parts and all(part in value for part in query_parts): + return FuzzyCandidate(value=value, score=0.8, reason="path-component") + ratio = difflib.SequenceMatcher(a=query, b=value).ratio() + if ratio >= 0.4: + return FuzzyCandidate(value=value, score=0.4 * ratio, reason="fuzzy") + return None + + +def rank_candidates( + query: str, + values: list[str], + *, + recency: dict[str, int] | None = None, + limit: int = 20, +) -> list[FuzzyCandidate]: + """Return ranked fuzzy candidates with deterministic tie-breaking.""" + scored: list[FuzzyCandidate] = [] + for value in values: + candidate = score_match(query, value) + if candidate is not None: + scored.append(candidate) + + recency_map = recency or {} + scored.sort( + key=lambda item: ( + -item.score, + -recency_map.get(item.value, 0), + item.value, + ) + ) + return scored[:limit] diff --git a/src/cleveragents/tui/widgets/__init__.py b/src/cleveragents/tui/widgets/__init__.py new file mode 100644 index 000000000..bdf0efcb0 --- /dev/null +++ b/src/cleveragents/tui/widgets/__init__.py @@ -0,0 +1,14 @@ +"""Widget collection for CleverAgents TUI.""" + +from cleveragents.tui.widgets.persona_bar import PersonaBar +from cleveragents.tui.widgets.prompt import PromptInput, PromptSubmitted +from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay +from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay + +__all__ = [ + "PersonaBar", + "PromptInput", + "PromptSubmitted", + "ReferencePickerOverlay", + "SlashCommandOverlay", +] diff --git a/src/cleveragents/tui/widgets/persona_bar.py b/src/cleveragents/tui/widgets/persona_bar.py new file mode 100644 index 000000000..d14ec1b5c --- /dev/null +++ b/src/cleveragents/tui/widgets/persona_bar.py @@ -0,0 +1,38 @@ +"""Persona bar widget.""" + +from __future__ import annotations + +import importlib +from typing import Any + + +def _load_static_base() -> type[Any]: + try: + return importlib.import_module("textual.widgets").Static + except Exception: # pragma: no cover - optional dependency + + class _FallbackStatic: + def __init__(self, *args: object, **kwargs: object) -> None: + self._text = "" + + def update(self, text: str) -> None: + self._text = text + + return _FallbackStatic + + +_StaticBase = _load_static_base() + + +class PersonaBar(_StaticBase): + """Displays active persona and preset metadata.""" + + def set_content( + self, + *, + persona_name: str, + actor_name: str, + preset_name: str, + scope_text: str, + ) -> None: + self.update(f"{persona_name} | {actor_name} | {preset_name} | {scope_text}") diff --git a/src/cleveragents/tui/widgets/prompt.py b/src/cleveragents/tui/widgets/prompt.py new file mode 100644 index 000000000..f7d1943b1 --- /dev/null +++ b/src/cleveragents/tui/widgets/prompt.py @@ -0,0 +1,40 @@ +"""Prompt widget and submitted message event.""" + +from __future__ import annotations + +import importlib +from dataclasses import dataclass +from typing import Any + + +def _load_input_base() -> type[Any]: + try: + return importlib.import_module("textual.widgets").Input + except Exception: # pragma: no cover + + class _FallbackInput: + value = "" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.value = "" + + return _FallbackInput + + +_InputBase = _load_input_base() + + +@dataclass(slots=True, frozen=True) +class PromptSubmitted: + """Submitted prompt payload.""" + + text: str + + +class PromptInput(_InputBase): + """Input widget wrapper with helper methods.""" + + def consume_text(self) -> PromptSubmitted: + text = self.value + self.value = "" + return PromptSubmitted(text=text) diff --git a/src/cleveragents/tui/widgets/reference_picker.py b/src/cleveragents/tui/widgets/reference_picker.py new file mode 100644 index 000000000..9d777db2c --- /dev/null +++ b/src/cleveragents/tui/widgets/reference_picker.py @@ -0,0 +1,37 @@ +"""Reference picker overlay widget.""" + +from __future__ import annotations + +import importlib +from typing import Any + + +def _load_static_base() -> type[Any]: + try: + return importlib.import_module("textual.widgets").Static + except Exception: # pragma: no cover + + class _FallbackStatic: + def __init__(self, *args: object, **kwargs: object) -> None: + self._text = "" + + def update(self, text: str) -> None: + self._text = text + + return _FallbackStatic + + +_StaticBase = _load_static_base() + + +class ReferencePickerOverlay(_StaticBase): + """Simple overlay displaying reference suggestions.""" + + def set_suggestions(self, query: str, suggestions: list[str]) -> None: + if not suggestions: + self.update(f"@{query}\n(no matches)") + return + lines = [f"@{query}"] + for item in suggestions[:10]: + lines.append(f" {item}") + self.update("\n".join(lines)) diff --git a/src/cleveragents/tui/widgets/slash_command_overlay.py b/src/cleveragents/tui/widgets/slash_command_overlay.py new file mode 100644 index 000000000..7b0a823eb --- /dev/null +++ b/src/cleveragents/tui/widgets/slash_command_overlay.py @@ -0,0 +1,39 @@ +"""Slash command overlay widget.""" + +from __future__ import annotations + +import importlib +from typing import Any + + +def _load_static_base() -> type[Any]: + try: + return importlib.import_module("textual.widgets").Static + except Exception: # pragma: no cover + + class _FallbackStatic: + def __init__(self, *args: object, **kwargs: object) -> None: + self._text = "" + + def update(self, text: str) -> None: + self._text = text + + return _FallbackStatic + + +_StaticBase = _load_static_base() + + +class SlashCommandOverlay(_StaticBase): + """Renderable list of slash command candidates.""" + + def set_commands(self, query: str, commands: list[str]) -> None: + filtered = ( + [item for item in commands if item.startswith(query)] if query else commands + ) + lines = [f"/{query}"] + for command in filtered[:12]: + lines.append(f" /{command}") + if len(lines) == 1: + lines.append(" (no commands)") + self.update("\n".join(lines)) -- 2.52.0 From d9e51d98f83db8d656319bb7519700379d64fa58 Mon Sep 17 00:00:00 2001 From: Aditya Chhabra Date: Tue, 17 Mar 2026 13:34:51 +0000 Subject: [PATCH 2/3] fix(tui,cli,tests): harden persona/input modes and stabilize parallel test execution - Add shell safety controls for REPL/TUI (`looks_dangerous`, confirmation gate, timeout handling, and env-based shell disable guard). - Secure persona workflows with strict name/path validation, safe import/export resolution, atomic+locked registry writes, and malformed YAML resilience. - Unify persona models and wiring by reusing canonical TUI schema/registry, adding DI providers, and lazy-loading TUI exports to avoid circular imports. - Improve reference discovery with ignored-directory filtering, symlink-safe walking, and TTL caching for CLI/TUI reference catalogs. - Expand Behave/Robot coverage for safety/error paths and parallel-isolation behavior; add shared `features/mocks/fake_repl_input.py` helper. - Fix parallel-run flakiness via deterministic cleanup/reload patterns and watchdog polling fallback when inotify limits are reached. ISSUES CLOSED: #695 --- asv/benchmarks/tui_reference_fuzzy_bench.py | 11 +- features/mocks/fake_repl_input.py | 22 +++ features/repl_input_modes.feature | 82 ++++++++- features/steps/m1_sourcecode_smoke_steps.py | 41 ++--- .../m4_correction_subplan_smoke_steps.py | 33 ++-- features/steps/plan_lifecycle_cli_steps.py | 4 +- features/steps/repl_input_modes_steps.py | 100 +++++++++-- features/steps/repl_steps.py | 20 +-- features/steps/tui_input_modes_steps.py | 78 +++++++- features/steps/tui_persona_system_steps.py | 112 +++++++++++- features/tui_input_modes.feature | 19 +- features/tui_persona_system.feature | 36 ++++ robot/tui_smoke.robot | 32 ++++ src/cleveragents/application/container.py | 6 + .../services/resource_file_watcher.py | 93 +++++++--- src/cleveragents/cli/commands/repl.py | 129 ++++++++++--- src/cleveragents/cli/commands/tui.py | 5 +- src/cleveragents/cli/persona.py | 170 +++--------------- src/cleveragents/tui/__init__.py | 9 +- src/cleveragents/tui/app.py | 16 +- src/cleveragents/tui/commands.py | 5 +- src/cleveragents/tui/input/modes.py | 16 +- .../tui/input/reference_parser.py | 47 +++-- src/cleveragents/tui/input/shell_exec.py | 51 +++++- src/cleveragents/tui/persona/registry.py | 121 ++++++++++--- src/cleveragents/tui/persona/schema.py | 23 ++- 26 files changed, 927 insertions(+), 354 deletions(-) create mode 100644 features/mocks/fake_repl_input.py diff --git a/asv/benchmarks/tui_reference_fuzzy_bench.py b/asv/benchmarks/tui_reference_fuzzy_bench.py index e8497ac8d..9acc25f76 100644 --- a/asv/benchmarks/tui_reference_fuzzy_bench.py +++ b/asv/benchmarks/tui_reference_fuzzy_bench.py @@ -10,14 +10,17 @@ from cleveragents.tui.search.fuzzy import rank_candidates class TuiReferenceFuzzyBench: """Bench weighted fuzzy ranking on a medium candidate set.""" - params: ClassVar[list[int]] = [1_000, 10_000] - param_names: ClassVar[list[str]] = ["candidate_count"] + params: ClassVar[list[list[object]]] = [ + [1_000, 10_000], + ["module_42/file_42", "zzzzzz-no-match-query"], + ] + param_names: ClassVar[list[str]] = ["candidate_count", "query"] def setup(self, candidate_count: int) -> None: self.values = [ f"src/module_{idx}/file_{idx}.py" for idx in range(candidate_count) ] - def time_rank_candidates(self, candidate_count: int) -> None: + def time_rank_candidates(self, candidate_count: int, query: str) -> None: del candidate_count - rank_candidates("module_42/file_42", self.values, limit=20) + rank_candidates(query, self.values, limit=20) diff --git a/features/mocks/fake_repl_input.py b/features/mocks/fake_repl_input.py new file mode 100644 index 000000000..16e06f760 --- /dev/null +++ b/features/mocks/fake_repl_input.py @@ -0,0 +1,22 @@ +"""Shared fake input helper for REPL Behave scenarios.""" + +from __future__ import annotations + + +class FakeReplInput: + """Feed predefined lines and then terminate input with EOFError.""" + + def __init__(self, lines: list[str], *, interrupt_token: str | None = None) -> None: + self._lines = list(lines) + self._index = 0 + self._interrupt_token = interrupt_token + + def __call__(self, prompt: str = "") -> str: + del prompt + if self._index >= len(self._lines): + raise EOFError + line = self._lines[self._index] + self._index += 1 + if self._interrupt_token is not None and line == self._interrupt_token: + raise KeyboardInterrupt + return line diff --git a/features/repl_input_modes.feature b/features/repl_input_modes.feature index 2f75d8305..f40524afb 100644 --- a/features/repl_input_modes.feature +++ b/features/repl_input_modes.feature @@ -17,11 +17,20 @@ Feature: REPL input modes and persona controls Scenario: Shell mode runs host shell commands Given a temporary REPL config directory When I run the REPL with input lines - | line | - | !printf hello | + | line | + | !echo hello | Then the REPL mode output should contain "hello" + Scenario: Shell mode blocks dangerous commands without confirmation + Given a temporary REPL config directory + When I run the REPL with input lines + | line | + | !rm -rf / | + | n | + Then the REPL mode output should contain "Blocked dangerous shell command." + Scenario: @ references are expanded with canonical category prefixes + Given a deterministic REPL reference catalog fixture When I expand REPL references for "info @README.md" Then the expanded REPL line should contain "@file:README.md" @@ -33,6 +42,14 @@ Feature: REPL input modes and persona controls | /persona reviewer | Then the REPL mode output should contain "Active persona: reviewer" + Scenario: CLI persona model rejects ANSI/control characters in names + When I validate CLI persona name "bad\\x1bname" + Then CLI persona validation should fail with "path/control" + + Scenario: CLI persona model preserves color field + When I validate CLI persona payload with color "red" + Then CLI persona color should be "red" + Scenario: Persona export import and delete workflow Given a temporary REPL config directory And a temporary persona export path @@ -48,6 +65,38 @@ Feature: REPL input modes and persona controls And the REPL mode output should contain "Imported persona: dev" And the REPL mode output should contain "Active persona: dev" + Scenario: Persona create rejects path traversal names + Given a temporary REPL config directory + When I run the REPL with input lines + | line | + | /persona create ../../etc/cron.d/evil --actor local/mock-default | + Then the REPL mode output should contain "name must not contain path/control separators" + + Scenario: Persona export rejects absolute path targets + Given a temporary REPL config directory + And an absolute persona export path + When I run the REPL with input lines + | line | + | /persona create dev --actor local/mock-default | + | /persona export dev {export_path} | + Then the REPL mode output should contain "Export path must be relative to current working directory" + + Scenario: Persona export rejects parent directory escape + Given a temporary REPL config directory + When I run the REPL with input lines + | line | + | /persona create dev --actor local/mock-default | + | /persona export dev ../outside.yaml | + Then the REPL mode output should contain "Export path must stay within working directory" + + Scenario: Persona import rejects absolute path targets + Given a temporary REPL config directory + And an absolute persona import file path + When I run the REPL with input lines + | line | + | /persona import {absolute_import_path} | + Then the REPL mode output should contain "Import path must be relative to current working directory" + Scenario: Persona binding is independent per REPL session Given a temporary REPL config directory When I run the REPL with input lines @@ -62,3 +111,32 @@ Feature: REPL input modes and persona controls | /session list | Then the REPL mode output should contain "work (persona=dev)" And the REPL mode output should contain "default (persona=reviewer)" + + Scenario: Deleting persona resets all sessions using it + Given a temporary REPL config directory + When I run the REPL with input lines + | line | + | /persona create dev --actor local/mock-default | + | /session new work | + | /session switch work | + | /persona set dev | + | /session switch default | + | /persona set dev | + | /persona delete dev | + | /session list | + Then the REPL mode output should contain "work (persona=default)" + And the REPL mode output should contain "default (persona=default)" + + Scenario: Slash persona commands surface validation and usage errors + Given a temporary REPL config directory + And an invalid persona import file path + When I run the REPL with input lines + | line | + | /persona set missing | + | /persona create dev | + | /persona delete default | + | /persona import {invalid_import_path} | + Then the REPL mode output should contain "Persona not found: missing" + And the REPL mode output should contain "Usage: /persona create --actor " + And the REPL mode output should contain "Cannot delete default persona." + And the REPL mode output should contain "Invalid persona YAML." diff --git a/features/steps/m1_sourcecode_smoke_steps.py b/features/steps/m1_sourcecode_smoke_steps.py index 2b58f424c..0e5383486 100644 --- a/features/steps/m1_sourcecode_smoke_steps.py +++ b/features/steps/m1_sourcecode_smoke_steps.py @@ -10,6 +10,7 @@ import contextlib import json from datetime import datetime from pathlib import Path +from typing import Any from unittest.mock import MagicMock, patch import yaml @@ -35,24 +36,6 @@ _FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "m1" _PLAN_ULID = "01M1SM0KE00000000000000001" -def _register_patcher_cleanup(context: Context, patcher: object) -> None: - """Register a patcher's stop() with Behave scenario cleanup.""" - stop = getattr(patcher, "stop", None) - if not callable(stop): - return - - def _safe_stop() -> None: - with contextlib.suppress(RuntimeError): - stop() - - if hasattr(context, "add_cleanup"): - context.add_cleanup(_safe_stop) - return - if not hasattr(context, "_cleanup_handlers"): - context._cleanup_handlers = [] - context._cleanup_handlers.append(_safe_stop) - - def _make_m1_plan( *, name: str = "local/m1-smoke-plan", @@ -105,6 +88,18 @@ def _make_m1_action( ) +def _register_patcher_cleanup(context: Context, patcher: Any) -> None: + """Start a patcher and ensure it is always stopped.""" + patcher.start() + context.add_cleanup(_safe_stop_patcher, patcher) + + +def _safe_stop_patcher(patcher: Any) -> None: + """Best-effort patcher stop for repeated/failed scenarios.""" + with contextlib.suppress(RuntimeError): + patcher.stop() + + # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @@ -153,16 +148,14 @@ def step_m1_smoke_mock_service(context: Context) -> None: context.link_patcher = patch( "cleveragents.cli.commands.project._get_resource_link_repo", ) - context.plan_patcher.start() _register_patcher_cleanup(context, context.plan_patcher) - context.action_patcher.start() _register_patcher_cleanup(context, context.action_patcher) context.mock_project_repo = context.project_patcher.start() - _register_patcher_cleanup(context, context.project_patcher) + context.add_cleanup(_safe_stop_patcher, context.project_patcher) context.mock_resource_svc = context.resource_patcher.start() - _register_patcher_cleanup(context, context.resource_patcher) + context.add_cleanup(_safe_stop_patcher, context.resource_patcher) context.mock_link_repo = context.link_patcher.start() - _register_patcher_cleanup(context, context.link_patcher) + context.add_cleanup(_safe_stop_patcher, context.link_patcher) context.last_result = None context.captured_plan_id = None @@ -476,7 +469,6 @@ def step_m1_plan_in_strategize(context: Context) -> None: "cleveragents.cli.commands.plan._get_plan_executor", return_value=MagicMock(), ) - context._m1_executor_patcher.start() _register_patcher_cleanup(context, context._m1_executor_patcher) @@ -528,7 +520,6 @@ def step_m1_plan_with_changeset(context: Context) -> None: "cleveragents.cli.commands.plan._get_apply_service", return_value=mock_apply_svc, ) - context.apply_patcher.start() _register_patcher_cleanup(context, context.apply_patcher) diff --git a/features/steps/m4_correction_subplan_smoke_steps.py b/features/steps/m4_correction_subplan_smoke_steps.py index beb92baee..3a0bbe293 100644 --- a/features/steps/m4_correction_subplan_smoke_steps.py +++ b/features/steps/m4_correction_subplan_smoke_steps.py @@ -41,24 +41,6 @@ _PLAN_ULID = "01M4SM0KE00000000000000001" _DECISION_ULID = "01M4DEC00000000000000000001" -def _register_patcher_cleanup(context: Context, patcher: object) -> None: - """Register a patcher's stop() with Behave scenario cleanup.""" - stop = getattr(patcher, "stop", None) - if not callable(stop): - return - - def _safe_stop() -> None: - with contextlib.suppress(RuntimeError): - stop() - - if hasattr(context, "add_cleanup"): - context.add_cleanup(_safe_stop) - return - if not hasattr(context, "_cleanup_handlers"): - context._cleanup_handlers = [] - context._cleanup_handlers.append(_safe_stop) - - def _make_m4_plan( *, phase: PlanPhase = PlanPhase.EXECUTE, @@ -96,6 +78,18 @@ def _make_m4_plan( ) +def _register_patcher_cleanup(context: Context, patcher: object) -> None: + """Start a patcher and always stop it via Behave cleanup.""" + patcher.start() + context.add_cleanup(_safe_stop_patcher, patcher) + + +def _safe_stop_patcher(patcher: object) -> None: + """Stop patcher safely even if it was already stopped.""" + with contextlib.suppress(RuntimeError): + patcher.stop() + + # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @@ -117,7 +111,6 @@ def step_m4_smoke_mock_service(context: Context) -> None: "cleveragents.cli.commands.plan._get_lifecycle_service", return_value=context.m4_mock_service, ) - context.m4_plan_patcher.start() _register_patcher_cleanup(context, context.m4_plan_patcher) context.m4_last_result = None @@ -249,7 +242,6 @@ def step_m4_plan_with_decision_tree(context: Context) -> None: "cleveragents.application.services.correction_service.CorrectionService", return_value=mock_correction_svc, ) - context.m4_correction_patcher.start() _register_patcher_cleanup(context, context.m4_correction_patcher) context.m4_mock_correction_service = mock_correction_svc @@ -263,7 +255,6 @@ def step_m4_plan_with_decision_tree(context: Context) -> None: "cleveragents.application.container.get_container", return_value=mock_container, ) - context.m4_container_patcher.start() _register_patcher_cleanup(context, context.m4_container_patcher) diff --git a/features/steps/plan_lifecycle_cli_steps.py b/features/steps/plan_lifecycle_cli_steps.py index 47faba3a5..faea92931 100644 --- a/features/steps/plan_lifecycle_cli_steps.py +++ b/features/steps/plan_lifecycle_cli_steps.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -113,11 +114,12 @@ def step_call_lifecycle_service_helper(context) -> None: settings = MagicMock() service = PlanLifecycleService(settings=settings) container = SimpleNamespace(plan_lifecycle_service=MagicMock(return_value=service)) + reloaded_plan_module = importlib.reload(plan_module) with patch( "cleveragents.application.container.get_container", return_value=container ): context.helper_settings = settings - context.helper_service = plan_module._get_lifecycle_service() + context.helper_service = reloaded_plan_module._get_lifecycle_service() @then("the lifecycle service helper should return a plan lifecycle service") diff --git a/features/steps/repl_input_modes_steps.py b/features/steps/repl_input_modes_steps.py index 6285f995a..efc415cf5 100644 --- a/features/steps/repl_input_modes_steps.py +++ b/features/steps/repl_input_modes_steps.py @@ -10,23 +10,11 @@ from unittest.mock import patch from behave import given, then, when from behave.runner import Context +from pydantic import ValidationError from cleveragents.cli.commands.repl import _expand_references, run_repl - - -class _FakeInput: - """Feed predefined lines and then terminate REPL with EOF.""" - - def __init__(self, lines: list[str]) -> None: - self._lines = list(lines) - self._idx = 0 - - def __call__(self, prompt: str = "") -> str: - if self._idx >= len(self._lines): - raise EOFError - line = self._lines[self._idx] - self._idx += 1 - return line +from cleveragents.cli.persona import PersonaConfig +from features.mocks.fake_repl_input import FakeReplInput def _run_repl_capture(lines: list[str], config_dir: Path) -> tuple[int, str]: @@ -36,7 +24,7 @@ def _run_repl_capture(lines: list[str], config_dir: Path) -> tuple[int, str]: output = StringIO() console = Console(file=output, no_color=True, width=180) - fake_input = _FakeInput(lines) + fake_input = FakeReplInput(lines) with ( patch("cleveragents.cli.commands.repl._console", console), patch("builtins.input", fake_input), @@ -53,8 +41,23 @@ def step_temp_repl_config(context: Context) -> None: context.add_cleanup(lambda: shutil.rmtree(str(config_dir), ignore_errors=True)) +@given("a deterministic REPL reference catalog fixture") +def step_repl_reference_fixture(context: Context) -> None: + fixture_dir = Path(tempfile.mkdtemp()) + (fixture_dir / "README.md").write_text("# fixture\n", encoding="utf-8") + context.repl_reference_fixture_dir = fixture_dir + context.add_cleanup(lambda: shutil.rmtree(str(fixture_dir), ignore_errors=True)) + + @given("a temporary persona export path") def step_temp_persona_export_path(context: Context) -> None: + export_path = Path("tmp-persona-export.yaml") + context.repl_mode_export_path = export_path + context.add_cleanup(lambda: export_path.unlink(missing_ok=True)) + + +@given("an absolute persona export path") +def step_absolute_persona_export_path(context: Context) -> None: export_path = Path(tempfile.mkdtemp()) / "persona-export.yaml" context.repl_mode_export_path = export_path context.add_cleanup( @@ -62,14 +65,40 @@ def step_temp_persona_export_path(context: Context) -> None: ) +@given("an invalid persona import file path") +def step_invalid_persona_import_file(context: Context) -> None: + invalid_path = Path("tmp-invalid-persona.yaml") + invalid_path.write_text("- bad\n- yaml\n", encoding="utf-8") + context.repl_mode_invalid_import_path = invalid_path + context.add_cleanup(lambda: invalid_path.unlink(missing_ok=True)) + + +@given("an absolute persona import file path") +def step_absolute_persona_import_path(context: Context) -> None: + temp_dir = Path(tempfile.mkdtemp()) + input_path = temp_dir / "persona-import.yaml" + input_path.write_text("name: x\nactor: local/mock-default\n", encoding="utf-8") + context.repl_mode_absolute_import_path = input_path + context.add_cleanup(lambda: shutil.rmtree(str(temp_dir), ignore_errors=True)) + + @when("I run the REPL with input lines") def step_run_repl_lines(context: Context) -> None: + replacements: dict[str, str] = {} export_path = getattr(context, "repl_mode_export_path", None) + if export_path is not None: + replacements["export_path"] = str(export_path) + invalid_import_path = getattr(context, "repl_mode_invalid_import_path", None) + if invalid_import_path is not None: + replacements["invalid_import_path"] = str(invalid_import_path) + absolute_import_path = getattr(context, "repl_mode_absolute_import_path", None) + if absolute_import_path is not None: + replacements["absolute_import_path"] = str(absolute_import_path) lines = [] for row in context.table: line = row["line"] - if export_path is not None: - line = line.format(export_path=str(export_path)) + if replacements: + line = line.format(**replacements) lines.append(line) code, output = _run_repl_capture(lines, context.repl_mode_config_dir) context.repl_mode_code = code @@ -85,7 +114,12 @@ def step_output_contains(context: Context, text: str) -> None: @when('I expand REPL references for "{line}"') def step_expand_references(context: Context, line: str) -> None: - context.expanded_repl_line = _expand_references(line) + fixture_dir = getattr(context, "repl_reference_fixture_dir", None) + if fixture_dir is None: + context.expanded_repl_line = _expand_references(line) + return + with patch("cleveragents.cli.commands.repl.Path.cwd", return_value=fixture_dir): + context.expanded_repl_line = _expand_references(line) @then('the expanded REPL line should contain "{text}"') @@ -93,3 +127,31 @@ def step_expanded_contains(context: Context, text: str) -> None: assert text in context.expanded_repl_line, ( f"Expected {text!r} in expanded line: {context.expanded_repl_line}" ) + + +@when('I validate CLI persona name "{name}"') +def step_validate_cli_persona_name(context: Context, name: str) -> None: + decoded = name.encode("utf-8").decode("unicode_escape") + context.repl_cli_persona_error = None + try: + PersonaConfig(name=decoded, actor="local/mock-default") + except ValidationError as exc: + context.repl_cli_persona_error = exc + + +@then('CLI persona validation should fail with "{message}"') +def step_cli_persona_validation_fails(context: Context, message: str) -> None: + assert context.repl_cli_persona_error is not None + assert message in str(context.repl_cli_persona_error) + + +@when('I validate CLI persona payload with color "{color}"') +def step_validate_cli_persona_payload_with_color(context: Context, color: str) -> None: + context.repl_cli_persona_payload = PersonaConfig.model_validate( + {"name": "colorful", "actor": "local/mock-default", "color": color} + ) + + +@then('CLI persona color should be "{color}"') +def step_cli_persona_color(context: Context, color: str) -> None: + assert context.repl_cli_persona_payload.color == color diff --git a/features/steps/repl_steps.py b/features/steps/repl_steps.py index af92f890a..fd57854d8 100644 --- a/features/steps/repl_steps.py +++ b/features/steps/repl_steps.py @@ -14,29 +14,13 @@ from cleveragents.cli.commands.repl import ( _get_prompt_context, run_repl, ) +from features.mocks.fake_repl_input import FakeReplInput # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -class _FakeInput: - """Simulate user input for the REPL by feeding lines then raising EOFError.""" - - def __init__(self, lines: list[str]) -> None: - self._lines = list(lines) - self._index = 0 - - def __call__(self, prompt: str = "") -> str: - if self._index >= len(self._lines): - raise EOFError - line = self._lines[self._index] - self._index += 1 - if line == "__KEYBOARD_INTERRUPT__": - raise KeyboardInterrupt - return line - - def _run_repl_with_input( lines: list[str], *, @@ -55,7 +39,7 @@ def _run_repl_with_input( if history_path is not None: kw["history_path"] = history_path - fake = _FakeInput(lines) + fake = FakeReplInput(lines, interrupt_token="__KEYBOARD_INTERRUPT__") with ( patch("cleveragents.cli.commands.repl._console", console), patch("builtins.input", fake), diff --git a/features/steps/tui_input_modes_steps.py b/features/steps/tui_input_modes_steps.py index a1949509c..3d2c34d78 100644 --- a/features/steps/tui_input_modes_steps.py +++ b/features/steps/tui_input_modes_steps.py @@ -2,11 +2,19 @@ from __future__ import annotations -from behave import then, when +import os +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch + +from behave import given, then, when from behave.runner import Context +from cleveragents.tui.input import reference_parser from cleveragents.tui.input.modes import InputModeRouter -from cleveragents.tui.input.reference_parser import parse_references +from cleveragents.tui.input.reference_parser import parse_references, suggestions +from cleveragents.tui.input.shell_exec import looks_dangerous @when('I parse TUI references for "{line}"') @@ -40,3 +48,69 @@ def step_shell_stdout(context: Context, value: str) -> None: shell = context.tui_mode_result.shell_result assert shell is not None assert value in shell.stdout + + +@then('the TUI shell stderr should contain "{value}"') +def step_shell_stderr(context: Context, value: str) -> None: + shell = context.tui_mode_result.shell_result + assert shell is not None + assert value in shell.stderr + + +@when('I check shell danger detection for "{command}"') +def step_check_shell_danger(context: Context, command: str) -> None: + context.shell_danger_result = looks_dangerous(command) + + +@then('shell danger detection result should be "{value}"') +def step_shell_danger_result(context: Context, value: str) -> None: + expected = value.strip().lower() == "true" + assert context.shell_danger_result is expected + + +@given("a deterministic TUI reference fixture") +def step_deterministic_tui_reference_fixture(context: Context) -> None: + fixture_dir = Path(tempfile.mkdtemp()) + (fixture_dir / "README.md").write_text("# fixture\n", encoding="utf-8") + (fixture_dir / ".git").mkdir(exist_ok=True) + (fixture_dir / ".git" / "secret.txt").write_text("secret\n", encoding="utf-8") + context.tui_reference_fixture_dir = fixture_dir + context.add_cleanup(lambda: shutil.rmtree(str(fixture_dir), ignore_errors=True)) + + +@when("I parse and suggest TUI references using fixture") +def step_parse_and_suggest_with_fixture(context: Context) -> None: + context.tui_walk_count = 0 + context.tui_fixture_suggestions = [] + reference_parser._catalog_cache["cwd"] = None + reference_parser._catalog_cache["created_at"] = 0.0 + reference_parser._catalog_cache["catalog"] = None + real_walk = os.walk + + def counting_walk(*args: object, **kwargs: object): + context.tui_walk_count += 1 + return real_walk(*args, **kwargs) + + with ( + patch( + "cleveragents.tui.input.reference_parser.Path.cwd", + return_value=context.tui_reference_fixture_dir, + ), + patch( + "cleveragents.tui.input.reference_parser.os.walk", side_effect=counting_walk + ), + ): + parse_references("inspect @README.md") + context.tui_fixture_suggestions = suggestions("secret", category="resource") + + +@then('TUI walk count should be "{count}"') +def step_tui_walk_count(context: Context, count: str) -> None: + assert context.tui_walk_count == int(count) + + +@then('TUI suggestions should not include "{value}"') +def step_tui_suggestions_not_include(context: Context, value: str) -> None: + assert all( + value not in suggestion for suggestion in context.tui_fixture_suggestions + ) diff --git a/features/steps/tui_persona_system_steps.py b/features/steps/tui_persona_system_steps.py index 1253d50e4..10a59a1dc 100644 --- a/features/steps/tui_persona_system_steps.py +++ b/features/steps/tui_persona_system_steps.py @@ -4,13 +4,14 @@ from __future__ import annotations import shutil import tempfile +import threading from pathlib import Path from behave import given, then, when from behave.runner import Context from cleveragents.tui.persona.registry import PersonaRegistry -from cleveragents.tui.persona.schema import Persona +from cleveragents.tui.persona.schema import Persona, PersonaPreset from cleveragents.tui.persona.state import PersonaState @@ -50,6 +51,25 @@ def step_given_save_persona(context: Context, name: str, actor: str) -> None: step_save_persona(context, name, actor) +@given( + 'I save TUI persona "{name}" with actor "{actor}" and extra preset "{preset_name}"' +) +def step_save_persona_with_extra_preset( + context: Context, name: str, actor: str, preset_name: str +) -> None: + persona = Persona( + name=name, + actor=actor, + argument_presets=[ + PersonaPreset(name="default", display="default", overrides={}), + PersonaPreset( + name=preset_name, display=preset_name, overrides={"mode": "x"} + ), + ], + ) + context.tui_registry.save(persona) + + @given( 'I save a TUI persona named "{name}" with actor "{actor}" and cycle order {cycle:d}' ) @@ -103,3 +123,93 @@ def step_set_active_persona( def step_active_persona(context: Context, session_id: str, persona_name: str) -> None: persona = context.tui_state.active_persona(session_id) assert persona.name == persona_name + + +@when('I cycle persona preset for session "{session_id}"') +def step_cycle_preset(context: Context, session_id: str) -> None: + if not hasattr(context, "tui_state"): + context.tui_state = PersonaState(registry=context.tui_registry) + context.tui_state.cycle_preset(session_id) + + +@then('current preset for session "{session_id}" should be "{preset_name}"') +def step_current_preset(context: Context, session_id: str, preset_name: str) -> None: + assert context.tui_state.current_preset(session_id) == preset_name + + +@given('I create malformed TUI persona file "{name}"') +def step_create_malformed_persona_file(context: Context, name: str) -> None: + malformed = context.tui_registry.personas_dir / f"{name}.yaml" + context.tui_registry.ensure_dirs() + malformed.write_text("name: broken\nactor:\n- not-a-string\n", encoding="utf-8") + + +@when("I list TUI personas") +def step_list_tui_personas(context: Context) -> None: + context.tui_list_error = None + context.tui_persona_names = [] + try: + personas = context.tui_registry.list_personas() + context.tui_persona_names = [persona.name for persona in personas] + except Exception as exc: + context.tui_list_error = exc + + +@then('TUI persona list should include "{name}"') +def step_tui_persona_list_includes(context: Context, name: str) -> None: + assert context.tui_list_error is None + assert name in context.tui_persona_names + + +@when('I export TUI persona "{name}" to "{output_path}"') +def step_export_tui_persona(context: Context, name: str, output_path: str) -> None: + context.tui_export_error = None + try: + context.tui_registry.export_persona(name, Path(output_path)) + except Exception as exc: + context.tui_export_error = exc + + +@then('TUI export should fail with "{message}"') +def step_tui_export_fails(context: Context, message: str) -> None: + assert context.tui_export_error is not None + assert message in str(context.tui_export_error) + + +@when('I import TUI persona from "{input_path}"') +def step_import_tui_persona(context: Context, input_path: str) -> None: + context.tui_import_error = None + try: + context.tui_registry.import_persona(Path(input_path)) + except Exception as exc: + context.tui_import_error = exc + + +@then('TUI import should fail with "{message}"') +def step_tui_import_fails(context: Context, message: str) -> None: + assert context.tui_import_error is not None + assert message in str(context.tui_import_error) + + +@when("I concurrently set TUI last persona names") +def step_concurrent_set_last_persona_names(context: Context) -> None: + context.tui_concurrency_errors = [] + + def _worker(persona_name: str) -> None: + try: + context.tui_registry.set_last_persona(persona_name) + except Exception as exc: # pragma: no cover - assertion handled after join + context.tui_concurrency_errors.append(exc) + + threads: list[threading.Thread] = [] + for row in context.table: + thread = threading.Thread(target=_worker, args=(row["name"],)) + threads.append(thread) + thread.start() + for thread in threads: + thread.join() + + +@then("TUI concurrent update should complete without errors") +def step_tui_concurrent_update_no_errors(context: Context) -> None: + assert context.tui_concurrency_errors == [] diff --git a/features/tui_input_modes.feature b/features/tui_input_modes.feature index a12bb8d3e..0e39fb770 100644 --- a/features/tui_input_modes.feature +++ b/features/tui_input_modes.feature @@ -11,6 +11,23 @@ Feature: TUI input modes And the TUI command result should be "ok:/help" Scenario: Shell mode runs host command - When I route TUI input "!printf hello" + When I route TUI input "!echo hello" Then the TUI mode should be "shell" And the TUI shell stdout should contain "hello" + + Scenario: Shell mode blocks dangerous command by default + When I route TUI input "!rm -rf /" + Then the TUI mode should be "shell" + And the TUI shell stderr should contain "blocked dangerous shell command" + + Scenario: Dangerous shell pattern detection helper + When I check shell danger detection for "rm -rf /tmp" + Then shell danger detection result should be "true" + When I check shell danger detection for "echo hello" + Then shell danger detection result should be "false" + + Scenario: Reference catalog ignores .git and caches scans + Given a deterministic TUI reference fixture + When I parse and suggest TUI references using fixture + Then TUI walk count should be "1" + And TUI suggestions should not include ".git/secret.txt" diff --git a/features/tui_persona_system.feature b/features/tui_persona_system.feature index be421b5b9..95605e5ac 100644 --- a/features/tui_persona_system.feature +++ b/features/tui_persona_system.feature @@ -25,3 +25,39 @@ Feature: TUI persona system And I set active persona to "reviewer" for session "s2" Then active persona for session "s1" should be "dev" And active persona for session "s2" should be "reviewer" + + Scenario: Persona state cycles through presets per session + Given a temporary TUI persona registry + And I save TUI persona "cycler" with actor "local/mock-default" and extra preset "focused" + When I set active persona to "cycler" for session "s1" + And I cycle persona preset for session "s1" + Then current preset for session "s1" should be "focused" + When I cycle persona preset for session "s1" + Then current preset for session "s1" should be "default" + + Scenario: Malformed persona YAML does not break registry operations + Given a temporary TUI persona registry + And I save a TUI persona named "good" with actor "local/mock-default" + And I create malformed TUI persona file "broken" + When I list TUI personas + Then TUI persona list should include "good" + When I save a TUI persona named "stillworks" with actor "local/mock-default" + Then loading persona "stillworks" should succeed + + Scenario: TUI registry rejects unsafe export and import paths + Given a temporary TUI persona registry + And I save a TUI persona named "safe" with actor "local/mock-default" + When I export TUI persona "safe" to "../escape.yaml" + Then TUI export should fail with "stay within working directory" + When I import TUI persona from "../escape.yaml" + Then TUI import should fail with "stay within working directory" + + Scenario: TUI registry state writes tolerate concurrent updates + Given a temporary TUI persona registry + When I concurrently set TUI last persona names + | name | + | a | + | b | + | c | + | d | + Then TUI concurrent update should complete without errors diff --git a/robot/tui_smoke.robot b/robot/tui_smoke.robot index 0e229afd2..48f828648 100644 --- a/robot/tui_smoke.robot +++ b/robot/tui_smoke.robot @@ -8,3 +8,35 @@ TUI Headless Startup Check Returns JSON Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} textual_available Should Contain ${result.stdout} default_persona + +TUI Headless Works When Shell Disabled + ${result}= Run Process ${PYTHON} -m cleveragents tui --headless shell=False stderr=STDOUT env:CLEVERAGENTS_DATABASE_URL=sqlite:///:memory: env:CLEVERAGENTS_DISABLE_SHELL_MODE=1 + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} textual_available + Should Contain ${result.stdout} default_persona + +TUI Input Mode Router And Prompt Widget Behavior + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.tui.input.modes import InputModeRouter + ... from cleveragents.tui.widgets.prompt import PromptInput + ... prompt = PromptInput() + ... prompt.value = "hello" + ... payload = prompt.consume_text() + ... assert payload.text == "hello" + ... assert prompt.value == "" + ... router = InputModeRouter(command_handler=lambda raw: f"ok:{raw}") + ... command_result = router.process("/help") + ... assert command_result.mode.value == "command" + ... assert command_result.command_result == "ok:help" + ... normal_result = router.process("inspect @README.md") + ... assert normal_result.mode.value == "normal" + ... print("router-widget-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} router-widget-ok + +TUI Headless Includes Router Help Payload + ${result}= Run Process ${PYTHON} -m cleveragents tui --headless shell=False stderr=STDOUT env:CLEVERAGENTS_DATABASE_URL=sqlite:///:memory: + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} "help" + Should Contain ${result.stdout} /persona diff --git a/src/cleveragents/application/container.py b/src/cleveragents/application/container.py index 3120c93fe..0a53d0966 100644 --- a/src/cleveragents/application/container.py +++ b/src/cleveragents/application/container.py @@ -96,6 +96,8 @@ from cleveragents.providers.registry import ProviderRegistry, get_provider_regis from cleveragents.reactive.route_bridge import RouteBridge from cleveragents.reactive.stream_router import ReactiveStreamRouter from cleveragents.shared.redaction import redact_value +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.state import PersonaState _logger = structlog.get_logger(__name__) @@ -539,6 +541,10 @@ class Container(containers.DeclarativeContainer): plan_service=plan_service, ) + # TUI persona wiring through DI container + persona_registry = providers.Factory(PersonaRegistry) + persona_state = providers.Factory(PersonaState, registry=persona_registry) + # Metrics Emitter - structured metric emission (Forgejo #579) metrics_emitter = providers.Singleton( MetricsEmitter.from_settings, diff --git a/src/cleveragents/application/services/resource_file_watcher.py b/src/cleveragents/application/services/resource_file_watcher.py index 2f9ffb4e2..645281ad4 100644 --- a/src/cleveragents/application/services/resource_file_watcher.py +++ b/src/cleveragents/application/services/resource_file_watcher.py @@ -29,6 +29,7 @@ from watchdog.events import ( FileSystemEventHandler, ) from watchdog.observers import Observer # type: ignore[import-untyped] +from watchdog.observers.polling import PollingObserver # type: ignore[import-untyped] from cleveragents.infrastructure.events.models import DomainEvent from cleveragents.infrastructure.events.protocol import EventBus @@ -100,6 +101,7 @@ class ResourceFileWatcher: # resolved path (str) -> pending debounce Timer self._pending_timers: dict[str, threading.Timer] = {} self._running = False + self._using_polling = False _logger.debug( "file_watcher.initialized", @@ -157,11 +159,28 @@ class ResourceFileWatcher: and parent_str not in self._dir_watches ): handler = _ResourceChangeHandler(self) - watch_handle = self._observer.schedule( - handler, - parent_str, - recursive=False, - ) + try: + watch_handle = self._observer.schedule( + handler, + parent_str, + recursive=False, + ) + except OSError as exc: + if not self._using_polling: + _logger.warning( + "file_watcher.watch_schedule_fallback", + error_type=type(exc).__name__, + error_message=str(exc), + fallback="polling_observer", + ) + self._switch_to_polling_observer_locked() + watch_handle = self._observer.schedule( # type: ignore[union-attr] + handler, + parent_str, + recursive=False, + ) + else: + raise self._dir_watches[parent_str] = watch_handle _logger.debug( @@ -229,23 +248,20 @@ class ResourceFileWatcher: if self._running: msg = "File watcher is already running" raise RuntimeError(msg) - observer = Observer() - observer.daemon = True - - handler = _ResourceChangeHandler(self) - seen_dirs: set[str] = set() - for path_str in self._watched_paths: - parent_str = str(Path(path_str).parent) - if parent_str not in seen_dirs: - watch_handle = observer.schedule( - handler, - parent_str, - recursive=False, - ) - self._dir_watches[parent_str] = watch_handle - seen_dirs.add(parent_str) - - observer.start() + observer = self._build_observer(use_polling=False) + try: + observer.start() + self._using_polling = False + except OSError as exc: + _logger.warning( + "file_watcher.inotify_unavailable", + error_type=type(exc).__name__, + error_message=str(exc), + fallback="polling_observer", + ) + observer = self._build_observer(use_polling=True) + observer.start() + self._using_polling = True self._observer = observer self._running = True @@ -255,6 +271,38 @@ class ResourceFileWatcher: watched_dirs=len(self._dir_watches), ) + def _build_observer(self, *, use_polling: bool) -> Observer: # type: ignore[valid-type] + observer: Observer = ( # type: ignore[valid-type] + PollingObserver() if use_polling else Observer() + ) + observer.daemon = True + self._dir_watches.clear() + handler = _ResourceChangeHandler(self) + seen_dirs: set[str] = set() + for path_str in self._watched_paths: + parent_str = str(Path(path_str).parent) + if parent_str in seen_dirs: + continue + watch_handle = observer.schedule( + handler, + parent_str, + recursive=False, + ) + self._dir_watches[parent_str] = watch_handle + seen_dirs.add(parent_str) + return observer + + def _switch_to_polling_observer_locked(self) -> None: + """Swap current observer to polling backend while lock is held.""" + old_observer = self._observer + if old_observer is not None: + old_observer.stop() + old_observer.join(timeout=5.0) + polling_observer = self._build_observer(use_polling=True) + polling_observer.start() + self._observer = polling_observer + self._using_polling = True + def stop(self) -> None: """Stop the watchdog observer and cancel pending timers.""" observer: Observer | None = None # type: ignore[valid-type] @@ -268,6 +316,7 @@ class ResourceFileWatcher: self._observer = None self._dir_watches.clear() self._running = False + self._using_polling = False # Join outside lock to avoid deadlock with timer callbacks. if observer is not None: diff --git a/src/cleveragents/cli/commands/repl.py b/src/cleveragents/cli/commands/repl.py index d7d09f3da..c2c321574 100644 --- a/src/cleveragents/cli/commands/repl.py +++ b/src/cleveragents/cli/commands/repl.py @@ -13,6 +13,7 @@ import readline import shlex import subprocess import sys +import time from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path @@ -23,6 +24,7 @@ import yaml from rich.console import Console from cleveragents.cli.persona import PersonaConfig, PersonaRegistry +from cleveragents.tui.input.shell_exec import looks_dangerous # --------------------------------------------------------------------------- # Constants @@ -33,6 +35,10 @@ _DEFAULT_HISTORY_PATH = _DEFAULT_HISTORY_DIR / "history" _MULTILINE_CONTINUATION = "\\" _LAST_COMMAND_TOKEN = "!!" _MAX_REFERENCE_FILES = 300 +_SHELL_TIMEOUT_SECONDS = 30 +_IGNORED_DIRS = {".git", ".venv", "node_modules", "__pycache__", ".mypy_cache"} +_REFERENCE_CACHE_TTL_SECONDS = 5.0 +_reference_cache: dict[str, object] = {"cwd": None, "created_at": 0.0, "catalog": None} # The subset of first-position CLI commands exposed inside the REPL. # Mirrors the ``valid_cmds`` list in ``main.py`` minus the flags. @@ -194,15 +200,33 @@ def _print_repl_help() -> None: def _build_reference_catalog() -> dict[str, list[str]]: """Build lightweight catalog for @ reference expansion.""" cwd = Path.cwd() + now = time.time() + cached_cwd = _reference_cache.get("cwd") + cached_time = _reference_cache.get("created_at") + cached_catalog = _reference_cache.get("catalog") + if ( + isinstance(cached_cwd, Path) + and cached_cwd == cwd + and isinstance(cached_time, float) + and now - cached_time < _REFERENCE_CACHE_TTL_SECONDS + and isinstance(cached_catalog, dict) + ): + return cached_catalog + files: list[str] = [] - for path in cwd.rglob("*"): - if len(files) >= _MAX_REFERENCE_FILES: - break - if path.is_file(): + for root, dirs, filenames in os.walk(cwd, followlinks=False): + dirs[:] = [name for name in dirs if name not in _IGNORED_DIRS] + root_path = Path(root) + for filename in filenames: + if len(files) >= _MAX_REFERENCE_FILES: + break + path = root_path / filename try: files.append(str(path.relative_to(cwd).as_posix())) except ValueError: files.append(path.as_posix()) + if len(files) >= _MAX_REFERENCE_FILES: + break actors: set[str] = set() actor_examples = cwd / "examples" / "actors" @@ -228,7 +252,7 @@ def _build_reference_catalog() -> dict[str, list[str]]: project_env = os.environ.get("CLEVERAGENTS_PROJECT", "") plan_env = os.environ.get("CLEVERAGENTS_PLAN", "") - return { + catalog = { "file": sorted(files), "actor": sorted(actors), "tool": sorted(tools), @@ -236,6 +260,10 @@ def _build_reference_catalog() -> dict[str, list[str]]: "project": [project_env] if project_env else [], "plan": [plan_env] if plan_env else [], } + _reference_cache["cwd"] = cwd + _reference_cache["created_at"] = now + _reference_cache["catalog"] = catalog + return catalog def _best_fuzzy_match(query: str, options: list[str]) -> str | None: @@ -270,9 +298,11 @@ def _find_reference_candidates( return (prefix + contains + fuzzy)[:limit] -def _expand_references(line: str) -> str: +def _expand_references( + line: str, *, catalog: dict[str, list[str]] | None = None +) -> str: """Expand inline @ tokens to canonical references when possible.""" - catalog = _build_reference_catalog() + active_catalog = catalog or _build_reference_catalog() tokens = line.split() expanded_tokens: list[str] = [] @@ -289,12 +319,12 @@ def _expand_references(line: str) -> str: query = raw if ":" in raw: maybe_category, rest = raw.split(":", 1) - if maybe_category in catalog: + if maybe_category in active_catalog: category = maybe_category query = rest if category: - candidate = _best_fuzzy_match(query, catalog.get(category, [])) + candidate = _best_fuzzy_match(query, active_catalog.get(category, [])) if candidate: expanded_tokens.append(f"@{category}:{candidate}") continue @@ -303,7 +333,7 @@ def _expand_references(line: str) -> str: best_category = "" best_value = "" - for candidate_category, values in catalog.items(): + for candidate_category, values in active_catalog.items(): candidate = _best_fuzzy_match(query, values) if candidate: best_category = candidate_category @@ -317,9 +347,11 @@ def _expand_references(line: str) -> str: return " ".join(expanded_tokens) -def _reference_suggestions(line: str) -> list[str]: +def _reference_suggestions( + line: str, *, catalog: dict[str, list[str]] | None = None +) -> list[str]: """Return lightweight suggestion hints for unresolved @ tokens.""" - catalog = _build_reference_catalog() + active_catalog = catalog or _build_reference_catalog() for token in line.split(): if not token.startswith("@") or token.startswith("\\@"): continue @@ -328,14 +360,14 @@ def _reference_suggestions(line: str) -> list[str]: continue if ":" in raw: category, query = raw.split(":", 1) - if category in catalog: + if category in active_catalog: candidates = _find_reference_candidates( - query, catalog[category], limit=5 + query, active_catalog[category], limit=5 ) return [f"@{category}:{value}" for value in candidates] else: merged: list[str] = [] - for category, values in catalog.items(): + for category, values in active_catalog.items(): for value in _find_reference_candidates(raw, values, limit=2): merged.append(f"@{category}:{value}") return merged[:5] @@ -343,11 +375,37 @@ def _reference_suggestions(line: str) -> list[str]: def _run_shell_command(command_text: str) -> int: - """Execute a shell command and print output.""" + """Execute a shell command with minimal safeguards. + + Threat model: + - Shell mode is intended for trusted local development workflows. + - It is not a sandbox and must never be exposed as a remote execution surface. + - Dangerous patterns require explicit user confirmation before execution. + """ if not command_text.strip(): _console.print("[yellow]Shell mode requires a command after '!'.[/yellow]") return 2 - proc = subprocess.run(command_text, shell=True, text=True, capture_output=True) # nosec B602 + if os.environ.get("CLEVERAGENTS_DISABLE_SHELL_MODE", "").strip() in {"1", "true"}: + _console.print("[yellow]Shell mode is disabled by configuration.[/yellow]") + return 2 + if looks_dangerous(command_text): + answer = input("Dangerous command detected. Continue? [y/N]: ").strip().lower() + if answer not in {"y", "yes"}: + _console.print("[yellow]Blocked dangerous shell command.[/yellow]") + return 1 + try: + proc = subprocess.run( + command_text, + shell=True, + text=True, + capture_output=True, + timeout=_SHELL_TIMEOUT_SECONDS, + ) # nosec B602 + except subprocess.TimeoutExpired: + _console.print( + f"[red]Shell command timed out after {_SHELL_TIMEOUT_SECONDS}s.[/red]" + ) + return 124 if proc.stdout: _console.print(proc.stdout.rstrip("\n")) if proc.stderr: @@ -507,8 +565,12 @@ def _handle_slash_command( if not deleted: _console.print(f"[red]Persona not found:[/red] {target}") return 1, current_session - if sessions[current_session].active_persona == target: - sessions[current_session].active_persona = "default" + reset_any = False + for state in sessions.values(): + if state.active_persona == target: + state.active_persona = "default" + reset_any = True + if reset_any: registry.set_active_persona_name("default") _console.print(f"[green]Deleted persona:[/green] {target}") return 0, current_session @@ -521,26 +583,34 @@ def _handle_slash_command( if persona is None: _console.print(f"[red]Persona not found:[/red] {target}") return 1, current_session - output_path = ( - Path(tokens[3]) if len(tokens) > 3 else Path.cwd() / f"{target}.yaml" - ) + output_path = Path(tokens[3]) if len(tokens) > 3 else Path(f"{target}.yaml") + try: + safe_output_path = registry.resolve_export_path(output_path) + except ValueError as exc: + _console.print(f"[red]{exc}[/red]") + return 1, current_session payload = persona.model_dump(mode="json", exclude_none=True) - output_path.write_text( + safe_output_path.write_text( yaml.safe_dump(payload, sort_keys=False, default_flow_style=False), encoding="utf-8", ) - _console.print(f"[green]Exported persona:[/green] {output_path}") + _console.print(f"[green]Exported persona:[/green] {safe_output_path}") return 0, current_session if tokens[1] == "import": if len(tokens) < 3: _console.print("[red]Usage:[/red] /persona import ") return 2, current_session input_path = Path(tokens[2]) - if not input_path.exists(): - _console.print(f"[red]File not found:[/red] {input_path}") + try: + safe_input_path = registry.resolve_import_path(input_path) + except ValueError as exc: + _console.print(f"[red]{exc}[/red]") + return 1, current_session + if not safe_input_path.exists(): + _console.print(f"[red]File not found:[/red] {safe_input_path}") return 1, current_session - raw = yaml.safe_load(input_path.read_text(encoding="utf-8")) or {} + raw = yaml.safe_load(safe_input_path.read_text(encoding="utf-8")) or {} if not isinstance(raw, dict): _console.print("[red]Invalid persona YAML.[/red]") return 1, current_session @@ -714,11 +784,12 @@ def run_repl( continue # Normal mode @ references - expanded_line = _expand_references(line) + reference_catalog = _build_reference_catalog() if "@" in line else None + expanded_line = _expand_references(line, catalog=reference_catalog) if expanded_line != line: _console.print(f"[dim]{expanded_line}[/dim]") elif "@" in line: - hints = _reference_suggestions(line) + hints = _reference_suggestions(line, catalog=reference_catalog) if hints: _console.print("[dim]Reference suggestions:[/dim] " + ", ".join(hints)) line = expanded_line diff --git a/src/cleveragents/cli/commands/tui.py b/src/cleveragents/cli/commands/tui.py index d52f68c4b..0ad83620f 100644 --- a/src/cleveragents/cli/commands/tui.py +++ b/src/cleveragents/cli/commands/tui.py @@ -6,8 +6,6 @@ from typing import Annotated import typer -from cleveragents.tui.commands import run_tui - app = typer.Typer(help="Launch the Textual TUI.") @@ -22,4 +20,7 @@ def tui_callback( ] = False, ) -> None: """Launch the CleverAgents TUI.""" + # Import lazily so non-TUI commands avoid Textual startup cost. + from cleveragents.tui.commands import run_tui + raise typer.Exit(run_tui(headless=headless)) diff --git a/src/cleveragents/cli/persona.py b/src/cleveragents/cli/persona.py index aa1ca2c36..cdc69a336 100644 --- a/src/cleveragents/cli/persona.py +++ b/src/cleveragents/cli/persona.py @@ -1,177 +1,51 @@ -"""Persona schema and local YAML storage for interactive CLI/TUI usage. - -Personas are intentionally a presentation-layer concept. They are stored as -YAML files under the user config directory and never persisted in domain -models or database tables. -""" +"""CLI compatibility layer for canonical TUI persona modules.""" from __future__ import annotations -import os -from dataclasses import dataclass, field from pathlib import Path -from typing import Any -import yaml -from pydantic import BaseModel, ConfigDict, Field, field_validator +from cleveragents.tui.persona.registry import PersonaRegistry as _TuiPersonaRegistry +from cleveragents.tui.persona.schema import Persona as PersonaConfig -def _default_config_dir() -> Path: - from_env = os.environ.get("CLEVERAGENTS_CONFIG_DIR", "").strip() - if from_env: - return Path(from_env) - return Path.home() / ".config" / "cleveragents" +class PersonaRegistry(_TuiPersonaRegistry): + """Backwards-compatible API for CLI callers. - -class PersonaPreset(BaseModel): - """Named argument overlay for a persona.""" - - name: str = Field(..., min_length=1) - display: str = Field(..., min_length=1) - overrides: dict[str, Any] = Field(default_factory=dict) - - model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True) - - -class PersonaConfig(BaseModel): - """Persona definition loaded from YAML.""" - - name: str = Field(..., min_length=1) - description: str = Field(default="") - icon: str = Field(default="") - actor: str = Field(..., min_length=1) - base_arguments: dict[str, Any] = Field(default_factory=dict) - scoped_projects: list[str] = Field(default_factory=list) - scoped_plans: list[str] = Field(default_factory=list) - argument_presets: list[PersonaPreset] = Field(default_factory=list) - cycle_order: int = Field(default=0, ge=0) - greeting: str = Field(default="") - - @field_validator("actor") - @classmethod - def _validate_actor(cls, value: str) -> str: - if "/" not in value: - raise ValueError("actor must be namespaced (namespace/name)") - return value - - @field_validator("argument_presets") - @classmethod - def _validate_default_preset( - cls, value: list[PersonaPreset] - ) -> list[PersonaPreset]: - if not value: - return [PersonaPreset(name="default", display="default", overrides={})] - defaults = [preset for preset in value if preset.name == "default"] - if len(defaults) != 1: - raise ValueError("persona must contain exactly one 'default' preset") - if defaults[0].overrides: - raise ValueError("'default' preset must have empty overrides") - return value - - model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True) - - -@dataclass(slots=True) -class PersonaRegistry: - """Loads and persists persona YAML files from user config space.""" - - config_dir: Path = field(default_factory=_default_config_dir) - - @property - def personas_dir(self) -> Path: - return self.config_dir / "personas" + Canonical persona schema/registry live in `cleveragents.tui.persona.*`. + This class preserves old method names used by REPL and CLI command code. + """ @property def state_file(self) -> Path: - return self.config_dir / "tui-state.yaml" + return self.state_path - def ensure_dirs(self) -> None: - self.personas_dir.mkdir(parents=True, exist_ok=True) + @property + def personas_lock_file(self) -> Path: + return self.personas_lock_path - def persona_path(self, name: str) -> Path: - return self.personas_dir / f"{name}.yaml" + @property + def state_lock_file(self) -> Path: + return self.state_lock_path def list_personas(self) -> list[PersonaConfig]: - self.ensure_dirs() - personas: list[PersonaConfig] = [] - for path in sorted(self.personas_dir.glob("*.y*ml")): - raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(raw, dict): - continue - personas.append(PersonaConfig.model_validate(raw)) - return personas + return super().list_personas() def get_persona(self, name: str) -> PersonaConfig | None: - path = self.persona_path(name) - if not path.exists(): - return None - raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(raw, dict): - return None - return PersonaConfig.model_validate(raw) + return super().get(name) def save_persona(self, persona: PersonaConfig) -> Path: - self.ensure_dirs() - if persona.cycle_order > 0: - for existing in self.list_personas(): - if ( - existing.name != persona.name - and existing.cycle_order == persona.cycle_order - ): - raise ValueError( - "cycle_order must be unique across personas " - "with cycle_order > 0" - ) - path = self.persona_path(persona.name) - payload = persona.model_dump(mode="json", exclude_none=True) - path.write_text( - yaml.safe_dump(payload, sort_keys=False, default_flow_style=False), - encoding="utf-8", - ) - return path + return super().save(persona) def delete_persona(self, name: str) -> bool: - path = self.persona_path(name) - if not path.exists(): - return False - path.unlink() - return True + return super().delete(name) def get_active_persona_name(self) -> str | None: - if not self.state_file.exists(): - return None - raw = yaml.safe_load(self.state_file.read_text(encoding="utf-8")) or {} - if not isinstance(raw, dict): - return None - active = raw.get("last_persona") - return active if isinstance(active, str) and active else None + return super().get_last_persona() def set_active_persona_name(self, name: str) -> None: - self.ensure_dirs() - state: dict[str, Any] = {} - if self.state_file.exists(): - loaded = yaml.safe_load(self.state_file.read_text(encoding="utf-8")) or {} - if isinstance(loaded, dict): - state = dict(loaded) - state["last_persona"] = name - self.state_file.write_text( - yaml.safe_dump(state, sort_keys=False, default_flow_style=False), - encoding="utf-8", - ) + super().set_last_persona(name) def ensure_default_persona( self, actor: str = "local/mock-default" ) -> PersonaConfig: - self.ensure_dirs() - existing = self.get_persona("default") - if existing is not None: - return existing - default_persona = PersonaConfig( - name="default", - description="Default persona", - actor=actor, - argument_presets=[PersonaPreset(name="default", display="default")], - ) - self.save_persona(default_persona) - self.set_active_persona_name(default_persona.name) - return default_persona + return super().ensure_default(actor=actor) diff --git a/src/cleveragents/tui/__init__.py b/src/cleveragents/tui/__init__.py index bd33abd42..650585e82 100644 --- a/src/cleveragents/tui/__init__.py +++ b/src/cleveragents/tui/__init__.py @@ -3,7 +3,14 @@ from __future__ import annotations from cleveragents.tui.app import CleverAgentsTuiApp -from cleveragents.tui.commands import run_tui + + +def run_tui(*, headless: bool = False) -> int: + """Run TUI command entrypoint lazily to avoid import cycles.""" + from cleveragents.tui.commands import run_tui as _run_tui + + return _run_tui(headless=headless) + __all__ = [ "CleverAgentsTuiApp", diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 918f2585e..214a058a1 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -3,8 +3,9 @@ from __future__ import annotations import importlib +import os from dataclasses import dataclass -from typing import Any, ClassVar, Protocol +from typing import TYPE_CHECKING, Any, ClassVar, Protocol from cleveragents.tui.input.modes import InputMode, InputModeRouter from cleveragents.tui.input.reference_parser import suggestions @@ -14,6 +15,11 @@ from cleveragents.tui.widgets.prompt import PromptInput from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay +if TYPE_CHECKING: + InputSubmittedEvent = Any +else: + InputSubmittedEvent = Any + _TEXTUAL_AVAILABLE = False _TextualApp: type[Any] = object _Vertical: type[Any] = object @@ -135,7 +141,7 @@ if _TEXTUAL_AVAILABLE: scope_text=scope_text, ) - def on_input_submitted(self, event: object) -> None: + def on_input_submitted(self, event: InputSubmittedEvent) -> None: del event prompt = self.query_one("#prompt", PromptInput) payload = prompt.consume_text() @@ -146,7 +152,11 @@ if _TEXTUAL_AVAILABLE: mode_router = InputModeRouter( command_handler=lambda raw: self._command_router.handle( raw, session_id=self._session.session_id - ) + ), + shell_confirm=lambda _cmd: ( + os.environ.get("CLEVERAGENTS_ALLOW_DANGEROUS_SHELL", "").strip() + in {"1", "true"} + ), ) result = mode_router.process(text) conversation = self.query_one("#conversation", _Static) diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index cab175faa..1134e995e 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -51,9 +51,8 @@ class TuiCommandRouter: def run_tui(*, headless: bool = False) -> int: """Run the Textual TUI app or a headless startup check.""" container = get_container() - _ = container.tui_adapters() - registry = PersonaRegistry() - state = PersonaState(registry=registry) + registry = container.persona_registry() + state = container.persona_state(registry=registry) router = TuiCommandRouter(persona_registry=registry, persona_state=state) if headless: diff --git a/src/cleveragents/tui/input/modes.py b/src/cleveragents/tui/input/modes.py index 920fdf121..76231444c 100644 --- a/src/cleveragents/tui/input/modes.py +++ b/src/cleveragents/tui/input/modes.py @@ -35,8 +35,16 @@ class ModeResult: class InputModeRouter: """Dispatch user input to normal/command/shell handlers.""" - def __init__(self, command_handler: Callable[[str], str]) -> None: + def __init__( + self, + command_handler: Callable[[str], str], + *, + shell_confirm: Callable[[str], bool] | None = None, + shell_timeout_seconds: int = 30, + ) -> None: self._command_handler = command_handler + self._shell_confirm = shell_confirm + self._shell_timeout_seconds = shell_timeout_seconds @staticmethod def detect_mode(text: str) -> InputMode: @@ -61,7 +69,11 @@ class InputModeRouter: ) if mode == InputMode.SHELL: command = text.lstrip()[1:].strip() - shell_result = run_shell_command(command) + shell_result = run_shell_command( + command, + confirm_dangerous=self._shell_confirm, + timeout_seconds=self._shell_timeout_seconds, + ) return ModeResult( mode=mode, expanded_text=text, diff --git a/src/cleveragents/tui/input/reference_parser.py b/src/cleveragents/tui/input/reference_parser.py index af0075b02..779f32a73 100644 --- a/src/cleveragents/tui/input/reference_parser.py +++ b/src/cleveragents/tui/input/reference_parser.py @@ -2,12 +2,18 @@ from __future__ import annotations +import os +import time from dataclasses import dataclass from pathlib import Path from cleveragents.tui.search.fuzzy import rank_candidates _REFERENCE_TYPES = ("project", "plan", "resource", "actor", "tool", "skill") +_IGNORED_DIRS = {".git", ".venv", "node_modules", "__pycache__", ".mypy_cache"} +_CATALOG_LIMIT = 400 +_CATALOG_CACHE_TTL_SECONDS = 5.0 +_catalog_cache: dict[str, object] = {"cwd": None, "created_at": 0.0, "catalog": None} @dataclass(slots=True, frozen=True) @@ -30,17 +36,34 @@ class ReferenceParseResult: def _catalog() -> dict[str, list[str]]: cwd = Path.cwd() - files = [] - for path in cwd.rglob("*"): - if path.is_file(): - try: - files.append(path.relative_to(cwd).as_posix()) - except ValueError: - files.append(path.as_posix()) - if len(files) >= 400: - break + now = time.time() + cached_cwd = _catalog_cache.get("cwd") + cached_time = _catalog_cache.get("created_at") + cached_catalog = _catalog_cache.get("catalog") + if ( + isinstance(cached_cwd, Path) + and cached_cwd == cwd + and isinstance(cached_time, float) + and now - cached_time < _CATALOG_CACHE_TTL_SECONDS + and isinstance(cached_catalog, dict) + ): + return cached_catalog - return { + files = [] + for root, dirs, filenames in os.walk(cwd, followlinks=False): + dirs[:] = [name for name in dirs if name not in _IGNORED_DIRS] + root_path = Path(root) + for filename in filenames: + try: + files.append((root_path / filename).relative_to(cwd).as_posix()) + except ValueError: + files.append((root_path / filename).as_posix()) + if len(files) >= _CATALOG_LIMIT: + break + if len(files) >= _CATALOG_LIMIT: + break + + catalog = { "resource": sorted(files), "project": [value for value in [cwd.name] if value], "plan": [], @@ -63,6 +86,10 @@ def _catalog() -> dict[str, list[str]]: if (cwd / "examples" / "skills").is_dir() else [], } + _catalog_cache["cwd"] = cwd + _catalog_cache["created_at"] = now + _catalog_cache["catalog"] = catalog + return catalog def _resolve(category: str, query: str, items: dict[str, list[str]]) -> str | None: diff --git a/src/cleveragents/tui/input/shell_exec.py b/src/cleveragents/tui/input/shell_exec.py index 552b9b7db..015214695 100644 --- a/src/cleveragents/tui/input/shell_exec.py +++ b/src/cleveragents/tui/input/shell_exec.py @@ -2,7 +2,9 @@ from __future__ import annotations +import os import subprocess +from collections.abc import Callable from dataclasses import dataclass @@ -29,13 +31,56 @@ def looks_dangerous(command: str) -> bool: return any(pattern in lowered for pattern in patterns) -def run_shell_command(command: str) -> ShellResult: - """Execute shell command and capture outputs.""" +def run_shell_command( + command: str, + *, + confirm_dangerous: Callable[[str], bool] | None = None, + timeout_seconds: int = 30, +) -> ShellResult: + """Execute shell command with basic safeguards. + + Threat model: + - Shell mode is a convenience feature for local development, not a sandbox. + - We block obviously dangerous command patterns unless explicitly confirmed. + - We apply a timeout to avoid hanging the UI event loop indefinitely. + """ if not command.strip(): return ShellResult( command=command, exit_code=2, stdout="", stderr="empty command" ) - proc = subprocess.run(command, shell=True, text=True, capture_output=True) # nosec B602 + if os.environ.get("CLEVERAGENTS_DISABLE_SHELL_MODE", "").strip() in {"1", "true"}: + return ShellResult( + command=command, + exit_code=2, + stdout="", + stderr="shell mode is disabled", + ) + if looks_dangerous(command): + confirmed = False + if confirm_dangerous is not None: + confirmed = confirm_dangerous(command) + if not confirmed: + return ShellResult( + command=command, + exit_code=1, + stdout="", + stderr="blocked dangerous shell command", + ) + try: + proc = subprocess.run( + command, + shell=True, + text=True, + capture_output=True, + timeout=timeout_seconds, + ) # nosec B602 + except subprocess.TimeoutExpired: + return ShellResult( + command=command, + exit_code=124, + stdout="", + stderr=f"command timed out after {timeout_seconds}s", + ) return ShellResult( command=command, exit_code=proc.returncode, diff --git a/src/cleveragents/tui/persona/registry.py b/src/cleveragents/tui/persona/registry.py index 6e94fa1cb..da7784847 100644 --- a/src/cleveragents/tui/persona/registry.py +++ b/src/cleveragents/tui/persona/registry.py @@ -2,15 +2,21 @@ from __future__ import annotations +import fcntl +import logging import os +import tempfile from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml +from pydantic import ValidationError from cleveragents.tui.persona.schema import Persona +_logger = logging.getLogger(__name__) + def _default_config_dir() -> Path: override = os.environ.get("CLEVERAGENTS_CONFIG_DIR", "").strip() @@ -33,20 +39,82 @@ class PersonaRegistry: def state_path(self) -> Path: return self.config_dir / "tui-state.yaml" + @property + def personas_lock_path(self) -> Path: + return self.config_dir / ".personas.lock" + + @property + def state_lock_path(self) -> Path: + return self.config_dir / ".state.lock" + def ensure_dirs(self) -> None: self.personas_dir.mkdir(parents=True, exist_ok=True) + def _lock_file(self, lock_path: Path) -> Any: + lock_path.parent.mkdir(parents=True, exist_ok=True) + handle = lock_path.open("a+", encoding="utf-8") + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + return handle + + @staticmethod + def _atomic_write_yaml(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=str(path.parent), + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + yaml.safe_dump(payload, handle, sort_keys=False, default_flow_style=False) + temp_name = handle.name + Path(temp_name).replace(path) + + def persona_path(self, name: str) -> Path: + Persona.validate_name_safe(name) + result = (self.personas_dir / f"{name}.yaml").resolve() + if not result.is_relative_to(self.personas_dir.resolve()): + raise ValueError(f"Invalid persona name: {name}") + return result + + def resolve_export_path(self, output_path: Path) -> Path: + if output_path.is_absolute(): + raise ValueError( + "Export path must be relative to current working directory" + ) + base = Path.cwd().resolve() + resolved = (base / output_path).resolve() + if not resolved.is_relative_to(base): + raise ValueError("Export path must stay within working directory") + return resolved + + def resolve_import_path(self, input_path: Path) -> Path: + if input_path.is_absolute(): + raise ValueError( + "Import path must be relative to current working directory" + ) + base = Path.cwd().resolve() + resolved = (base / input_path).resolve() + if not resolved.is_relative_to(base): + raise ValueError("Import path must stay within working directory") + return resolved + def list_personas(self) -> list[Persona]: self.ensure_dirs() personas: list[Persona] = [] for file in sorted(self.personas_dir.glob("*.y*ml")): - raw = yaml.safe_load(file.read_text(encoding="utf-8")) or {} - if isinstance(raw, dict): + try: + raw = yaml.safe_load(file.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + continue personas.append(Persona.model_validate(raw)) + except (yaml.YAMLError, ValidationError) as exc: + _logger.warning("Skipping invalid persona file %s: %s", file, exc) return personas def get(self, name: str) -> Persona | None: - file = self.personas_dir / f"{name}.yaml" + file = self.persona_path(name) if not file.exists(): return None raw = yaml.safe_load(file.read_text(encoding="utf-8")) or {} @@ -65,17 +133,19 @@ class PersonaRegistry: def save(self, persona: Persona) -> Path: self.ensure_dirs() - self._validate_cycle_order(persona) - file = self.personas_dir / f"{persona.name}.yaml" - payload = persona.model_dump(mode="json", exclude_none=True) - file.write_text( - yaml.safe_dump(payload, sort_keys=False, default_flow_style=False), - encoding="utf-8", - ) - return file + lock = self._lock_file(self.personas_lock_path) + try: + self._validate_cycle_order(persona) + file = self.persona_path(persona.name) + payload = persona.model_dump(mode="json", exclude_none=True) + self._atomic_write_yaml(file, payload) + return file + finally: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + lock.close() def delete(self, name: str) -> bool: - file = self.personas_dir / f"{name}.yaml" + file = self.persona_path(name) if not file.exists(): return False file.unlink() @@ -86,14 +156,13 @@ class PersonaRegistry: if persona is None: raise ValueError(f"Persona not found: {name}") payload = persona.model_dump(mode="json", exclude_none=True) - output_path.write_text( - yaml.safe_dump(payload, sort_keys=False, default_flow_style=False), - encoding="utf-8", - ) - return output_path + safe_output = self.resolve_export_path(output_path) + self._atomic_write_yaml(safe_output, payload) + return safe_output def import_persona(self, input_path: Path) -> Persona: - raw = yaml.safe_load(input_path.read_text(encoding="utf-8")) or {} + safe_input = self.resolve_import_path(input_path) + raw = yaml.safe_load(safe_input.read_text(encoding="utf-8")) or {} if not isinstance(raw, dict): raise ValueError("Invalid persona file") persona = Persona.model_validate(raw) @@ -110,10 +179,7 @@ class PersonaRegistry: def save_state(self, state: dict[str, Any]) -> None: self.ensure_dirs() - self.state_path.write_text( - yaml.safe_dump(state, sort_keys=False, default_flow_style=False), - encoding="utf-8", - ) + self._atomic_write_yaml(self.state_path, state) def get_last_persona(self) -> str | None: state = self.load_state() @@ -121,9 +187,14 @@ class PersonaRegistry: return value if isinstance(value, str) and value else None def set_last_persona(self, name: str) -> None: - state = self.load_state() - state["last_persona"] = name - self.save_state(state) + lock = self._lock_file(self.state_lock_path) + try: + state = self.load_state() + state["last_persona"] = name + self.save_state(state) + finally: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + lock.close() def ensure_default(self, actor: str = "local/mock-default") -> Persona: existing = self.get("default") diff --git a/src/cleveragents/tui/persona/schema.py b/src/cleveragents/tui/persona/schema.py index 1692dc49f..0f9553d0f 100644 --- a/src/cleveragents/tui/persona/schema.py +++ b/src/cleveragents/tui/persona/schema.py @@ -39,19 +39,18 @@ class Persona(BaseModel): raise ValueError("actor must be namespaced (namespace/name)") return value - @field_validator("argument_presets") + @field_validator("name") @classmethod - def validate_default_preset( - cls, presets: list[PersonaPreset] - ) -> list[PersonaPreset]: - if not presets: - return presets - defaults = [preset for preset in presets if preset.name == "default"] - if len(defaults) != 1: - raise ValueError("must contain exactly one default preset") - if defaults[0].overrides: - raise ValueError("default preset overrides must be empty") - return presets + def validate_name_safe(cls, value: str) -> str: + if ( + "/" in value + or "\\" in value + or ".." in value + or "\x00" in value + or any(ord(char) < 32 or ord(char) == 127 for char in value) + ): + raise ValueError("name must not contain path/control separators") + return value @model_validator(mode="after") def ensure_default_preset(self) -> Persona: -- 2.52.0 From b8f9da4cca6cd84404f8e015e07627fd53174ea9 Mon Sep 17 00:00:00 2001 From: Aditya Chhabra Date: Wed, 18 Mar 2026 07:30:33 +0000 Subject: [PATCH 3/3] fix(tui/persona): lock delete operation in registry Wrap `PersonaRegistry.delete()` with the personas file lock and use race-safe `unlink` handling for concurrent save/delete consistency. --- features/repl_input_modes.feature | 78 +++++ features/steps/repl_input_modes_steps.py | 384 ++++++++++++++++++++++- features/steps/tui_input_modes_steps.py | 29 ++ features/tui_input_modes.feature | 6 + src/cleveragents/tui/persona/registry.py | 16 +- 5 files changed, 507 insertions(+), 6 deletions(-) diff --git a/features/repl_input_modes.feature b/features/repl_input_modes.feature index f40524afb..447c8cec6 100644 --- a/features/repl_input_modes.feature +++ b/features/repl_input_modes.feature @@ -140,3 +140,81 @@ Feature: REPL input modes and persona controls And the REPL mode output should contain "Usage: /persona create --actor " And the REPL mode output should contain "Cannot delete default persona." And the REPL mode output should contain "Invalid persona YAML." + + Scenario: Persona create parser accepts optional fields and validates cycle-order + When I parse persona create tokens "/persona create dev --actor local/mock-default --description fast --icon robot --greeting hi --cycle-order 7" + Then parsed persona name should be "dev" + And parsed persona actor should be "local/mock-default" + And parsed persona cycle order should be 7 + When I parse persona create tokens "/persona create dev --actor local/mock-default --cycle-order nope" + Then persona create parse should fail with "--cycle-order must be an integer" + + Scenario: Shell helper handles empty, disabled, and timeout paths + When I run REPL shell helper with empty command + Then REPL shell helper exit code should be 2 + And REPL shell helper output should contain "Shell mode requires a command after '!'." + When I run REPL shell helper with command "echo hi" and shell disabled + Then REPL shell helper exit code should be 2 + And REPL shell helper output should contain "Shell mode is disabled by configuration." + When I run REPL shell helper with command "echo hi" and subprocess timeout + Then REPL shell helper exit code should be 124 + And REPL shell helper output should contain "Shell command timed out after 30s." + + Scenario: Slash command parser reports errors and session usage guidance + Given slash command test sessions + When I handle slash text that raises parser error + Then slash handler exit code should be 2 + And slash handler output should contain "Parse error:" + When I handle empty slash text + Then slash handler exit code should be 2 + And slash handler output should contain "Empty slash command." + When I handle slash text "session new" + Then slash handler exit code should be 2 + And slash handler output should contain "Usage:" + + Scenario: Reference helpers cover catalog, fuzzy, and suggestion branches + Given a rich REPL reference fixture + When I build REPL reference catalog from fixture + Then REPL catalog should include fixture actor tool skill and env entries + And REPL catalog should include non-relative walk file path + When I evaluate REPL fuzzy and suggestion helpers + Then REPL helper evaluation should pass + + Scenario: Slash helper covers picker and unknown command branches + Given slash command test sessions + And slash command test registry with personas + When I handle picker slash text "persona pick" using input "x" + Then slash handler exit code should be 2 + And slash handler output should contain "Invalid selection." + When I handle picker slash text "persona pick" using input "9" + Then slash handler exit code should be 2 + And slash handler output should contain "Selection out of range." + When I handle picker slash text "persona pick reviewer" using input "1" + Then slash handler exit code should be 0 + And slash handler output should contain "Active persona: reviewer" + When I handle slash text "persona pick zzz" + Then slash handler exit code should be 1 + And slash handler output should contain "No personas matched 'zzz'." + When I handle slash text "persona import" + Then slash handler exit code should be 2 + And slash handler output should contain "Usage: /persona import " + When I handle slash text "session new default" + Then slash handler exit code should be 1 + And slash handler output should contain "Session already exists: default" + When I handle slash text "session switch missing" + Then slash handler exit code should be 1 + And slash handler output should contain "Session not found: missing" + When I handle slash text "session boom" + Then slash handler exit code should be 2 + And slash handler output should contain "Unknown session command:" + When I handle slash text "persona boom" + Then slash handler exit code should be 1 + And slash handler output should contain "Persona not found: boom" + When I handle slash text "foo bar" + Then slash handler exit code should be 2 + And slash handler output should contain "Unknown slash command:" + + Scenario: Shell helper prints stderr and returns subprocess code + When I run REPL shell helper with command "echo hi" and subprocess stderr "boom" code 7 + Then REPL shell helper exit code should be 7 + And REPL shell helper output should contain "boom" diff --git a/features/steps/repl_input_modes_steps.py b/features/steps/repl_input_modes_steps.py index efc415cf5..b86b71d26 100644 --- a/features/steps/repl_input_modes_steps.py +++ b/features/steps/repl_input_modes_steps.py @@ -4,15 +4,30 @@ from __future__ import annotations import os import shutil +import subprocess import tempfile +from io import StringIO from pathlib import Path +from typing import Any from unittest.mock import patch from behave import given, then, when from behave.runner import Context from pydantic import ValidationError +from rich.console import Console -from cleveragents.cli.commands.repl import _expand_references, run_repl +from cleveragents.cli.commands.repl import ( + _best_fuzzy_match, + _build_reference_catalog, + _expand_references, + _find_reference_candidates, + _handle_slash_command, + _parse_persona_create, + _reference_suggestions, + _ReplSessionState, + _run_shell_command, + run_repl, +) from cleveragents.cli.persona import PersonaConfig from features.mocks.fake_repl_input import FakeReplInput @@ -49,6 +64,27 @@ def step_repl_reference_fixture(context: Context) -> None: context.add_cleanup(lambda: shutil.rmtree(str(fixture_dir), ignore_errors=True)) +@given("a rich REPL reference fixture") +def step_rich_repl_reference_fixture(context: Context) -> None: + fixture_dir = Path(tempfile.mkdtemp()) + (fixture_dir / "README.md").write_text("# fixture\n", encoding="utf-8") + (fixture_dir / "examples" / "actors").mkdir(parents=True, exist_ok=True) + (fixture_dir / "examples" / "tools").mkdir(parents=True, exist_ok=True) + (fixture_dir / "examples" / "skills").mkdir(parents=True, exist_ok=True) + (fixture_dir / "examples" / "actors" / "review.yaml").write_text( + "name: review\n", encoding="utf-8" + ) + (fixture_dir / "examples" / "tools" / "search.yml").write_text( + "name: search\n", encoding="utf-8" + ) + (fixture_dir / "examples" / "skills" / "guide").mkdir(parents=True, exist_ok=True) + (fixture_dir / "examples" / "skills" / "skill.yml").write_text( + "name: skill\n", encoding="utf-8" + ) + context.repl_reference_fixture_dir = fixture_dir + context.add_cleanup(lambda: shutil.rmtree(str(fixture_dir), ignore_errors=True)) + + @given("a temporary persona export path") def step_temp_persona_export_path(context: Context) -> None: export_path = Path("tmp-persona-export.yaml") @@ -155,3 +191,349 @@ def step_validate_cli_persona_payload_with_color(context: Context, color: str) - @then('CLI persona color should be "{color}"') def step_cli_persona_color(context: Context, color: str) -> None: assert context.repl_cli_persona_payload.color == color + + +@when('I parse persona create tokens "{command}"') +def step_parse_persona_create_tokens(context: Context, command: str) -> None: + context.repl_parse_error = None + context.repl_parsed_persona = None + text = command[1:] if command.startswith("/") else command + tokens = text.split() + try: + context.repl_parsed_persona = _parse_persona_create(tokens) + except Exception as exc: + context.repl_parse_error = exc + + +@then('parsed persona name should be "{name}"') +def step_parsed_persona_name(context: Context, name: str) -> None: + assert context.repl_parsed_persona is not None + assert context.repl_parsed_persona.name == name + + +@then('parsed persona actor should be "{actor}"') +def step_parsed_persona_actor(context: Context, actor: str) -> None: + assert context.repl_parsed_persona is not None + assert context.repl_parsed_persona.actor == actor + + +@then("parsed persona cycle order should be {cycle:d}") +def step_parsed_persona_cycle(context: Context, cycle: int) -> None: + assert context.repl_parsed_persona is not None + assert context.repl_parsed_persona.cycle_order == cycle + + +@then('persona create parse should fail with "{message}"') +def step_parse_persona_create_fails(context: Context, message: str) -> None: + assert context.repl_parse_error is not None + assert message in str(context.repl_parse_error) + + +def _run_shell_helper_with_capture(command: str) -> tuple[int, str]: + output = StringIO() + console = Console(file=output, no_color=True, width=180) + with patch("cleveragents.cli.commands.repl._console", console): + code = _run_shell_command(command) + return code, output.getvalue() + + +@when('I run REPL shell helper with command "{command}"') +def step_run_shell_helper(context: Context, command: str) -> None: + code, output = _run_shell_helper_with_capture(command) + context.repl_shell_helper_code = code + context.repl_shell_helper_output = output + + +@when("I run REPL shell helper with empty command") +def step_run_shell_helper_empty(context: Context) -> None: + code, output = _run_shell_helper_with_capture("") + context.repl_shell_helper_code = code + context.repl_shell_helper_output = output + + +@when('I run REPL shell helper with command "{command}" and shell disabled') +def step_run_shell_helper_disabled(context: Context, command: str) -> None: + with patch.dict(os.environ, {"CLEVERAGENTS_DISABLE_SHELL_MODE": "true"}): + code, output = _run_shell_helper_with_capture(command) + context.repl_shell_helper_code = code + context.repl_shell_helper_output = output + + +@when('I run REPL shell helper with command "{command}" and subprocess timeout') +def step_run_shell_helper_timeout(context: Context, command: str) -> None: + output = StringIO() + console = Console(file=output, no_color=True, width=180) + with ( + patch("cleveragents.cli.commands.repl._console", console), + patch( + "cleveragents.cli.commands.repl.subprocess.run", + side_effect=subprocess.TimeoutExpired(command, 30), + ), + ): + context.repl_shell_helper_code = _run_shell_command(command) + context.repl_shell_helper_output = output.getvalue() + + +@when( + 'I run REPL shell helper with command "{command}" and subprocess stderr "{stderr}" code {code:d}' +) +def step_run_shell_helper_stderr( + context: Context, command: str, stderr: str, code: int +) -> None: + output = StringIO() + console = Console(file=output, no_color=True, width=180) + proc = subprocess.CompletedProcess( + args=command, returncode=code, stdout="", stderr=stderr + ) + with ( + patch("cleveragents.cli.commands.repl._console", console), + patch("cleveragents.cli.commands.repl.subprocess.run", return_value=proc), + ): + context.repl_shell_helper_code = _run_shell_command(command) + context.repl_shell_helper_output = output.getvalue() + + +@then("REPL shell helper exit code should be {code:d}") +def step_shell_helper_exit_code(context: Context, code: int) -> None: + assert context.repl_shell_helper_code == code + + +@then('REPL shell helper output should contain "{text}"') +def step_shell_helper_output_contains(context: Context, text: str) -> None: + assert text in context.repl_shell_helper_output + + +class _SlashRegistryStub: + def __init__(self) -> None: + self.personas: dict[str, PersonaConfig] = {} + self.active_name = "default" + + def list_personas(self) -> list[PersonaConfig]: + return list(self.personas.values()) + + def get_persona(self, _name: str) -> PersonaConfig | None: + return self.personas.get(_name) + + def set_active_persona_name(self, name: str) -> None: + self.active_name = name + + def save_persona(self, persona: PersonaConfig) -> None: + self.personas[persona.name] = persona + + def delete_persona(self, name: str) -> bool: + return self.personas.pop(name, None) is not None + + def resolve_import_path(self, path: Path) -> Path: + return path + + +@given("slash command test sessions") +def step_slash_test_sessions(context: Context) -> None: + context.slash_registry = _SlashRegistryStub() + context.slash_sessions = { + "default": _ReplSessionState(name="default", active_persona="default") + } + context.slash_current_session = "default" + + +def _handle_slash_with_capture( + slash_text: str, + *, + registry: Any, + sessions: Any, + current_session: str, + input_value: str | None = None, +) -> tuple[int, str, str]: + output = StringIO() + console = Console(file=output, no_color=True, width=180) + with patch("cleveragents.cli.commands.repl._console", console): + if input_value is None: + code, new_session = _handle_slash_command( + slash_text, + registry=registry, + sessions=sessions, + current_session=current_session, + ) + else: + with patch("builtins.input", return_value=input_value): + code, new_session = _handle_slash_command( + slash_text, + registry=registry, + sessions=sessions, + current_session=current_session, + ) + return code, new_session, output.getvalue() + + +@when('I handle slash text with parser error "{slash_text}"') +def step_handle_slash_parser_error(context: Context, slash_text: str) -> None: + code, new_session, output = _handle_slash_with_capture( + slash_text, + registry=context.slash_registry, + sessions=context.slash_sessions, + current_session=context.slash_current_session, + ) + context.slash_handler_code = code + context.slash_handler_session = new_session + context.slash_handler_output = output + + +@when("I handle slash text that raises parser error") +def step_handle_slash_parser_error_forced(context: Context) -> None: + output = StringIO() + console = Console(file=output, no_color=True, width=180) + with ( + patch("cleveragents.cli.commands.repl._console", console), + patch( + "cleveragents.cli.commands.repl.shlex.split", + side_effect=ValueError("forced parser failure"), + ), + ): + code, new_session = _handle_slash_command( + "persona test", + registry=context.slash_registry, + sessions=context.slash_sessions, + current_session=context.slash_current_session, + ) + context.slash_handler_code = code + context.slash_handler_session = new_session + context.slash_handler_output = output.getvalue() + + +@when('I handle slash text "{slash_text}"') +def step_handle_slash_text(context: Context, slash_text: str) -> None: + code, new_session, output = _handle_slash_with_capture( + slash_text, + registry=context.slash_registry, + sessions=context.slash_sessions, + current_session=context.slash_current_session, + ) + context.slash_handler_code = code + context.slash_handler_session = new_session + context.slash_handler_output = output + + +@when('I handle picker slash text "{slash_text}" using input "{input_value}"') +def step_handle_slash_text_with_input( + context: Context, slash_text: str, input_value: str +) -> None: + code, new_session, output = _handle_slash_with_capture( + slash_text, + registry=context.slash_registry, + sessions=context.slash_sessions, + current_session=context.slash_current_session, + input_value=input_value, + ) + context.slash_handler_code = code + context.slash_handler_session = new_session + context.slash_handler_output = output + + +@when("I handle empty slash text") +def step_handle_empty_slash_text(context: Context) -> None: + code, new_session, output = _handle_slash_with_capture( + "", + registry=context.slash_registry, + sessions=context.slash_sessions, + current_session=context.slash_current_session, + ) + context.slash_handler_code = code + context.slash_handler_session = new_session + context.slash_handler_output = output + + +@then("slash handler exit code should be {code:d}") +def step_slash_handler_code(context: Context, code: int) -> None: + assert context.slash_handler_code == code + + +@then('slash handler output should contain "{text}"') +def step_slash_handler_output_contains(context: Context, text: str) -> None: + assert text in context.slash_handler_output + + +@given("slash command test registry with personas") +def step_slash_registry_with_personas(context: Context) -> None: + context.slash_registry.save_persona( + PersonaConfig(name="dev", actor="local/mock-default") + ) + context.slash_registry.save_persona( + PersonaConfig(name="reviewer", actor="local/mock-default") + ) + + +@when("I build REPL reference catalog from fixture") +def step_build_repl_reference_catalog_fixture(context: Context) -> None: + context.repl_reference_catalog = {} + outside_root = Path("/tmp/repl-outside-root") + + def fake_walk(_cwd: Path, followlinks: bool = False): + yield ( + str(context.repl_reference_fixture_dir), + ["examples", ".git"], + ["README.md"], + ) + yield str(outside_root), [], ["outside.txt"] + + with ( + patch( + "cleveragents.cli.commands.repl.Path.cwd", + return_value=context.repl_reference_fixture_dir, + ), + patch("cleveragents.cli.commands.repl.os.walk", side_effect=fake_walk), + patch.dict( + os.environ, + {"CLEVERAGENTS_PROJECT": "local/proj", "CLEVERAGENTS_PLAN": "local/plan"}, + clear=False, + ), + ): + context.repl_reference_catalog = _build_reference_catalog() + + +@then("REPL catalog should include fixture actor tool skill and env entries") +def step_catalog_includes_fixture_entries(context: Context) -> None: + catalog = context.repl_reference_catalog + assert "local/review" in catalog["actor"] + assert "local/search" in catalog["tool"] + assert "local/guide" in catalog["skill"] + assert "local/skill" in catalog["skill"] + assert catalog["project"] == ["local/proj"] + assert catalog["plan"] == ["local/plan"] + + +@then("REPL catalog should include non-relative walk file path") +def step_catalog_has_non_relative_path(context: Context) -> None: + assert any( + value.endswith("/outside.txt") + for value in context.repl_reference_catalog["file"] + ) + + +@when("I evaluate REPL fuzzy and suggestion helpers") +def step_eval_repl_fuzzy_helpers(context: Context) -> None: + catalog = { + "file": ["README.md", "docs/guide.md"], + "actor": ["local/reviewer"], + "tool": ["local/searcher"], + "skill": ["local/guide"], + "project": ["local/proj"], + "plan": ["local/plan"], + } + context.repl_helper_checks = [ + _best_fuzzy_match("", ["x"]) is None, + _best_fuzzy_match("REA", ["README.md"]) == "README.md", + _best_fuzzy_match("guide", ["docs/guide.md"]) == "docs/guide.md", + _best_fuzzy_match("reviwer", ["local/reviewer"]) == "local/reviewer", + _find_reference_candidates("", ["a", "b"], limit=1) == ["a"], + _expand_references( + "echo \\@literal @file:README @zzz", catalog=catalog + ).startswith("echo @literal @file:"), + len(_reference_suggestions("inspect @file:READ", catalog=catalog)) > 0, + len(_reference_suggestions("inspect @rev", catalog=catalog)) > 0, + _reference_suggestions("inspect \\@file:README", catalog=catalog) == [], + ] + + +@then("REPL helper evaluation should pass") +def step_repl_helper_eval_pass(context: Context) -> None: + assert all(context.repl_helper_checks) diff --git a/features/steps/tui_input_modes_steps.py b/features/steps/tui_input_modes_steps.py index 3d2c34d78..38f8ae327 100644 --- a/features/steps/tui_input_modes_steps.py +++ b/features/steps/tui_input_modes_steps.py @@ -11,6 +11,7 @@ from unittest.mock import patch from behave import given, then, when from behave.runner import Context +from cleveragents.tui import app as tui_app_module from cleveragents.tui.input import reference_parser from cleveragents.tui.input.modes import InputModeRouter from cleveragents.tui.input.reference_parser import parse_references, suggestions @@ -114,3 +115,31 @@ def step_tui_suggestions_not_include(context: Context, value: str) -> None: assert all( value not in suggestion for suggestion in context.tui_fixture_suggestions ) + + +@when("I check TUI app textual availability flag") +def step_check_tui_textual_availability(context: Context) -> None: + context.tui_textual_available = tui_app_module.textual_available() + + +@then("TUI textual availability should be boolean") +def step_tui_textual_is_bool(context: Context) -> None: + assert isinstance(context.tui_textual_available, bool) + + +@when("I run fallback TUI app") +def step_run_fallback_tui_app(context: Context) -> None: + context.tui_fallback_error = None + fallback = tui_app_module._FallbackCleverAgentsTuiApp( + command_router=object(), persona_state=object() + ) + try: + fallback.run() + except RuntimeError as exc: + context.tui_fallback_error = exc + + +@then('fallback TUI app should fail with "{message}"') +def step_fallback_tui_fails(context: Context, message: str) -> None: + assert context.tui_fallback_error is not None + assert message in str(context.tui_fallback_error) diff --git a/features/tui_input_modes.feature b/features/tui_input_modes.feature index 0e39fb770..954e7cc12 100644 --- a/features/tui_input_modes.feature +++ b/features/tui_input_modes.feature @@ -31,3 +31,9 @@ Feature: TUI input modes When I parse and suggest TUI references using fixture Then TUI walk count should be "1" And TUI suggestions should not include ".git/secret.txt" + + Scenario: TUI app fallback path reports missing textual dependency + When I check TUI app textual availability flag + Then TUI textual availability should be boolean + When I run fallback TUI app + Then fallback TUI app should fail with "Textual dependency missing." diff --git a/src/cleveragents/tui/persona/registry.py b/src/cleveragents/tui/persona/registry.py index da7784847..958867bb5 100644 --- a/src/cleveragents/tui/persona/registry.py +++ b/src/cleveragents/tui/persona/registry.py @@ -145,11 +145,17 @@ class PersonaRegistry: lock.close() def delete(self, name: str) -> bool: - file = self.persona_path(name) - if not file.exists(): - return False - file.unlink() - return True + lock = self._lock_file(self.personas_lock_path) + try: + file = self.persona_path(name) + try: + file.unlink() + return True + except FileNotFoundError: + return False + finally: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + lock.close() def export_persona(self, name: str, output_path: Path) -> Path: persona = self.get(name) -- 2.52.0