fix: accept markdown agent definitions with YAML frontmatter

Agent discovery now loads .md files with YAML frontmatter alongside .toml
files, matching the Claude Code agent definition convention. Markdown
agent files must have ----delimited YAML frontmatter with at least name
or description fields.

Key changes:
- parse_agent_frontmatter extracts name, description, model, model_reasoning_effort
- load_agents_from_roots_with_invalids collects both valid and invalid agents
- InvalidAgentConfig tracks rejected .md files with reason
- AgentCollection groups valid agents with invalid entries
- agents JSON output includes valid_count, invalid_count, invalid_agents
- Status is degraded when invalid agents exist

Generated with https://github.com/Yeachan-Heo/gajae-code
Co-authored-by: Gajae Code <dev@gajae-code.com>
This commit is contained in:
bellman
2026-06-04 23:57:33 +09:00
parent 453d8945bb
commit 58a30f6ab8
2 changed files with 180 additions and 35 deletions

View File

@@ -2147,7 +2147,7 @@ impl DefinitionSource {
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AgentSummary {
pub(crate) struct AgentSummary {
name: String,
description: Option<String>,
model: Option<String>,
@@ -2158,6 +2158,20 @@ struct AgentSummary {
path: Option<PathBuf>,
}
/// An agent definition file that could not be loaded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InvalidAgentConfig {
pub(crate) path: PathBuf,
pub(crate) reason: String,
}
/// Loaded agent definitions plus any invalid entries that were skipped.
#[derive(Debug, Clone, Default)]
pub(crate) struct AgentCollection {
pub(crate) agents: Vec<AgentSummary>,
pub(crate) invalid_agents: Vec<InvalidAgentConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillSummary {
name: String,
@@ -2494,8 +2508,8 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
match normalize_optional_args(args) {
None | Some("list") => {
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report_json(cwd, &agents))
let collection = load_agents_from_roots_with_invalids(&roots)?;
Ok(render_agents_report_json(cwd, &collection))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
@@ -2512,17 +2526,26 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}));
}
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
let filtered: Vec<_> = agents
let collection = load_agents_from_roots_with_invalids(&roots)?;
let filtered_agents: Vec<_> = collection
.agents
.into_iter()
.filter(|a| a.name.to_lowercase().contains(&filter))
.collect();
Ok(render_agents_report_json(cwd, &filtered))
let filtered_collection = AgentCollection {
agents: filtered_agents,
invalid_agents: collection.invalid_agents,
};
Ok(render_agents_report_json(cwd, &filtered_collection))
}
Some("show" | "info" | "describe") => {
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report_json_with_action(cwd, &agents, "show"))
let collection = load_agents_from_roots_with_invalids(&roots)?;
Ok(render_agents_report_json_with_action(
cwd,
&collection,
"show",
))
}
Some(args)
if args.starts_with("show ")
@@ -2553,8 +2576,9 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}));
}
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
let matched: Vec<_> = agents
let collection = load_agents_from_roots_with_invalids(&roots)?;
let matched: Vec<_> = collection
.agents
.into_iter()
.filter(|a| a.name.to_lowercase() == name)
.collect();
@@ -2571,7 +2595,15 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
"hint": "Run `claw agents list` to see available agents.",
}));
}
Ok(render_agents_report_json_with_action(cwd, &matched, "show"))
let matched_collection = AgentCollection {
agents: matched,
invalid_agents: collection.invalid_agents,
};
Ok(render_agents_report_json_with_action(
cwd,
&matched_collection,
"show",
))
}
Some("create") => Ok(render_agents_missing_argument_json("create", "agent_name")),
Some(args) if args.starts_with("create ") => {
@@ -3902,30 +3934,69 @@ fn push_unique_skill_root(
fn load_agents_from_roots(
roots: &[(DefinitionSource, PathBuf)],
) -> std::io::Result<Vec<AgentSummary>> {
let collection = load_agents_from_roots_with_invalids(roots)?;
Ok(collection.agents)
}
/// Load agent definitions from all roots, collecting both valid agents and
/// invalid entries (wrong extension, broken frontmatter, etc.).
fn load_agents_from_roots_with_invalids(
roots: &[(DefinitionSource, PathBuf)],
) -> std::io::Result<AgentCollection> {
let mut agents = Vec::new();
let mut invalid_agents = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
for (source, root) in roots {
let mut root_agents = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
if entry.path().extension().is_none_or(|ext| ext != "toml") {
continue;
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str());
match ext {
Some("toml") => {
let contents = fs::read_to_string(&path)?;
let fallback_name = path.file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
description: parse_toml_string(&contents, "description"),
model: parse_toml_string(&contents, "model"),
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
source: *source,
shadowed_by: None,
path: Some(path),
});
}
Some("md") => {
let contents = fs::read_to_string(&path)?;
let (name, description, model, reasoning_effort) =
parse_agent_frontmatter(&contents);
if name.is_none() && description.is_none() {
invalid_agents.push(InvalidAgentConfig {
path,
reason: "Markdown agent file has no YAML frontmatter with name or description fields".to_string(),
});
continue;
}
let fallback_name = path.file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: name.unwrap_or(fallback_name),
description,
model,
reasoning_effort,
source: *source,
shadowed_by: None,
path: Some(path),
});
}
_ => continue,
}
let contents = fs::read_to_string(entry.path())?;
let fallback_name = entry.path().file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
description: parse_toml_string(&contents, "description"),
model: parse_toml_string(&contents, "model"),
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
source: *source,
shadowed_by: None,
path: Some(entry.path()),
});
}
root_agents.sort_by(|left, right| left.name.cmp(&right.name));
@@ -3940,7 +4011,10 @@ fn load_agents_from_roots(
}
}
Ok(agents)
Ok(AgentCollection {
agents,
invalid_agents,
})
}
fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
@@ -4091,6 +4165,63 @@ fn unquote_frontmatter_value(value: &str) -> String {
.to_string()
}
/// Parse agent metadata from YAML frontmatter in `.md` agent files.
/// Returns (name, description, model, reasoning_effort) extracted from
/// the `---`-delimited YAML block at the top of the file.
fn parse_agent_frontmatter(
contents: &str,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
let mut lines = contents.lines();
if lines.next().map(str::trim) != Some("---") {
return (None, None, None, None);
}
let mut name = None;
let mut description = None;
let mut model = None;
let mut reasoning_effort = None;
for line in lines {
let trimmed = line.trim();
if trimmed == "---" {
break;
}
if let Some(value) = trimmed.strip_prefix("name:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
name = Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("description:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
description = Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("model:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
model = Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("model_reasoning_effort:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
reasoning_effort = Some(value);
}
}
}
(name, description, model, reasoning_effort)
}
fn render_agents_report(agents: &[AgentSummary]) -> String {
if agents.is_empty() {
return "No agents found.".to_string();
@@ -4133,31 +4264,42 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
lines.join("\n").trim_end().to_string()
}
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
render_agents_report_json_with_action(cwd, agents, "list")
fn render_agents_report_json(cwd: &Path, collection: &AgentCollection) -> Value {
render_agents_report_json_with_action(cwd, collection, "list")
}
fn render_agents_report_json_with_action(
cwd: &Path,
agents: &[AgentSummary],
collection: &AgentCollection,
action: &str,
) -> Value {
let agents = &collection.agents;
let invalid_agents = &collection.invalid_agents;
let active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
let has_invalids = !invalid_agents.is_empty();
let status = if has_invalids { "degraded" } else { "ok" };
json!({
"kind": "agents",
"status": "ok",
"status": status,
"action": action,
"working_directory": cwd.display().to_string(),
"count": agents.len(),
"valid_count": agents.len(),
"invalid_count": invalid_agents.len(),
"summary": {
"total": agents.len(),
"active": active,
"shadowed": agents.len().saturating_sub(active),
},
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
"invalid_agents": invalid_agents.iter().map(|invalid| json!({
"path": invalid.path.display().to_string(),
"reason": &invalid.reason,
"valid": false,
})).collect::<Vec<_>>(),
})
}
@@ -5127,7 +5269,7 @@ mod tests {
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
render_plugins_report_with_failures, render_skills_report, render_slash_command_help,
render_slash_command_help_detail, resolve_skill_path, resume_supported_slash_commands,
slash_command_specs, suggest_slash_commands, validate_slash_command_input,
slash_command_specs, suggest_slash_commands, validate_slash_command_input, AgentCollection,
DefinitionSource, SkillOrigin, SkillRoot, SkillSlashDispatch, SlashCommand,
};
use plugins::{
@@ -6121,7 +6263,10 @@ mod tests {
];
let report = render_agents_report_json(
&workspace,
&load_agents_from_roots(&roots).expect("agent roots should load"),
&AgentCollection {
agents: load_agents_from_roots(&roots).expect("agent roots should load"),
invalid_agents: Vec::new(),
},
);
assert_eq!(report["kind"], "agents");