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 ] [--output ] [--output-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::(&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 and agents list 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::>(); 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)> = 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::>(); 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 { 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::>() .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::>() .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::>() .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::>() .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::>() .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::>() .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 --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::>() .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 --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::>() .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 ` 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 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"); }