feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
189
crates/infrastructure/src/runtime/mod.rs
Normal file
189
crates/infrastructure/src/runtime/mod.rs
Normal file
@ -0,0 +1,189 @@
|
||||
//! [`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
|
||||
//! `<command> --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, SpawnSpec,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
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<dyn ProcessSpawner>,
|
||||
}
|
||||
|
||||
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<dyn ProcessSpawner>) -> 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 `<command> --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<SpawnSpec, RuntimeError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves the profile's `cwd_template` against the project root.
|
||||
///
|
||||
/// The only recognised placeholder is `{projectRoot}` (CONTEXT §9). An empty
|
||||
/// template defaults to the project root itself.
|
||||
fn resolve_cwd(profile: &AgentProfile, root: &ProjectPath) -> Result<ProjectPath, RuntimeError> {
|
||||
let template = profile.cwd_template.trim();
|
||||
if template.is_empty() {
|
||||
return Ok(root.clone());
|
||||
}
|
||||
let resolved = template.replace("{projectRoot}", root.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", "<path>"]`); a flag *with* `{path}` is split on
|
||||
/// whitespace after substitution (e.g. `--context-file {path}` →
|
||||
/// `["--context-file", "<path>"]`).
|
||||
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() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentRuntime for CliAgentRuntime {
|
||||
fn detect(&self, profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
||||
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,
|
||||
) -> Result<SpawnSpec, RuntimeError> {
|
||||
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());
|
||||
}
|
||||
|
||||
Ok(SpawnSpec {
|
||||
command: profile.command.clone(),
|
||||
args,
|
||||
cwd: resolved_cwd,
|
||||
env: Vec::new(),
|
||||
context_plan: Some(plan),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<F: std::future::Future>(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)
|
||||
}
|
||||
Reference in New Issue
Block a user