Preserve structured JSON parity for claw agents

`claw agents --output-format json` was still wrapping the text report,
which meant automation could not distinguish empty inventories from
populated agent definitions. Add a dedicated structured handler in the
commands crate, wire the CLI to it, and extend the contracts to cover
both empty and populated agent listings.

Constraint: Keep text-mode `claw agents` output unchanged while aligning JSON behavior with existing structured inventory handlers
Rejected: Parse the text report into JSON in the CLI layer | brittle duplication and no reusable structured handler
Confidence: high
Scope-risk: narrow
Directive: Keep inventory subcommands on dedicated structured handlers instead of serializing human-readable reports
Tested: cargo test -p commands renders_agents_reports_as_json; cargo test -p rusty-claude-cli --test output_format_contract; cargo test --workspace; cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings
Not-tested: Manual invocation of `claw agents --output-format json` outside automated tests
This commit is contained in:
Yeachan-Heo
2026-04-06 01:42:43 +00:00
parent ee92f131b0
commit ceaf9cbc23
3 changed files with 240 additions and 14 deletions

View File

@@ -2187,6 +2187,27 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
}
}
pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::io::Result<Value> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_agents_usage_json(None),
_ => render_agents_usage_json(Some(&help_path.join(" "))),
});
}
}
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))
}
Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)),
Some(args) => Ok(render_agents_usage_json(Some(args))),
}
}
pub fn handle_mcp_slash_command(
args: Option<&str>,
cwd: &Path,
@@ -3039,6 +3060,25 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
lines.join("\n").trim_end().to_string()
}
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
let active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
json!({
"kind": "agents",
"action": "list",
"working_directory": cwd.display().to_string(),
"count": agents.len(),
"summary": {
"total": agents.len(),
"active": active,
"shadowed": agents.len().saturating_sub(active),
},
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
})
}
fn agent_detail(agent: &AgentSummary) -> String {
let mut parts = vec![agent.name.clone()];
if let Some(description) = &agent.description {
@@ -3327,6 +3367,19 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
lines.join("\n")
}
fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
json!({
"kind": "agents",
"action": "help",
"usage": {
"slash_command": "/agents [list|help]",
"direct_cli": "claw agents [list|help]",
"sources": [".claw/agents", "~/.claw/agents", "$CLAW_CONFIG_HOME/agents"],
},
"unexpected": unexpected,
})
}
fn render_skills_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Skills".to_string(),
@@ -3478,6 +3531,18 @@ fn definition_source_json(source: DefinitionSource) -> Value {
})
}
fn agent_summary_json(agent: &AgentSummary) -> Value {
json!({
"name": &agent.name,
"description": &agent.description,
"model": &agent.model,
"reasoning_effort": &agent.reasoning_effort,
"source": definition_source_json(agent.source),
"active": agent.shadowed_by.is_none(),
"shadowed_by": agent.shadowed_by.map(definition_source_json),
})
}
fn skill_origin_id(origin: SkillOrigin) -> &'static str {
match origin {
SkillOrigin::SkillsDir => "skills_dir",
@@ -3686,8 +3751,9 @@ pub fn handle_slash_command(
#[cfg(test)]
mod tests {
use super::{
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
load_agents_from_roots, load_skills_from_roots, render_agents_report,
handle_agents_slash_command_json, handle_plugins_slash_command,
handle_skills_slash_command_json, handle_slash_command, load_agents_from_roots,
load_skills_from_roots, render_agents_report, render_agents_report_json,
render_mcp_report_json_for, render_plugins_report, render_skills_report,
render_slash_command_help, render_slash_command_help_detail,
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
@@ -4363,6 +4429,72 @@ mod tests {
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn renders_agents_reports_as_json() {
let workspace = temp_dir("agents-json-workspace");
let project_agents = workspace.join(".codex").join("agents");
let user_home = temp_dir("agents-json-home");
let user_agents = user_home.join(".codex").join("agents");
write_agent(
&project_agents,
"planner",
"Project planner",
"gpt-5.4",
"medium",
);
write_agent(
&project_agents,
"verifier",
"Verification agent",
"gpt-5.4-mini",
"high",
);
write_agent(
&user_agents,
"planner",
"User planner",
"gpt-5.4-mini",
"high",
);
let roots = vec![
(DefinitionSource::ProjectCodex, project_agents),
(DefinitionSource::UserCodex, user_agents),
];
let report = render_agents_report_json(
&workspace,
&load_agents_from_roots(&roots).expect("agent roots should load"),
);
assert_eq!(report["kind"], "agents");
assert_eq!(report["action"], "list");
assert_eq!(report["working_directory"], workspace.display().to_string());
assert_eq!(report["count"], 3);
assert_eq!(report["summary"]["active"], 2);
assert_eq!(report["summary"]["shadowed"], 1);
assert_eq!(report["agents"][0]["name"], "planner");
assert_eq!(report["agents"][0]["model"], "gpt-5.4");
assert_eq!(report["agents"][0]["active"], true);
assert_eq!(report["agents"][1]["name"], "verifier");
assert_eq!(report["agents"][2]["name"], "planner");
assert_eq!(report["agents"][2]["active"], false);
assert_eq!(report["agents"][2]["shadowed_by"]["id"], "project_claw");
let help = handle_agents_slash_command_json(Some("help"), &workspace).expect("agents help");
assert_eq!(help["kind"], "agents");
assert_eq!(help["action"], "help");
assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
let unexpected = handle_agents_slash_command_json(Some("show planner"), &workspace)
.expect("agents usage");
assert_eq!(unexpected["action"], "help");
assert_eq!(unexpected["unexpected"], "show planner");
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn lists_skills_from_project_and_user_roots() {
let workspace = temp_dir("skills-workspace");