Skip to content

Commit 552ea20

Browse files
echobtfactorydroid
andauthored
feat: add support for agent.md format (.agents/, .agent/) for agents and skills (#263)
This commit adds support for the https://agent.md/ specification format alongside the traditional .cortex/ paths for discovering custom agents and skills. Changes: - Custom agents are now searched in priority order: 1. .agents/ (project, agent.md format) 2. .agent/ (project, agent.md format) 3. .cortex/agents/ (project, traditional) 4. ~/.cortex/agents/ (global personal) 5. ~/.config/cortex/agents/ (global config) - Skills are now searched in priority order: 1. ./SKILL.md (local) 2. .agents/<skill>/ (project, agent.md format) 3. .agent/<skill>/ (project, agent.md format) 4. .cortex/skills/ (project, traditional) 5. ~/.cortex/skills/ (personal) - Updated help messages to reflect new directory options - Updated tests to verify new path ordering - First match wins for duplicates (project > personal) Co-authored-by: Droid Agent <droid@factory.ai>
1 parent ff00fc2 commit 552ea20

5 files changed

Lines changed: 198 additions & 62 deletions

File tree

cortex-agents/src/custom/loader.rs

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,30 @@ impl CustomAgentLoader {
2929

3030
/// Create a loader with default paths.
3131
///
32-
/// Default paths:
33-
/// 1. `.cortex/agents/` (project-local)
34-
/// 2. `~/.config/cortex/agents/` (global)
32+
/// Default paths (in priority order):
33+
/// 1. `.agents/` (project-local, https://agent.md/ compatible)
34+
/// 2. `.agent/` (project-local, https://agent.md/ compatible)
35+
/// 3. `.cortex/agents/` (project-local)
36+
/// 4. `~/.cortex/agents/` (global personal)
37+
/// 5. `~/.config/cortex/agents/` (global config)
3538
pub fn with_default_paths(project_root: Option<&Path>) -> Self {
3639
let mut loader = Self::new();
3740

38-
// Project-local agents
41+
// Project-local agents (multiple formats supported)
3942
if let Some(root) = project_root {
43+
// Support https://agent.md/ format (.agents/ and .agent/)
44+
loader.search_paths.push(root.join(".agents"));
45+
loader.search_paths.push(root.join(".agent"));
46+
// Traditional .cortex/agents format
4047
loader.search_paths.push(root.join(".cortex/agents"));
4148
}
4249

43-
// Global agents
50+
// Global personal agents from ~/.cortex/agents/
51+
if let Some(home) = dirs::home_dir() {
52+
loader.search_paths.push(home.join(".cortex/agents"));
53+
}
54+
55+
// Global agents from config directory
4456
if let Some(config) = dirs::config_dir() {
4557
loader.search_paths.push(config.join("cortex/agents"));
4658
}
@@ -244,13 +256,31 @@ pub mod sync {
244256
}
245257

246258
/// Get default search directories.
259+
///
260+
/// Returns paths in priority order:
261+
/// 1. `.agents/` (project-local, https://agent.md/ compatible)
262+
/// 2. `.agent/` (project-local, https://agent.md/ compatible)
263+
/// 3. `.cortex/agents/` (project-local)
264+
/// 4. `~/.cortex/agents/` (global personal)
265+
/// 5. `~/.config/cortex/agents/` (global config)
247266
pub fn default_search_dirs(project_root: Option<&Path>) -> Vec<PathBuf> {
248267
let mut dirs = Vec::new();
249268

269+
// Project-local agents (multiple formats supported)
250270
if let Some(root) = project_root {
271+
// Support https://agent.md/ format (.agents/ and .agent/)
272+
dirs.push(root.join(".agents"));
273+
dirs.push(root.join(".agent"));
274+
// Traditional .cortex/agents format
251275
dirs.push(root.join(".cortex/agents"));
252276
}
253277

278+
// Global personal agents from ~/.cortex/agents/
279+
if let Some(home) = dirs::home_dir() {
280+
dirs.push(home.join(".cortex/agents"));
281+
}
282+
283+
// Global agents from config directory
254284
if let Some(config) = dirs::config_dir() {
255285
dirs.push(config.join("cortex/agents"));
256286
}
@@ -305,8 +335,14 @@ No closing delimiter"#;
305335
let loader = CustomAgentLoader::with_default_paths(Some(temp.path()));
306336

307337
let paths = loader.search_paths();
308-
assert!(!paths.is_empty());
309-
assert!(paths[0].ends_with(".cortex/agents"));
338+
// Should have at least 3 paths: .agents/, .agent/, .cortex/agents/
339+
assert!(paths.len() >= 3);
340+
// First path should be .agents/ (agent.md format)
341+
assert!(paths[0].ends_with(".agents"));
342+
// Second path should be .agent/ (agent.md format singular)
343+
assert!(paths[1].ends_with(".agent"));
344+
// Third path should be .cortex/agents/ (traditional format)
345+
assert!(paths[2].ends_with(".cortex/agents"));
310346
}
311347

312348
#[tokio::test]

cortex-cli/src/agent_cmd.rs

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -301,35 +301,62 @@ fn get_agents_dir() -> Result<PathBuf> {
301301
Ok(cortex_home.join("agents"))
302302
}
303303

304-
/// Get the project agents directory path.
305-
fn get_project_agents_dir() -> Option<PathBuf> {
306-
let cwd = std::env::current_dir().ok()?;
307-
let project_dir = cwd.join(".cortex").join("agents");
308-
if project_dir.exists() {
309-
Some(project_dir)
310-
} else {
311-
None
312-
}
304+
/// Get all project agents directories.
305+
///
306+
/// Returns directories in priority order:
307+
/// 1. `.agents/` (https://agent.md/ compatible)
308+
/// 2. `.agent/` (https://agent.md/ compatible)
309+
/// 3. `.cortex/agents/` (traditional format)
310+
fn get_project_agents_dirs() -> Vec<PathBuf> {
311+
let cwd = match std::env::current_dir() {
312+
Ok(p) => p,
313+
Err(_) => return Vec::new(),
314+
};
315+
316+
let candidates = [
317+
cwd.join(".agents"), // agent.md format
318+
cwd.join(".agent"), // agent.md format (singular)
319+
cwd.join(".cortex").join("agents"), // traditional format
320+
];
321+
322+
candidates.into_iter().filter(|p| p.exists()).collect()
313323
}
314324

315325
/// Load all agents from various sources.
316326
fn load_all_agents() -> Result<Vec<AgentInfo>> {
317327
let mut agents = Vec::new();
328+
let mut seen_names = std::collections::HashSet::new();
318329

319-
// Load built-in agents
320-
agents.extend(load_builtin_agents());
330+
// Load built-in agents first
331+
for agent in load_builtin_agents() {
332+
seen_names.insert(agent.name.clone());
333+
agents.push(agent);
334+
}
335+
336+
// Load project agents from multiple directories (.agents/, .agent/, .cortex/agents/)
337+
// Project agents take precedence over personal agents
338+
for project_dir in get_project_agents_dirs() {
339+
if let Ok(project_agents) = load_agents_from_dir(&project_dir, AgentSource::Project) {
340+
for agent in project_agents {
341+
if !seen_names.contains(&agent.name) {
342+
seen_names.insert(agent.name.clone());
343+
agents.push(agent);
344+
}
345+
}
346+
}
347+
}
321348

322349
// Load personal agents from ~/.cortex/agents/
323350
let personal_dir = get_agents_dir()?;
324351
if personal_dir.exists() {
325-
let personal_agents = load_agents_from_dir(&personal_dir, AgentSource::Personal)?;
326-
agents.extend(personal_agents);
327-
}
328-
329-
// Load project agents from .cortex/agents/
330-
if let Some(project_dir) = get_project_agents_dir() {
331-
let project_agents = load_agents_from_dir(&project_dir, AgentSource::Project)?;
332-
agents.extend(project_agents);
352+
if let Ok(personal_agents) = load_agents_from_dir(&personal_dir, AgentSource::Personal) {
353+
for agent in personal_agents {
354+
if !seen_names.contains(&agent.name) {
355+
seen_names.insert(agent.name.clone());
356+
agents.push(agent);
357+
}
358+
}
359+
}
333360
}
334361

335362
Ok(agents)

cortex-engine/src/commands.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,11 @@ impl CommandHandler for SkillsCommand {
575575

576576
if skills.is_empty() {
577577
return Ok(CommandResult::success(
578-
"No skills installed.\n\nTo add skills:\n- Personal: ~/.cortex/skills/<skill-name>/SKILL.md\n- Project: .cortex/skills/<skill-name>/SKILL.md",
578+
"No skills installed.\n\nTo add skills:\n\
579+
- .agents/<skill-name>/SKILL.md (project, agent.md format)\n\
580+
- .agent/<skill-name>/SKILL.md (project, agent.md format)\n\
581+
- .cortex/skills/<skill-name>/SKILL.md (project)\n\
582+
- ~/.cortex/skills/<skill-name>/SKILL.md (personal)",
579583
));
580584
}
581585

cortex-engine/src/skills.rs

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ impl Skill {
122122
pub enum SkillSource {
123123
/// Personal skill from ~/.cortex/skills/
124124
Personal,
125-
/// Project skill from .cortex/skills/
125+
/// Project skill from .cortex/skills/, .agents/, or .agent/
126126
Project,
127127
/// Plugin-provided skill
128128
Plugin,
@@ -144,22 +144,39 @@ pub struct SkillRegistry {
144144
skills: RwLock<HashMap<String, Skill>>,
145145
/// Personal skills directory (~/.cortex/skills/).
146146
personal_dir: PathBuf,
147-
/// Project skills directory (.cortex/skills/).
148-
project_dir: Option<PathBuf>,
147+
/// Project skills directories (.agents/, .agent/, .cortex/skills/).
148+
project_dirs: Vec<PathBuf>,
149149
/// Plugin skills directories.
150150
plugin_dirs: Vec<PathBuf>,
151151
}
152152

153153
impl SkillRegistry {
154154
/// Create a new skill registry.
155+
///
156+
/// Skills are searched in the following order:
157+
/// 1. `.agents/` (project, https://agent.md/ compatible)
158+
/// 2. `.agent/` (project, https://agent.md/ compatible)
159+
/// 3. `.cortex/skills/` (project, traditional format)
160+
/// 4. `~/.cortex/skills/` (personal)
161+
/// 5. Plugin directories
155162
pub fn new(cortex_home: &Path, project_root: Option<&Path>) -> Self {
156163
let personal_dir = cortex_home.join("skills");
157-
let project_dir = project_root.map(|p| p.join(".cortex").join("skills"));
164+
165+
// Support multiple project skill directories
166+
let project_dirs = if let Some(root) = project_root {
167+
vec![
168+
root.join(".agents"), // agent.md format
169+
root.join(".agent"), // agent.md format (singular)
170+
root.join(".cortex").join("skills"), // traditional format
171+
]
172+
} else {
173+
Vec::new()
174+
};
158175

159176
Self {
160177
skills: RwLock::new(HashMap::new()),
161178
personal_dir,
162-
project_dir,
179+
project_dirs,
163180
plugin_dirs: Vec::new(),
164181
}
165182
}
@@ -172,26 +189,43 @@ impl SkillRegistry {
172189
/// Scan and load all available skills.
173190
pub async fn scan(&self) -> Result<Vec<Skill>> {
174191
let mut all_skills = Vec::new();
192+
let mut seen_names = std::collections::HashSet::new();
193+
194+
// Scan project skills first (from .agents/, .agent/, .cortex/skills/)
195+
// Project skills take precedence
196+
for project_dir in &self.project_dirs {
197+
if project_dir.exists() {
198+
let skills = self.scan_directory(project_dir, SkillSource::Project)?;
199+
for skill in skills {
200+
if !seen_names.contains(&skill.metadata.name) {
201+
seen_names.insert(skill.metadata.name.clone());
202+
all_skills.push(skill);
203+
}
204+
}
205+
}
206+
}
175207

176208
// Scan personal skills
177209
if self.personal_dir.exists() {
178210
let skills = self.scan_directory(&self.personal_dir, SkillSource::Personal)?;
179-
all_skills.extend(skills);
180-
}
181-
182-
// Scan project skills
183-
if let Some(ref project_dir) = self.project_dir
184-
&& project_dir.exists()
185-
{
186-
let skills = self.scan_directory(project_dir, SkillSource::Project)?;
187-
all_skills.extend(skills);
211+
for skill in skills {
212+
if !seen_names.contains(&skill.metadata.name) {
213+
seen_names.insert(skill.metadata.name.clone());
214+
all_skills.push(skill);
215+
}
216+
}
188217
}
189218

190219
// Scan plugin skills
191220
for plugin_dir in &self.plugin_dirs {
192221
if plugin_dir.exists() {
193222
let skills = self.scan_directory(plugin_dir, SkillSource::Plugin)?;
194-
all_skills.extend(skills);
223+
for skill in skills {
224+
if !seen_names.contains(&skill.metadata.name) {
225+
seen_names.insert(skill.metadata.name.clone());
226+
all_skills.push(skill);
227+
}
228+
}
195229
}
196230
}
197231

0 commit comments

Comments
 (0)