Files
claude-code/rust/crates/rusty-claude-cli/tests/output_format_contract.rs

2615 lines
94 KiB
Rust

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use runtime::Session;
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn help_emits_json_when_requested() {
let root = unique_temp_dir("help-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let parsed = assert_json_command(&root, &["--output-format", "json", "help"]);
assert_eq!(parsed["kind"], "help");
assert_eq!(
parsed["status"], "ok",
"help JSON must have status:ok (#700)"
);
assert!(parsed["message"]
.as_str()
.expect("help text")
.contains("Usage:"));
}
#[test]
fn export_help_emits_bounded_json_when_requested_384() {
let root = unique_temp_dir("export-help-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let parsed = assert_json_command(&root, &["export", "--help", "--output-format", "json"]);
assert_eq!(parsed["kind"], "help");
assert_eq!(
parsed["status"], "ok",
"export help JSON must have status:ok (#700)"
);
assert_eq!(parsed["topic"], "export");
assert_eq!(parsed["command"], "export");
assert_eq!(
parsed["usage"],
"claw export [--session <id|latest>] [--output <path>] [--output-format <format>]"
);
assert_eq!(parsed["defaults"]["session"], "latest");
assert!(parsed["options"].as_array().expect("options").len() >= 4);
assert!(parsed.get("message").is_none());
}
#[test]
fn export_help_preserves_plaintext_in_text_mode_384() {
let root = unique_temp_dir("export-help-text");
fs::create_dir_all(&root).expect("temp dir should exist");
let output = run_claw(&root, &["export", "--help"], &[]);
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
assert!(stdout.starts_with("Export\n"));
assert!(stdout.contains("Usage claw export"));
serde_json::from_str::<Value>(&stdout).expect_err("text help should remain plaintext");
}
#[test]
fn version_emits_json_when_requested() {
let root = unique_temp_dir("version-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
assert_eq!(parsed["kind"], "version");
assert_eq!(
parsed["action"], "show",
"version JSON must have action:show (#711)"
);
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
// Provenance fields must be present for binary identification (#507).
assert!(
parsed["build_date"].is_string(),
"build_date must be a string in version JSON"
);
assert!(
parsed["executable_path"].is_string(),
"executable_path must be a string in version JSON so callers can identify which binary is running"
);
}
#[test]
fn status_and_sandbox_emit_json_when_requested() {
let root = unique_temp_dir("status-sandbox-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
assert_eq!(status["kind"], "status");
assert!(status["workspace"]["cwd"].as_str().is_some());
let sandbox = assert_json_command(&root, &["--output-format", "json", "sandbox"]);
assert_eq!(sandbox["kind"], "sandbox");
assert!(sandbox["filesystem_mode"].as_str().is_some());
}
#[test]
fn status_json_surfaces_permission_mode_override_for_security_audit() {
let root = unique_temp_dir("status-json-permission-mode");
fs::create_dir_all(&root).expect("temp dir should exist");
let parsed = assert_json_command(
&root,
&[
"--permission-mode",
"read-only",
"--output-format",
"json",
"status",
],
);
assert_eq!(parsed["kind"], "status");
assert_eq!(parsed["permission_mode"], "read-only");
assert!(
parsed["workspace"]["cwd"].as_str().is_some(),
"status JSON should retain workspace context with permission mode"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn acp_guidance_emits_json_when_requested() {
let root = unique_temp_dir("acp-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let acp = assert_json_command(&root, &["--output-format", "json", "acp"]);
assert_eq!(acp["kind"], "acp");
assert_eq!(acp["schema_version"], "1.0");
assert_eq!(acp["status"], "unsupported");
assert_eq!(acp["phase"], "discoverability_only");
assert_eq!(acp["supported"], false);
assert_eq!(acp["exit_code"], 0);
assert_eq!(acp["serve_alias_only"], true);
assert_eq!(acp["protocol"]["json_rpc"], false);
assert_eq!(acp["protocol"]["daemon"], false);
assert!(acp["protocol"]["endpoint"].is_null());
assert_eq!(
acp["contracts"]["unsupported_invocation_kind"],
"unsupported_acp_invocation"
);
assert_eq!(acp["discoverability_tracking"], "ROADMAP #64a");
assert_eq!(acp["tracking"], "ROADMAP #76 / #3033 / #3004");
assert!(acp["message"]
.as_str()
.expect("acp message")
.contains("discoverability alias"));
}
#[test]
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 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());
// #717: agents show <name> and agents list <filter> should be valid subcommands
let agents_show_env = [
("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"),
),
];
let agents_show_missing = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"agents",
"show",
"nonexistent-xyz",
],
&agents_show_env,
);
assert_eq!(agents_show_missing["kind"], "agents", "agents show kind");
assert_eq!(agents_show_missing["action"], "show", "agents show action");
assert_eq!(
agents_show_missing["status"], "error",
"agents show not-found status"
);
assert_eq!(
agents_show_missing["error_kind"], "agent_not_found",
"agents show error_kind"
);
assert_eq!(
agents_show_missing["requested"], "nonexistent-xyz",
"agents show requested"
);
let agents_list_filtered = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"agents",
"list",
"nonexistent-filter-xyz",
],
&agents_show_env,
);
assert_eq!(
agents_list_filtered["kind"], "agents",
"agents list filter kind"
);
assert_eq!(
agents_list_filtered["action"], "list",
"agents list filter action"
);
assert_eq!(
agents_list_filtered["status"], "ok",
"agents list filter status"
);
assert!(agents_list_filtered["agents"]
.as_array()
.expect("agents array")
.is_empty());
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
assert_eq!(mcp["kind"], "mcp");
assert_eq!(mcp["action"], "list");
assert_eq!(mcp["status"], "ok");
assert!(mcp["config_load_error"].is_null());
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
assert_eq!(skills["kind"], "skills");
assert_eq!(skills["action"], "list");
let plugins = assert_json_command(&root, &["--output-format", "json", "plugins"]);
assert_eq!(plugins["kind"], "plugin");
assert_eq!(plugins["action"], "list");
assert_eq!(plugins["status"], "ok");
assert!(plugins["config_load_error"].is_null());
// reload_runtime and target are operation-result fields; list response omits them (#703)
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime"
);
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("target")),
"plugins list should not include target"
);
// #703: structured summary replaces prose message
assert!(
plugins["summary"]["total"].is_number(),
"plugins list should have summary.total"
);
assert!(
plugins["summary"]["enabled"].is_number(),
"plugins list should have summary.enabled"
);
assert!(
plugins["summary"]["disabled"].is_number(),
"plugins list should have summary.disabled"
);
assert_eq!(plugins["status"], "ok");
let plugin_entries = plugins["plugins"].as_array().expect("plugins array");
for plugin in plugin_entries {
assert!(
plugin["lifecycle_state"].is_string(),
"plugin entries should expose lifecycle_state"
);
assert!(
plugin["lifecycle"]["configured"].is_boolean(),
"plugin entries should expose lifecycle contract summary"
);
}
assert!(plugins["load_failures"]
.as_array()
.expect("plugin load failures array")
.is_empty());
}
#[test]
fn plugins_json_surfaces_lifecycle_contract_when_plugin_is_installed() {
let root = unique_temp_dir("plugin-lifecycle-json");
let workspace = root.join("workspace");
let home = root.join("home");
let config_home = root.join("config-home");
let plugin_root = root.join("source-plugin");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(plugin_root.join(".claude-plugin")).expect("manifest dir should exist");
fs::create_dir_all(plugin_root.join("lifecycle")).expect("lifecycle dir should exist");
fs::write(
plugin_root.join("lifecycle").join("init.sh"),
"#!/bin/sh\nexit 0\n",
)
.expect("init lifecycle script should write");
fs::write(
plugin_root.join("lifecycle").join("shutdown.sh"),
"#!/bin/sh\nexit 0\n",
)
.expect("shutdown lifecycle script should write");
fs::write(
plugin_root.join(".claude-plugin").join("plugin.json"),
r#"{
"name": "lifecycle-json",
"version": "1.0.0",
"description": "lifecycle JSON fixture",
"lifecycle": {
"Init": ["./lifecycle/init.sh"],
"Shutdown": ["./lifecycle/shutdown.sh"]
}
}"#,
)
.expect("plugin manifest should write");
let parsed = assert_json_command_with_env(
&workspace,
&[
"--output-format",
"json",
"plugins",
"install",
plugin_root
.to_str()
.expect("plugin source path should be utf8"),
],
&[
("HOME", home.to_str().expect("home path should be utf8")),
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("config path should be utf8"),
),
],
);
assert_eq!(parsed["kind"], "plugin");
assert_eq!(parsed["action"], "install");
assert_eq!(parsed["status"], "ok");
assert_eq!(parsed["reload_runtime"], true);
assert!(parsed["load_failures"]
.as_array()
.expect("load_failures array")
.is_empty());
let plugins = parsed["plugins"].as_array().expect("plugins array");
let plugin = plugins
.iter()
.find(|plugin| plugin["id"] == "lifecycle-json@external")
.expect("installed plugin should be present");
assert_eq!(plugin["enabled"], true);
assert_eq!(plugin["lifecycle_state"], "ready");
assert_eq!(plugin["lifecycle"]["configured"], true);
assert_eq!(plugin["lifecycle"]["init"]["configured"], true);
assert_eq!(plugin["lifecycle"]["init"]["command_count"], 1);
assert_eq!(plugin["lifecycle"]["shutdown"]["configured"], true);
assert_eq!(plugin["lifecycle"]["shutdown"]["command_count"], 1);
}
#[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]["source"]["label"], "Project roots");
assert_eq!(parsed["agents"][0]["source"]["detail_label"], Value::Null);
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 agents_and_skills_inventory_share_source_schema_702() {
let root = unique_temp_dir("inventory-source-schema-702");
let workspace = root.join("workspace");
let project_agents = workspace.join(".codex").join("agents");
let project_skills = workspace.join(".codex").join("skills");
let legacy_commands = workspace.join(".claude").join("commands");
let home = root.join("home");
let isolated_config = root.join("config-home");
let isolated_codex = root.join("codex-home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&home).expect("home should exist");
write_agent(
&project_agents,
"planner",
"Project planner",
"gpt-5.4",
"medium",
);
write_skill(&project_skills, "plan", "Project planning guidance");
write_legacy_command(&legacy_commands, "deploy", "Legacy deployment guidance");
let envs = [
("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"),
),
];
let agents =
assert_json_command_with_env(&workspace, &["--output-format", "json", "agents"], &envs);
let skills =
assert_json_command_with_env(&workspace, &["--output-format", "json", "skills"], &envs);
let agent_source = &agents["agents"][0]["source"];
let skill_source = &skills["skills"][0]["source"];
for source in [agent_source, skill_source] {
assert!(
source.get("id").is_some(),
"inventory source must expose id: {source}"
);
assert!(
source.get("label").is_some(),
"inventory source must expose label: {source}"
);
assert!(
source.get("detail_label").is_some(),
"inventory source must expose detail_label for a stable cross-resource path: {source}"
);
}
assert_eq!(agent_source["id"], "project_claw");
assert_eq!(agent_source["label"], "Project roots");
assert_eq!(agent_source["detail_label"], Value::Null);
assert_eq!(skill_source["id"], "project_claw");
assert_eq!(skill_source["label"], "Project roots");
assert_eq!(skill_source["detail_label"], Value::Null);
let legacy_skill = skills["skills"]
.as_array()
.expect("skills array")
.iter()
.find(|skill| skill["name"] == "deploy")
.expect("legacy command skill should be listed");
assert_eq!(legacy_skill["source"]["id"], "project_claw");
assert_eq!(legacy_skill["source"]["label"], "Project roots");
assert_eq!(legacy_skill["source"]["detail_label"], "legacy /commands");
assert_eq!(
legacy_skill["origin"]["id"], "legacy_commands_dir",
"legacy origin stays for compatibility while generic parsers use source"
);
}
#[test]
fn bootstrap_and_system_prompt_emit_json_when_requested() {
let root = unique_temp_dir("bootstrap-system-prompt-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let plan = assert_json_command(&root, &["--output-format", "json", "bootstrap-plan"]);
assert_eq!(plan["kind"], "bootstrap-plan");
assert_eq!(
plan["status"], "ok",
"bootstrap-plan JSON must have status:ok (#458)"
);
assert!(plan["phases"].as_array().expect("phases").len() > 1);
let prompt = assert_json_command(&root, &["--output-format", "json", "system-prompt"]);
assert_eq!(prompt["kind"], "system-prompt");
assert_eq!(
prompt["action"], "show",
"system-prompt JSON must have action:show (#711)"
);
assert!(prompt["message"]
.as_str()
.expect("prompt text")
.contains("interactive agent"));
}
#[test]
fn dump_manifests_and_init_emit_json_when_requested() {
let root = unique_temp_dir("manifest-init-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let upstream = write_upstream_fixture(&root);
let manifests = assert_json_command(
&root,
&[
"--output-format",
"json",
"dump-manifests",
"--manifests-dir",
upstream.to_str().expect("utf8 upstream"),
],
);
assert_eq!(manifests["kind"], "dump-manifests");
assert_eq!(manifests["commands"], 1);
assert_eq!(manifests["tools"], 1);
let workspace = root.join("workspace");
fs::create_dir_all(&workspace).expect("workspace should exist");
let init = assert_json_command(&workspace, &["--output-format", "json", "init"]);
assert_eq!(init["kind"], "init");
assert_eq!(
init["action"], "init",
"init JSON must have action:init (#711)"
);
assert!(workspace.join("CLAUDE.md").exists());
}
#[test]
fn doctor_and_resume_status_emit_json_when_requested() {
let root = unique_temp_dir("doctor-resume-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
assert_eq!(doctor["kind"], "doctor");
assert!(
matches!(doctor["status"].as_str(), Some("ok" | "warn")),
"doctor may warn on platforms without namespace sandbox/tmux support: {doctor}"
);
assert!(doctor["message"].is_string());
let summary = doctor["summary"].as_object().expect("doctor summary");
assert!(summary["ok"].as_u64().is_some());
assert!(summary["warnings"].as_u64().is_some());
assert!(summary["failures"].as_u64().is_some());
let checks = doctor["checks"].as_array().expect("doctor checks");
assert_eq!(checks.len(), 7);
let check_names = checks
.iter()
.map(|check| {
assert!(check["status"].as_str().is_some());
assert!(check["summary"].as_str().is_some());
assert!(check["details"].is_array());
// #704: each check must have a stable snake_case id
assert!(
check["id"].as_str().is_some(),
"doctor check missing stable id field: {:?}",
check["name"]
);
check["name"].as_str().expect("doctor check name")
})
.collect::<Vec<_>>();
assert_eq!(
check_names,
vec![
"auth",
"config",
"install source",
"workspace",
"boot preflight",
"sandbox",
"system"
]
);
let install_source = checks
.iter()
.find(|check| check["name"] == "install source")
.expect("install source check");
assert_eq!(
install_source["official_repo"],
"https://github.com/ultraworkers/claw-code"
);
assert_eq!(
install_source["deprecated_install"],
"cargo install claw-code"
);
let workspace = checks
.iter()
.find(|check| check["name"] == "workspace")
.expect("workspace check");
assert!(workspace["cwd"].as_str().is_some());
assert!(workspace["in_git_repo"].is_boolean());
let boot_preflight = checks
.iter()
.find(|check| check["name"] == "boot preflight")
.expect("boot preflight check");
assert!(boot_preflight["boot_preflight"]["repo"]["exists"].is_boolean());
assert!(boot_preflight["boot_preflight"]["mcp_startup"]["eligible"].is_boolean());
assert!(boot_preflight["boot_preflight"]["required_binaries"].is_array());
// #736: details[] must be {key,value} objects with non-null values;
// regression guard for the double-space separator fix on boot_preflight prose strings.
let bp_details = boot_preflight["details"]
.as_array()
.expect("boot_preflight details must be array");
for entry in bp_details {
assert!(
entry["key"].is_string(),
"boot_preflight detail entry missing string key: {entry:?}"
);
assert!(
!entry["value"].is_null(),
"boot_preflight detail entry has null value (prose-splitter failed): key={:?}",
entry["key"]
);
}
let sandbox = checks
.iter()
.find(|check| check["name"] == "sandbox")
.expect("sandbox check");
assert!(sandbox["filesystem_mode"].as_str().is_some());
assert!(sandbox["enabled"].is_boolean());
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
let session_path = write_session_fixture(&root, "resume-json", Some("hello"));
let resumed = assert_json_command(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/status",
],
);
assert_eq!(resumed["kind"], "status");
// model is null in resume mode (not known without --model flag)
assert!(resumed["model"].is_null());
assert_eq!(resumed["usage"]["messages"], 1);
assert!(resumed["workspace"]["cwd"].as_str().is_some());
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());
}
#[test]
fn resumed_inventory_commands_emit_structured_json_when_requested() {
let root = unique_temp_dir("resume-inventory-json");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let session_path = write_session_fixture(&root, "resume-inventory-json", Some("inventory"));
let mcp = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/mcp",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(mcp["kind"], "mcp");
assert_eq!(mcp["action"], "list");
assert!(mcp["servers"].is_array());
let skills = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/skills",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(skills["kind"], "skills");
assert_eq!(skills["action"], "list");
assert!(skills["summary"]["total"].is_number());
assert!(skills["skills"].is_array());
let agents = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/agents",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(agents["kind"], "agents");
assert_eq!(agents["action"], "list");
assert!(
agents["agents"].is_array(),
"agents field must be a JSON array"
);
assert!(
agents["count"].is_number(),
"count must be a number, not a text render"
);
let plugins = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/plugins",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(plugins["kind"], "plugin");
assert_eq!(plugins["action"], "list");
assert_eq!(plugins["status"], "ok");
assert!(plugins["config_load_error"].is_null());
// reload_runtime and target are operation-result fields; list response omits them (#703)
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime"
);
assert!(
!plugins
.as_object()
.map_or(false, |o| o.contains_key("target")),
"plugins list should not include target"
);
assert!(
plugins["summary"]["total"].is_number(),
"plugins list should have summary.total"
);
}
#[test]
fn resumed_version_and_init_emit_structured_json_when_requested() {
let root = unique_temp_dir("resume-version-init-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let session_path = write_session_fixture(&root, "resume-version-init-json", None);
let version = assert_json_command(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/version",
],
);
assert_eq!(version["kind"], "version");
assert_eq!(version["version"], env!("CARGO_PKG_VERSION"));
let init = assert_json_command(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/init",
],
);
assert_eq!(init["kind"], "init");
assert!(root.join("CLAUDE.md").exists());
}
#[test]
fn config_section_json_emits_section_and_value() {
let root = unique_temp_dir("config-section-json");
fs::create_dir_all(&root).expect("temp dir should exist");
// Without a section: should return base envelope (no section field).
let base = assert_json_command(&root, &["--output-format", "json", "config"]);
assert_eq!(base["kind"], "config");
assert!(base["loaded_files"].is_number());
assert!(base["merged_keys"].is_number());
assert!(
base.get("section").is_none(),
"no section field without section arg"
);
// With a known section: should add section + section_value fields.
for section in &["model", "env", "hooks", "plugins"] {
let result = assert_json_command(&root, &["--output-format", "json", "config", section]);
assert_eq!(result["kind"], "config", "section={section}");
assert_eq!(
result["section"].as_str(),
Some(*section),
"section field must match requested section, got {result:?}"
);
assert!(
result.get("section_value").is_some(),
"section_value field must be present for section={section}"
);
}
// With an unsupported section: should return ok:false + error field.
let bad = assert_json_command(&root, &["--output-format", "json", "config", "unknown"]);
assert_eq!(bad["kind"], "config");
assert_eq!(bad["ok"], false);
assert!(bad["error"].as_str().is_some());
assert!(bad["section"].as_str().is_some());
}
#[test]
fn mcp_json_reports_required_optional_and_redacts_secret_values() {
let root = unique_temp_dir("mcp-required-optional");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(root.join(".claw")).expect("workspace config should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
fs::write(
root.join(".claw").join("settings.json"),
r#"{
"mcpServers": {
"required-stdio": {
"command": "python3",
"args": ["-c", "print('ready')"],
"env": {"TOKEN": "secret-token-value"},
"required": true
},
"optional-remote": {
"type": "http",
"url": "https://example.test/mcp",
"headers": {
"Authorization": "Bearer secret-header-value",
"X-Trace": "visible-key-only"
},
"required": false
}
}
}"#,
)
.expect("mcp config should write");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("config home"),
),
("HOME", home.to_str().expect("home")),
];
let list = assert_json_command_with_env(&root, &["--output-format", "json", "mcp"], &envs);
assert_eq!(list["kind"], "mcp");
assert_eq!(list["action"], "list");
assert_eq!(list["status"], "ok");
assert_eq!(list["configured_servers"], 2);
let servers = list["servers"].as_array().expect("servers array");
let required = servers
.iter()
.find(|server| server["name"] == "required-stdio")
.expect("required stdio server should be listed");
let optional = servers
.iter()
.find(|server| server["name"] == "optional-remote")
.expect("optional remote server should be listed");
assert_eq!(required["required"], true);
assert_eq!(optional["required"], false);
assert_eq!(required["details"]["env_keys"][0], "TOKEN");
assert_eq!(optional["details"]["header_keys"][0], "Authorization");
assert_eq!(optional["details"]["header_keys"][1], "X-Trace");
let list_text = serde_json::to_string(&list).expect("mcp list json should serialize");
assert!(!list_text.contains("secret-token-value"));
assert!(!list_text.contains("secret-header-value"));
assert!(!list_text.contains("visible-key-only"));
let show = assert_json_command_with_env(
&root,
&["--output-format", "json", "mcp", "show", "optional-remote"],
&envs,
);
assert_eq!(show["action"], "show");
assert_eq!(show["status"], "ok");
assert_eq!(show["server"]["required"], false);
assert_eq!(show["server"]["details"]["header_keys"][0], "Authorization");
let show_text = serde_json::to_string(&show).expect("mcp show json should serialize");
assert!(!show_text.contains("secret-header-value"));
assert!(!show_text.contains("visible-key-only"));
}
#[test]
fn mcp_degraded_config_and_failed_usage_are_distinct_json_contracts() {
let root = unique_temp_dir("mcp-degraded-vs-failed");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&root).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
fs::write(
root.join(".claw.json"),
r#"{
"mcpServers": {
"missing-command": {
"args": ["arg-only-no-command"],
"required": true
}
}
}"#,
)
.expect("malformed mcp config should write");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("config home"),
),
("HOME", home.to_str().expect("home")),
];
let degraded = assert_json_command_with_env(&root, &["--output-format", "json", "mcp"], &envs);
assert_eq!(degraded["kind"], "mcp");
assert_eq!(degraded["action"], "list");
assert_eq!(degraded["status"], "degraded");
assert!(degraded["config_load_error"]
.as_str()
.is_some_and(|error| error.contains("mcpServers.missing-command")));
assert_eq!(degraded["configured_servers"], 0);
assert!(degraded["servers"].as_array().expect("servers").is_empty());
let failed_output = run_claw(
&root,
&["--output-format", "json", "mcp", "list", "extra"],
&envs,
);
assert!(
!failed_output.status.success(),
"unsupported MCP action should exit non-zero"
);
let failed: Value =
serde_json::from_slice(&failed_output.stdout).expect("failed stdout should be json");
assert_eq!(failed["kind"], "mcp");
assert_eq!(failed["action"], "error");
assert_eq!(failed["ok"], false);
assert_eq!(failed["error_kind"], "unsupported_action");
assert!(failed.get("config_load_error").is_none());
}
#[test]
fn local_json_surfaces_have_non_empty_action_contract_714() {
let root = unique_temp_dir("json-action-sweep-714");
let workspace = root.join("workspace");
let init_workspace = root.join("init-workspace");
let git_workspace = root.join("git-workspace");
let home = root.join("home");
let config_home = root.join("config-home");
let codex_home = root.join("codex-home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&init_workspace).expect("init workspace should exist");
fs::create_dir_all(&git_workspace).expect("git workspace should exist");
fs::create_dir_all(&home).expect("home should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&codex_home).expect("codex home should exist");
let session_path = write_session_fixture(&workspace, "action-sweep-export", Some("export me"));
let export_output = root.join("export.md");
let upstream = write_upstream_fixture(&root);
let git_init = Command::new("git")
.arg("init")
.current_dir(&git_workspace)
.output()
.expect("git init should launch");
assert!(
git_init.status.success(),
"git init stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&git_init.stdout),
String::from_utf8_lossy(&git_init.stderr)
);
let envs = [
("HOME", home.to_str().expect("home utf8")),
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("config utf8"),
),
("CODEX_HOME", codex_home.to_str().expect("codex utf8")),
];
let surfaces: Vec<(&Path, Vec<String>)> = vec![
(&workspace, strings(&["--output-format", "json", "help"])),
(&workspace, strings(&["--output-format", "json", "version"])),
(&workspace, strings(&["--output-format", "json", "doctor"])),
(&workspace, strings(&["--output-format", "json", "status"])),
(&workspace, strings(&["--output-format", "json", "sandbox"])),
(
&workspace,
strings(&["--output-format", "json", "bootstrap-plan"]),
),
(
&workspace,
strings(&["--output-format", "json", "system-prompt"]),
),
(
&workspace,
vec![
"--output-format".into(),
"json".into(),
"dump-manifests".into(),
"--manifests-dir".into(),
upstream.to_str().expect("upstream utf8").into(),
],
),
(
&workspace,
vec![
"--output-format".into(),
"json".into(),
"export".into(),
"--session".into(),
session_path.to_str().expect("session utf8").into(),
],
),
(
&workspace,
vec![
"--output-format".into(),
"json".into(),
"export".into(),
"--session".into(),
session_path.to_str().expect("session utf8").into(),
"--output".into(),
export_output.to_str().expect("export output utf8").into(),
],
),
(
&init_workspace,
strings(&["--output-format", "json", "init"]),
),
(&workspace, strings(&["--output-format", "json", "diff"])),
(
&git_workspace,
strings(&["--output-format", "json", "diff"]),
),
(&workspace, strings(&["--output-format", "json", "acp"])),
(&workspace, strings(&["--output-format", "json", "config"])),
(
&workspace,
strings(&["--output-format", "json", "config", "model"]),
),
(
&workspace,
strings(&["--output-format", "json", "config", "unknown"]),
),
(&workspace, strings(&["--output-format", "json", "skills"])),
(&workspace, strings(&["--output-format", "json", "agents"])),
(&workspace, strings(&["--output-format", "json", "plugins"])),
(&workspace, strings(&["--output-format", "json", "mcp"])),
];
for (current_dir, args) in surfaces {
let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
let parsed = assert_json_command_with_env(current_dir, &arg_refs, &envs);
assert_non_empty_action(&parsed, &arg_refs);
}
}
#[test]
fn inventory_commands_deduplicate_config_deprecation_warnings_per_process() {
let root = unique_temp_dir("config-warning-dedup");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
fs::write(
config_home.join("settings.json"),
r#"{"enabledPlugins": {}}"#,
)
.expect("deprecated config fixture should write");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
];
for args in [&["plugins", "list"][..], &["mcp", "list"][..]] {
let output = run_claw(&root, args, &envs);
assert!(
output.status.success(),
"args={args:?}\nstdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
let warning_count = stderr
.matches("field \"enabledPlugins\" is deprecated")
.count();
assert_eq!(
warning_count, 1,
"args={args:?} should emit the deprecated enabledPlugins warning once per process:\n{stderr}"
);
}
}
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
assert_json_command_with_env(current_dir, args, &[])
}
fn assert_json_command_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Value {
let output = run_claw(current_dir, args, envs);
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let parsed: Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid json");
assert_non_empty_action(&parsed, args);
parsed
}
fn assert_non_empty_action(parsed: &Value, args: &[&str]) {
let action = parsed
.get("action")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
!action.trim().is_empty(),
"JSON output for args={args:?} must include a non-empty stable action field: {parsed}"
);
}
fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(current_dir).args(args);
for (key, value) in envs {
command.env(key, value);
}
command.output().expect("claw should launch")
}
fn strings(items: &[&str]) -> Vec<String> {
items.iter().map(|item| (*item).to_string()).collect()
}
fn write_upstream_fixture(root: &Path) -> PathBuf {
let upstream = root.join("claw-code");
let src = upstream.join("src");
let entrypoints = src.join("entrypoints");
fs::create_dir_all(&entrypoints).expect("upstream entrypoints dir should exist");
fs::write(
src.join("commands.ts"),
"import FooCommand from './commands/foo'\n",
)
.expect("commands fixture should write");
fs::write(
src.join("tools.ts"),
"import ReadTool from './tools/read'\n",
)
.expect("tools fixture should write");
fs::write(
entrypoints.join("cli.tsx"),
"if (args[0] === '--version') {}\nstartupProfiler()\n",
)
.expect("cli fixture should write");
upstream
}
fn write_session_fixture(root: &Path, session_id: &str, user_text: Option<&str>) -> PathBuf {
let session_path = root.join("session.jsonl");
let mut session = Session::new()
.with_workspace_root(root.to_path_buf())
.with_persistence_path(session_path.clone());
session.session_id = session_id.to_string();
if let Some(text) = user_text {
session
.push_user_text(text)
.expect("session fixture message should persist");
} else {
session
.save_to_path(&session_path)
.expect("session fixture should persist");
}
session_path
}
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 write_skill(root: &Path, name: &str, description: &str) {
let skill_root = root.join(name);
fs::create_dir_all(&skill_root).expect("skill root should exist");
fs::write(
skill_root.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
)
.expect("skill fixture should write");
}
fn write_legacy_command(root: &Path, name: &str, description: &str) {
fs::create_dir_all(root).expect("legacy command root should exist");
fs::write(
root.join(format!("{name}.md")),
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
)
.expect("legacy command fixture should write");
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_millis();
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"claw-output-format-{label}-{}-{millis}-{counter}",
std::process::id()
))
}
#[test]
fn diff_json_has_status_and_result_field_702() {
// #458/#702: `claw diff --output-format json` must have status ∈ {ok,error}
// and a `result` field to distinguish clean/changes/no-repo states.
let root = unique_temp_dir("diff-json-status");
fs::create_dir_all(&root).expect("temp dir should exist");
// In a non-git directory, diff should report status:ok + result:no_git_repo
// or status:error; in a git repo it should report ok + result:clean|changes.
// We only assert the shape, not the value, to avoid flakiness.
let parsed = assert_json_command(&root, &["--output-format", "json", "diff"]);
assert_eq!(
parsed["kind"], "diff",
"diff JSON must have kind:diff (#458)"
);
let status = parsed["status"]
.as_str()
.expect("diff JSON must have status field (#458/#702)");
assert!(
matches!(status, "ok" | "error"),
"diff status must be ok or error, got {status:?}"
);
assert!(
parsed.get("result").is_some(),
"diff JSON must have result field"
);
// #710: diff JSON must have action:diff and working_directory
assert_eq!(
parsed["action"], "diff",
"diff JSON must have action:diff (#710)"
);
assert!(
parsed
.get("working_directory")
.and_then(|v| v.as_str())
.is_some(),
"diff JSON must have working_directory field (#710)"
);
// #740: diff JSON changed_file_count contract: numeric in git repos, absent for no_git_repo
let result_str = parsed.get("result").and_then(|v| v.as_str());
if result_str == Some("no_git_repo") {
// Non-git repos don't emit changed_file_count
assert!(
parsed.get("changed_file_count").is_none(),
"diff JSON should not have changed_file_count for no_git_repo (#733)"
);
} else {
// Git repos must emit numeric changed_file_count
assert!(
parsed
.get("changed_file_count")
.and_then(|v| v.as_u64())
.is_some(),
"diff JSON changed_file_count must be numeric in git repos (#733)"
);
}
}
#[test]
fn diff_json_changed_file_count_deduplication_733() {
// #733/#742: changed_file_count must be numeric in a git repo, be 0 for clean,
// and deduplicate staged+unstaged edits to the same file (1 file changed = count 1).
use std::process::Command;
let root = unique_temp_dir("diff-changed-dedup");
fs::create_dir_all(&root).expect("temp dir");
// git init + identity config + initial commit
Command::new("git")
.args(["init"])
.current_dir(&root)
.output()
.expect("git init");
Command::new("git")
.args(["config", "user.email", "test@claw.test"])
.current_dir(&root)
.output()
.expect("git config email");
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&root)
.output()
.expect("git config name");
fs::write(root.join("tracked.txt"), b"v1").expect("write tracked");
Command::new("git")
.args(["add", "tracked.txt"])
.current_dir(&root)
.output()
.expect("git add");
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&root)
.output()
.expect("git commit");
// Clean state: changed_file_count must be 0
let bin = env!("CARGO_BIN_EXE_claw");
let clean = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "diff"])
.output()
.expect("claw diff clean");
let clean_json: serde_json::Value =
serde_json::from_slice(&clean.stdout).expect("diff clean stdout must be valid JSON");
assert_eq!(clean_json["result"], "clean", "fresh repo must be clean");
assert_eq!(
clean_json["changed_file_count"].as_u64(),
Some(0),
"clean repo must have changed_file_count:0 (#733)"
);
// Make a staged edit AND an unstaged edit to the same file
fs::write(root.join("tracked.txt"), b"v2").expect("staged write");
Command::new("git")
.args(["add", "tracked.txt"])
.current_dir(&root)
.output()
.expect("git add staged");
fs::write(root.join("tracked.txt"), b"v3").expect("unstaged write");
// Dirty state: same file appears in staged+unstaged — must deduplicate to count 1
let dirty = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "diff"])
.output()
.expect("claw diff dirty");
let dirty_json: serde_json::Value =
serde_json::from_slice(&dirty.stdout).expect("diff dirty stdout must be valid JSON");
assert_eq!(
dirty_json["result"], "changes",
"dirty repo must have result:changes (#733)"
);
assert_eq!(
dirty_json["changed_file_count"].as_u64(),
Some(1),
"staged+unstaged edits to same file must deduplicate to changed_file_count:1 (#733)"
);
}
#[test]
fn prompt_no_arg_json_error_kind_750() {
// #751/#750: `claw prompt --output-format json` with no prompt argument must emit
// error_kind:"missing_prompt" and a non-empty hint. Before #750 it returned
// error_kind:"unknown" + hint:null.
use std::process::Command;
let root = unique_temp_dir("prompt-no-arg");
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
let output = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "prompt"])
.output()
.expect("claw prompt should run");
assert!(
!output.status.success(),
"claw prompt with no arg must exit non-zero"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr)
.lines()
.filter(|l| l.starts_with('{'))
.collect::<Vec<_>>()
.join("");
let raw = if stdout.trim().starts_with('{') {
stdout.trim().to_string()
} else {
stderr
};
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|_| {
panic!("claw prompt (no arg) --output-format json must emit valid JSON; got: {raw}")
});
assert_eq!(
parsed["error_kind"], "missing_prompt",
"claw prompt no-arg must have error_kind:missing_prompt (#750); got: {parsed}"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw prompt no-arg hint must be non-empty (#750)"
);
assert!(
hint.contains("claw prompt") || hint.contains("echo"),
"hint should mention 'claw prompt' or 'echo': {hint}"
);
}
#[test]
fn flag_value_errors_have_error_kind_and_hint_756() {
// #756: missing/invalid flag-value errors must emit typed error_kind + non-null hint.
// Before #756: all returned error_kind:"unknown" + hint:null.
use std::process::Command;
let root = unique_temp_dir("flag-value-errors");
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
// Case 1: --reasoning-effort with invalid value
let out = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "--reasoning-effort", "HIGH"])
.output()
.expect("claw --reasoning-effort HIGH should run");
assert!(
!out.status.success(),
"invalid reasoning-effort must exit non-zero"
);
let raw = String::from_utf8_lossy(&out.stderr)
.lines()
.filter(|l| l.starts_with('{'))
.collect::<Vec<_>>()
.join("");
let parsed: serde_json::Value = serde_json::from_str(&raw)
.unwrap_or_else(|_| panic!("invalid --reasoning-effort must emit JSON; got: {raw}"));
assert_eq!(
parsed["error_kind"], "invalid_flag_value",
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
);
assert!(
parsed["hint"].as_str().map_or(false, |h| h.contains("low")
|| h.contains("medium")
|| h.contains("high")),
"hint must mention valid values (#756): {parsed}"
);
// Case 2: --model flag with missing value (trailing flag)
let out2 = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "--model"])
.output()
.expect("claw --model (no value) should run");
assert!(
!out2.status.success(),
"missing --model value must exit non-zero"
);
let raw2 = String::from_utf8_lossy(&out2.stderr)
.lines()
.filter(|l| l.starts_with('{'))
.collect::<Vec<_>>()
.join("");
let parsed2: serde_json::Value = serde_json::from_str(&raw2)
.unwrap_or_else(|_| panic!("missing --model value must emit JSON; got: {raw2}"));
assert_eq!(
parsed2["error_kind"], "missing_flag_value",
"missing --model value must be missing_flag_value (#756): {parsed2}"
);
assert!(
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
"missing --model hint must be non-empty (#756): {parsed2}"
);
}
#[test]
fn short_p_flag_swallows_no_flags_755() {
// #755: `claw -p hello --output-format json` must parse --output-format json
// as a flag rather than swallowing it as part of the prompt. Before #755,
// args[index+1..].join(" ") consumed all remaining tokens into the prompt.
// After #755, -p consumes exactly one token and remaining flags are parsed.
// We verify by checking that the envelope IS JSON (meaning --output-format json
// was interpreted as a flag, not literal prompt text).
use std::process::Command;
let root = unique_temp_dir("short-p-flags");
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
// -p hello --output-format json: with no credentials, should fail with
// missing_credentials (not missing_prompt), proving --output-format json was parsed.
let output = Command::new(bin)
.current_dir(&root)
.args(["-p", "hello", "--output-format", "json"])
.env_remove("ANTHROPIC_API_KEY")
.env_remove("ANTHROPIC_AUTH_TOKEN")
.output()
.expect("claw -p should run");
assert!(
!output.status.success(),
"claw -p hello --output-format json must exit non-zero (no credentials)"
);
let raw = String::from_utf8_lossy(&output.stderr)
.lines()
.filter(|l| l.starts_with('{'))
.collect::<Vec<_>>()
.join("");
// Must be valid JSON (i.e. --output-format json was parsed, not swallowed)
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|_| {
panic!("--output-format json must be parsed as a flag, not prompt text; stderr: {raw}")
});
assert_eq!(
parsed["error_kind"], "missing_credentials",
"flags after -p prompt text must be parsed normally (#755); got: {parsed}"
);
// Also verify -p --model bogus is rejected as missing_prompt (flag-as-prompt guard)
let output2 = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "-p", "--model", "sonnet"])
.output()
.expect("claw -p flag-as-prompt should run");
let raw2 = String::from_utf8_lossy(&output2.stderr)
.lines()
.filter(|l| l.starts_with('{'))
.collect::<Vec<_>>()
.join("");
let parsed2: serde_json::Value = serde_json::from_str(&raw2)
.unwrap_or_else(|_| panic!("claw -p --model must emit JSON; got: {raw2}"));
assert_eq!(
parsed2["error_kind"], "missing_prompt",
"flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
);
assert!(
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
"missing_prompt hint must be non-empty (#755)"
);
}
#[test]
fn short_p_flag_no_arg_json_error_kind_753() {
// #753: `claw --output-format json -p` (no prompt) must emit error_kind:"missing_prompt"
// and non-empty hint. Before #753 it returned error_kind:"unknown" + hint:null.
// Parity with #750 which fixed the explicit `prompt` verb.
use std::process::Command;
let root = unique_temp_dir("short-p-no-arg");
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
let output = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "-p"])
.output()
.expect("claw -p should run");
assert!(
!output.status.success(),
"claw -p with no arg must exit non-zero"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let raw = if stdout.trim().starts_with('{') {
stdout.trim().to_string()
} else {
String::from_utf8_lossy(&output.stderr)
.lines()
.filter(|l| l.starts_with('{'))
.collect::<Vec<_>>()
.join("")
};
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|_| {
panic!("claw -p (no arg) --output-format json must emit valid JSON; got: {raw}")
});
assert_eq!(
parsed["error_kind"], "missing_prompt",
"claw -p no-arg must have error_kind:missing_prompt (#753); got: {parsed}"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw -p no-arg hint must be non-empty (#753)"
);
assert!(
hint.contains("claw -p") || hint.contains("claw prompt"),
"hint should mention 'claw -p' or 'claw prompt': {hint}"
);
}
#[test]
fn bare_slash_command_hint_745() {
// #747/#745: claw <slash-cmd> --output-format json must return non-null hint.
// bare_slash_command_guidance() previously had no \n so split_error_hint returned hint:null.
use std::process::Command;
let root = unique_temp_dir("bare-slash-hint");
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
// issue and pr are non-resume-supported; commit is resume-supported.
// All must emit non-null hint in their interactive_only error envelope.
for cmd in &["issue", "pr", "commit"] {
let output = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", cmd])
.env("ANTHROPIC_API_KEY", "test")
.output()
.expect("claw should run");
assert!(
!output.status.success(),
"claw {cmd} outside REPL must exit non-zero"
);
// Error envelope is on stderr (type:error path) or stdout
let stderr = String::from_utf8_lossy(&output.stderr)
.lines()
.filter(|l| l.starts_with('{'))
.collect::<Vec<_>>()
.join("");
let stdout = String::from_utf8_lossy(&output.stdout);
let raw = if !stderr.is_empty() {
stderr
} else {
stdout.trim().to_string()
};
let parsed: serde_json::Value = serde_json::from_str(&raw)
.unwrap_or_else(|_| panic!("claw {cmd} must emit JSON; got: {raw}"));
assert_eq!(
parsed["error_kind"], "interactive_only",
"claw {cmd} must have error_kind:interactive_only (#745)"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw {cmd} --output-format json hint must be non-empty (#745); got null"
);
}
}
#[test]
fn config_unsupported_section_json_hint_741() {
// #744/#741: claw config <unknown-section> --output-format json must return
// error_kind:unsupported_config_section with a non-null hint and supported_sections[].
// This is the regression guard for #741 (hint was null before fix).
use std::process::Command;
let root = unique_temp_dir("config-unsupported-section");
fs::create_dir_all(&root).expect("temp dir");
let bin = env!("CARGO_BIN_EXE_claw");
for section in &["list", "show", "bogus", "help"] {
let output = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "config", section])
.output()
.expect("claw config should run");
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|_| {
panic!("claw config {section} --output-format json must emit valid JSON; got: {stdout}")
});
assert_eq!(
parsed["kind"], "config",
"config {section} JSON must have kind:config (#741)"
);
assert_eq!(
parsed["status"], "error",
"config {section} must return status:error (#741)"
);
assert_eq!(
parsed["error_kind"], "unsupported_config_section",
"config {section} must return error_kind:unsupported_config_section (#741)"
);
// #741: hint must be a non-empty string (was null before fix)
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"config {section} --output-format json hint must be non-empty (#741)"
);
// supported_sections must still be present and non-empty
assert!(
parsed["supported_sections"]
.as_array()
.map_or(false, |a| !a.is_empty()),
"config {section} JSON must include supported_sections (#741)"
);
}
}
#[test]
fn export_json_has_kind_702() {
// #458/#702: `claw export --output-format json` must emit kind:export.
// We check only the kind field to avoid flakiness from session-store state.
// A success path with an actual session would also carry status:ok.
let root = unique_temp_dir("export-json-kind");
fs::create_dir_all(&root).expect("temp dir should exist");
// Run without asserting exit code — may fail with no sessions or legacy sessions.
use std::process::Command;
let bin = env!("CARGO_BIN_EXE_claw");
let output = Command::new(bin)
.current_dir(&root)
.args(["--output-format", "json", "export"])
.env("ANTHROPIC_API_KEY", "test")
.output()
.expect("claw binary should run");
// On success stdout has kind:export; on failure stderr has type:error.
// Either way, both envelopes must be valid JSON.
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr)
.lines()
.filter(|l| l.starts_with('{'))
.collect::<Vec<_>>()
.join("");
if output.status.success() {
let parsed: serde_json::Value =
serde_json::from_str(&stdout).expect("export success stdout must be valid JSON");
assert_eq!(
parsed["kind"], "export",
"export JSON must have kind:export (#458)"
);
let status = parsed["status"]
.as_str()
.expect("export JSON must have status");
assert!(
matches!(status, "ok" | "error"),
"export status must be ok or error"
);
} else {
// Error envelope on stderr must be parseable JSON.
assert!(
!stderr.is_empty(),
"export failure must emit JSON to stderr"
);
let parsed: serde_json::Value =
serde_json::from_str(&stderr).expect("export error stderr must be valid JSON");
assert_eq!(
parsed["type"], "error",
"export error envelope must have type:error"
);
}
}
#[test]
fn config_parse_error_has_typed_error_kind_and_hint_764() {
// #764: Malformed .claw/settings.json must emit error_kind:config_parse_error
// and a non-null hint in --output-format json mode (was error_kind:"unknown"
// + hint:null before #763/#764 fixes).
let root = unique_temp_dir("config-parse-error-764");
fs::create_dir_all(root.join(".claw")).expect("temp .claw dir should exist");
// Write an invalid JSON file (type mismatch: model must be a string)
fs::write(root.join(".claw").join("settings.json"), r#"{"model": 99}"#)
.expect("settings.json should write");
let output = run_claw(&root, &["--output-format", "json", "config", "show"], &[]);
assert!(
!output.status.success(),
"malformed settings.json should cause non-zero exit"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("stderr should contain a JSON error envelope");
let parsed: serde_json::Value =
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
assert_eq!(
parsed["error_kind"], "config_parse_error",
"malformed settings.json must return error_kind:config_parse_error (#763)"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"malformed settings.json must return non-null hint (#764), got: {hint:?}"
);
}
#[test]
fn login_logout_removed_subcommands_have_error_kind_and_hint_765() {
// #765: `claw login` and `claw logout` are removed; JSON envelope must carry
// error_kind:removed_subcommand + non-null hint pointing to the env var migration.
// Before fix: single-line error string → error_kind:"unknown" + hint:null.
let root = unique_temp_dir("login-logout-removed-765");
fs::create_dir_all(&root).expect("temp dir should exist");
for subcmd in &["login", "logout"] {
let output = run_claw(&root, &["--output-format", "json", subcmd], &[]);
assert!(
!output.status.success(),
"claw {subcmd} should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| panic!("claw {subcmd} stderr should contain a JSON envelope"));
let parsed: serde_json::Value =
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
assert_eq!(
parsed["error_kind"], "removed_subcommand",
"claw {subcmd} must return error_kind:removed_subcommand (#765)"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw {subcmd} must return non-null hint (#765), got: {hint:?}"
);
assert!(
hint.contains("ANTHROPIC_API_KEY") || hint.contains("ANTHROPIC_AUTH_TOKEN"),
"claw {subcmd} hint must mention the env var migration path, got: {hint:?}"
);
}
}
#[test]
fn diff_extra_args_have_typed_error_kind_and_hint_766() {
// #766: `claw diff --bogus` returned error_kind:"unknown" + hint:null.
// `diff` takes no arguments; extra args were unclassified with no remediation.
let root = unique_temp_dir("diff-extra-args-766");
fs::create_dir_all(&root).expect("temp dir should exist");
// Need a git repo for diff to parse past arg validation
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(&root, &["--output-format", "json", "diff", "--bogus"], &[]);
assert!(
!output.status.success(),
"claw diff --bogus should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("stderr should contain a JSON error envelope");
let parsed: serde_json::Value =
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
assert_eq!(
parsed["error_kind"], "unexpected_extra_args",
"claw diff --bogus must return error_kind:unexpected_extra_args (#766)"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw diff --bogus must return non-null hint (#766), got: {hint:?}"
);
}
#[test]
fn resume_non_slash_trailing_arg_has_typed_error_kind_and_hint_768() {
// #768: `claw --resume latest compact` (missing leading /) returned
// error_kind:"unknown" + hint:null. Resume is orchestration-critical;
// wrappers need a machine-readable signal with a recovery hint.
let root = unique_temp_dir("resume-invalid-arg-768");
fs::create_dir_all(&root).expect("temp dir should exist");
let output = run_claw(
&root,
&["--output-format", "json", "--resume", "latest", "compact"],
&[],
);
assert!(
!output.status.success(),
"claw --resume latest compact should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("stderr should contain a JSON error envelope");
let parsed: serde_json::Value =
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
assert_eq!(
parsed["error_kind"], "invalid_resume_argument",
"non-slash resume trailing arg must return error_kind:invalid_resume_argument (#768)"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"non-slash resume trailing arg must return non-null hint (#768), got: {hint:?}"
);
assert!(
hint.contains("/compact") || hint.contains("slash-command"),
"hint must reference slash-command usage, got: {hint:?}"
);
}
#[test]
fn session_with_unknown_subcommand_returns_interactive_only_not_credentials_767() {
// #767: `claw session bogus` bypassed all guards and fell through to
// CliAction::Prompt, reaching the credential-check gate and returning
// error_kind:"missing_credentials" instead of a structured routing error.
// Fix: explicit "session" match arm returns interactive_only guidance.
let root = unique_temp_dir("session-unknown-767");
fs::create_dir_all(&root).expect("temp dir should exist");
for sub in &["bogus", "nuke", "delete-all"] {
let output = run_claw(&root, &["--output-format", "json", "session", sub], &[]);
assert!(
!output.status.success(),
"claw session {sub} should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| panic!("claw session {sub} stderr should contain JSON"));
let parsed: serde_json::Value =
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
assert_eq!(
parsed["error_kind"], "interactive_only",
"claw session {sub} must return error_kind:interactive_only (#767), not missing_credentials"
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw session {sub} must return non-null hint (#767)"
);
assert!(
hint.contains("/session") || hint.contains("--resume"),
"hint must reference /session usage, got: {hint:?}"
);
}
}
#[test]
fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() {
// #770: `claw cost breakdown`, `claw clear --force`, `claw memory reset`,
// `claw ultraplan bogus`, `claw model opus extra` all fell through to
// CliAction::Prompt and reached the credential gate, returning
// error_kind:"missing_credentials". These are all slash-only commands;
// any multi-token invocation should return interactive_only guidance.
let root = unique_temp_dir("slash-verbs-770");
fs::create_dir_all(&root).expect("temp dir should exist");
let cases: &[&[&str]] = &[
&["cost", "breakdown"],
&["clear", "--force"],
&["memory", "reset"],
&["ultraplan", "bogus"],
&["model", "opus", "extra"],
];
for args in cases {
let full_args: Vec<&str> = std::iter::once("--output-format")
.chain(std::iter::once("json"))
.chain(args.iter().copied())
.collect();
let output = run_claw(&root, &full_args, &[]);
assert!(
!output.status.success(),
"claw {} should exit non-zero",
args.join(" ")
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| {
panic!(
"claw {} stderr should contain JSON, got: {stderr}",
args.join(" ")
)
});
let parsed: serde_json::Value =
serde_json::from_str(json_line).expect("error envelope should be valid JSON");
assert_eq!(
parsed["error_kind"],
"interactive_only",
"claw {} must return error_kind:interactive_only (#770), not missing_credentials",
args.join(" ")
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw {} must return non-null hint (#770)",
args.join(" ")
);
}
}
#[test]
fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
// #774: `claw agents bogus`, `claw plugins bogus`, `claw mcp bogus` returned
// hint:null despite having correct error_kind. Fixed by adding \n delimiter
// to error strings in commands/src/lib.rs and explicit hint in mcp JSON envelope.
let root = unique_temp_dir("unknown-subcommands-774");
fs::create_dir_all(&root).expect("temp dir should exist");
// agents bogus
{
let output = run_claw(&root, &["--output-format", "json", "agents", "bogus"], &[]);
assert!(!output.status.success(), "agents bogus should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("agents bogus should emit JSON error");
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(parsed["error_kind"], "unknown_agents_subcommand");
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"agents bogus hint must be non-null (#774)"
);
assert!(
hint.contains("list") || hint.contains("show") || hint.contains("help"),
"agents bogus hint must mention supported actions, got: {hint:?}"
);
}
// plugins bogus
{
let output = run_claw(&root, &["--output-format", "json", "plugins", "bogus"], &[]);
assert!(!output.status.success(), "plugins bogus should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("plugins bogus should emit JSON error");
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(parsed["error_kind"], "unknown_plugins_action");
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"plugins bogus hint must be non-null (#774)"
);
}
// mcp bogus
{
let output = run_claw(&root, &["--output-format", "json", "mcp", "bogus"], &[]);
assert!(!output.status.success(), "mcp bogus should fail");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_str = if stdout.trim().starts_with('{') {
stdout.to_string()
} else {
stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or("")
.to_string()
};
let parsed: serde_json::Value =
serde_json::from_str(json_str.trim()).expect("mcp bogus should emit JSON");
assert_eq!(parsed["error_kind"], "unknown_mcp_action");
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(!hint.is_empty(), "mcp bogus hint must be non-null (#774)");
}
}
#[test]
fn interactive_only_guard_batch_769_to_771() {
// #769-#771: a sweep of slash-only verbs with args that previously fell to
// CliAction::Prompt hitting the credential gate. All must return
// error_kind:interactive_only (not missing_credentials) with non-null hint.
let root = unique_temp_dir("interactive-only-batch-769-771");
fs::create_dir_all(&root).expect("temp dir should exist");
// Need a git repo for some subcommands
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let cases: &[&[&str]] = &[
// #769: session with unknown subcommand
&["session", "bogus"],
&["session", "nuke"],
// #770: slash-only verbs with trailing args
&["cost", "breakdown"],
&["clear", "--force"],
&["memory", "reset"],
&["ultraplan", "bogus"],
&["model", "opus", "extra"],
// #771: usage/stats/fork
&["usage", "extra"],
&["stats", "extra"],
&["fork", "newbranch"],
];
for args in cases {
let full_args: Vec<&str> = std::iter::once("--output-format")
.chain(std::iter::once("json"))
.chain(args.iter().copied())
.collect();
let output = run_claw(&root, &full_args, &[]);
assert!(
!output.status.success(),
"claw {} should exit non-zero",
args.join(" ")
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| {
panic!(
"claw {} should emit JSON, got stderr: {stderr}",
args.join(" ")
)
});
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(
parsed["error_kind"],
"interactive_only",
"claw {} must return interactive_only, got {:?}",
args.join(" "),
parsed["error_kind"]
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"claw {} must have non-null hint",
args.join(" ")
);
}
}
#[test]
fn resume_plugin_mutations_are_typed_interactive_only_777() {
// #777: `/plugins install|enable|disable|uninstall|update` in resume mode returned
// a generic single-line error; after #776's classify/split it fell to
// error_kind:"unknown" + hint:null because there was no interactive_only: prefix.
// Fix: each mutation arm now returns "interactive_only: ... \n..." so the caller
// gets error_kind:interactive_only + non-null hint pointing at live REPL.
let root = unique_temp_dir("resume-plugin-mutations-777");
fs::create_dir_all(&root).expect("temp dir should exist");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// Create a minimal session file so we get past session load and into command dispatch
let session_file = write_session_fixture(&root, "resume-plugin-777", None);
for mutation in &["install", "enable", "disable", "uninstall", "update"] {
let cmd = format!("/plugins {mutation} my-plugin");
let output = run_claw(
&root,
&[
"--resume",
session_file.to_str().unwrap(),
"--output-format",
"json",
&cmd,
],
&[],
);
assert!(
!output.status.success(),
"/plugins {mutation} in resume mode should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| {
panic!("/plugins {mutation} should emit JSON error, got stderr: {stderr}")
});
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(
parsed["error_kind"], "interactive_only",
"/plugins {mutation} must return interactive_only, got {:?}",
parsed["error_kind"]
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"/plugins {mutation} must have non-null hint (#777)"
);
assert!(
hint.contains("claw") || hint.contains("REPL") || hint.contains("plugins"),
"/plugins {mutation} hint must reference live session or CLI, got: {hint:?}"
);
}
}
#[test]
fn resume_skills_invocation_is_typed_interactive_only_779() {
// #779: `/skills <skill>` invocation in resume mode returned bare prose;
// after #776 classify/split it fell to error_kind:"unknown" + hint:null.
// Fix: use interactive_only: prefix + \n hint so callers get typed fields.
let root = unique_temp_dir("resume-skills-invocation-779");
fs::create_dir_all(&root).expect("temp dir should exist");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let session_file = write_session_fixture(&root, "resume-skills-779", None);
// A non-empty skills arg that would classify as Invoke
let output = run_claw(
&root,
&[
"--resume",
session_file.to_str().unwrap(),
"--output-format",
"json",
"/skills my-skill",
],
&[],
);
assert!(
!output.status.success(),
"/skills <skill> in resume mode should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.unwrap_or_else(|| {
panic!("/skills invocation should emit JSON error, got stderr: {stderr}")
});
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(
parsed["error_kind"], "interactive_only",
"resumed /skills invocation must return interactive_only, got {:?}",
parsed["error_kind"]
);
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"resumed /skills invocation must have non-null hint (#779)"
);
assert!(
hint.contains("claw") || hint.contains("REPL") || hint.contains("skills"),
"hint must reference live session or CLI, got: {hint:?}"
);
}
#[test]
fn acp_unsupported_invocation_has_hint_782() {
// #782: `claw acp start` returned error_kind:unsupported_acp_invocation but hint:null
// because the remediation text was on the same line as the error message.
// Fix: add \n-delimited hint so split_error_hint extracts it.
let root = unique_temp_dir("acp-unsupported-782");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
let output = run_claw(&root, &["--output-format", "json", "acp", "start"], &[]);
assert!(!output.status.success(), "acp start should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
let json_line = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.expect("should emit JSON error");
let parsed: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(
parsed["error_kind"], "unsupported_acp_invocation",
"unsupported ACP invocation should be classified correctly"
);
let hint = parsed["hint"]
.as_str()
.expect("hint must be non-null (#782)");
assert!(!hint.is_empty(), "hint must not be empty");
assert!(
hint.contains("discoverability") || hint.contains("ROADMAP"),
"hint should explain the discoverability-only status, got: {hint:?}"
);
}
#[test]
fn init_json_envelope_has_hint_and_already_initialized_783() {
// #783: claw --output-format json init was missing the hint field entirely.
// Also added already_initialized: bool so orchestrators can detect the idempotent
// case without checking created.len() == 0.
let root = unique_temp_dir("init-hint-783");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// Fresh init — already_initialized should be false, hint should mention CLAUDE.md
let output = run_claw(&root, &["--output-format", "json", "init"], &[]);
assert!(output.status.success(), "init should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let raw = if stdout.trim_start().starts_with('{') {
&*stdout
} else {
&*stderr
};
let parsed: serde_json::Value = serde_json::from_str(raw.trim()).unwrap_or_else(|_| {
// multi-line JSON; find the whole block
serde_json::from_str(raw).expect("should emit valid JSON")
});
assert_eq!(parsed["status"], "ok", "init should succeed");
assert!(
parsed.get("already_initialized").is_some(),
"init JSON must include already_initialized field (#783)"
);
assert_eq!(
parsed["already_initialized"], false,
"first init: already_initialized must be false"
);
let hint = parsed["hint"]
.as_str()
.expect("hint must be present and non-null (#783)");
assert!(!hint.is_empty(), "hint must not be empty");
assert!(
hint.contains("CLAUDE.md") || hint.contains("doctor"),
"fresh-init hint should mention CLAUDE.md or doctor, got: {hint:?}"
);
// Idempotent re-init — already_initialized should be true
let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]);
assert!(output2.status.success(), "re-init should succeed");
let stdout2 = String::from_utf8_lossy(&output2.stdout);
let stderr2 = String::from_utf8_lossy(&output2.stderr);
let raw2 = if stdout2.trim_start().starts_with('{') {
&*stdout2
} else {
&*stderr2
};
let parsed2: serde_json::Value = serde_json::from_str(raw2.trim())
.or_else(|_| serde_json::from_str(raw2))
.expect("re-init should emit valid JSON");
assert_eq!(
parsed2["already_initialized"], true,
"re-init: already_initialized must be true"
);
let hint2 = parsed2["hint"]
.as_str()
.expect("hint must be present on re-init");
assert!(
hint2.contains("already") || hint2.contains("doctor"),
"re-init hint should acknowledge workspace exists, got: {hint2:?}"
);
}
#[test]
fn export_arg_errors_have_typed_kind_and_hint_784() {
// #784: `claw export --output` (missing flag value) returned error_kind:"unknown" + hint:null.
// `claw export a.md b.md` (extra positional) also returned unknown+null.
// Both export arg errors now use typed prefixes + usage hint.
let root = unique_temp_dir("export-arg-errors-784");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// Missing --output value
let out1 = run_claw(
&root,
&["--output-format", "json", "export", "--output"],
&[],
);
assert!(!out1.status.success(), "--output with no value should fail");
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let j1: serde_json::Value = stderr1
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("missing --output should emit JSON error");
assert_eq!(
j1["error_kind"], "missing_flag_value",
"missing --output value should be missing_flag_value, got {:?}",
j1["error_kind"]
);
let h1 = j1["hint"]
.as_str()
.expect("missing_flag_value must have hint (#784)");
assert!(
!h1.is_empty() && h1.contains("export"),
"hint must reference export usage, got: {h1:?}"
);
// Extra positional argument
let out2 = run_claw(
&root,
&["--output-format", "json", "export", "first.md", "second.md"],
&[],
);
assert!(!out2.status.success(), "extra positional should fail");
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let j2: serde_json::Value = stderr2
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("extra positional should emit JSON error");
assert_eq!(
j2["error_kind"], "unexpected_extra_args",
"extra positional should be unexpected_extra_args, got {:?}",
j2["error_kind"]
);
let h2 = j2["hint"]
.as_str()
.expect("unexpected_extra_args must have hint (#784)");
assert!(
!h2.is_empty() && h2.contains("export"),
"hint must reference export usage, got: {h2:?}"
);
}
#[test]
fn unknown_subcommand_returns_typed_kind_785() {
// #785: `claw dump` (a near-miss for dump-manifests) returned error_kind:"unknown"
// because the classifier had no arm for "unknown subcommand:" prose prefix.
// Fix: added "unknown_subcommand" arm in classify_error_kind.
let root = unique_temp_dir("unknown-subcommand-785");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// "dump" is close enough to "dump-manifests" to trigger the typo suggestion path
let output = run_claw(&root, &["--output-format", "json", "dump"], &[]);
assert!(!output.status.success(), "unknown subcommand should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
let j: serde_json::Value = stderr
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("unknown subcommand should emit JSON error");
assert_eq!(
j["error_kind"], "unknown_subcommand",
"unknown subcommand should return unknown_subcommand kind, got {:?}",
j["error_kind"]
);
// hint should point at the suggestion and/or --help
let hint = j["hint"].as_str().unwrap_or("");
assert!(
hint.contains("dump-manifests") || hint.contains("--help") || hint.contains("claw"),
"hint should reference the suggested subcommand or help, got: {hint:?}"
);
}
#[test]
fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
// #786: `claw dump-manifests --manifests-dir` (no value) and `--manifests-dir=` (empty)
// both emitted plain "--manifests-dir requires a path" with error_kind:"unknown" + hint:null.
// Fix: use missing_flag_value: prefix + \n usage hint.
let root = unique_temp_dir("dump-manifests-missing-dir-786");
fs::create_dir_all(&root).expect("temp dir");
std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(&root)
.output()
.ok();
// Case 1: --manifests-dir with no following value (next arg is --output-format)
let out1 = run_claw(
&root,
&[
"--output-format",
"json",
"dump-manifests",
"--manifests-dir",
"--output-format",
"json",
],
&[],
);
assert!(!out1.status.success());
let stderr1 = String::from_utf8_lossy(&out1.stderr);
let j1: serde_json::Value = stderr1
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("missing --manifests-dir value should emit JSON error");
assert_eq!(
j1["error_kind"], "missing_flag_value",
"missing --manifests-dir value should be missing_flag_value, got {:?}",
j1["error_kind"]
);
let h1 = j1["hint"]
.as_str()
.expect("missing_flag_value must have hint (#786)");
assert!(
h1.contains("dump-manifests") || h1.contains("manifests-dir"),
"hint should reference dump-manifests usage, got: {h1:?}"
);
// Case 2: --manifests-dir= with empty value
let out2 = run_claw(
&root,
&[
"--output-format",
"json",
"dump-manifests",
"--manifests-dir=",
],
&[],
);
assert!(!out2.status.success());
let stderr2 = String::from_utf8_lossy(&out2.stderr);
let j2: serde_json::Value = stderr2
.lines()
.find(|l| l.trim_start().starts_with('{'))
.and_then(|l| serde_json::from_str(l).ok())
.expect("empty --manifests-dir= should emit JSON error");
assert_eq!(
j2["error_kind"], "missing_flag_value",
"empty --manifests-dir= should be missing_flag_value, got {:?}",
j2["error_kind"]
);
let h2 = j2["hint"]
.as_str()
.expect("missing_flag_value must have hint (#786)");
assert!(!h2.is_empty(), "hint must not be empty");
}