//! [`CliAgentRuntime`] — the single, generic [`AgentRuntime`] adapter driven by //! an [`AgentProfile`] (ARCHITECTURE §5, §4; CONTEXT §9). //! //! There is **one** adapter for *every* AI CLI: the diversity (Claude, Codex, //! Gemini, Aider, custom) lives entirely in the declarative [`AgentProfile`] //! data, never in code. Adding an AI = adding a profile (the **Open/Closed** //! principle made literal). //! //! # Responsibilities //! //! - [`detect`](AgentRuntime::detect): run the profile's detection command via //! the injected [`ProcessSpawner`] and report whether the CLI is installed //! (exit code 0). When `profile.detect` is `None`, fall back to //! ` --version` (see [`detection_spec`]). //! - [`prepare_invocation`](AgentRuntime::prepare_invocation): a **pure** //! function (no I/O) that builds the [`SpawnSpec`] — command, args, resolved //! `cwd` (template substitution), and the context-injection plan derived from //! the profile's [`ContextInjection`]. This is the testable core of L5. use std::sync::Arc; use async_trait::async_trait; use domain::ports::{ AgentRuntime, ContextInjectionPlan, PreparedContext, ProcessSpawner, RuntimeError, SessionPlan, SpawnSpec, }; use domain::profile::{AgentProfile, ContextInjection, SessionStrategy}; use domain::project::ProjectPath; /// The single generic AI-runtime adapter. Holds a [`ProcessSpawner`] (used only /// by [`detect`](AgentRuntime::detect)); [`prepare_invocation`] is pure. #[derive(Clone)] pub struct CliAgentRuntime { spawner: Arc, } impl CliAgentRuntime { /// Builds the runtime from an injected [`ProcessSpawner`] (the composition /// root passes a [`crate::LocalProcessSpawner`], or a remote one later). #[must_use] pub fn new(spawner: Arc) -> Self { Self { spawner } } /// Builds the [`SpawnSpec`] used to *detect* a profile's CLI. /// /// - If `profile.detect` is set, it is parsed as a whitespace-delimited /// command line (`"claude --version"` → command `claude`, args /// `["--version"]`). The first token is the executable. /// - Otherwise we fall back to ` --version`, the near-universal /// "is it installed?" probe. /// /// Detection runs in the current working directory and injects nothing. /// /// This is pure (no I/O) and therefore unit-testable without a spawner. /// /// # Errors /// [`RuntimeError::Detection`] if the detection command is blank. pub fn detection_spec(profile: &AgentProfile) -> Result { let line = profile .detect .clone() .unwrap_or_else(|| format!("{} --version", profile.command)); let mut tokens = line.split_whitespace(); let command = tokens .next() .ok_or_else(|| RuntimeError::Detection("empty detection command".to_owned()))? .to_owned(); let args = tokens.map(str::to_owned).collect(); // Detection runs in a neutral cwd; "." is a safe relative placeholder the // spawner resolves against the process cwd. let cwd = ProjectPath::new("/").map_err(|e| RuntimeError::Detection(e.to_string()))?; Ok(SpawnSpec { command, args, cwd, env: Vec::new(), context_plan: None, sandbox: None, }) } /// Resolves the profile's `cwd_template` against the supplied `base` cwd. /// /// The base is the agent's **run directory** (`.ideai/run//`), /// already computed (and created) by `LaunchAgent` and passed as the `cwd` /// argument of [`prepare_invocation`](AgentRuntime::prepare_invocation). The /// recognised placeholder is `{agentRunDir}` (ARCHITECTURE §14.1); the legacy /// `{projectRoot}` is still substituted with the same base for backwards /// compatibility (the caller always passes the run dir now). An empty template /// defaults to the base itself. fn resolve_cwd( profile: &AgentProfile, base: &ProjectPath, ) -> Result { let template = profile.cwd_template.trim(); if template.is_empty() { return Ok(base.clone()); } let resolved = template .replace("{agentRunDir}", base.as_str()) .replace("{projectRoot}", base.as_str()); ProjectPath::new(resolved).map_err(|e| RuntimeError::Invocation(e.to_string())) } /// Builds the [`ContextInjectionPlan`] for a given [`ContextInjection`] and /// the prepared context. **Pure** — the testable heart of L5. /// /// | `ContextInjection` | `ContextInjectionPlan` | /// |---------------------------|----------------------------------------------------------| /// | `ConventionFile { target }` | `File { target }` — caller writes the `.md` there | /// | `Flag { flag }` | `Args { args }` — `{path}` substituted with the ctx path | /// | `Stdin` | `Stdin` — content piped on stdin | /// | `Env { var }` | `Env { var }` — content/path delivered via env var | /// /// For `Flag`, the flag template may contain `{path}` (replaced by the /// context's relative path). A flag *without* `{path}` is treated as a /// standalone switch followed by the path as a separate argument /// (e.g. `-f` → `["-f", ""]`); a flag *with* `{path}` is split on /// whitespace after substitution (e.g. `--context-file {path}` → /// `["--context-file", ""]`). fn injection_plan(injection: &ContextInjection, ctx: &PreparedContext) -> ContextInjectionPlan { match injection { ContextInjection::ConventionFile { target } => ContextInjectionPlan::File { target: target.clone(), }, ContextInjection::Flag { flag } => { let path = &ctx.relative_path; let args = if flag.contains("{path}") { flag.replace("{path}", path) .split_whitespace() .map(str::to_owned) .collect() } else { vec![flag.clone(), path.clone()] }; ContextInjectionPlan::Args { args } } ContextInjection::Stdin => ContextInjectionPlan::Stdin, ContextInjection::Env { var } => ContextInjectionPlan::Env { var: var.clone() }, } } /// Composes the session resume/assign arguments for a launch. **Pure** — the /// testable heart of T3, mirroring [`injection_plan`](Self::injection_plan). /// /// The profile's optional [`SessionStrategy`] crossed with the per-launch /// [`SessionPlan`] yields the args to append (exhaustive truth table): /// /// | `profile.session` | `SessionPlan` | Args added | /// |----------------------------------------|----------------|------------| /// | `None` | any | `[]` | /// | `Some{assign_flag: Some(f), ..}` | `Assign{id}` | `[f, id]` | /// | `Some{resume_flag: r, ..}` | `Resume{id}` | `[r, id]` | /// | `Some{assign_flag: None, resume_flag: r}` | `Resume{id}` | `[r]` (degraded) | /// | `Some{..}` | `None` | `[]` | /// | `Some{assign_flag: None, ..}` | `Assign{id}` | `[]` (no flag) | fn session_args(session: Option<&SessionStrategy>, plan: &SessionPlan) -> Vec { let Some(strategy) = session else { return Vec::new(); }; match plan { SessionPlan::None => Vec::new(), SessionPlan::Assign { conversation_id } => match &strategy.assign_flag { Some(flag) => vec![flag.clone(), conversation_id.clone()], None => Vec::new(), }, SessionPlan::Resume { conversation_id } => { let mut args = vec![strategy.resume_flag.clone()]; if strategy.assign_flag.is_some() { args.push(conversation_id.clone()); } args } } } } #[async_trait] impl AgentRuntime for CliAgentRuntime { fn detect(&self, profile: &AgentProfile) -> Result { let spec = Self::detection_spec(profile)?; // The port is synchronous but the spawner is async; block on it. The // adapter is invoked off the Tauri async runtime (detection is a // short-lived probe), so a transient runtime here is acceptable and keeps // the `AgentRuntime` port plain-`fn` as the domain declares it. let spawner = Arc::clone(&self.spawner); let output = futures_block_on(async move { spawner.run(spec).await }) .map_err(|e| RuntimeError::Detection(e.to_string()))?; Ok(output.status.code == Some(0)) } fn prepare_invocation( &self, profile: &AgentProfile, ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, ) -> Result { let resolved_cwd = Self::resolve_cwd(profile, cwd)?; let plan = Self::injection_plan(&profile.context_injection, ctx); // For the `Flag` strategy the context path travels *on the command line*, // so fold those args into the spec's args (after the profile's static // args). The other strategies leave args untouched; the plan tells the // launcher (L6) what else to do (write file / pipe stdin / set env). let mut args = profile.args.clone(); if let ContextInjectionPlan::Args { args: extra } = &plan { args.extend(extra.iter().cloned()); } // Session resume/assign args come *after* the static + context-injection // args, so re-opening a conversation never disturbs context delivery. args.extend(Self::session_args(profile.session.as_ref(), session)); Ok(SpawnSpec { command: profile.command.clone(), args, cwd: resolved_cwd, env: Vec::new(), context_plan: Some(plan), sandbox: None, }) } } /// Minimal block-on helper that drives a future to completion on a fresh /// current-thread tokio runtime. Used only by [`CliAgentRuntime::detect`], whose /// port signature is synchronous while the [`ProcessSpawner`] it calls is async. fn futures_block_on(fut: F) -> F::Output { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("failed to build a current-thread runtime for detection") .block_on(fut) }