From ceaf9cbc23f4957293ae30e95d6b23c3d0523b5a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Mon, 6 Apr 2026 01:42:43 +0000 Subject: [PATCH] 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 --- rust/crates/commands/src/lib.rs | 136 +++++++++++++++++- rust/crates/rusty-claude-cli/src/main.rs | 17 +-- .../tests/output_format_contract.rs | 101 ++++++++++++- 3 files changed, 240 insertions(+), 14 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 6e81ba5..60da84a 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -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 { + 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::>(), + }) +} + 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"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 45c2ebb..faf4c18 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -31,10 +31,10 @@ use api::{ }; use commands::{ - handle_agents_slash_command, handle_mcp_slash_command, handle_mcp_slash_command_json, - handle_plugins_slash_command, handle_skills_slash_command, handle_skills_slash_command_json, - render_slash_command_help, resume_supported_slash_commands, slash_command_specs, - validate_slash_command_input, SlashCommand, + handle_agents_slash_command, handle_agents_slash_command_json, handle_mcp_slash_command, + handle_mcp_slash_command_json, handle_plugins_slash_command, handle_skills_slash_command, + handle_skills_slash_command_json, render_slash_command_help, resume_supported_slash_commands, + slash_command_specs, validate_slash_command_input, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; @@ -3276,16 +3276,11 @@ impl LiveCli { output_format: CliOutputFormat, ) -> Result<(), Box> { let cwd = env::current_dir()?; - let message = handle_agents_slash_command(args, &cwd)?; match output_format { - CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Text => println!("{}", handle_agents_slash_command(args, &cwd)?), CliOutputFormat::Json => println!( "{}", - serde_json::to_string_pretty(&json!({ - "kind": "agents", - "message": message, - "args": args, - }))? + serde_json::to_string_pretty(&handle_agents_slash_command_json(args, &cwd)?)? ), } Ok(()) diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 74e81f5..3700fbd 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -50,8 +50,34 @@ fn inventory_commands_emit_structured_json_when_requested() { let root = unique_temp_dir("inventory-json"); fs::create_dir_all(&root).expect("temp dir should exist"); - let agents = assert_json_command(&root, &["--output-format", "json", "agents"]); + let isolated_home = root.join("home"); + let isolated_config = root.join("config-home"); + let isolated_codex = root.join("codex-home"); + fs::create_dir_all(&isolated_home).expect("isolated home should exist"); + + let agents = assert_json_command_with_env( + &root, + &["--output-format", "json", "agents"], + &[ + ("HOME", isolated_home.to_str().expect("utf8 home")), + ( + "CLAW_CONFIG_HOME", + isolated_config.to_str().expect("utf8 config home"), + ), + ( + "CODEX_HOME", + isolated_codex.to_str().expect("utf8 codex home"), + ), + ], + ); assert_eq!(agents["kind"], "agents"); + assert_eq!(agents["action"], "list"); + assert_eq!(agents["count"], 0); + assert_eq!(agents["summary"]["active"], 0); + assert!(agents["agents"] + .as_array() + .expect("agents array") + .is_empty()); let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]); assert_eq!(mcp["kind"], "mcp"); @@ -62,6 +88,68 @@ fn inventory_commands_emit_structured_json_when_requested() { assert_eq!(skills["action"], "list"); } +#[test] +fn agents_command_emits_structured_agent_entries_when_requested() { + let root = unique_temp_dir("agents-json-populated"); + let workspace = root.join("workspace"); + let project_agents = workspace.join(".codex").join("agents"); + let home = root.join("home"); + let user_agents = home.join(".codex").join("agents"); + let isolated_config = root.join("config-home"); + let isolated_codex = root.join("codex-home"); + fs::create_dir_all(&workspace).expect("workspace should exist"); + 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 parsed = assert_json_command_with_env( + &workspace, + &["--output-format", "json", "agents"], + &[ + ("HOME", home.to_str().expect("utf8 home")), + ( + "CLAW_CONFIG_HOME", + isolated_config.to_str().expect("utf8 config home"), + ), + ( + "CODEX_HOME", + isolated_codex.to_str().expect("utf8 codex home"), + ), + ], + ); + + assert_eq!(parsed["kind"], "agents"); + assert_eq!(parsed["action"], "list"); + assert_eq!(parsed["count"], 3); + assert_eq!(parsed["summary"]["active"], 2); + assert_eq!(parsed["summary"]["shadowed"], 1); + assert_eq!(parsed["agents"][0]["name"], "planner"); + assert_eq!(parsed["agents"][0]["source"]["id"], "project_claw"); + assert_eq!(parsed["agents"][0]["active"], true); + assert_eq!(parsed["agents"][1]["name"], "verifier"); + assert_eq!(parsed["agents"][2]["name"], "planner"); + assert_eq!(parsed["agents"][2]["active"], false); + assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw"); +} + #[test] fn bootstrap_and_system_prompt_emit_json_when_requested() { let root = unique_temp_dir("bootstrap-system-prompt-json"); @@ -183,6 +271,17 @@ fn write_upstream_fixture(root: &Path) -> PathBuf { upstream } +fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) { + fs::create_dir_all(root).expect("agent root should exist"); + fs::write( + root.join(format!("{name}.toml")), + format!( + "name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n" + ), + ) + .expect("agent fixture should write"); +} + fn unique_temp_dir(label: &str) -> PathBuf { let millis = SystemTime::now() .duration_since(UNIX_EPOCH)