diff --git a/Cargo.lock b/Cargo.lock index ecdfa402..77bfbf0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "sysinfo", "tar", "thiserror", "tokio", @@ -858,7 +859,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1137,6 +1138,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1152,6 +1162,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + [[package]] name = "object" version = "0.37.3" @@ -1723,6 +1742,19 @@ dependencies = [ "syn", ] +[[package]] +name = "sysinfo" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b93974b3d3aeaa036504b8eefd4c039dced109171c1ae973f1dc63b2c7e4b2" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "windows", +] + [[package]] name = "tar" version = "0.4.46" @@ -2059,19 +2091,74 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -2083,6 +2170,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -2100,6 +2198,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 2902a1b5..bc60b6cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,11 @@ rpassword = "7" thiserror = "2" eyre = "0.6" color-eyre = "0.6" +sysinfo = { version = "0.34", optional = true, default-features = false, features = ["system"] } + +[features] +default = [] +process-tree = ["sysinfo"] [build-dependencies] built = { version = "0.8", features = ["chrono"] } diff --git a/src/init.rs b/src/init.rs index b1d4e2a7..d1cdd195 100644 --- a/src/init.rs +++ b/src/init.rs @@ -22,7 +22,108 @@ fn agentflare_binary() -> String { .unwrap_or_else(|| "agentflare".to_string()) } -pub fn run(agent: &str) { +fn confirm_ponytail_migration(agent: &str, yes: bool) -> bool { + let detected = match agent { + "claude-code" | "cowork" => has_existing_ponytail_claude(), + "cursor" | "cursor-cli" => has_existing_ponytail_cursor(), + "opencode" => has_existing_ponytail_opencode(), + _ => false, + }; + + if !detected { + return true; + } + + println!(); + println!("⚠ Existing ponytail plugin detected for {agent}."); + println!(" agentflare has ponytail built-in — the npm plugin would conflict."); + + if !yes { + print!(" Uninstall ponytail plugin? [Y/n] "); + let mut input = String::new(); + let bytes_read = std::io::stdin().read_line(&mut input).ok(); + if bytes_read == Some(0) { + println!(" Skipped. Re-run: agentflare init --agent {agent}"); + return false; + } + match input.trim().to_lowercase().as_str() { + "y" | "yes" | "" => {} + _ => { + println!(" Skipped. Re-run: agentflare init --agent {agent}"); + return false; + } + } + } + + match agent { + "opencode" => { + println!(" Running: opencode plugin uninstall ponytail@ponytail"); + match std::process::Command::new("opencode") + .args(["plugin", "uninstall", "ponytail@ponytail"]) + .output() + { + Ok(out) => { + if out.status.success() { + println!(" ok ponytail plugin uninstalled"); + } else { + let stderr = String::from_utf8_lossy(&out.stderr); + println!(" fail {stderr}"); + } + } + Err(e) => println!(" fail could not run opencode: {e}"), + } + true + } + "claude-code" | "cowork" => { + println!(" Run '/plugin uninstall ponytail@ponytail' in a Claude Code session"); + true + } + _ => true, + } +} + +fn has_existing_ponytail_claude() -> bool { + let path = home().join(".claude").join("settings.json"); + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(settings) = serde_json::from_str::(&content) { + let hooks = settings.get("hooks"); + let has_ponytail = hooks + .and_then(|h| h.get("SessionStart")) + .map(|v| v.to_string().contains("ponytail")) + .unwrap_or(false); + let not_agentflare = hooks + .and_then(|h| h.get("SessionStart")) + .map(|v| !v.to_string().contains("agentflare")) + .unwrap_or(true); + return has_ponytail && not_agentflare; + } + } + false +} + +fn has_existing_ponytail_cursor() -> bool { + let path = cwd().join(".cursor").join("hooks.json"); + if let Ok(content) = fs::read_to_string(&path) { + has_ponytail_ref(&content) && !content.contains("agentflare") + } else { + false + } +} + +fn has_existing_ponytail_opencode() -> bool { + let path = home().join(".config").join("opencode").join("opencode.jsonc"); + if let Ok(content) = fs::read_to_string(&path) { + has_ponytail_ref(&content) && !content.contains("agentflare") + } else { + false + } +} + +fn has_ponytail_ref(content: &str) -> bool { + content.to_lowercase().contains("ponytail") +} + +pub fn run(agent: &str, yes: bool) { println!("agentflare init --agent {agent}\n"); for c in get_components(agent) { @@ -34,9 +135,29 @@ pub fn run(agent: &str) { } match agent { - "claude-code" => wire_claude_code(), - "cursor" => wire_cursor(), - "opencode" => wire_opencode(), + "claude-code" => { + wire_claude_code(); + if confirm_ponytail_migration(agent, yes) { + wire_ponytail_hooks(agent); + } + } + "cursor" => { + wire_cursor(); + if confirm_ponytail_migration(agent, yes) { + wire_ponytail_hooks(agent); + } + } + "opencode" => { + wire_opencode(); + if has_existing_ponytail_opencode() { + println!(); + println!(" info Ponytail plugin detected. Keep it — OpenCode uses"); + println!(" plugins for hooks, not config. Plugin + agentflare"); + println!(" work together (plugin handles hooks, agentflare provides"); + println!(" skill engine)."); + } + wire_ponytail_opencode(); + } _ => {} } @@ -172,6 +293,114 @@ fn wire_opencode() { } } +pub fn wire_ponytail_hooks(agent: &str) { + match agent { + "claude-code" | "cowork" => wire_ponytail_claude_code(), + "cursor" | "cursor-cli" => wire_ponytail_cursor(), + "opencode" => wire_ponytail_opencode(), + _ => println!(" info auto-wiring not supported for {agent}. Manual config required."), + } +} + +fn wire_ponytail_claude_code() { + let path = home().join(".claude").join("settings.json"); + let mut settings: Value = fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| json!({})); + if !settings.is_object() { + settings = json!({}); + } + let bin = agentflare_binary(); + + let already_wired = settings + .get("hooks") + .and_then(|h| h.get("SessionStart")) + .map(|v| v.to_string().contains("ponytail")) + .unwrap_or(false); + if already_wired { + println!(" skip ponytail hooks already wired in ~/.claude/settings.json"); + return; + } + + let obj = settings.as_object_mut().unwrap(); + let hooks = obj.entry("hooks").or_insert_with(|| json!({})); + let hooks_obj = hooks.as_object_mut().unwrap(); + + hooks_obj.entry("SessionStart").or_insert_with(|| json!([])).as_array_mut().unwrap().push(json!({ + "hooks": [{ "type": "command", "command": format!("\"{bin}\" ponytail hook session-start"), "timeout": 10 }] + })); + hooks_obj.entry("SubagentStart").or_insert_with(|| json!([])).as_array_mut().unwrap().push(json!({ + "hooks": [{ "type": "command", "command": format!("\"{bin}\" ponytail hook subagent-start"), "timeout": 5 }] + })); + hooks_obj.entry("UserPromptSubmit").or_insert_with(|| json!([])).as_array_mut().unwrap().push(json!({ + "hooks": [{ "type": "command", "command": format!("\"{bin}\" ponytail hook prompt-submit"), "timeout": 5 }] + })); + + obj.insert("statusLine".to_string(), json!({ + "type": "command", + "command": format!("\"{bin}\" ponytail hook statusline") + })); + + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + match fs::write(&path, serde_json::to_string_pretty(&settings).unwrap() + "\n") { + Ok(_) => println!(" ok ponytail hooks wired in ~/.claude/settings.json"), + Err(e) => println!(" fail writing ~/.claude/settings.json: {e}"), + } +} + +fn wire_ponytail_cursor() { + let path = cwd().join(".cursor").join("hooks.json"); + let bin = agentflare_binary(); + + if path.exists() { + let existing = fs::read_to_string(&path).unwrap_or_default(); + if existing.contains("ponytail") { + println!(" skip ponytail hooks already wired in .cursor/hooks.json"); + return; + } + } + + let mut content: Value = fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_else(|| json!({ "version": 1, "hooks": {} })); + if !content.is_object() { + content = json!({ "version": 1, "hooks": {} }); + } + + let hooks = content.as_object_mut().unwrap() + .entry("hooks").or_insert_with(|| json!({})); + let hooks_obj = hooks.as_object_mut().unwrap(); + + hooks_obj.entry("sessionStart").or_insert_with(|| json!([])).as_array_mut().unwrap().push(json!({ + "command": format!("\"{bin}\" ponytail hook session-start"), + "type": "command", + "timeout": 30 + })); + hooks_obj.entry("beforeSubmitPrompt").or_insert_with(|| json!([])).as_array_mut().unwrap().push(json!({ + "command": format!("\"{bin}\" ponytail hook prompt-submit"), + "type": "command", + "timeout": 10 + })); + + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + match fs::write(&path, serde_json::to_string_pretty(&content).unwrap() + "\n") { + Ok(_) => println!(" ok ponytail hooks wired in .cursor/hooks.json"), + Err(e) => println!(" fail writing .cursor/hooks.json: {e}"), + } +} + +fn wire_ponytail_opencode() { + println!(" info OpenCode uses plugin system for hooks, not config."); + println!(" Keep @dietrichgebert/ponytail in plugin list."); + println!(" The plugin's built-in hooks work alongside agentflare."); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index 5344d5a1..37e3f465 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,9 @@ enum Commands { Init { #[arg(long, value_enum)] agent: Agent, + /// Skip all prompts — accept defaults. + #[arg(long, short = 'y')] + yes: bool, }, /// Hook entry points, invoked by whatever `init` (or the Codex plugin /// manifest) wired into the target agent's hook config. Not meant to be @@ -342,8 +345,6 @@ enum AuthAction { #[derive(Subcommand)] enum PonytailAction { - /// Download SKILL.md and print per-platform hook config snippets. - Setup, /// Show active ponytail mode (reads flag file + config default). Status, /// Set session-scoped mode (off|lite|full|ultra). Writes flag file. @@ -477,7 +478,7 @@ fn main() { color_eyre::install().expect("color_eyre::install failed"); let cli = Cli::parse(); match cli.command { - Commands::Init { agent } => init::run(agent.as_str()), + Commands::Init { agent, yes } => init::run(agent.as_str(), yes), Commands::Hook { event } => match event { HookEvent::SessionStart { agent } => hook::session_start(agent.as_str()), HookEvent::PromptSubmit { agent } => hook::prompt_submit(agent.as_str()), @@ -548,22 +549,6 @@ fn main() { uninstall::run(dry_run, keep_config, keep_binary) } Commands::Ponytail { action } => match action { - PonytailAction::Setup => { - match ponytail::download_skill() { - Ok(path) => { - println!("SKILL.md saved to {path}"); - println!("Hook config: add to agent hook settings:"); - println!(" Claude Code: agentflare ponytail hook session-start"); - println!(" Codex: agentflare ponytail hook session-start"); - println!(" Copilot: agentflare ponytail hook session-start"); - println!(" Statusline: agentflare ponytail hook statusline"); - } - Err(e) => { - eprintln!("download failed: {e}"); - std::process::exit(1); - } - } - } PonytailAction::Status => { let mode = ponytail::active_mode().unwrap_or_else(ponytail::default_mode); println!("{mode}"); diff --git a/src/ponytail/detect.rs b/src/ponytail/detect.rs new file mode 100644 index 00000000..c3dd5deb --- /dev/null +++ b/src/ponytail/detect.rs @@ -0,0 +1,207 @@ +use std::env; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetectionSource { + #[cfg(feature = "process-tree")] + ParentProcess, + StandardEnvVar, + ToolEnvVar, +} + +pub struct DetectResult { + pub name: String, + pub source: DetectionSource, +} + +struct AgentEntry { + name: &'static str, + #[cfg_attr(not(feature = "process-tree"), allow(dead_code))] + process_names: &'static [&'static str], + env_vars: &'static [&'static str], +} + +static AGENTS: &[AgentEntry] = &[ + AgentEntry { name: "cursor", process_names: &["cursor"], env_vars: &["CURSOR_TRACE_ID"] }, + AgentEntry { name: "cursor-cli", process_names: &[], env_vars: &["CURSOR_AGENT", "CURSOR_EXTENSION_HOST_ROLE"] }, + AgentEntry { name: "gemini", process_names: &["gemini"], env_vars: &["GEMINI_CLI"] }, + AgentEntry { name: "codex", process_names: &["codex"], env_vars: &["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"] }, + AgentEntry { name: "antigravity", process_names: &["amp"], env_vars: &["ANTIGRAVITY_AGENT"] }, + AgentEntry { name: "augment-cli", process_names: &["augment-cli"], env_vars: &["AUGMENT_AGENT"] }, + AgentEntry { name: "opencode", process_names: &["opencode"], env_vars: &["OPENCODE_CLIENT"] }, + AgentEntry { name: "claude-code", process_names: &["claude"], env_vars: &["CLAUDECODE", "CLAUDE_CODE"] }, + AgentEntry { name: "cline", process_names: &[], env_vars: &["CLINE_ACTIVE"] }, + AgentEntry { name: "goose", process_names: &["goose"], env_vars: &["GOOSE_TERMINAL"] }, + AgentEntry { name: "openclaw", process_names: &[], env_vars: &["OPENCLAW_SHELL"] }, + AgentEntry { name: "pi", process_names: &[], env_vars: &["PI_CODING_AGENT"] }, + AgentEntry { name: "roo-code", process_names: &[], env_vars: &["ROO_ACTIVE"] }, + AgentEntry { name: "trae", process_names: &["trae"], env_vars: &["TRAE_AI_SHELL_ID"] }, + AgentEntry { name: "replit", process_names: &[], env_vars: &["REPL_ID"] }, + AgentEntry { name: "github-copilot", process_names: &["copilot"], env_vars: &["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"] }, + AgentEntry { name: "aider", process_names: &["aider"], env_vars: &[] }, + AgentEntry { name: "carapace", process_names: &["cara"], env_vars: &[] }, + AgentEntry { name: "codebuddy", process_names: &["codebuddy"], env_vars: &[] }, + AgentEntry { name: "devin", process_names: &["devin"], env_vars: &[] }, + AgentEntry { name: "gloamy", process_names: &["gloamy"], env_vars: &[] }, + AgentEntry { name: "hermes", process_names: &["hermes"], env_vars: &[] }, + AgentEntry { name: "ironclaw", process_names: &["ironclaw"], env_vars: &[] }, + AgentEntry { name: "kimi-cli", process_names: &["kimi", "kimi-cli"], env_vars: &[] }, + AgentEntry { name: "loong", process_names: &["loong"], env_vars: &[] }, + AgentEntry { name: "microclaw", process_names: &["microclaw"], env_vars: &[] }, + AgentEntry { name: "moltis", process_names: &["moltis"], env_vars: &[] }, + AgentEntry { name: "nanobot", process_names: &["nanobot"], env_vars: &[] }, + AgentEntry { name: "picoclaw", process_names: &["picoclaw"], env_vars: &[] }, + AgentEntry { name: "windsurf", process_names: &["windsurf"], env_vars: &[] }, + AgentEntry { name: "zeroclaw", process_names: &["zeroclaw"], env_vars: &[] }, + AgentEntry { name: "alayacore", process_names: &["alayacore"], env_vars: &[] }, + AgentEntry { name: "anda-bot", process_names: &["anda"], env_vars: &[] }, + AgentEntry { name: "astrbot", process_names: &["astrbot"], env_vars: &["ASTRBOT_CLI"] }, + AgentEntry { name: "autohand-code", process_names: &["autohand"], env_vars: &["AUTOHAND_CLI"] }, + AgentEntry { name: "axiomate", process_names: &["axiomate"], env_vars: &[] }, + AgentEntry { name: "bash-agent", process_names: &[], env_vars: &["BASH_AGENT_HOME"] }, + AgentEntry { name: "clawx", process_names: &["clawx"], env_vars: &["CLAWHUB_WORKDIR"] }, + AgentEntry { name: "codeproxy-cli", process_names: &["codeproxy"], env_vars: &[] }, + AgentEntry { name: "cow-agent", process_names: &["cow"], env_vars: &[] }, + AgentEntry { name: "crush", process_names: &["crush"], env_vars: &[] }, + AgentEntry { name: "ctrl", process_names: &["ctrl"], env_vars: &[] }, + AgentEntry { name: "deep-code", process_names: &["deepcode"], env_vars: &[] }, + AgentEntry { name: "deep-copilot", process_names: &["deep-copilot"], env_vars: &[] }, + AgentEntry { name: "deeplossless", process_names: &["deeplossless"], env_vars: &[] }, + AgentEntry { name: "deepseek-tui", process_names: &["deepseek-tui"], env_vars: &[] }, + AgentEntry { name: "deepseekx", process_names: &["deepseekx"], env_vars: &[] }, + AgentEntry { name: "dscli", process_names: &["dscli"], env_vars: &[] }, + AgentEntry { name: "dscode", process_names: &["dscode"], env_vars: &[] }, + AgentEntry { name: "goagent", process_names: &["goagent"], env_vars: &[] }, + AgentEntry { name: "halfcopilot", process_names: &["halfcopilot"], env_vars: &[] }, + AgentEntry { name: "kilo-code", process_names: &["kilo"], env_vars: &[] }, + AgentEntry { name: "kimix", process_names: &["kimix"], env_vars: &[] }, + AgentEntry { name: "langbot", process_names: &["langbot"], env_vars: &[] }, + AgentEntry { name: "langcli", process_names: &["langcli"], env_vars: &[] }, + AgentEntry { name: "markus", process_names: &["markus"], env_vars: &[] }, + AgentEntry { name: "morph", process_names: &["mistermorph"], env_vars: &[] }, + AgentEntry { name: "oh-my-pi", process_names: &["omp"], env_vars: &[] }, + AgentEntry { name: "operit", process_names: &["operit"], env_vars: &[] }, + AgentEntry { name: "proma", process_names: &["proma"], env_vars: &[] }, + AgentEntry { name: "qwen-code", process_names: &["qwen"], env_vars: &[] }, + AgentEntry { name: "reasonix", process_names: &["reasonix"], env_vars: &[] }, + AgentEntry { name: "snow-cli", process_names: &["snow"], env_vars: &[] }, + AgentEntry { name: "soloncode", process_names: &["soloncode"], env_vars: &[] }, + AgentEntry { name: "tday", process_names: &["tday"], env_vars: &[] }, + AgentEntry { name: "tiangong", process_names: &["tiangong"], env_vars: &[] }, + AgentEntry { name: "whale", process_names: &["whale"], env_vars: &[] }, + AgentEntry { name: "xpro", process_names: &["xpro"], env_vars: &[] }, + AgentEntry { name: "zot", process_names: &["zot"], env_vars: &[] }, +]; + +pub fn detect() -> Option { + #[cfg(feature = "process-tree")] + if let Some(name) = find_in_parent_tree() { + return Some(DetectResult { + name, + source: DetectionSource::ParentProcess, + }); + } + + if let Some(r) = check_standard_env_vars() { + return Some(r); + } + + check_tool_env_vars() +} + +#[cfg(feature = "process-tree")] +fn find_in_parent_tree() -> Option { + use sysinfo::{Pid, ProcessesToUpdate, System}; + + use sysinfo::ProcessRefreshKind; + + let mut system = System::new(); + system.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + ProcessRefreshKind::nothing(), + ); + + let current_pid = Pid::from_u32(std::process::id()); + let mut pid = system + .process(current_pid) + .and_then(|p| p.parent()); + + while let Some(p) = pid { + let proc = system.process(p)?; + let name_raw = proc.name().to_str()?; + + for agent in AGENTS { + for &candidate in agent.process_names { + if is_process_match(name_raw, candidate) { + let name = if agent.name == "claude-code" && is_cowork_override() { + "cowork" + } else { + agent.name + }; + return Some(name.to_string()); + } + } + } + + pid = proc.parent(); + } + + None +} + +#[cfg(feature = "process-tree")] +fn is_process_match(name: &str, candidate: &str) -> bool { + name.strip_suffix(".exe") + .unwrap_or(name) + .eq_ignore_ascii_case(candidate) +} + +fn is_cowork_override() -> bool { + env::var("CLAUDE_CODE_IS_COWORK").is_ok_and(|v| !v.trim().is_empty()) +} + +fn check_standard_env_vars() -> Option { + const VARS: &[&str] = &["AI_AGENT", "AGENT"]; + + for &var in VARS { + if let Ok(value) = env::var(var) { + let name = value.trim().to_ascii_lowercase(); + if !name.is_empty() { + return Some(DetectResult { + name, + source: DetectionSource::StandardEnvVar, + }); + } + } + } + + None +} + +fn check_tool_env_vars() -> Option { + for agent in AGENTS { + for &env_var in agent.env_vars { + if env_var_is_set(env_var) { + let name = if agent.name == "claude-code" && is_cowork_override() { + "cowork".to_string() + } else { + agent.name.to_string() + }; + return Some(DetectResult { + name, + source: DetectionSource::ToolEnvVar, + }); + } + } + } + + None +} + +fn env_var_is_set(var: &str) -> bool { + if var == "CURSOR_EXTENSION_HOST_ROLE" { + env::var(var).is_ok_and(|v| v.trim() == "agent-exec") + } else { + env::var(var).is_ok_and(|v| !v.trim().is_empty()) + } +} diff --git a/src/ponytail/mod.rs b/src/ponytail/mod.rs index dce507f2..73259ea2 100644 --- a/src/ponytail/mod.rs +++ b/src/ponytail/mod.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod detect; pub mod instructions; pub mod platform; pub mod state; @@ -7,6 +8,6 @@ pub mod switcher; pub use config::{default_mode, normalize_config_mode, set_default_mode}; pub use instructions::{build as build_instructions, download_skill}; -pub use platform::{detect as detect_platform, format_hook_output}; +pub use platform::{detect_platform, format_hook_output}; pub use state::{active_mode, clear_active, set_active}; pub use switcher::{detect as detect_switch, SwitchAction}; diff --git a/src/ponytail/platform.rs b/src/ponytail/platform.rs index b31a8468..b1ae4ab5 100644 --- a/src/ponytail/platform.rs +++ b/src/ponytail/platform.rs @@ -1,5 +1,7 @@ use serde_json::json; +use crate::ponytail::detect; + pub enum AgentPlatform { Claude, Codex, @@ -7,13 +9,14 @@ pub enum AgentPlatform { Fallback, } -pub fn detect() -> AgentPlatform { - if std::env::var("CLAUDE_CONFIG_DIR").is_ok() { - AgentPlatform::Claude - } else if std::env::var("COPILOT_PLUGIN_DATA").is_ok() { - AgentPlatform::Copilot - } else if std::env::var("PLUGIN_DATA").is_ok() { - AgentPlatform::Codex +pub fn detect_platform() -> AgentPlatform { + if let Some(result) = detect::detect() { + match result.name.as_str() { + "claude-code" | "cowork" => AgentPlatform::Claude, + "codex" => AgentPlatform::Codex, + "github-copilot" => AgentPlatform::Copilot, + _ => AgentPlatform::Fallback, + } } else { AgentPlatform::Fallback } @@ -31,19 +34,14 @@ pub fn format_hook_output(event: &str, ctx: &str, platform: &AgentPlatform) -> S .to_string() } AgentPlatform::Codex => { - let sys_msg = if event == "SessionStart" { - "PONYTAIL:FULL" - } else { - "" - }; let mut output = json!({ "hookSpecificOutput": { "hookEventName": event, "additionalContext": ctx, } }); - if !sys_msg.is_empty() { - output["systemMessage"] = json!(sys_msg); + if event == "SessionStart" { + output["systemMessage"] = json!("PONYTAIL:FULL"); } output.to_string() }