From edbb7eb569021376f4418ebf2a16d43dc32e37cc Mon Sep 17 00:00:00 2001 From: RealZST Date: Sun, 12 Jul 2026 11:52:07 +0200 Subject: [PATCH 1/9] fix(adapters): correct project-path declarations per official docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - windsurf: remove `.windsurf/mcp_config.json` from both project_mcp_config_relpath and project_settings_patterns — the official MCP doc documents only the global ~/.codeium/windsurf/mcp_config.json, unlike the skills/hooks docs on the same site which do scope workspace paths; add a pinning test so the deliberate None survives - cursor: drop the unsourced "2.4+" version claim from the skills comment - antigravity: cite the official skills codelab as the primary source and note the singular `.agent/skills/` is still live for the CLI Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NjDxpjt7gKEdGiyv5SvmnZ --- crates/hk-core/src/adapter/antigravity.rs | 13 ++++--- crates/hk-core/src/adapter/cursor.rs | 2 +- crates/hk-core/src/adapter/windsurf.rs | 41 ++++++++++++++++++----- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/crates/hk-core/src/adapter/antigravity.rs b/crates/hk-core/src/adapter/antigravity.rs index 2ae21266..1f6addb7 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 e77ea4f8..72809836 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/windsurf.rs b/crates/hk-core/src/adapter/windsurf.rs index 1c09b7d3..c748c725 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")) + ); + } } From 474fcae8161b89b6930f5f2de6db8ff46cfe5009 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sun, 12 Jul 2026 12:20:31 +0200 Subject: [PATCH 2/9] feat(core): scope-aware install_to_agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a required target_scope param to service::install_to_agent and make all four kind branches resolve their write path through the existing *_for(scope) adapter methods: - skill/CLI: skill_dir_for(target_scope) (+ category variant for hermes) - MCP: mcp_config_path_for(target_scope), clean Validation error for agents without project-level MCP (windsurf, antigravity, hermes) - hook: hook_config_path_for(target_scope); the Kiro global-hook bail-out (kirodotdev/Kiro#5440) now applies only at Global scope — project-scope hook install to Kiro is exactly the supported alternative Reject installs into unregistered projects up front: sync_extensions prunes extension rows whose project path is missing from the projects table, so such an install would silently vanish on the next rescan. Delete ensure_codex_hooks_enabled (+ its tests): per official Codex docs hooks are enabled by default and [features].hooks is a disable switch, so writing `true` was redundant and would trample an explicit user opt-out. Hook execution is gated by project trust + /hooks review. Both endpoints pass &ConfigScope::Global as a placeholder until the next commit threads the request param through; Global behavior is unchanged (skill_dir_for(&Global) == skill_dirs().first(), *_config_path_for(&Global) == the v1 getters). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NjDxpjt7gKEdGiyv5SvmnZ --- crates/hk-core/src/deployer.rs | 122 +------ crates/hk-core/src/service.rs | 371 ++++++++++++++++++++-- crates/hk-desktop/src/commands/install.rs | 3 + crates/hk-web/src/handlers/install.rs | 3 + 4 files changed, 349 insertions(+), 150 deletions(-) diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index ed971aba..e0d9c678 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/service.rs b/crates/hk-core/src/service.rs index 954aaf60..d8b0c185 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/install.rs b/crates/hk-desktop/src/commands/install.rs index 6c4e50ab..888d8ea4 100644 --- a/crates/hk-desktop/src/commands/install.rs +++ b/crates/hk-desktop/src/commands/install.rs @@ -645,6 +645,9 @@ pub async fn install_to_agent( &extension_id, &target_agent, hermes_category.as_deref(), + // Placeholder until the command grows a target_scope param in + // the next commit; Global preserves v1 behavior exactly. + &ConfigScope::Global, ) }) .await diff --git a/crates/hk-web/src/handlers/install.rs b/crates/hk-web/src/handlers/install.rs index fb4b42bd..730dc3cb 100644 --- a/crates/hk-web/src/handlers/install.rs +++ b/crates/hk-web/src/handlers/install.rs @@ -276,6 +276,9 @@ pub async fn install_to_agent( ¶ms.extension_id, ¶ms.target_agent, params.hermes_category.as_deref(), + // Placeholder until the endpoint grows a target_scope param in + // the next commit; Global preserves v1 behavior exactly. + &ConfigScope::Global, )?; // Web-only: re-scan + sync after a successful deploy so the new From ce9e89cebb219201f67c4ffd67d3efd30770eb7f Mon Sep 17 00:00:00 2001 From: RealZST Date: Sun, 12 Jul 2026 12:31:28 +0200 Subject: [PATCH 3/9] feat(api): target_scope on install_to_agent endpoints Add a required target_scope field to the web InstallToAgentParams and the desktop Tauri command, threading it into the scope-aware service call. Required rather than defaulted, matching every sibling install endpoint: an implicit Global default could silently misroute a project-scope install from an out-of-date client. The web handler now returns a scope-correct extension ID via stable_id_with_scope_for (the value is currently discarded by the frontend; the pre-existing hook translated-event ID caveat is documented in place). Tests: missing target_scope is a client error; both ConfigScope wire shapes deserialize. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NjDxpjt7gKEdGiyv5SvmnZ --- crates/hk-desktop/src/commands/install.rs | 5 +- crates/hk-web/src/handlers/install.rs | 20 ++++++-- crates/hk-web/tests/api_test.rs | 57 +++++++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/crates/hk-desktop/src/commands/install.rs b/crates/hk-desktop/src/commands/install.rs index 888d8ea4..c7c506e9 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,9 +646,7 @@ pub async fn install_to_agent( &extension_id, &target_agent, hermes_category.as_deref(), - // Placeholder until the command grows a target_scope param in - // the next commit; Global preserves v1 behavior exactly. - &ConfigScope::Global, + &target_scope, ) }) .await diff --git a/crates/hk-web/src/handlers/install.rs b/crates/hk-web/src/handlers/install.rs index 730dc3cb..e153fc0f 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,9 +280,7 @@ pub async fn install_to_agent( ¶ms.extension_id, ¶ms.target_agent, params.hermes_category.as_deref(), - // Placeholder until the endpoint grows a target_scope param in - // the next commit; Global preserves v1 behavior exactly. - &ConfigScope::Global, + ¶ms.target_scope, )?; // Web-only: re-scan + sync after a successful deploy so the new @@ -290,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 601c9d21..a8c3668b 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" + ); + } +} From 696829449c74c1f65bc77cfdee2261ea426a0da3 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sun, 12 Jul 2026 12:43:25 +0200 Subject: [PATCH 4/9] feat(core): agent capabilities in AgentInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive per-agent install capabilities from adapter declarations (AgentCapabilities::from_adapter) and ship them inside every AgentInfo response from both the web and desktop list_agents: - project_install.{skill,mcp,hook,cli}: whether the adapter declares a project-level write path for the kind (cli follows skill — a CLI install deploys the companion skill; the binary itself is global) - hooks_supported: hook_format() != HookFormat::None - global_hook_install: supports_global_hook_install() (false only for Kiro, kirodotdev/Kiro#5440) This makes the backend the single source of truth for the frontend's per-(agent, kind, scope) install gating; the hand-maintained (and already drifted) PROJECT_INSTALL_SUPPORT table in agent-capabilities.ts is deleted in the follow-up frontend commit. test_agent_capabilities_matrix pins the full 10-agent matrix, every value doc-verified (citations in the plan doc). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NjDxpjt7gKEdGiyv5SvmnZ --- crates/hk-core/src/adapter/mod.rs | 64 ++++++++++++++++++++++++ crates/hk-core/src/models.rs | 26 ++++++++++ crates/hk-desktop/src/commands/agents.rs | 1 + crates/hk-web/src/handlers/agents.rs | 3 +- 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 08854b17..ab659d30 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/models.rs b/crates/hk-core/src/models.rs index 6f692b68..c19f1ab5 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-desktop/src/commands/agents.rs b/crates/hk-desktop/src/commands/agents.rs index 66cb9cca..99ec3724 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-web/src/handlers/agents.rs b/crates/hk-web/src/handlers/agents.rs index 412f1edb..7ad32e64 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)); From b7bb74ad5a4cc47d30b4c209b270991569c5fb10 Mon Sep 17 00:00:00 2001 From: RealZST Date: Sun, 12 Jul 2026 12:58:50 +0200 Subject: [PATCH 5/9] feat(web): scope-aware Install to Agent, capabilities from backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install to Agent now targets the active scope: the selected project in project mode, Global in Global and All modes (All shows an "installs to Global" hint instead of adding a picker). The source instance prefers the copy already in the target scope, then a global copy, then any — which also unblocks installing a project-only group back to Global (recreate a deleted global row). Gating now reads the backend-derived AgentInfo.capabilities everywhere: the hand-maintained PROJECT_INSTALL_SUPPORT table (already drifted from the Rust adapters) and the parallel AGENTS_WITHOUT_HOOKS / Kiro special-cases are deleted. Kiro hook installs grey out only when the target scope is Global (kirodotdev/Kiro#5440); project-scope Kiro hook install is now live. Project-incapable combos (windsurf MCP, hermes, antigravity MCP) disable with an explanatory tooltip. CLI bundles skip children the target agent can't take at the chosen scope (with an info toast) instead of failing mid-loop with a partial install; the Hermes category picker closes on scope switch so a pending install can't silently retarget. New pure helpers resolveInstallTargetScope / pickSourceInstance in extension-helpers (unit-tested); canInstallAtScope reads capabilities and its tests use AgentInfo fixtures; en/zh strings updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NjDxpjt7gKEdGiyv5SvmnZ --- .../extensions/extension-detail.tsx | 138 ++++++++++++------ src/components/extensions/install-dialog.tsx | 13 +- src/lib/__tests__/agent-capabilities.test.ts | 60 ++++++-- src/lib/agent-capabilities.ts | 56 ++++--- src/lib/i18n/locales/en/extensions.json | 5 +- src/lib/i18n/locales/zh/extensions.json | 5 +- src/lib/invoke.ts | 2 + src/lib/types.ts | 19 +++ src/pages/marketplace.tsx | 2 +- .../__tests__/extension-helpers.test.ts | 67 +++++++++ src/stores/extension-helpers.ts | 31 +++- src/stores/extension-store.ts | 5 +- 12 files changed, 301 insertions(+), 102 deletions(-) diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx index ef785e52..dcc50b21 100644 --- a/src/components/extensions/extension-detail.tsx +++ b/src/components/extensions/extension-detail.tsx @@ -19,6 +19,7 @@ import { DetailPaths } from "@/components/extensions/detail-paths"; import { PermissionDetail } from "@/components/extensions/permission-detail"; import { SkillFileSection } from "@/components/extensions/skill-file-section"; import { HermesCategoryPicker } from "@/components/shared/hermes-category-picker"; +import { canInstallAtScope } from "@/lib/agent-capabilities"; import i18n from "@/lib/i18n"; import { api } from "@/lib/invoke"; import { isDesktop } from "@/lib/transport"; @@ -33,8 +34,13 @@ import { sortAgents, } from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; -import { findCliChildren } from "@/stores/extension-helpers"; +import { + findCliChildren, + 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 { @@ -66,14 +72,15 @@ 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 projectScopeBlocked = !globalSourceInstance; + const scope = useScopeStore((s) => s.current); + // Install to Agent targets the active scope; All-scopes mode falls back + // to Global (labeled next to the section header) — no extra picker, + // matching the scope switcher's "working context" semantics. + const targetScope = resolveInstallTargetScope(scope); + const targetScopeKey = scopeKey(targetScope); + const sourceInstance = group + ? pickSourceInstance(group.instances, targetScope) + : undefined; const [deploying, setDeploying] = useState(null); // Hermes cross-agent deploy: show category picker before confirming install const [hermesCategoryPicker, setHermesCategoryPicker] = useState(false); @@ -166,6 +173,12 @@ export function ExtensionDetail() { } }, [group?.instances.length]); + // Close the Hermes category picker when the active scope changes — the + // pending install would otherwise silently retarget the new scope. + useEffect(() => { + setHermesCategoryPicker(false); + }, [scope]); + // Reset deleteAgents when showDelete is toggled on useEffect(() => { if (showDelete && group) { @@ -482,20 +495,17 @@ export function ExtensionDetail() { agents.filter((a) => 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( + // Offer agents that don't already have an instance in the TARGET + // scope — installing again there would be a no-op. Agents with + // copies only in OTHER scopes stay listed (recreate a deleted + // global row, or add a project copy where only global exists). + const agentsInTargetScope = new Set( group.instances - .filter((i) => i.scope.type === "global") + .filter((i) => scopeKey(i.scope) === targetScopeKey) .flatMap((i) => i.agents), ); const otherAgents = detectedAgents.filter( - (a) => !agentsWithGlobalInstance.has(a.name), + (a) => !agentsInTargetScope.has(a.name), ); if (otherAgents.length === 0) return null; return ( @@ -504,7 +514,7 @@ export function ExtensionDetail() {

{t("detail.installToAgent")}

- {projectScopeBlocked && ( + {scope.type === "all" && ( {t("detail.globalOnly")} @@ -512,39 +522,47 @@ export function ExtensionDetail() {
{otherAgents.map((agent) => { + // All gating reads the backend-derived capabilities + // (AgentInfo.capabilities) — same source of truth the + // service deploys with. 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" && + targetScope.type === "global" && + !agent.capabilities.global_hook_install; + const scopeIncapable = !canInstallAtScope( + agent, + group.kind, + scope, + ); + const blocked = + hookUnsupported || globalHookBlocked || scopeIncapable; const isHermes = agent.name === "hermes" && group.kind === "skill"; return ( ); @@ -670,7 +764,7 @@ 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) => (