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
16 changes: 16 additions & 0 deletions crates/agent-registry/src/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,22 @@ pub fn detect_all(
detect_all_with(registry, cache, &RealVersionRunner)
}

/// IDs of every `Tier::Cli` agent in `registry` with a binary on PATH —
/// presence only, no version resolution. `detect_all`/`detect_all_with` spawn
/// a `--version` subprocess per detected agent (`resolve_version_with`), which
/// is the right cost for callers that display versions (`agentflare agents
/// list`/`doctor`) but pure waste for callers that only need the ID list:
/// on Windows, Node-wrapped CLIs (e.g. opencode, copilot) can each take
/// 500ms+ just to start up and print `--version`, paid sequentially.
#[must_use]
pub fn detect_present(registry: &[AgentSpec]) -> Vec<&'static str> {
registry
.iter()
.filter(|spec| spec.tier == Tier::Cli && find_binary(spec.binary_names).is_some())
.map(|spec| spec.id.as_str())
.collect()
}

#[cfg(test)]
mod detect_all_tests {
use super::*;
Expand Down
2 changes: 1 addition & 1 deletion crates/agent-registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ pub mod registry;
pub mod router;
pub use detect::{
DetectedAgent, RealVersionRunner, VersionCacheEntry, VersionRunner, detect_all,
detect_all_with, find_binary, resolve_version, resolve_version_with,
detect_all_with, detect_present, find_binary, resolve_version, resolve_version_with,
};
pub use registry::{
Agent, AgentSpec, REGISTRY, Tier, agent_by_name, autonomous_args, canonicalize, headless_args,
Expand Down
54 changes: 36 additions & 18 deletions src/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,32 @@ fn cwd() -> PathBuf {
std::env::current_dir().unwrap_or_default()
}

/// `doctor` builds a fresh `Component` list per host (6+ hosts by default),
/// but these three checks each spawn a subprocess (`git`, `where`/`which`)
/// and their result never depends on which host is being checked — spawning
/// them once per host multiplies process-creation overhead (dominant cost on
/// Windows) for no reason. Memoize for the process's lifetime.
fn mise_present_cached() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHE.get_or_init(|| crate::mise_install::mise_bin().is_some())
}

fn leanctx_installed_cached() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHE.get_or_init(|| {
crate::tool_install::installed(&crate::tool_install::LEAN_CTX)
&& crate::gateway_integrations::already_registered("leanctx")
})
}

fn githooks_installed_cached() -> bool {
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*CACHE.get_or_init(|| match flare_git_core::branch::repo_toplevel(&cwd()) {
Some(root) => crate::cli::git::hooks_installed_for(&root),
None => true,
})
}

