diff --git a/.gitignore b/.gitignore index b64f9cf..de5dd15 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ docs/ .superpowers/ .claude/ +.worktrees/ branding/ .impeccable.md .env* diff --git a/crates/hk-core/src/adapter/antigravity.rs b/crates/hk-core/src/adapter/antigravity.rs index 32d3979..7b79b7a 100644 --- a/crates/hk-core/src/adapter/antigravity.rs +++ b/crates/hk-core/src/adapter/antigravity.rs @@ -54,6 +54,12 @@ impl AgentAdapter for AntigravityAdapter { self.home.join(".gemini").join("antigravity").join("skills"), ] } + fn project_skill_dirs(&self) -> Vec { + // Antigravity workspace skills. + // SINGULAR ".agent/skills" (Antigravity convention) — NOT ".agents/skills". + // Source: https://codelabs.developers.google.com/getting-started-with-antigravity-skills + vec![".agent/skills".into()] + } fn mcp_config_path(&self) -> PathBuf { self.home .join(".gemini") diff --git a/crates/hk-core/src/adapter/codex.rs b/crates/hk-core/src/adapter/codex.rs index a49dd6a..a66cb3b 100644 --- a/crates/hk-core/src/adapter/codex.rs +++ b/crates/hk-core/src/adapter/codex.rs @@ -101,6 +101,12 @@ impl AgentAdapter for CodexAdapter { vec![".codex/config.toml".into()] } + fn project_skill_dirs(&self) -> Vec { + // Codex CLI scans .agents/skills from cwd up to the repo root. + // Source: https://developers.openai.com/codex/skills + vec![".agents/skills".into()] + } + fn read_mcp_servers(&self) -> Vec { self.read_mcp_servers_from(&self.mcp_config_path()) } diff --git a/crates/hk-core/src/adapter/copilot.rs b/crates/hk-core/src/adapter/copilot.rs index e16d68b..2a87918 100644 --- a/crates/hk-core/src/adapter/copilot.rs +++ b/crates/hk-core/src/adapter/copilot.rs @@ -117,6 +117,14 @@ impl AgentAdapter for CopilotAdapter { self.home.join(".agents").join("skills"), ] } + fn project_skill_dirs(&self) -> Vec { + // GitHub Copilot Agent Skills (canonical path .github/skills; also accepts + // .claude/skills and .agents/skills aliases — declaring only the canonical + // here keeps scanner output deduplicated when other adapters declare those + // alias paths). + // Source: https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/add-skills + vec![".github/skills".into()] + } fn mcp_config_path(&self) -> PathBuf { self.vscode_user_dir().join("mcp.json") } diff --git a/crates/hk-core/src/adapter/cursor.rs b/crates/hk-core/src/adapter/cursor.rs index b55c84d..deb561f 100644 --- a/crates/hk-core/src/adapter/cursor.rs +++ b/crates/hk-core/src/adapter/cursor.rs @@ -59,6 +59,12 @@ impl AgentAdapter for CursorAdapter { ] } + fn project_skill_dirs(&self) -> Vec { + // Cursor 2.4+ project skills. + // Source: https://cursor.com/docs/skills + vec![".cursor/skills".into()] + } + fn mcp_config_path(&self) -> PathBuf { self.base_dir().join("mcp.json") } diff --git a/crates/hk-core/src/adapter/gemini.rs b/crates/hk-core/src/adapter/gemini.rs index cea9637..3258bef 100644 --- a/crates/hk-core/src/adapter/gemini.rs +++ b/crates/hk-core/src/adapter/gemini.rs @@ -91,6 +91,13 @@ impl AgentAdapter for GeminiAdapter { self.home.join(".agents").join("skills"), ] } + fn project_skill_dirs(&self) -> Vec { + // Gemini CLI workspace skills (also accepts .agents/skills as an alias — + // declaring only the canonical path here; cross-agent shared skills via + // .agents/skills are picked up by the Codex adapter naturally). + // Source: https://geminicli.com/docs/cli/skills/ + vec![".gemini/skills".into()] + } fn mcp_config_path(&self) -> PathBuf { self.base_dir().join("settings.json") } diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 3229ed6..9e90c1d 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -225,6 +225,21 @@ pub trait AgentAdapter: Send + Sync { .map(|rel| std::path::Path::new(path).join(rel)), } } + + /// Resolve the skill directory for a given scope. + /// - `Global` → first entry of `skill_dirs()` (today's behavior). + /// - `Project` → `/`, or `None` if + /// the adapter has no project-level skill support. + fn skill_dir_for(&self, scope: &ConfigScope) -> Option { + match scope { + ConfigScope::Global => self.skill_dirs().into_iter().next(), + ConfigScope::Project { path, .. } => self + .project_skill_dirs() + .into_iter() + .next() + .map(|rel| std::path::Path::new(path).join(rel)), + } + } } /// Returns all agent adapters in canonical display order. @@ -299,4 +314,81 @@ mod tests { let _ = a.project_workflow_patterns(); } } + + #[test] + fn test_skill_dir_for_global_matches_skill_dirs_first() { + let adapters = all_adapters(); + for a in &adapters { + let global = ConfigScope::Global; + let computed = a.skill_dir_for(&global); + let expected = a.skill_dirs().into_iter().next(); + assert_eq!( + computed, + expected, + "{} skill_dir_for(Global) should match skill_dirs()[0]", + a.name() + ); + } + } + + #[test] + fn test_skill_dir_for_project_joins_path_with_project_skill_dirs_first() { + let adapters = all_adapters(); + let scope = ConfigScope::Project { + name: "demo".into(), + path: "/tmp/demo".into(), + }; + for adapter in &adapters { + let computed = adapter.skill_dir_for(&scope); + let rel = adapter.project_skill_dirs().into_iter().next(); + match (&computed, &rel) { + (Some(p), Some(r)) => { + assert_eq!(p, &std::path::Path::new("/tmp/demo").join(r)); + } + (None, None) => {} // adapter has no project skill support + _ => panic!( + "{}: mismatched some/none: computed={computed:?} vs project_skill_dirs first={rel:?}", + adapter.name() + ), + } + } + } + + #[test] + fn test_every_adapter_declares_project_skill_dir() { + // Universal Agent Skills standard (Dec 2025) — every adapter must declare a + // project skill directory. If a future adapter genuinely has no project + // skill concept, drop it from this assertion explicitly. + let adapters = all_adapters(); + for a in &adapters { + assert!( + !a.project_skill_dirs().is_empty(), + "{} must declare project_skill_dirs (Universal Agent Skills standard)", + a.name() + ); + } + } + + #[test] + fn test_project_skill_dir_paths_match_upstream_conventions() { + // Verify each adapter's first-party documented path. Update when adapter + // upstream conventions change. + let adapters = all_adapters(); + let expected: std::collections::HashMap<&str, &str> = [ + ("claude", ".claude/skills"), + ("codex", ".agents/skills"), // Universal alias adopted by OpenAI + ("cursor", ".cursor/skills"), + ("windsurf", ".windsurf/skills"), + ("gemini", ".gemini/skills"), + ("antigravity", ".agent/skills"), // Singular — Antigravity convention + ("copilot", ".github/skills"), + ] + .into_iter() + .collect(); + for a in &adapters { + let actual = a.project_skill_dirs().into_iter().next().unwrap(); + let want = expected.get(a.name()).expect("adapter not in expected map"); + assert_eq!(&actual, want, "{} project skill path mismatch", a.name()); + } + } } diff --git a/crates/hk-core/src/adapter/windsurf.rs b/crates/hk-core/src/adapter/windsurf.rs index 7c5d1d9..ce8fa53 100644 --- a/crates/hk-core/src/adapter/windsurf.rs +++ b/crates/hk-core/src/adapter/windsurf.rs @@ -68,6 +68,12 @@ impl AgentAdapter for WindsurfAdapter { ] } + fn project_skill_dirs(&self) -> Vec { + // Windsurf Cascade workspace skills. + // Source: https://docs.windsurf.com/windsurf/cascade/skills + vec![".windsurf/skills".into()] + } + fn mcp_config_path(&self) -> PathBuf { self.base_dir().join("mcp_config.json") } diff --git a/crates/hk-core/src/error.rs b/crates/hk-core/src/error.rs index f4b802c..2ef5295 100644 --- a/crates/hk-core/src/error.rs +++ b/crates/hk-core/src/error.rs @@ -107,7 +107,7 @@ mod tests { #[test] fn test_from_io_error_other() { - let err = std::io::Error::new(std::io::ErrorKind::Other, "something else"); + let err = std::io::Error::other("something else"); let hk: HkError = err.into(); assert!(matches!(hk, HkError::Internal(_))); } diff --git a/crates/hk-core/src/service.rs b/crates/hk-core/src/service.rs index e5ea4ff..18c0acb 100644 --- a/crates/hk-core/src/service.rs +++ b/crates/hk-core/src/service.rs @@ -21,21 +21,54 @@ pub fn post_install_sync( skill_name: &str, install_meta: Option, pack: Option<&str>, + target_scope: &ConfigScope, ) -> Result, HkError> { - // 1. Scan and sync affected agents + // 1. Scan and sync affected agents — scope-aware. let mut extensions = Vec::new(); for a in adapters { - if agent_names.contains(&a.name().to_string()) { - let exts = scanner::scan_adapter(a.as_ref()); - store.sync_extensions_for_agent(a.name(), &exts)?; - extensions.extend(exts); + if !agent_names.contains(&a.name().to_string()) { + continue; + } + match target_scope { + ConfigScope::Global => { + // Existing path: scan_adapter covers global skill_dirs / mcp / + // hooks / plugins, and sync_extensions_for_agent's stale-removal + // is correct here (we DO want stale global rows for this agent + // to be cleaned up). + let exts = scanner::scan_adapter(a.as_ref()); + store.sync_extensions_for_agent(a.name(), &exts)?; + extensions.extend(exts); + } + ConfigScope::Project { name, path } => { + // Project path: scan_project_extensions returns Project-scoped + // rows with scope-aware stable_ids. We deliberately use + // insert_extension (upsert-only, no stale removal) instead of + // sync_extensions_for_agent — the latter would treat every + // global row for this agent as stale (since they're absent + // from the project scan) and delete the unprotected ones. + let exts = scanner::scan_project_extensions( + a.as_ref(), + name, + std::path::Path::new(path), + ); + for ext in &exts { + store.insert_extension(ext)?; + } + extensions.extend(exts); + } } } - // 2. Set install meta and pack for each agent + // 2. Set install meta and pack for each agent — scope-aware id so the + // right row gets updated. if let Some(ref meta) = install_meta { for agent_name in agent_names { - let ext_id = scanner::stable_id_for(skill_name, "skill", agent_name); + let ext_id = scanner::stable_id_with_scope_for( + skill_name, + "skill", + agent_name, + target_scope, + ); let _ = store.set_install_meta(&ext_id, meta); if let Some(p) = pack { let _ = store.update_pack(&ext_id, Some(p)); @@ -52,6 +85,35 @@ pub fn post_install_sync( Ok(extensions) } +/// Whether an extension is eligible for HK's update flow. +/// +/// Skills are the only kind that supports update via git clone + redeploy. +/// User-managed project skills (no install_meta) are excluded so the +/// marketplace name-match auto-linker doesn't bind them to a marketplace +/// skill that just happens to share a name. Project skills installed by HK +/// itself (which always carry install_meta) ARE eligible. +pub fn is_update_eligible(ext: &Extension) -> bool { + if ext.kind != ExtensionKind::Skill { + return false; + } + matches!(ext.scope, ConfigScope::Global) || ext.install_meta.is_some() +} + +/// Whether two extensions share the same scope. Used by update-apply flows +/// to scope sibling refreshes — a Global update should only refresh Global +/// copies (not clobber a user's project copy of the same name) and a +/// project update should only refresh that project's own copies. +pub fn same_scope(a: &ConfigScope, b: &ConfigScope) -> bool { + match (a, b) { + (ConfigScope::Global, ConfigScope::Global) => true, + ( + ConfigScope::Project { path: pa, .. }, + ConfigScope::Project { path: pb, .. }, + ) => pa == pb, + _ => false, + } +} + /// Full audit of all extensions — scans skill content, MCP server info, hooks, plugins, /// and CLIs, then runs the auditor's rule engine and persists results. /// @@ -884,6 +946,101 @@ mod tests { (store, dir) } + fn make_skill(scope: ConfigScope, install_meta: Option) -> Extension { + Extension { + id: "test-id".into(), + kind: ExtensionKind::Skill, + name: "test".into(), + description: String::new(), + source: Source { + origin: SourceOrigin::Git, + url: None, + version: None, + commit_hash: None, + }, + agents: vec!["claude".into()], + tags: vec![], + permissions: vec![], + enabled: true, + trust_score: None, + installed_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + scope, + install_meta, + pack: None, + source_path: None, + cli_parent_id: None, + cli_meta: None, + } + } + + fn meta() -> InstallMeta { + InstallMeta { + install_type: "marketplace".into(), + url: Some("https://github.com/x/y".into()), + url_resolved: None, + branch: None, + subpath: None, + revision: None, + remote_revision: None, + checked_at: None, + check_error: None, + } + } + + #[test] + fn test_is_update_eligible_global_skill() { + // Global skill, no install_meta — eligible (auto-link via name match). + assert!(is_update_eligible(&make_skill(ConfigScope::Global, None))); + // Global skill, has install_meta — eligible. + assert!(is_update_eligible(&make_skill( + ConfigScope::Global, + Some(meta()), + ))); + } + + #[test] + fn test_is_update_eligible_project_skill() { + let proj = ConfigScope::Project { + name: "demo".into(), + path: "/p/demo".into(), + }; + // Project skill, no install_meta — NOT eligible (user-managed). + assert!(!is_update_eligible(&make_skill(proj.clone(), None))); + // Project skill, has install_meta — eligible (HK-installed). + assert!(is_update_eligible(&make_skill(proj, Some(meta())))); + } + + #[test] + fn test_is_update_eligible_non_skill_kinds_skipped() { + let mut mcp = make_skill(ConfigScope::Global, Some(meta())); + mcp.kind = ExtensionKind::Mcp; + assert!(!is_update_eligible(&mcp)); + } + + #[test] + fn test_same_scope() { + let g = ConfigScope::Global; + let p1 = ConfigScope::Project { + name: "a".into(), + path: "/a".into(), + }; + let p2 = ConfigScope::Project { + name: "b".into(), + path: "/b".into(), + }; + // Project name is irrelevant — same path is the contract. + let p1_alias = ConfigScope::Project { + name: "renamed".into(), + path: "/a".into(), + }; + + assert!(same_scope(&g, &g)); + assert!(same_scope(&p1, &p1_alias)); + assert!(!same_scope(&g, &p1)); + assert!(!same_scope(&p1, &p2)); + } + #[test] fn test_post_install_sync_empty_agents() { let (store, _dir) = test_store(); @@ -895,11 +1052,85 @@ mod tests { "test-skill", None, None, + &ConfigScope::Global, ); assert!(result.is_ok()); assert!(result.unwrap().is_empty()); } + /// Project-scope post_install_sync must scan the project directory, upsert + /// the project row, and write install_meta to the project-scoped row id — + /// not the unscoped (global) one. + #[test] + fn test_post_install_sync_writes_install_meta_to_project_scoped_row() { + use crate::adapter; + + let dir = TempDir::new().unwrap(); + let proj_dir = TempDir::new().unwrap(); + let home = dir.path(); + let store = Store::open(&home.join("test.db")).unwrap(); + + // Project-scope skill on disk (matches Claude's project_skill_dirs()) + let proj_path = proj_dir.path().to_string_lossy().to_string(); + let skills_dir = proj_dir.path().join(".claude").join("skills").join("foo"); + std::fs::create_dir_all(&skills_dir).unwrap(); + std::fs::write(skills_dir.join("SKILL.md"), "---\nname: foo\n---\n").unwrap(); + + let adapters: Vec> = vec![ + Box::new(adapter::claude::ClaudeAdapter::with_home(home.to_path_buf())), + ]; + + let target_scope = ConfigScope::Project { + name: "demo".into(), + path: proj_path.clone(), + }; + let meta = InstallMeta { + install_type: "git".into(), + url: Some("https://github.com/foo/bar".into()), + url_resolved: None, + branch: None, + subpath: None, + revision: None, + remote_revision: None, + checked_at: None, + check_error: None, + }; + + post_install_sync( + &store, + &adapters, + &["claude".into()], + "foo", + Some(meta.clone()), + None, + &target_scope, + ) + .unwrap(); + + // Assert: install_meta lands on the project-scoped row + let project_id = + scanner::stable_id_with_scope_for("foo", "skill", "claude", &target_scope); + let ext = store + .get_extension(&project_id) + .unwrap() + .expect("project-scoped row should exist after sync"); + assert_eq!( + ext.install_meta + .as_ref() + .expect("install_meta should be set") + .url, + meta.url, + ); + + // And: no global row got bogus meta + let global_id = scanner::stable_id_for("foo", "skill", "claude"); + let global = store.get_extension(&global_id).unwrap(); + assert!( + global.is_none() || global.unwrap().install_meta.is_none(), + "global row should not exist or should not have install_meta", + ); + } + #[test] fn test_run_full_audit_empty_store() { let (store, _dir) = test_store(); diff --git a/crates/hk-core/src/store.rs b/crates/hk-core/src/store.rs index 85919b1..89b9714 100644 --- a/crates/hk-core/src/store.rs +++ b/crates/hk-core/src/store.rs @@ -6,7 +6,11 @@ use std::path::{Path, PathBuf}; use crate::models::*; /// Latest schema version supported by this binary. -const LATEST_SCHEMA_VERSION: i64 = 3; +const LATEST_SCHEMA_VERSION: i64 = 4; + +/// One row of `custom_config_paths`: (id, path, label, category, scope_json). +/// `scope_json` is `None` for legacy rows that predate v4 schema migration. +pub type CustomConfigPathRow = (i64, String, String, String, Option); /// Upsert SQL for scanner-derived extensions (18 columns, no install meta). /// Used by `sync_extensions` and `sync_extensions_for_agent`. @@ -136,6 +140,7 @@ impl Store { if current_version < 1 { self.migrate_v1()?; } if current_version < 2 { self.migrate_v2()?; } if current_version < 3 { self.migrate_v3()?; } + if current_version < 4 { self.migrate_v4()?; } // Update schema version to latest if current_version < LATEST_SCHEMA_VERSION { @@ -245,6 +250,16 @@ impl Store { Ok(()) } + /// Schema v4: scope_json column on custom_config_paths so user-added + /// custom paths surface under the scope they were added in. NULL is + /// interpreted as Global (legacy rows added before scope tracking). + fn migrate_v4(&self) -> Result<(), HkError> { + self.migrate_add_column( + "ALTER TABLE custom_config_paths ADD COLUMN scope_json TEXT", + ); + Ok(()) + } + /// Schema v2: extension_agents join table for efficient agent-based filtering. fn migrate_v2(&self) -> Result<(), HkError> { self.conn.execute_batch(" @@ -352,10 +367,11 @@ impl Store { path: &str, label: &str, category: &str, + scope_json: Option<&str>, ) -> Result { self.conn.execute( - "INSERT OR IGNORE INTO custom_config_paths (agent, path, label, category) VALUES (?1, ?2, ?3, ?4)", - params![agent, path, label, category], + "INSERT OR IGNORE INTO custom_config_paths (agent, path, label, category, scope_json) VALUES (?1, ?2, ?3, ?4, ?5)", + params![agent, path, label, category, scope_json], )?; let id: i64 = self.conn.query_row( "SELECT id FROM custom_config_paths WHERE agent = ?1 AND path = ?2", @@ -388,13 +404,13 @@ impl Store { pub fn list_custom_config_paths( &self, agent: &str, - ) -> Result, HkError> { + ) -> Result, HkError> { let mut stmt = self.conn.prepare( - "SELECT id, path, label, category FROM custom_config_paths WHERE agent = ?1 ORDER BY label" + "SELECT id, path, label, category, scope_json FROM custom_config_paths WHERE agent = ?1 ORDER BY label" )?; let rows = stmt .query_map(params![agent], |row| { - Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)) })? .filter_map(|r| r.map_err(|e| eprintln!("[hk] row error: {e}")).ok()) .collect(); @@ -1933,28 +1949,57 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let store = Store::open(&dir.path().join("test.db")).unwrap(); let id1 = store - .add_custom_config_path("claude", "/some/path", "label", "settings") + .add_custom_config_path("claude", "/some/path", "label", "settings", None) .unwrap(); // Insert a different path to change last_insert_rowid let _id_other = store - .add_custom_config_path("claude", "/other/path", "label", "settings") + .add_custom_config_path("claude", "/other/path", "label", "settings", None) .unwrap(); // Now try to insert the first path again - this should return id1, not id_other let id2 = store - .add_custom_config_path("claude", "/some/path", "label", "settings") + .add_custom_config_path("claude", "/some/path", "label", "settings", None) .unwrap(); assert_eq!(id1, id2, "Duplicate insert should return the same ID"); assert!(id1 > 0, "ID should be positive"); } + #[test] + fn test_custom_config_path_persists_scope_round_trip() { + let (store, _dir) = test_store(); + let scope = ConfigScope::Project { + name: "demo".into(), + path: "/p/demo".into(), + }; + let scope_json = serde_json::to_string(&scope).unwrap(); + store + .add_custom_config_path( + "claude", + "/p/demo/foo", + "foo", + "settings", + Some(&scope_json), + ) + .unwrap(); + // NULL scope row coexists (legacy / Global default) + store + .add_custom_config_path("claude", "/u/global/bar", "bar", "settings", None) + .unwrap(); + + let rows = store.list_custom_config_paths("claude").unwrap(); + let scoped = rows.iter().find(|r| r.1 == "/p/demo/foo").unwrap(); + assert_eq!(scoped.4, Some(scope_json), "project scope persisted"); + let global = rows.iter().find(|r| r.1 == "/u/global/bar").unwrap(); + assert_eq!(global.4, None, "NULL scope (legacy/Global) preserved"); + } + #[test] fn test_list_all_custom_config_paths_includes_all_agents() { let (store, _dir) = test_store(); store - .add_custom_config_path("claude", "/tmp/a", "a", "settings") + .add_custom_config_path("claude", "/tmp/a", "a", "settings", None) .unwrap(); store - .add_custom_config_path("codex", "/tmp/b", "b", "rules") + .add_custom_config_path("codex", "/tmp/b", "b", "rules", None) .unwrap(); let mut paths = store.list_all_custom_config_paths().unwrap(); diff --git a/crates/hk-desktop/src/commands/agents.rs b/crates/hk-desktop/src/commands/agents.rs index 5edb915..7307483 100644 --- a/crates/hk-desktop/src/commands/agents.rs +++ b/crates/hk-desktop/src/commands/agents.rs @@ -85,7 +85,7 @@ pub fn list_agent_configs(state: State) -> Result, Hk .map(|p| p.to_string_lossy().to_string()) .collect(); if let Ok(custom_paths) = store.list_custom_config_paths(a.name()) { - for (id, path, label, category_str) in custom_paths { + for (id, path, label, category_str, scope_json) in custom_paths { let canonical = std::path::Path::new(&path) .canonicalize() .map(|p| p.to_string_lossy().to_string()) @@ -100,6 +100,10 @@ pub fn list_agent_configs(state: State) -> Result, Hk "ignore" => ConfigCategory::Ignore, _ => ConfigCategory::Settings, }; + let scope = scope_json + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .unwrap_or(ConfigScope::Global); let p = std::path::Path::new(&path); let (size_bytes, modified_at, is_dir, exists) = if let Ok(meta) = std::fs::metadata(p) { @@ -116,7 +120,7 @@ pub fn list_agent_configs(state: State) -> Result, Hk path: path.clone(), agent: a.name().to_string(), category, - scope: ConfigScope::Global, + scope, file_name: p .file_name() .map(|f| f.to_string_lossy().to_string()) diff --git a/crates/hk-desktop/src/commands/extensions.rs b/crates/hk-desktop/src/commands/extensions.rs index 673e983..bf81251 100644 --- a/crates/hk-desktop/src/commands/extensions.rs +++ b/crates/hk-desktop/src/commands/extensions.rs @@ -349,15 +349,7 @@ pub async fn check_updates( let mut has_meta = Vec::new(); let mut no_meta = Vec::new(); for e in extensions { - // Only skills support update via git clone + deploy - if e.kind != ExtensionKind::Skill { - continue; - } - // Project-scoped skills are owned by the project's own version - // control, not by HK's marketplace/update flow. Skip them so we - // don't auto-link them to a marketplace skill that just happens - // to share a name. - if !matches!(e.scope, ConfigScope::Global) { + if !service::is_update_eligible(&e) { continue; } if let Some(meta) = e.install_meta { @@ -639,8 +631,8 @@ pub async fn update_extension( }; // Find all installed paths (deduplicated) and copy the latest version - // to each. Restrict to global-scope siblings so the update flow doesn't - // overwrite user-managed project copies of the same name. + // to each. Restrict to siblings in the same scope as the trigger ext + // so a Global update doesn't clobber a project copy and vice versa. let all_siblings: Vec = { let store = store_clone.lock(); let all = store.list_extensions(Some(ext.kind), None)?; @@ -648,7 +640,7 @@ pub async fn update_extension( .filter(|e| { e.name == ext.name && e.source_path.is_some() - && matches!(e.scope, ConfigScope::Global) + && service::same_scope(&e.scope, &ext.scope) }) .collect() }; diff --git a/crates/hk-desktop/src/commands/install.rs b/crates/hk-desktop/src/commands/install.rs index cb6a118..4f3a0b6 100644 --- a/crates/hk-desktop/src/commands/install.rs +++ b/crates/hk-desktop/src/commands/install.rs @@ -22,6 +22,7 @@ pub async fn install_from_local( state: State<'_, AppState>, path: String, target_agents: Vec, + target_scope: ConfigScope, ) -> Result { let store = state.store.clone(); let adapters = state.adapters.clone(); @@ -63,8 +64,11 @@ pub async fn install_from_local( .iter() .find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - HkError::Internal(format!("No skill directory for agent '{}'", agent_name)) + let target_dir = a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, target_scope + )) })?; std::fs::create_dir_all(&target_dir)?; deployer::deploy_skill(source_path, &target_dir)?; @@ -103,6 +107,7 @@ pub async fn install_from_local( &skill_name, Some(meta), pack.as_deref(), + &target_scope, )?; } @@ -118,6 +123,7 @@ pub async fn install_from_git( url: String, target_agent: Option, skill_id: Option, + target_scope: ConfigScope, ) -> Result { let store = state.store.clone(); let adapters = state.adapters.clone(); @@ -129,8 +135,11 @@ pub async fn install_from_git( .iter() .find(|a| a.name() == agent.as_str()) .ok_or_else(|| HkError::NotFound(format!("Agent '{}' not found", agent)))?; - let dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - HkError::Internal(format!("No skill directory for agent '{}'", agent)) + let dir = a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent, target_scope + )) })?; (dir, agent.clone()) } else { @@ -140,11 +149,12 @@ pub async fn install_from_git( .find(|a| a.detect()) .ok_or_else(|| HkError::Internal("No detected agent found".into()))?; let name = a.name().to_string(); - let dir = a - .skill_dirs() - .into_iter() - .next() - .ok_or_else(|| HkError::Internal("No agent skill directory found".into()))?; + let dir = a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + name, target_scope + )) + })?; (dir, name) }; @@ -178,6 +188,7 @@ pub async fn install_from_git( &result.name, Some(meta), pack.as_deref(), + &target_scope, )?; } @@ -192,6 +203,7 @@ pub async fn scan_git_repo( state: State<'_, AppState>, url: String, target_agents: Vec, + target_scope: ConfigScope, ) -> Result { // Clean up stale pending clones (older than 10 minutes) { @@ -252,8 +264,11 @@ pub async fn scan_git_repo( .ok_or_else(|| { HkError::NotFound(format!("Agent '{}' not found", agent_name)) })?; - let target_dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - HkError::Internal(format!("No skill directory for agent '{}'", agent_name)) + let target_dir = a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, target_scope + )) })?; std::fs::create_dir_all(&target_dir)?; let result = manager::install_from_git_with_id(&url, &target_dir, skill_id)?; @@ -284,6 +299,7 @@ pub async fn scan_git_repo( &result.name, Some(meta), pack.as_deref(), + &target_scope, )?; } @@ -319,6 +335,7 @@ pub async fn install_scanned_skills( clone_id: String, skill_ids: Vec, target_agents: Vec, + target_scope: ConfigScope, ) -> Result, HkError> { let pending = { let mut clones = state.pending_clones.lock(); @@ -338,8 +355,11 @@ pub async fn install_scanned_skills( .iter() .find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - HkError::Internal(format!("No skill directory for agent '{}'", agent_name)) + let target_dir = a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, target_scope + )) })?; std::fs::create_dir_all(&target_dir)?; @@ -378,7 +398,12 @@ pub async fn install_scanned_skills( checked_at: None, check_error: None, }; - let ext_id = scanner::stable_id_for(&result.name, "skill", _agent_name); + let ext_id = scanner::stable_id_with_scope_for( + &result.name, + "skill", + _agent_name, + &target_scope, + ); let _ = store.set_install_meta(&ext_id, &meta); if let Some(ref p) = install_pack { let _ = store.update_pack(&ext_id, Some(p)); @@ -407,6 +432,7 @@ pub async fn install_scanned_skills( &result.name, Some(meta), install_pack.as_deref(), + &target_scope, )?; } } @@ -427,6 +453,7 @@ pub async fn install_new_repo_skills( url: String, skill_ids: Vec, target_agents: Vec, + target_scope: ConfigScope, ) -> Result, HkError> { let store_clone = state.store.clone(); let adapters = state.adapters.clone(); @@ -454,8 +481,11 @@ pub async fn install_new_repo_skills( .iter() .find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - HkError::Internal(format!("No skill directory for agent '{}'", agent_name)) + let target_dir = a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, target_scope + )) })?; std::fs::create_dir_all(&target_dir)?; @@ -489,7 +519,12 @@ pub async fn install_new_repo_skills( checked_at: None, check_error: None, }; - let ext_id = scanner::stable_id_for(&result.name, "skill", _agent_name); + let ext_id = scanner::stable_id_with_scope_for( + &result.name, + "skill", + _agent_name, + &target_scope, + ); let _ = store.set_install_meta(&ext_id, &meta); if let Some(ref p) = install_pack { let _ = store.update_pack(&ext_id, Some(p)); @@ -514,6 +549,7 @@ pub async fn install_new_repo_skills( &result.name, Some(meta), install_pack.as_deref(), + &target_scope, )?; } } diff --git a/crates/hk-desktop/src/commands/marketplace.rs b/crates/hk-desktop/src/commands/marketplace.rs index d62ca0a..64fb8d9 100644 --- a/crates/hk-desktop/src/commands/marketplace.rs +++ b/crates/hk-desktop/src/commands/marketplace.rs @@ -55,6 +55,7 @@ pub async fn install_from_marketplace( source: String, skill_id: String, target_agent: Option, + target_scope: ConfigScope, ) -> Result { let store_clone = state.store.clone(); let adapters = state.adapters.clone(); @@ -65,8 +66,11 @@ pub async fn install_from_marketplace( .iter() .find(|a| a.name() == agent.as_str()) .ok_or_else(|| HkError::Internal(format!("Agent '{}' not found", agent)))?; - let dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - HkError::Internal(format!("No skill directory for agent '{}'", agent)) + let dir = a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent, target_scope + )) })?; (dir, agent.clone()) } else { @@ -75,11 +79,12 @@ pub async fn install_from_marketplace( .find(|a| a.detect()) .ok_or_else(|| HkError::Internal("No detected agent found".into()))?; let name = a.name().to_string(); - let dir = a - .skill_dirs() - .into_iter() - .next() - .ok_or_else(|| HkError::Internal("No agent skill directory found".into()))?; + let dir = a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + name, target_scope + )) + })?; (dir, name) }; std::fs::create_dir_all(&target_dir)?; @@ -128,6 +133,7 @@ pub async fn install_from_marketplace( &result.name, Some(meta), pack.as_deref(), + &target_scope, )?; } Ok(result) diff --git a/crates/hk-desktop/src/commands/settings.rs b/crates/hk-desktop/src/commands/settings.rs index e95bac2..984ad99 100644 --- a/crates/hk-desktop/src/commands/settings.rs +++ b/crates/hk-desktop/src/commands/settings.rs @@ -224,6 +224,7 @@ pub fn add_custom_config_path( path: String, label: String, category: String, + target_scope: ConfigScope, ) -> Result { // Resolve ~ to home directory let resolved = if path.starts_with("~/") { @@ -252,8 +253,9 @@ pub fn add_custom_config_path( "Cannot use home directory itself as a config path".into(), )); } + let scope_json = serde_json::to_string(&target_scope).ok(); let store = state.store.lock(); - store.add_custom_config_path(&agent, &resolved, &label, &category) + store.add_custom_config_path(&agent, &resolved, &label, &category, scope_json.as_deref()) } #[tauri::command] @@ -331,7 +333,7 @@ mod tests { state .store .lock() - .add_custom_config_path("claude", &custom_dir.to_string_lossy(), "", "settings") + .add_custom_config_path("claude", &custom_dir.to_string_lossy(), "", "settings", None) .unwrap(); assert!(is_path_within_allowed_dirs(&custom_dir, &state).unwrap()); diff --git a/crates/hk-web/src/handlers/agents.rs b/crates/hk-web/src/handlers/agents.rs index 712b6a9..1523f2c 100644 --- a/crates/hk-web/src/handlers/agents.rs +++ b/crates/hk-web/src/handlers/agents.rs @@ -144,7 +144,7 @@ pub async fn list_agent_configs( .map(|p| super::normalize(&p).to_string_lossy().to_string()) .collect(); if let Ok(custom_paths) = store.list_custom_config_paths(a.name()) { - for (id, path, label, category_str) in custom_paths { + for (id, path, label, category_str, scope_json) in custom_paths { let canonical = std::path::Path::new(&path) .canonicalize() .map(|p| super::normalize(&p).to_string_lossy().to_string()) @@ -159,6 +159,10 @@ pub async fn list_agent_configs( "ignore" => ConfigCategory::Ignore, _ => ConfigCategory::Settings, }; + let scope = scope_json + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .unwrap_or(ConfigScope::Global); let p = std::path::Path::new(&path); let (size_bytes, modified_at, is_dir, exists) = if let Ok(meta) = std::fs::metadata(p) { @@ -175,7 +179,7 @@ pub async fn list_agent_configs( path: path.clone(), agent: a.name().to_string(), category, - scope: ConfigScope::Global, + scope, file_name: p.file_name() .map(|f| f.to_string_lossy().to_string()) .unwrap_or_else(|| path.clone()), @@ -215,6 +219,7 @@ pub struct AddCustomConfigPathParams { pub path: String, pub label: String, pub category: String, + pub target_scope: ConfigScope, } pub async fn add_custom_config_path( @@ -224,7 +229,14 @@ pub async fn add_custom_config_path( blocking(move || { let store = state.store.lock(); let resolved = resolve_and_validate_config_path(¶ms.path, &store)?; - store.add_custom_config_path(¶ms.agent, &resolved, ¶ms.label, ¶ms.category) + let scope_json = serde_json::to_string(¶ms.target_scope).ok(); + store.add_custom_config_path( + ¶ms.agent, + &resolved, + ¶ms.label, + ¶ms.category, + scope_json.as_deref(), + ) }).await } diff --git a/crates/hk-web/src/handlers/install.rs b/crates/hk-web/src/handlers/install.rs index 888d274..bb5db19 100644 --- a/crates/hk-web/src/handlers/install.rs +++ b/crates/hk-web/src/handlers/install.rs @@ -16,6 +16,7 @@ pub struct InstallFromGitParams { pub url: String, pub target_agent: Option, pub skill_id: Option, + pub target_scope: ConfigScope, } pub async fn install_from_git( @@ -30,16 +31,23 @@ pub async fn install_from_git( let a = state.adapters.iter() .find(|a| a.name() == agent.as_str()) .ok_or_else(|| hk_core::HkError::NotFound(format!("Agent '{}' not found", agent)))?; - let dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - hk_core::HkError::Internal(format!("No skill directory for agent '{}'", agent)) + let dir = a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent, params.target_scope + )) })?; (dir, agent.clone()) } else { let a = state.adapters.iter().find(|a| a.detect()) .ok_or_else(|| hk_core::HkError::Internal("No detected agent found".into()))?; let name = a.name().to_string(); - let dir = a.skill_dirs().into_iter().next() - .ok_or_else(|| hk_core::HkError::Internal("No agent skill directory found".into()))?; + let dir = a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + name, params.target_scope + )) + })?; (dir, name) }; @@ -64,7 +72,7 @@ pub async fn install_from_git( let store = state.store.lock(); service::post_install_sync( &store, &state.adapters, &agents, &result.name, - Some(meta), pack.as_deref(), + Some(meta), pack.as_deref(), ¶ms.target_scope, )?; } @@ -77,6 +85,7 @@ pub struct InstallFromMarketplaceParams { pub source: String, pub skill_id: String, pub target_agent: Option, + pub target_scope: ConfigScope, } pub async fn install_from_marketplace( @@ -89,16 +98,23 @@ pub async fn install_from_marketplace( let a = state.adapters.iter() .find(|a| a.name() == agent.as_str()) .ok_or_else(|| hk_core::HkError::Internal(format!("Agent '{}' not found", agent)))?; - let dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - hk_core::HkError::Internal(format!("No skill directory for agent '{}'", agent)) + let dir = a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent, params.target_scope + )) })?; (dir, agent.clone()) } else { let a = state.adapters.iter().find(|a| a.detect()) .ok_or_else(|| hk_core::HkError::Internal("No detected agent found".into()))?; let name = a.name().to_string(); - let dir = a.skill_dirs().into_iter().next() - .ok_or_else(|| hk_core::HkError::Internal("No agent skill directory found".into()))?; + let dir = a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + name, params.target_scope + )) + })?; (dir, name) }; std::fs::create_dir_all(&target_dir)?; @@ -123,7 +139,7 @@ pub async fn install_from_marketplace( let store = state.store.lock(); service::post_install_sync( &store, &state.adapters, &agents, &result.name, - Some(meta), pack.as_deref(), + Some(meta), pack.as_deref(), ¶ms.target_scope, )?; } @@ -135,6 +151,7 @@ pub async fn install_from_marketplace( pub struct InstallFromLocalParams { pub path: String, pub target_agents: Vec, + pub target_scope: ConfigScope, } pub async fn install_from_local( @@ -168,8 +185,11 @@ pub async fn install_from_local( let a = state.adapters.iter() .find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| hk_core::HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - hk_core::HkError::Internal(format!("No skill directory for agent '{}'", agent_name)) + let target_dir = a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, params.target_scope + )) })?; std::fs::create_dir_all(&target_dir)?; deployer::deploy_skill(source_path, &target_dir)?; @@ -201,7 +221,7 @@ pub async fn install_from_local( let store = state.store.lock(); service::post_install_sync( &store, &state.adapters, &agents, &skill_name, - Some(meta), pack.as_deref(), + Some(meta), pack.as_deref(), ¶ms.target_scope, )?; } @@ -322,8 +342,8 @@ pub async fn update_extension( }; // Find all installed paths (deduplicated) and copy the latest version - // to each. Restrict to global-scope siblings so the update flow doesn't - // overwrite user-managed project copies of the same name. + // to each. Restrict to siblings in the same scope as the trigger ext + // so a Global update doesn't clobber a project copy and vice versa. let all_siblings: Vec = { let store = state.store.lock(); let all = store.list_extensions(Some(ext.kind), None)?; @@ -331,7 +351,7 @@ pub async fn update_extension( .filter(|e| { e.name == ext.name && e.source_path.is_some() - && matches!(e.scope, ConfigScope::Global) + && service::same_scope(&e.scope, &ext.scope) }) .collect() }; @@ -387,6 +407,7 @@ pub enum ScanResult { pub struct ScanGitRepoParams { pub url: String, pub target_agents: Vec, + pub target_scope: ConfigScope, } pub async fn scan_git_repo( @@ -428,8 +449,11 @@ pub async fn scan_git_repo( for agent_name in &agents { let a = state.adapters.iter().find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| hk_core::HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - hk_core::HkError::Internal(format!("No skill directory for agent '{}'", agent_name)) + let target_dir = a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, params.target_scope + )) })?; std::fs::create_dir_all(&target_dir)?; let result = manager::install_from_git_with_id(¶ms.url, &target_dir, skill_id)?; @@ -450,7 +474,7 @@ pub async fn scan_git_repo( }; let pack = meta.url.as_deref().and_then(scanner::extract_pack_from_url); let store = state.store.lock(); - service::post_install_sync(&store, &state.adapters, &installed_agents, &result.name, Some(meta), pack.as_deref())?; + service::post_install_sync(&store, &state.adapters, &installed_agents, &result.name, Some(meta), pack.as_deref(), ¶ms.target_scope)?; } Ok(ScanResult::Installed { result: last_result.ok_or_else(|| hk_core::HkError::Internal("No install results produced".into()))?, @@ -473,6 +497,7 @@ pub struct InstallScannedSkillsParams { pub clone_id: String, pub skill_ids: Vec, pub target_agents: Vec, + pub target_scope: ConfigScope, } pub async fn install_scanned_skills( @@ -490,8 +515,11 @@ pub async fn install_scanned_skills( for agent_name in ¶ms.target_agents { let a = state.adapters.iter().find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| hk_core::HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - hk_core::HkError::Internal(format!("No skill directory for agent '{}'", agent_name)) + let target_dir = a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, params.target_scope + )) })?; std::fs::create_dir_all(&target_dir)?; for sid in ¶ms.skill_ids { @@ -514,7 +542,7 @@ pub async fn install_scanned_skills( revision: result.revision.clone(), remote_revision: None, checked_at: None, check_error: None, }; - let ext_id = scanner::stable_id_for(&result.name, "skill", _agent_name); + let ext_id = scanner::stable_id_with_scope_for(&result.name, "skill", _agent_name, ¶ms.target_scope); let _ = store.set_install_meta(&ext_id, &meta); if let Some(ref p) = install_pack { let _ = store.update_pack(&ext_id, Some(p)); } continue; @@ -528,7 +556,7 @@ pub async fn install_scanned_skills( }; service::post_install_sync( &store, &state.adapters, ¶ms.target_agents, - &result.name, Some(meta), install_pack.as_deref(), + &result.name, Some(meta), install_pack.as_deref(), ¶ms.target_scope, )?; } } @@ -542,6 +570,7 @@ pub struct InstallNewRepoSkillsParams { pub url: String, pub skill_ids: Vec, pub target_agents: Vec, + pub target_scope: ConfigScope, } pub async fn install_new_repo_skills( @@ -565,8 +594,11 @@ pub async fn install_new_repo_skills( for agent_name in ¶ms.target_agents { let a = state.adapters.iter().find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| hk_core::HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = a.skill_dirs().into_iter().next().ok_or_else(|| { - hk_core::HkError::Internal(format!("No skill directory for agent '{}'", agent_name)) + let target_dir = a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, params.target_scope + )) })?; std::fs::create_dir_all(&target_dir)?; for sid in ¶ms.skill_ids { @@ -589,7 +621,7 @@ pub async fn install_new_repo_skills( revision: result.revision.clone(), remote_revision: None, checked_at: None, check_error: None, }; - let ext_id = scanner::stable_id_for(&result.name, "skill", _agent_name); + let ext_id = scanner::stable_id_with_scope_for(&result.name, "skill", _agent_name, ¶ms.target_scope); let _ = store.set_install_meta(&ext_id, &meta); if let Some(ref p) = install_pack { let _ = store.update_pack(&ext_id, Some(p)); } continue; @@ -603,7 +635,7 @@ pub async fn install_new_repo_skills( }; service::post_install_sync( &store, &state.adapters, ¶ms.target_agents, - &result.name, Some(meta), install_pack.as_deref(), + &result.name, Some(meta), install_pack.as_deref(), ¶ms.target_scope, )?; } } @@ -641,12 +673,7 @@ pub async fn check_updates( let mut has_meta = Vec::new(); let mut no_meta = Vec::new(); for e in extensions { - if e.kind != ExtensionKind::Skill { continue; } - // Project-scoped skills are owned by the project's own version - // control (the user's git repo or hand-authored files), not by - // HK's marketplace/update flow. Skip them so we don't auto-link - // them to a marketplace skill that just happens to share a name. - if !matches!(e.scope, ConfigScope::Global) { continue; } + if !service::is_update_eligible(&e) { continue; } if let Some(meta) = e.install_meta { match meta.install_type.as_str() { "git" | "marketplace" => has_meta.push((e.id, e.name, meta)), diff --git a/package-lock.json b/package-lock.json index 3264fd5..eb82b25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "devDependencies": { "@biomejs/biome": "2.4.10", "@tailwindcss/vite": "^4.0.0", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.0.0", @@ -339,6 +340,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2095,12 +2106,68 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2238,6 +2305,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2437,6 +2505,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2983,6 +3061,13 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -3933,6 +4018,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4774,6 +4869,34 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -4909,6 +5032,13 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", diff --git a/package.json b/package.json index 7fd7215..71f9fb6 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@biomejs/biome": "2.4.10", "@tailwindcss/vite": "^4.0.0", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.0.0", diff --git a/src/App.tsx b/src/App.tsx index 7b7116f..8fb3e26 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -113,7 +113,9 @@ export default function App() { // Force macOS vibrancy to match — "light" | "dark" | null (system) if (isDesktop()) { getCurrentWindow() - .setTheme(showOnboarding ? "light" : mode === "system" ? null : resolved) + .setTheme( + showOnboarding ? "light" : mode === "system" ? null : resolved, + ) .catch((e) => console.error("Failed to set window theme:", e)); } }, [themeName, mode, resolved, showOnboarding]); diff --git a/src/components/agents/agent-detail.tsx b/src/components/agents/agent-detail.tsx index 3d5808a..bc265b0 100644 --- a/src/components/agents/agent-detail.tsx +++ b/src/components/agents/agent-detail.tsx @@ -1,6 +1,6 @@ -import { FileSearch, FolderPlus, FolderSearch, Plus, X } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { FileSearch, FolderPlus, FolderSearch, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useScope } from "@/hooks/use-scope"; import { openDirectoryPicker, openFilePicker } from "@/lib/dialog"; import { isDesktop } from "@/lib/transport"; import { @@ -8,7 +8,6 @@ import { type ConfigCategory, type ConfigScope, type ExtensionCounts, - scopeKey, scopeLabel, } from "@/lib/types"; import { useAgentConfigStore } from "@/stores/agent-config-store"; @@ -26,21 +25,44 @@ const CATEGORY_ORDER: ConfigCategory[] = [ ]; export function AgentDetail() { - const navigate = useNavigate(); const agentDetails = useAgentConfigStore((s) => s.agentDetails); const selectedAgent = useAgentConfigStore((s) => s.selectedAgent); const addCustomPath = useAgentConfigStore((s) => s.addCustomPath); const allExtensions = useExtensionStore((s) => s.extensions); + const { scope } = useScope(); const agent = agentDetails.find((a) => a.name === selectedAgent); const [showAddForm, setShowAddForm] = useState(false); const [customPath, setCustomPath] = useState(""); - /** Active scope filter: null = no filter, otherwise a `scopeKey` value. */ - const [activeScope, setActiveScope] = useState(null); - // Reset filter whenever the user switches to a different agent. - useEffect(() => { - setActiveScope(null); - }, [selectedAgent]); + const matchesScope = (s: ConfigScope) => { + if (scope.type === "all") return true; + if (scope.type === "global") return s.type === "global"; + // scope.type === "project" + return s.type === "project" && s.path === scope.path; + }; + + // Client-side compute scope-filtered counts for THIS agent so the summary + // card reflects the global scope rather than the system-wide Rust totals. + // The scope check is inlined (rather than calling matchesScope) so the + // useMemo dependency list stays minimal — `scope` is the only reactive input + // beyond `allExtensions` and `agent`. + const scopedCounts = useMemo(() => { + const c: ExtensionCounts = { skill: 0, mcp: 0, plugin: 0, hook: 0, cli: 0 }; + if (!agent) return c; + for (const ext of allExtensions) { + if (!ext.agents.includes(agent.name)) continue; + const s = ext.scope; + if (scope.type === "global" && s.type !== "global") continue; + if ( + scope.type === "project" && + !(s.type === "project" && s.path === scope.path) + ) { + continue; + } + c[ext.kind] = (c[ext.kind] ?? 0) + 1; + } + return c; + }, [allExtensions, agent, scope]); if (!agent) { return ( @@ -50,21 +72,6 @@ export function AgentDetail() { ); } - // Collect all distinct scopes seen in this agent's config files; preserve - // first-seen order so "Global" stays leading and projects keep stable order. - const distinctScopes: ConfigScope[] = []; - const seenScopeKeys = new Set(); - for (const file of agent.config_files) { - const key = scopeKey(file.scope); - if (!seenScopeKeys.has(key)) { - seenScopeKeys.add(key); - distinctScopes.push(file.scope); - } - } - - const matchesScope = (s: ConfigScope) => - activeScope === null || scopeKey(s) === activeScope; - const customFiles = agent.config_files.filter( (f) => f.custom_id != null && matchesScope(f.scope), ); @@ -78,19 +85,18 @@ export function AgentDetail() { if (list) list.push(file); } - // Recompute extension counts honoring the scope filter so the summary card - // stays in sync with the rest of the page. When no filter is active we trust - // the backend-provided totals (cheaper and matches existing behavior). - const filteredExtensionCounts: ExtensionCounts = useMemo(() => { - if (activeScope === null) return agent.extension_counts; - const counts = { skill: 0, mcp: 0, plugin: 0, hook: 0, cli: 0 }; - for (const ext of allExtensions) { - if (!ext.agents.includes(agent.name)) continue; - if (scopeKey(ext.scope) !== activeScope) continue; - counts[ext.kind] += 1; - } - return counts; - }, [activeScope, allExtensions, agent.name, agent.extension_counts]); + // Scope-aware empty state: when scoped to a specific project and the agent + // has no config files in that scope, render a focused empty card instead of + // a stack of empty section headers. + const totalVisible = nonCustomFiles.length + customFiles.length; + const isProjectScopeEmpty = scope.type === "project" && totalVisible === 0; + + const summaryActiveScope = + scope.type === "all" + ? null + : scope.type === "global" + ? "global" + : scope.path; return (
@@ -106,37 +112,6 @@ export function AgentDetail() { )}
- {distinctScopes.map((scope) => { - const key = scopeKey(scope); - const isActive = activeScope === key; - return ( - - ); - })} -
)} - {CATEGORY_ORDER.map((cat) => { - const files = byCategory.get(cat) ?? []; - // When a scope filter is active, hide sections that have nothing in - // the selected scope so the page collapses cleanly instead of - // showing rows of "0" headers. - if (activeScope !== null && files.length === 0) return null; - return ( - - ); - })} - {customFiles.length > 0 && ( - + {isProjectScopeEmpty ? ( +
+

+ {agentDisplayName(agent.name)} has no configuration in{" "} + {scopeLabel(scope as ConfigScope)} +

+
+ ) : ( + <> + {CATEGORY_ORDER.map((cat) => { + const files = byCategory.get(cat) ?? []; + // When the active scope hides everything in a category, collapse + // the section instead of rendering a "0" header. Always show + // categories when scope is "all" so empty categories render once + // across scopes. + if (scope.type !== "all" && files.length === 0) return null; + return ( + + ); + })} + {customFiles.length > 0 && ( + + )} + )} ); diff --git a/src/components/agents/config-file-entry.tsx b/src/components/agents/config-file-entry.tsx index 77db3cd..9d928a7 100644 --- a/src/components/agents/config-file-entry.tsx +++ b/src/components/agents/config-file-entry.tsx @@ -31,9 +31,7 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { const previewLoading = useAgentConfigStore((s) => s.previewLoading); const previewErrors = useAgentConfigStore((s) => s.previewErrors); const pendingFocusFile = useAgentConfigStore((s) => s.pendingFocusFile); - const setPendingFocusFile = useAgentConfigStore( - (s) => s.setPendingFocusFile, - ); + const setPendingFocusFile = useAgentConfigStore((s) => s.setPendingFocusFile); const handleNestedWheel = useScrollPassthrough(); const isExpanded = expandedFiles.has(file.path); @@ -61,6 +59,11 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { // already force-opened so this row is mounted. Scroll it into view, flash a // ring for ~1.5s, then clear the pending state so a subsequent navigation // to the same file re-triggers the effect. + // + // We clear pendingFocusFile *inside* the rAF (after the scroll fires) so the + // store update doesn't cause a synchronous re-run that cancels our own rAF. + // The highlight timer is split into its own effect so re-renders triggered + // by the store update can't kill the 1.5s ring before it shows. useEffect(() => { if (pendingFocusFile !== file.path) return; const el = buttonRef.current; @@ -70,15 +73,19 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { const raf = requestAnimationFrame(() => { el.scrollIntoView({ behavior: "smooth", block: "center" }); setHighlight(true); + setPendingFocusFile(null); }); - const timer = setTimeout(() => setHighlight(false), 1500); - setPendingFocusFile(null); - return () => { - cancelAnimationFrame(raf); - clearTimeout(timer); - }; + return () => cancelAnimationFrame(raf); }, [pendingFocusFile, file.path, setPendingFocusFile]); + // Clear the highlight 1.5s after it turns on. Independent of pendingFocusFile + // so a same-frame store update doesn't cancel the timer prematurely. + useEffect(() => { + if (!highlight) return; + const timer = setTimeout(() => setHighlight(false), 1500); + return () => clearTimeout(timer); + }, [highlight]); + const scopePath = file.custom_id != null ? file.path diff --git a/src/components/extensions/delete-dialog.tsx b/src/components/extensions/delete-dialog.tsx index cbd00a8..7e5c3f3 100644 --- a/src/components/extensions/delete-dialog.tsx +++ b/src/components/extensions/delete-dialog.tsx @@ -383,7 +383,13 @@ export function DeleteDialog({ Also removes entry from{" "} - + {item.configCleanup} diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx index 37079e6..70b5dda 100644 --- a/src/components/extensions/extension-detail.tsx +++ b/src/components/extensions/extension-detail.tsx @@ -2,6 +2,7 @@ import { AlertTriangle, Calendar, Download, + Folder, FolderOpen, GitBranch, Globe, @@ -17,8 +18,14 @@ import { PermissionDetail } from "@/components/extensions/permission-detail"; import { SkillFileSection } from "@/components/extensions/skill-file-section"; import { api } from "@/lib/invoke"; import { isDesktop } from "@/lib/transport"; -import type { ExtensionContent as ExtContent } from "@/lib/types"; -import { agentDisplayName, extensionGroupKey, sortAgents } from "@/lib/types"; +import type { ConfigScope, ExtensionContent as ExtContent } from "@/lib/types"; +import { + agentDisplayName, + extensionGroupKey, + scopeKey, + scopeLabel, + sortAgents, +} from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; import { findCliChildren } from "@/stores/extension-helpers"; import { useExtensionStore } from "@/stores/extension-store"; @@ -51,6 +58,14 @@ 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 [deploying, setDeploying] = useState(null); const [activeInstanceId, setActiveInstanceId] = useState(null); const [showDelete, setShowDelete] = useState(false); @@ -281,6 +296,25 @@ export function ExtensionDetail() { : "\u2014"} + {(() => { + // After Phase C dedup, a single group can span multiple scopes + // (same skill installed both globally and in a project). Show + // each unique scope on its own row so the user can see exactly + // where this extension lives. + const uniqueScopes = new Map(); + for (const inst of group.instances) { + uniqueScopes.set(scopeKey(inst.scope), inst.scope); + } + return [...uniqueScopes.values()].map((s) => ( +
+ + {scopeLabel(s)} +
+ )); + })()} {group.source.origin === "git" && group.source.url && !group.instances.find((i) => i.install_meta) && ( @@ -324,12 +358,19 @@ export function ExtensionDetail() { if (otherAgents.length === 0) return null; return (
-

- Install to Agent -

+
+

+ Install to Agent +

+ {projectScopeBlocked && ( + + · global only (project soon) + + )} +
{otherAgents.map((agent) => { const hookUnsupported = @@ -338,12 +379,20 @@ export function ExtensionDetail() { return ( + )} + + ); +} diff --git a/src/components/shared/toast-container.tsx b/src/components/shared/toast-container.tsx index 2b887b2..791d5ff 100644 --- a/src/components/shared/toast-container.tsx +++ b/src/components/shared/toast-container.tsx @@ -1,11 +1,12 @@ import { clsx } from "clsx"; -import { Check, Info, X } from "lucide-react"; +import { AlertTriangle, Check, Info, X } from "lucide-react"; import { useToastStore } from "@/stores/toast-store"; const icons = { success: Check, error: X, info: Info, + warning: AlertTriangle, }; export function ToastContainer() { @@ -29,6 +30,8 @@ export function ToastContainer() { "border-toast-error-border bg-toast-error-bg text-toast-error-text", t.type === "info" && "border-toast-info-border bg-toast-info-bg text-toast-info-text", + t.type === "warning" && + "border-toast-warning-border bg-toast-warning-bg text-toast-warning-text", )} > diff --git a/src/hooks/use-scope.ts b/src/hooks/use-scope.ts new file mode 100644 index 0000000..4848533 --- /dev/null +++ b/src/hooks/use-scope.ts @@ -0,0 +1,30 @@ +import { useScopeStore, type ScopeValue } from "@/stores/scope-store"; + +function computeScopeId(scope: ScopeValue): string { + if (scope.type === "all") return "all"; + if (scope.type === "global") return "global"; + return scope.path; +} + +/** Read + write the current scope. setScope only mutates the store; URL + * sync is handled by AppShell Effect 3 (store → URL). + * + * Why no inline navigate(): a previous version of this hook called + * navigate({ search: ... }) inside setScope to mirror the URL eagerly, + * but that fought any *follow-up* navigate() in the same tick — e.g. + * Overview's "click an agent file" handler does `setScope(file.scope); + * navigate('/agents?...')`, and React Router would batch the two and + * drop the second navigate. Letting the AppShell effect handle URL + * sync asynchronously sidesteps the conflict and gives us a single + * authoritative direction (store → URL). */ +export function useScope() { + const scope = useScopeStore((s) => s.current); + const setScope = useScopeStore((s) => s.setScope); + + return { + scope, + scopeId: computeScopeId(scope), + isAll: scope.type === "all", + setScope, + }; +} diff --git a/src/index.css b/src/index.css index cdfc73c..70c0a1a 100644 --- a/src/index.css +++ b/src/index.css @@ -96,6 +96,9 @@ --toast-info-bg: oklch(0.94 0.04 260); --toast-info-border: oklch(0.58 0.14 260); --toast-info-text: oklch(0.45 0.12 260); + --toast-warning-bg: oklch(0.95 0.06 75); + --toast-warning-border: oklch(0.7 0.16 75); + --toast-warning-text: oklch(0.45 0.13 75); } .dark { @@ -186,6 +189,9 @@ --toast-info-bg: oklch(0.23 0.03 260); --toast-info-border: oklch(0.52 0.12 260); --toast-info-text: oklch(0.78 0.1 260); + --toast-warning-bg: oklch(0.24 0.04 75); + --toast-warning-border: oklch(0.55 0.14 75); + --toast-warning-text: oklch(0.82 0.12 75); } /* ============================================================ @@ -276,6 +282,9 @@ --toast-info-bg: oklch(0.94 0.04 250); --toast-info-border: oklch(0.58 0.12 250); --toast-info-text: oklch(0.45 0.1 250); + --toast-warning-bg: oklch(0.94 0.06 75); + --toast-warning-border: oklch(0.65 0.15 75); + --toast-warning-text: oklch(0.45 0.13 75); } :root[data-theme="claude"].dark { @@ -360,6 +369,9 @@ --toast-info-bg: oklch(0.26 0.03 250); --toast-info-border: oklch(0.55 0.1 250); --toast-info-text: oklch(0.78 0.08 250); + --toast-warning-bg: oklch(0.26 0.04 75); + --toast-warning-border: oklch(0.55 0.14 75); + --toast-warning-text: oklch(0.82 0.12 75); } /* ============================================================ @@ -456,6 +468,9 @@ --toast-info-bg: oklch(0.94 0.04 260); --toast-info-border: oklch(0.58 0.14 260); --toast-info-text: oklch(0.45 0.12 260); + --toast-warning-bg: oklch(0.95 0.06 75); + --toast-warning-border: oklch(0.7 0.16 75); + --toast-warning-text: oklch(0.45 0.13 75); } :root[data-theme="tiesen"].dark { @@ -546,6 +561,9 @@ --toast-info-bg: oklch(0.23 0.03 260); --toast-info-border: oklch(0.52 0.12 260); --toast-info-text: oklch(0.78 0.1 260); + --toast-warning-bg: oklch(0.24 0.04 75); + --toast-warning-border: oklch(0.55 0.14 75); + --toast-warning-text: oklch(0.82 0.12 75); } /* Tiesen dark on web: sidebar↔background gap is only 0.025 in lightness, @@ -619,6 +637,9 @@ html.dark[data-theme="tiesen"][data-web="true"] { --color-toast-info-bg: var(--toast-info-bg); --color-toast-info-border: var(--toast-info-border); --color-toast-info-text: var(--toast-info-text); + --color-toast-warning-bg: var(--toast-warning-bg); + --color-toast-warning-border: var(--toast-warning-border); + --color-toast-warning-text: var(--toast-warning-text); --font-sans: var(--font-sans); --font-mono: var(--font-mono); diff --git a/src/lib/__tests__/scope-store.test.ts b/src/lib/__tests__/scope-store.test.ts new file mode 100644 index 0000000..f5fee1d --- /dev/null +++ b/src/lib/__tests__/scope-store.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "@/lib/invoke"; +import type { Project } from "@/lib/types"; +import { useProjectStore } from "@/stores/project-store"; +import { useScopeStore } from "@/stores/scope-store"; + +const makeProject = (name: string, path: string): Project => ({ + id: name, + name, + path, + created_at: "2026-01-01T00:00:00Z", + exists: true, +}); + +beforeEach(() => { + localStorage.clear(); + useScopeStore.setState({ current: { type: "global" }, hydrated: false }); +}); + +describe("scope-store hydrate", () => { + it("uses URL scope when valid project path", () => { + const projects = [makeProject("alpha", "/Users/me/alpha")]; + useScopeStore.getState().hydrate("/Users/me/alpha", projects); + expect(useScopeStore.getState().current).toEqual({ + type: "project", + name: "alpha", + path: "/Users/me/alpha", + }); + }); + + it("uses URL 'global' value", () => { + useScopeStore.getState().hydrate("global", []); + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); + + it("uses URL 'all' when projects exist", () => { + useScopeStore.getState().hydrate("all", [makeProject("a", "/p")]); + expect(useScopeStore.getState().current).toEqual({ type: "all" }); + }); + + it("coerces URL 'all' to global when no projects", () => { + useScopeStore.getState().hydrate("all", []); + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); + + it("falls back to localStorage when URL is null", () => { + localStorage.setItem( + "HK_SCOPE_LAST_USED", + JSON.stringify({ type: "project", name: "beta", path: "/b" }), + ); + useScopeStore.getState().hydrate(null, [makeProject("beta", "/b")]); + expect(useScopeStore.getState().current).toEqual({ + type: "project", + name: "beta", + path: "/b", + }); + }); + + it("falls back to global when localStorage is invalid", () => { + localStorage.setItem("HK_SCOPE_LAST_USED", "not-json{{"); + useScopeStore.getState().hydrate(null, []); + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); + + it("sets hydrated true after hydrate", () => { + useScopeStore.getState().hydrate(null, []); + expect(useScopeStore.getState().hydrated).toBe(true); + }); + + it("writes the resolved value back to localStorage", () => { + useScopeStore.getState().hydrate("all", []); // coerces to global + expect(localStorage.getItem("HK_SCOPE_LAST_USED")).toBe( + JSON.stringify({ type: "global" }), + ); + }); + + it("URL with unknown project path falls through to global when project list is empty", () => { + useScopeStore.getState().hydrate("/Users/me/gone", []); + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); + + it("localStorage with deleted project falls back to global", () => { + localStorage.setItem( + "HK_SCOPE_LAST_USED", + JSON.stringify({ type: "project", name: "old", path: "/p/old" }), + ); + useScopeStore.getState().hydrate(null, []); + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); + + it("removeProject resets scope to Global when current project removed", async () => { + useScopeStore.setState({ + current: { type: "project", name: "alpha", path: "/p/alpha" }, + hydrated: true, + }); + vi.spyOn(api, "removeProject").mockResolvedValue(undefined); + useProjectStore.setState({ + projects: [ + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, + ], + loading: false, + loaded: true, + }); + + await useProjectStore.getState().removeProject("alpha"); + + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); + + it("removeProject does NOT reset scope when a different project is removed", async () => { + useScopeStore.setState({ + current: { type: "project", name: "alpha", path: "/p/alpha" }, + hydrated: true, + }); + vi.spyOn(api, "removeProject").mockResolvedValue(undefined); + useProjectStore.setState({ + projects: [ + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, + { + id: "beta", + name: "beta", + path: "/p/beta", + created_at: "", + exists: true, + }, + ], + loading: false, + loaded: true, + }); + + await useProjectStore.getState().removeProject("beta"); + + expect(useScopeStore.getState().current).toEqual({ + type: "project", + name: "alpha", + path: "/p/alpha", + }); + }); +}); diff --git a/src/lib/__tests__/scope-switcher.test.tsx b/src/lib/__tests__/scope-switcher.test.tsx new file mode 100644 index 0000000..69ea925 --- /dev/null +++ b/src/lib/__tests__/scope-switcher.test.tsx @@ -0,0 +1,126 @@ +import { vi } from "vitest"; + +vi.mock("@/lib/dialog", () => ({ + openDirectoryPicker: vi.fn(), +})); + +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ScopeSwitcher } from "@/components/layout/scope-switcher"; +import { useProjectStore } from "@/stores/project-store"; +import { useScopeStore } from "@/stores/scope-store"; + +beforeEach(() => { + localStorage.clear(); + useScopeStore.setState({ current: { type: "global" }, hydrated: true }); + useProjectStore.setState({ projects: [], loading: false }); +}); + +const renderSwitcher = () => + render( + + + , + ); + +describe("ScopeSwitcher", () => { + it("shows current scope label in trigger", () => { + renderSwitcher(); + expect( + screen.getByRole("button", { name: /switch scope/i }).textContent, + ).toContain("Global"); + }); + + it("opens dropdown on click", () => { + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch scope/i })); + expect(screen.getByRole("listbox")).toBeTruthy(); + }); + + it("hides 'All scopes' entry when no projects exist", () => { + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch scope/i })); + expect(screen.queryByText(/all scopes/i)).toBeNull(); + }); + + it("shows 'All scopes' entry when projects exist", () => { + useProjectStore.setState({ + projects: [ + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, + ], + loading: false, + }); + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch scope/i })); + expect(screen.getByText(/all scopes/i)).toBeTruthy(); + }); + + it("selecting a project updates scope-store", () => { + useProjectStore.setState({ + projects: [ + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, + ], + loading: false, + }); + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch scope/i })); + fireEvent.click(screen.getByText("alpha")); + expect(useScopeStore.getState().current).toEqual({ + type: "project", + name: "alpha", + path: "/p/alpha", + }); + }); + + it("Escape closes dropdown", () => { + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch scope/i })); + expect(screen.getByRole("listbox")).toBeTruthy(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("clicking outside closes dropdown", () => { + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch scope/i })); + fireEvent.mouseDown(document.body); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("ArrowDown moves active option, Enter selects", () => { + // Start in All-scopes so a transition to Global is observable. + useScopeStore.setState({ current: { type: "all" }, hydrated: true }); + useProjectStore.setState({ + projects: [ + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, + ], + loading: false, + }); + renderSwitcher(); + fireEvent.click(screen.getByRole("button", { name: /switch scope/i })); + // Initial activeIndex = 0 → "All scopes" (because projects exist). + // ArrowDown moves to index 1 → Global. Enter selects it. + fireEvent.keyDown(document, { key: "ArrowDown" }); + fireEvent.keyDown(document, { key: "Enter" }); + expect(useScopeStore.getState().current.type).toBe("global"); + }); +}); diff --git a/src/lib/__tests__/scope-target-field.test.tsx b/src/lib/__tests__/scope-target-field.test.tsx new file mode 100644 index 0000000..0ed3dee --- /dev/null +++ b/src/lib/__tests__/scope-target-field.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ScopeTargetField } from "@/components/shared/scope-target-field"; +import { useProjectStore } from "@/stores/project-store"; +import { useScopeStore } from "@/stores/scope-store"; + +beforeEach(() => { + useScopeStore.setState({ current: { type: "global" }, hydrated: true }); + useProjectStore.setState({ projects: [], loading: false, loaded: true }); +}); + +const wrap = (ui: React.ReactNode) => {ui}; + +describe("ScopeTargetField", () => { + it("renders a hint (no picker) in single-scope mode", () => { + useScopeStore.setState({ current: { type: "global" }, hydrated: true }); + render(wrap( {}} />)); + expect(screen.getByText("Global")).toBeTruthy(); + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("renders a project hint in project scope", () => { + useScopeStore.setState({ + current: { type: "project", name: "alpha", path: "/p/alpha" }, + hydrated: true, + }); + render(wrap( {}} />)); + expect(screen.getByText("alpha")).toBeTruthy(); + }); + + it("renders a required dropdown in All-scopes mode", () => { + useScopeStore.setState({ current: { type: "all" }, hydrated: true }); + useProjectStore.setState({ + projects: [ + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, + ], + loading: false, + loaded: true, + }); + render(wrap( {}} />)); + const select = screen.getByLabelText( + /install to scope/i, + ) as HTMLSelectElement; + expect(select).toBeTruthy(); + expect(select.value).toBe(""); + }); + + it("calls onChange with selected scope", () => { + useScopeStore.setState({ current: { type: "all" }, hydrated: true }); + useProjectStore.setState({ + projects: [ + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, + ], + loading: false, + loaded: true, + }); + const onChange = vi.fn(); + render(wrap()); + fireEvent.change(screen.getByLabelText(/install to scope/i), { + target: { value: "global" }, + }); + expect(onChange).toHaveBeenCalledWith({ type: "global" }); + }); + + it("shows smart-default 'Use X' shortcut when value is null and smartDefault provided", () => { + useScopeStore.setState({ current: { type: "all" }, hydrated: true }); + useProjectStore.setState({ + projects: [ + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, + ], + loading: false, + loaded: true, + }); + const onChange = vi.fn(); + render( + wrap( + , + ), + ); + fireEvent.click(screen.getByText(/use alpha/i)); + expect(onChange).toHaveBeenCalledWith({ + type: "project", + name: "alpha", + path: "/p/alpha", + }); + }); +}); diff --git a/src/lib/__tests__/types.test.ts b/src/lib/__tests__/types.test.ts index 04466b2..f608bd2 100644 --- a/src/lib/__tests__/types.test.ts +++ b/src/lib/__tests__/types.test.ts @@ -211,7 +211,9 @@ describe("extensionGroupKey", () => { ...fooInAlpha, scope: { type: "project", name: "beta", path: "/Users/me/beta" }, }; - expect(extensionGroupKey(fooInAlpha)).not.toBe(extensionGroupKey(fooInBeta)); + expect(extensionGroupKey(fooInAlpha)).not.toBe( + extensionGroupKey(fooInBeta), + ); }); }); diff --git a/src/lib/__tests__/use-scope.test.tsx b/src/lib/__tests__/use-scope.test.tsx new file mode 100644 index 0000000..7d7b769 --- /dev/null +++ b/src/lib/__tests__/use-scope.test.tsx @@ -0,0 +1,64 @@ +import { act, renderHook } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useScope } from "@/hooks/use-scope"; +import { useScopeStore } from "@/stores/scope-store"; + +beforeEach(() => { + localStorage.clear(); + useScopeStore.setState({ current: { type: "global" }, hydrated: true }); +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("useScope", () => { + it("returns current scope from store", () => { + const { result } = renderHook(() => useScope(), { wrapper }); + expect(result.current.scope).toEqual({ type: "global" }); + expect(result.current.scopeId).toBe("global"); + expect(result.current.isAll).toBe(false); + }); + + it("setScope updates store and writes localStorage", () => { + const { result } = renderHook(() => useScope(), { wrapper }); + act(() => { + result.current.setScope({ + type: "project", + name: "alpha", + path: "/p/alpha", + }); + }); + expect(useScopeStore.getState().current).toEqual({ + type: "project", + name: "alpha", + path: "/p/alpha", + }); + expect(localStorage.getItem("HK_SCOPE_LAST_USED")).toBe( + JSON.stringify({ type: "project", name: "alpha", path: "/p/alpha" }), + ); + }); + + it("scopeId returns 'all' for all scope", () => { + useScopeStore.setState({ current: { type: "all" }, hydrated: true }); + const { result } = renderHook(() => useScope(), { wrapper }); + expect(result.current.scopeId).toBe("all"); + expect(result.current.isAll).toBe(true); + }); + + it("scopeId returns project path for project scope", () => { + useScopeStore.setState({ + current: { type: "project", name: "x", path: "/p/x" }, + hydrated: true, + }); + const { result } = renderHook(() => useScope(), { wrapper }); + expect(result.current.scopeId).toBe("/p/x"); + }); + + // Note: URL sync used to live inside useScope.setScope but was moved to + // AppShell's Effect 3 (store → URL) so a follow-up navigate() in the same + // tick (e.g. Overview's "click an agent file" handler) doesn't fight a + // search-params-only navigate from the hook. The hook is now a thin + // store-only wrapper; AppShell owns URL mirroring. +}); diff --git a/src/lib/agent-capabilities.ts b/src/lib/agent-capabilities.ts new file mode 100644 index 0000000..42a7147 --- /dev/null +++ b/src/lib/agent-capabilities.ts @@ -0,0 +1,35 @@ +import type { ExtensionKind } from "@/lib/types"; +import type { ScopeValue } from "@/stores/scope-store"; + +// Mirrors the per-adapter project_skill_dirs / project_mcp_config_relpath / +// project_hook_config_relpath declarations in crates/hk-core/src/adapter/*.rs. +// +// All 7 agents support project-level skill via the Universal Agent Skills +// standard (SKILL.md, December 2025). Task 7 declares project_skill_dirs on +// each adapter so this row is always ✓ for skill in v1. +// +// "mcp" / "hook" / "cli" rows are forward-compat for v2 cross-agent deploy +// (see follow-up roadmap). Several adapters need MCP/hook completion before +// those columns become accurate; v1 install pipeline doesn't consume them. +// +// Keep in sync when adapters change project-level declarations. +const PROJECT_INSTALL_SUPPORT: Record> = { + claude: new Set(["skill", "mcp", "hook", "cli"]), + codex: new Set(["skill"]), // MCP/hook adapter completion deferred (v2) + cursor: new Set(["skill", "mcp", "hook"]), + windsurf: new Set(["skill", "mcp", "hook"]), + gemini: new Set(["skill"]), // MCP/hook adapter completion deferred (v2) + antigravity: new Set(["skill"]), // MCP/hook adapter completion deferred (v2) + copilot: new Set(["skill"]), // MCP adapter completion deferred (v2) +}; + +/** Whether the agent's adapter declares project-level support for this kind. + * Returns true for non-project scopes (Global / All). */ +export function canInstallAtScope( + agent: string, + kind: ExtensionKind, + scope: ScopeValue, +): boolean { + if (scope.type !== "project") return true; + return PROJECT_INSTALL_SUPPORT[agent]?.has(kind) ?? false; +} diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 790ddc7..a258e63 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -4,6 +4,7 @@ import type { AgentInfo, AuditResult, CheckUpdatesResult, + ConfigScope, DashboardStats, DiscoveredProject, Extension, @@ -95,32 +96,45 @@ export const api = { installFromLocal( path: string, targetAgents: string[], + targetScope: ConfigScope, ): Promise { - return transport("install_from_local", { path, targetAgents }); + return transport("install_from_local", { path, targetAgents, targetScope }); }, installFromGit( url: string, - targetAgent?: string, - skillId?: string, + targetAgent: string | undefined, + skillId: string | undefined, + targetScope: ConfigScope, ): Promise { validateGitUrl(url); - return transport("install_from_git", { url, targetAgent, skillId }); + return transport("install_from_git", { + url, + targetAgent, + skillId, + targetScope, + }); }, - scanGitRepo(url: string, targetAgents: string[]): Promise { - return transport("scan_git_repo", { url, targetAgents }); + scanGitRepo( + url: string, + targetAgents: string[], + targetScope: ConfigScope, + ): Promise { + return transport("scan_git_repo", { url, targetAgents, targetScope }); }, installScannedSkills( cloneId: string, skillIds: string[], targetAgents: string[], + targetScope: ConfigScope, ): Promise { return transport("install_scanned_skills", { cloneId, skillIds, targetAgents, + targetScope, }); }, @@ -128,11 +142,13 @@ export const api = { url: string, skillIds: string[], targetAgents: string[], + targetScope: ConfigScope, ): Promise { return transport("install_new_repo_skills", { url, skillIds, targetAgents, + targetScope, }); }, @@ -205,9 +221,15 @@ export const api = { installFromMarketplace( source: string, skillId: string, - targetAgent?: string, + targetAgent: string | undefined, + targetScope: ConfigScope, ): Promise { - return transport("install_from_marketplace", { source, skillId, targetAgent }); + return transport("install_from_marketplace", { + source, + skillId, + targetAgent, + targetScope, + }); }, installToAgent(extensionId: string, targetAgent: string): Promise { @@ -263,8 +285,15 @@ export const api = { path: string, label: string, category: string, + targetScope: ConfigScope, ): Promise { - return transport("add_custom_config_path", { agent, path, label, category }); + return transport("add_custom_config_path", { + agent, + path, + label, + category, + targetScope, + }); }, updateCustomConfigPath( @@ -273,7 +302,12 @@ export const api = { label: string, category: string, ): Promise { - return transport("update_custom_config_path", { id, path, label, category }); + return transport("update_custom_config_path", { + id, + path, + label, + category, + }); }, removeCustomConfigPath(id: number): Promise { diff --git a/src/lib/web-select.ts b/src/lib/web-select.ts new file mode 100644 index 0000000..0095113 --- /dev/null +++ b/src/lib/web-select.ts @@ -0,0 +1,23 @@ +import { isDesktop } from "@/lib/transport"; + +/** Native `` elements consistent across web / + * desktop. In Tauri (desktop), returns `undefined` so the native chrome + * is preserved. + */ +export const webSelectStyle: React.CSSProperties | undefined = !isDesktop() + ? { + appearance: "none", + backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='12' viewBox='0 0 8 12'%3E%3Cpath d='M4 1L7 4.5H1Z' fill='%23888'/%3E%3Cpath d='M4 11L1 7.5H7Z' fill='%23888'/%3E%3C/svg%3E")`, + backgroundRepeat: "no-repeat", + backgroundPosition: "right 8px center", + paddingRight: "24px", + } + : undefined; + +/** True in web mode — used to swap `rounded-[6px] h-[26px]` for the more + * permissive Tauri styling (`rounded-lg py-1.5`). */ +export const isWeb = !isDesktop(); diff --git a/src/pages/agents.tsx b/src/pages/agents.tsx index 7d95edf..0ff78b6 100644 --- a/src/pages/agents.tsx +++ b/src/pages/agents.tsx @@ -1,46 +1,92 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useSearchParams } from "react-router-dom"; import { AgentDetail } from "@/components/agents/agent-detail"; import { AgentList } from "@/components/agents/agent-list"; +import { useScope } from "@/hooks/use-scope"; import { useAgentConfigStore } from "@/stores/agent-config-store"; +import { useProjectStore } from "@/stores/project-store"; +import { + resolveDeepLinkScope, + scopesEqual, + useScopeStore, +} from "@/stores/scope-store"; export default function AgentsPage() { + const hydrated = useScopeStore((s) => s.hydrated); const fetch = useAgentConfigStore((s) => s.fetch); const loading = useAgentConfigStore((s) => s.loading); const selectAgent = useAgentConfigStore((s) => s.selectAgent); const expandFile = useAgentConfigStore((s) => s.expandFile); - const setPendingFocusFile = useAgentConfigStore( - (s) => s.setPendingFocusFile, - ); + const setPendingFocusFile = useAgentConfigStore((s) => s.setPendingFocusFile); + const { scope, setScope } = useScope(); + const projects = useProjectStore((s) => s.projects); const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { + if (!hydrated) return; fetch(); - }, [fetch]); + }, [fetch, hydrated]); + + // When the user switches scope (e.g., via the Sidebar ScopeSwitcher), collapse + // all expanded file previews and drop any pending focus signal — the file + // visible just before the switch may not exist (or differ) in the new scope. + const prevScopeRef = useRef(scope); + useEffect(() => { + if (prevScopeRef.current !== scope) { + useAgentConfigStore.setState({ + expandedFiles: new Set(), + pendingFocusFile: null, + }); + prevScopeRef.current = scope; + } + }, [scope]); + // Collapse expansions when leaving the page so revisiting starts clean. + // expandedFiles lives in zustand (persists across remounts) — without this, + // navigating to Extensions and back would keep an old preview pane open. + useEffect(() => { + return () => { + useAgentConfigStore.setState({ + expandedFiles: new Set(), + pendingFocusFile: null, + }); + }; + }, []); + + // Deep-link handler: applies ?scope= and selects the target agent + file. + // Pre-syncs prevScopeRef so the scope-change cleanup above doesn't wipe + // the focus signal we're about to set. useEffect(() => { const agent = searchParams.get("agent"); + if (loading || !agent) return; const file = searchParams.get("file"); - if (!loading && agent) { - selectAgent(agent); - if (file) { - // expandFile opens the file's preview pane; pendingFocusFile is what - // the detail page uses to force-open the (possibly collapsed) parent - // section and scroll/highlight the row. - expandFile(file); - setPendingFocusFile(file); - } - setSearchParams({}, { replace: true }); + const targetScope = resolveDeepLinkScope(searchParams.get("scope"), projects); + if (!scopesEqual(targetScope, scope)) { + setScope(targetScope); + prevScopeRef.current = targetScope; } + selectAgent(agent); + if (file) { + expandFile(file); + setPendingFocusFile(file); + } + setSearchParams({}, { replace: true }); }, [ loading, searchParams, + scope, + setScope, + projects, selectAgent, expandFile, setPendingFocusFile, setSearchParams, ]); + if (!hydrated) { + return
Loading...
; + } + return (
diff --git a/src/pages/audit.tsx b/src/pages/audit.tsx index 8220a63..4c32f7b 100644 --- a/src/pages/audit.tsx +++ b/src/pages/audit.tsx @@ -1,3 +1,4 @@ +import { clsx } from "clsx"; import { Check, ChevronRight, @@ -13,16 +14,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Hint } from "@/components/shared/hint"; import { TrustBadge } from "@/components/shared/trust-badge"; +import { useScope } from "@/hooks/use-scope"; import { api } from "@/lib/invoke"; -import type { Extension } from "@/lib/types"; +import type { ConfigScope, Extension } from "@/lib/types"; import { extensionGroupKey, formatRelativeTime, + scopeLabel, type TrustTier, trustTier, } from "@/lib/types"; +import { isWeb, webSelectStyle } from "@/lib/web-select"; import { useAuditStore } from "@/stores/audit-store"; import { buildGroups } from "@/stores/extension-store"; +import { useScopeStore } from "@/stores/scope-store"; import { AUDIT_RULES, type GroupedResult, @@ -43,6 +48,7 @@ function IndeterminateBar({ className = "" }: { className?: string }) { } export default function AuditPage() { + const hydrated = useScopeStore((s) => s.hydrated); const { results, loading, @@ -69,10 +75,22 @@ export default function AuditPage() { }); const [allExtensions, setAllExtensions] = useState([]); const [extensionsReady, setExtensionsReady] = useState(false); + const { scope } = useScope(); + + // Close any expanded finding row when the user switches scope — the + // previously-open extension may not exist in the new scope. + const prevScopeRef = useRef(scope); + useEffect(() => { + if (prevScopeRef.current !== scope) { + setOpenId(null); + prevScopeRef.current = scope; + } + }, [scope]); // Search & filter state — persisted in Zustand store so filters survive navigation useEffect(() => { + if (!hydrated) return; loadCached(); // Fetch ALL extensions (unfiltered) for name resolution api @@ -84,7 +102,7 @@ export default function AuditPage() { .catch(() => { setExtensionsReady(true); }); - }, [loadCached]); + }, [loadCached, hydrated]); // Capture ?ext= query param for deferred scrolling (resolved after groupedResults are ready) const pendingScrollRef = useRef(searchParams.get("ext")); @@ -129,23 +147,46 @@ export default function AuditPage() { return map; }, [allExtensions]); + // Map extension ID → scope (used by the scope filter on scopedResults). + const scopeMap = useMemo(() => { + const map = new Map(); + for (const ext of allExtensions) { + map.set(ext.id, ext.scope); + } + return map; + }, [allExtensions]); + + // Apply the global scope filter to raw audit results before any other + // derivation (counts, sorting, grouping). In All-scopes mode every result + // passes; otherwise we keep only results whose extension lives in the + // selected scope. + const scopedResults = useMemo(() => { + if (scope.type === "all") return results; + return results.filter((r) => { + const extScope = scopeMap.get(r.extension_id); + if (!extScope) return false; + if (scope.type === "global") return extScope.type === "global"; + return extScope.type === "project" && extScope.path === scope.path; + }); + }, [results, scope, scopeMap]); + // Count extensions that actually have audit results const totalExtensions = useMemo(() => { - const auditedIds = new Set(results.map((r) => r.extension_id)); + const auditedIds = new Set(scopedResults.map((r) => r.extension_id)); return buildGroups(allExtensions.filter((e) => auditedIds.has(e.id))) .length; - }, [allExtensions, results]); + }, [allExtensions, scopedResults]); const sortedResults = useMemo( () => - [...results].sort((a, b) => { + [...scopedResults].sort((a, b) => { const scoreDiff = a.trust_score - b.trust_score; if (scoreDiff !== 0) return scoreDiff; const nameA = nameMap.get(a.extension_id) ?? a.extension_id; const nameB = nameMap.get(b.extension_id) ?? b.extension_id; return nameA.localeCompare(nameB); }), - [results, nameMap], + [scopedResults, nameMap], ); // Map extension ID → agent names for display @@ -236,6 +277,14 @@ export default function AuditPage() { return filtered; }, [groupedResults, searchQuery, tierFilter]); + // Scope-aware empty state: when the user has scoped to a specific project + // but no audit findings exist in that scope (yet results exist elsewhere), + // surface a focused empty state instead of the generic filter UI. + const isProjectScopeEmpty = + scope.type === "project" && + scopedResults.length === 0 && + results.length > 0; + const scrollToExtensionResult = useCallback( (extensionId: string) => { const group = groupedResults.find( @@ -275,6 +324,10 @@ export default function AuditPage() { }); } + if (!hydrated) { + return
Loading...
; + } + return (
{/* Fixed header */} @@ -370,7 +423,11 @@ export default function AuditPage() { setTierFilter((e.target.value || null) as TrustTier | null) } aria-label="Filter by trust tier" - className="rounded-lg border border-border bg-card px-2.5 py-1.5 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + style={webSelectStyle} + className={clsx( + "shrink-0 border border-border bg-card px-3 text-xs text-foreground focus:border-ring focus:outline-none transition-colors", + isWeb ? "rounded-[6px] h-[26px]" : "rounded-lg py-1.5", + )} > @@ -432,20 +489,33 @@ export default function AuditPage() {
)} - {filteredResults.length === 0 && results.length > 0 && !loading && ( -
- No extensions match your filters. - + {isProjectScopeEmpty && !loading && ( +
+

+ No audit findings in {scopeLabel(scope as ConfigScope)} +

+

+ Nothing is installed in this scope yet. +

)} + {!isProjectScopeEmpty && + filteredResults.length === 0 && + results.length > 0 && + !loading && ( +
+ No extensions match your filters. + +
+ )} {extensionsReady && filteredResults.map((group) => { const { primaryId } = group; diff --git a/src/pages/extensions.tsx b/src/pages/extensions.tsx index 6379e2f..99827cc 100644 --- a/src/pages/extensions.tsx +++ b/src/pages/extensions.tsx @@ -5,15 +5,22 @@ import { ExtensionDetail } from "@/components/extensions/extension-detail"; import { ExtensionFilters } from "@/components/extensions/extension-filters"; import { ExtensionTable } from "@/components/extensions/extension-table"; import { NewSkillsDialog } from "@/components/extensions/new-skills-dialog"; +import { useScope } from "@/hooks/use-scope"; import { useAgentStore } from "@/stores/agent-store"; import { useExtensionStore } from "@/stores/extension-store"; +import { useProjectStore } from "@/stores/project-store"; +import { + resolveDeepLinkScope, + scopesEqual, + useScopeStore, +} from "@/stores/scope-store"; import { toast } from "@/stores/toast-store"; export default function ExtensionsPage() { - const [searchParams] = useSearchParams(); + const hydrated = useScopeStore((s) => s.hydrated); + const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const setAgentFilter = useExtensionStore((s) => s.setAgentFilter); - const setScopeFilter = useExtensionStore((s) => s.setScopeFilter); const setSelectedId = useExtensionStore((s) => s.setSelectedId); const setKindFilter = useExtensionStore((s) => s.setKindFilter); @@ -22,55 +29,84 @@ export default function ExtensionsPage() { const allGrouped = useExtensionStore((s) => s.grouped); const extensions = useExtensionStore((s) => s.extensions); - const pendingNameRef = useRef(searchParams.get("name")); - const pendingGroupKeyRef = useRef(searchParams.get("groupKey")); + const groupKeyParam = searchParams.get("groupKey"); + const nameParam = searchParams.get("name"); + const isDeepLink = !!(groupKeyParam || nameParam); + const { scope, setScope } = useScope(); + const projects = useProjectStore((s) => s.projects); - // Apply query params synchronously on first render to avoid filter-change flash. - const didApplyRef = useRef(false); - if (!didApplyRef.current) { + // Apply filter overrides synchronously on first render to avoid an initial + // filter-change flash. Scope + selection are handled by the deep-link + // effect below — calling setScope() in render warns about updating a + // different component (ScopeSwitcher) while rendering this one. + const didApplyFiltersRef = useRef(false); + if (!didApplyFiltersRef.current) { const agent = searchParams.get("agent"); if (agent) setAgentFilter(agent); - const scope = searchParams.get("scope"); - if (scope) setScopeFilter(scope); - if (pendingNameRef.current || pendingGroupKeyRef.current) { + if (isDeepLink) { setKindFilter(null); setAgentFilter(null); - setScopeFilter(null); setPackFilter(null); setSearchQuery(""); } - didApplyRef.current = true; + didApplyFiltersRef.current = true; } - // Match the extension once data is available and scroll to it - const [scrollToId, setScrollToId] = useState(null); + // Cleanup: when the user manually changes scope (e.g. via Sidebar + // ScopeSwitcher), close the detail panel — the selected ext may not exist + // in the new scope. Declared BEFORE the deep-link effect so the deep-link + // can pre-sync prevScopeRef.current without this cleanup undoing it on + // the same render's effect phase. + const prevScopeRef = useRef(scope); useEffect(() => { - if (extensions.length === 0) return; - const groups = allGrouped(); - - const gk = pendingGroupKeyRef.current; - if (gk) { - const match = groups.find((g) => g.groupKey === gk); - if (match) { - setSelectedId(match.groupKey); - setScrollToId(match.groupKey); - pendingGroupKeyRef.current = null; - pendingNameRef.current = null; - } - return; + if (prevScopeRef.current !== scope) { + setSelectedId(null); + prevScopeRef.current = scope; } + }, [scope, setSelectedId]); - const name = pendingNameRef.current; - if (!name) return; - const match = groups.find( - (g) => g.name.toLowerCase() === name.toLowerCase(), + // Deep-link handler: applies ?scope= from URL, selects the target group, + // then clears the URL params. Clearing is critical — without it, every + // subsequent scope change (e.g. user clicking Sidebar ScopeSwitcher) + // would re-fire this effect (scope dep), see the still-present groupKey + // and "restore" the deep-link's scope/selection, fighting the user. + // Mirrors the pattern in agents.tsx. + const [scrollToId, setScrollToId] = useState(null); + useEffect(() => { + if (!isDeepLink) return; + if (extensions.length === 0) return; + const targetScope = resolveDeepLinkScope( + searchParams.get("scope"), + projects, ); + if (!scopesEqual(targetScope, scope)) { + setScope(targetScope); + prevScopeRef.current = targetScope; + } + const groups = allGrouped(); + const match = groupKeyParam + ? groups.find((g) => g.groupKey === groupKeyParam) + : groups.find( + (g) => g.name.toLowerCase() === nameParam!.toLowerCase(), + ); if (match) { setSelectedId(match.groupKey); setScrollToId(match.groupKey); - pendingNameRef.current = null; } - }, [extensions, allGrouped, setSelectedId]); + setSearchParams({}, { replace: true }); + }, [ + isDeepLink, + extensions, + allGrouped, + scope, + setScope, + projects, + searchParams, + setSearchParams, + groupKeyParam, + nameParam, + setSelectedId, + ]); // Individual selectors — prevents unrelated state changes from causing re-renders const loading = useExtensionStore((s) => s.loading); const fetch = useExtensionStore((s) => s.fetch); @@ -97,14 +133,27 @@ export default function ExtensionsPage() { const data = useExtensionStore((s) => s.filtered()); const batchMode = selectedIds.size > 0; + // Close the detail panel when leaving the page so revisiting starts clean. + // selectedId lives in zustand (persists across remounts) — without this, + // navigating to Agents and back would keep an old row open. + useEffect(() => { + return () => { + useExtensionStore.setState({ selectedId: null }); + }; + }, []); + const fetchAgents = useAgentStore((s) => s.fetch); const didFetchRef = useRef(false); useEffect(() => { - if (didFetchRef.current) return; + if (!hydrated || didFetchRef.current) return; didFetchRef.current = true; fetch(); fetchAgents(); - }, [fetch, fetchAgents]); + }, [fetch, fetchAgents, hydrated]); + + if (!hydrated) { + return
Loading...
; + } return (
@@ -262,8 +311,13 @@ export default function ExtensionsPage() { {showNewSkills && newRepoSkills.length > 0 && ( { - await installNewRepoSkills(url, skillIds, targetAgents); + onInstall={async (url, skillIds, targetAgents, targetScope) => { + await installNewRepoSkills( + url, + skillIds, + targetAgents, + targetScope, + ); toast.success( `${skillIds.length} skill${skillIds.length > 1 ? "s" : ""} installed`, ); diff --git a/src/pages/marketplace.tsx b/src/pages/marketplace.tsx index 2b10c55..f6e3411 100644 --- a/src/pages/marketplace.tsx +++ b/src/pages/marketplace.tsx @@ -21,17 +21,23 @@ import { useEffect, useRef, useState } from "react"; import { InstallDialog } from "@/components/extensions/install-dialog"; import { AgentMascot } from "@/components/shared/agent-mascot/agent-mascot"; import { Hint } from "@/components/shared/hint"; +import { ScopeTargetField } from "@/components/shared/scope-target-field"; +import { useScope } from "@/hooks/use-scope"; import { useScrollPassthrough } from "@/hooks/use-scroll-passthrough"; +import { canInstallAtScope } from "@/lib/agent-capabilities"; import { humanizeError } from "@/lib/errors"; import { agentDisplayName, + type ConfigScope, type MarketplaceItem, + scopeKey, type SkillAuditInfo, sortAgents, } from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; import { useExtensionStore } from "@/stores/extension-store"; import { useMarketplaceStore } from "@/stores/marketplace-store"; +import type { ScopeValue } from "@/stores/scope-store"; import { toast } from "@/stores/toast-store"; /** Extract install-related section from README markdown. @@ -223,6 +229,14 @@ export default function MarketplacePage() { } = useMarketplaceStore(); const { agents, fetch: fetchAgents, agentOrder } = useAgentStore(); const extensions = useExtensionStore((s) => s.extensions); + const { scope, isAll } = useScope(); + const [installTargetScope, setInstallTargetScope] = + useState(null); + // In single-scope mode, the active scope IS the install target. In All-scopes + // mode, the user must pick a scope via ScopeTargetField (null until picked). + const effectiveTarget: ConfigScope | null = isAll + ? installTargetScope + : (scope as ConfigScope); const [installed, setInstalled] = useState>(new Set()); const [justInstalled, setJustInstalled] = useState>(new Set()); const [error, setError] = useState(null); @@ -230,20 +244,30 @@ export default function MarketplacePage() { const [installMode, setInstallMode] = useState<"git" | "local">("git"); const detailPanelRef = useRef(null); - const isItemInstalled = (item: MarketplaceItem, agentName: string) => { + const isItemInstalled = ( + item: MarketplaceItem, + agentName: string, + activeScope: ScopeValue, + ) => { const key = `${item.id}:${agentName}`; if (installed.has(key)) return true; + const targetKey = + activeScope.type === "all" ? null : scopeKey(activeScope as ConfigScope); + return extensions.some((ext) => { if (!ext.agents.includes(agentName)) return false; - + if (item.kind === "skill") { if (!["skill", "plugin"].includes(ext.kind)) return false; } else { if (ext.kind !== item.kind) return false; } - const extUrl = ext.install_meta?.url_resolved ?? ext.install_meta?.url ?? ext.source.url; + const extUrl = + ext.install_meta?.url_resolved ?? + ext.install_meta?.url ?? + ext.source.url; let extSource = ""; if (extUrl) { const match = extUrl.match(/github\.com\/([^/]+\/[^/]+)/); @@ -259,16 +283,24 @@ export default function MarketplacePage() { extSource.toLowerCase() === itemSourceLower || (ext.pack ?? "").toLowerCase() === itemSourceLower; + let nameMatches: boolean; if (item.kind === "skill") { // Match strictly by name. The scanner sometimes classifies individual // items in a collection repo (e.g. github/awesome-copilot) as kind=plugin, // so "same source URL + kind=plugin" doesn't reliably mean the whole repo // is installed — it could be just one sibling. See PR #21 discussion. - const targetName = item.skill_id && item.skill_id.length > 0 ? item.skill_id : item.name; - return ext.name.toLowerCase() === targetName.toLowerCase() && matchSource; + const targetName = + item.skill_id && item.skill_id.length > 0 ? item.skill_id : item.name; + nameMatches = ext.name.toLowerCase() === targetName.toLowerCase(); + } else { + nameMatches = ext.name.toLowerCase() === item.name.toLowerCase(); } + if (!nameMatches || !matchSource) return false; - return ext.name.toLowerCase() === item.name.toLowerCase() && matchSource; + // Scope-aware: in single-scope mode only count installs in the active + // scope. In All-scopes mode (targetKey === null) any scope counts. + if (targetKey === null) return true; + return scopeKey(ext.scope) === targetKey; }); }; @@ -295,10 +327,14 @@ export default function MarketplacePage() { search(); }, 300); }; - const handleInstall = async (item: MarketplaceItem, targetAgent?: string) => { + const handleInstall = async ( + item: MarketplaceItem, + targetAgent: string | undefined, + targetScope: ConfigScope, + ) => { setError(null); try { - const result = await install(item, targetAgent); + const result = await install(item, targetAgent, targetScope); // Refresh extension store so audit page can resolve names immediately useExtensionStore.getState().fetch(); const key = `${item.id}:${targetAgent ?? ""}`; @@ -691,40 +727,94 @@ export default function MarketplacePage() { {/* Install to agents */} {detectedAgents.length > 0 && selectedItem.kind === "skill" && (
-

- Install to Agent -

+
+

+ Install to Agent +

+ {/* Single-scope mode: render the inline "· 📁 name" hint + * next to the header to save vertical space. All-scopes + * mode renders the picker on its own row below since the + * dropdown doesn't fit alongside the header. */} + {!isAll && ( + + )} +
+ {isAll && ( +
+ +
+ )}
{detectedAgents.map((agent) => { const key = `${selectedItem.id}:${agent.name}`; - const isInstalled = isItemInstalled(selectedItem, agent.name); + // ConfigScope ⊂ ScopeValue (ScopeValue adds the "all" + // variant), so we can pass either through + // canInstallAtScope's ScopeValue parameter. + const targetScopeForCheck: ScopeValue = + effectiveTarget ?? scope; + const capabilityOk = canInstallAtScope( + agent.name, + "skill", + targetScopeForCheck, + ); + const isInstalled = isItemInstalled( + selectedItem, + agent.name, + targetScopeForCheck, + ); const isFlashing = justInstalled.has(key); const isInstallingThis = installing === key; const isInstallingAny = installing?.startsWith(`${selectedItem.id}:`) ?? false; + const disabled = + !effectiveTarget || + isInstallingAny || + isInstalled || + !capabilityOk; return ( ); diff --git a/src/pages/overview.tsx b/src/pages/overview.tsx index b1c36bb..d25600a 100644 --- a/src/pages/overview.tsx +++ b/src/pages/overview.tsx @@ -71,7 +71,10 @@ interface ActivityItem { label: string; sublabel: string; timestamp: number; - navigateTo: string; + /** Click handler that should setScope (so the destination page sees the + * right scope) BEFORE navigating. Overview is scope-agnostic, so deep + * links must carry their own scope context. */ + onSelect: () => void; } function formatTerminalCount(value: number) { @@ -141,9 +144,7 @@ function QuickAction({ {label} - - {sublabel} - + {sublabel}
); @@ -336,14 +337,26 @@ export default function OverviewPage() { label: cfg.file_name, sublabel: `${agentDisplayName(agent.name)} \u00B7 Modified ${formatRelativeTime(cfg.modified_at)}`, timestamp: new Date(cfg.modified_at).getTime(), - navigateTo: `/agents?agent=${agent.name}&file=${encodeURIComponent(cfg.path)}`, + // Pass the file's scope through the URL so Agents lands in the + // right scope (Agents reads ?scope= and applies it locally). Doing + // setScope + navigate in the same event handler races: React 18 + // batches both updates and the router update gets dropped. + onSelect: () => { + const scopeParam = + cfg.scope.type === "global" + ? "" + : `&scope=${encodeURIComponent(cfg.scope.path)}`; + navigate( + `/agents?agent=${agent.name}&file=${encodeURIComponent(cfg.path)}${scopeParam}`, + ); + }, }); } } items.sort((a, b) => b.timestamp - a.timestamp); return items.slice(0, 20); - }, [agentConfigs]); + }, [agentConfigs, navigate]); // ----------------------------------------------------------------------- // Section A-right: Recent Extensions (recently installed) @@ -369,13 +382,23 @@ export default function OverviewPage() { label: ext.name, sublabel: `${ext.kind.toUpperCase()} · Installed ${formatRelativeTime(ext.installed_at)}`, timestamp: new Date(ext.installed_at).getTime(), - navigateTo: `/extensions?groupKey=${encodeURIComponent(extensionGroupKey(ext))}`, + // Pass scope through the URL (see config-items comment above for why + // setScope + navigate in the same handler races and loses the nav). + onSelect: () => { + const scopeParam = + ext.scope.type === "global" + ? "" + : `&scope=${encodeURIComponent(ext.scope.path)}`; + navigate( + `/extensions?groupKey=${encodeURIComponent(extensionGroupKey(ext))}${scopeParam}`, + ); + }, }); } items.sort((a, b) => b.timestamp - a.timestamp); return items.slice(0, 20); - }, [visibleExtensions]); + }, [visibleExtensions, navigate]); const hasActivity = agentActivityItems.length > 0 || extensionActivityItems.length > 0; @@ -549,7 +572,7 @@ export default function OverviewPage() { agentActivityItems.map((item, i) => (