Compare commits

..

1 Commits

Author SHA1 Message Date
Yeachan-Heo
e5d904edaf Keep skills and agents JSON success envelopes machine-checkable
Constraint: ROADMAP #458 requires top-level status on successful skills and agents JSON outputs without changing error semantics.
Confidence: high
Scope-risk: narrow
Directive: Preserve existing error envelopes and avoid unrelated LSP/MCP changes.
Tested: cargo fmt; cargo test -p commands; cargo build -p rusty-claude-cli --bin claw; rebuilt JSON status sweep for status/mcp/skills/agents/doctor/sandbox/init/system-prompt/version.
Not-tested: full workspace cargo test.
2026-05-25 06:32:34 +00:00
7 changed files with 47 additions and 587 deletions

View File

@@ -7561,21 +7561,3 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
697. **`claw plugins remove <name>` silently returns `status:"ok"` with exit 0 when the named plugin does not exist — no `not_found` error, no non-zero exit, no indication the operation was a no-op; sibling: `claw agents <unknown-subcommand>` returns `action:"help"` with exit 0 instead of a typed `unknown_subcommand` error** — dogfooded 2026-05-25 on `63a5a874`. Reproduction: `claw plugins remove nonexistent-plugin --output-format json </dev/null` returns `{"kind":"plugin","action":"remove","status":"ok","error":null,...}` and exits 0. No plugin named `nonexistent-plugin` exists. A caller cannot distinguish "plugin was successfully removed" from "plugin was never there" — both produce the same `status:"ok"` envelope. Sibling: `claw agents stop nonexistent-agent --output-format json </dev/null` returns `{"kind":"agents","action":"help","unexpected":"stop nonexistent-agent",...}` and exits 0 — unknown subcommand falls through to help output rather than a typed error with exit 1. **Required fix shape:** (a) `plugins remove` must check whether the named plugin exists before reporting success; emit `{"kind":"plugin","action":"remove","status":"error","error_kind":"plugin_not_found","plugin_name":"<name>"}` with exit 1 when the plugin is absent; (b) `agents <unknown>` must emit `{"kind":"agents","action":"error","error_kind":"unknown_subcommand","subcommand":"<token>","supported":["list","help"]}` with exit 1 instead of falling back to help output with exit 0; (c) add regression tests proving both paths exit 1 with typed error envelopes. **Why this matters:** idempotent-but-silent remove is fine for infrastructure tools with explicit idempotency contracts; claw has no such contract, and `status:"ok"` for a name-miss means automation cannot audit whether a remove actually ran vs was a no-op. Source: Jobdori dogfood on `63a5a874`, 2026-05-25.
698. **Config deprecation warnings emit once per `ConfigLoader::load()` call, so surfaces that call `load()` multiple times in a single invocation emit duplicate `warning:` lines to stderr — `claw plugins list` and `claw mcp list` each print the same deprecation warning twice** — dogfooded 2026-05-25 on `c345ce6d`. Reproduction: `echo '{"enabledPlugins": {}}' > ~/.claw/settings.json && claw plugins list 2>&1 | grep warning` prints the same `field "enabledPlugins" is deprecated. Use "plugins.enabled" instead` line twice. Root cause: `config.rs:304` emits `eprintln!("warning: {warning}")` for every warning in every `loader.load()` call; surfaces like `plugins_command_payload_for` and `render_mcp_report_json_for` each trigger an independent `loader.load()` (one for runtime config, one inside the command handler), multiplying the stderr output. `skills list` emits only one warning because its command path calls `load()` once; `plugins` and `mcp` emit two. **Required fix shape:** (a) track already-emitted warning strings in a process-lifetime `std::sync::OnceLock<Mutex<HashSet<String>>>` in `config.rs` and skip re-emitting duplicates within the same process run; or (b) collect all warnings at a single call site after all config loads are complete and emit once with dedup; or (c) change `load()` to return warnings alongside the result instead of eagerly printing them, letting call sites emit once. Option (a) is a minimal one-file fix. **Why this matters:** duplicate warnings make the CLI look buggy, cause CI log noise, and — when the deprecation warning fires on every invocation — are more likely to be `tail -f`'d away than acted on. A single clean warning per invocation is the standard. Source: Jobdori dogfood on `c345ce6d`, 2026-05-25.
699. **`bootstrap-plan` and `dump-manifests` JSON/help probes fall through to prompt/auth instead of local command dispatch unless global flags are positioned just so; with normal subcommand-style argv they either hang behind the spinner or return `missing_credentials`, making local startup/manifest introspection non-local** — dogfooded 2026-05-25 on `11a6e081a` after the ROADMAP #458 envelope sweep. Reproduction with the freshly rebuilt debug binary: `./rust/target/debug/claw bootstrap-plan --output-format json </dev/null` times out after 10s with spinner bytes on stdout and only a config deprecation warning on stderr in the normal home env; in an isolated env without credentials it exits 1 as `missing_credentials` instead of returning the local bootstrap phase JSON. `./rust/target/debug/claw dump-manifests --output-format json </dev/null` behaves the same. The help forms `bootstrap-plan --help --output-format json` and `dump-manifests --help --output-format json` also time out behind the spinner. This is distinct from #690/#692, which assumed these help paths were intercepted and only lacked schema depth; current dogfood shows an even lower-level routing/argv-order gap on the rebuilt `11a6e081a` binary. **Why it matters:** `bootstrap-plan` and `dump-manifests` are supposed to be credential-free local introspection/preflight commands. If the parser treats `--output-format json` after the subcommand as prompt text or routes into the provider/auth path, claws cannot safely probe startup phases or manifest availability without API credentials and timeout guards. **Required fix shape:** (a) make subcommand-local `--output-format json` and `--help --output-format json` dispatch before prompt/auth for `bootstrap-plan` and `dump-manifests`; (b) guarantee these commands are local-only and do not require provider credentials; (c) add regression tests for both argv orders if global flags are supported after subcommands, or emit a fast typed `cli_parse` error with `status:"error"` and no spinner if not supported; (d) acceptance: `env -i HOME=/tmp/empty PATH=/usr/bin:/bin TERM=dumb ./rust/target/debug/claw bootstrap-plan --output-format json </dev/null | jq -e '.kind=="bootstrap-plan" and (.phases|length>0)'` and the analogous dump-manifests/help probes must return within 1s without credentials. Source: gaebal-gajae dogfood for the 2026-05-25 07:30 Clawhip nudge.
700. **`claw help --output-format json` emits `{"kind":"help","message":"<prose>"}` with no `status` field, and `claw sessions` (via `/sessions list` slash command) emits `{"kind":"session_list",...}` — both are envelope shape inconsistencies relative to the now-complete #458 sweep** — dogfooded 2026-05-25 on `eb7c14c4`. (1) `help` JSON: all 12 probed surfaces now have `status ∈ {ok,warn,error,unsupported}` after the #458 sweep; `help` is the one remaining surface that emits JSON but lacks `status`. The envelope has only `kind:"help"` and `message:"<prose blob>"` — no machine-readable status, no structured sections array (per #325/#686/#687/#688). (2) `session_list` kind: `claw sessions` and the `/sessions list` slash command emit `"kind":"session_list"` — all other surfaces use the subcommand name as the kind token (`kind:"skills"`, `kind:"agents"`, `kind:"mcp"` etc). `session_list` is a verb+noun compound that breaks the convention and makes kind-based routing require a special case. **Required fix shape:** (a) add `"status": "ok"` to all `help` JSON emission sites (`print_help` at line ~7120 and ~7167, plus inline REPL help at ~3924 in `main.rs`); (b) rename `"kind":"session_list"` to `"kind":"sessions"` at the two emission sites (lines ~3912, ~6385) and update any test assertions; keep a `"action":"list"` field for the action discriminant. Both are 13 line changes. **Why this matters:** the #458 acceptance check `for c in … ; do claw $c --output-format json | jq -e '.status | IN(…)' || echo FAIL; done` still FAILs for `help`; `session_list` kind breaks any kind-routing table that maps surface names to handler IDs. Source: Jobdori dogfood on `eb7c14c4`, 2026-05-25.
700. **Top-level `help --output-format json` hangs behind the prompt spinner instead of returning bounded help JSON or a typed parse error** — dogfooded 2026-05-25 on freshly rebuilt `f9e98a263` during the 08:30 Clawhip nudge. Reproduction: `timeout 8 ./rust/target/debug/claw help --output-format json </dev/null` exits 124; stdout only shows spinner/TUI bytes and stderr is empty or only unrelated config warning depending on HOME. Positive control in the same rebuilt binary: `version --output-format json` returns promptly with `{kind:"version",status:"ok",git_sha:"f9e98a263"}`. This is adjacent to #699 but distinct: #699 covers `bootstrap-plan`/`dump-manifests` local subcommands falling through to prompt/auth; #700 covers the root bootstrap help surface itself. **Why it matters:** `help --output-format json` is the first command a wrapper or new claw probes to discover the CLI. A hanging help path forces every orchestrator to wrap discovery in external timeouts and cannot distinguish “unsupported JSON help” from “provider/auth prompt path accidentally started.” **Required fix shape:** (a) intercept top-level `help --output-format json` before prompt/provider startup; (b) return bounded JSON with `kind:"help"`, `status:"ok"`, command list, formats, and schema/version metadata, or if this argv order is intentionally unsupported, fail fast with `kind:"cli_parse"`, `status:"error"`, no spinner; (c) add regression proving `env -i HOME=/tmp/empty PATH=/usr/bin:/bin TERM=dumb ./rust/target/debug/claw help --output-format json </dev/null` exits within 1s without credentials. Source: gaebal-gajae dogfood for the 2026-05-25 08:30 Clawhip nudge.
701. **`claw doctor --output-format json` `checks[].details[]` entries are prose strings like `"Enabled true"` — no structured key/value — so downstream claws must split on whitespace to extract scalar values from the detail table** — dogfooded 2026-05-25 on `f9e98a26`. Reproduction: `claw doctor --output-format json | jq '.checks[] | {name, details}'` returns `details: ["Repo exists true", "Worktree exists true", ...]` — each detail is a human-formatted table row. A caller wanting `sandbox.enabled` must do `split(/\s+/)` and strip padding. The `checks[].status` field is already structured (`"ok"/"warn"/"error"`), but the supporting evidence is prose-only. **Required fix shape:** (a) change `details: string[]` to `details: {key: string, value: string | bool | number | null}[]` for all doctor checks; use booleans for `true`/`false` values and numbers for counts; keep a `raw` or `label` string for human rendering; (b) update `format_doctor_detail` / `build_boot_preflight_details` etc. in `main.rs` to emit structured objects instead of padded strings; (c) update test assertions in `output_format_contract.rs` to verify `details[0].key` and `details[0].value` shapes; (d) acceptance: `claw doctor --output-format json | jq '.checks[] | .details[] | if type == "string" then error else . end'` should not error. **Why this matters:** `details[]` is the only per-check evidence available to a claw diagnosing a `"warn"` or `"error"` check; if the values are prose strings, the claw must scrape rather than parse. Source: Jobdori dogfood on `f9e98a26`, 2026-05-25.
702. **`claw agents --output-format json` per-agent entries use `source: {id, label}` while `claw skills --output-format json` per-skill entries use `origin: {id, detail_label}` — same concept, different field name and key shape, breaking any generic inventory parser** — dogfooded 2026-05-25 on `ee24ff2d`. Reproduction: `claw agents --output-format json | jq '.agents[0] | {source}` returns `{"source": {"id": "project_claw", "label": "Project roots"}}`. `claw skills --output-format json | jq '.skills[0] | {origin}` returns `{"origin": {"id": "skills_dir", "detail_label": null}}`. The provenance concept is the same (where the definition file was loaded from), but the field name (`source` vs `origin`), the human-label key (`label` vs `detail_label`), and the presence of `detail_label: null` vs no such field create two incompatible schemas. A generic claw that wants "where did this agent/skill come from?" must hard-code separate paths for agents and skills. **Required fix shape:** (a) normalise to a single shape — either `source: {id, label, detail_label?}` or `origin: {id, label, detail_label?}` — used identically in agent, skill, and any future resource listings; (b) update test assertions in `output_format_contract.rs` to verify the unified shape; (c) add a cross-resource schema test that parses both agents and skills provenance through the same JSON path. **Why it matters:** multi-resource orchestration (listing agents and skills to pick delegation targets) requires a uniform field layout; name divergence forces per-kind special-casing in every consumer. Source: Jobdori dogfood on `ee24ff2d`, 2026-05-25.
703. **`claw plugins --output-format json` list response uses prose `message` for inventory summary instead of a structured `summary: {total, enabled, disabled}` object, and leaks `reload_runtime`/`target` into the list envelope** — dogfooded 2026-05-25 on `5bca9ef0`. `claw skills --output-format json` returns `summary: {"active":81,"shadowed":47,"total":128}` — fully machine-readable. `claw plugins --output-format json` returns `message: "Plugins\n example-bundled v0.1.0 enabled\n sample-hooks v0.1.0 disabled"` — prose that requires scraping to count plugins. The envelope also includes `reload_runtime: false` and `target: null` which are operation-result fields, not list-response fields (they're only meaningful after install/enable/disable/uninstall). A generic claw computing "how many plugins are active?" cannot do so without text parsing. **Required fix shape:** (a) add `summary: {total, enabled, disabled, load_failures}` to the `plugins list` JSON envelope; (b) drop `reload_runtime` and `target` from the list response (they belong only in install/enable/disable/uninstall/update responses); (c) keep `message` as an optional human field alongside the structured summary; (d) update `output_format_contract.rs` to assert plugins list has `summary.total` and no `reload_runtime`. **Why it matters:** plugin-count queries and health checks require machine-readable inventory summaries; matching `skills` schema parity enables generic resource health checks across both subsystems. Source: Jobdori dogfood on `5bca9ef0`, 2026-05-25.
704. **`claw doctor --output-format json` all `checks[].label` fields are `null` — downstream claws cannot identify which check produced a `warn`/`error` without scraping the prose `name` or `details[]` array** — dogfooded 2026-05-25 on `1a6f54b9`. Reproduction: `claw doctor --output-format json | jq '[.checks[] | {label, status}]'` returns 7 entries all with `"label": null`. The `status` field correctly encodes `"ok"/"warn"/"error"` but there is no stable machine-readable identifier for each check. A claw automating `claw doctor` must either enumerate checks by positional index (fragile) or scrape the `name` prose string (brittle). **Required fix shape:** (a) add a stable `id` or `label` field to each `DiagnosticCheck` (e.g. `"credentials"`, `"git"`, `"sandbox"`, `"config"`, `"mcp"`, `"trust"`, `"workspace"`) that downstream parsers can key on; (b) the field should be `snake_case` and never change across releases; (c) the `name` field can remain as the human-readable title; (d) add regression asserting at least one check has a non-null `label` in `output_format_contract.rs`. **Why it matters:** doctor automation (e.g. preflight gates, CI health checks) requires routing on which check failed, not just that *a* check failed; positional-index routing breaks whenever a new check is added. Source: Jobdori dogfood on `1a6f54b9`, 2026-05-25.
705. **`status` and `export` usage JSON `estimated_cost_usd` is a string `"$0.0000"` not a number — downstream claws must strip `$` and parse float to compute costs** — dogfooded 2026-05-25 on `8f809d9a`. `claw status --output-format json | jq '.usage.estimated_cost_usd'` returns `"$0.0000"` (string). Cost aggregation or threshold checks require `parseFloat(x.replace("$",""))`. **Fix shape:** emit `estimated_cost_usd` as a JSON number and add `estimated_cost_usd_formatted` as the display string. Partial fix landed: `estimated_cost_usd_num` (float) added as a companion field at `cb...` alongside the legacy string field for backwards compatibility; `estimated_cost_usd` string preserved. Source: Jobdori dogfood on `8f809d9a`, 2026-05-25.
706. **`claw skills show <name> --output-format json` silently returns `status:"ok"` with empty `skills:[]` when the named skill does not exist — downstream claws cannot distinguish "no skill installed" from "skill name typo"** — dogfooded 2026-05-26 on `f84799c8`. Reproduction: `claw skills show nonexistent --output-format json``{kind:"skills", action:"list", status:"ok", skills:[], summary:{total:0,...}}` exit 0. A claw checking whether a skill is available treats empty success as "no skills installed anywhere" rather than "skill not found". **Fix shape:** return `{kind:"skills", action:"show", status:"error", error_kind:"skill_not_found", requested:"<name>"}` + exit 1 when `show <name>` matches nothing; landed at `...`. Source: Jobdori dogfood on `f84799c8`, 2026-05-26.

1
rust/Cargo.lock generated
View File

@@ -2124,7 +2124,6 @@ dependencies = [
"serde_json",
"sha2",
"telemetry",
"tempfile",
"tokio",
"walkdir",
]

View File

@@ -2301,9 +2301,12 @@ pub fn handle_plugins_slash_command(
reload_runtime: true,
})
}
Some(other) => Err(PluginError::CommandFailed(format!(
"unknown_plugins_action: '{other}' is not a supported /plugins action. Use list, install, enable, disable, uninstall, or update."
))),
Some(other) => Ok(PluginsCommandResult {
message: format!(
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
),
reload_runtime: false,
}),
}
}
@@ -2485,17 +2488,6 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
.into_iter()
.filter(|s| s.name.to_lowercase() == name)
.collect();
// #706: return typed error when named skill is not found instead of silent empty list
if matched.is_empty() {
return Ok(json!({
"kind": "skills",
"action": "show",
"status": "error",
"error_kind": "skill_not_found",
"message": format!("skill '{}' not found", name),
"requested": name,
}));
}
Ok(render_skills_report_json(&matched))
}
Some("install") => Ok(render_skills_usage_json(Some("install"))),
@@ -2814,11 +2806,7 @@ fn render_mcp_report_json_for(
runtime_config.mcp().get(server_name),
);
if let Some(map) = value.as_object_mut() {
// Only override status to "ok" if the server was found;
// render_mcp_server_report_json already sets status:"error" for not-found.
if map.get("found") == Some(&Value::Bool(true)) {
map.insert("status".to_string(), Value::String("ok".to_string()));
}
map.insert("status".to_string(), Value::String("ok".to_string()));
map.insert("config_load_error".to_string(), Value::Null);
}
Ok(value)
@@ -3248,12 +3236,7 @@ fn resolve_skill_install_source(source: &str, cwd: &Path) -> std::io::Result<Ski
} else {
cwd.join(candidate)
};
let source = fs::canonicalize(&source).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("skill source '{}' not found: {e}", source.display()),
)
})?;
let source = fs::canonicalize(&source)?;
if source.is_dir() {
let prompt_path = source.join("SKILL.md");
@@ -3762,7 +3745,6 @@ fn render_skill_install_report(skill: &InstalledSkill) -> String {
fn render_skill_install_report_json(skill: &InstalledSkill) -> Value {
json!({
"kind": "skills",
"status": "ok",
"action": "install",
"status": "ok",
"result": "installed",
@@ -3907,7 +3889,6 @@ fn render_mcp_server_report_json(
Some(server) => json!({
"kind": "mcp",
"action": "show",
"status": "ok",
"working_directory": cwd.display().to_string(),
"found": true,
"server": mcp_server_json(server_name, server),
@@ -3915,8 +3896,6 @@ fn render_mcp_server_report_json(
None => json!({
"kind": "mcp",
"action": "show",
"status": "error",
"error_kind": "server_not_found",
"working_directory": cwd.display().to_string(),
"found": false,
"server_name": server_name,
@@ -4133,17 +4112,9 @@ fn definition_source_id(source: DefinitionSource) -> &'static str {
}
fn definition_source_json(source: DefinitionSource) -> Value {
definition_source_json_with_detail(source, None)
}
fn definition_source_json_with_detail(
source: DefinitionSource,
detail_label: Option<&'static str>,
) -> Value {
json!({
"id": definition_source_id(source),
"label": source.label(),
"detail_label": detail_label,
})
}
@@ -4177,7 +4148,7 @@ fn skill_summary_json(skill: &SkillSummary) -> Value {
json!({
"name": &skill.name,
"description": &skill.description,
"source": definition_source_json_with_detail(skill.source, skill.origin.detail_label()),
"source": definition_source_json(skill.source),
"origin": skill_origin_json(skill.origin),
"active": skill.shadowed_by.is_none(),
"shadowed_by": skill.shadowed_by.map(definition_source_json),
@@ -5480,18 +5451,7 @@ mod tests {
assert_eq!(report["summary"]["shadowed"], 1);
assert_eq!(report["skills"][0]["name"], "plan");
assert_eq!(report["skills"][0]["source"]["id"], "project_claw");
assert_eq!(report["skills"][0]["source"]["label"], "Project roots");
assert_eq!(
report["skills"][0]["source"]["detail_label"],
serde_json::Value::Null
);
assert_eq!(report["skills"][1]["name"], "deploy");
assert_eq!(report["skills"][1]["source"]["id"], "project_claw");
assert_eq!(report["skills"][1]["source"]["label"], "Project roots");
assert_eq!(
report["skills"][1]["source"]["detail_label"],
"legacy /commands"
);
assert_eq!(report["skills"][1]["origin"]["id"], "legacy_commands_dir");
assert_eq!(report["skills"][3]["shadowed_by"]["id"], "project_claw");

View File

@@ -342,7 +342,6 @@ where
let mut tool_results = Vec::new();
let mut prompt_cache_events = Vec::new();
let mut iterations = 0;
let mut auto_compaction = None;
loop {
iterations += 1;
@@ -398,12 +397,6 @@ where
.map_err(|error| RuntimeError::new(error.to_string()))?;
assistant_messages.push(assistant_message);
// Run auto-compaction check before next API call, including on the terminal
// (no-tool) iteration, to prevent unbounded session growth (#3106).
if let Some(compaction) = self.maybe_auto_compact() {
auto_compaction = Some(compaction);
}
if pending_tool_uses.is_empty() {
break;
}
@@ -510,6 +503,8 @@ where
}
}
let auto_compaction = self.maybe_auto_compact();
let summary = TurnSummary {
assistant_messages,
tool_results,

View File

@@ -217,22 +217,16 @@ fn main() {
.any(|w| w[0] == "--output-format" && w[1] == "json")
|| argv.iter().any(|a| a == "--output-format=json");
if json_output {
// #77/#696: classify error by prefix so downstream claws can route
// without regex-scraping prose. Keep the legacy `type`/`kind`
// fields and add the stable status/error_kind/action contract used
// by non-interactive command guards.
// #77: classify error by prefix so downstream claws can route without
// regex-scraping the prose. Split short-reason from hint-runbook.
let kind = classify_error_kind(&message);
let (short_reason, hint) = split_error_hint(&message);
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"kind": kind,
"status": "error",
"error_kind": kind,
"error": short_reason,
"message": short_reason,
"action": "abort",
"kind": kind,
"hint": hint,
"exit_code": 1,
})
@@ -304,16 +298,6 @@ fn classify_error_kind(message: &str) -> &'static str {
"unknown_agents_subcommand"
} else if message.contains("is not installed") {
"plugin_not_found"
} else if (message.contains("skill source") && message.contains("not found"))
|| message.starts_with("skill '")
{
"skill_not_found"
} else if message.contains("Unsupported config section") {
"unsupported_config_section"
} else if message.contains("unknown_plugins_action") {
"unknown_plugins_action"
} else if message.contains("is a slash command") || message.starts_with("interactive_only:") {
"interactive_only"
} else {
"unknown"
}
@@ -984,16 +968,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
if let Some(action) = parse_local_help_action(&rest, output_format) {
return action;
}
// #696: `claw compact` is the bare name of the interactive `/compact`
// slash command, not a prompt. When extra args such as `--help` appear
// after the word `compact`, the generic prompt fallback used to send
// `compact --help` to provider startup and could hang under closed stdin /
// JSON output. Fail closed before any provider, prompt, TUI, or spinner
// startup. `claw --resume SESSION.jsonl /compact` remains the supported
// non-interactive session compaction path.
if rest.first().map(String::as_str) == Some("compact") {
return Err(compact_interactive_only_error());
}
if let Some(action) = parse_single_word_command_alias(
&rest,
&model,
@@ -1329,11 +1303,6 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
Some(guidance)
}
fn compact_interactive_only_error() -> String {
"interactive_only: `claw compact` is an interactive/session command. Start `claw` and run `/compact`, or use `claw --resume SESSION.jsonl /compact` to compact an existing session."
.to_string()
}
fn removed_auth_surface_error(command_name: &str) -> String {
format!(
"`claw {command_name}` has been removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead."
@@ -2020,14 +1989,7 @@ impl DiagnosticCheck {
}
fn json_value(&self) -> Value {
// Derive a stable snake_case id from the check name for machine-readable keying (#704).
let id = self
.name
.to_ascii_lowercase()
.replace(' ', "_")
.replace('-', "_");
let mut value = Map::from_iter([
("id".to_string(), Value::String(id.clone())),
(
"name".to_string(),
Value::String(self.name.to_ascii_lowercase()),
@@ -2047,37 +2009,6 @@ impl DiagnosticCheck {
.collect::<Vec<_>>(),
),
),
(
// #701: structured key/value pairs parsed from prose detail strings.
// Each detail string is `"Key Label value"` separated by 2+ spaces.
// Booleans (`true`/`false`) and integers are emitted as JSON scalars.
"detail_entries".to_string(),
Value::Array(
self.details
.iter()
.map(|s| {
// Split on first run of 2+ spaces to separate key from value.
let parts: Vec<&str> = s.splitn(2, " ").collect();
if parts.len() == 2 {
let k = parts[0].trim().to_string();
let v_str = parts[1].trim();
let v: Value = if v_str == "true" {
Value::Bool(true)
} else if v_str == "false" {
Value::Bool(false)
} else if let Ok(n) = v_str.parse::<i64>() {
Value::Number(n.into())
} else {
Value::String(v_str.to_string())
};
json!({"key": k, "value": v})
} else {
json!({"key": s.trim(), "value": Value::Null})
}
})
.collect::<Vec<_>>(),
),
),
]);
value.extend(self.data.clone());
Value::Object(value)
@@ -2922,7 +2853,6 @@ fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box<dyn st
"{}",
serde_json::to_string_pretty(&json!({
"kind": "bootstrap-plan",
"status": "ok",
"phases": phases,
}))?
),
@@ -3978,9 +3908,7 @@ fn run_resume_command(
session: session.clone(),
message: Some(text),
json: Some(serde_json::json!({
"kind": "sessions",
"status": "ok",
"action": "list",
"kind": "session_list",
"sessions": session_ids,
"session_details": session_details,
"active": active_id,
@@ -4112,7 +4040,7 @@ fn run_resume_command(
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
"total_tokens": usage.total_tokens(),
"estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.estimate_cost_usd().total_cost_usd(),
"estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()),
"pricing": "estimated-default",
})),
})
@@ -4185,7 +4113,6 @@ fn run_resume_command(
)),
json: Some(serde_json::json!({
"kind": "export",
"status": "ok",
"file": export_path.display().to_string(),
"message_count": msg_count,
})),
@@ -4229,31 +4156,17 @@ fn run_resume_command(
let cwd = env::current_dir()?;
let payload = plugins_command_payload_for(&cwd, action.as_deref(), target.as_deref())?;
let action_str = action.as_deref().unwrap_or("list");
let enabled_count = payload
.plugins
.iter()
.filter(|p| p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false))
.count();
let disabled_count = payload.plugins.len().saturating_sub(enabled_count);
let mut json = serde_json::json!({
let json = serde_json::json!({
"kind": "plugin",
"action": action_str,
"target": target,
"status": payload.status,
"summary": {
"total": payload.plugins.len(),
"enabled": enabled_count,
"disabled": disabled_count,
"load_failures": payload.load_failures.len(),
},
"config_load_error": payload.config_load_error,
"message": &payload.message,
"reload_runtime": payload.reload_runtime,
"plugins": payload.plugins,
"load_failures": payload.load_failures,
});
if action_str != "list" {
json["target"] = serde_json::json!(target);
json["reload_runtime"] = serde_json::json!(payload.reload_runtime);
json["message"] = serde_json::json!(&payload.message);
}
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(payload.message),
@@ -4280,7 +4193,7 @@ fn run_resume_command(
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
"cache_read_input_tokens": usage.cache_read_input_tokens,
"total_tokens": usage.total_tokens(),
"estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.estimate_cost_usd().total_cost_usd(),
"estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()),
"pricing": "estimated-default",
})),
})
@@ -5924,8 +5837,7 @@ impl LiveCli {
// Propagate ok:false → non-zero exit so automation callers
// can rely on exit code instead of inspecting the envelope.
// (#68: mcp error envelopes previously always exited 0.)
let is_error = value.get("ok").and_then(|v| v.as_bool()) == Some(false)
|| value.get("status").and_then(|v| v.as_str()) == Some("error");
let is_error = value.get("ok").and_then(|v| v.as_bool()) == Some(false);
println!("{}", serde_json::to_string_pretty(&value)?);
if is_error {
std::process::exit(1);
@@ -5942,19 +5854,10 @@ impl LiveCli {
let cwd = env::current_dir()?;
match output_format {
CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?),
CliOutputFormat::Json => {
let result = handle_skills_slash_command_json(args, &cwd)?;
let is_error = result.get("status").and_then(|v| v.as_str()) == Some("error");
println!("{}", serde_json::to_string_pretty(&result)?);
if is_error {
return Err(result
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("skills command failed")
.to_string()
.into());
}
}
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&handle_skills_slash_command_json(args, &cwd)?)?
),
}
Ok(())
}
@@ -5968,36 +5871,20 @@ impl LiveCli {
let payload = plugins_command_payload_for(&cwd, action, target)?;
match output_format {
CliOutputFormat::Text => println!("{}", payload.message),
CliOutputFormat::Json => {
let action_str = action.unwrap_or("list");
let enabled_count = payload
.plugins
.iter()
.filter(|p| p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false))
.count();
let disabled_count = payload.plugins.len().saturating_sub(enabled_count);
let mut obj = json!({
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "plugin",
"action": action_str,
"action": action.unwrap_or("list"),
"target": target,
"status": payload.status,
"summary": {
"total": payload.plugins.len(),
"enabled": enabled_count,
"disabled": disabled_count,
"load_failures": payload.load_failures.len(),
},
"config_load_error": payload.config_load_error,
"message": payload.message,
"reload_runtime": payload.reload_runtime,
"plugins": payload.plugins,
"load_failures": payload.load_failures,
});
// Only include operation-result fields for mutating actions
if action_str != "list" {
obj["target"] = json!(target);
obj["reload_runtime"] = json!(payload.reload_runtime);
obj["message"] = json!(payload.message);
}
println!("{}", serde_json::to_string_pretty(&obj)?);
}
}))?
),
}
Ok(())
}
@@ -6493,9 +6380,7 @@ fn run_resumed_session_command(
session: session.clone(),
message: Some(text),
json: Some(serde_json::json!({
"kind": "sessions",
"status": "ok",
"action": "list",
"kind": "session_list",
"sessions": session_ids,
"session_details": session_details_json(&sessions),
"active": active_id,
@@ -6777,7 +6662,7 @@ fn status_json_value(
"cumulative_cache_creation_input": usage.cumulative.cache_creation_input_tokens,
"cumulative_cache_read_input": usage.cumulative.cache_read_input_tokens,
"cumulative_total": usage.cumulative.total_tokens(),
"estimated_cost_usd": format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.cumulative.estimate_cost_usd().total_cost_usd(),
"estimated_cost_usd": format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()),
"pricing": "estimated-default",
"estimated_tokens": usage.estimated_tokens,
},
@@ -7231,7 +7116,6 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str {
fn render_export_help_json() -> serde_json::Value {
json!({
"kind": "help",
"status": "ok",
"topic": "export",
"command": "export",
"usage": "claw export [--session <id|latest>] [--output <path>] [--output-format <format>]",
@@ -7279,7 +7163,6 @@ fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value {
json!({
"kind": "help",
"status": "ok",
"topic": local_help_topic_command(topic),
"command": local_help_topic_command(topic),
"message": render_help_topic(topic),
@@ -7472,7 +7355,6 @@ fn render_config_json(
let base = serde_json::json!({
"kind": "config",
"status": "ok",
"cwd": cwd.display().to_string(),
"loaded_files": loaded_paths.len(),
"merged_keys": runtime_config.merged().len(),
@@ -7491,8 +7373,6 @@ fn render_config_json(
other => {
return Ok(serde_json::json!({
"kind": "config",
"status": "error",
"error_kind": "unsupported_config_section",
"section": other,
"ok": false,
"error": format!("Unsupported config section '{other}'. Use env, hooks, model, or plugins."),
@@ -7680,7 +7560,6 @@ fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::er
if !in_git_repo {
return Ok(serde_json::json!({
"kind": "diff",
"status": "error",
"result": "no_git_repo",
"detail": format!("{} is not inside a git project", cwd.display()),
}));
@@ -7689,7 +7568,6 @@ fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::er
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
Ok(serde_json::json!({
"kind": "diff",
"status": "ok",
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
"staged": staged.trim(),
"unstaged": unstaged.trim(),
@@ -8212,7 +8090,6 @@ fn run_export(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "export",
"status": "ok",
"message": report,
"session_id": handle.id,
"file": path.display().to_string(),
@@ -8234,7 +8111,6 @@ fn run_export(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "export",
"status": "ok",
"session_id": handle.id,
"file": handle.path.display().to_string(),
"messages": session.messages.len(),
@@ -10605,7 +10481,6 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
"{}",
serde_json::to_string_pretty(&json!({
"kind": "help",
"status": "ok",
"message": message,
}))?
),
@@ -11450,8 +11325,6 @@ mod tests {
#[test]
fn rejects_unknown_allowed_tools() {
let _env_guard = env_lock();
let _cwd_guard = cwd_guard();
let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
.expect_err("tool should be rejected");
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
@@ -11459,8 +11332,6 @@ mod tests {
#[test]
fn rejects_empty_allowed_tools_flag() {
let _env_guard = env_lock();
let _cwd_guard = cwd_guard();
for raw in ["", ",,"] {
let error = parse_args(&["--allowedTools".to_string(), raw.to_string()])
.expect_err("empty allowedTools should be rejected");

View File

@@ -2,9 +2,9 @@
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use std::time::{SystemTime, UNIX_EPOCH};
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
use serde_json::Value;
@@ -245,84 +245,6 @@ stderr:
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
let workspace = unique_temp_dir("compact-nontty-json-help");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let output = run_claw_closed_stdin_with_timeout(
&workspace,
&config_home,
&home,
&["compact", "--output-format", "json", "--help"],
Duration::from_secs(2),
);
assert!(
!output.status.success(),
"compact json help should fail non-zero"
);
assert!(
output.stdout.is_empty(),
"compact json help should not start a prompt/spinner on stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
let parsed: Value = serde_json::from_str(stderr.trim()).expect("stderr should be JSON error");
assert_eq!(parsed["status"], "error");
assert_eq!(parsed["error_kind"], "interactive_only");
assert_eq!(parsed["action"], "abort");
assert!(
parsed["message"]
.as_str()
.unwrap_or_default()
.contains("claw compact"),
"message should name compact: {parsed}"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn compact_subcommand_text_fails_fast_when_stdin_closed() {
let workspace = unique_temp_dir("compact-nontty-text");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let output = run_claw_closed_stdin_with_timeout(
&workspace,
&config_home,
&home,
&["compact"],
Duration::from_secs(2),
);
assert!(
!output.status.success(),
"compact text should fail non-zero"
);
assert!(
output.stdout.is_empty(),
"compact text should not start a prompt/spinner on stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
assert!(
stderr.contains("[error-kind: interactive_only]"),
"{stderr}"
);
assert!(stderr.contains("claw compact"), "{stderr}");
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
fn run_claw(
cwd: &std::path::Path,
config_home: &std::path::Path,
@@ -344,48 +266,6 @@ fn run_claw(
command.output().expect("claw should launch")
}
fn run_claw_closed_stdin_with_timeout(
cwd: &std::path::Path,
config_home: &std::path::Path,
home: &std::path::Path,
args: &[&str],
timeout: Duration,
) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(cwd)
.env_clear()
.env("CLAW_CONFIG_HOME", config_home)
.env("HOME", home)
.env("NO_COLOR", "1")
.env("PATH", "/usr/bin:/bin")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.args(args)
.spawn()
.expect("claw should launch");
let start = Instant::now();
loop {
if child.try_wait().expect("try_wait should succeed").is_some() {
return child.wait_with_output().expect("output should collect");
}
if start.elapsed() > timeout {
let _ = child.kill();
let output = child
.wait_with_output()
.expect("killed output should collect");
panic!(
"claw did not exit within {:?}\nstdout:\n{}\nstderr:\n{}",
timeout,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(10));
}
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)

View File

@@ -16,10 +16,6 @@ fn help_emits_json_when_requested() {
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")
@@ -33,10 +29,6 @@ fn export_help_emits_bounded_json_when_requested_384() {
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!(
@@ -202,31 +194,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
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"
plugins["reload_runtime"].is_boolean(),
"plugins reload_runtime should be a boolean"
);
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"
plugins["target"].is_null(),
"plugins target should be null when no plugin is targeted"
);
assert_eq!(plugins["status"], "ok");
let plugin_entries = plugins["plugins"].as_array().expect("plugins array");
@@ -377,8 +351,6 @@ fn agents_command_emits_structured_agent_entries_when_requested() {
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");
@@ -386,83 +358,6 @@ fn agents_command_emits_structured_agent_entries_when_requested() {
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");
@@ -470,10 +365,6 @@ fn bootstrap_and_system_prompt_emit_json_when_requested() {
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"]);
@@ -536,12 +427,6 @@ fn doctor_and_resume_status_emit_json_when_requested() {
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<_>>();
@@ -715,22 +600,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
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"
plugins["reload_runtime"].is_boolean(),
"plugins reload_runtime should be a boolean"
);
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"
plugins["target"].is_null(),
"plugins target should be null when no plugin is targeted"
);
}
@@ -1017,25 +893,6 @@ fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasonin
.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)
@@ -1047,87 +904,3 @@ fn unique_temp_dir(label: &str) -> PathBuf {
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"
);
}
#[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"
);
}
}