Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions mise.local.toml
Original file line number Diff line number Diff line change
@@ -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"
31 changes: 30 additions & 1 deletion src/agent_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,10 +56,23 @@ pub fn run_launch(
}
};

let mut cmd = Command::new(&binary);
// `mise exec -- <binary> …` 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);
}
Comment on lines +59 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Error message misattributes failures when launched via mise.

Once the mise branch is taken, a spawn failure at cmd.status() may originate from invoking mise itself, but the downstream Err(e) handler still always reports "failed to launch {binary}: {e}", which is misleading for debugging.

🐛 Proposed fix
     match cmd.status() {
         Ok(s) if s.success() => LaunchOutcome::Launched,
         Ok(s) => {
             let code = s.code().unwrap_or(-1);
             std::process::exit(code);
         }
-        Err(e) => LaunchOutcome::NotFound(format!(
-            "failed to launch {}: {e}",
-            binary.display()
-        )),
+        Err(e) => {
+            let target = mise.as_ref().map(|m| m.display().to_string()).unwrap_or_else(|| binary.display().to_string());
+            LaunchOutcome::NotFound(format!("failed to launch {target}: {e}"))
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// `mise exec -- <binary> …` 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);
}
Err(e) => {
let target = mise
.as_ref()
.map(|m| m.display().to_string())
.unwrap_or_else(|| binary.display().to_string());
LaunchOutcome::NotFound(format!("failed to launch {target}: {e}"))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent_launch.rs` around lines 59 - 75, Update the error handling around
cmd.status() in the agent launch function to report the actual executable being
spawned: use “mise” when via_mise is true and the agent binary otherwise, while
retaining the original error details. Reference the mise command construction
and downstream Err handler so the message accurately identifies the failing
launcher.


if let Some(m) = model {
cmd.arg("--model").arg(m);
Expand Down
25 changes: 25 additions & 0 deletions src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,31 @@ pub fn cli_launch(agent: &str, model: Option<&str>, mode: Option<&str>, args: &[
}
}

/// `agentflare run <agent>` — launch through mise (so its tools are on PATH) with
/// wrangler-style `.dev.vars`[.<stage>] 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::*;
Expand Down
3 changes: 3 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod hook;
mod init;
mod mcp;
mod ponytail;
mod run;
mod uninstall;
mod update;

Expand Down Expand Up @@ -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),
Expand All @@ -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(),
Expand Down
31 changes: 31 additions & 0 deletions src/cli/run.rs
Original file line number Diff line number Diff line change
@@ -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.<stage>` instead of `.dev.vars` (replaces it).
#[arg(long)]
pub env: Option<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub mode: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub args: Vec<String>,
}

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,
);
}
}
136 changes: 97 additions & 39 deletions src/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -305,31 +305,47 @@ pub fn get_components(host: &str) -> Vec<Component> {
})
},
},
// 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 || {
if log.exists() {
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,
}
})
},
Comment on lines 337 to 351

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A failed lean-ctx install can never be retried.

The log is written unconditionally after install(...) (Line 345), and the early guard returns on log.exists() (Lines 340-342). So if the installer fails once, every subsequent agentflare init short-circuits to "already triggered" while check (via tool_install::installed) still reports the tool absent — leaving the component permanently unsatisfiable until the user manually deletes the log. Write the marker only on success.

🔒️ Proposed fix: persist the marker only on success
                     let _ = fs::create_dir_all(log.parent().unwrap());
                     let outcome = crate::tool_install::install(&crate::tool_install::LEAN_CTX);
-                    let _ = fs::write(&log, format!("{:?}", std::time::SystemTime::now()));
                     match outcome {
-                        Ok(m) => format!("{m} + onboarded"),
+                        Ok(m) => {
+                            let _ = fs::write(&log, format!("{:?}", std::time::SystemTime::now()));
+                            format!("{m} + onboarded")
+                        }
                         Err(e) => e,
                     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
apply: {
let log = leanctx_log.clone();
Box::new(move || {
if log.exists() {
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,
}
})
},
apply: {
let log = leanctx_log.clone();
Box::new(move || {
if log.exists() {
return format!("lean-ctx install already triggered — check {}", log.display());
}
let _ = fs::create_dir_all(log.parent().unwrap());
let outcome = crate::tool_install::install(&crate::tool_install::LEAN_CTX);
match outcome {
Ok(m) => {
let _ = fs::write(&log, format!("{:?}", std::time::SystemTime::now()));
format!("{m} + onboarded")
}
Err(e) => e,
}
})
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components.rs` around lines 337 - 351, Only persist the lean-ctx
installation marker after a successful install: update the closure under the
apply component in src/components.rs to write the log within the Ok branch of
the tool_install::install result, while leaving failed outcomes unmarked so
subsequent agentflare init attempts can retry.

Expand All @@ -340,57 +356,91 @@ pub fn get_components(host: &str) -> Vec<Component> {
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");
Expand Down Expand Up @@ -460,13 +510,19 @@ pub fn get_components(host: &str) -> Vec<Component> {
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" => {
Expand Down Expand Up @@ -640,6 +696,7 @@ mod tests {
#[cfg(not(feature = "skill-overrides-sync"))]
let expected: Vec<&str> = vec![
"rules",
"mise",
"leanctx",
"engram",
"agentflare-mcp",
Expand All @@ -650,6 +707,7 @@ mod tests {
#[cfg(feature = "skill-overrides-sync")]
let expected: Vec<&str> = vec![
"rules",
"mise",
"leanctx",
"engram",
"agentflare-mcp",
Expand Down
Loading
Loading