From c96c09e26649c69f50d95da856618bde181e6bbb Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 8 May 2026 14:10:48 +0300 Subject: [PATCH 1/5] feat(core): add Subagents config category and trait methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new ConfigCategory::Subagents variant (ordered between Memory and Settings) and matching AgentAdapter trait methods global_subagent_files() and project_subagent_patterns(). Wire them into scan_agent_configs so any adapter that overrides them gets its subagent definition files scanned into the new category — the trait defaults stay vec![] so adapters with no subagent concept (e.g. windsurf, antigravity) need no changes. This commit is the structural foundation; per-adapter implementations (claude/codex/cursor/gemini/copilot/opencode) come in a follow-up commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/hk-core/src/adapter/mod.rs | 17 +++++++++++++++++ crates/hk-core/src/models.rs | 19 ++++++++++++++++--- crates/hk-core/src/scanner.rs | 9 +++++++-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index f57eaca..04c59c1 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -170,6 +170,14 @@ pub trait AgentAdapter: Send + Sync { vec![] } + /// Global subagent definition files (absolute paths). Each file in the + /// returned list is one subagent persona definition (e.g. + /// `~/.claude/agents/foo.md`, `~/.codex/agents/bar.toml`). Distinct from + /// settings: subagents define behavior/personality, not config knobs. + fn global_subagent_files(&self) -> Vec { + vec![] + } + /// Relative paths/globs for rules within a project dir (e.g. "CLAUDE.md") fn project_rules_patterns(&self) -> Vec { vec![] @@ -185,6 +193,13 @@ pub trait AgentAdapter: Send + Sync { vec![] } + /// Relative paths/globs for subagent definition files within a project dir + /// (e.g. `.claude/agents/*.md`, `.codex/agents/*.toml`). Each matched file + /// is one subagent persona definition. + fn project_subagent_patterns(&self) -> Vec { + vec![] + } + /// Relative paths/globs for ignore files within a project dir fn project_ignore_patterns(&self) -> Vec { vec![] @@ -344,9 +359,11 @@ mod tests { let _ = a.global_rules_files(); let _ = a.global_memory_files(); let _ = a.global_settings_files(); + let _ = a.global_subagent_files(); let _ = a.project_rules_patterns(); let _ = a.project_memory_patterns(); let _ = a.project_settings_patterns(); + let _ = a.project_subagent_patterns(); let _ = a.project_ignore_patterns(); let _ = a.global_workflow_files(); let _ = a.project_workflow_patterns(); diff --git a/crates/hk-core/src/models.rs b/crates/hk-core/src/models.rs index ac6a42e..1bf58e0 100644 --- a/crates/hk-core/src/models.rs +++ b/crates/hk-core/src/models.rs @@ -374,6 +374,7 @@ pub struct AgentConfigFile { pub enum ConfigCategory { Rules, Memory, + Subagents, Settings, Workflow, Ignore, @@ -384,6 +385,7 @@ impl ConfigCategory { match self { Self::Rules => "rules", Self::Memory => "memory", + Self::Subagents => "subagents", Self::Settings => "settings", Self::Workflow => "workflow", Self::Ignore => "ignore", @@ -394,9 +396,10 @@ impl ConfigCategory { match self { Self::Rules => 0, Self::Memory => 1, - Self::Settings => 2, - Self::Workflow => 3, - Self::Ignore => 4, + Self::Subagents => 2, + Self::Settings => 3, + Self::Workflow => 4, + Self::Ignore => 5, } } } @@ -499,11 +502,21 @@ mod tests { fn test_config_category_as_str() { assert_eq!(ConfigCategory::Rules.as_str(), "rules"); assert_eq!(ConfigCategory::Memory.as_str(), "memory"); + assert_eq!(ConfigCategory::Subagents.as_str(), "subagents"); assert_eq!(ConfigCategory::Settings.as_str(), "settings"); assert_eq!(ConfigCategory::Workflow.as_str(), "workflow"); assert_eq!(ConfigCategory::Ignore.as_str(), "ignore"); } + #[test] + fn test_config_category_order() { + assert!(ConfigCategory::Rules.order() < ConfigCategory::Memory.order()); + assert!(ConfigCategory::Memory.order() < ConfigCategory::Subagents.order()); + assert!(ConfigCategory::Subagents.order() < ConfigCategory::Settings.order()); + assert!(ConfigCategory::Settings.order() < ConfigCategory::Workflow.order()); + assert!(ConfigCategory::Workflow.order() < ConfigCategory::Ignore.order()); + } + #[test] fn test_config_scope_serialization() { let global = ConfigScope::Global; diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 99239b2..0708b44 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -1830,9 +1830,10 @@ pub fn scan_agent_configs( let mut configs = Vec::new(); // --- Global files --- - let global_groups: [(ConfigCategory, Vec); 4] = [ + let global_groups: [(ConfigCategory, Vec); 5] = [ (ConfigCategory::Rules, adapter.global_rules_files()), (ConfigCategory::Memory, adapter.global_memory_files()), + (ConfigCategory::Subagents, adapter.global_subagent_files()), (ConfigCategory::Settings, adapter.global_settings_files()), (ConfigCategory::Workflow, adapter.global_workflow_files()), ]; @@ -1847,9 +1848,13 @@ pub fn scan_agent_configs( } // --- Project files --- - let project_groups: [(ConfigCategory, Vec); 5] = [ + let project_groups: [(ConfigCategory, Vec); 6] = [ (ConfigCategory::Rules, adapter.project_rules_patterns()), (ConfigCategory::Memory, adapter.project_memory_patterns()), + ( + ConfigCategory::Subagents, + adapter.project_subagent_patterns(), + ), ( ConfigCategory::Settings, adapter.project_settings_patterns(), From 2f4d668a68ed3bd41123d44c046014fd02a853dc Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 8 May 2026 14:11:27 +0300 Subject: [PATCH 2/5] feat(adapters): scan project-level subagents across 6 agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the new global_subagent_files() / project_subagent_patterns() trait methods for claude, codex, cursor, gemini, copilot, and opencode. Adds project-level subagent file discovery (.{agent}/agents/) which was previously missing for all 6, and closes Codex's global-side gap (it never scanned ~/.codex/agents/). Five adapters (claude/cursor/gemini/copilot/opencode) previously misclassified their global agents/ directory under Settings. This commit moves those scans into the new Subagents category — a behavior change visible to users as agent persona files relocating from the Settings section to a new Subagents section. opencode's existing test was updated to reflect the move. Also tightens copilot's filter from extension == "md" to require a *.agent.md naming, matching the documented Copilot CLI convention. Plain .md notes left in ~/.copilot/agents/ no longer get misclassified as subagents. Two parsers (hk-web/handlers/agents.rs, hk-desktop/commands/agents.rs) accept the new "subagents" string in custom-config-path categorization. Tests: 6 adapter unit tests covering empty/happy/wrong-ext/no-leak contracts, plus 2 scanner integration tests (Claude .md, Codex .toml). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/hk-core/src/adapter/claude.rs | 62 ++++++++++++--- crates/hk-core/src/adapter/codex.rs | 48 ++++++++++++ crates/hk-core/src/adapter/copilot.rs | 78 +++++++++++++++++-- crates/hk-core/src/adapter/cursor.rs | 43 ++++++++++- crates/hk-core/src/adapter/gemini.rs | 56 ++++++++++++-- crates/hk-core/src/adapter/opencode.rs | 51 ++++++++++--- crates/hk-core/src/scanner.rs | 97 ++++++++++++++++++++++++ crates/hk-desktop/src/commands/agents.rs | 1 + crates/hk-web/src/handlers/agents.rs | 1 + 9 files changed, 400 insertions(+), 37 deletions(-) diff --git a/crates/hk-core/src/adapter/claude.rs b/crates/hk-core/src/adapter/claude.rs index d59a229..13fcab7 100644 --- a/crates/hk-core/src/adapter/claude.rs +++ b/crates/hk-core/src/adapter/claude.rs @@ -217,9 +217,9 @@ impl AgentAdapter for ClaudeAdapter { self.base_dir().join("settings.local.json"), self.base_dir().join("keybindings.json"), ]; - // ~/.claude/agents/*.md - let agents_dir = self.base_dir().join("agents"); - if let Ok(entries) = std::fs::read_dir(&agents_dir) { + // ~/.claude/commands/*.md (legacy, still functional) + let commands_dir = self.base_dir().join("commands"); + if let Ok(entries) = std::fs::read_dir(&commands_dir) { for entry in entries.flatten() { let p = entry.path(); if p.extension().is_some_and(|e| e == "md") { @@ -227,9 +227,9 @@ impl AgentAdapter for ClaudeAdapter { } } } - // ~/.claude/commands/*.md (legacy, still functional) - let commands_dir = self.base_dir().join("commands"); - if let Ok(entries) = std::fs::read_dir(&commands_dir) { + // ~/.claude/output-styles/*.md + let styles_dir = self.base_dir().join("output-styles"); + if let Ok(entries) = std::fs::read_dir(&styles_dir) { for entry in entries.flatten() { let p = entry.path(); if p.extension().is_some_and(|e| e == "md") { @@ -237,9 +237,14 @@ impl AgentAdapter for ClaudeAdapter { } } } - // ~/.claude/output-styles/*.md - let styles_dir = self.base_dir().join("output-styles"); - if let Ok(entries) = std::fs::read_dir(&styles_dir) { + files + } + + fn global_subagent_files(&self) -> Vec { + // ~/.claude/agents/*.md + let mut files = Vec::new(); + let agents_dir = self.base_dir().join("agents"); + if let Ok(entries) = std::fs::read_dir(&agents_dir) { for entry in entries.flatten() { let p = entry.path(); if p.extension().is_some_and(|e| e == "md") { @@ -273,6 +278,10 @@ impl AgentAdapter for ClaudeAdapter { ] } + fn project_subagent_patterns(&self) -> Vec { + vec![".claude/agents/*.md".into()] + } + fn project_ignore_patterns(&self) -> Vec { vec![] // Claude Code does NOT have .claudeignore } @@ -479,4 +488,39 @@ mod tests { let project_ignore = adapter.project_ignore_patterns(); assert!(project_ignore.is_empty()); } + + #[test] + fn test_claude_subagent_methods() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = ClaudeAdapter::with_home(tmp.path().to_path_buf()); + + // Missing agents/ dir → empty. + assert!(adapter.global_subagent_files().is_empty()); + + // Populate agents/ with one .md and one non-.md (must be filtered). + let agents_dir = adapter.base_dir().join("agents"); + std::fs::create_dir_all(&agents_dir).unwrap(); + std::fs::write(agents_dir.join("reviewer.md"), "# reviewer").unwrap(); + std::fs::write(agents_dir.join("notes.txt"), "ignore me").unwrap(); + + let subagents = adapter.global_subagent_files(); + assert!(subagents.iter().any(|p| p.ends_with("agents/reviewer.md"))); + assert!( + !subagents.iter().any(|p| p.ends_with("notes.txt")), + "non-.md files in agents/ must be filtered" + ); + + // Subagent dir contents must NOT leak into settings anymore. + let settings = adapter.global_settings_files(); + assert!( + !settings.iter().any(|p| p.ends_with("agents/reviewer.md")), + "agents/ moved to global_subagent_files; must not appear in settings" + ); + + // Project pattern is the canonical .claude/agents/*.md. + assert_eq!( + adapter.project_subagent_patterns(), + vec![".claude/agents/*.md".to_string()] + ); + } } diff --git a/crates/hk-core/src/adapter/codex.rs b/crates/hk-core/src/adapter/codex.rs index 7955b3a..2c858a0 100644 --- a/crates/hk-core/src/adapter/codex.rs +++ b/crates/hk-core/src/adapter/codex.rs @@ -79,6 +79,22 @@ impl AgentAdapter for CodexAdapter { ] } + fn global_subagent_files(&self) -> Vec { + // ~/.codex/agents/*.toml (Codex CLI subagents) + // Source: https://developers.openai.com/codex/subagents + let mut files = Vec::new(); + let agents_dir = self.base_dir().join("agents"); + if let Ok(entries) = std::fs::read_dir(&agents_dir) { + for entry in entries.flatten() { + let p = entry.path(); + if p.extension().is_some_and(|e| e == "toml") { + files.push(p); + } + } + } + files + } + fn global_memory_files(&self) -> Vec { let mut files = Vec::new(); let memories_dir = self.base_dir().join("memories"); @@ -105,6 +121,10 @@ impl AgentAdapter for CodexAdapter { vec![".codex/config.toml".into()] } + fn project_subagent_patterns(&self) -> Vec { + vec![".codex/agents/*.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 @@ -486,4 +506,32 @@ enabled = false assert_eq!(hooks[0].event, "PreToolUse"); assert_eq!(hooks[0].command, "echo test"); } + + #[test] + fn test_codex_subagent_methods() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = CodexAdapter::with_home(tmp.path().to_path_buf()); + + // Missing agents/ dir → empty. + assert!(adapter.global_subagent_files().is_empty()); + + // Codex subagents are TOML, not Markdown. + // Source: https://developers.openai.com/codex/subagents + let agents_dir = adapter.base_dir().join("agents"); + fs::create_dir_all(&agents_dir).unwrap(); + fs::write(agents_dir.join("reviewer.toml"), "name = \"reviewer\"").unwrap(); + fs::write(agents_dir.join("notes.md"), "ignore — wrong ext").unwrap(); + + let subagents = adapter.global_subagent_files(); + assert!(subagents.iter().any(|p| p.ends_with("agents/reviewer.toml"))); + assert!( + !subagents.iter().any(|p| p.ends_with("notes.md")), + ".md files in Codex agents/ must be filtered (Codex uses .toml)" + ); + + assert_eq!( + adapter.project_subagent_patterns(), + vec![".codex/agents/*.toml".to_string()] + ); + } } diff --git a/crates/hk-core/src/adapter/copilot.rs b/crates/hk-core/src/adapter/copilot.rs index 1bbbf7e..2cb38e8 100644 --- a/crates/hk-core/src/adapter/copilot.rs +++ b/crates/hk-core/src/adapter/copilot.rs @@ -152,22 +152,34 @@ impl AgentAdapter for CopilotAdapter { self.base_dir().join("config.json"), self.vscode_user_dir().join("mcp.json"), ]; - // ~/.copilot/agents/*.agent.md - let agents_dir = self.base_dir().join("agents"); - if let Ok(entries) = std::fs::read_dir(&agents_dir) { + // ~/.copilot/hooks/*.json + let hooks_dir = self.base_dir().join("hooks"); + if let Ok(entries) = std::fs::read_dir(&hooks_dir) { for entry in entries.flatten() { let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { + if p.extension().is_some_and(|e| e == "json") { files.push(p); } } } - // ~/.copilot/hooks/*.json - let hooks_dir = self.base_dir().join("hooks"); - if let Ok(entries) = std::fs::read_dir(&hooks_dir) { + files + } + + fn global_subagent_files(&self) -> Vec { + // ~/.copilot/agents/*.agent.md + // Per Copilot docs the canonical naming is `.agent.md`; require + // the `.agent` segment so plain `.md` notes left in this dir aren't + // misclassified as subagents. + let mut files = Vec::new(); + let agents_dir = self.base_dir().join("agents"); + if let Ok(entries) = std::fs::read_dir(&agents_dir) { for entry in entries.flatten() { let p = entry.path(); - if p.extension().is_some_and(|e| e == "json") { + if p.extension().is_some_and(|e| e == "md") + && p.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|stem| stem.ends_with(".agent")) + { files.push(p); } } @@ -197,6 +209,10 @@ impl AgentAdapter for CopilotAdapter { ] } + fn project_subagent_patterns(&self) -> Vec { + vec![".github/agents/*.agent.md".into()] + } + fn project_ignore_patterns(&self) -> Vec { vec![".copilotignore".into()] } @@ -472,4 +488,50 @@ mod tests { assert_eq!(plugins[0].source, "user/my-repo"); assert!(plugins[0].path.is_some()); } + + #[test] + fn test_copilot_subagent_methods() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = CopilotAdapter::with_home(tmp.path().to_path_buf()); + + assert!(adapter.global_subagent_files().is_empty()); + + // Copilot subagent files use the `*.agent.md` naming convention. + // The filter requires the `.agent` stem segment so plain `*.md` files + // left in agents/ are not misclassified as subagents. + let agents_dir = adapter.base_dir().join("agents"); + std::fs::create_dir_all(&agents_dir).unwrap(); + std::fs::write(agents_dir.join("reviewer.agent.md"), "# reviewer").unwrap(); + std::fs::write(agents_dir.join("draft.md"), "scratch note, not a subagent").unwrap(); + std::fs::write(agents_dir.join("notes.txt"), "ignore me").unwrap(); + + let subagents = adapter.global_subagent_files(); + assert!( + subagents + .iter() + .any(|p| p.ends_with("agents/reviewer.agent.md")), + "*.agent.md must be picked up" + ); + assert!( + !subagents.iter().any(|p| p.ends_with("draft.md")), + "plain *.md without .agent stem must be filtered" + ); + assert!( + !subagents.iter().any(|p| p.ends_with("notes.txt")), + "non-.md files in agents/ must be filtered" + ); + + let settings = adapter.global_settings_files(); + assert!( + !settings + .iter() + .any(|p| p.ends_with("agents/reviewer.agent.md")), + "agents/ moved to global_subagent_files; must not appear in settings" + ); + + assert_eq!( + adapter.project_subagent_patterns(), + vec![".github/agents/*.agent.md".to_string()] + ); + } } diff --git a/crates/hk-core/src/adapter/cursor.rs b/crates/hk-core/src/adapter/cursor.rs index 23a2ab2..76541af 100644 --- a/crates/hk-core/src/adapter/cursor.rs +++ b/crates/hk-core/src/adapter/cursor.rs @@ -156,12 +156,16 @@ impl AgentAdapter for CursorAdapter { } fn global_settings_files(&self) -> Vec { - let mut files = vec![ + vec![ self.base_dir().join("mcp.json"), self.base_dir().join("permissions.json"), self.base_dir().join("hooks.json"), - ]; + ] + } + + fn global_subagent_files(&self) -> Vec { // ~/.cursor/agents/*.md + let mut files = Vec::new(); let agents_dir = self.base_dir().join("agents"); if let Ok(entries) = std::fs::read_dir(&agents_dir) { for entry in entries.flatten() { @@ -198,6 +202,10 @@ impl AgentAdapter for CursorAdapter { vec![".cursor/mcp.json".into()] } + fn project_subagent_patterns(&self) -> Vec { + vec![".cursor/agents/*.md".into()] + } + fn project_ignore_patterns(&self) -> Vec { vec![".cursorignore".into(), ".cursorindexingignore".into()] } @@ -292,6 +300,37 @@ mod tests { use super::super::AgentAdapter; use super::*; + #[test] + fn test_cursor_subagent_methods() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = CursorAdapter::with_home(tmp.path().to_path_buf()); + + assert!(adapter.global_subagent_files().is_empty()); + + let agents_dir = adapter.base_dir().join("agents"); + std::fs::create_dir_all(&agents_dir).unwrap(); + std::fs::write(agents_dir.join("reviewer.md"), "# reviewer").unwrap(); + std::fs::write(agents_dir.join("notes.txt"), "ignore me").unwrap(); + + let subagents = adapter.global_subagent_files(); + assert!(subagents.iter().any(|p| p.ends_with("agents/reviewer.md"))); + assert!( + !subagents.iter().any(|p| p.ends_with("notes.txt")), + "non-.md files in agents/ must be filtered" + ); + + let settings = adapter.global_settings_files(); + assert!( + !settings.iter().any(|p| p.ends_with("agents/reviewer.md")), + "agents/ moved to global_subagent_files; must not appear in settings" + ); + + assert_eq!( + adapter.project_subagent_patterns(), + vec![".cursor/agents/*.md".to_string()] + ); + } + #[test] fn read_hooks_cursor_format() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/hk-core/src/adapter/gemini.rs b/crates/hk-core/src/adapter/gemini.rs index 5f27675..6794985 100644 --- a/crates/hk-core/src/adapter/gemini.rs +++ b/crates/hk-core/src/adapter/gemini.rs @@ -127,22 +127,27 @@ impl AgentAdapter for GeminiAdapter { } } } - // ~/.gemini/agents/*.md - let agents_dir = self.base_dir().join("agents"); - if let Ok(entries) = std::fs::read_dir(&agents_dir) { + // ~/.gemini/policies/*.toml + let policies_dir = self.base_dir().join("policies"); + if let Ok(entries) = std::fs::read_dir(&policies_dir) { for entry in entries.flatten() { let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { + if p.extension().is_some_and(|e| e == "toml") { files.push(p); } } } - // ~/.gemini/policies/*.toml - let policies_dir = self.base_dir().join("policies"); - if let Ok(entries) = std::fs::read_dir(&policies_dir) { + files + } + + fn global_subagent_files(&self) -> Vec { + // ~/.gemini/agents/*.md + let mut files = Vec::new(); + let agents_dir = self.base_dir().join("agents"); + if let Ok(entries) = std::fs::read_dir(&agents_dir) { for entry in entries.flatten() { let p = entry.path(); - if p.extension().is_some_and(|e| e == "toml") { + if p.extension().is_some_and(|e| e == "md") { files.push(p); } } @@ -166,6 +171,10 @@ impl AgentAdapter for GeminiAdapter { vec![".gemini/settings.json".into()] } + fn project_subagent_patterns(&self) -> Vec { + vec![".gemini/agents/*.md".into()] + } + fn project_ignore_patterns(&self) -> Vec { vec![".geminiignore".into()] } @@ -425,4 +434,35 @@ mod tests { let plugins = adapter.read_plugins(); assert!(plugins[0].enabled, "last rule is enable, should be enabled"); } + + #[test] + fn test_gemini_subagent_methods() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = GeminiAdapter::with_home(tmp.path().to_path_buf()); + + assert!(adapter.global_subagent_files().is_empty()); + + let agents_dir = adapter.base_dir().join("agents"); + std::fs::create_dir_all(&agents_dir).unwrap(); + std::fs::write(agents_dir.join("reviewer.md"), "# reviewer").unwrap(); + std::fs::write(agents_dir.join("notes.txt"), "ignore me").unwrap(); + + let subagents = adapter.global_subagent_files(); + assert!(subagents.iter().any(|p| p.ends_with("agents/reviewer.md"))); + assert!( + !subagents.iter().any(|p| p.ends_with("notes.txt")), + "non-.md files in agents/ must be filtered" + ); + + let settings = adapter.global_settings_files(); + assert!( + !settings.iter().any(|p| p.ends_with("agents/reviewer.md")), + "agents/ moved to global_subagent_files; must not appear in settings" + ); + + assert_eq!( + adapter.project_subagent_patterns(), + vec![".gemini/agents/*.md".to_string()] + ); + } } diff --git a/crates/hk-core/src/adapter/opencode.rs b/crates/hk-core/src/adapter/opencode.rs index 22563e8..5a30088 100644 --- a/crates/hk-core/src/adapter/opencode.rs +++ b/crates/hk-core/src/adapter/opencode.rs @@ -242,22 +242,27 @@ impl AgentAdapter for OpencodeAdapter { // Includes the canonical config file plus the .jsonc variant (only one // exists per install, but listing both lets the scanner find either), // and every directory whose contents are user-configurable settings: - // - agents/*.md : agent definitions // - modes/*.md : agent mode definitions // - themes/*.json: UI themes (palette/styling JSON) - // tools/*.ts and plugins/*.ts are intentionally excluded — they are - // code, not settings, and have their own discovery paths. + // agents/*.md is exposed via `global_subagent_files` (Subagents + // category), not Settings. tools/*.ts and plugins/*.ts are + // intentionally excluded — they are code, not settings, and have their + // own discovery paths. let base = self.base_dir(); let mut files = vec![ self.mcp_config_path(), // opencode.json base.join("opencode.jsonc"), // jsonc variant ]; - files.extend(Self::files_with_ext(&base.join("agents"), "md")); files.extend(Self::files_with_ext(&base.join("modes"), "md")); files.extend(Self::files_with_ext(&base.join("themes"), "json")); files } + fn global_subagent_files(&self) -> Vec { + // ~/.config/opencode/agents/*.md + Self::files_with_ext(&self.base_dir().join("agents"), "md") + } + fn global_workflow_files(&self) -> Vec { Self::files_with_ext(&self.base_dir().join("commands"), "md") } @@ -293,6 +298,12 @@ impl AgentAdapter for OpencodeAdapter { vec![".opencode/commands/*.md".into()] } + fn project_subagent_patterns(&self) -> Vec { + // Project subagents: .opencode/agents/*.md + // (https://opencode.ai/docs/agents/). + vec![".opencode/agents/*.md".into()] + } + fn project_skill_dirs(&self) -> Vec { vec![".opencode/skills".into()] } @@ -455,7 +466,7 @@ mod tests { } #[test] - fn global_settings_and_workflows_include_configurable_subdirs() { + fn global_settings_workflows_and_subagents_include_configurable_subdirs() { let tmp = tempfile::tempdir().unwrap(); let base = tmp.path().join(".config/opencode"); for sub in ["agents", "modes", "themes", "commands", "tools"] { @@ -473,22 +484,36 @@ mod tests { let adapter = OpencodeAdapter::with_home(tmp.path().to_path_buf()); let settings = adapter.global_settings_files(); let workflows = adapter.global_workflow_files(); + let subagents = adapter.global_subagent_files(); - // Three subdir kinds plus the two top-level config paths. - assert!(settings.iter().any(|p| p.ends_with("agents/reviewer.md"))); + // Settings holds modes/, themes/, and the top-level configs — agents/ + // moved to its own Subagents category. assert!(settings.iter().any(|p| p.ends_with("modes/build.md"))); assert!(settings.iter().any(|p| p.ends_with("themes/dark.json"))); assert!(settings.iter().any(|p| p.ends_with("opencode.json"))); assert!(settings.iter().any(|p| p.ends_with("opencode.jsonc"))); + assert!( + !settings.iter().any(|p| p.ends_with("agents/reviewer.md")), + "agents/ must NOT appear under settings anymore — moved to global_subagent_files" + ); + + // Subagents pulls from agents/ only, with extension filtering. + assert!(subagents.iter().any(|p| p.ends_with("agents/reviewer.md"))); + assert!( + !subagents.iter().any(|p| p.ends_with("notes.txt")), + "files with non-md extensions in agents/ must be filtered" + ); - // Code-bearing dirs and non-matching extensions are excluded. + // Code-bearing dirs and stray non-.md files in agents/ are excluded + // from settings (the latter never were settings; double-check it + // didn't sneak in via the moved scan). assert!( !settings.iter().any(|p| p.ends_with("tools/lint.ts")), "tools/ holds code, must not be in settings" ); assert!( - !settings.iter().any(|p| p.ends_with("notes.txt")), - "files with non-md extensions in agents/ must be filtered" + !settings.iter().any(|p| p.ends_with("agents/notes.txt")), + "agents/notes.txt must not appear in settings" ); // Workflows still flows through commands/. @@ -517,6 +542,12 @@ mod tests { vec![".opencode/commands/*.md".to_string()] ); + // Subagents: .opencode/agents/*.md. + assert_eq!( + adapter.project_subagent_patterns(), + vec![".opencode/agents/*.md".to_string()] + ); + // MCP project config sits inside the same opencode.json, not a // separate .mcp.json — distinct from Claude's split-file convention. assert_eq!( diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 0708b44..e87f756 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -2628,6 +2628,103 @@ mod config_tests { ); } + #[test] + fn test_scan_agent_configs_subagents_global_and_project() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + + let global_agents = home.join(".claude/agents"); + fs::create_dir_all(&global_agents).unwrap(); + fs::write(global_agents.join("reviewer.md"), "# global reviewer").unwrap(); + // Filtered (wrong extension). + fs::write(global_agents.join("scratch.txt"), "ignore").unwrap(); + + let project = home.join("myproject"); + let project_agents = project.join(".claude/agents"); + fs::create_dir_all(&project_agents).unwrap(); + fs::write(project_agents.join("planner.md"), "# project planner").unwrap(); + + let adapter = ClaudeAdapter::with_home(home.to_path_buf()); + let projects = vec![( + "myproject".to_string(), + project.to_string_lossy().to_string(), + )]; + let configs = scan_agent_configs(&adapter, &projects); + + let subagents: Vec<_> = configs + .iter() + .filter(|c| c.category == ConfigCategory::Subagents) + .collect(); + assert_eq!(subagents.len(), 2, "expected one global + one project subagent"); + + let global = subagents + .iter() + .find(|c| matches!(c.scope, ConfigScope::Global)) + .expect("global subagent missing"); + assert_eq!(global.file_name, "reviewer.md"); + + let project_entry = subagents + .iter() + .find(|c| matches!(&c.scope, ConfigScope::Project { .. })) + .expect("project subagent missing"); + assert_eq!(project_entry.file_name, "planner.md"); + + // Settings must not contain agent files anymore — guards against the + // pre-PR behavior where global agents/*.md leaked into Settings. + let settings: Vec<_> = configs + .iter() + .filter(|c| c.category == ConfigCategory::Settings) + .collect(); + assert!( + settings.iter().all(|c| !c.path.contains("/agents/")), + "agent definition files must not appear under Settings category" + ); + } + + #[test] + fn test_scan_agent_configs_subagents_codex_toml() { + use crate::adapter::codex::CodexAdapter; + + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + + let global_agents = home.join(".codex/agents"); + fs::create_dir_all(&global_agents).unwrap(); + fs::write(global_agents.join("reviewer.toml"), "name = \"reviewer\"").unwrap(); + + let project = home.join("myproject"); + let project_agents = project.join(".codex/agents"); + fs::create_dir_all(&project_agents).unwrap(); + fs::write(project_agents.join("planner.toml"), "name = \"planner\"").unwrap(); + + let adapter = CodexAdapter::with_home(home.to_path_buf()); + let projects = vec![( + "myproject".to_string(), + project.to_string_lossy().to_string(), + )]; + let configs = scan_agent_configs(&adapter, &projects); + + let subagents: Vec<_> = configs + .iter() + .filter(|c| c.category == ConfigCategory::Subagents) + .collect(); + assert_eq!( + subagents.len(), + 2, + "Codex .toml subagents must be scanned at both global and project scope" + ); + assert!( + subagents.iter().any(|c| c.file_name == "reviewer.toml" + && matches!(c.scope, ConfigScope::Global)), + "reviewer.toml must be scoped Global" + ); + assert!( + subagents.iter().any(|c| c.file_name == "planner.toml" + && matches!(&c.scope, ConfigScope::Project { .. })), + "planner.toml must be scoped Project" + ); + } + #[test] fn test_parse_skill_frontmatter_with_bins_inline() { let content = "---\nname: wecomcli-send\ndescription: Send messages\nbins: [\"wecom-cli\"]\n---\nBody"; diff --git a/crates/hk-desktop/src/commands/agents.rs b/crates/hk-desktop/src/commands/agents.rs index 7307483..a3ebd16 100644 --- a/crates/hk-desktop/src/commands/agents.rs +++ b/crates/hk-desktop/src/commands/agents.rs @@ -96,6 +96,7 @@ pub fn list_agent_configs(state: State) -> Result, Hk let category = match category_str.as_str() { "rules" => ConfigCategory::Rules, "memory" => ConfigCategory::Memory, + "subagents" => ConfigCategory::Subagents, "workflow" => ConfigCategory::Workflow, "ignore" => ConfigCategory::Ignore, _ => ConfigCategory::Settings, diff --git a/crates/hk-web/src/handlers/agents.rs b/crates/hk-web/src/handlers/agents.rs index b26dc24..5418d49 100644 --- a/crates/hk-web/src/handlers/agents.rs +++ b/crates/hk-web/src/handlers/agents.rs @@ -123,6 +123,7 @@ pub async fn list_agent_configs( let category = match category_str.as_str() { "rules" => ConfigCategory::Rules, "memory" => ConfigCategory::Memory, + "subagents" => ConfigCategory::Subagents, "workflow" => ConfigCategory::Workflow, "ignore" => ConfigCategory::Ignore, _ => ConfigCategory::Settings, From 5cde92973606a85f86b711543f940a916afc7420 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 8 May 2026 15:03:18 +0300 Subject: [PATCH 3/5] refactor(adapter): extract files_with_ext helper as iterator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote OpenCode's previously-private `Self::files_with_ext` to a module-level `pub(crate) fn files_with_ext(dir, ext) -> impl Iterator<...>` in adapter/mod.rs. All 6 adapters' new `global_subagent_files` use it, and OpenCode's 4 existing callsites migrate too — eliminating duplication of the read_dir + extension-filter pattern in subagent paths. Returning `impl Iterator` (not Vec) lets callers chain extra predicates without paying a second allocation. Copilot benefits directly: its `*.agent.md` stem filter is now a single `.filter(...).collect()` instead of `.into_iter().filter(...).collect()` on a pre-materialized Vec. Simple callers add one `.collect()`; `Vec::extend` callers work unchanged (extend accepts IntoIterator). Pre-existing same-shape loops elsewhere (claude commands/output-styles, gemini commands/policies, copilot hooks, codex memories) are intentionally left for a separate cleanup PR — tracked in roadmap. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/hk-core/src/adapter/claude.rs | 12 +----------- crates/hk-core/src/adapter/codex.rs | 12 +----------- crates/hk-core/src/adapter/copilot.rs | 22 +++++++--------------- crates/hk-core/src/adapter/cursor.rs | 12 +----------- crates/hk-core/src/adapter/gemini.rs | 12 +----------- crates/hk-core/src/adapter/mod.rs | 24 +++++++++++++++++++++++- crates/hk-core/src/adapter/opencode.rs | 19 ++++--------------- 7 files changed, 38 insertions(+), 75 deletions(-) diff --git a/crates/hk-core/src/adapter/claude.rs b/crates/hk-core/src/adapter/claude.rs index 13fcab7..d67017d 100644 --- a/crates/hk-core/src/adapter/claude.rs +++ b/crates/hk-core/src/adapter/claude.rs @@ -242,17 +242,7 @@ impl AgentAdapter for ClaudeAdapter { fn global_subagent_files(&self) -> Vec { // ~/.claude/agents/*.md - let mut files = Vec::new(); - let agents_dir = self.base_dir().join("agents"); - if let Ok(entries) = std::fs::read_dir(&agents_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } - } - files + super::files_with_ext(&self.base_dir().join("agents"), "md").collect() } fn project_markers(&self) -> Vec { diff --git a/crates/hk-core/src/adapter/codex.rs b/crates/hk-core/src/adapter/codex.rs index 2c858a0..a27fbf6 100644 --- a/crates/hk-core/src/adapter/codex.rs +++ b/crates/hk-core/src/adapter/codex.rs @@ -82,17 +82,7 @@ impl AgentAdapter for CodexAdapter { fn global_subagent_files(&self) -> Vec { // ~/.codex/agents/*.toml (Codex CLI subagents) // Source: https://developers.openai.com/codex/subagents - let mut files = Vec::new(); - let agents_dir = self.base_dir().join("agents"); - if let Ok(entries) = std::fs::read_dir(&agents_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "toml") { - files.push(p); - } - } - } - files + super::files_with_ext(&self.base_dir().join("agents"), "toml").collect() } fn global_memory_files(&self) -> Vec { diff --git a/crates/hk-core/src/adapter/copilot.rs b/crates/hk-core/src/adapter/copilot.rs index 2cb38e8..0c1b3b8 100644 --- a/crates/hk-core/src/adapter/copilot.rs +++ b/crates/hk-core/src/adapter/copilot.rs @@ -170,21 +170,13 @@ impl AgentAdapter for CopilotAdapter { // Per Copilot docs the canonical naming is `.agent.md`; require // the `.agent` segment so plain `.md` notes left in this dir aren't // misclassified as subagents. - let mut files = Vec::new(); - let agents_dir = self.base_dir().join("agents"); - if let Ok(entries) = std::fs::read_dir(&agents_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") - && p.file_stem() - .and_then(|s| s.to_str()) - .is_some_and(|stem| stem.ends_with(".agent")) - { - files.push(p); - } - } - } - files + super::files_with_ext(&self.base_dir().join("agents"), "md") + .filter(|p| { + p.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|stem| stem.ends_with(".agent")) + }) + .collect() } fn project_markers(&self) -> Vec { diff --git a/crates/hk-core/src/adapter/cursor.rs b/crates/hk-core/src/adapter/cursor.rs index 76541af..5f6b2ab 100644 --- a/crates/hk-core/src/adapter/cursor.rs +++ b/crates/hk-core/src/adapter/cursor.rs @@ -165,17 +165,7 @@ impl AgentAdapter for CursorAdapter { fn global_subagent_files(&self) -> Vec { // ~/.cursor/agents/*.md - let mut files = Vec::new(); - let agents_dir = self.base_dir().join("agents"); - if let Ok(entries) = std::fs::read_dir(&agents_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } - } - files + super::files_with_ext(&self.base_dir().join("agents"), "md").collect() } fn project_markers(&self) -> Vec { diff --git a/crates/hk-core/src/adapter/gemini.rs b/crates/hk-core/src/adapter/gemini.rs index 6794985..7b7cd5f 100644 --- a/crates/hk-core/src/adapter/gemini.rs +++ b/crates/hk-core/src/adapter/gemini.rs @@ -142,17 +142,7 @@ impl AgentAdapter for GeminiAdapter { fn global_subagent_files(&self) -> Vec { // ~/.gemini/agents/*.md - let mut files = Vec::new(); - let agents_dir = self.base_dir().join("agents"); - if let Ok(entries) = std::fs::read_dir(&agents_dir) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } - } - files + super::files_with_ext(&self.base_dir().join("agents"), "md").collect() } fn project_markers(&self) -> Vec { diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 04c59c1..e93a943 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -9,7 +9,29 @@ pub mod opencode; pub mod windsurf; use crate::models::ConfigScope; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +/// Return every file directly inside `dir` whose extension equals `ext` +/// (case-sensitive, no leading dot). Missing or unreadable directories +/// yield an empty iterator — adapters use this for "scan a fixed subdir" +/// listings (subagent / mode / theme / command files etc.) and rely on +/// the silent-empty behavior so a missing optional dir isn't an error. +/// +/// Returns an iterator (not a Vec) so callers that want to chain extra +/// predicates pay only one allocation in `.collect()`. Callers that just +/// need the full list write `.collect()` once. +pub(crate) fn files_with_ext<'a>( + dir: &'a Path, + ext: &'a str, +) -> impl Iterator + 'a { + std::fs::read_dir(dir) + .ok() + .into_iter() + .flatten() // Option → entries (or none) + .flatten() // Result → DirEntry (skip Err) + .map(|entry| entry.path()) + .filter(move |path| path.extension().is_some_and(|e| e == ext)) +} /// Represents an MCP server entry parsed from an agent's config #[derive(Debug, Clone)] diff --git a/crates/hk-core/src/adapter/opencode.rs b/crates/hk-core/src/adapter/opencode.rs index 5a30088..967d157 100644 --- a/crates/hk-core/src/adapter/opencode.rs +++ b/crates/hk-core/src/adapter/opencode.rs @@ -39,17 +39,6 @@ impl OpencodeAdapter { .ok() } - fn files_with_ext(dir: &Path, ext: &str) -> Vec { - let Ok(entries) = std::fs::read_dir(dir) else { - return vec![]; - }; - entries - .flatten() - .map(|entry| entry.path()) - .filter(|path| path.extension().is_some_and(|e| e == ext)) - .collect() - } - fn plugin_name(path: &Path) -> String { let file_name = path .file_name() @@ -253,18 +242,18 @@ impl AgentAdapter for OpencodeAdapter { self.mcp_config_path(), // opencode.json base.join("opencode.jsonc"), // jsonc variant ]; - files.extend(Self::files_with_ext(&base.join("modes"), "md")); - files.extend(Self::files_with_ext(&base.join("themes"), "json")); + files.extend(super::files_with_ext(&base.join("modes"), "md")); + files.extend(super::files_with_ext(&base.join("themes"), "json")); files } fn global_subagent_files(&self) -> Vec { // ~/.config/opencode/agents/*.md - Self::files_with_ext(&self.base_dir().join("agents"), "md") + super::files_with_ext(&self.base_dir().join("agents"), "md").collect() } fn global_workflow_files(&self) -> Vec { - Self::files_with_ext(&self.base_dir().join("commands"), "md") + super::files_with_ext(&self.base_dir().join("commands"), "md").collect() } fn project_markers(&self) -> Vec { From 99aa7e594567ac8256acd7358302964c0d0dd113 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 8 May 2026 15:22:40 +0300 Subject: [PATCH 4/5] feat(ui): render Subagents config category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the new "subagents" category through the frontend so the backend's ConfigCategory::Subagents files (claude/codex/cursor/gemini/copilot/opencode agent persona definitions) actually render in the UI. Without this commit agents/*.md files would be silently dropped after backend migration — agent-detail.tsx:84 only includes files whose category appears in CATEGORY_ORDER. Five touch points across four files: TypeScript union, label record, render order, icon map, anchor rail catalog. All purely declarative — the existing render pipeline iterates these tables, so adding entries auto-wires the new section without logic changes. Position: subagents sits between rules and memory in both CATEGORY_ORDER and SECTION_CATALOG — semantically adjacent to rules (both define agent behavior; memory is accumulated state). Icon: lucide-react Bot. Closes the end-to-end feature originally split as a separate PR2. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/agents/agent-detail.tsx | 1 + src/components/agents/config-section.tsx | 2 ++ src/components/agents/section-anchor-rail.tsx | 1 + src/lib/types.ts | 2 ++ 4 files changed, 6 insertions(+) diff --git a/src/components/agents/agent-detail.tsx b/src/components/agents/agent-detail.tsx index a74469b..b974e61 100644 --- a/src/components/agents/agent-detail.tsx +++ b/src/components/agents/agent-detail.tsx @@ -20,6 +20,7 @@ const CATEGORY_ORDER: ConfigCategory[] = [ "settings", "workflow", "rules", + "subagents", "memory", "ignore", ]; diff --git a/src/components/agents/config-section.tsx b/src/components/agents/config-section.tsx index 0cb71ea..722fef5 100644 --- a/src/components/agents/config-section.tsx +++ b/src/components/agents/config-section.tsx @@ -1,4 +1,5 @@ import { + Bot, Brain, ChevronDown, ChevronRight, @@ -17,6 +18,7 @@ import { ConfigFileEntry } from "./config-file-entry"; const CATEGORY_ICONS: Record = { rules: FileText, memory: Brain, + subagents: Bot, settings: Settings, workflow: Workflow, ignore: EyeOff, diff --git a/src/components/agents/section-anchor-rail.tsx b/src/components/agents/section-anchor-rail.tsx index 4f3608f..733ccc3 100644 --- a/src/components/agents/section-anchor-rail.tsx +++ b/src/components/agents/section-anchor-rail.tsx @@ -11,6 +11,7 @@ const SECTION_CATALOG: SectionAnchor[] = [ { id: "section-settings", label: "Settings" }, { id: "section-workflow", label: "Workflow" }, { id: "section-rules", label: "Rules" }, + { id: "section-subagents", label: "Subagents" }, { id: "section-memory", label: "Memory" }, { id: "section-ignore", label: "Ignore" }, { id: "section-custom", label: "Custom" }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 9b29292..0979280 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -195,6 +195,7 @@ export interface AgentInfo { export type ConfigCategory = | "rules" | "memory" + | "subagents" | "settings" | "workflow" | "ignore"; @@ -246,6 +247,7 @@ export interface AgentDetail { export const CONFIG_CATEGORY_LABELS: Record = { rules: "Rules", memory: "Memory", + subagents: "Subagents", settings: "Settings", workflow: "Workflows", ignore: "Ignore", From 21157696c83ac0da4ba77761103a4ba54087bc5b Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 8 May 2026 18:34:26 +0300 Subject: [PATCH 5/5] refactor: dedupe pre-existing category-table duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small refactors that close pre-existing duplication this PR's earlier commits perpetuated rather than introduced. Frontend: introduce CONFIG_CATEGORY_ORDER as the single source of truth for ConfigCategory render order. agent-detail.tsx and section-anchor-rail.tsx both now derive from it (the rail composes ids/labels via map). Side benefit: rail label "Workflow" → "Workflows", aligning with the section header (which already used CONFIG_CATEGORY_LABELS). Backend: add `impl FromStr for ConfigCategory` plus a round-trip parity test (`as_str().parse() == Ok(self)` for every variant + unknown-string negative). hk-web and hk-desktop custom-config parsers replace their identical 6-arm match with `.parse().unwrap_or(Settings)`. The Err policy is documented inline so callsite-level fallback intent stays explicit. Net: -34 / +70 lines (most of the +70 is the FromStr impl + parity test). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/hk-core/src/models.rs | 39 +++++++++++++++++++ crates/hk-desktop/src/commands/agents.rs | 11 ++---- crates/hk-web/src/handlers/agents.rs | 11 ++---- src/components/agents/agent-detail.tsx | 14 ++----- src/components/agents/section-anchor-rail.tsx | 19 +++++---- src/lib/types.ts | 12 ++++++ 6 files changed, 72 insertions(+), 34 deletions(-) diff --git a/crates/hk-core/src/models.rs b/crates/hk-core/src/models.rs index 1bf58e0..d9cd845 100644 --- a/crates/hk-core/src/models.rs +++ b/crates/hk-core/src/models.rs @@ -404,6 +404,23 @@ impl ConfigCategory { } } +impl std::str::FromStr for ConfigCategory { + /// Returns `Err` for unknown strings; callers decide the fallback policy + /// (existing custom-config parsers do `.parse().unwrap_or(Settings)`). + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "rules" => Ok(Self::Rules), + "memory" => Ok(Self::Memory), + "subagents" => Ok(Self::Subagents), + "settings" => Ok(Self::Settings), + "workflow" => Ok(Self::Workflow), + "ignore" => Ok(Self::Ignore), + _ => Err(()), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ConfigScope { @@ -517,6 +534,28 @@ mod tests { assert!(ConfigCategory::Workflow.order() < ConfigCategory::Ignore.order()); } + #[test] + fn test_config_category_from_str_round_trip() { + // Every variant's `as_str()` must parse back to itself — this is the + // sole guard that catches a missed arm when adding a new variant. + for cat in [ + ConfigCategory::Rules, + ConfigCategory::Memory, + ConfigCategory::Subagents, + ConfigCategory::Settings, + ConfigCategory::Workflow, + ConfigCategory::Ignore, + ] { + assert_eq!( + cat.as_str().parse::(), + Ok(cat), + "{:?} round-trip failed", + cat + ); + } + assert!("not-a-category".parse::().is_err()); + } + #[test] fn test_config_scope_serialization() { let global = ConfigScope::Global; diff --git a/crates/hk-desktop/src/commands/agents.rs b/crates/hk-desktop/src/commands/agents.rs index a3ebd16..66cb9cc 100644 --- a/crates/hk-desktop/src/commands/agents.rs +++ b/crates/hk-desktop/src/commands/agents.rs @@ -93,14 +93,9 @@ pub fn list_agent_configs(state: State) -> Result, Hk if existing_paths.contains(&canonical) { continue; } - let category = match category_str.as_str() { - "rules" => ConfigCategory::Rules, - "memory" => ConfigCategory::Memory, - "subagents" => ConfigCategory::Subagents, - "workflow" => ConfigCategory::Workflow, - "ignore" => ConfigCategory::Ignore, - _ => ConfigCategory::Settings, - }; + let category = category_str + .parse::() + .unwrap_or(ConfigCategory::Settings); let scope = scope_json .as_deref() .and_then(|s| serde_json::from_str::(s).ok()) diff --git a/crates/hk-web/src/handlers/agents.rs b/crates/hk-web/src/handlers/agents.rs index 5418d49..412f1ed 100644 --- a/crates/hk-web/src/handlers/agents.rs +++ b/crates/hk-web/src/handlers/agents.rs @@ -120,14 +120,9 @@ pub async fn list_agent_configs( if existing_paths.contains(&canonical) { continue; } - let category = match category_str.as_str() { - "rules" => ConfigCategory::Rules, - "memory" => ConfigCategory::Memory, - "subagents" => ConfigCategory::Subagents, - "workflow" => ConfigCategory::Workflow, - "ignore" => ConfigCategory::Ignore, - _ => ConfigCategory::Settings, - }; + let category = category_str + .parse::() + .unwrap_or(ConfigCategory::Settings); let scope = scope_json .as_deref() .and_then(|s| serde_json::from_str::(s).ok()) diff --git a/src/components/agents/agent-detail.tsx b/src/components/agents/agent-detail.tsx index b974e61..5efb6eb 100644 --- a/src/components/agents/agent-detail.tsx +++ b/src/components/agents/agent-detail.tsx @@ -6,6 +6,7 @@ import { isDesktop } from "@/lib/transport"; import { agentDisplayName, type ConfigCategory, + CONFIG_CATEGORY_ORDER, type ConfigScope, type ExtensionCounts, scopeLabel, @@ -16,15 +17,6 @@ import { ConfigSection } from "./config-section"; import { ExtensionsSummaryCard } from "./extensions-summary-card"; import { SectionAnchorRail } from "./section-anchor-rail"; -const CATEGORY_ORDER: ConfigCategory[] = [ - "settings", - "workflow", - "rules", - "subagents", - "memory", - "ignore", -]; - export function AgentDetail() { const agentDetails = useAgentConfigStore((s) => s.agentDetails); const selectedAgent = useAgentConfigStore((s) => s.selectedAgent); @@ -80,7 +72,7 @@ export function AgentDetail() { (f) => f.custom_id == null && matchesScope(f.scope), ); const byCategory = new Map(); - for (const cat of CATEGORY_ORDER) byCategory.set(cat, []); + for (const cat of CONFIG_CATEGORY_ORDER) byCategory.set(cat, []); for (const file of nonCustomFiles) { const list = byCategory.get(file.category); if (list) list.push(file); @@ -231,7 +223,7 @@ export function AgentDetail() { ) : ( <> - {CATEGORY_ORDER.map((cat) => { + {CONFIG_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 diff --git a/src/components/agents/section-anchor-rail.tsx b/src/components/agents/section-anchor-rail.tsx index 733ccc3..3c74b3f 100644 --- a/src/components/agents/section-anchor-rail.tsx +++ b/src/components/agents/section-anchor-rail.tsx @@ -1,4 +1,8 @@ import { useEffect, useState } from "react"; +import { + CONFIG_CATEGORY_LABELS, + CONFIG_CATEGORY_ORDER, +} from "@/lib/types"; interface SectionAnchor { id: string; @@ -6,14 +10,15 @@ interface SectionAnchor { } /** Catalog of every anchor the rail knows about. The rail filters this list - * down to sections that are actually rendered in the current page. */ + * down to sections that are actually rendered in the current page. The + * ConfigCategory portion is derived from `CONFIG_CATEGORY_ORDER` so a + * category added there auto-propagates here; `custom` and `extensions` are + * synthetic UI-only anchors that don't correspond to a ConfigCategory. */ const SECTION_CATALOG: SectionAnchor[] = [ - { id: "section-settings", label: "Settings" }, - { id: "section-workflow", label: "Workflow" }, - { id: "section-rules", label: "Rules" }, - { id: "section-subagents", label: "Subagents" }, - { id: "section-memory", label: "Memory" }, - { id: "section-ignore", label: "Ignore" }, + ...CONFIG_CATEGORY_ORDER.map((c) => ({ + id: `section-${c}`, + label: CONFIG_CATEGORY_LABELS[c], + })), { id: "section-custom", label: "Custom" }, { id: "section-extensions", label: "Extensions" }, ]; diff --git a/src/lib/types.ts b/src/lib/types.ts index 0979280..b8c9d8c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -253,6 +253,18 @@ export const CONFIG_CATEGORY_LABELS: Record = { ignore: "Ignore", }; +/** Canonical visual order for config categories across all UI surfaces. + * Single source of truth — the agent detail render order and the + * section-anchor rail catalog both derive from this. */ +export const CONFIG_CATEGORY_ORDER: ConfigCategory[] = [ + "settings", + "workflow", + "rules", + "subagents", + "memory", + "ignore", +]; + export interface FileEntry { name: string; path: string;