From fbfbab7ba1ee1b2f439bc77dff3d12af5d8a0318 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 9 Sep 2026 20:19:22 +0100 Subject: [PATCH 1/2] refactor(terraphim_agent): extract cli_helpers module (~200 LOC) Step 1 of #211. Moves the formatting/word-boundary/UI-building helpers out of main.rs (which was 6 842 LOC, the only file over the soft threshold across the entire Terraphim polyrepo family per the 2026-09-09 de-monolithize census). No behaviour change. Same signatures, same callers. Helpers are pub(crate) so main.rs can still reference them via the existing call-site names (truncate_snippet, format_auto_route_line, is_word_boundary_char, is_at_word_boundary, format_replacement_link, transparent_style, create_block); all call sites were updated implicitly via a single 'use cli_helpers::*;' import. The 7 unit tests for is_word_boundary_* and the 4 tests for truncate_snippet and the 1 test for format_auto_route_line move with their functions into cli_helpers.rs. main.rs: 6 842 -> 6 610 LOC (-232). cli_helpers.rs: 0 -> 251 LOC (new). Verified locally: cargo build -p terraphim_agent --features server OK cargo clippy -p terraphim_agent --features server -- -D warnings OK cargo test -p terraphim_agent --features server --lib --bin terraphim-agent 582 passed, 0 failed --- crates/terraphim_agent/src/cli_helpers.rs | 252 ++++++++++++++++++++++ crates/terraphim_agent/src/main.rs | 242 +-------------------- 2 files changed, 257 insertions(+), 237 deletions(-) create mode 100644 crates/terraphim_agent/src/cli_helpers.rs diff --git a/crates/terraphim_agent/src/cli_helpers.rs b/crates/terraphim_agent/src/cli_helpers.rs new file mode 100644 index 0000000..a6b62ed --- /dev/null +++ b/crates/terraphim_agent/src/cli_helpers.rs @@ -0,0 +1,252 @@ +//! Small CLI/output formatting and UI-building helpers extracted from `main.rs`. +//! +//! Originally part of the monolithic `main.rs`; moved here as step 1 of the +//! de-monolithization tracked in terraphim/terraphim-clients#211. +//! +//! These helpers have no shared mutable state with the dispatch logic in +//! `main.rs` and are reusable by `repl/handler.rs` and `service.rs`. + +use ratatui::{ + style::{Color, Style}, + widgets::{Block, Borders}, +}; + +/// Truncate a snippet at a UTF-8 char boundary, appending "..." when truncated. +/// +/// Naive `&s[..max]` panics when `max` lands inside a multi-byte char (e.g. typographic +/// quotes from email subjects). This walks char boundaries and stops at the last one +/// whose byte index is ≤ max. +pub(crate) fn truncate_snippet(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_string(); + } + let cutoff = s + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= max_bytes) + .last() + .unwrap_or(0); + format!("{}...", &s[..cutoff]) +} + +#[cfg(test)] +mod truncate_snippet_tests { + use super::truncate_snippet; + + #[test] + fn short_string_unchanged() { + assert_eq!(truncate_snippet("hello", 120), "hello"); + } + + #[test] + fn ascii_truncated() { + let s = "a".repeat(200); + let out = truncate_snippet(&s, 120); + assert!(out.ends_with("...")); + assert_eq!(out.len(), 123); + } + + #[test] + fn multibyte_does_not_panic() { + // Reproduces crates/terraphim_agent/src/main.rs:1414 panic where + // `&s[..120]` landed inside a typographic quote (3 bytes: e2 80 9c). + let s = "Includes dependencies for llama.cpp, integration with retreival, and CLI/GUI flows; the project positions itself as \u{201C}ultimate open-source RAG app\u{201D} with curated features."; + let out = truncate_snippet(s, 120); + // Must not panic and must be a valid UTF-8 string ending in "..." + assert!(out.ends_with("...")); + assert!(out.is_char_boundary(out.len())); + } + + #[test] + fn cyrillic_safe() { + let s = "консенсус ".repeat(20); + let out = truncate_snippet(&s, 120); + assert!(out.ends_with("...")); + } +} + +/// Format the one-line stderr explainability message emitted when the search +/// command auto-routes (i.e. the user did not pass `--role`). +/// +/// Exact format pinned by the design (section 5): +/// `[auto-route] picked role "" (score=, candidates=); to override, pass --role` +pub(crate) fn format_auto_route_line(result: &terraphim_service::auto_route::AutoRouteResult) -> String { + format!( + "[auto-route] picked role \"{}\" (score={}, candidates={}); to override, pass --role", + result.role.as_str(), + result.score, + result.candidates.len(), + ) +} + +#[cfg(test)] +mod format_auto_route_line_tests { + use super::format_auto_route_line; + use terraphim_service::auto_route::{AutoRouteReason, AutoRouteResult}; + use terraphim_types::RoleName; + + #[test] + fn pinned_exact_format() { + let r = AutoRouteResult { + role: RoleName::new("Personal Assistant"), + score: 42, + candidates: vec![ + (RoleName::new("Personal Assistant"), 42), + (RoleName::new("Default"), 0), + ], + reason: AutoRouteReason::ScoredWinner, + }; + assert_eq!( + format_auto_route_line(&r), + "[auto-route] picked role \"Personal Assistant\" (score=42, candidates=2); to override, pass --role" + ); + } +} + +/// Check if a character is a word boundary character (not alphanumeric). +pub(crate) fn is_word_boundary_char(c: char) -> bool { + !c.is_alphanumeric() && c != '_' +} + +/// Check if a match position is at word boundaries in the text. +/// Returns true if the character before start (or start of string) and +/// the character after end (or end of string) are word boundary characters. +pub(crate) fn is_at_word_boundary(text: &str, start: usize, end: usize) -> bool { + // Check character before start + let before_ok = if start == 0 { + true + } else { + text[..start] + .chars() + .last() + .map(is_word_boundary_char) + .unwrap_or(true) + }; + + // Check character after end + let after_ok = if end >= text.len() { + true + } else { + text[end..] + .chars() + .next() + .map(is_word_boundary_char) + .unwrap_or(true) + }; + + before_ok && after_ok +} + +/// Format a replacement link from a NormalizedTerm and LinkType. +pub(crate) fn format_replacement_link( + term: &terraphim_types::NormalizedTerm, + link_type: terraphim_hooks::LinkType, +) -> String { + let display_text = term.display(); + match link_type { + terraphim_hooks::LinkType::WikiLinks => format!("[[{}]]", display_text), + terraphim_hooks::LinkType::HTMLLinks => format!( + "{}", + term.url.as_deref().unwrap_or_default(), + display_text + ), + terraphim_hooks::LinkType::MarkdownLinks => format!( + "[{}]({})", + display_text, + term.url.as_deref().unwrap_or_default() + ), + terraphim_hooks::LinkType::PlainText => display_text.to_string(), + } +} + +/// Create a transparent style for UI elements +pub(crate) fn transparent_style() -> Style { + Style::default().bg(Color::Reset) +} + +/// Create a block with optional transparent background +pub(crate) fn create_block(title: &str, transparent: bool) -> Block<'_> { + let block = Block::default().title(title).borders(Borders::ALL); + + if transparent { + block.style(transparent_style()) + } else { + block + } +} + +#[cfg(test)] +mod word_boundary_tests { + use super::{is_at_word_boundary, is_word_boundary_char}; + + #[test] + fn test_is_word_boundary_char() { + // Non-alphanumeric chars are boundaries + assert!(is_word_boundary_char(' ')); + assert!(is_word_boundary_char('\t')); + assert!(is_word_boundary_char('\n')); + assert!(is_word_boundary_char('.')); + assert!(is_word_boundary_char(',')); + assert!(is_word_boundary_char('(')); + assert!(is_word_boundary_char(')')); + assert!(is_word_boundary_char('"')); + + // Alphanumeric chars are NOT boundaries + assert!(!is_word_boundary_char('a')); + assert!(!is_word_boundary_char('Z')); + assert!(!is_word_boundary_char('0')); + assert!(!is_word_boundary_char('9')); + + // Underscore is NOT a boundary (word char in most regex) + assert!(!is_word_boundary_char('_')); + } + + #[test] + fn test_is_at_word_boundary_start_of_string() { + // At start of string, "npm" should be at boundary + let text = "npm install"; + assert!(is_at_word_boundary(text, 0, 3)); // "npm" at start + } + + #[test] + fn test_is_at_word_boundary_end_of_string() { + // At end of string, "npm" should be at boundary + let text = "install npm"; + assert!(is_at_word_boundary(text, 8, 11)); // "npm" at end + } + + #[test] + fn test_is_at_word_boundary_middle_with_spaces() { + // In middle with spaces, "npm" should be at boundary + let text = "run npm install"; + assert!(is_at_word_boundary(text, 4, 7)); // "npm" surrounded by spaces + } + + #[test] + fn test_is_at_word_boundary_not_at_boundary() { + // "npm" embedded in "anpmb" should NOT be at boundary + let text = "anpmb"; + assert!(!is_at_word_boundary(text, 1, 4)); // "npm" embedded + } + + #[test] + fn test_is_at_word_boundary_partial_boundary() { + // "npm" at start but not end: "npma" + let text = "npma"; + assert!(!is_at_word_boundary(text, 0, 3)); // "npm" no boundary after + + // "npm" at end but not start: "anpm" + let text2 = "anpm"; + assert!(!is_at_word_boundary(text2, 1, 4)); // "npm" no boundary before + } + + #[test] + fn test_is_at_word_boundary_with_punctuation() { + // Punctuation counts as boundary + let text = "(npm)"; + assert!(is_at_word_boundary(text, 1, 4)); // "npm" between parens + + let text2 = "use npm, please"; + assert!(is_at_word_boundary(text2, 4, 7)); // "npm" followed by comma + } +} \ No newline at end of file diff --git a/crates/terraphim_agent/src/main.rs b/crates/terraphim_agent/src/main.rs index a3a3bad..b7af951 100644 --- a/crates/terraphim_agent/src/main.rs +++ b/crates/terraphim_agent/src/main.rs @@ -14,9 +14,9 @@ use ratatui::{ Terminal, backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::Line, - widgets::{Block, Borders, List, ListItem, Paragraph}, + widgets::{List, ListItem, Paragraph}, }; use serde::Serialize; #[cfg(feature = "repl")] @@ -25,9 +25,12 @@ use terraphim_agent::{forgiving, guard_patterns, learnings, onboarding, robot, t use terraphim_persistence::Persistable; use tokio::runtime::Runtime; +mod cli_helpers; mod listener; mod shell_dispatch; +use cli_helpers::*; + // Robot mode and forgiving CLI - always available // Learning capture for failed commands @@ -49,98 +52,6 @@ enum LogicalOperatorCli { Or, } -/// Truncate a snippet at a UTF-8 char boundary, appending "..." when truncated. -/// -/// Naive `&s[..max]` panics when `max` lands inside a multi-byte char (e.g. typographic -/// quotes from email subjects). This walks char boundaries and stops at the last one -/// whose byte index is ≤ max. -fn truncate_snippet(s: &str, max_bytes: usize) -> String { - if s.len() <= max_bytes { - return s.to_string(); - } - let cutoff = s - .char_indices() - .map(|(i, _)| i) - .take_while(|&i| i <= max_bytes) - .last() - .unwrap_or(0); - format!("{}...", &s[..cutoff]) -} - -#[cfg(test)] -mod truncate_snippet_tests { - use super::truncate_snippet; - - #[test] - fn short_string_unchanged() { - assert_eq!(truncate_snippet("hello", 120), "hello"); - } - - #[test] - fn ascii_truncated() { - let s = "a".repeat(200); - let out = truncate_snippet(&s, 120); - assert!(out.ends_with("...")); - assert_eq!(out.len(), 123); - } - - #[test] - fn multibyte_does_not_panic() { - // Reproduces crates/terraphim_agent/src/main.rs:1414 panic where - // `&s[..120]` landed inside a typographic quote (3 bytes: e2 80 9c). - let s = "Includes dependencies for llama.cpp, integration with retreival, and CLI/GUI flows; the project positions itself as \u{201C}ultimate open-source RAG app\u{201D} with curated features."; - let out = truncate_snippet(s, 120); - // Must not panic and must be a valid UTF-8 string ending in "..." - assert!(out.ends_with("...")); - assert!(out.is_char_boundary(out.len())); - } - - #[test] - fn cyrillic_safe() { - let s = "консенсус ".repeat(20); - let out = truncate_snippet(&s, 120); - assert!(out.ends_with("...")); - } -} - -/// Format the one-line stderr explainability message emitted when the search -/// command auto-routes (i.e. the user did not pass `--role`). -/// -/// Exact format pinned by the design (section 5): -/// `[auto-route] picked role "" (score=, candidates=); to override, pass --role` -fn format_auto_route_line(result: &terraphim_service::auto_route::AutoRouteResult) -> String { - format!( - "[auto-route] picked role \"{}\" (score={}, candidates={}); to override, pass --role", - result.role.as_str(), - result.score, - result.candidates.len(), - ) -} - -#[cfg(test)] -mod format_auto_route_line_tests { - use super::format_auto_route_line; - use terraphim_service::auto_route::{AutoRouteReason, AutoRouteResult}; - use terraphim_types::RoleName; - - #[test] - fn pinned_exact_format() { - let r = AutoRouteResult { - role: RoleName::new("Personal Assistant"), - score: 42, - candidates: vec![ - (RoleName::new("Personal Assistant"), 42), - (RoleName::new("Default"), 0), - ], - reason: AutoRouteReason::ScoredWinner, - }; - assert_eq!( - format_auto_route_line(&r), - "[auto-route] picked role \"Personal Assistant\" (score=42, candidates=2); to override, pass --role" - ); - } -} - /// Show helpful usage information when run without a TTY fn show_usage_info() { println!("Terraphim AI Agent v{}", env!("CARGO_PKG_VERSION")); @@ -230,78 +141,6 @@ pub enum BoundaryMode { Word, } -/// Check if a character is a word boundary character (not alphanumeric). -fn is_word_boundary_char(c: char) -> bool { - !c.is_alphanumeric() && c != '_' -} - -/// Check if a match position is at word boundaries in the text. -/// Returns true if the character before start (or start of string) and -/// the character after end (or end of string) are word boundary characters. -fn is_at_word_boundary(text: &str, start: usize, end: usize) -> bool { - // Check character before start - let before_ok = if start == 0 { - true - } else { - text[..start] - .chars() - .last() - .map(is_word_boundary_char) - .unwrap_or(true) - }; - - // Check character after end - let after_ok = if end >= text.len() { - true - } else { - text[end..] - .chars() - .next() - .map(is_word_boundary_char) - .unwrap_or(true) - }; - - before_ok && after_ok -} - -/// Format a replacement link from a NormalizedTerm and LinkType. -fn format_replacement_link( - term: &terraphim_types::NormalizedTerm, - link_type: terraphim_hooks::LinkType, -) -> String { - let display_text = term.display(); - match link_type { - terraphim_hooks::LinkType::WikiLinks => format!("[[{}]]", display_text), - terraphim_hooks::LinkType::HTMLLinks => format!( - "{}", - term.url.as_deref().unwrap_or_default(), - display_text - ), - terraphim_hooks::LinkType::MarkdownLinks => format!( - "[{}]({})", - display_text, - term.url.as_deref().unwrap_or_default() - ), - terraphim_hooks::LinkType::PlainText => display_text.to_string(), - } -} - -/// Create a transparent style for UI elements -fn transparent_style() -> Style { - Style::default().bg(Color::Reset) -} - -/// Create a block with optional transparent background -fn create_block(title: &str, transparent: bool) -> Block<'_> { - let block = Block::default().title(title).borders(Borders::ALL); - - if transparent { - block.style(transparent_style()) - } else { - block - } -} - #[derive(Debug, Clone, PartialEq)] enum ViewMode { Search, @@ -428,77 +267,6 @@ mod tests { ); } - #[test] - fn test_is_word_boundary_char() { - // Non-alphanumeric chars are boundaries - assert!(is_word_boundary_char(' ')); - assert!(is_word_boundary_char('\t')); - assert!(is_word_boundary_char('\n')); - assert!(is_word_boundary_char('.')); - assert!(is_word_boundary_char(',')); - assert!(is_word_boundary_char('(')); - assert!(is_word_boundary_char(')')); - assert!(is_word_boundary_char('"')); - - // Alphanumeric chars are NOT boundaries - assert!(!is_word_boundary_char('a')); - assert!(!is_word_boundary_char('Z')); - assert!(!is_word_boundary_char('0')); - assert!(!is_word_boundary_char('9')); - - // Underscore is NOT a boundary (word char in most regex) - assert!(!is_word_boundary_char('_')); - } - - #[test] - fn test_is_at_word_boundary_start_of_string() { - // At start of string, "npm" should be at boundary - let text = "npm install"; - assert!(is_at_word_boundary(text, 0, 3)); // "npm" at start - } - - #[test] - fn test_is_at_word_boundary_end_of_string() { - // At end of string, "npm" should be at boundary - let text = "install npm"; - assert!(is_at_word_boundary(text, 8, 11)); // "npm" at end - } - - #[test] - fn test_is_at_word_boundary_middle_with_spaces() { - // In middle with spaces, "npm" should be at boundary - let text = "run npm install"; - assert!(is_at_word_boundary(text, 4, 7)); // "npm" surrounded by spaces - } - - #[test] - fn test_is_at_word_boundary_not_at_boundary() { - // "npm" embedded in "anpmb" should NOT be at boundary - let text = "anpmb"; - assert!(!is_at_word_boundary(text, 1, 4)); // "npm" embedded - } - - #[test] - fn test_is_at_word_boundary_partial_boundary() { - // "npm" at start but not end: "npma" - let text = "npma"; - assert!(!is_at_word_boundary(text, 0, 3)); // "npm" no boundary after - - // "npm" at end but not start: "anpm" - let text2 = "anpm"; - assert!(!is_at_word_boundary(text2, 1, 4)); // "npm" no boundary before - } - - #[test] - fn test_is_at_word_boundary_with_punctuation() { - // Punctuation counts as boundary - let text = "(npm)"; - assert!(is_at_word_boundary(text, 1, 4)); // "npm" between parens - - let text2 = "use npm, please"; - assert!(is_at_word_boundary(text2, 4, 7)); // "npm" followed by comma - } - #[test] fn resolve_tui_server_url_uses_explicit_then_env_then_default() { let explicit = resolve_tui_server_url_with_env(Some("http://explicit:9000"), None); From 657e73c2ae7b80a006bc39df9149c688060a8403 Mon Sep 17 00:00:00 2001 From: Terraphim Agent Date: Wed, 9 Sep 2026 21:14:55 +0100 Subject: [PATCH 2/2] refactor(terraphim_agent): extract cli_schema module (~878 LOC) Step 2 of #211. Moves the entire CLI schema (Cli struct, Command enum with all variants, all subcommand enums, and helper enums for format/operator/boundary/hook types) into cli_schema.rs. Following the Phase 2 evidence in terraphim/demonolith-workspaces/per-repo/terraphim-clients/phase2_findings_main_rs.md which identified the schema as the next cluster after cli_helpers. --- crates/terraphim_agent/src/cli_schema.rs | 899 ++++++++++++++++++++++ crates/terraphim_agent/src/main.rs | 908 +---------------------- 2 files changed, 914 insertions(+), 893 deletions(-) create mode 100644 crates/terraphim_agent/src/cli_schema.rs diff --git a/crates/terraphim_agent/src/cli_schema.rs b/crates/terraphim_agent/src/cli_schema.rs new file mode 100644 index 0000000..92f0e54 --- /dev/null +++ b/crates/terraphim_agent/src/cli_schema.rs @@ -0,0 +1,899 @@ +//! CLI schema for the `terraphim-agent` binary. +//! +//! Originally part of the monolithic `main.rs`; moved here as step 2 of the +//! de-monolithization tracked in terraphim/terraphim-clients#211 (the first +//! extraction, `cli_helpers`, was step 1 / PR #212). +//! +//! This module holds the clap-derived schema (`Cli`, `Command`, the per-sub +//! enums, and the small `ValueEnum`/format enums they reference). Dispatch, +//! output formatting, and `RobotFormat`/`CommandOutputConfig` stay in +//! `main.rs` because they have their own coupling to output rendering and +//! the robot layer. +//! +//! All items are `pub(crate)` so `main.rs` can pattern-match on them without +//! leaking the schema outside the binary crate. + +use std::path::PathBuf; + +use clap::{Parser, Subcommand, ValueEnum}; +use terraphim_agent::{learnings, robot}; +use terraphim_types::LogicalOperator; + +/// Hook types for Claude Code integration +#[derive(ValueEnum, Debug, Clone)] +pub(crate) enum HookType { + /// Pre-tool-use hook (intercepts tool calls) + PreToolUse, + /// Post-tool-use hook (processes tool results) + PostToolUse, + /// Pre-commit hook (validate before commit) + PreCommit, + /// Prepare-commit-msg hook (enhance commit message) + PrepareCommitMsg, +} + +/// Boundary mode for text replacement +#[derive(ValueEnum, Debug, Clone, Default)] +pub(crate) enum BoundaryMode { + /// Match anywhere (default, current behavior) + #[default] + None, + /// Only match at word boundaries + Word, +} + +#[derive(ValueEnum, Debug, Clone, Default)] +pub(crate) enum OutputFormat { + /// Human-readable output (default) + #[default] + Human, + /// Machine-readable JSON output + Json, + /// Compact JSON for piping + JsonCompact, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommandOutputMode { + Human, + Json, + JsonCompact, +} + +#[derive(ValueEnum, Debug, Clone)] +pub(crate) enum LogicalOperatorCli { + And, + Or, +} + +impl From for LogicalOperator { + fn from(op: LogicalOperatorCli) -> Self { + match op { + LogicalOperatorCli::And => LogicalOperator::And, + LogicalOperatorCli::Or => LogicalOperator::Or, + } + } +} + +#[derive(Parser, Debug)] +#[command( + name = "terraphim-agent", + version, + about = "Terraphim Agent: server-backed fullscreen TUI with offline-capable REPL and CLI commands", + after_long_help = "EXIT CODES (F1.2 contract)\n\ + \n\ + \x20 0 SUCCESS Operation completed successfully\n\ + \x20 1 ERROR_GENERAL Unspecified or unexpected error\n\ + \x20 2 ERROR_USAGE Invalid arguments or unknown command\n\ + \x20 3 ERROR_INDEX_MISSING Required index not initialised\n\ + \x20 4 ERROR_NOT_FOUND No results (only with --fail-on-empty)\n\ + \x20 5 ERROR_AUTH Authentication required or failed\n\ + \x20 6 ERROR_NETWORK Transport-level network error\n\ + \x20 7 ERROR_TIMEOUT Operation exceeded configured timeout\n" +)] +pub(crate) struct Cli { + /// Use server API mode instead of self-contained offline mode + #[arg(long, default_value_t = false)] + pub(crate) server: bool, + /// Server URL for API mode + #[arg(long, default_value = "http://localhost:8000")] + pub(crate) server_url: String, + /// Enable transparent background mode + #[arg(long, default_value_t = false)] + pub(crate) transparent: bool, + /// Enable robot mode for AI agent integration (JSON output, exit codes) + #[arg(long, default_value_t = false)] + pub(crate) robot: bool, + /// Output format (human, json, json-compact) + #[arg(long, value_enum, default_value_t = OutputFormat::Human)] + pub(crate) format: OutputFormat, + /// Path to a JSON config file (overrides settings.toml and persistence) + #[arg(long)] + pub(crate) config: Option, + #[command(subcommand)] + pub(crate) command: Option, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + /// Search documents using the knowledge graph + Search { + /// Primary search query + query: String, + /// Additional search terms for multi-term queries + #[arg(long, num_args = 1.., value_delimiter = ',')] + terms: Option>, + /// Logical operator for combining multiple search terms (and/or) + #[arg(long, value_enum)] + operator: Option, + #[arg(long)] + role: Option, + #[arg(long, default_value_t = 10)] + limit: usize, + #[arg(long, default_value_t = false)] + fail_on_empty: bool, + /// Include pinned KG entries in results + #[arg(long, default_value_t = false)] + include_pinned: bool, + /// Minimum composite quality score (0.0-1.0). Excludes documents below this threshold. + #[arg(long)] + min_quality: Option, + /// Maximum estimated tokens in robot-mode output (4 chars ≈ 1 token) + #[arg(long)] + max_tokens: Option, + /// Maximum characters per content/preview field before truncation + #[arg(long)] + max_content_length: Option, + /// Output field set: full, summary, minimal, or custom:, + #[arg(long)] + fields: Option, + }, + /// Manage roles (list, select) + Roles { + #[command(subcommand)] + sub: RolesSub, + }, + /// Manage configuration (show, set, validate, reload) + Config { + #[command(subcommand)] + sub: ConfigSub, + }, + /// Display the knowledge graph for a role + Graph { + #[arg(long)] + role: Option, + #[arg(long, default_value_t = 50)] + top_k: usize, + /// Show only pinned entries + #[arg(long, default_value_t = false)] + pinned: bool, + }, + /// Manage knowledge graph entries + Kg { + #[command(subcommand)] + sub: KgSub, + }, + /// Chat with the AI using a specific role + #[cfg(feature = "llm")] + Chat { + #[arg(long)] + role: Option, + prompt: String, + #[arg(long)] + model: Option, + }, + /// Extract paragraphs matching knowledge graph terms from text + Extract { + text: String, + #[arg(long)] + role: Option, + #[arg(long, default_value_t = false)] + exclude_term: bool, + }, + /// Replace terms in text using the knowledge graph thesaurus + Replace { + /// Text to replace (reads from stdin if not provided) + text: Option, + #[arg(long)] + role: Option, + /// Output format: plain (default), markdown, wiki, html + #[arg(long)] + format: Option, + /// Boundary mode: none (match anywhere) or word (only at word boundaries) + #[arg(long, default_value = "none")] + boundary: BoundaryMode, + /// Output as JSON with metadata (for hook integration) + #[arg(long, default_value_t = false)] + json: bool, + /// Suppress errors and pass through unchanged on failure + #[arg(long, default_value_t = false)] + fail_open: bool, + }, + /// Validate text against knowledge graph + Validate { + /// Text to validate (reads from stdin if not provided) + text: Option, + /// Role to use for validation + #[arg(long)] + role: Option, + /// Check if all matched terms are connected by a single path + #[arg(long, default_value_t = false)] + connectivity: bool, + /// Validate against a named checklist (e.g., "code_review", "security") + #[arg(long)] + checklist: Option, + /// Output as JSON + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Suggest similar terms using fuzzy matching + Suggest { + /// Query to search for (reads from stdin if not provided) + query: Option, + /// Role to use for suggestions + #[arg(long)] + role: Option, + /// Enable fuzzy matching + #[arg(long, default_value_t = true)] + fuzzy: bool, + /// Minimum similarity threshold (0.0-1.0) + #[arg(long, default_value_t = 0.6)] + threshold: f64, + /// Maximum number of suggestions + #[arg(long, default_value_t = 10)] + limit: usize, + /// Output as JSON + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Unified hook handler for Claude Code integration + Hook { + /// Hook type (pre-tool-use, post-tool-use, pre-commit, etc.) + #[arg(long, value_enum)] + hook_type: HookType, + /// JSON input from Claude Code (reads from stdin if not provided) + #[arg(long)] + input: Option, + /// Role to use for processing + #[arg(long)] + role: Option, + /// Output as JSON (always true for hooks, but explicit) + #[arg(long, default_value_t = true)] + json: bool, + /// Include guard check for destructive commands (git reset --hard, rm -rf, etc.) + /// + /// Defaults to true for pre-tool-use; for other hooks (post-tool-use, + /// pre-commit, prepare-commit-msg) the default is false because they + /// fire after execution or on text inputs that do not need a guard. + #[arg(long, default_value_t = false)] + with_guard: bool, + /// Force the guard check off (overrides `--with-guard` and the per-hook-type default). + /// + /// Use this escape hatch only when you have already vetted the command and + /// need to bypass the safety net. Clap does not auto-derive `--no-with-guard`, + /// hence this explicit negation flag. + #[arg(long, default_value_t = false, conflicts_with = "with_guard")] + no_with_guard: bool, + /// Allow thesaurus-based command rewriting (e.g. `npm install` -> `bun add`) + /// + /// Defaults to **false**. Substitution is opt-in so a stray substring + /// match cannot silently mutate a destructive command. Pass `--rewrite` + /// to enable KG-driven rewriting. + #[arg(long, default_value_t = false)] + rewrite: bool, + }, + /// Check command against safety guard patterns (blocks destructive git/fs commands) + Guard { + /// Command to check (reads from stdin if not provided) + command: Option, + /// Output as JSON + #[arg(long, default_value_t = false)] + json: bool, + /// Suppress errors and pass through unchanged on failure + #[arg(long, default_value_t = false)] + fail_open: bool, + /// Path to custom destructive patterns thesaurus JSON file + #[arg(long)] + guard_thesaurus: Option, + /// Path to custom allowlist thesaurus JSON file + #[arg(long)] + guard_allowlist: Option, + /// Print per-stage evaluation trace (allowlist > destructive > suspicious > default) + /// showing which stage matched and short-circuited. Requires `--json` for structured + /// output; without `--json` the trace is printed to stderr in a readable form. + #[arg(long, default_value_t = false)] + explain: bool, + }, + /// Start fullscreen interactive TUI mode (requires running server) + Interactive, + + /// Start REPL (Read-Eval-Print-Loop) interface + #[cfg(feature = "repl")] + Repl { + /// Start in server mode + #[arg(long)] + server: bool, + /// Server URL for API mode + #[arg(long, default_value = "http://localhost:8000")] + server_url: String, + }, + + /// Interactive setup wizard for first-time configuration + Setup { + /// Apply a specific template directly (skip interactive wizard) + #[arg(long)] + template: Option, + /// Path to use with the template (required for some templates like local-notes) + #[arg(long)] + path: Option, + /// Add a new role to existing configuration (instead of replacing) + #[arg(long, default_value_t = false)] + add_role: bool, + /// List available templates and exit + #[arg(long, default_value_t = false)] + list_templates: bool, + }, + + /// Check for updates without installing + CheckUpdate, + + /// Update to latest version if available + Update, + + /// Learning capture for failed commands + Learn { + #[command(subcommand)] + sub: LearnSub, + }, + + /// Session management for AI coding assistant history + #[cfg(feature = "repl-sessions")] + Sessions { + #[command(subcommand)] + sub: SessionsSub, + }, + + /// Start listener mode for AI agent communication (offline-only) + Listen { + /// Agent identity/name for this listener instance + #[arg(long)] + identity: Option, + /// Optional listener configuration JSON file + #[arg(long)] + config: Option, + /// Start in server mode (rejected -- listen is offline-only) + #[arg(long)] + server: bool, + }, + + /// Manage the compiled thesaurus cache + Cache { + #[command(subcommand)] + sub: CacheSub, + }, + + /// Robot mode self-documentation commands + Robot { + #[command(subcommand)] + sub: RobotSub, + }, + + /// Memory lifecycle management (capture, distill, scope, provenance, retrieve, + /// apply, validate, retire, rubric, second-run) + Memory { + #[command(subcommand)] + sub: MemorySub, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum CacheSub { + /// Flush (delete) compiled thesaurus cache entries + Flush { + /// Specific role to flush (if omitted, flushes all cached thesauri) + #[arg(long)] + role: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum LearnSub { + /// Capture a failed command as a learning + Capture { + /// The command that failed + command: String, + /// The error output (stderr) + #[arg(long)] + error: String, + /// The exit code + #[arg(long, default_value_t = 1)] + exit_code: i32, + /// Enable debug output + #[arg(long, default_value_t = false)] + debug: bool, + }, + /// List recent learnings + List { + /// Number of recent learnings to show + #[arg(long, default_value_t = 10)] + recent: usize, + /// Show global learnings instead of project + #[arg(long, default_value_t = false)] + global: bool, + }, + /// Query learnings by pattern + Query { + /// Search pattern + pattern: String, + /// Use exact match instead of substring + #[arg(long, default_value_t = false)] + exact: bool, + /// Show global learnings instead of project + #[arg(long, default_value_t = false)] + global: bool, + /// Enable semantic matching via KG entities + #[arg(long, default_value_t = false)] + semantic: bool, + }, + /// Add correction to an existing learning + Correct { + /// Learning ID + id: String, + /// The correction to add + #[arg(long)] + correction: String, + }, + /// Record and list user corrections (tool preference, naming, workflow, etc.) + Correction { + #[command(subcommand)] + sub: CorrectionSub, + }, + /// Process hook input from AI agents (reads JSON from stdin) + Hook { + /// AI agent format + #[arg(long, value_enum, default_value = "claude")] + format: learnings::AgentFormat, + /// Hook type for multi-hook pipeline + #[arg(long, value_enum, default_value = "post-tool-use")] + learn_hook_type: learnings::LearnHookType, + }, + /// Install hook for AI agent + InstallHook { + /// AI agent to install hook for + #[arg(value_enum)] + agent: learnings::AgentType, + }, + /// Manage captured procedures (recorded command sequences) + Procedure { + #[command(subcommand)] + sub: ProcedureSub, + }, + /// Compile captured corrections into a thesaurus for the replace command + Compile { + /// Output path for compiled thesaurus JSON + #[arg(long, default_value = "compiled-corrections.json")] + output: PathBuf, + /// Optional: merge with this curated thesaurus file + #[arg(long)] + merge_with: Option, + }, + /// Review and approve/reject knowledge suggestions + #[cfg(feature = "shared-learning")] + Suggest { + #[command(subcommand)] + sub: SuggestSub, + }, + /// Export captured corrections as reviewable KG markdown artefacts + ExportKg { + /// Output directory for KG markdown files + #[arg(long)] + output: PathBuf, + /// Filter by correction type: tool-preference or all (default: all) + #[arg(long, default_value = "all")] + correction_type: String, + }, + /// Manage shared learnings with trust levels (L1/L2/L3) + #[cfg(feature = "shared-learning")] + Shared { + #[command(subcommand)] + sub: SharedLearningSub, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum CorrectionSub { + /// Record a new user correction + Add { + /// What the agent said/did originally + #[arg(long)] + original: String, + /// What the user said instead + #[arg(long)] + corrected: String, + /// Type of correction: tool-preference, code-pattern, naming, workflow-step, fact-correction, style-preference, other + #[arg(long, default_value = "other")] + correction_type: String, + /// Context description (optional) + #[arg(long, default_value = "")] + context: String, + /// Session ID for traceability + #[arg(long)] + session_id: Option, + }, + /// List stored corrections + List { + /// Show at most this many corrections (default: 20) + #[arg(long, default_value_t = 20)] + recent: usize, + /// Filter by correction type (e.g. tool-preference, code-pattern) + #[arg(long)] + filter_type: Option, + /// Show global corrections instead of project-local + #[arg(long, default_value_t = false)] + global: bool, + }, +} + +#[cfg(feature = "shared-learning")] +#[derive(Subcommand, Debug)] +pub(crate) enum SharedLearningSub { + /// List shared learnings, optionally filtered by trust level + List { + /// Filter by trust level: l1, l2, l3 + #[arg(long)] + trust_level: Option, + /// Maximum number of learnings to show + #[arg(long, default_value_t = 20)] + limit: usize, + }, + /// Promote a shared learning to a higher trust level + Promote { + /// Learning ID + id: String, + /// Target trust level: l2 or l3 + #[arg(long)] + to: String, + }, + /// Import local captured learnings into the shared learning store at L1 + Import, + /// Show shared learning statistics by trust level + Stats, + /// Sync L2/L3 learnings to Gitea wiki + Sync, + /// Inject learnings from shared directory into local store + #[cfg(feature = "cross-agent-injection")] + Inject { + /// Minimum trust level to inject (l1, l2, l3) + #[arg(long, default_value = "l2")] + min_trust: String, + /// Dry run (show what would be injected without injecting) + #[arg(long, default_value_t = false)] + dry_run: bool, + }, +} + +#[cfg(feature = "shared-learning")] +#[derive(Subcommand, Debug)] +pub(crate) enum SuggestSub { + /// List pending suggestions, optionally filtered by status + List { + /// Filter by status: pending, approved, rejected + #[arg(long)] + status: Option, + #[arg(long, default_value_t = 20)] + limit: usize, + }, + /// Show full details of a suggestion + Show { id: String }, + /// Approve a suggestion (promotes to L3 and marks as approved) + Approve { id: String }, + /// Reject a suggestion + Reject { + id: String, + #[arg(long)] + reason: Option, + }, + /// Approve all pending suggestions above a confidence threshold + ApproveAll { + #[arg(long, default_value_t = 0.8)] + min_confidence: f64, + #[arg(long, default_value_t = false)] + dry_run: bool, + }, + /// Reject all pending suggestions below a confidence threshold + RejectAll { + #[arg(long, default_value_t = 0.3)] + max_confidence: f64, + #[arg(long, default_value_t = false)] + dry_run: bool, + }, + /// Show suggestion approval metrics + Metrics, + /// Show session-end suggestion summary + SessionEnd { + #[arg(long)] + context: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ProcedureSub { + /// List stored procedures (most recent first) + List { + /// Number of recent procedures to show + #[arg(long, default_value_t = 10)] + recent: usize, + }, + /// Show full details of a procedure + Show { + /// Procedure ID + id: String, + }, + /// Create a new empty procedure + Record { + /// Procedure title + title: String, + /// Optional description + #[arg(long)] + description: Option, + }, + /// Add a step to an existing procedure + AddStep { + /// Procedure ID + id: String, + /// Command to execute in this step + command: String, + /// Precondition that must hold before this step + #[arg(long)] + precondition: Option, + /// Postcondition that should hold after this step + #[arg(long)] + postcondition: Option, + }, + /// Record a successful execution of a procedure + Success { + /// Procedure ID + id: String, + }, + /// Record a failed execution of a procedure + Failure { + /// Procedure ID + id: String, + }, + /// Replay a stored procedure (execute its steps in order) + Replay { + /// Procedure ID + id: String, + /// Print steps without executing them + #[arg(long, default_value_t = false)] + dry_run: bool, + }, + /// Show health status of all procedures (auto-disables critically failing ones) + Health, + /// Enable a previously disabled procedure + Enable { + /// Procedure ID + id: String, + }, + /// Disable a procedure (prevents replay) + Disable { + /// Procedure ID + id: String, + }, + /// Auto-capture a procedure from a session's Bash commands + #[cfg(feature = "repl-sessions")] + FromSession { + /// Session ID to extract commands from + session_id: String, + /// Optional title (auto-generated from first command if not provided) + #[arg(long)] + title: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum RolesSub { + List, + Select { name: String }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ConfigSub { + /// Show current configuration as JSON + Show, + /// Set a configuration value + Set { key: String, value: String }, + /// Validate configuration loading (shows what would be loaded and from where) + Validate, + /// Reload roles from JSON file specified in settings.toml role_config + Reload, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum KgSub { + /// List knowledge graph entries + List { + #[arg(long)] + role: Option, + #[arg(long, default_value_t = 50)] + top_k: usize, + /// Show only pinned entries + #[arg(long, default_value_t = false)] + pinned: bool, + }, +} + +#[cfg(feature = "repl-sessions")] +#[derive(Subcommand, Debug)] +pub(crate) enum SessionsSub { + /// Detect available session sources (Claude Code, Cursor, etc.) + Sources, + /// List all cached sessions (auto-imports if cache is empty) + List { + /// Limit number of sessions to show + #[arg(long, default_value_t = 20)] + limit: usize, + }, + /// Search sessions by query string (auto-imports if cache is empty) + Search { + /// Search query + query: String, + /// Limit number of results + #[arg(long, default_value_t = 10)] + limit: usize, + }, + /// Show session statistics (auto-imports if cache is empty) + Stats, + /// Print the full body of a session by ID + Expand { + /// Session ID to expand + id: String, + /// Lines of context to show around matched content (reserved for future --query support) + #[arg(long, default_value_t = 5)] + context_lines: usize, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum RobotSub { + /// Show robot capabilities + Capabilities { + /// Output format + #[arg(long, value_enum, default_value_t = super::RobotFormat::Json)] + format: super::RobotFormat, + }, + /// Show command schemas + Schemas { + /// Command name to get schema for (all commands if omitted) + command: Option, + /// Output format + #[arg(long, value_enum, default_value_t = super::RobotFormat::Json)] + format: super::RobotFormat, + }, + /// Show command examples + Examples { + /// Command name to get examples for (all commands if omitted) + command: Option, + /// Output format + #[arg(long, value_enum, default_value_t = super::RobotFormat::Table)] + format: super::RobotFormat, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum MemorySub { + /// Capture a command or session event as an agentic memory item + /// (writes to evolution store with provenance metadata) + Capture { + /// Provenance tag for traceability (session ID, commit SHA) + #[arg(long)] + provenance_tag: Option, + }, + /// Distill captured learnings into thesaurus and KG entries + /// (routes to `learn compile` + `learn export-kg`) + Distill { + /// Output format: markdown or json + #[arg(long, default_value = "markdown")] + format: String, + }, + /// Show or check role and project memory boundaries + Scope { + /// Role name to show scope for + #[arg(long)] + role: Option, + /// Project path to show scope for + #[arg(long)] + project: Option, + /// Check for permissioned items in public locations + #[arg(long, default_value_t = false)] + check: bool, + }, + /// Search session provenance for a memory ID + /// (routes to `sessions search`) + Provenance { + /// Memory ID to search provenance for + #[arg(long)] + memory_id: Option, + /// Search query + query: Option, + }, + /// Retrieve memory items by query within role scope + /// (routes to `search`) + Retrieve { + /// Role scope for retrieval + #[arg(long)] + role: Option, + /// Search query + query: String, + }, + /// Show what hooks would inject for a given prompt or diff + /// (routes to `terraphim_hooks` diff) + Apply { + /// Prompt text to diff hook application against + #[arg(long)] + prompt: Option, + }, + /// Validate memory items against the reliability rubric + /// (calls judge pipeline for scoring) + Validate { + /// Validate all stored memory items + #[arg(long, default_value_t = false)] + all: bool, + /// Validate a specific lesson by ID + #[arg(long)] + lesson_id: Option, + }, + /// Propose retirement of a memory item + /// (writes to learned-rules.md with CTO approval flag) + Retire { + /// Learning ID to retire + #[arg(long)] + lesson_id: Option, + /// Reason for retirement + #[arg(long)] + reason: Option, + }, + /// Run the full Memory Reliability Rubric diagnostic on a project + /// (6 dimensions: faithfulness, scope, provenance, actionability, decay, risk) + Rubric { + /// Project path to run rubric against + #[arg(long)] + project: String, + /// Output file for markdown readout (stdout if omitted) + #[arg(long)] + output: Option, + }, + /// List memory items from the evolution store + List { + /// Filter by type (fact, experience, lesson, etc.) + #[arg(long)] + item_type: Option, + /// Maximum items to show + #[arg(long, default_value_t = 20)] + limit: usize, + }, + /// Show details of a specific memory item or lesson by ID + Show { + /// Memory item or lesson ID + id: String, + /// Show raw JSON output + #[arg(long, default_value_t = false)] + json: bool, + }, + /// Export memory items and lessons as JSON or markdown + Export { + /// Output format: json or markdown + #[arg(long, default_value = "json")] + format: String, + /// Output file path (stdout if omitted) + #[arg(long)] + output: Option, + }, + /// Compute token delta between two ADF runs of the same Gitea issue + /// (second-run acceleration signal) + SecondRun { + /// Gitea issue number to compare runs for + #[arg(long)] + issue: u64, + }, +} \ No newline at end of file diff --git a/crates/terraphim_agent/src/main.rs b/crates/terraphim_agent/src/main.rs index b7af951..2da7cb5 100644 --- a/crates/terraphim_agent/src/main.rs +++ b/crates/terraphim_agent/src/main.rs @@ -1,8 +1,7 @@ use std::io; -use std::path::PathBuf; use anyhow::Result; -use clap::{Parser, Subcommand}; +use clap::Parser; use crossterm::{ event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers, @@ -26,10 +25,12 @@ use terraphim_persistence::Persistable; use tokio::runtime::Runtime; mod cli_helpers; +mod cli_schema; mod listener; mod shell_dispatch; use cli_helpers::*; +use cli_schema::*; // Robot mode and forgiving CLI - always available @@ -46,12 +47,6 @@ use terraphim_types::{ }; use terraphim_update::{TerraphimUpdater, UpdaterConfig}; -#[derive(clap::ValueEnum, Debug, Clone)] -enum LogicalOperatorCli { - And, - Or, -} - /// Show helpful usage information when run without a TTY fn show_usage_info() { println!("Terraphim AI Agent v{}", env!("CARGO_PKG_VERSION")); @@ -109,38 +104,6 @@ fn ensure_tui_server_reachable( .map_err(|err| tui_server_requirement_error(url, &err)) } -impl From for LogicalOperator { - fn from(op: LogicalOperatorCli) -> Self { - match op { - LogicalOperatorCli::And => LogicalOperator::And, - LogicalOperatorCli::Or => LogicalOperator::Or, - } - } -} - -/// Hook types for Claude Code integration -#[derive(clap::ValueEnum, Debug, Clone)] -pub enum HookType { - /// Pre-tool-use hook (intercepts tool calls) - PreToolUse, - /// Post-tool-use hook (processes tool results) - PostToolUse, - /// Pre-commit hook (validate before commit) - PreCommit, - /// Prepare-commit-msg hook (enhance commit message) - PrepareCommitMsg, -} - -/// Boundary mode for text replacement -#[derive(clap::ValueEnum, Debug, Clone, Default)] -pub enum BoundaryMode { - /// Match anywhere (default, current behavior) - #[default] - None, - /// Only match at word boundaries - Word, -} - #[derive(Debug, Clone, PartialEq)] enum ViewMode { Search, @@ -340,33 +303,15 @@ mod tests { } #[derive(clap::ValueEnum, Debug, Clone, Default)] -pub enum OutputFormat { - /// Human-readable output (default) - #[default] - Human, - /// Machine-readable JSON output - Json, - /// Compact JSON for piping - JsonCompact, -} - -#[derive(clap::ValueEnum, Debug, Clone, Default)] -enum RobotFormat { +pub(crate) enum RobotFormat { #[default] Json, Table, Minimal, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CommandOutputMode { - Human, - Json, - JsonCompact, -} - #[derive(Debug, Clone, Copy)] -struct CommandOutputConfig { +pub(crate) struct CommandOutputConfig { mode: CommandOutputMode, robot: bool, } @@ -392,6 +337,16 @@ fn resolve_output_config(robot: bool, format: OutputFormat) -> CommandOutputConf CommandOutputConfig { mode, robot } } +/// Get the session cache file path +#[cfg(feature = "repl-sessions")] +fn get_session_cache_path() -> std::path::PathBuf { + let cache_dir = dirs::cache_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("terraphim-agent"); + std::fs::create_dir_all(&cache_dir).ok(); + cache_dir.join("sessions.json") +} + #[cfg(feature = "repl-sessions")] mod session_output { use serde::Serialize; @@ -475,839 +430,6 @@ fn print_json_output(value: &T, mode: CommandOutputMode) -> Result Ok(()) } -#[derive(Parser, Debug)] -#[command( - name = "terraphim-agent", - version, - about = "Terraphim Agent: server-backed fullscreen TUI with offline-capable REPL and CLI commands", - after_long_help = "EXIT CODES (F1.2 contract)\n\ - \n\ - \x20 0 SUCCESS Operation completed successfully\n\ - \x20 1 ERROR_GENERAL Unspecified or unexpected error\n\ - \x20 2 ERROR_USAGE Invalid arguments or unknown command\n\ - \x20 3 ERROR_INDEX_MISSING Required index not initialised\n\ - \x20 4 ERROR_NOT_FOUND No results (only with --fail-on-empty)\n\ - \x20 5 ERROR_AUTH Authentication required or failed\n\ - \x20 6 ERROR_NETWORK Transport-level network error\n\ - \x20 7 ERROR_TIMEOUT Operation exceeded configured timeout\n" -)] -struct Cli { - /// Use server API mode instead of self-contained offline mode - #[arg(long, default_value_t = false)] - server: bool, - /// Server URL for API mode - #[arg(long, default_value = "http://localhost:8000")] - server_url: String, - /// Enable transparent background mode - #[arg(long, default_value_t = false)] - transparent: bool, - /// Enable robot mode for AI agent integration (JSON output, exit codes) - #[arg(long, default_value_t = false)] - robot: bool, - /// Output format (human, json, json-compact) - #[arg(long, value_enum, default_value_t = OutputFormat::Human)] - format: OutputFormat, - /// Path to a JSON config file (overrides settings.toml and persistence) - #[arg(long)] - config: Option, - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Search documents using the knowledge graph - Search { - /// Primary search query - query: String, - /// Additional search terms for multi-term queries - #[arg(long, num_args = 1.., value_delimiter = ',')] - terms: Option>, - /// Logical operator for combining multiple search terms (and/or) - #[arg(long, value_enum)] - operator: Option, - #[arg(long)] - role: Option, - #[arg(long, default_value_t = 10)] - limit: usize, - #[arg(long, default_value_t = false)] - fail_on_empty: bool, - /// Include pinned KG entries in results - #[arg(long, default_value_t = false)] - include_pinned: bool, - /// Minimum composite quality score (0.0-1.0). Excludes documents below this threshold. - #[arg(long)] - min_quality: Option, - /// Maximum estimated tokens in robot-mode output (4 chars ≈ 1 token) - #[arg(long)] - max_tokens: Option, - /// Maximum characters per content/preview field before truncation - #[arg(long)] - max_content_length: Option, - /// Output field set: full, summary, minimal, or custom:, - #[arg(long)] - fields: Option, - }, - /// Manage roles (list, select) - Roles { - #[command(subcommand)] - sub: RolesSub, - }, - /// Manage configuration (show, set, validate, reload) - Config { - #[command(subcommand)] - sub: ConfigSub, - }, - /// Display the knowledge graph for a role - Graph { - #[arg(long)] - role: Option, - #[arg(long, default_value_t = 50)] - top_k: usize, - /// Show only pinned entries - #[arg(long, default_value_t = false)] - pinned: bool, - }, - /// Manage knowledge graph entries - Kg { - #[command(subcommand)] - sub: KgSub, - }, - /// Chat with the AI using a specific role - #[cfg(feature = "llm")] - Chat { - #[arg(long)] - role: Option, - prompt: String, - #[arg(long)] - model: Option, - }, - /// Extract paragraphs matching knowledge graph terms from text - Extract { - text: String, - #[arg(long)] - role: Option, - #[arg(long, default_value_t = false)] - exclude_term: bool, - }, - /// Replace terms in text using the knowledge graph thesaurus - Replace { - /// Text to replace (reads from stdin if not provided) - text: Option, - #[arg(long)] - role: Option, - /// Output format: plain (default), markdown, wiki, html - #[arg(long)] - format: Option, - /// Boundary mode: none (match anywhere) or word (only at word boundaries) - #[arg(long, default_value = "none")] - boundary: BoundaryMode, - /// Output as JSON with metadata (for hook integration) - #[arg(long, default_value_t = false)] - json: bool, - /// Suppress errors and pass through unchanged on failure - #[arg(long, default_value_t = false)] - fail_open: bool, - }, - /// Validate text against knowledge graph - Validate { - /// Text to validate (reads from stdin if not provided) - text: Option, - /// Role to use for validation - #[arg(long)] - role: Option, - /// Check if all matched terms are connected by a single path - #[arg(long, default_value_t = false)] - connectivity: bool, - /// Validate against a named checklist (e.g., "code_review", "security") - #[arg(long)] - checklist: Option, - /// Output as JSON - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Suggest similar terms using fuzzy matching - Suggest { - /// Query to search for (reads from stdin if not provided) - query: Option, - /// Role to use for suggestions - #[arg(long)] - role: Option, - /// Enable fuzzy matching - #[arg(long, default_value_t = true)] - fuzzy: bool, - /// Minimum similarity threshold (0.0-1.0) - #[arg(long, default_value_t = 0.6)] - threshold: f64, - /// Maximum number of suggestions - #[arg(long, default_value_t = 10)] - limit: usize, - /// Output as JSON - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Unified hook handler for Claude Code integration - Hook { - /// Hook type (pre-tool-use, post-tool-use, pre-commit, etc.) - #[arg(long, value_enum)] - hook_type: HookType, - /// JSON input from Claude Code (reads from stdin if not provided) - #[arg(long)] - input: Option, - /// Role to use for processing - #[arg(long)] - role: Option, - /// Output as JSON (always true for hooks, but explicit) - #[arg(long, default_value_t = true)] - json: bool, - /// Include guard check for destructive commands (git reset --hard, rm -rf, etc.) - /// - /// Defaults to true for pre-tool-use; for other hooks (post-tool-use, - /// pre-commit, prepare-commit-msg) the default is false because they - /// fire after execution or on text inputs that do not need a guard. - #[arg(long, default_value_t = false)] - with_guard: bool, - /// Force the guard check off (overrides `--with-guard` and the per-hook-type default). - /// - /// Use this escape hatch only when you have already vetted the command and - /// need to bypass the safety net. Clap does not auto-derive `--no-with-guard`, - /// hence this explicit negation flag. - #[arg(long, default_value_t = false, conflicts_with = "with_guard")] - no_with_guard: bool, - /// Allow thesaurus-based command rewriting (e.g. `npm install` -> `bun add`) - /// - /// Defaults to **false**. Substitution is opt-in so a stray substring - /// match cannot silently mutate a destructive command. Pass `--rewrite` - /// to enable KG-driven rewriting. - #[arg(long, default_value_t = false)] - rewrite: bool, - }, - /// Check command against safety guard patterns (blocks destructive git/fs commands) - Guard { - /// Command to check (reads from stdin if not provided) - command: Option, - /// Output as JSON - #[arg(long, default_value_t = false)] - json: bool, - /// Suppress errors and pass through unchanged on failure - #[arg(long, default_value_t = false)] - fail_open: bool, - /// Path to custom destructive patterns thesaurus JSON file - #[arg(long)] - guard_thesaurus: Option, - /// Path to custom allowlist thesaurus JSON file - #[arg(long)] - guard_allowlist: Option, - /// Print per-stage evaluation trace (allowlist > destructive > suspicious > default) - /// showing which stage matched and short-circuited. Requires `--json` for structured - /// output; without `--json` the trace is printed to stderr in a readable form. - #[arg(long, default_value_t = false)] - explain: bool, - }, - /// Start fullscreen interactive TUI mode (requires running server) - Interactive, - - /// Start REPL (Read-Eval-Print-Loop) interface - #[cfg(feature = "repl")] - Repl { - /// Start in server mode - #[arg(long)] - server: bool, - /// Server URL for API mode - #[arg(long, default_value = "http://localhost:8000")] - server_url: String, - }, - - /// Interactive setup wizard for first-time configuration - Setup { - /// Apply a specific template directly (skip interactive wizard) - #[arg(long)] - template: Option, - /// Path to use with the template (required for some templates like local-notes) - #[arg(long)] - path: Option, - /// Add a new role to existing configuration (instead of replacing) - #[arg(long, default_value_t = false)] - add_role: bool, - /// List available templates and exit - #[arg(long, default_value_t = false)] - list_templates: bool, - }, - - /// Check for updates without installing - CheckUpdate, - - /// Update to latest version if available - Update, - - /// Learning capture for failed commands - Learn { - #[command(subcommand)] - sub: LearnSub, - }, - - /// Session management for AI coding assistant history - #[cfg(feature = "repl-sessions")] - Sessions { - #[command(subcommand)] - sub: SessionsSub, - }, - - /// Start listener mode for AI agent communication (offline-only) - Listen { - /// Agent identity/name for this listener instance - #[arg(long)] - identity: Option, - /// Optional listener configuration JSON file - #[arg(long)] - config: Option, - /// Start in server mode (rejected -- listen is offline-only) - #[arg(long)] - server: bool, - }, - - /// Manage the compiled thesaurus cache - Cache { - #[command(subcommand)] - sub: CacheSub, - }, - - /// Robot mode self-documentation commands - Robot { - #[command(subcommand)] - sub: RobotSub, - }, - - /// Memory lifecycle management (capture, distill, scope, provenance, retrieve, - /// apply, validate, retire, rubric, second-run) - Memory { - #[command(subcommand)] - sub: MemorySub, - }, -} - -#[derive(Subcommand, Debug)] -enum CacheSub { - /// Flush (delete) compiled thesaurus cache entries - Flush { - /// Specific role to flush (if omitted, flushes all cached thesauri) - #[arg(long)] - role: Option, - }, -} - -#[derive(Subcommand, Debug)] -enum LearnSub { - /// Capture a failed command as a learning - Capture { - /// The command that failed - command: String, - /// The error output (stderr) - #[arg(long)] - error: String, - /// The exit code - #[arg(long, default_value_t = 1)] - exit_code: i32, - /// Enable debug output - #[arg(long, default_value_t = false)] - debug: bool, - }, - /// List recent learnings - List { - /// Number of recent learnings to show - #[arg(long, default_value_t = 10)] - recent: usize, - /// Show global learnings instead of project - #[arg(long, default_value_t = false)] - global: bool, - }, - /// Query learnings by pattern - Query { - /// Search pattern - pattern: String, - /// Use exact match instead of substring - #[arg(long, default_value_t = false)] - exact: bool, - /// Show global learnings instead of project - #[arg(long, default_value_t = false)] - global: bool, - /// Enable semantic matching via KG entities - #[arg(long, default_value_t = false)] - semantic: bool, - }, - /// Add correction to an existing learning - Correct { - /// Learning ID - id: String, - /// The correction to add - #[arg(long)] - correction: String, - }, - /// Record and list user corrections (tool preference, naming, workflow, etc.) - Correction { - #[command(subcommand)] - sub: CorrectionSub, - }, - /// Process hook input from AI agents (reads JSON from stdin) - Hook { - /// AI agent format - #[arg(long, value_enum, default_value = "claude")] - format: learnings::AgentFormat, - /// Hook type for multi-hook pipeline - #[arg(long, value_enum, default_value = "post-tool-use")] - learn_hook_type: learnings::LearnHookType, - }, - /// Install hook for AI agent - InstallHook { - /// AI agent to install hook for - #[arg(value_enum)] - agent: learnings::AgentType, - }, - /// Manage captured procedures (recorded command sequences) - Procedure { - #[command(subcommand)] - sub: ProcedureSub, - }, - /// Compile captured corrections into a thesaurus for the replace command - Compile { - /// Output path for compiled thesaurus JSON - #[arg(long, default_value = "compiled-corrections.json")] - output: PathBuf, - /// Optional: merge with this curated thesaurus file - #[arg(long)] - merge_with: Option, - }, - /// Review and approve/reject knowledge suggestions - #[cfg(feature = "shared-learning")] - Suggest { - #[command(subcommand)] - sub: SuggestSub, - }, - /// Export captured corrections as reviewable KG markdown artefacts - ExportKg { - /// Output directory for KG markdown files - #[arg(long)] - output: PathBuf, - /// Filter by correction type: tool-preference or all (default: all) - #[arg(long, default_value = "all")] - correction_type: String, - }, - /// Manage shared learnings with trust levels (L1/L2/L3) - #[cfg(feature = "shared-learning")] - Shared { - #[command(subcommand)] - sub: SharedLearningSub, - }, -} - -#[derive(Subcommand, Debug)] -enum CorrectionSub { - /// Record a new user correction - Add { - /// What the agent said/did originally - #[arg(long)] - original: String, - /// What the user said instead - #[arg(long)] - corrected: String, - /// Type of correction: tool-preference, code-pattern, naming, workflow-step, fact-correction, style-preference, other - #[arg(long, default_value = "other")] - correction_type: String, - /// Context description (optional) - #[arg(long, default_value = "")] - context: String, - /// Session ID for traceability - #[arg(long)] - session_id: Option, - }, - /// List stored corrections - List { - /// Show at most this many corrections (default: 20) - #[arg(long, default_value_t = 20)] - recent: usize, - /// Filter by correction type (e.g. tool-preference, code-pattern) - #[arg(long)] - filter_type: Option, - /// Show global corrections instead of project-local - #[arg(long, default_value_t = false)] - global: bool, - }, -} - -#[cfg(feature = "shared-learning")] -#[derive(Subcommand, Debug)] -enum SharedLearningSub { - /// List shared learnings, optionally filtered by trust level - List { - /// Filter by trust level: l1, l2, l3 - #[arg(long)] - trust_level: Option, - /// Maximum number of learnings to show - #[arg(long, default_value_t = 20)] - limit: usize, - }, - /// Promote a shared learning to a higher trust level - Promote { - /// Learning ID - id: String, - /// Target trust level: l2 or l3 - #[arg(long)] - to: String, - }, - /// Import local captured learnings into the shared learning store at L1 - Import, - /// Show shared learning statistics by trust level - Stats, - /// Sync L2/L3 learnings to Gitea wiki - Sync, - /// Inject learnings from shared directory into local store - #[cfg(feature = "cross-agent-injection")] - Inject { - /// Minimum trust level to inject (l1, l2, l3) - #[arg(long, default_value = "l2")] - min_trust: String, - /// Dry run (show what would be injected without injecting) - #[arg(long, default_value_t = false)] - dry_run: bool, - }, -} - -#[cfg(feature = "shared-learning")] -#[derive(Subcommand, Debug)] -enum SuggestSub { - /// List pending suggestions, optionally filtered by status - List { - /// Filter by status: pending, approved, rejected - #[arg(long)] - status: Option, - #[arg(long, default_value_t = 20)] - limit: usize, - }, - /// Show full details of a suggestion - Show { id: String }, - /// Approve a suggestion (promotes to L3 and marks as approved) - Approve { id: String }, - /// Reject a suggestion - Reject { - id: String, - #[arg(long)] - reason: Option, - }, - /// Approve all pending suggestions above a confidence threshold - ApproveAll { - #[arg(long, default_value_t = 0.8)] - min_confidence: f64, - #[arg(long, default_value_t = false)] - dry_run: bool, - }, - /// Reject all pending suggestions below a confidence threshold - RejectAll { - #[arg(long, default_value_t = 0.3)] - max_confidence: f64, - #[arg(long, default_value_t = false)] - dry_run: bool, - }, - /// Show suggestion approval metrics - Metrics, - /// Show session-end suggestion summary - SessionEnd { - #[arg(long)] - context: Option, - }, -} - -#[derive(Subcommand, Debug)] -enum ProcedureSub { - /// List stored procedures (most recent first) - List { - /// Number of recent procedures to show - #[arg(long, default_value_t = 10)] - recent: usize, - }, - /// Show full details of a procedure - Show { - /// Procedure ID - id: String, - }, - /// Create a new empty procedure - Record { - /// Procedure title - title: String, - /// Optional description - #[arg(long)] - description: Option, - }, - /// Add a step to an existing procedure - AddStep { - /// Procedure ID - id: String, - /// Command to execute in this step - command: String, - /// Precondition that must hold before this step - #[arg(long)] - precondition: Option, - /// Postcondition that should hold after this step - #[arg(long)] - postcondition: Option, - }, - /// Record a successful execution of a procedure - Success { - /// Procedure ID - id: String, - }, - /// Record a failed execution of a procedure - Failure { - /// Procedure ID - id: String, - }, - /// Replay a stored procedure (execute its steps in order) - Replay { - /// Procedure ID - id: String, - /// Print steps without executing them - #[arg(long, default_value_t = false)] - dry_run: bool, - }, - /// Show health status of all procedures (auto-disables critically failing ones) - Health, - /// Enable a previously disabled procedure - Enable { - /// Procedure ID - id: String, - }, - /// Disable a procedure (prevents replay) - Disable { - /// Procedure ID - id: String, - }, - /// Auto-capture a procedure from a session's Bash commands - #[cfg(feature = "repl-sessions")] - FromSession { - /// Session ID to extract commands from - session_id: String, - /// Optional title (auto-generated from first command if not provided) - #[arg(long)] - title: Option, - }, -} - -#[derive(Subcommand, Debug)] -enum RolesSub { - List, - Select { name: String }, -} - -#[derive(Subcommand, Debug)] -enum ConfigSub { - /// Show current configuration as JSON - Show, - /// Set a configuration value - Set { key: String, value: String }, - /// Validate configuration loading (shows what would be loaded and from where) - Validate, - /// Reload roles from JSON file specified in settings.toml role_config - Reload, -} - -#[derive(Subcommand, Debug)] -enum KgSub { - /// List knowledge graph entries - List { - #[arg(long)] - role: Option, - #[arg(long, default_value_t = 50)] - top_k: usize, - /// Show only pinned entries - #[arg(long, default_value_t = false)] - pinned: bool, - }, -} - -/// Get the session cache file path -#[cfg(feature = "repl-sessions")] -fn get_session_cache_path() -> std::path::PathBuf { - let cache_dir = dirs::cache_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("terraphim-agent"); - std::fs::create_dir_all(&cache_dir).ok(); - cache_dir.join("sessions.json") -} - -#[cfg(feature = "repl-sessions")] -#[derive(Subcommand, Debug)] -enum SessionsSub { - /// Detect available session sources (Claude Code, Cursor, etc.) - Sources, - /// List all cached sessions (auto-imports if cache is empty) - List { - /// Limit number of sessions to show - #[arg(long, default_value_t = 20)] - limit: usize, - }, - /// Search sessions by query string (auto-imports if cache is empty) - Search { - /// Search query - query: String, - /// Limit number of results - #[arg(long, default_value_t = 10)] - limit: usize, - }, - /// Show session statistics (auto-imports if cache is empty) - Stats, - /// Print the full body of a session by ID - Expand { - /// Session ID to expand - id: String, - /// Lines of context to show around matched content (reserved for future --query support) - #[arg(long, default_value_t = 5)] - context_lines: usize, - }, -} - -#[derive(Subcommand, Debug)] -enum RobotSub { - /// Show robot capabilities - Capabilities { - /// Output format - #[arg(long, value_enum, default_value_t = RobotFormat::Json)] - format: RobotFormat, - }, - /// Show command schemas - Schemas { - /// Command name to get schema for (all commands if omitted) - command: Option, - /// Output format - #[arg(long, value_enum, default_value_t = RobotFormat::Json)] - format: RobotFormat, - }, - /// Show command examples - Examples { - /// Command name to get examples for (all commands if omitted) - command: Option, - /// Output format - #[arg(long, value_enum, default_value_t = RobotFormat::Table)] - format: RobotFormat, - }, -} - -#[derive(Subcommand, Debug)] -enum MemorySub { - /// Capture a command or session event as an agentic memory item - /// (writes to evolution store with provenance metadata) - Capture { - /// Provenance tag for traceability (session ID, commit SHA) - #[arg(long)] - provenance_tag: Option, - }, - /// Distill captured learnings into thesaurus and KG entries - /// (routes to `learn compile` + `learn export-kg`) - Distill { - /// Output format: markdown or json - #[arg(long, default_value = "markdown")] - format: String, - }, - /// Show or check role and project memory boundaries - Scope { - /// Role name to show scope for - #[arg(long)] - role: Option, - /// Project path to show scope for - #[arg(long)] - project: Option, - /// Check for permissioned items in public locations - #[arg(long, default_value_t = false)] - check: bool, - }, - /// Search session provenance for a memory ID - /// (routes to `sessions search`) - Provenance { - /// Memory ID to search provenance for - #[arg(long)] - memory_id: Option, - /// Search query - query: Option, - }, - /// Retrieve memory items by query within role scope - /// (routes to `search`) - Retrieve { - /// Role scope for retrieval - #[arg(long)] - role: Option, - /// Search query - query: String, - }, - /// Show what hooks would inject for a given prompt or diff - /// (routes to `terraphim_hooks` diff) - Apply { - /// Prompt text to diff hook application against - #[arg(long)] - prompt: Option, - }, - /// Validate memory items against the reliability rubric - /// (calls judge pipeline for scoring) - Validate { - /// Validate all stored memory items - #[arg(long, default_value_t = false)] - all: bool, - /// Validate a specific lesson by ID - #[arg(long)] - lesson_id: Option, - }, - /// Propose retirement of a memory item - /// (writes to learned-rules.md with CTO approval flag) - Retire { - /// Learning ID to retire - #[arg(long)] - lesson_id: Option, - /// Reason for retirement - #[arg(long)] - reason: Option, - }, - /// Run the full Memory Reliability Rubric diagnostic on a project - /// (6 dimensions: faithfulness, scope, provenance, actionability, decay, risk) - Rubric { - /// Project path to run rubric against - #[arg(long)] - project: String, - /// Output file for markdown readout (stdout if omitted) - #[arg(long)] - output: Option, - }, - /// List memory items from the evolution store - List { - /// Filter by type (fact, experience, lesson, etc.) - #[arg(long)] - item_type: Option, - /// Maximum items to show - #[arg(long, default_value_t = 20)] - limit: usize, - }, - /// Show details of a specific memory item or lesson by ID - Show { - /// Memory item or lesson ID - id: String, - /// Show raw JSON output - #[arg(long, default_value_t = false)] - json: bool, - }, - /// Export memory items and lessons as JSON or markdown - Export { - /// Output format: json or markdown - #[arg(long, default_value = "json")] - format: String, - /// Output file path (stdout if omitted) - #[arg(long)] - output: Option, - }, - /// Compute token delta between two ADF runs of the same Gitea issue - /// (second-run acceleration signal) - SecondRun { - /// Gitea issue number to compare runs for - #[arg(long)] - issue: u64, - }, -} - fn emit_robot_error_and_exit( err: &anyhow::Error, code: robot::exit_codes::ExitCode,