Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 44 additions & 10 deletions crates/hk-core/src/adapter/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -250,6 +240,11 @@ impl AgentAdapter for ClaudeAdapter {
files
}

fn global_subagent_files(&self) -> Vec<PathBuf> {
// ~/.claude/agents/*.md
super::files_with_ext(&self.base_dir().join("agents"), "md").collect()
}

fn project_markers(&self) -> Vec<ProjectMarker> {
vec![
ProjectMarker::Dir(".claude"),
Expand All @@ -273,6 +268,10 @@ impl AgentAdapter for ClaudeAdapter {
]
}

fn project_subagent_patterns(&self) -> Vec<String> {
vec![".claude/agents/*.md".into()]
}

fn project_ignore_patterns(&self) -> Vec<String> {
vec![] // Claude Code does NOT have .claudeignore
}
Expand Down Expand Up @@ -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()]
);
}
}
38 changes: 38 additions & 0 deletions crates/hk-core/src/adapter/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ impl AgentAdapter for CodexAdapter {
]
}

fn global_subagent_files(&self) -> Vec<PathBuf> {
// ~/.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<PathBuf> {
let mut files = Vec::new();
let memories_dir = self.base_dir().join("memories");
Expand All @@ -105,6 +111,10 @@ impl AgentAdapter for CodexAdapter {
vec![".codex/config.toml".into()]
}

fn project_subagent_patterns(&self) -> Vec<String> {
vec![".codex/agents/*.toml".into()]
}

fn project_skill_dirs(&self) -> Vec<String> {
// Codex CLI scans .agents/skills from cwd up to the repo root.
// Source: https://developers.openai.com/codex/skills
Expand Down Expand Up @@ -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()]
);
}
}
74 changes: 64 additions & 10 deletions crates/hk-core/src/adapter/copilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -175,6 +165,20 @@ impl AgentAdapter for CopilotAdapter {
files
}

fn global_subagent_files(&self) -> Vec<PathBuf> {
// ~/.copilot/agents/*.agent.md
// Per Copilot docs the canonical naming is `<name>.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<ProjectMarker> {
vec![
ProjectMarker::File(".github/copilot-instructions.md"),
Expand All @@ -197,6 +201,10 @@ impl AgentAdapter for CopilotAdapter {
]
}

fn project_subagent_patterns(&self) -> Vec<String> {
vec![".github/agents/*.agent.md".into()]
}

fn project_ignore_patterns(&self) -> Vec<String> {
vec![".copilotignore".into()]
}
Expand Down Expand Up @@ -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()]
);
}
}
53 changes: 41 additions & 12 deletions crates/hk-core/src/adapter/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,22 +156,16 @@ impl AgentAdapter for CursorAdapter {
}

fn global_settings_files(&self) -> Vec<PathBuf> {
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<PathBuf> {
// ~/.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<ProjectMarker> {
Expand All @@ -198,6 +192,10 @@ impl AgentAdapter for CursorAdapter {
vec![".cursor/mcp.json".into()]
}

fn project_subagent_patterns(&self) -> Vec<String> {
vec![".cursor/agents/*.md".into()]
}

fn project_ignore_patterns(&self) -> Vec<String> {
vec![".cursorignore".into(), ".cursorindexingignore".into()]
}
Expand Down Expand Up @@ -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();
Expand Down
50 changes: 40 additions & 10 deletions crates/hk-core/src/adapter/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -150,6 +140,11 @@ impl AgentAdapter for GeminiAdapter {
files
}

fn global_subagent_files(&self) -> Vec<PathBuf> {
// ~/.gemini/agents/*.md
super::files_with_ext(&self.base_dir().join("agents"), "md").collect()
}

fn project_markers(&self) -> Vec<ProjectMarker> {
vec![ProjectMarker::Dir(".gemini")]
}
Expand All @@ -166,6 +161,10 @@ impl AgentAdapter for GeminiAdapter {
vec![".gemini/settings.json".into()]
}

fn project_subagent_patterns(&self) -> Vec<String> {
vec![".gemini/agents/*.md".into()]
}

fn project_ignore_patterns(&self) -> Vec<String> {
vec![".geminiignore".into()]
}
Expand Down Expand Up @@ -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()]
);
}
}
Loading
Loading