diff --git a/crates/hk-core/src/adapter/antigravity.rs b/crates/hk-core/src/adapter/antigravity.rs index 2ae2126..1f6addb 100644 --- a/crates/hk-core/src/adapter/antigravity.rs +++ b/crates/hk-core/src/adapter/antigravity.rs @@ -85,9 +85,11 @@ impl AgentAdapter for AntigravityAdapter { dirs } fn project_skill_dirs(&self) -> Vec { - // Antigravity 1.18.4+ migrated from `.agent/` (singular) to `.agents/` - // (plural). Both still load; `.agents/` is canonical going forward. - // Source: https://discuss.ai.google.dev/t/new-folder-for-rules/126165 + // `.agents/skills/` (plural) is the current canonical project path; + // `.agent/skills/` (singular) is still live for the Antigravity CLI, + // not merely legacy — both load. + // Source: https://codelabs.developers.google.com/getting-started-with-antigravity-skills + // (folder rename context: https://discuss.ai.google.dev/t/new-folder-for-rules/126165) vec![".agents/skills".into(), ".agent/skills".into()] } fn mcp_config_path(&self) -> PathBuf { @@ -126,10 +128,7 @@ impl AgentAdapter for AntigravityAdapter { fn project_rules_patterns(&self) -> Vec { // `.agents/` is canonical (1.18.4+); `.agent/` kept for backward compat. // Source: https://discuss.ai.google.dev/t/new-folder-for-rules/126165 - vec![ - ".agents/rules/*.md".into(), - ".agent/rules/*.md".into(), - ] + vec![".agents/rules/*.md".into(), ".agent/rules/*.md".into()] } fn project_settings_patterns(&self) -> Vec { diff --git a/crates/hk-core/src/adapter/cursor.rs b/crates/hk-core/src/adapter/cursor.rs index e77ea4f..7280983 100644 --- a/crates/hk-core/src/adapter/cursor.rs +++ b/crates/hk-core/src/adapter/cursor.rs @@ -60,7 +60,7 @@ impl AgentAdapter for CursorAdapter { } fn project_skill_dirs(&self) -> Vec { - // Cursor 2.4+ project skills. + // Cursor project skills (no documented minimum version). // Source: https://cursor.com/docs/skills vec![".cursor/skills".into()] } diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 08854b1..ab659d3 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -431,6 +431,29 @@ pub trait AgentAdapter: Send + Sync { } } +impl crate::models::AgentCapabilities { + /// Derive install capabilities purely from the adapter's own + /// declarations — no per-agent special cases. Whatever an adapter + /// declares here is exactly what `service::install_to_agent` can + /// resolve a write path for, so frontend gating and backend behavior + /// cannot drift. + pub fn from_adapter(a: &dyn AgentAdapter) -> Self { + let skill = !a.project_skill_dirs().is_empty(); + Self { + project_install: crate::models::KindFlags { + skill, + mcp: a.project_mcp_config_relpath().is_some(), + hook: a.project_hook_config_relpath().is_some(), + // A CLI install deploys the companion skill into the skill + // dir (the binary itself is global), so CLI follows skill. + cli: skill, + }, + hooks_supported: a.hook_format() != HookFormat::None, + global_hook_install: a.supports_global_hook_install(), + } + } +} + fn pick_unique_concrete(patterns: Vec) -> Option { let mut concrete = patterns .into_iter() @@ -538,6 +561,47 @@ mod tests { } } + /// The full per-agent capability matrix, every value verified against + /// official docs 2026-07 (see docs/superpowers/specs/ + /// 2026-07-11-cross-agent-project-install-plan.md for citations). + /// If an adapter declaration changes, this test forces the change to be + /// deliberate — it is the single source the frontend gates against. + #[test] + fn test_agent_capabilities_matrix() { + use crate::models::AgentCapabilities; + + // (name, skill, mcp, hook, hooks_supported, global_hook_install); + // cli always equals skill by construction. + let expected = [ + ("claude", true, true, true, true, true), + ("codex", true, true, true, true, true), + ("cursor", true, true, true, true, true), + ("windsurf", true, false, true, true, true), // MCP global-only upstream + ("gemini", true, true, false, true, true), // project hooks deferred + ("copilot", true, true, false, true, true), // project hooks deferred + ("antigravity", true, false, false, false, true), // no MCP/project, no hooks + ("opencode", true, true, false, false, true), // hooks are JS plugins + ("kiro", true, true, true, true, false), // kirodotdev/Kiro#5440 + ("hermes", false, false, false, true, true), // global-only (hermes-agent#4667) + ]; + + let adapters = all_adapters(); + assert_eq!(adapters.len(), expected.len(), "agent count drifted"); + for (name, skill, mcp, hook, hooks_supported, global_hook) in expected { + let a = adapters + .iter() + .find(|a| a.name() == name) + .unwrap_or_else(|| panic!("adapter {name} missing")); + let caps = AgentCapabilities::from_adapter(a.as_ref()); + assert_eq!(caps.project_install.skill, skill, "{name} project skill"); + assert_eq!(caps.project_install.mcp, mcp, "{name} project mcp"); + assert_eq!(caps.project_install.hook, hook, "{name} project hook"); + assert_eq!(caps.project_install.cli, skill, "{name} project cli follows skill"); + assert_eq!(caps.hooks_supported, hooks_supported, "{name} hooks_supported"); + assert_eq!(caps.global_hook_install, global_hook, "{name} global_hook_install"); + } + } + #[test] fn test_skill_dir_for_category_default_is_none_except_hermes() { // The install handlers rely on this contract: only category-aware diff --git a/crates/hk-core/src/adapter/windsurf.rs b/crates/hk-core/src/adapter/windsurf.rs index 1c09b7d..c748c72 100644 --- a/crates/hk-core/src/adapter/windsurf.rs +++ b/crates/hk-core/src/adapter/windsurf.rs @@ -207,10 +207,10 @@ impl AgentAdapter for WindsurfAdapter { } fn project_settings_patterns(&self) -> Vec { - vec![ - ".windsurf/mcp_config.json".into(), - ".windsurf/hooks.json".into(), - ] + // No `.windsurf/mcp_config.json` here: Windsurf MCP is global-only + // (see `project_mcp_config_relpath`), so a workspace copy would be a + // file Windsurf never reads. + vec![".windsurf/hooks.json".into()] } fn project_ignore_patterns(&self) -> Vec { @@ -218,7 +218,14 @@ impl AgentAdapter for WindsurfAdapter { } fn project_mcp_config_relpath(&self) -> Option { - Some(".windsurf/mcp_config.json".into()) + // Windsurf MCP is global-only: the official MCP doc documents a + // single config at `~/.codeium/windsurf/mcp_config.json` and never + // mentions a workspace path — unlike the skills and hooks docs on + // the same site, which explicitly scope `.windsurf/skills/` and + // `.windsurf/hooks.json` to the workspace. Third-party guides + // confirm ("Windsurf doesn't load a project-scoped copy"). + // Source: https://docs.windsurf.com/windsurf/cascade/mcp + None } fn project_hook_config_relpath(&self) -> Option { @@ -295,8 +302,7 @@ mod tests { hook.event == "pre_user_prompt" && hook.command == "python3 /tmp/check.py" })); assert!(hooks.iter().any(|hook| { - hook.event == "post_cascade_response" - && hook.command == "python C:\\hooks\\log.py" + hook.event == "post_cascade_response" && hook.command == "python C:\\hooks\\log.py" })); } @@ -339,7 +345,11 @@ mod tests { fn global_settings_files_excludes_workflows() { let adapter = WindsurfAdapter::with_home(tempfile::tempdir().unwrap().path().to_path_buf()); let files = adapter.global_settings_files(); - assert!(!files.iter().any(|p| p.to_string_lossy().contains("global_workflows"))); + assert!( + !files + .iter() + .any(|p| p.to_string_lossy().contains("global_workflows")) + ); } #[test] @@ -355,4 +365,19 @@ mod tests { let patterns = adapter.project_settings_patterns(); assert!(!patterns.iter().any(|p| p.contains("workflows"))); } + + #[test] + fn project_mcp_is_none_global_only() { + // Windsurf MCP has no workspace config (official docs document only + // `~/.codeium/windsurf/mcp_config.json`); pin the deliberate None so + // it isn't "fixed" back by symmetry with other adapters. + let adapter = WindsurfAdapter::with_home(tempfile::tempdir().unwrap().path().to_path_buf()); + assert!(adapter.project_mcp_config_relpath().is_none()); + assert!( + !adapter + .project_settings_patterns() + .iter() + .any(|p| p.contains("mcp_config")) + ); + } } diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index ed971ab..e0d9c67 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -1484,51 +1484,12 @@ pub fn restore_plugin_entry( }) } -/// Ensure Codex hooks feature is enabled in config.toml. -/// -/// Codex requires `[features] hooks = true` to activate hook support. The -/// flag was originally named `codex_hooks` and was renamed to `hooks` in a -/// recent release; Codex still honors the old name with a deprecation warning, -/// so we don't editorialize and accept either form as "already enabled". -/// -/// Parse-modify-serialize (rather than string append) is required so that a -/// pre-existing `[features]` table gets the new key inserted in-place, -/// instead of producing a duplicate section that TOML rejects on re-parse. -pub fn ensure_codex_hooks_enabled(codex_base_dir: &Path) -> Result<(), HkError> { - // No flock (cf. `set_codex_plugin_enabled`): single-caller deploy path. - let config_toml = codex_base_dir.join("config.toml"); - if let Some(parent) = config_toml.parent() { - std::fs::create_dir_all(parent)?; - } - let content = if config_toml.exists() { - std::fs::read_to_string(&config_toml)? - } else { - String::new() - }; - let mut doc: toml::Table = if content.is_empty() { - toml::Table::new() - } else { - content - .parse::() - .map_err(|e| HkError::ConfigCorrupted(e.to_string()))? - }; - - let features = doc - .entry("features") - .or_insert_with(|| toml::Value::Table(toml::Table::new())) - .as_table_mut() - .ok_or_else(|| HkError::ConfigCorrupted("features is not a table".into()))?; - // Codex honors either `hooks` (canonical) or `codex_hooks` (deprecated); - // skip rewriting in either case. - if features.contains_key("hooks") || features.contains_key("codex_hooks") { - return Ok(()); - } - features.insert("hooks".into(), toml::Value::Boolean(true)); - - let output = toml::to_string_pretty(&doc).map_err(|e| HkError::Internal(e.to_string()))?; - atomic_write(&config_toml, &output)?; - Ok(()) -} +// NOTE: `ensure_codex_hooks_enabled` used to live here, writing +// `[features] hooks = true` into ~/.codex/config.toml on hook deploy. It was +// removed: Codex hooks are enabled by default (the flag is a DISABLE switch, +// per https://developers.openai.com/codex/hooks), so the write was redundant +// and would trample an explicit user `hooks = false` opt-out. Hook execution +// is gated by project trust + per-hook `/hooks` review on the Codex side. /// Read an MCP server entry's full JSON value from a config file. pub fn read_mcp_server_config( @@ -3496,77 +3457,6 @@ mod tests { assert!(plugins.contains_key("pluginB@marketplace")); } - /// Helper: read config.toml, return the `[features]` table (panics if missing/wrong type). - fn read_features(config_path: &Path) -> toml::Table { - let parsed: toml::Table = std::fs::read_to_string(config_path) - .unwrap() - .parse() - .unwrap(); - parsed["features"].as_table().unwrap().clone() - } - - #[test] - fn ensure_codex_hooks_appends_when_missing() { - let dir = TempDir::new().unwrap(); - ensure_codex_hooks_enabled(dir.path()).unwrap(); - let features = read_features(&dir.path().join("config.toml")); - assert_eq!(features["hooks"].as_bool(), Some(true)); - assert!(!features.contains_key("codex_hooks")); - } - - #[test] - fn ensure_codex_hooks_skips_when_canonical_flag_present() { - let dir = TempDir::new().unwrap(); - let config = dir.path().join("config.toml"); - std::fs::write(&config, "[features]\nhooks = true\n").unwrap(); - let before = std::fs::read_to_string(&config).unwrap(); - ensure_codex_hooks_enabled(dir.path()).unwrap(); - let after = std::fs::read_to_string(&config).unwrap(); - assert_eq!( - before, after, - "config must not be rewritten when hooks=true" - ); - } - - #[test] - fn ensure_codex_hooks_skips_when_deprecated_flag_present() { - let dir = TempDir::new().unwrap(); - let config = dir.path().join("config.toml"); - std::fs::write(&config, "[features]\ncodex_hooks = true\n").unwrap(); - let before = std::fs::read_to_string(&config).unwrap(); - ensure_codex_hooks_enabled(dir.path()).unwrap(); - let after = std::fs::read_to_string(&config).unwrap(); - assert_eq!( - before, after, - "deprecated codex_hooks=true must still count as enabled" - ); - } - - #[test] - fn ensure_codex_hooks_inserts_into_existing_features_table() { - // Regression: previously we appended a duplicate `[features]` section, - // which TOML rejects on re-parse. - let dir = TempDir::new().unwrap(); - let config = dir.path().join("config.toml"); - std::fs::write(&config, "[features]\nmemories = true\n").unwrap(); - ensure_codex_hooks_enabled(dir.path()).unwrap(); - let features = read_features(&config); - assert_eq!(features["memories"].as_bool(), Some(true)); - assert_eq!(features["hooks"].as_bool(), Some(true)); - // Re-parse round-trip: file must be valid TOML (no duplicate section). - let raw = std::fs::read_to_string(&config).unwrap(); - assert!(raw.parse::().is_ok()); - } - - #[test] - fn ensure_codex_hooks_errors_on_corrupted_toml() { - let dir = TempDir::new().unwrap(); - let config = dir.path().join("config.toml"); - std::fs::write(&config, "this is not valid TOML [[[").unwrap(); - let err = ensure_codex_hooks_enabled(dir.path()).unwrap_err(); - assert!(matches!(err, HkError::ConfigCorrupted(_))); - } - #[test] fn test_remove_vscode_plugin_entry() { let dir = TempDir::new().unwrap(); diff --git a/crates/hk-core/src/models.rs b/crates/hk-core/src/models.rs index 6f692b6..c19f1ab 100644 --- a/crates/hk-core/src/models.rs +++ b/crates/hk-core/src/models.rs @@ -335,6 +335,32 @@ pub struct AgentInfo { pub extension_count: usize, pub path: String, pub enabled: bool, + pub capabilities: AgentCapabilities, +} + +/// Per-extension-kind boolean flags. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KindFlags { + pub skill: bool, + pub mcp: bool, + pub hook: bool, + pub cli: bool, +} + +/// Install-capability facts derived from an agent's adapter declarations +/// (see `AgentCapabilities::from_adapter` in adapter/mod.rs). Shipped inside +/// every `AgentInfo` so the frontend gates install targets from the same +/// source of truth the backend deploys with — no hand-maintained TS table. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentCapabilities { + /// Which kinds can be installed at PROJECT scope for this agent. + pub project_install: KindFlags, + /// Whether the agent has a declarative hook system at all + /// (`hook_format() != HookFormat::None`). + pub hooks_supported: bool, + /// Whether user-level (global) hook install works upstream + /// (false only for Kiro today, kirodotdev/Kiro#5440). + pub global_hook_install: bool, } // --- Dashboard Stats --- diff --git a/crates/hk-core/src/service.rs b/crates/hk-core/src/service.rs index 954aaf6..d8b0c18 100644 --- a/crates/hk-core/src/service.rs +++ b/crates/hk-core/src/service.rs @@ -1244,16 +1244,18 @@ pub fn get_extension_content( } /// Cross-agent deploy: copy a Skill / MCP / Hook / CLI from its source agent -/// into `target_agent`. Returns a human-readable identifier of what was -/// deployed (skill name, MCP server name, or `event:command` for hooks) so -/// the UI can show the result. The wrapper is responsible for any post-deploy -/// rescan/sync (web does this; desktop does not, matching prior behavior). +/// into `target_agent` at `target_scope`. Returns a human-readable identifier +/// of what was deployed (skill name, MCP server name, or `event:command` for +/// hooks) so the UI can show the result. The wrapper is responsible for any +/// post-deploy rescan/sync (web does this; desktop does not, matching prior +/// behavior). pub fn install_to_agent( store: &Mutex, adapters: &[Box], extension_id: &str, target_agent: &str, hermes_category: Option<&str>, + target_scope: &ConfigScope, ) -> Result { let (ext, projects) = { let store = store.lock(); @@ -1264,6 +1266,19 @@ pub fn install_to_agent( (ext, projects) }; + // A project-scope deploy must target a project HK already tracks: + // `sync_extensions` prunes extension rows whose project path is not in + // the projects table, so an install into an unregistered project would + // silently vanish on the next rescan. No auto-registration here — the + // UI only offers registered projects, so this guards direct API calls. + if let ConfigScope::Project { path, .. } = target_scope + && !projects.iter().any(|(_, p)| p == path) + { + return Err(HkError::Validation(format!( + "Project '{path}' is not registered in HarnessKit; add the project first" + ))); + } + let target_adapter = adapters .iter() .find(|a| a.name() == target_agent) @@ -1275,14 +1290,17 @@ pub fn install_to_agent( scanner::find_skill_by_id(adapters, extension_id, &ext.agents, &projects) .map(|loc| loc.entry_path) .ok_or_else(|| HkError::Internal("Could not find source skill files".into()))?; - // Cross-agent install always lands at the target's global scope. // `skill_dir_for_category` returns None for flat-layout agents, so - // non-Hermes targets fall through to their default skill dir. + // non-Hermes targets fall through to the scope's default skill dir. + // At project scope `skill_dir_for` is None for agents without + // project-level skills (hermes) — surface that as a clean error. let target_dir = hermes_category - .and_then(|cat| target_adapter.skill_dir_for_category(&ConfigScope::Global, cat)) - .or_else(|| target_adapter.skill_dirs().into_iter().next()) + .and_then(|cat| target_adapter.skill_dir_for_category(target_scope, cat)) + .or_else(|| target_adapter.skill_dir_for(target_scope)) .ok_or_else(|| { - HkError::Internal(format!("No skill directory for agent '{}'", target_agent)) + HkError::Validation(format!( + "{target_agent} has no skill directory at this scope" + )) })?; let deployed_name = deployer::deploy_skill(&source_path, &target_dir)?; @@ -1302,7 +1320,7 @@ pub fn install_to_agent( &deployed_name, Some(meta), ext.pack.as_deref(), - &ConfigScope::Global, + target_scope, )?; } Ok(deployed_name) @@ -1338,7 +1356,13 @@ pub fn install_to_agent( if target_adapter.needs_path_injection() { deployer::ensure_path_injection(&mut entry); } - let config_path = target_adapter.mcp_config_path(); + let config_path = target_adapter + .mcp_config_path_for(target_scope) + .ok_or_else(|| { + HkError::Validation(format!( + "{target_agent} does not support project-level MCP servers" + )) + })?; deployer::deploy_mcp_server(&config_path, &entry, target_adapter.mcp_format())?; Ok(entry.name) } @@ -1393,26 +1417,27 @@ pub fn install_to_agent( })?; entry.event = translated_event; - // v1 install_to_agent always targets the GLOBAL hook config; bail - // out for agents that don't load user-level hooks (e.g. Kiro, - // kirodotdev/Kiro#5440) instead of writing a config that would - // silently never fire. - if !target_adapter.supports_global_hook_install() { + // Global installs bail out for agents that don't load user-level + // hooks (e.g. Kiro, kirodotdev/Kiro#5440) instead of writing a + // config that would silently never fire. Project-scope installs + // are exactly the supported alternative for those agents. + if matches!(target_scope, ConfigScope::Global) + && !target_adapter.supports_global_hook_install() + { return Err(HkError::Validation(format!( "{target_agent} does not load user-level (global) hooks yet; \ hooks for this agent can only be installed per-project" ))); } - let config_path = target_adapter.hook_config_path(); + let config_path = target_adapter + .hook_config_path_for(target_scope) + .ok_or_else(|| { + HkError::Validation(format!( + "{target_agent} does not support project-level hooks" + )) + })?; deployer::deploy_hook(&config_path, &entry, target_adapter.hook_format())?; - // Codex requires hooks feature enabled in config.toml - if target_adapter.name() == "codex" - && let Err(e) = deployer::ensure_codex_hooks_enabled(&target_adapter.base_dir()) - { - eprintln!("[hk] warning: {e}"); - } - Ok(format!("{}:{}", entry.event, entry.command)) } ExtensionKind::Cli => { @@ -1432,13 +1457,11 @@ pub fn install_to_agent( .ok_or_else(|| { HkError::Internal("Could not find source skill files for CLI".into()) })?; - let target_dir = target_adapter - .skill_dirs() - .into_iter() - .next() - .ok_or_else(|| { - HkError::Internal(format!("No skill directory for agent '{}'", target_agent)) - })?; + let target_dir = target_adapter.skill_dir_for(target_scope).ok_or_else(|| { + HkError::Validation(format!( + "{target_agent} has no skill directory at this scope" + )) + })?; let deployed_name = deployer::deploy_skill(&source_path, &target_dir)?; Ok(deployed_name) } @@ -1938,7 +1961,15 @@ mod tests { store.lock().insert_extension(&source_ext).unwrap(); // Cross-agent deploy: claude/foo → codex. - install_to_agent(&store, &adapters, &source_id, "codex", None).unwrap(); + install_to_agent( + &store, + &adapters, + &source_id, + "codex", + None, + &ConfigScope::Global, + ) + .unwrap(); // File deployed to codex's canonical skill dir (~/.agents/skills), // which is now first in skill_dirs() per Codex's current docs; @@ -2033,7 +2064,15 @@ mod tests { }; store.lock().insert_extension(&source_ext).unwrap(); - install_to_agent(&store, &adapters, &source_id, "codex", None).unwrap(); + install_to_agent( + &store, + &adapters, + &source_id, + "codex", + None, + &ConfigScope::Global, + ) + .unwrap(); // No install_meta to propagate — target row may not even exist in // the DB yet (we only sync target when there's meta to write). The @@ -2148,7 +2187,15 @@ mod tests { }) .unwrap(); - install_to_agent(&store, &adapters, &source_id, "codex", None).unwrap(); + install_to_agent( + &store, + &adapters, + &source_id, + "codex", + None, + &ConfigScope::Global, + ) + .unwrap(); let target_id = scanner::stable_id_for("baz", "skill", "codex"); let sibling_id = scanner::stable_id_for("baz", "skill", "gemini"); @@ -2662,4 +2709,260 @@ mod tests { "weather should be removed from plugins.enabled after delete" ); } + + // ---- install_to_agent target_scope ------------------------------------- + + /// Register `path` in the projects table and return the matching scope. + fn register_test_project( + store: &Mutex, + name: &str, + path: &std::path::Path, + ) -> ConfigScope { + let path_str = path.to_string_lossy().to_string(); + store + .lock() + .insert_project(&Project { + id: format!("proj-{name}"), + name: name.into(), + path: path_str.clone(), + created_at: chrono::Utc::now(), + exists: true, + }) + .unwrap(); + ConfigScope::Project { + name: name.into(), + path: path_str, + } + } + + /// Minimal global Claude skill on disk + matching extension row. + fn seed_claude_skill(store: &Mutex, home: &std::path::Path, name: &str) -> String { + std::fs::create_dir_all(home.join(".claude").join("skills").join(name)).unwrap(); + std::fs::write( + home.join(".claude") + .join("skills") + .join(name) + .join("SKILL.md"), + format!("---\nname: {name}\n---\n"), + ) + .unwrap(); + let id = scanner::stable_id_for(name, "skill", "claude"); + let mut ext = make_skill(ConfigScope::Global, None); + ext.id = id.clone(); + ext.name = name.into(); + ext.source_path = Some( + home.join(".claude") + .join("skills") + .join(name) + .join("SKILL.md") + .to_string_lossy() + .to_string(), + ); + store.lock().insert_extension(&ext).unwrap(); + id + } + + #[test] + fn test_install_to_agent_skill_lands_in_project_scope() { + use crate::adapter; + + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let store = Mutex::new(Store::open(&home.join("test.db")).unwrap()); + let project_dir = home.join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let scope = register_test_project(&store, "proj", &project_dir); + + let source_id = seed_claude_skill(&store, home, "foo"); + let adapters: Vec> = vec![ + Box::new(adapter::claude::ClaudeAdapter::with_home( + home.to_path_buf(), + )), + Box::new(adapter::codex::CodexAdapter::with_home(home.to_path_buf())), + ]; + + install_to_agent(&store, &adapters, &source_id, "codex", None, &scope).unwrap(); + + // Codex project skills live in /.agents/skills/. + assert!( + project_dir + .join(".agents") + .join("skills") + .join("foo") + .join("SKILL.md") + .exists(), + "skill should land in the project's codex skill dir" + ); + // Global codex dirs stay untouched. + assert!(!home.join(".agents").join("skills").join("foo").exists()); + } + + #[test] + fn test_install_to_agent_rejects_unregistered_project() { + use crate::adapter; + + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let store = Mutex::new(Store::open(&home.join("test.db")).unwrap()); + let source_id = seed_claude_skill(&store, home, "foo"); + let adapters: Vec> = vec![Box::new( + adapter::claude::ClaudeAdapter::with_home(home.to_path_buf()), + )]; + + let scope = ConfigScope::Project { + name: "ghost".into(), + path: home.join("ghost").to_string_lossy().to_string(), + }; + let err = + install_to_agent(&store, &adapters, &source_id, "claude", None, &scope).unwrap_err(); + assert!( + matches!(&err, HkError::Validation(msg) if msg.contains("not registered")), + "expected unregistered-project rejection, got: {err:?}" + ); + } + + #[test] + fn test_install_to_agent_hermes_has_no_project_skills() { + use crate::adapter; + + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let store = Mutex::new(Store::open(&home.join("test.db")).unwrap()); + let project_dir = home.join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let scope = register_test_project(&store, "proj", &project_dir); + let source_id = seed_claude_skill(&store, home, "foo"); + + let adapters: Vec> = vec![ + Box::new(adapter::claude::ClaudeAdapter::with_home( + home.to_path_buf(), + )), + Box::new(adapter::hermes::HermesAdapter::with_home( + home.to_path_buf(), + )), + ]; + + let err = + install_to_agent(&store, &adapters, &source_id, "hermes", None, &scope).unwrap_err(); + assert!( + matches!(&err, HkError::Validation(msg) if msg.contains("no skill directory")), + "hermes has no project-level skills (hermes-agent#4667), got: {err:?}" + ); + } + + #[test] + fn test_install_to_agent_mcp_project_scope_and_windsurf_rejection() { + use crate::adapter; + + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let store = Mutex::new(Store::open(&home.join("test.db")).unwrap()); + let project_dir = home.join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let scope = register_test_project(&store, "proj", &project_dir); + + // Source: a Claude global MCP server in ~/.claude.json. + std::fs::write( + home.join(".claude.json"), + r#"{"mcpServers":{"srv":{"command":"npx","args":["-y","srv"]}}}"#, + ) + .unwrap(); + let source_id = scanner::stable_id_for("srv", "mcp", "claude"); + let mut ext = make_skill(ConfigScope::Global, None); + ext.id = source_id.clone(); + ext.kind = ExtensionKind::Mcp; + ext.name = "srv".into(); + ext.source_path = None; + store.lock().insert_extension(&ext).unwrap(); + + let adapters: Vec> = vec![ + Box::new(adapter::claude::ClaudeAdapter::with_home( + home.to_path_buf(), + )), + Box::new(adapter::cursor::CursorAdapter::with_home( + home.to_path_buf(), + )), + Box::new(adapter::windsurf::WindsurfAdapter::with_home( + home.to_path_buf(), + )), + ]; + + // Cursor: project MCP lands in /.cursor/mcp.json. + install_to_agent(&store, &adapters, &source_id, "cursor", None, &scope).unwrap(); + let deployed = + std::fs::read_to_string(project_dir.join(".cursor").join("mcp.json")).unwrap(); + assert!(deployed.contains("\"srv\"")); + + // Windsurf: MCP is global-only upstream — clean Validation error. + let err = + install_to_agent(&store, &adapters, &source_id, "windsurf", None, &scope).unwrap_err(); + assert!( + matches!(&err, HkError::Validation(msg) if msg.contains("project-level MCP")), + "windsurf has no workspace MCP config, got: {err:?}" + ); + } + + #[test] + fn test_install_to_agent_kiro_hook_project_ok_global_rejected() { + use crate::adapter; + + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let store = Mutex::new(Store::open(&home.join("test.db")).unwrap()); + let project_dir = home.join("proj"); + std::fs::create_dir_all(&project_dir).unwrap(); + let scope = register_test_project(&store, "proj", &project_dir); + + // Source: a Claude global hook in ~/.claude/settings.json. + std::fs::create_dir_all(home.join(".claude")).unwrap(); + std::fs::write( + home.join(".claude").join("settings.json"), + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]}}"#, + ) + .unwrap(); + let hook_name = "PreToolUse:Bash:echo hi"; + let source_id = + scanner::stable_id_with_scope_for(hook_name, "hook", "claude", &ConfigScope::Global); + let mut ext = make_skill(ConfigScope::Global, None); + ext.id = source_id.clone(); + ext.kind = ExtensionKind::Hook; + ext.name = hook_name.into(); + ext.source_path = None; + store.lock().insert_extension(&ext).unwrap(); + + let adapters: Vec> = vec![ + Box::new(adapter::claude::ClaudeAdapter::with_home( + home.to_path_buf(), + )), + Box::new(adapter::kiro::KiroAdapter::with_home(home.to_path_buf())), + ]; + + // Global install to Kiro stays rejected (kirodotdev/Kiro#5440). + let err = install_to_agent( + &store, + &adapters, + &source_id, + "kiro", + None, + &ConfigScope::Global, + ) + .unwrap_err(); + assert!( + matches!(&err, HkError::Validation(msg) if msg.contains("per-project")), + "kiro global hook install must stay blocked, got: {err:?}" + ); + + // Project install is exactly the supported alternative. + install_to_agent(&store, &adapters, &source_id, "kiro", None, &scope).unwrap(); + let hook_file = project_dir + .join(".kiro") + .join("hooks") + .join("harnesskit.json"); + let written = std::fs::read_to_string(&hook_file).unwrap(); + assert!( + written.contains("\"v1\""), + "Kiro hook file needs version v1" + ); + assert!(written.contains("echo hi")); + } } diff --git a/crates/hk-desktop/src/commands/agents.rs b/crates/hk-desktop/src/commands/agents.rs index 66cb9cc..99ec372 100644 --- a/crates/hk-desktop/src/commands/agents.rs +++ b/crates/hk-desktop/src/commands/agents.rs @@ -21,6 +21,7 @@ pub fn list_agents(state: State) -> Result, HkError> { extension_count: 0, path, enabled, + capabilities: AgentCapabilities::from_adapter(a.as_ref()), }); } diff --git a/crates/hk-desktop/src/commands/install.rs b/crates/hk-desktop/src/commands/install.rs index 6c4e50a..c7c506e 100644 --- a/crates/hk-desktop/src/commands/install.rs +++ b/crates/hk-desktop/src/commands/install.rs @@ -635,6 +635,7 @@ pub async fn install_to_agent( extension_id: String, target_agent: String, hermes_category: Option, + target_scope: ConfigScope, ) -> Result { let store = state.store.clone(); let adapters = state.adapters.clone(); @@ -645,6 +646,7 @@ pub async fn install_to_agent( &extension_id, &target_agent, hermes_category.as_deref(), + &target_scope, ) }) .await diff --git a/crates/hk-web/src/handlers/agents.rs b/crates/hk-web/src/handlers/agents.rs index 412f1ed..7ad32e6 100644 --- a/crates/hk-web/src/handlers/agents.rs +++ b/crates/hk-web/src/handlers/agents.rs @@ -1,6 +1,6 @@ use axum::extract::State; use axum::Json; -use hk_core::models::{AgentDetail, AgentInfo, ExtensionCounts, ExtensionKind, AgentConfigFile, ConfigCategory, ConfigScope}; +use hk_core::models::{AgentDetail, AgentInfo, AgentCapabilities, ExtensionCounts, ExtensionKind, AgentConfigFile, ConfigCategory, ConfigScope}; use hk_core::scanner; use serde::Deserialize; @@ -26,6 +26,7 @@ pub async fn list_agents( extension_count: 0, path, enabled, + capabilities: AgentCapabilities::from_adapter(a.as_ref()), }); } result.sort_by_key(|a| *order_map.get(&a.name).unwrap_or(&999)); diff --git a/crates/hk-web/src/handlers/install.rs b/crates/hk-web/src/handlers/install.rs index fb4b42b..e153fc0 100644 --- a/crates/hk-web/src/handlers/install.rs +++ b/crates/hk-web/src/handlers/install.rs @@ -246,6 +246,10 @@ pub struct InstallToAgentParams { pub extension_id: String, pub target_agent: String, pub hermes_category: Option, + /// Required, like every sibling install endpoint (the frontend sends it + /// as of the same release): an implicit Global default could silently + /// misroute a project-scope install from an out-of-date client. + pub target_scope: ConfigScope, } pub async fn install_to_agent( @@ -276,6 +280,7 @@ pub async fn install_to_agent( ¶ms.extension_id, ¶ms.target_agent, params.hermes_category.as_deref(), + ¶ms.target_scope, )?; // Web-only: re-scan + sync after a successful deploy so the new @@ -287,7 +292,17 @@ pub async fn install_to_agent( let scanned = scanner::scan_all(&state.adapters, &projects); store.sync_extensions(&scanned)?; - Ok(scanner::stable_id_for(&ext_name, ext_kind.as_str(), ¶ms.target_agent)) + // Scope-correct ID for the deployed row. Known pre-existing caveat: + // for hooks the service deploys under a *translated* event name + // (agents disagree on event naming), so the ID recomputed from the + // source name may not match the scanned row; the frontend currently + // discards this value, so the mismatch is latent, not user-visible. + Ok(scanner::stable_id_with_scope_for( + &ext_name, + ext_kind.as_str(), + ¶ms.target_agent, + ¶ms.target_scope, + )) }).await } diff --git a/crates/hk-web/tests/api_test.rs b/crates/hk-web/tests/api_test.rs index 601c9d2..a8c3668 100644 --- a/crates/hk-web/tests/api_test.rs +++ b/crates/hk-web/tests/api_test.rs @@ -194,3 +194,60 @@ async fn kit_command_routes_return_json_not_spa_html() { ); } } + +/// install_to_agent requires target_scope: an old client omitting the field +/// must get a client error, never a silent Global-scope install. +#[tokio::test] +async fn install_to_agent_missing_target_scope_is_client_error() { + let (state, _tmp) = test_state(); + let app = hk_web::router::build_router(state); + + let response = app + .oneshot( + Request::post("/api/install_to_agent") + .header("content-type", "application/json") + .body(Body::from( + r#"{"extension_id":"x","target_agent":"claude","hermes_category":null}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + response.status().is_client_error(), + "missing target_scope must be rejected, got {}", + response.status() + ); +} + +/// Both ConfigScope wire shapes deserialize. The requests then 404 on the +/// unknown extension — reaching the handler body proves the scope parsed +/// (a shape error would be a 4xx from the Json extractor instead). +#[tokio::test] +async fn install_to_agent_accepts_both_scope_shapes() { + for scope in [ + r#"{"type":"global"}"#, + r#"{"type":"project","name":"p","path":"/tmp/p"}"#, + ] { + let (state, _tmp) = test_state(); + let app = hk_web::router::build_router(state); + let body = format!( + r#"{{"extension_id":"missing","target_agent":"claude","hermes_category":null,"target_scope":{scope}}}"# + ); + let response = app + .oneshot( + Request::post("/api/install_to_agent") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "scope shape {scope} should parse and 404 on the missing extension" + ); + } +} diff --git a/src/components/extensions/delete-dialog.tsx b/src/components/extensions/delete-dialog.tsx index 6916db5..017cb73 100644 --- a/src/components/extensions/delete-dialog.tsx +++ b/src/components/extensions/delete-dialog.tsx @@ -9,6 +9,8 @@ import type { GroupedExtension, } from "@/lib/types"; import { agentDisplayName, instanceDir } from "@/lib/types"; +import { instancesInScope } from "@/stores/extension-helpers"; +import type { ScopeValue } from "@/stores/scope-store"; type DeleteItem = { key: string; @@ -123,6 +125,7 @@ export function DeleteDialog({ onClose, childExtensions, skillLocations, + scope, }: { group: GroupedExtension; instanceData: Map; @@ -133,6 +136,9 @@ export function DeleteDialog({ onClose: () => void; childExtensions?: Extension[]; skillLocations?: [string, string, string | null][]; + /** Active UI scope: deletable items are filtered to it (All shows every + * scope), matching the detail panel's other scope-aware sections. */ + scope: ScopeValue; }) { const { t } = useTranslation("extensions"); const { t: tc } = useTranslation("common"); @@ -269,14 +275,19 @@ export function DeleteDialog({ // ── Standard Delete Dialog (skill, MCP, hook, plugin) ── const isSkill = group.kind === "skill"; + // Offer only the ACTIVE scope's copies for deletion (All shows every + // scope) — the same projection the badges/paths/docs sections use, so + // "delete" in a project never lists global copies. + const scopedInstances = instancesInScope(group.instances, scope); + // skillLocations is scope-agnostic on purpose (the get_skill_locations // API surfaces every place a skill named X exists). For deletion we - // must restrict to paths belonging to *this* group's instances, or the + // must restrict to paths belonging to the in-scope instances, or the // dialog lists e.g. a global same-named skill alongside a project one. // Delete is keyed on instance ids built per-(agent, source_path) — see // buildPathItems — so multi-scope rows are not accidentally collapsed. const instanceDirs = new Set( - group.instances.map(instanceDir).filter((p): p is string => !!p), + scopedInstances.map(instanceDir).filter((p): p is string => !!p), ); const filteredSkillLocations = skillLocations && instanceDirs.size > 0 @@ -289,9 +300,9 @@ export function DeleteDialog({ // is what lets TS narrow inside the true branch — cleaner than a `!`. const items: DeleteItem[] = usePathBased && filteredSkillLocations - ? buildPathItems(filteredSkillLocations, group.instances) + ? buildPathItems(filteredSkillLocations, scopedInstances) : buildAgentItems( - group.instances, + scopedInstances, instanceData, group.kind, group.name, diff --git a/src/components/extensions/detail-paths.tsx b/src/components/extensions/detail-paths.tsx index 46ce009..cd6bf91 100644 --- a/src/components/extensions/detail-paths.tsx +++ b/src/components/extensions/detail-paths.tsx @@ -6,12 +6,16 @@ import type { GroupedExtension, } from "@/lib/types"; import { agentDisplayName, instanceDir, instanceVersion } from "@/lib/types"; +import { instancesInScope } from "@/stores/extension-helpers"; +import type { ScopeValue } from "@/stores/scope-store"; interface DetailPathsProps { group: GroupedExtension; instanceData: Map; skillLocations: [string, string, string | null][]; agentOrder: readonly string[]; + /** Active UI scope: paths are filtered to it (All shows every scope). */ + scope: ScopeValue; } function ScopePill({ @@ -40,25 +44,31 @@ export function DetailPaths({ instanceData, skillLocations, agentOrder, + scope, }: DetailPathsProps) { const { t } = useTranslation("extensions"); const { t: tc } = useTranslation("common"); if (group.kind === "cli" || group.instances.length === 0) return null; + // Show only the active scope's copies (All mode shows everything) — + // same projection the agent badges use. + const scopedInstances = instancesInScope(group.instances, scope); + if (scopedInstances.length === 0) return null; + // skillLocations is scope-agnostic on purpose (the get_skill_locations // API surfaces every place a skill named X exists, used by other UIs). // We render one card per instance; each card resolves its physical // path(s) by matching skillLocations against this instance's source_path // dir, falling back to instanceData for instances without on-disk scan // results. - const hasAnyVersion = group.instances.some( + const hasAnyVersion = scopedInstances.some( (i) => instanceVersion(i) !== null, ); // Sort instances by agent order, then global-before-project, then by // project name. Stable so multi-scope on the same agent clusters. const agentRank = new Map(agentOrder.map((a, i) => [a, i] as const)); - const sortedInstances = [...group.instances].sort((a, b) => { + const sortedInstances = [...scopedInstances].sort((a, b) => { const aAgent = a.agents[0] ?? "unknown"; const bAgent = b.agents[0] ?? "unknown"; const aRank = agentRank.get(aAgent) ?? Number.MAX_SAFE_INTEGER; diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx index ef785e5..585897a 100644 --- a/src/components/extensions/extension-detail.tsx +++ b/src/components/extensions/extension-detail.tsx @@ -1,3 +1,4 @@ +import { clsx } from "clsx"; import { AlertTriangle, Calendar, @@ -8,6 +9,7 @@ import { Info, Loader2, Pencil, + ShieldCheck, Trash2, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -18,7 +20,10 @@ import { DetailHeader } from "@/components/extensions/detail-header"; import { DetailPaths } from "@/components/extensions/detail-paths"; import { PermissionDetail } from "@/components/extensions/permission-detail"; import { SkillFileSection } from "@/components/extensions/skill-file-section"; +import { AgentMascot } from "@/components/shared/agent-mascot/agent-mascot"; import { HermesCategoryPicker } from "@/components/shared/hermes-category-picker"; +import { ScopeTargetField } from "@/components/shared/scope-target-field"; +import { canInstallAtScope } from "@/lib/agent-capabilities"; import i18n from "@/lib/i18n"; import { api } from "@/lib/invoke"; import { isDesktop } from "@/lib/transport"; @@ -33,8 +38,15 @@ import { sortAgents, } from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; -import { findCliChildren } from "@/stores/extension-helpers"; +import { + agentsInScope, + findCliChildren, + instancesInScope, + pickSourceInstance, + resolveInstallTargetScope, +} from "@/stores/extension-helpers"; import { useExtensionStore } from "@/stores/extension-store"; +import { useScopeStore } from "@/stores/scope-store"; import { toast } from "@/stores/toast-store"; function formatDate(iso: string): string { @@ -48,6 +60,7 @@ function formatDate(iso: string): string { export function ExtensionDetail() { const { t } = useTranslation("extensions"); const { t: tc } = useTranslation("common"); + const { t: tm } = useTranslation("marketplace"); const grouped = useExtensionStore((s) => s.grouped); const selectedId = useExtensionStore((s) => s.selectedId); const setSelectedId = useExtensionStore((s) => s.setSelectedId); @@ -66,14 +79,49 @@ export function ExtensionDetail() { const [loadingContent, setLoadingContent] = useState(false); const agents = useAgentStore((s) => s.agents); const agentOrder = useAgentStore((s) => s.agentOrder); - // Cross-agent install (install_to_agent) needs a source instance to copy - // from; v1 service::install_to_agent has no target_scope param so it uses - // the source's scope implicitly. Without a global instance there's no - // scope-safe source — we block. v2 will add target_scope and lift this gate. - const globalSourceInstance = group?.instances.find( - (i) => i.scope.type === "global", + const scope = useScopeStore((s) => s.current); + // Install to Agent targets the active scope. In All-scopes mode the user + // must pick a target via ScopeTargetField (null until picked) — the same + // contract as the Marketplace install panel. + const [installTargetScope, setInstallTargetScope] = + useState(null); + const effectiveTarget: ConfigScope | null = + scope.type === "all" + ? installTargetScope + : resolveInstallTargetScope(scope); + const sourceInstance = group + ? pickSourceInstance(group.instances, effectiveTarget ?? { type: "global" }) + : undefined; + // Agents that already hold a copy of this group in the target scope — + // rendered as installed (mascot + check) rather than hidden. Before an + // All-mode target is picked (effectiveTarget null) nothing is marked + // installed: picking a target should only ever ADD check badges, never + // flip an "installed" agent back to installable. + const agentsInTargetScope = new Set( + effectiveTarget && group + ? instancesInScope(group.instances, effectiveTarget).flatMap( + (i) => i.agents, + ) + : [], ); - const projectScopeBlocked = !globalSourceInstance; + // Instances visible in the ACTIVE scope (All = everything) — drives the + // DOCUMENTATION tabs; DetailPaths applies the same projection internally. + const scopedInstances = group ? instancesInScope(group.instances, scope) : []; + // Flash animation for a just-completed install (Marketplace parity). + const [justInstalled, setJustInstalled] = useState>(new Set()); + const prefersReducedMotion = () => + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const flashInstalled = (agentName: string) => { + if (prefersReducedMotion()) return; + setJustInstalled((prev) => new Set(prev).add(agentName)); + setTimeout(() => { + setJustInstalled((prev) => { + const next = new Set(prev); + next.delete(agentName); + return next; + }); + }, 500); + }; const [deploying, setDeploying] = useState(null); // Hermes cross-agent deploy: show category picker before confirming install const [hermesCategoryPicker, setHermesCategoryPicker] = useState(false); @@ -166,6 +214,30 @@ export function ExtensionDetail() { } }, [group?.instances.length]); + // Reset install-target state when the active scope changes: a pending + // Hermes install would otherwise silently retarget the new scope, and a + // previously picked All-mode target no longer matches the picker UI. + // biome-ignore lint/correctness/useExhaustiveDependencies: `scope` is the trigger — the effect must re-run on every scope switch even though the body doesn't read it. + useEffect(() => { + setHermesCategoryPicker(false); + setInstallTargetScope(null); + }, [scope]); + + // Re-anchor the documentation tab when scope or group changes — the + // selected instance may not exist in the new scope's projection. + // biome-ignore lint/correctness/useExhaustiveDependencies: scopedInstances is derived from scope+group each render; the in-body guard makes redundant runs no-ops. + useEffect(() => { + if (!group) return; + if ( + activeInstanceId && + scopedInstances.some((i) => i.id === activeInstanceId) + ) + return; + setActiveInstanceId( + scopedInstances[0]?.id ?? group.instances[0]?.id ?? null, + ); + }, [scope, group?.groupKey, activeInstanceId]); + // Reset deleteAgents when showDelete is toggled on useEffect(() => { if (showDelete && group) { @@ -462,7 +534,7 @@ export function ExtensionDetail() { {t("detail.agents")}
- {group.agents.map((agent) => ( + {agentsInScope(group, scope).map((agent) => ( a.detected && a.enabled), agentOrder, ); - const AGENTS_WITHOUT_HOOKS = new Set(["antigravity", "opencode"]); - // "Install to Agent" implicitly targets Global scope (see - // service::install_to_agent v1). Filter by which agents already - // have a GLOBAL instance, not just any instance — otherwise a - // skill that exists only in project scope (e.g., user deleted - // the global row but kept the project copy) hides its agent - // from the install list and you can't recreate the global row. - const agentsWithGlobalInstance = new Set( - group.instances - .filter((i) => i.scope.type === "global") - .flatMap((i) => i.agents), - ); - const otherAgents = detectedAgents.filter( - (a) => !agentsWithGlobalInstance.has(a.name), - ); - if (otherAgents.length === 0) return null; + if (detectedAgents.length === 0) return null; return (
-
+

{t("detail.installToAgent")}

- {projectScopeBlocked && ( - - {t("detail.globalOnly")} - + {/* Single-scope mode: inline "📁 scope" hint next to the + * header; All-scopes mode renders the required picker on + * its own row below (Marketplace parity). */} + {scope.type !== "all" && ( + )}
-
- {otherAgents.map((agent) => { + {scope.type === "all" && ( +
+ +
+ )} +
+ {detectedAgents.map((agent) => { + // All gating reads the backend-derived capabilities + // (AgentInfo.capabilities) — same source of truth the + // service deploys with. + const scopeForCheck = effectiveTarget ?? scope; const hookUnsupported = group.kind === "hook" && - AGENTS_WITHOUT_HOOKS.has(agent.name); - // Kiro supports hooks, but only loads workspace ones — - // user-level ~/.kiro/hooks/ is documented yet unimplemented - // (kirodotdev/Kiro#5440), and this install path is - // global-only. Mirrors supports_global_hook_install() in - // crates/hk-core/src/adapter/kiro.rs. - const kiroGlobalHooksPending = - group.kind === "hook" && agent.name === "kiro"; - const hookBlocked = - hookUnsupported || kiroGlobalHooksPending; + !agent.capabilities.hooks_supported; + // Kiro loads workspace hooks but no released version + // loads user-level ~/.kiro/hooks/ (kirodotdev/Kiro#5440), + // so global-targeted hook installs stay blocked while + // project-scope installs go through. + const globalHookBlocked = + group.kind === "hook" && + effectiveTarget?.type === "global" && + !agent.capabilities.global_hook_install; + const scopeIncapable = !canInstallAtScope( + agent, + group.kind, + scopeForCheck, + ); + const blocked = + hookUnsupported || globalHookBlocked || scopeIncapable; + // Already has a copy in the target scope: shown as + // installed (check icon) instead of hidden, so the user + // can see WHERE this extension already lives. + const isInstalled = agentsInTargetScope.has(agent.name); + const isFlashing = justInstalled.has(agent.name); const isHermes = agent.name === "hermes" && group.kind === "skill"; + const disabled = + !effectiveTarget || + deploying !== null || + blocked || + isInstalled; return ( ); @@ -628,16 +764,18 @@ export function ExtensionDetail() {
- {/* Agent tabs for switching instance content */} - {group.instances.length > 1 && ( + {/* Agent tabs for switching instance content — only the + * active scope's instances (same projection as PATHS). */} + {scopedInstances.length > 1 && (
- {group.instances.map((instance) => ( + {scopedInstances.map((instance) => (