fn run_ok(cmd: &str, args: &[&str]) -> bool {
Command::new(cmd)
.args(args)
Expand Down Expand Up @@ -308,16 +334,14 @@ pub(crate) fn unsync_host(rule_id: &str, host: &str) -> Result<(), String> {
/// Agent IDs detected on this machine, for `skill_registry::Registry::open_default`'s
/// `detected_agents` param. skill-registry itself has no `agent-registry` dependency
/// (deliberately decoupled — skill discovery only needs agent IDs, not the version-
/// detection machinery); every call site collects them the same way, using a
/// throwaway cache since none of these callers need cross-call version caching.
/// detection machinery), so this uses `detect_present` (PATH presence only) rather
/// than `detect_all`, which would spawn a `--version` subprocess per detected agent
/// for a value nothing here reads.
pub(crate) fn detected_skill_agents() -> Vec<String> {
agent_registry::detect_all(
agent_registry::REGISTRY,
&mut std::collections::HashMap::new(),
)
.into_iter()
.map(|d| d.id.to_lowercase())
.collect()
agent_registry::detect_present(agent_registry::REGISTRY)
.into_iter()
.map(str::to_lowercase)
.collect()
}

/// Every skill name the shared skill_registry cache currently knows about —
Expand Down Expand Up @@ -666,7 +690,7 @@ pub fn get_components(host: &str) -> Vec<Component> {
id: "mise",
needs_consent: true,
describe: "mise (dev-tool manager) — used by `agentflare run` to launch agents with mise-managed tools on PATH; https://mise.run".to_string(),
check: Box::new(|| crate::mise_install::mise_bin().is_some()),
check: Box::new(mise_present_cached),
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) => {
Expand Down Expand Up @@ -701,10 +725,7 @@ pub fn get_components(host: &str) -> Vec<Component> {
id: "githooks",
needs_consent: true,
describe: "Branch-protection git hooks (.githooks/, core.hooksPath) — blocks direct commits/pushes to the default branch for every git client, not just tool calls this agent's PreToolUse hook watches".to_string(),
check: Box::new(|| match flare_git_core::branch::repo_toplevel(&cwd()) {
Some(root) => crate::cli::git::hooks_installed_for(&root),
None => true,
}),
check: Box::new(githooks_installed_cached),
apply: Box::new(|| match flare_git_core::branch::repo_toplevel(&cwd()) {
Some(root) => match crate::cli::git::install_hooks_for(&root) {
Ok(true) => "installed .githooks/* + core.hooksPath = .githooks".to_string(),
Expand Down Expand Up @@ -775,10 +796,7 @@ pub fn get_components(host: &str) -> Vec<Component> {
// whatever native entry the upstream onboarder already created so
// the same ~80 ctx_* tools aren't declared twice.
describe: "lean-ctx (context compression) — native installer (curl | sh, or brew), registered behind the agentflare gateway (the `tool` action-dispatch), not the host's native tool list".to_string(),
check: Box::new(|| {
crate::tool_install::installed(&crate::tool_install::LEAN_CTX)
&& crate::gateway_integrations::already_registered("leanctx")
}),
check: Box::new(leanctx_installed_cached),
apply: {
let log = leanctx_log.clone();
let host = host_owned.clone();
Expand Down
49 changes: 32 additions & 17 deletions src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ pub(crate) fn stale_rules_for_host(host: &str) -> Vec<StaleRule> {
}

/// Hosts to check when `--agent` isn't given: every agent binary actually
/// detected on PATH (same detection `agentflare agents` uses) — avoids
/// reporting on config for tools the user doesn't have installed. Version
/// resolution accuracy doesn't matter here (only which agents are present),
/// so a throwaway cache is fine.
/// detected on PATH — avoids reporting on config for tools the user doesn't
/// have installed. Presence-only (`detect_present`), not `detect_all`: this
/// only needs the ID list, and `detect_all` would additionally spawn a
/// `--version` subprocess per detected agent that doctor never looks at —
/// Node-wrapped CLIs make that spawn cost hundreds of ms each.
fn default_hosts() -> Vec<String> {
let mut cache = std::collections::HashMap::new();
agent_registry::detect::detect_all(agent_registry::REGISTRY, &mut cache)
agent_registry::detect::detect_present(agent_registry::REGISTRY)
.into_iter()
.map(|a| a.id.to_string())
.map(str::to_string)
.collect()
}

Expand Down Expand Up @@ -97,18 +97,33 @@ pub fn run(agent: Option<&str>, json: bool) {
.unwrap()
);
} else {
for r in &checks {
let mark = if r.ok { "ok" } else { "MISSING" };
println!(
"[{mark:7}] {:16} {:10} {}",
r.host, r.component_id, r.describe
);
}
for s in &stale {
println!("[STALE ] {:16} {}", s.host, s.path.display());
let total = checks.len();
let passed = checks.iter().filter(|r| r.ok).count();

println!("agentflare doctor\n");
for host in &hosts {
let host_checks: Vec<&CheckResult> =
checks.iter().filter(|r| &r.host == host).collect();
let host_stale: Vec<&StaleRule> = stale.iter().filter(|s| &s.host == host).collect();
let host_ok = host_checks.iter().filter(|r| r.ok).count();
let host_total = host_checks.len();
let host_healthy = host_ok == host_total && host_stale.is_empty();
let mark = if host_healthy { "✓" } else { "✗" };

println!("{mark} {host:16} {host_ok}/{host_total} ok");
for r in host_checks.iter().filter(|r| !r.ok) {
println!(" ✗ {:22} {}", r.component_id, r.describe);
}
for s in &host_stale {
println!(" ⚠ stale rule: {}", s.path.display());
}
}

println!();
if healthy {
println!("all checks passed");
println!("{passed}/{total} checks passed — all good.");
} else {
println!("{passed}/{total} checks passed — run `agentflare init` to fix.");
}
}

Expand Down
Loading