diff --git a/mise.local.toml b/mise.local.toml index a371ae19..ddd73827 100644 --- a/mise.local.toml +++ b/mise.local.toml @@ -1,4 +1,3 @@ [tasks.refresh-devel] -description = "Build the local devel branch (master + all open PRs) and reinstall it as the system agentflare binary" -dir = "{{config_root}}/.worktrees/devel" +description = "Build the current worktree and reinstall it as the system agentflare binary" run = "cargo install --path . --force" diff --git a/src/agent_launch.rs b/src/agent_launch.rs index 7da49e55..ba05856d 100644 --- a/src/agent_launch.rs +++ b/src/agent_launch.rs @@ -18,6 +18,22 @@ pub fn run_launch( model: Option<&str>, mode: Option<&str>, args: &[String], +) -> LaunchOutcome { + run_launch_env(registry, agent, model, mode, args, &[], false) +} + +/// Like `run_launch`, but injects `env` overrides into the child and — when +/// `via_mise` is set and mise is available — launches through `mise exec` so the +/// agent (and everything it spawns) inherits mise's tool paths. Powers +/// `agentflare run`. Falls back to a plain launch if mise isn't installed. +pub fn run_launch_env( + registry: &[AgentSpec], + agent: &str, + model: Option<&str>, + mode: Option<&str>, + args: &[String], + env: &[(String, String)], + via_mise: bool, ) -> LaunchOutcome { let spec = match registry.iter().find(|s| s.id.as_str() == agent) { Some(s) => s, @@ -40,10 +56,23 @@ pub fn run_launch( } }; - let mut cmd = Command::new(&binary); + // `mise exec -- …` runs the agent inside mise's environment, so its + // tool paths are on PATH for the agent and its child shells. + let mise = if via_mise { crate::mise_install::mise_bin() } else { None }; + let mut cmd = match &mise { + Some(m) => { + let mut c = Command::new(m); + c.arg("exec").arg("--").arg(&binary); + c + } + None => Command::new(&binary), + }; cmd.stdout(Stdio::inherit()); cmd.stderr(Stdio::inherit()); cmd.stdin(Stdio::inherit()); + for (k, v) in env { + cmd.env(k, v); + } if let Some(m) = model { cmd.arg("--model").arg(m); diff --git a/src/agents.rs b/src/agents.rs index 87ae7525..d0bd4ecd 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -190,6 +190,31 @@ pub fn cli_launch(agent: &str, model: Option<&str>, mode: Option<&str>, args: &[ } } +/// `agentflare run ` — launch through mise (so its tools are on PATH) with +/// wrangler-style `.dev.vars`[.] env vars injected. Reports what it +/// injects on stderr so it doesn't pollute the agent's stdout. +pub fn cli_run(agent: &str, stage: Option<&str>, model: Option<&str>, mode: Option<&str>, args: &[String]) { + let cwd = std::env::current_dir().unwrap_or_default(); + let env = match crate::dev_vars::load(&cwd, stage) { + Some((path, vars)) => { + eprintln!("agentflare run: injecting {} var(s) from {}", vars.len(), path.display()); + vars + } + None => { + if let Some(s) = stage { + eprintln!("agentflare run: no .dev.vars.{s} or .dev.vars found"); + } + Vec::new() + } + }; + match agent_launch::run_launch_env(agent_registry::REGISTRY, agent, model, mode, args, &env, true) { + LaunchOutcome::Launched => {} + LaunchOutcome::NotFound(msg) => eprintln!("error: {msg}"), + LaunchOutcome::UnknownAgent(msg) => eprintln!("error: unknown agent: {msg}"), + LaunchOutcome::Extension(msg) => eprintln!("error: {msg}"), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d9270a05..c5b35028 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -9,6 +9,7 @@ mod hook; mod init; mod mcp; mod ponytail; +mod run; mod uninstall; mod update; @@ -40,6 +41,7 @@ pub enum Commands { Gateway(gateway::GatewayArgs), Mcp(mcp::McpArgs), Agents(agents::AgentsArgs), + Run(run::RunArgs), Alias(alias::AliasArgs), Update(update::UpdateArgs), Uninstall(uninstall::UninstallArgs), @@ -58,6 +60,7 @@ impl Commands { Self::Gateway(cmd) => cmd.run(), Self::Mcp(cmd) => cmd.run(), Self::Agents(cmd) => cmd.run(), + Self::Run(cmd) => cmd.run(), Self::Alias(cmd) => cmd.run(), Self::Update(cmd) => cmd.run(), Self::Uninstall(cmd) => cmd.run(), diff --git a/src/cli/run.rs b/src/cli/run.rs new file mode 100644 index 00000000..325f8d75 --- /dev/null +++ b/src/cli/run.rs @@ -0,0 +1,31 @@ +use clap::Args; + +/// Launch an agent through mise (so all mise-managed tools are on PATH for the +/// session and anything it spawns) with wrangler-style `.dev.vars` env vars +/// injected. Example: `agentflare run claude-code --env staging`. +#[derive(Args)] +pub struct RunArgs { + /// Agent to launch (e.g. claude-code). + pub agent: String, + /// Env stage: load `.dev.vars.` instead of `.dev.vars` (replaces it). + #[arg(long)] + pub env: Option, + #[arg(long)] + pub model: Option, + #[arg(long)] + pub mode: Option, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub args: Vec, +} + +impl RunArgs { + pub fn run(self) { + crate::agents::cli_run( + &self.agent, + self.env.as_deref(), + self.model.as_deref(), + self.mode.as_deref(), + &self.args, + ); + } +} diff --git a/src/components.rs b/src/components.rs index a2633896..7992a708 100644 --- a/src/components.rs +++ b/src/components.rs @@ -201,7 +201,7 @@ pub(crate) fn rule_targets(host: &str) -> Vec<(PathBuf, String)> { /// Per-host completion marker for components whose "done" state can't be /// read back from the target's own config (or where re-checking would need -/// per-host format parsing). engram-setup specifically: `engram_installed()` +/// per-host format parsing). engram-setup specifically: `installed_via_mise()` /// alone can't tell "set up for THIS host" from "set up for some other host /// on this machine" once the binary exists globally. fn host_marker(component: &str, host: &str) -> PathBuf { @@ -305,14 +305,35 @@ pub fn get_components(host: &str) -> Vec { }) }, }, + // mise (dev-tool version manager) — the cross-platform, dependency-free + // installer for engram's prebuilt binary (mise's `github:` backend + // downloads + checksum-verifies it, no toolchain). Installed before + // engram so its install site can rely on it. Host-independent. (lean-ctx + // has its own native installer and doesn't need mise; see tool_install.) + Component { + id: "mise", + needs_consent: true, + describe: "mise (dev-tool manager) — installs engram's prebuilt binary via its github backend; https://mise.run".to_string(), + check: Box::new(|| crate::mise_install::mise_bin().is_some()), + apply: Box::new(|| match crate::mise_install::ensure_mise() { + crate::mise_install::MiseOutcome::Present(_) => "mise already installed".to_string(), + crate::mise_install::MiseOutcome::Installed(p) => { + format!("mise installed ({p}) — open a new shell to put it on PATH") + } + crate::mise_install::MiseOutcome::Failed(m) => format!("mise install failed — {m}"), + }), + }, Component { id: "leanctx", needs_consent: true, - // lean-ctx's own `onboard` command wires MCP into whichever - // supported tool it detects, so no per-host branching needed - // here — same as engram, trust the upstream tool's own setup. - describe: "lean-ctx (context compression) — npm install -g lean-ctx-bin && lean-ctx onboard".to_string(), - check: Box::new(|| run_ok(if cfg!(windows) { "where" } else { "which" }, &["lean-ctx"])), + // lean-ctx's own installer (and `onboard`) wires MCP into whichever + // supported tool it detects, so no per-host branching needed here — + // same as engram, trust the upstream tool's own setup. Installed via + // its native prebuilt-binary installer (see tool_install), not mise: + // lean-ctx ships a proper `curl | sh` that downloads, verifies, and + // onboards on its own. + describe: "lean-ctx (context compression) — native installer (curl | sh, or brew) + onboard".to_string(), + check: Box::new(|| crate::tool_install::installed(&crate::tool_install::LEAN_CTX)), apply: { let log = leanctx_log.clone(); Box::new(move || { @@ -320,16 +341,11 @@ pub fn get_components(host: &str) -> Vec { return format!("lean-ctx install already triggered — check {}", log.display()); } let _ = fs::create_dir_all(log.parent().unwrap()); - let cmd = "npm install -g lean-ctx-bin && lean-ctx onboard"; - let result = if cfg!(windows) { - Command::new("cmd").args(["/c", cmd]).status() - } else { - Command::new("sh").args(["-c", cmd]).status() - }; + let outcome = crate::tool_install::install(&crate::tool_install::LEAN_CTX); let _ = fs::write(&log, format!("{:?}", std::time::SystemTime::now())); - match result { - Ok(s) if s.success() => "lean-ctx installed and onboarded".to_string(), - _ => "lean-ctx install failed — run manually: npm install -g lean-ctx-bin && lean-ctx onboard".to_string(), + match outcome { + Ok(m) => format!("{m} + onboarded"), + Err(e) => e, } }) }, @@ -340,57 +356,91 @@ pub fn get_components(host: &str) -> Vec { describe: if claude_code_only { "engram (cross-session memory) — claude plugin marketplace add Gentleman-Programming/engram && claude plugin install engram".to_string() } else if ENGRAM_NATIVE_HOSTS.contains(&host) { - format!("engram (cross-session memory) — engram setup {host} (auto-installs engram itself first via go install/brew if missing)") + format!("engram (cross-session memory) — mise installs the prebuilt engram binary, then engram setup {host}") } else { - format!("engram (cross-session memory) — manual MCP registration (no native `engram setup {host}`), auto-installs engram itself via go install/brew if missing") + format!("engram (cross-session memory) — mise installs the prebuilt engram binary, then manual MCP registration (no native `engram setup {host}`)") }, check: { let host = host_owned2.clone(); Box::new(move || { if host == "claude-code" { - return plugin_enabled(&claude_settings(), "engram@engram"); + // Working engram needs the plugin (memory skill) AND a + // reachable MCP server. The plugin's own server calls a + // bare `engram` off PATH (ENOENT), so agentflare + // registers one itself against a mise-provided absolute + // path. "Done" = plugin enabled AND our `engram` entry + // present in ~/.claude.json. + return plugin_enabled(&claude_settings(), "engram@engram") + && claude_json() + .get("mcpServers") + .and_then(|m| m.get("engram")) + .is_some(); } // Binary existing globally isn't enough — this specific // host's setup/registration must have run too. - engram_install::engram_installed() && host_marker("engram-setup", &host).exists() + engram_install::installed_via_mise() && host_marker("engram-setup", &host).exists() }) }, apply: { let host = host_owned2.clone(); Box::new(move || { if host == "claude-code" { - let ok = run_ok("claude", &["plugin", "marketplace", "add", "Gentleman-Programming/engram"]) + let plugin_ok = run_ok("claude", &["plugin", "marketplace", "add", "Gentleman-Programming/engram"]) && run_ok("claude", &["plugin", "install", "engram"]); - return if ok { - "engram plugin installed — restart to activate".to_string() + if !plugin_ok { + return "engram plugin install failed — run manually: claude plugin marketplace add Gentleman-Programming/engram && claude plugin install engram".to_string(); + } + // The plugin's MCP server calls a bare `engram` that + // isn't on PATH. Install the binary through mise and + // register our own MCP server against its absolute path + // — PATH-independent, no symlinks or PATH edits. + let Some(mise) = crate::mise_install::mise_bin() else { + return "engram plugin installed, but the engram binary needs mise — re-run `agentflare init` after the mise component sets it up".to_string(); + }; + let bin = match engram_install::install_via_mise(&mise) { + Ok(p) => p, + Err(e) => return format!("engram plugin installed, but binary install via mise failed — {e}"), + }; + return if run_ok("claude", &["mcp", "add", "engram", "-s", "user", "--", &bin, "mcp", "--tools=agent"]) { + "engram installed via mise + MCP registered — restart to activate".to_string() } else { - "engram plugin install failed — run manually: claude plugin marketplace add Gentleman-Programming/engram && claude plugin install engram".to_string() + format!("engram installed at {bin}, but MCP registration failed — run: claude mcp add engram -s user -- \"{bin}\" mcp --tools=agent") }; } - if !engram_install::engram_installed() { - return match engram_install::install_and_setup(&host) { - engram_install::InstallOutcome::Started(m) => m, - engram_install::InstallOutcome::NoSafePath(m) => m, - }; - } + // Every non-claude host installs engram through mise (the + // github backend — a prebuilt binary, no toolchain) and is + // wired against the returned absolute path, so GUI-launched + // clients that don't inherit ~/.local/bin on PATH still + // resolve it. mise is the only install backend we ship. + let Some(mise) = crate::mise_install::mise_bin() else { + return "engram needs mise — re-run `agentflare init` after the mise component installs it".to_string(); + }; + let bin = match engram_install::install_via_mise(&mise) { + Ok(p) => p, + Err(e) => return format!("engram install via mise failed — {e}"), + }; let marker = host_marker("engram-setup", &host); if ENGRAM_NATIVE_HOSTS.contains(&host.as_str()) { - return if run_ok("engram", &["setup", &host]) { + // `engram setup` writes the invoked binary's absolute + // path into the host's MCP config (and installs the + // memory persona), so run it through the mise path to + // get a PATH-independent entry. + return if run_ok(&bin, &["setup", &host]) { mark_done(&marker); - format!("engram setup {host} done") + format!("engram installed via mise + setup {host} done") } else { - format!("engram setup {host} failed — run manually: engram setup {host}") + format!("engram installed at {bin}, but `engram setup {host}` failed — run manually: {bin} setup {host}") }; } // cline/continue/opencode: no native `engram setup` — - // register the MCP command directly in the host's - // config, same shape engram's docs use for "any - // other MCP client". - let entry = serde_json::json!({ "command": "engram", "args": ENGRAM_MCP_ARGS }); + // register the MCP command directly in the host's config, + // same shape engram's docs use for "any other MCP client", + // against the mise absolute path. + let entry = serde_json::json!({ "command": bin, "args": ENGRAM_MCP_ARGS }); let result = match host.as_str() { "cline" => { let path = home().join(".cline").join("mcp.json"); @@ -460,13 +510,19 @@ pub fn get_components(host: &str) -> Vec { apply: { let host = host_owned.clone(); Box::new(move || { - let entry = serde_json::json!({ "command": "agentflare", "args": ["mcp"] }); + // Register the absolute binary path, not the bare name: + // Claude Code launches MCP servers from its own process, + // which (when started from a GUI/launcher) may not have + // agentflare's install dir on PATH. Same reasoning as the + // hook wiring in init.rs. + let bin = crate::paths::agentflare_binary(); + let entry = serde_json::json!({ "command": bin, "args": ["mcp"] }); match host.as_str() { "claude-code" => { - if run_ok("claude", &["mcp", "add", "agentflare", "-s", "user", "--", "agentflare", "mcp"]) { + if run_ok("claude", &["mcp", "add", "agentflare", "-s", "user", "--", &bin, "mcp"]) { "agentflare MCP server registered with claude-code".to_string() } else { - "agentflare MCP registration failed — run manually: claude mcp add agentflare -- agentflare mcp".to_string() + format!("agentflare MCP registration failed — run manually: claude mcp add agentflare -s user -- \"{bin}\" mcp") } } "cline" => { @@ -640,6 +696,7 @@ mod tests { #[cfg(not(feature = "skill-overrides-sync"))] let expected: Vec<&str> = vec![ "rules", + "mise", "leanctx", "engram", "agentflare-mcp", @@ -650,6 +707,7 @@ mod tests { #[cfg(feature = "skill-overrides-sync")] let expected: Vec<&str> = vec![ "rules", + "mise", "leanctx", "engram", "agentflare-mcp", diff --git a/src/dev_vars.rs b/src/dev_vars.rs new file mode 100644 index 00000000..3d237e9f --- /dev/null +++ b/src/dev_vars.rs @@ -0,0 +1,96 @@ +// Wrangler-style `.dev.vars` loader for `agentflare run`. Injects local env vars +// into a launched agent session. Format is dotenv (KEY=VALUE, `#` comments, +// optionally quoted values). Multi-stage mirrors wrangler: with a stage, +// `.dev.vars.` REPLACES the base `.dev.vars` entirely — per the docs, +// "if .dev.vars. exists then only this will be loaded; the +// .dev.vars file will not be loaded". +use std::path::{Path, PathBuf}; + +/// Pick the file for `stage` (replacement semantics), parse it, and return +/// (path, vars). `None` when no matching file exists. +pub fn load(dir: &Path, stage: Option<&str>) -> Option<(PathBuf, Vec<(String, String)>)> { + let staged = stage.map(|s| dir.join(format!(".dev.vars.{s}"))); + let path = match staged { + Some(p) if p.exists() => p, + _ => dir.join(".dev.vars"), + }; + let content = std::fs::read_to_string(&path).ok()?; + Some((path, parse(&content))) +} + +/// Minimal dotenv: skip blank and `#`-comment lines, drop an optional `export` +/// prefix, split on the first `=`, trim, and strip one layer of matching quotes +/// from the value. Intentionally does not do trailing-comment or escape parsing +/// — values with `#` stay intact. +fn parse(content: &str) -> Vec<(String, String)> { + content + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .filter_map(|l| l.strip_prefix("export ").unwrap_or(l).split_once('=')) + .map(|(k, v)| (k.trim().to_string(), unquote(v.trim()).to_string())) + .filter(|(k, _)| !k.is_empty()) + .collect() +} + +fn unquote(v: &str) -> &str { + let b = v.as_bytes(); + if v.len() >= 2 && (b[0] == b'"' || b[0] == b'\'') && b[b.len() - 1] == b[0] { + &v[1..v.len() - 1] + } else { + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_dotenv_with_comments_quotes_and_export() { + let vars = parse("# comment\nexport A=1\nB=\"two words\"\nC='x'\n\n D = 4 \nBAD"); + assert_eq!( + vars, + vec![ + ("A".into(), "1".into()), + ("B".into(), "two words".into()), + ("C".into(), "x".into()), + ("D".into(), "4".into()), + ] + ); + } + + #[test] + fn value_containing_hash_is_preserved() { + assert_eq!(parse("K=a#b"), vec![("K".into(), "a#b".into())]); + } + + #[test] + fn staged_file_replaces_base() { + let dir = std::env::temp_dir().join(format!("agentflare-devvars-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(".dev.vars"), "A=base\n").unwrap(); + std::fs::write(dir.join(".dev.vars.prod"), "A=prod\n").unwrap(); + + let (path, vars) = load(&dir, Some("prod")).unwrap(); + assert!(path.ends_with(".dev.vars.prod")); + assert_eq!(vars, vec![("A".into(), "prod".into())]); + + // No staged file → falls back to base. + let (path, vars) = load(&dir, Some("missing")).unwrap(); + assert!(path.ends_with(".dev.vars")); + assert_eq!(vars, vec![("A".into(), "base".into())]); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn returns_none_when_no_file() { + let dir = std::env::temp_dir().join(format!("agentflare-devvars-none-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + assert!(load(&dir, None).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/engram_install.rs b/src/engram_install.rs index 41130a2c..97ca6a1b 100644 --- a/src/engram_install.rs +++ b/src/engram_install.rs @@ -1,73 +1,28 @@ -// engram (github.com/Gentleman-Programming/engram) has no safe universal -// one-liner: the maintainer's own docs say prebuilt Windows binaries get -// AV-flagged as false positives and explicitly recommend `go install` -// (compiles locally, never flagged) or Homebrew on macOS/Linux instead. -// Only install through one of those two safe paths; otherwise print the -// documented manual options. -use std::process::{Command, Stdio}; +// engram (github.com/Gentleman-Programming/engram) is installed through mise's +// `github:` backend, which downloads the project's prebuilt release asset +// (engram___.tar.gz/.zip) and verifies it against the release's +// own checksums.txt — no Go toolchain, no compile, no npm. It's the only mise +// backend we ship: unlike `go:`/`npm:`, it pulls in no language toolchain of +// its own. mise installs off-PATH, so callers wire an MCP server against the +// returned absolute path rather than expecting a bare `engram` to resolve — +// which also survives GUI-launched hosts that don't inherit ~/.local/bin on +// PATH. -pub fn has(cmd: &str) -> bool { - let checker = if cfg!(windows) { "where" } else { "which" }; - Command::new(checker) - .arg(cmd) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -pub fn engram_installed() -> bool { - Command::new("engram") - .arg("version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) -} - -pub enum InstallOutcome { - Started(String), - NoSafePath(String), -} +/// mise backend spec for the engram binary. mise auto-scores the right release +/// asset per OS/arch, so no pattern is needed. See the module header. +const MISE_SPEC: &str = "github:Gentleman-Programming/engram"; -/// Runs synchronously (unlike the old JS's detached background spawn) since -/// `agentflare init` is an explicit, one-shot user command — the user is -/// already waiting on it to finish, there's no session-start timeout budget -/// to protect here. -pub fn install_and_setup(agent: &str) -> InstallOutcome { - if has("go") { - let cmd = format!( - "go install github.com/Gentleman-Programming/engram/cmd/engram@latest && engram setup {agent}" - ); - return run_shell(&cmd, "go install"); - } - if !cfg!(windows) && has("brew") { - let cmd = format!("brew install gentleman-programming/tap/engram && engram setup {agent}"); - return run_shell(&cmd, "brew"); - } - InstallOutcome::NoSafePath(if cfg!(windows) { - "engram: no safe auto-install path (no Go toolchain, and prebuilt Windows binaries are AV-flagged per the project's own docs). Install Go then re-run, or see github.com/Gentleman-Programming/engram/releases and accept the AV warning yourself.".to_string() - } else { - "engram: no Go or Homebrew found. Install one, or see github.com/Gentleman-Programming/engram/blob/main/docs/INSTALLATION.md".to_string() - }) +/// Install engram through mise and return the absolute path to the binary. +/// Used where the binary must be referenced by full path (e.g. registering an +/// MCP server) rather than resolved off PATH. +pub fn install_via_mise(mise: &str) -> Result { + crate::mise_install::install_tool(mise, MISE_SPEC, "engram") } -fn run_shell(cmd: &str, via: &str) -> InstallOutcome { - let result = if cfg!(windows) { - Command::new("cmd").args(["/c", cmd]).status() - } else { - Command::new("sh").args(["-c", cmd]).status() - }; - match result { - Ok(status) if status.success() => { - InstallOutcome::Started(format!("engram installed and set up via {via}")) - } - Ok(status) => InstallOutcome::NoSafePath(format!( - "engram install via {via} failed (exit {:?}) — see github.com/Gentleman-Programming/engram/blob/main/docs/INSTALLATION.md", - status.code() - )), - Err(e) => InstallOutcome::NoSafePath(format!("engram install via {via} failed to start: {e}")), - } +/// Whether engram is installed and resolvable through mise. Used instead of a +/// bare-PATH `engram version` check, since mise installs the binary off-PATH. +pub fn installed_via_mise() -> bool { + crate::mise_install::mise_bin() + .and_then(|m| crate::mise_install::which_tool(&m, "engram")) + .is_some() } diff --git a/src/init.rs b/src/init.rs index 9960e423..c85c589a 100644 --- a/src/init.rs +++ b/src/init.rs @@ -6,7 +6,7 @@ // Codex's hook only activates through its plugin system, so that wiring // lives in .codex-plugin/ instead, not here. use crate::components::{get_components, rule_targets}; -use crate::paths::home; +use crate::paths::{agentflare_binary, home}; use crate::rule_text; use serde_json::{json, Value}; use std::fs; @@ -16,13 +16,6 @@ fn cwd() -> PathBuf { std::env::current_dir().unwrap_or_default() } -fn agentflare_binary() -> String { - std::env::current_exe() - .ok() - .and_then(|p| p.to_str().map(String::from)) - .unwrap_or_else(|| "agentflare".to_string()) -} - fn confirm_ponytail_migration(agent: &str, yes: bool) -> bool { let detected = match agent { "claude-code" | "cowork" => has_existing_ponytail_claude(), diff --git a/src/main.rs b/src/main.rs index ff444cd1..f323d7f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod gateway_secrets; mod cli; mod coaching; mod components; +mod dev_vars; mod cost; mod engram_install; mod errors; @@ -18,6 +19,7 @@ mod hook; mod init; mod mcp_prompts; mod mcp_server; +mod mise_install; mod optimize; mod paths; mod pricing; @@ -25,6 +27,7 @@ mod rollup; mod rule_text; mod shell; mod state; +mod tool_install; mod uninstall; mod update; diff --git a/src/mise_install.rs b/src/mise_install.rs new file mode 100644 index 00000000..7594576b --- /dev/null +++ b/src/mise_install.rs @@ -0,0 +1,230 @@ +// Cross-platform detect-or-install for mise (github.com/jdx/mise), a dev-tool +// version manager. agentflare uses it as a uniform, host-independent way to +// provide the external toolchains its integrations need — Go for the engram +// binary, Node/npm for lean-ctx — on machines that don't already have them. +// +// This module only handles mise itself (detection + bootstrap). Installing the +// individual tools through mise happens at each tool's install site. +use crate::paths::home; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +pub enum MiseOutcome { + /// Already on the system (path to the binary). + Present(String), + /// We just installed it (path to the binary). + Installed(String), + /// Not present and could not be installed (reason). + Failed(String), +} + +/// A usable mise binary, or `None`. Checks PATH first, then mise's default +/// per-OS install location — a freshly-installed mise lands outside the +/// current process's PATH, so "just installed" wouldn't otherwise be visible +/// until a new shell. +pub fn mise_bin() -> Option { + if let Some(p) = which("mise") { + return Some(p); + } + default_locations() + .into_iter() + .find(|p| p.exists()) + .map(|p| p.to_string_lossy().into_owned()) +} + +/// Ensure mise is available, installing it cross-platform if absent. +pub fn ensure_mise() -> MiseOutcome { + if let Some(bin) = mise_bin() { + return MiseOutcome::Present(bin); + } + if let Err(e) = install() { + return MiseOutcome::Failed(e); + } + match mise_bin() { + Some(bin) => MiseOutcome::Installed(bin), + None => MiseOutcome::Failed( + "mise installer reported success but the binary was not found on PATH \ + or its default install location — open a new shell and re-run, or see \ + https://mise.jdx.dev/installing-mise.html" + .to_string(), + ), + } +} + +/// mise's default install location per OS (see mise's own install docs): +/// `~/.local/bin/mise` on Unix, `%LOCALAPPDATA%\mise\bin\mise.exe` on Windows. +fn default_locations() -> Vec { + if cfg!(windows) { + let local = std::env::var("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|_| home().join("AppData").join("Local")); + vec![local.join("mise").join("bin").join("mise.exe")] + } else { + vec![home().join(".local").join("bin").join("mise")] + } +} + +fn install() -> Result<(), String> { + if cfg!(windows) { + install_windows() + } else { + install_unix() + } +} + +/// Official installer (https://mise.run) via curl, wget as a fallback for +/// curl-less minimal images. Installs to ~/.local/bin/mise. +fn install_unix() -> Result<(), String> { + let cmd = if has("curl") { + "curl -fsSL https://mise.run | sh" + } else if has("wget") { + "wget -qO- https://mise.run | sh" + } else { + return Err( + "cannot install mise: neither curl nor wget is available. Install one, \ + or install mise manually per https://mise.jdx.dev/installing-mise.html" + .to_string(), + ); + }; + run_shell(cmd) +} + +/// Windows has no official one-line install *script* (the PowerShell snippet in +/// mise's docs only wires activation), so use the package managers mise itself +/// recommends: winget, then scoop. +fn install_windows() -> Result<(), String> { + if has("winget") + && run_status( + "winget", + &[ + "install", + "-e", + "--id", + "jdx.mise", + "--silent", + "--accept-source-agreements", + "--accept-package-agreements", + ], + ) + { + return Ok(()); + } + if has("scoop") && run_status("scoop", &["install", "mise"]) { + return Ok(()); + } + Err( + "cannot install mise on Windows: neither winget nor scoop succeeded. Install \ + one (or mise directly) per https://mise.jdx.dev/installing-mise.html" + .to_string(), + ) +} + +/// `mise use -g ` — install/activate a tool globally. Run from $HOME so +/// an untrusted project-local mise config in the user's cwd can't block a +/// global install. +pub fn use_global(mise: &str, spec: &str) -> bool { + Command::new(mise) + .current_dir(home()) + .args(["use", "-g", spec]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Install a tool globally from a backend spec (e.g. +/// `github:Gentleman-Programming/engram`) and return the absolute path mise +/// resolves `bin` to. mise installs into its own data dir, which is NOT on +/// PATH — so callers use this absolute path directly (as an MCP command, etc.) +/// rather than expecting the bare name to resolve. That's the whole point of +/// routing through mise: a PATH-independent, cross-platform install path. +pub fn install_tool(mise: &str, spec: &str, bin: &str) -> Result { + if !use_global(mise, spec) { + return Err(format!("`mise use -g {spec}` failed")); + } + which_tool(mise, bin) + .ok_or_else(|| format!("mise installed {spec} but `mise which {bin}` returned no path")) +} + +/// Absolute path mise resolves `bin` to, or `None`. Reads stdout only — mise +/// can emit unrelated warnings on stderr. +pub fn which_tool(mise: &str, bin: &str) -> Option { + let out = Command::new(mise) + .current_dir(home()) + .args(["which", bin]) + .stderr(Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + String::from_utf8_lossy(&out.stdout) + .lines() + .last() + .map(|l| l.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn which(cmd: &str) -> Option { + let checker = if cfg!(windows) { "where" } else { "which" }; + let out = Command::new(checker) + .arg(cmd) + .stderr(Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + String::from_utf8_lossy(&out.stdout) + .lines() + .next() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) +} + +fn has(cmd: &str) -> bool { + which(cmd).is_some() +} + +fn run_shell(cmd: &str) -> Result<(), String> { + let result = if cfg!(windows) { + Command::new("cmd").args(["/c", cmd]).status() + } else { + Command::new("sh").args(["-c", cmd]).status() + }; + match result { + Ok(s) if s.success() => Ok(()), + Ok(s) => Err(format!("mise installer exited with {:?}", s.code())), + Err(e) => Err(format!("failed to run mise installer: {e}")), + } +} + +fn run_status(cmd: &str, args: &[&str]) -> bool { + Command::new(cmd) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_locations_are_platform_appropriate_and_nonempty() { + let locs = default_locations(); + assert!(!locs.is_empty()); + let joined = locs.iter().map(|p| p.to_string_lossy()).collect::(); + assert!(joined.contains("mise")); + if cfg!(windows) { + assert!(joined.ends_with("mise.exe")); + } + } + + #[test] + fn which_returns_none_for_a_nonexistent_command() { + assert!(which("definitely-not-a-real-binary-xyz-123").is_none()); + } +} diff --git a/src/paths.rs b/src/paths.rs index 3fdf7abf..d8df262a 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -12,6 +12,19 @@ pub fn home() -> PathBuf { dirs::home_dir().expect("home directory not found") } +/// Absolute path to the currently-running agentflare binary, falling back to +/// the bare name if it can't be resolved. Used wherever agentflare registers +/// itself as a command in another tool's config (Claude Code hooks, MCP +/// servers) so the integration keeps working even when the launching process +/// doesn't inherit agentflare's install dir on PATH — e.g. a GUI-launched +/// Claude Code that never sourced the shell profile that adds ~/.local/bin. +pub fn agentflare_binary() -> String { + std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(String::from)) + .unwrap_or_else(|| "agentflare".to_string()) +} + /// Shared by mcp_server.rs (serving skill_search/skill_load) and /// components.rs (syncing skillOverrides) — same on-disk cache, single path /// definition so the two can never drift apart. diff --git a/src/tool_install.rs b/src/tool_install.rs new file mode 100644 index 00000000..ba49da8e --- /dev/null +++ b/src/tool_install.rs @@ -0,0 +1,194 @@ +// Native installers for external CLI tools, declared as data. Each tool lists +// its official install methods in priority order; the runner detects which +// required helper (curl, brew, …) is present on this platform and runs the +// first method that fits. No version manager needed — the tools' own installers +// fetch prebuilt binaries, verify checksums, and fix PATH themselves. +// +// This is the counterpart to engram's mise path: engram has no universal +// installer script (only go/brew/prebuilt), so it routes through mise; tools +// that DO ship a proper `curl | sh` installer use it directly here. +use std::process::{Command, Stdio}; + +/// One official install method for a tool. +pub struct Method { + /// Helper that must be on PATH to use this method (e.g. "curl", "brew"). + pub requires: &'static str, + /// Self-contained shell command that installs — and, where the upstream + /// installer doesn't do it itself, onboards — the tool. + pub command: &'static str, +} + +/// An installable external tool and its native install methods. +pub struct Tool { + pub id: &'static str, + /// Binary name, used to detect an existing install. + pub bin: &'static str, + /// Install methods, highest priority first. + pub methods: &'static [Method], + /// Best-effort commands run (as direct process spawns — no shell) after a + /// successful install; each entry is a full argv. For lean-ctx this + /// allowlists `agentflare` in lean-ctx's own shell hook: once onboarded, + /// that hook blocks any non-allowlisted command — including agentflare's own + /// re-invocations (its SessionStart hooks, its MCP server) — so agentflare + /// must add itself, or it locks itself out of every hooked shell. + pub post_install: &'static [&'static [&'static str]], +} + +/// lean-ctx (github.com/yvgude/lean-ctx) — token-efficient context tool. +/// +/// The universal installer downloads a prebuilt binary from GitHub releases, +/// SHA256-verifies it, installs to ~/.local/bin, fixes PATH, and runs `onboard` +/// itself — so `curl | sh` is the whole install. We fetch the script from +/// GitHub raw rather than leanctx.com, which fronts the same script but is +/// unreliable. brew is the platform-native alternative; its formula doesn't +/// onboard, so that command does it explicitly. +pub const LEAN_CTX: Tool = Tool { + id: "lean-ctx", + bin: "lean-ctx", + methods: &[ + Method { + requires: "curl", + command: "curl -fsSL https://raw.githubusercontent.com/yvgude/lean-ctx/main/install.sh | sh", + }, + Method { + requires: "brew", + command: "brew tap yvgude/lean-ctx && brew install lean-ctx && lean-ctx onboard", + }, + ], + // Post-install steps, run as direct process spawns (not `sh -c`, which + // lean-ctx's hook also blocks): + // 1. allow agentflare + mise in the shell-hook allowlist. agentflare + // installs engram through mise and `agentflare run` launches via it, + // and neither is in lean-ctx's built-in default allowlist, so the + // onboarded gate would otherwise block them under the default enforce. + // 2. set the strongest compression ("power mode") — the reason to run + // lean-ctx at all is denser model output. + post_install: &[ + &["lean-ctx", "allow", "agentflare", "mise"], + &["lean-ctx", "config", "set", "compression_level", "max"], + ], +}; + +/// Whether `tool` is already installed (its binary resolves on PATH). +pub fn installed(tool: &Tool) -> bool { + has(tool.bin) +} + +/// Install `tool` via the first method whose required helper is present. +/// Returns a human-readable status, or an error naming the helpers it needs. +pub fn install(tool: &Tool) -> Result { + if cfg!(windows) { + return Err(format!( + "{}: no native installer for Windows yet — build from source", + tool.id + )); + } + let Some(method) = tool.methods.iter().find(|m| has(m.requires)) else { + let helpers: Vec<_> = tool.methods.iter().map(|m| m.requires).collect(); + return Err(format!( + "{}: no native installer available — install one of [{}] first", + tool.id, + helpers.join(", ") + )); + }; + match run_shell(method.command) { + Ok(()) => { + // Best-effort — a failed post-step (e.g. allowlisting) shouldn't + // undo a successful install. + for argv in tool.post_install { + let _ = run_direct(argv); + } + Ok(format!("{} installed via {}", tool.id, method.requires)) + } + Err(e) => Err(format!( + "{} install via {} failed — {e}. Run manually: {}", + tool.id, method.requires, method.command + )), + } +} + +fn has(cmd: &str) -> bool { + let checker = if cfg!(windows) { "where" } else { "which" }; + Command::new(checker) + .arg(cmd) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Spawn a command directly (no shell), with ~/.local/bin prepended to PATH so +/// a binary the native installer just dropped there resolves. Direct spawn also +/// dodges lean-ctx's `sh -c` block. Returns whether it succeeded. +fn run_direct(argv: &[&str]) -> bool { + let Some((prog, args)) = argv.split_first() else { + return false; + }; + let existing = std::env::var_os("PATH").unwrap_or_default(); + let mut dirs = vec![crate::paths::home().join(".local").join("bin")]; + dirs.extend(std::env::split_paths(&existing)); + let path = std::env::join_paths(dirs).unwrap_or(existing); + Command::new(prog) + .args(args) + .env("PATH", path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn run_shell(cmd: &str) -> Result<(), String> { + let result = if cfg!(windows) { + Command::new("cmd").args(["/c", cmd]).status() + } else { + Command::new("sh").args(["-c", cmd]).status() + }; + match result { + Ok(s) if s.success() => Ok(()), + Ok(s) => Err(format!("exit {:?}", s.code())), + Err(e) => Err(format!("failed to start: {e}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lean_ctx_is_declared_with_ordered_nonempty_methods() { + assert_eq!(LEAN_CTX.bin, "lean-ctx"); + assert!(!LEAN_CTX.methods.is_empty()); + // curl (universal, no extra deps) is preferred over brew. + assert_eq!(LEAN_CTX.methods[0].requires, "curl"); + assert!(LEAN_CTX.methods.iter().all(|m| !m.command.is_empty())); + // agentflare must allowlist itself (and mise) in lean-ctx's shell hook, + // and turn on power-mode compression. + assert!(LEAN_CTX.post_install.iter().any(|argv| { + argv.first() == Some(&"lean-ctx") + && argv.contains(&"agentflare") + && argv.contains(&"mise") + })); + assert!(LEAN_CTX + .post_install + .iter() + .any(|argv| argv.contains(&"compression_level") && argv.contains(&"max"))); + } + + #[test] + fn install_reports_missing_helpers_when_none_present() { + const FAKE: Tool = Tool { + id: "fake", + bin: "fake", + methods: &[Method { + requires: "definitely-not-a-real-helper-xyz-123", + command: "true", + }], + post_install: &[], + }; + let err = install(&FAKE).unwrap_err(); + // Both the missing-helper and windows branches say "no native installer". + assert!(err.contains("no native installer"), "got: {err}"); + } +}