mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-07 00:24:50 +08:00
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:
@@ -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");
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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(())
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user