From 921eac5352177d2ee05c8b3e76f1e4ab12590199 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 14:29:06 +0900 Subject: [PATCH 01/10] refactor(template): replace panics with Result in paths.rs (#42) Closes #42 --- template/src/error.rs | 3 +++ template/src/main.rs | 2 ++ template/src/paths.rs | 60 +++++++++++++++++++++++++++++++------------ 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/template/src/error.rs b/template/src/error.rs index 3699978..93554e7 100644 --- a/template/src/error.rs +++ b/template/src/error.rs @@ -24,6 +24,9 @@ pub enum AppError { #[snafu(display("config error: {message}"))] Config { message: String }, + #[snafu(display("path resolution failed: {message}"))] + PathResolution { message: String }, + #[snafu(display("agent execution failed: {source}"))] AgentExecution { source: std::io::Error }, diff --git a/template/src/main.rs b/template/src/main.rs index 6aee4ec..8739cbe 100644 --- a/template/src/main.rs +++ b/template/src/main.rs @@ -36,6 +36,8 @@ async fn run() -> error::Result<()> { return Ok(()); } + {{crate_name}}::paths::init_data_dir()?; + let command = cli.command.ok_or_else(|| { ConfigSnafu { message: "no command specified — try --help or --agent-describe".to_string(), diff --git a/template/src/paths.rs b/template/src/paths.rs index 0b831b0..32f66e3 100644 --- a/template/src/paths.rs +++ b/template/src/paths.rs @@ -2,36 +2,64 @@ //! //! All paths derive from a single data root, resolved once via `OnceLock`. //! The root can be overridden by setting the `APP_DATA_DIR` environment variable. +//! +//! Call [`init_data_dir`] early in startup to validate paths before use. use std::{ path::{Path, PathBuf}, sync::OnceLock, }; +use snafu::ensure; + +use crate::error::{self, PathResolutionSnafu}; + static DATA_DIR: OnceLock = OnceLock::new(); -/// Root data directory, resolved in order: +/// Validate and cache the data directory. +/// +/// Resolves the root in order: /// 1. `APP_DATA_DIR` env var (must be non-empty and an absolute path) /// 2. `~/.{{project-name}}` /// -/// # Panics -/// Panics if `APP_DATA_DIR` is set but empty or not an absolute path, -/// or if no home directory can be resolved and `APP_DATA_DIR` is unset. -pub fn data_dir() -> &'static Path { - DATA_DIR.get_or_init(|| { - if let Ok(dir) = std::env::var("APP_DATA_DIR") { - let path = PathBuf::from(&dir); - assert!( - !dir.is_empty() && path.is_absolute(), - "APP_DATA_DIR must be a non-empty absolute path, got: {dir:?}" - ); - return path; - } +/// Must be called once during startup before any calls to [`data_dir`]. +pub fn init_data_dir() -> error::Result<&'static Path> { + if let Some(existing) = DATA_DIR.get() { + return Ok(existing.as_path()); + } + let path = if let Ok(dir) = std::env::var("APP_DATA_DIR") { + let path = PathBuf::from(&dir); + ensure!( + !dir.is_empty() && path.is_absolute(), + PathResolutionSnafu { + message: format!("APP_DATA_DIR must be a non-empty absolute path, got: {dir:?}") + } + ); + path + } else { dirs::home_dir() - .expect("home directory must be resolvable — set APP_DATA_DIR as a fallback") + .ok_or_else(|| { + PathResolutionSnafu { + message: "home directory must be resolvable — set APP_DATA_DIR as a fallback" + .to_string(), + } + .build() + })? .join(".{{project-name}}") - }) + }; + + Ok(DATA_DIR.get_or_init(|| path)) +} + +/// Root data directory, resolved during [`init_data_dir`]. +/// +/// # Panics +/// Panics if called before [`init_data_dir`]. +pub fn data_dir() -> &'static Path { + DATA_DIR + .get() + .expect("data_dir() called before init_data_dir()") } /// Config file path: `/config.toml` From c97d1d75f5f0ce254aa3fc44da1e3fe2cb9efdc2 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 14:30:46 +0900 Subject: [PATCH 02/10] refactor(template): propagate config parse errors via snafu (#42) Closes #42 --- template/src/app_config.rs | 48 ++++++++++++++++++++++++-------------- template/src/error.rs | 3 +++ template/src/main.rs | 9 +++---- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/template/src/app_config.rs b/template/src/app_config.rs index 626752a..02e69c1 100644 --- a/template/src/app_config.rs +++ b/template/src/app_config.rs @@ -3,8 +3,10 @@ use std::sync::OnceLock; use serde::{Deserialize, Serialize}; +use snafu::ResultExt; use crate::agent::AgentConfig; +use crate::error::{self, ConfigParseSnafu}; static APP_CONFIG: OnceLock = OnceLock::new(); @@ -34,25 +36,35 @@ impl Default for ExampleConfig { } } -/// Load config from TOML file, falling back to defaults. +/// Load config from TOML file, cache in `OnceLock`, and return a reference. /// -/// The result is cached in a `OnceLock` — subsequent calls return the same -/// value even after [`save`]. This is fine for CLI usage (one command per -/// process) but callers using this as a library should be aware of the -/// caching behavior. -pub fn load() -> &'static AppConfig { - APP_CONFIG.get_or_init(|| { - let path = crate::paths::config_file(); - if path.exists() { - let settings = config::Config::builder() - .add_source(config::File::from(path.as_ref())) - .build() - .unwrap_or_default(); - settings.try_deserialize().unwrap_or_default() - } else { - AppConfig::default() - } - }) +/// If the config file does not exist, `AppConfig::default()` is used. +/// If the config file exists but is malformed, the parse error is returned. +/// +/// Must be called once (typically at startup) before [`get`]. +pub fn init() -> error::Result<&'static AppConfig> { + let path = crate::paths::config_file(); + let cfg = if path.exists() { + let settings = config::Config::builder() + .add_source(config::File::from(path.as_ref())) + .build() + .context(ConfigParseSnafu)?; + settings.try_deserialize().context(ConfigParseSnafu)? + } else { + AppConfig::default() + }; + Ok(APP_CONFIG.get_or_init(|| cfg)) +} + +/// Infallible accessor — returns the cached config. +/// +/// # Panics +/// +/// Panics if [`init`] has not been called yet. +pub fn get() -> &'static AppConfig { + APP_CONFIG + .get() + .expect("app_config::init() must be called before app_config::get()") } /// Save config to TOML file. diff --git a/template/src/error.rs b/template/src/error.rs index 93554e7..f829e0b 100644 --- a/template/src/error.rs +++ b/template/src/error.rs @@ -24,6 +24,9 @@ pub enum AppError { #[snafu(display("config error: {message}"))] Config { message: String }, + #[snafu(display("config parse error: {source}"))] + ConfigParse { source: config::ConfigError }, + #[snafu(display("path resolution failed: {message}"))] PathResolution { message: String }, diff --git a/template/src/main.rs b/template/src/main.rs index 8739cbe..f767b8b 100644 --- a/template/src/main.rs +++ b/template/src/main.rs @@ -37,6 +37,7 @@ async fn run() -> error::Result<()> { } {{crate_name}}::paths::init_data_dir()?; + {{crate_name}}::app_config::init()?; let command = cli.command.ok_or_else(|| { ConfigSnafu { @@ -48,19 +49,19 @@ async fn run() -> error::Result<()> { match command { Command::Config { action } => match action { ConfigAction::Set { key, value } => { - let mut cfg = app_config::load().clone(); + let mut cfg = app_config::get().clone(); set_config_field(&mut cfg, &key, &value)?; app_config::save(&cfg).context(IoSnafu)?; eprintln!("set {key} = {value}"); AgentResponse::ok(ConfigSetResult { key, value }).print(); } ConfigAction::Get { key } => { - let cfg = app_config::load(); + let cfg = app_config::get(); let value = get_config_field(cfg, &key)?; AgentResponse::ok(ConfigGetResult { key, value }).print(); } ConfigAction::List => { - let cfg = app_config::load(); + let cfg = app_config::get(); let entries: std::collections::BTreeMap = config_as_map(cfg).into_iter().collect(); AgentResponse::ok(ConfigListResult { entries }).print(); @@ -74,7 +75,7 @@ async fn run() -> error::Result<()> { Command::Agent { prompt, backend } => { use {{crate_name}}::agent::{CliBackend, CliExecutor}; - let cfg = app_config::load(); + let cfg = app_config::get(); let mut agent_cfg = cfg.agent.clone(); if let Some(b) = backend { agent_cfg.backend = b; From 35c902faf51fe885ec2755a68b359623411f439e Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 14:33:33 +0900 Subject: [PATCH 03/10] refactor(template): table-driven backend constructors (#42) Replace ~20 individual constructor methods with static preset tables. Closes #42 --- template/src/agent/backend.rs | 608 +++++++++++----------------------- 1 file changed, 192 insertions(+), 416 deletions(-) diff --git a/template/src/agent/backend.rs b/template/src/agent/backend.rs index ab2cebb..ad198d9 100644 --- a/template/src/agent/backend.rs +++ b/template/src/agent/backend.rs @@ -1,8 +1,8 @@ //! CLI backend definitions for different AI tools. //! -//! Provides factory methods for configuring various agent CLIs +//! Provides table-driven presets for configuring various agent CLIs //! (Claude, Kiro, Gemini, Codex, etc.) with the correct flags and -//! prompt-passing conventions. Ported from ralph-orchestrator. +//! prompt-passing conventions. use std::io::Write; @@ -60,6 +60,178 @@ pub enum PromptMode { Stdin, } +/// Preset configuration for a named backend. +struct BackendPreset { + command: &'static str, + args: &'static [&'static str], + prompt_mode: PromptMode, + prompt_flag: Option<&'static str>, + output_format: OutputFormat, +} + +impl From<&BackendPreset> for CliBackend { + fn from(preset: &BackendPreset) -> Self { + Self { + command: preset.command.to_string(), + args: preset.args.iter().map(|s| (*s).to_string()).collect(), + prompt_mode: preset.prompt_mode, + prompt_flag: preset.prompt_flag.map(str::to_string), + output_format: preset.output_format, + env_vars: vec![], + } + } +} + +/// Headless/autonomous backend presets (non-interactive, exits after completion). +static HEADLESS_PRESETS: &[(&str, BackendPreset)] = &[ + ("claude", BackendPreset { + command: "claude", + args: &["--dangerously-skip-permissions", "--verbose", "--output-format", "stream-json"], + prompt_mode: PromptMode::Arg, + prompt_flag: Some("-p"), + output_format: OutputFormat::StreamJson, + }), + ("kiro", BackendPreset { + command: "kiro-cli", + args: &["chat", "--no-interactive", "--trust-all-tools"], + prompt_mode: PromptMode::Arg, + prompt_flag: None, + output_format: OutputFormat::Text, + }), + ("kiro-acp", BackendPreset { + command: "kiro-cli", + args: &["acp"], + prompt_mode: PromptMode::Stdin, + prompt_flag: None, + output_format: OutputFormat::Acp, + }), + ("gemini", BackendPreset { + command: "gemini", + args: &["--yolo"], + prompt_mode: PromptMode::Arg, + prompt_flag: Some("-p"), + output_format: OutputFormat::Text, + }), + ("codex", BackendPreset { + command: "codex", + args: &["exec", "--full-auto"], + prompt_mode: PromptMode::Arg, + prompt_flag: None, + output_format: OutputFormat::Text, + }), + ("amp", BackendPreset { + command: "amp", + args: &["--dangerously-allow-all"], + prompt_mode: PromptMode::Arg, + prompt_flag: Some("-x"), + output_format: OutputFormat::Text, + }), + ("copilot", BackendPreset { + command: "copilot", + args: &["--allow-all-tools"], + prompt_mode: PromptMode::Arg, + prompt_flag: Some("-p"), + output_format: OutputFormat::Text, + }), + ("opencode", BackendPreset { + command: "opencode", + args: &["run"], + prompt_mode: PromptMode::Arg, + prompt_flag: None, + output_format: OutputFormat::Text, + }), + ("pi", BackendPreset { + command: "pi", + args: &["-p", "--mode", "json", "--no-session"], + prompt_mode: PromptMode::Arg, + prompt_flag: None, + output_format: OutputFormat::PiStreamJson, + }), + ("roo", BackendPreset { + command: "roo", + args: &["--print", "--ephemeral"], + prompt_mode: PromptMode::Arg, + prompt_flag: None, + output_format: OutputFormat::Text, + }), +]; + +/// Interactive backend presets (TUI mode with initial prompt support). +static INTERACTIVE_PRESETS: &[(&str, BackendPreset)] = &[ + ("claude", BackendPreset { + command: "claude", + args: &["--dangerously-skip-permissions"], + prompt_mode: PromptMode::Arg, + prompt_flag: None, + output_format: OutputFormat::Text, + }), + ("kiro", BackendPreset { + command: "kiro-cli", + args: &["chat", "--trust-all-tools"], + prompt_mode: PromptMode::Arg, + prompt_flag: None, + output_format: OutputFormat::Text, + }), + ("gemini", BackendPreset { + command: "gemini", + args: &["--yolo"], + prompt_mode: PromptMode::Arg, + prompt_flag: Some("-i"), + output_format: OutputFormat::Text, + }), + ("codex", BackendPreset { + command: "codex", + args: &[], + prompt_mode: PromptMode::Arg, + prompt_flag: None, + output_format: OutputFormat::Text, + }), + ("amp", BackendPreset { + command: "amp", + args: &[], + prompt_mode: PromptMode::Arg, + prompt_flag: Some("-x"), + output_format: OutputFormat::Text, + }), + ("copilot", BackendPreset { + command: "copilot", + args: &[], + prompt_mode: PromptMode::Arg, + prompt_flag: Some("-p"), + output_format: OutputFormat::Text, + }), + ("opencode", BackendPreset { + command: "opencode", + args: &[], + prompt_mode: PromptMode::Arg, + prompt_flag: Some("--prompt"), + output_format: OutputFormat::Text, + }), + ("pi", BackendPreset { + command: "pi", + args: &["--no-session"], + prompt_mode: PromptMode::Arg, + prompt_flag: None, + output_format: OutputFormat::Text, + }), + ("roo", BackendPreset { + command: "roo", + args: &[], + prompt_mode: PromptMode::Arg, + prompt_flag: None, + output_format: OutputFormat::Text, + }), +]; + +/// Looks up a named preset in a table and converts it to a [`CliBackend`]. +fn lookup_preset(table: &[(&str, BackendPreset)], name: &str) -> Result { + table + .iter() + .find(|(n, _)| *n == name) + .map(|(_, preset)| CliBackend::from(preset)) + .ok_or_else(|| BackendError::UnknownBackend { name: name.to_string() }) +} + /// Prepared command ready for execution. /// /// Returned by [`CliBackend::build_command`]. The `_temp_file` handle must @@ -129,350 +301,33 @@ impl CliBackend { Ok(backend) } - /// Creates the Claude backend. - /// - /// Uses `-p` flag for headless/print mode execution. This runs Claude - /// in non-interactive mode where it executes the prompt and exits. - /// - /// Emits `--output-format stream-json` for NDJSON streaming output. - /// Note: `--verbose` is required when using `--output-format stream-json` with `-p`. - pub fn claude() -> Self { - Self { - command: "claude".to_string(), - args: vec![ - "--dangerously-skip-permissions".to_string(), - "--verbose".to_string(), - "--output-format".to_string(), - "stream-json".to_string(), - ], - prompt_mode: PromptMode::Arg, - prompt_flag: Some("-p".to_string()), - output_format: OutputFormat::StreamJson, - env_vars: vec![], - } - } - - /// Creates the Claude backend for interactive prompt injection. - /// - /// Runs Claude without `-p` flag, passing prompt as a positional argument. - pub fn claude_interactive() -> Self { - Self { - command: "claude".to_string(), - args: vec!["--dangerously-skip-permissions".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Creates the Claude interactive backend with Agent Teams support. + /// Creates a headless backend from a named preset. /// - /// Like `claude_interactive()` but with - /// `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var. - pub fn claude_interactive_teams() -> Self { - Self { - command: "claude".to_string(), - args: vec!["--dangerously-skip-permissions".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![( - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS".to_string(), - "1".to_string(), - )], - } - } - - /// Creates the Kiro backend. - /// - /// Uses `kiro-cli` in headless mode with all tools trusted. - pub fn kiro() -> Self { - Self { - command: "kiro-cli".to_string(), - args: vec![ - "chat".to_string(), - "--no-interactive".to_string(), - "--trust-all-tools".to_string(), - ], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], - } + /// # Errors + /// Returns [`BackendError::UnknownBackend`] if the name is not recognized. + pub fn from_name(name: &str) -> Result { + lookup_preset(HEADLESS_PRESETS, name) } - /// Creates the Kiro backend with a specific agent and optional extra args. + /// Creates a headless backend with additional args. /// - /// Uses `kiro-cli` with `--agent` flag to select a specific agent. - pub fn kiro_with_agent(agent: String, extra_args: &[String]) -> Self { - let mut backend = Self { - command: "kiro-cli".to_string(), - args: vec![ - "chat".to_string(), - "--no-interactive".to_string(), - "--trust-all-tools".to_string(), - "--agent".to_string(), - agent, - ], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], - }; + /// # Errors + /// Returns error if the backend name is invalid. + pub fn from_name_with_args(name: &str, extra_args: &[String]) -> Result { + let mut backend = Self::from_name(name)?; backend.args.extend(extra_args.iter().cloned()); - backend - } - - /// Creates the Kiro ACP backend. - /// - /// Uses `kiro-cli` with the ACP subcommand for structured JSON-RPC - /// communication over stdio instead of PTY text scraping. - pub fn kiro_acp() -> Self { - Self::kiro_acp_with_options(None, None) - } - - /// Creates the Kiro ACP backend with an optional agent and/or model. - pub fn kiro_acp_with_options(agent: Option<&str>, model: Option<&str>) -> Self { - let mut args = vec!["acp".to_string()]; - if let Some(name) = agent { - args.push("--agent".to_string()); - args.push(name.to_string()); - } - if let Some(m) = model { - args.push("--model".to_string()); - args.push(m.to_string()); - } - Self { - command: "kiro-cli".to_string(), - args, - prompt_mode: PromptMode::Stdin, - prompt_flag: None, - output_format: OutputFormat::Acp, - env_vars: vec![], - } - } - - /// Creates the Gemini backend. - pub fn gemini() -> Self { - Self { - command: "gemini".to_string(), - args: vec!["--yolo".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: Some("-p".to_string()), - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Gemini in interactive mode with initial prompt (uses `-i`, not `-p`). - /// - /// **Critical quirk**: Gemini requires `-i` flag for interactive+prompt mode. - /// Using `-p` would make it run headless and exit after one response. - pub fn gemini_interactive() -> Self { - Self { - command: "gemini".to_string(), - args: vec!["--yolo".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: Some("-i".to_string()), - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Creates the Codex backend. - pub fn codex() -> Self { - Self { - command: "codex".to_string(), - args: vec!["exec".to_string(), "--full-auto".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Codex in interactive TUI mode (no `exec` subcommand). - /// - /// Unlike headless `codex()`, this runs without `exec` and `--full-auto` - /// flags, allowing interactive TUI mode. - pub fn codex_interactive() -> Self { - Self { - command: "codex".to_string(), - args: vec![], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Creates the Amp backend. - pub fn amp() -> Self { - Self { - command: "amp".to_string(), - args: vec!["--dangerously-allow-all".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: Some("-x".to_string()), - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Amp in interactive mode (removes `--dangerously-allow-all`). - /// - /// Unlike headless `amp()`, this runs without the auto-approve flag, - /// requiring user confirmation for tool usage. - pub fn amp_interactive() -> Self { - Self { - command: "amp".to_string(), - args: vec![], - prompt_mode: PromptMode::Arg, - prompt_flag: Some("-x".to_string()), - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Creates the Copilot backend for autonomous mode. - /// - /// Uses GitHub Copilot CLI with `--allow-all-tools` for automated tool approval. - pub fn copilot() -> Self { - Self { - command: "copilot".to_string(), - args: vec!["--allow-all-tools".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: Some("-p".to_string()), - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Copilot in interactive mode (removes `--allow-all-tools`). - /// - /// Unlike headless `copilot()`, this runs without the auto-approve flag, - /// requiring user confirmation for tool usage. - pub fn copilot_interactive() -> Self { - Self { - command: "copilot".to_string(), - args: vec![], - prompt_mode: PromptMode::Arg, - prompt_flag: Some("-p".to_string()), - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Creates the Copilot TUI backend for interactive mode. - /// - /// Runs Copilot in full interactive mode (no `-p` flag), allowing - /// Copilot's native TUI to render. - pub fn copilot_tui() -> Self { - Self { - command: "copilot".to_string(), - args: vec![], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Creates the `OpenCode` backend for autonomous/headless mode. - /// - /// Uses `OpenCode` CLI with `run` subcommand. The prompt is passed as a - /// positional argument after the subcommand. - pub fn opencode() -> Self { - Self { - command: "opencode".to_string(), - args: vec!["run".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// `OpenCode` in interactive TUI mode. - /// - /// Runs `OpenCode` TUI with an initial prompt via `--prompt` flag. - /// Unlike `opencode()` which uses `opencode run` (headless mode), - /// this launches the interactive TUI and injects the prompt. - pub fn opencode_interactive() -> Self { - Self { - command: "opencode".to_string(), - args: vec![], - prompt_mode: PromptMode::Arg, - prompt_flag: Some("--prompt".to_string()), - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Creates the Pi backend for headless execution. - /// - /// Uses `-p` for print mode with `--mode json` for NDJSON streaming output. - /// Emits `PiStreamJson` output format for structured event parsing. - pub fn pi() -> Self { - Self { - command: "pi".to_string(), - args: vec![ - "-p".to_string(), - "--mode".to_string(), - "json".to_string(), - "--no-session".to_string(), - ], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::PiStreamJson, - env_vars: vec![], - } - } - - /// Creates the Pi backend for interactive mode with initial prompt. - /// - /// Runs pi TUI without `-p` or `--mode json`, passing the prompt as a - /// positional argument. - pub fn pi_interactive() -> Self { - Self { - command: "pi".to_string(), - args: vec!["--no-session".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - - /// Creates the Roo backend for headless execution. - /// - /// Uses `--print` for non-interactive output and `--ephemeral` for clean - /// disk state. Prompts are always passed via `--prompt-file` (handled in - /// `build_command()`). - pub fn roo() -> Self { - Self { - command: "roo".to_string(), - args: vec!["--print".to_string(), "--ephemeral".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], + if backend.command == "codex" { + Self::reconcile_codex_args(&mut backend.args); } + Ok(backend) } - /// Creates the Roo backend for interactive mode with initial prompt. + /// Creates an interactive backend with initial prompt support. /// - /// Runs roo TUI without `--print` or `--ephemeral`, passing the prompt - /// as a positional argument. - pub fn roo_interactive() -> Self { - Self { - command: "roo".to_string(), - args: vec![], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], - } + /// # Errors + /// Returns [`BackendError::UnknownBackend`] if the backend name is not recognized. + pub fn for_interactive_prompt(name: &str) -> Result { + lookup_preset(INTERACTIVE_PRESETS, name) } /// Creates a custom backend from configuration. @@ -499,85 +354,6 @@ impl CliBackend { }) } - /// Creates a backend from a named backend string. - /// - /// # Errors - /// Returns [`BackendError::UnknownBackend`] if the name is not recognized. - pub fn from_name(name: &str) -> Result { - match name { - "claude" => Ok(Self::claude()), - "kiro" => Ok(Self::kiro()), - "kiro-acp" => Ok(Self::kiro_acp()), - "gemini" => Ok(Self::gemini()), - "codex" => Ok(Self::codex()), - "amp" => Ok(Self::amp()), - "copilot" => Ok(Self::copilot()), - "opencode" => Ok(Self::opencode()), - "pi" => Ok(Self::pi()), - "roo" => Ok(Self::roo()), - _ => UnknownBackendSnafu { - name: name.to_string(), - } - .fail(), - } - } - - /// Creates a backend from a named backend with additional args. - /// - /// # Errors - /// Returns error if the backend name is invalid. - pub fn from_name_with_args( - name: &str, - extra_args: &[String], - ) -> Result { - let mut backend = Self::from_name(name)?; - backend.args.extend(extra_args.iter().cloned()); - if backend.command == "codex" { - Self::reconcile_codex_args(&mut backend.args); - } - Ok(backend) - } - - /// Creates a backend configured for interactive mode with initial prompt. - /// - /// Returns the correct backend configuration for running an interactive - /// session with an initial prompt. - /// - /// # Errors - /// Returns [`BackendError::UnknownBackend`] if the backend name is not recognized. - pub fn for_interactive_prompt(backend_name: &str) -> Result { - match backend_name { - "claude" => Ok(Self::claude_interactive()), - "kiro" => Ok(Self::kiro_interactive()), - "gemini" => Ok(Self::gemini_interactive()), - "codex" => Ok(Self::codex_interactive()), - "amp" => Ok(Self::amp_interactive()), - "copilot" => Ok(Self::copilot_interactive()), - "opencode" => Ok(Self::opencode_interactive()), - "pi" => Ok(Self::pi_interactive()), - "roo" => Ok(Self::roo_interactive()), - _ => UnknownBackendSnafu { - name: backend_name.to_string(), - } - .fail(), - } - } - - /// Kiro in interactive mode (removes `--no-interactive`). - /// - /// Unlike headless `kiro()`, this allows the user to interact with - /// Kiro's TUI while still passing an initial prompt. - pub fn kiro_interactive() -> Self { - Self { - command: "kiro-cli".to_string(), - args: vec!["chat".to_string(), "--trust-all-tools".to_string()], - prompt_mode: PromptMode::Arg, - prompt_flag: None, - output_format: OutputFormat::Text, - env_vars: vec![], - } - } - /// Builds roo prompt-file args: writes prompt to a temp file and /// appends `--prompt-file ` to args. Falls back to positional /// arg if temp file creation fails. From bd9309c2f3166c4ae1640567325f22796696d4c8 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 14:34:25 +0900 Subject: [PATCH 04/10] fix(template): unify terminate_child signature across platforms (#42) Co-Authored-By: Claude Opus 4.6 --- template/src/agent/executor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/src/agent/executor.rs b/template/src/agent/executor.rs index 6d5e767..9f4b639 100644 --- a/template/src/agent/executor.rs +++ b/template/src/agent/executor.rs @@ -224,7 +224,7 @@ impl CliExecutor { /// Terminates the child process gracefully via SIGTERM (Unix). #[cfg(unix)] - fn terminate_child(child: &tokio::process::Child) { + fn terminate_child(child: &mut tokio::process::Child) { if let Some(pid) = child.id() { #[allow(clippy::cast_possible_wrap)] let pid = Pid::from_raw(pid as i32); From e167ac0f86d4d66a25b0b108c840e7b27b6d83ac Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 14:34:57 +0900 Subject: [PATCH 05/10] refactor(template): add section markers to config field registry (#42) --- template/src/main.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/template/src/main.rs b/template/src/main.rs index f767b8b..d15908d 100644 --- a/template/src/main.rs +++ b/template/src/main.rs @@ -36,6 +36,7 @@ async fn run() -> error::Result<()> { return Ok(()); } + // Initialize data directory and config (must precede any config/path access) {{crate_name}}::paths::init_data_dir()?; {{crate_name}}::app_config::init()?; @@ -115,6 +116,10 @@ async fn run() -> error::Result<()> { Ok(()) } +// ── Config field registry ───────────────────────────────────────────── +// When adding a new config section, update ALL THREE functions below: +// set_config_field, get_config_field, config_as_map. + /// Set a config field by dotted key path. fn set_config_field(cfg: &mut app_config::AppConfig, key: &str, value: &str) -> error::Result<()> { match key { From 5d9c36df101fe8e0e3d6c28b2eafa6a15e0f6d75 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 14:35:20 +0900 Subject: [PATCH 06/10] feat(template): add prek-init and init recipes to justfile (#42) --- template/justfile | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/template/justfile b/template/justfile index a524a1e..4733310 100644 --- a/template/justfile +++ b/template/justfile @@ -65,9 +65,17 @@ build-release: # Run all pre-commit checks pre-commit: fmt-check lint test -# Install git hooks via prek -setup-hooks: +# Initialize prek pre-commit hooks (first-time setup) +prek-init: prek install + @echo "Pre-commit hooks installed. Run 'just pre-commit' to verify." + +# Install git hooks via prek (alias for prek-init) +setup-hooks: prek-init + +# Initialize full development environment +init: prek-init + @echo "Development environment ready." # ─── Changelog & Release ───────────────────────────────────────────── From de401e22e0153d64e3bab65cc9a4cf74e5342bc1 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 14:36:04 +0900 Subject: [PATCH 07/10] test(template): add integration test scaffolding (#42) Closes #42 --- template/tests/cli_smoke.rs | 53 ++++++++++++++++++++++++++++++++++++ template/tests/common/mod.rs | 7 +++++ 2 files changed, 60 insertions(+) create mode 100644 template/tests/cli_smoke.rs diff --git a/template/tests/cli_smoke.rs b/template/tests/cli_smoke.rs new file mode 100644 index 0000000..58e934d --- /dev/null +++ b/template/tests/cli_smoke.rs @@ -0,0 +1,53 @@ +//! Smoke tests for CLI commands. + +mod common; + +#[test] +fn help_flag_succeeds() { + common::cmd().arg("--help").assert().success(); +} + +#[test] +fn version_flag_succeeds() { + common::cmd().arg("--version").assert().success(); +} + +#[test] +fn agent_describe_outputs_valid_json() { + let output = common::cmd() + .arg("--agent-describe") + .output() + .expect("command should execute"); + + assert!(output.status.success()); + + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("output should be valid JSON"); + + assert_eq!(json["protocol"], "agent-cli/1"); + assert!(json["commands"].is_array()); +} + +#[test] +fn hello_command_returns_agent_response() { + let output = common::cmd() + .args(["hello", "Alice"]) + .output() + .expect("command should execute"); + + assert!(output.status.success()); + + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("output should be valid JSON"); + + assert_eq!(json["ok"], true); + assert!(json["data"]["greeting"].as_str().unwrap().contains("Alice")); +} + +#[test] +fn unknown_command_fails() { + common::cmd() + .arg("nonexistent") + .assert() + .failure(); +} diff --git a/template/tests/common/mod.rs b/template/tests/common/mod.rs index 0ce5709..890e315 100644 --- a/template/tests/common/mod.rs +++ b/template/tests/common/mod.rs @@ -1 +1,8 @@ //! Shared test helpers for integration tests. + +use assert_cmd::Command; + +/// Create a command for the project binary. +pub fn cmd() -> Command { + Command::cargo_bin(env!("CARGO_PKG_NAME")).expect("binary should be built") +} From 2efd2269e380fbb7ed96eb7a41a80ce5505b9754 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 14:37:29 +0900 Subject: [PATCH 08/10] docs(template): update agent-quickstart for init pattern and prek (#42) Co-Authored-By: Claude Opus 4.6 --- template/docs/guides/agent-quickstart.md | 28 +++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/template/docs/guides/agent-quickstart.md b/template/docs/guides/agent-quickstart.md index 1d3beb7..f6abe70 100644 --- a/template/docs/guides/agent-quickstart.md +++ b/template/docs/guides/agent-quickstart.md @@ -68,16 +68,24 @@ After replacing placeholders, customize these files: | `src/main.rs` | Replace the `Hello` command dispatch with your own commands | | `cliff.toml` | Verify the `repo` field matches your GitHub repo name | -### 1d. Verify Initialization +### 1d. Initialize Development Environment + +```bash +just init # Installs prek pre-commit hooks +just pre-commit # Verify all checks pass +``` + +### 1e. Verify Initialization ```bash cargo check # Must compile without errors cargo test # Baseline tests pass cargo run -- --help # CLI shows your project name cargo run -- hello world # Example command works +cargo run -- --agent-describe # Agent schema outputs valid JSON ``` -### 1e. Clean Up Example Code +### 1f. Clean Up Example Code Once your first real command is in place, remove the scaffolding: @@ -99,8 +107,8 @@ src/ ├── cli/ │ └── mod.rs # Clap definitions: Cli struct, Command enum, subcommand enums ├── error.rs # AppError enum (snafu) — add variants for new error sources -├── app_config.rs # TOML config: AppConfig struct + load()/save() -├── paths.rs # ~/.{project-name}/ data directory resolution +├── app_config.rs # TOML config: AppConfig struct + init()/get()/save() +├── paths.rs # ~/.{project-name}/ data directory — init_data_dir()/data_dir() ├── http.rs # Shared reqwest clients (client() + download_client()) └── agent/ # AI agent CLI integration (usually don't need to modify) ├── mod.rs # Re-exports @@ -118,15 +126,19 @@ User input Cli::parse() ← src/cli/mod.rs (clap derive) │ ▼ +paths::init_data_dir() ← src/paths.rs (validates + caches data dir) +app_config::init() ← src/app_config.rs (loads + caches config) + │ + ▼ match cli.command ← src/main.rs (dispatch) │ ├─▶ Your module logic ← src/yourmodule/mod.rs - │ ├── reads config ← app_config::load() + │ ├── reads config ← app_config::get() │ ├── makes HTTP ← http::client() │ └── returns data │ ▼ -JSON output to stdout ← serde_json::json!({"ok": true, ...}) +JSON output to stdout ← AgentResponse::ok(data).print() Logs/errors to stderr ← eprintln!() / tracing ``` @@ -459,8 +471,8 @@ let resp = http::client() ```rust use crate::app_config; -// Read (cached, returns &'static AppConfig) -let cfg = app_config::load(); +// Read (cached, returns &'static AppConfig — init() must have been called in main) +let cfg = app_config::get(); let dir = &cfg.download.output_dir; // Write From 3e46b344a1afa117dddffb8696f56cf9fa02786c Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 14:37:59 +0900 Subject: [PATCH 09/10] docs(template): fix remaining load() reference in agent-quickstart (#42) --- template/docs/guides/agent-quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/docs/guides/agent-quickstart.md b/template/docs/guides/agent-quickstart.md index f6abe70..ad50b79 100644 --- a/template/docs/guides/agent-quickstart.md +++ b/template/docs/guides/agent-quickstart.md @@ -476,7 +476,7 @@ let cfg = app_config::get(); let dir = &cfg.download.output_dir; // Write -let mut cfg = app_config::load().clone(); +let mut cfg = app_config::get().clone(); cfg.download.max_concurrent = 8; app_config::save(&cfg).context(IoSnafu)?; ``` From 11d914fa2063bad54e585b3f31b73264890ed867 Mon Sep 17 00:00:00 2001 From: crrow Date: Fri, 27 Mar 2026 14:39:52 +0900 Subject: [PATCH 10/10] fix(template): add idempotency guard to app_config::init() (#42) --- template/src/app_config.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/template/src/app_config.rs b/template/src/app_config.rs index 02e69c1..f188dd7 100644 --- a/template/src/app_config.rs +++ b/template/src/app_config.rs @@ -43,6 +43,10 @@ impl Default for ExampleConfig { /// /// Must be called once (typically at startup) before [`get`]. pub fn init() -> error::Result<&'static AppConfig> { + if let Some(existing) = APP_CONFIG.get() { + return Ok(existing); + } + let path = crate::paths::config_file(); let cfg = if path.exists() { let settings = config::Config::builder()