diff --git a/crates/hk-core/src/adapter/claude.rs b/crates/hk-core/src/adapter/claude.rs index d59a229..d67017d 100644 --- a/crates/hk-core/src/adapter/claude.rs +++ b/crates/hk-core/src/adapter/claude.rs @@ -217,16 +217,6 @@ 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) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } - } // ~/.claude/commands/*.md (legacy, still functional) let commands_dir = self.base_dir().join("commands"); if let Ok(entries) = std::fs::read_dir(&commands_dir) { @@ -250,6 +240,11 @@ impl AgentAdapter for ClaudeAdapter { files } + fn global_subagent_files(&self) -> Vec { + // ~/.claude/agents/*.md + super::files_with_ext(&self.base_dir().join("agents"), "md").collect() + } + fn project_markers(&self) -> Vec { vec![ ProjectMarker::Dir(".claude"), @@ -273,6 +268,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 +478,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..a27fbf6 100644 --- a/crates/hk-core/src/adapter/codex.rs +++ b/crates/hk-core/src/adapter/codex.rs @@ -79,6 +79,12 @@ impl AgentAdapter for CodexAdapter { ] } + fn global_subagent_files(&self) -> Vec { + // ~/.codex/agents/*.toml (Codex CLI subagents) + // Source: https://developers.openai.com/codex/subagents + super::files_with_ext(&self.base_dir().join("agents"), "toml").collect() + } + fn global_memory_files(&self) -> Vec { let mut files = Vec::new(); let memories_dir = self.base_dir().join("memories"); @@ -105,6 +111,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 +496,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..0c1b3b8 100644 --- a/crates/hk-core/src/adapter/copilot.rs +++ b/crates/hk-core/src/adapter/copilot.rs @@ -152,16 +152,6 @@ 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) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } - } // ~/.copilot/hooks/*.json let hooks_dir = self.base_dir().join("hooks"); if let Ok(entries) = std::fs::read_dir(&hooks_dir) { @@ -175,6 +165,20 @@ impl AgentAdapter for CopilotAdapter { 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. + 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 { vec![ ProjectMarker::File(".github/copilot-instructions.md"), @@ -197,6 +201,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 +480,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..5f6b2ab 100644 --- a/crates/hk-core/src/adapter/cursor.rs +++ b/crates/hk-core/src/adapter/cursor.rs @@ -156,22 +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 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 { @@ -198,6 +192,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 +290,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..7b7cd5f 100644 --- a/crates/hk-core/src/adapter/gemini.rs +++ b/crates/hk-core/src/adapter/gemini.rs @@ -127,16 +127,6 @@ 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) { - for entry in entries.flatten() { - let p = entry.path(); - if p.extension().is_some_and(|e| e == "md") { - files.push(p); - } - } - } // ~/.gemini/policies/*.toml let policies_dir = self.base_dir().join("policies"); if let Ok(entries) = std::fs::read_dir(&policies_dir) { @@ -150,6 +140,11 @@ impl AgentAdapter for GeminiAdapter { files } + fn global_subagent_files(&self) -> Vec { + // ~/.gemini/agents/*.md + super::files_with_ext(&self.base_dir().join("agents"), "md").collect() + } + fn project_markers(&self) -> Vec { vec![ProjectMarker::Dir(".gemini")] } @@ -166,6 +161,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 +424,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/mod.rs b/crates/hk-core/src/adapter/mod.rs index f57eaca..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)] @@ -170,6 +192,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 +215,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 +381,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/adapter/opencode.rs b/crates/hk-core/src/adapter/opencode.rs index 22563e8..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() @@ -242,24 +231,29 @@ 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.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 + 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 { @@ -293,6 +287,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 +455,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 +473,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 +531,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/models.rs b/crates/hk-core/src/models.rs index ac6a42e..d9cd845 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,27 @@ 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, + } + } +} + +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(()), } } } @@ -499,11 +519,43 @@ 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_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-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 99239b2..e87f756 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(), @@ -2623,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..66cb9cc 100644 --- a/crates/hk-desktop/src/commands/agents.rs +++ b/crates/hk-desktop/src/commands/agents.rs @@ -93,13 +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, - "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 b26dc24..412f1ed 100644 --- a/crates/hk-web/src/handlers/agents.rs +++ b/crates/hk-web/src/handlers/agents.rs @@ -120,13 +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, - "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 a74469b..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,14 +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", - "memory", - "ignore", -]; - export function AgentDetail() { const agentDetails = useAgentConfigStore((s) => s.agentDetails); const selectedAgent = useAgentConfigStore((s) => s.selectedAgent); @@ -79,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); @@ -230,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/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..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,13 +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-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 9b29292..b8c9d8c 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,11 +247,24 @@ export interface AgentDetail { export const CONFIG_CATEGORY_LABELS: Record = { rules: "Rules", memory: "Memory", + subagents: "Subagents", settings: "Settings", workflow: "Workflows", 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;