Compare commits

...

28 Commits

Author SHA1 Message Date
YeonGyu-Kim
47c0226a61 fix(#708): skills show/info/describe responses now emit action:show instead of action:list; remove duplicate status key from render_skills_report_json 2026-05-26 01:05:07 +09:00
YeonGyu-Kim
26a50d918b Merge pull request #3107 from Yeachan-Heo/fix/issue-698-config-warning-dedup
test: cover config warning dedup for inventory commands
2026-05-26 00:41:39 +09:00
YeonGyu-Kim
401f6b152c fix(#707): init test temp_dir combines AtomicU64 counter+nanos to prevent same-process parallel test collisions 2026-05-26 00:36:07 +09:00
Yeachan-Heo
1b5a9b02c2 test: cover config warning dedup for inventory commands
Add a CLI contract proving plugins list and mcp list emit a deprecated enabledPlugins warning only once per process after config warning dedup landed. Covers ROADMAP #698.
2026-05-25 15:30:48 +00:00
YeonGyu-Kim
dedad14ae4 fix(#706): skills show <name> returns error+exit1 when skill not found; classify_error_kind covers skill_not_found from prose message 2026-05-26 00:04:39 +09:00
YeonGyu-Kim
f84799c8ef fix: auto_compact runs before every iteration break, including terminal no-tool turns; closes #3106 2026-05-25 23:59:04 +09:00
YeonGyu-Kim
732007da8e fix(#705): add estimated_cost_usd_num (float) to usage JSON alongside string field; doc entry filed 2026-05-25 23:33:14 +09:00
YeonGyu-Kim
8f809d9a9e fix(#704): DiagnosticCheck.json_value now emits stable snake_case id field; doctor checks addressable without scraping name prose 2026-05-25 23:04:06 +09:00
YeonGyu-Kim
f6cab2711f docs(roadmap): add #704 doctor checks label:null makes check identity unaddressable by machine parsers 2026-05-25 23:01:22 +09:00
YeonGyu-Kim
1a6f54b970 fix(#703): plugins list JSON now has summary:{total,enabled,disabled,load_failures}; drop reload_runtime/target from list response in both top-level and resume paths 2026-05-25 22:34:20 +09:00
YeonGyu-Kim
1555785294 Merge pull request #3104 from Yeachan-Heo/fix/issue-702-allowed-tools-ci
Stabilize allowedTools rejection contract in CI
2026-05-25 22:03:40 +09:00
YeonGyu-Kim
2f9429cbf0 fix: slash-command guard errors now emit error_kind:interactive_only instead of unknown; covers memory, permissions, review, and any bare_slash_command_guidance path 2026-05-25 22:02:30 +09:00
Yeachan-Heo
4daefc7bd5 Stabilize allowedTools rejection contract in CI
Serialize the allowedTools rejection tests with the existing environment/current-directory test guards so full parallel cargo test runs cannot observe a transient project config or cwd from another test while building the tool registry.\n\nConstraint: Post-merge Rust build workflow run 26399647443 failed only in the full cargo test job while the focused test passed locally.\nRejected: Changing allowedTools parser output | the product contract remains correct and focused reproduction preserves the expected unsupported-tool error.\nConfidence: high\nScope-risk: narrow\nDirective: Keep this as test isolation only; do not bundle inventory provenance or ROADMAP work into this follow-up.\nTested: cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli tests::rejects_unknown_allowed_tools -- --exact --nocapture; cargo test --verbose -p rusty-claude-cli --bin claw; cargo test --verbose; cargo fmt --check; cargo check --workspace --locked; cargo build --workspace --locked\nNot-tested: GitHub Actions rerun before opening PR
2026-05-25 12:55:53 +00:00
YeonGyu-Kim
a7a30627a9 docs(roadmap): add #703 plugins list JSON missing structured summary; leaks reload_runtime/target 2026-05-25 21:31:01 +09:00
YeonGyu-Kim
5bca9ef039 Merge pull request #3103 from Yeachan-Heo/fix/issue-702-inventory-provenance
Fix #702 inventory provenance schema
2026-05-25 21:10:18 +09:00
YeonGyu-Kim
b8eca2a68e fix(#349): plugins unknown action emits status:error + error_kind:unknown_plugins_action + exit 1 instead of status:ok with prose 2026-05-25 21:08:14 +09:00
Yeachan-Heo
566992c331 Unify inventory provenance for generic parsers
Expose a stable source object on agent and skill inventory entries with the same id/label/detail_label keys while preserving skill origin for compatibility.\n\nConstraint: ROADMAP #702 scope only; keep existing skills origin field for compatibility.\nRejected: Rename agent source to origin | would break existing agents consumers and still require per-resource branching during migration.\nConfidence: high\nScope-risk: narrow\nDirective: Future inventory resources should expose provenance through source.id, source.label, and source.detail_label.\nTested: cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo test --manifest-path rust/Cargo.toml -p commands renders_skills_reports_as_json -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test output_format_contract -- --nocapture; cargo build --manifest-path rust/Cargo.toml --workspace --locked; git diff --check\nNot-tested: full cargo test --manifest-path rust/Cargo.toml --workspace
2026-05-25 12:05:50 +00:00
YeonGyu-Kim
36b36267ec fix(#458): add status:ok to config JSON envelope; unknown section now emits status:error + error_kind:unsupported_config_section 2026-05-25 20:33:36 +09:00
YeonGyu-Kim
21a986034e docs(roadmap): add #702 agents source vs skills origin field name inconsistency 2026-05-25 20:02:44 +09:00
YeonGyu-Kim
ee24ff2d83 Merge pull request #3102 from Yeachan-Heo/fix/issue-696-compact-nontty
Fix compact non-TTY hang
2026-05-25 19:41:27 +09:00
Yeachan-Heo
9e6f753640 Fail closed for compact without an interactive session
Route bare compact invocations to a typed interactive-only error before prompt/provider startup so non-TTY JSON probes terminate predictably. Keep the existing resume-session /compact path as the supported automation surface.\n\nConstraint: ROADMAP pinpoint #696 requires no spinner/hang for closed stdin and --output-format json.\nRejected: Broad help-schema rewrites | #699/#700/#701 are outside this PR scope.\nConfidence: high\nScope-risk: narrow\nDirective: Do not route bare slash-command names through the prompt fallback when they require a session.\nTested: cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test compact_output compact_subcommand_ -- --nocapture; cargo build --manifest-path rust/Cargo.toml --workspace; cargo build --manifest-path rust/Cargo.toml --workspace --locked; timeout probes for compact JSON/text with stdin closed.\nNot-tested: Full workspace test suite.
2026-05-25 10:37:12 +00:00
YeonGyu-Kim
de2e32c5d4 fix: skills install nonexistent path emits skill_not_found error kind with descriptive message; classify_error_kind adds skill_not_found branch 2026-05-25 19:34:25 +09:00
YeonGyu-Kim
9d1998b3fd test(#458/#700/#701/#702): add status:ok assertions for help/bootstrap-plan/export-help contracts; add diff/export JSON shape tests 2026-05-25 19:07:03 +09:00
YeonGyu-Kim
181b12f0a9 fix: mcp show <nonexistent> now returns status:error + error_kind:server_not_found + exit 1; extend ok:false gate to also check status:error 2026-05-25 18:34:43 +09:00
YeonGyu-Kim
47521cf178 fix(#701): add detail_entries structured key/value to doctor check JSON; booleans/ints emitted as JSON scalars 2026-05-25 18:02:03 +09:00
YeonGyu-Kim
9c5f190fcc docs(roadmap): add #701 doctor details prose-string gap; details[] should be structured key/value objects 2026-05-25 17:32:04 +09:00
Yeachan-Heo
9f14a7aa9e docs(roadmap): add #700 help JSON prompt fallthrough 2026-05-25 08:30:57 +00:00
YeonGyu-Kim
f9e98a2634 fix(#700): add status:ok to all help JSON envelopes; rename session_list kind to sessions with action:list 2026-05-25 17:05:28 +09:00
8 changed files with 634 additions and 56 deletions

View File

@@ -7565,3 +7565,21 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
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.
707. **`init.rs` test `temp_dir()` uses nanoseconds only — two parallel test runs in the same process can land in the same nanosecond window and collide on the same temp path, causing intermittent test failures** — dogfooded 2026-05-26 on `dedad14a`. `artifacts_with_status_partitions_fresh_and_idempotent_runs` was seen flaking in full-suite parallel runs but passing in isolation. Root cause: `temp_dir()` at `init.rs:383` used only `SystemTime::now().as_nanos()` as the uniqueness token. Two concurrent callers within the same nanosecond window produce the same path. Fix: added `AtomicU64` counter combined with nanoseconds → `rusty-claude-init-{nanos}-{id}` eliminates same-process collisions. Source: Jobdori dogfood on `dedad14a`, 2026-05-26.
708. **`claw skills show <name> --output-format json` returns `action:"list"` even on the show path — `render_skills_report_json` hardcoded `"action":"list"` for all skill responses including show/info/describe** — dogfooded 2026-05-26 on `26a50d91`. `claw skills show korea-weather --output-format json` returned `{action:"list"}` even when the show path was taken. Also had duplicate `"status":"ok"` key in the JSON object. Fix: renamed `render_skills_report_json` to `render_skills_report_json_with_action(skills, action)` and updated all call sites to pass `"list"` or `"show"` appropriately; removed duplicate status key. Source: Jobdori dogfood on `26a50d91`, 2026-05-26.

1
rust/Cargo.lock generated
View File

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

View File

@@ -2301,12 +2301,9 @@ pub fn handle_plugins_slash_command(
reload_runtime: true,
})
}
Some(other) => Ok(PluginsCommandResult {
message: format!(
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
),
reload_runtime: false,
}),
Some(other) => Err(PluginError::CommandFailed(format!(
"unknown_plugins_action: '{other}' is not a supported /plugins action. Use list, install, enable, disable, uninstall, or update."
))),
}
}
@@ -2454,7 +2451,7 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
None | Some("list") => {
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json(&skills))
Ok(render_skills_report_json_with_action(&skills, "list"))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
@@ -2464,12 +2461,12 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
.into_iter()
.filter(|s| s.name.to_lowercase().contains(&filter))
.collect();
Ok(render_skills_report_json(&filtered))
Ok(render_skills_report_json_with_action(&filtered, "list"))
}
Some("show" | "info" | "describe") => {
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json(&skills))
Ok(render_skills_report_json_with_action(&skills, "show"))
}
Some(args)
if args.starts_with("show ")
@@ -2488,7 +2485,18 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
.into_iter()
.filter(|s| s.name.to_lowercase() == name)
.collect();
Ok(render_skills_report_json(&matched))
// #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_with_action(&matched, "show"))
}
Some("install") => Ok(render_skills_usage_json(Some("install"))),
Some(args) if args.starts_with("install ") => {
@@ -2806,7 +2814,11 @@ fn render_mcp_report_json_for(
runtime_config.mcp().get(server_name),
);
if let Some(map) = value.as_object_mut() {
map.insert("status".to_string(), Value::String("ok".to_string()));
// 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("config_load_error".to_string(), Value::Null);
}
Ok(value)
@@ -3236,7 +3248,12 @@ fn resolve_skill_install_source(source: &str, cwd: &Path) -> std::io::Result<Ski
} else {
cwd.join(candidate)
};
let source = fs::canonicalize(&source)?;
let source = fs::canonicalize(&source).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("skill source '{}' not found: {e}", source.display()),
)
})?;
if source.is_dir() {
let prompt_path = source.join("SKILL.md");
@@ -3702,7 +3719,7 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
lines.join("\n").trim_end().to_string()
}
fn render_skills_report_json(skills: &[SkillSummary]) -> Value {
fn render_skills_report_json_with_action(skills: &[SkillSummary], action: &str) -> Value {
let active = skills
.iter()
.filter(|skill| skill.shadowed_by.is_none())
@@ -3710,8 +3727,7 @@ fn render_skills_report_json(skills: &[SkillSummary]) -> Value {
json!({
"kind": "skills",
"status": "ok",
"action": "list",
"status": "ok",
"action": action,
"summary": {
"total": skills.len(),
"active": active,
@@ -3890,6 +3906,7 @@ 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),
@@ -3897,6 +3914,8 @@ 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,
@@ -4113,9 +4132,17 @@ 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,
})
}
@@ -4149,7 +4176,7 @@ fn skill_summary_json(skill: &SkillSummary) -> Value {
json!({
"name": &skill.name,
"description": &skill.description,
"source": definition_source_json(skill.source),
"source": definition_source_json_with_detail(skill.source, skill.origin.detail_label()),
"origin": skill_origin_json(skill.origin),
"active": skill.shadowed_by.is_none(),
"shadowed_by": skill.shadowed_by.map(definition_source_json),
@@ -5442,8 +5469,9 @@ mod tests {
origin: SkillOrigin::SkillsDir,
},
];
let report = super::render_skills_report_json(
let report = super::render_skills_report_json_with_action(
&load_skills_from_roots(&roots).expect("skills should load"),
"list",
);
assert_eq!(report["kind"], "skills");
assert_eq!(report["action"], "list");
@@ -5452,7 +5480,18 @@ 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,6 +342,7 @@ 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;
@@ -397,6 +398,12 @@ 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;
}
@@ -503,8 +510,6 @@ where
}
}
let auto_compaction = self.maybe_auto_compact();
let summary = TurnSummary {
assistant_messages,
tool_results,

View File

@@ -381,11 +381,16 @@ mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("rusty-claude-init-{nanos}"))
// Combine counter + nanoseconds so parallel tests in the same process
// never collide even if two calls land in the same nanosecond (#707).
std::env::temp_dir().join(format!("rusty-claude-init-{nanos}-{id}"))
}
#[test]

View File

@@ -217,16 +217,22 @@ fn main() {
.any(|w| w[0] == "--output-format" && w[1] == "json")
|| argv.iter().any(|a| a == "--output-format=json");
if json_output {
// #77: classify error by prefix so downstream claws can route without
// regex-scraping the prose. Split short-reason from hint-runbook.
// #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.
let kind = classify_error_kind(&message);
let (short_reason, hint) = split_error_hint(&message);
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": short_reason,
"kind": kind,
"status": "error",
"error_kind": kind,
"error": short_reason,
"message": short_reason,
"action": "abort",
"hint": hint,
"exit_code": 1,
})
@@ -298,6 +304,16 @@ 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"
}
@@ -968,6 +984,16 @@ 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,
@@ -1303,6 +1329,11 @@ 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."
@@ -1989,7 +2020,14 @@ 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()),
@@ -2009,6 +2047,37 @@ 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)
@@ -3909,7 +3978,9 @@ fn run_resume_command(
session: session.clone(),
message: Some(text),
json: Some(serde_json::json!({
"kind": "session_list",
"kind": "sessions",
"status": "ok",
"action": "list",
"sessions": session_ids,
"session_details": session_details,
"active": active_id,
@@ -4041,7 +4112,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": format_usd(usage.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.estimate_cost_usd().total_cost_usd(),
"pricing": "estimated-default",
})),
})
@@ -4158,17 +4229,31 @@ 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 json = serde_json::json!({
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!({
"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),
@@ -4195,7 +4280,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": format_usd(usage.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.estimate_cost_usd().total_cost_usd(),
"pricing": "estimated-default",
})),
})
@@ -5839,7 +5924,8 @@ 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);
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");
println!("{}", serde_json::to_string_pretty(&value)?);
if is_error {
std::process::exit(1);
@@ -5856,10 +5942,19 @@ impl LiveCli {
let cwd = env::current_dir()?;
match output_format {
CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&handle_skills_slash_command_json(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());
}
}
}
Ok(())
}
@@ -5873,20 +5968,36 @@ impl LiveCli {
let payload = plugins_command_payload_for(&cwd, action, target)?;
match output_format {
CliOutputFormat::Text => println!("{}", payload.message),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
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!({
"kind": "plugin",
"action": action.unwrap_or("list"),
"target": target,
"action": action_str,
"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(())
}
@@ -6382,7 +6493,9 @@ fn run_resumed_session_command(
session: session.clone(),
message: Some(text),
json: Some(serde_json::json!({
"kind": "session_list",
"kind": "sessions",
"status": "ok",
"action": "list",
"sessions": session_ids,
"session_details": session_details_json(&sessions),
"active": active_id,
@@ -6664,7 +6777,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": format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.cumulative.estimate_cost_usd().total_cost_usd(),
"pricing": "estimated-default",
"estimated_tokens": usage.estimated_tokens,
},
@@ -7118,6 +7231,7 @@ 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>]",
@@ -7165,6 +7279,7 @@ 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),
@@ -7357,6 +7472,7 @@ 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(),
@@ -7375,6 +7491,8 @@ 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."),
@@ -10487,6 +10605,7 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
"{}",
serde_json::to_string_pretty(&json!({
"kind": "help",
"status": "ok",
"message": message,
}))?
),
@@ -11331,6 +11450,8 @@ 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"));
@@ -11338,6 +11459,8 @@ 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};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
use serde_json::Value;
@@ -245,6 +245,84 @@ 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,
@@ -266,6 +344,48 @@ 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,6 +16,10 @@ 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")
@@ -29,6 +33,10 @@ 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!(
@@ -194,13 +202,31 @@ 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["reload_runtime"].is_boolean(),
"plugins reload_runtime should be a boolean"
!plugins
.as_object()
.map_or(false, |o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime"
);
assert!(
plugins["target"].is_null(),
"plugins target should be null when no plugin is targeted"
!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");
@@ -351,6 +377,8 @@ 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");
@@ -358,6 +386,83 @@ 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");
@@ -365,6 +470,10 @@ 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"]);
@@ -427,6 +536,12 @@ 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<_>>();
@@ -600,13 +715,22 @@ 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["reload_runtime"].is_boolean(),
"plugins reload_runtime should be a boolean"
!plugins
.as_object()
.map_or(false, |o| o.contains_key("reload_runtime")),
"plugins list should not include reload_runtime"
);
assert!(
plugins["target"].is_null(),
"plugins target should be null when no plugin is targeted"
!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"
);
}
@@ -817,6 +941,46 @@ fn mcp_degraded_config_and_failed_usage_are_distinct_json_contracts() {
assert!(failed.get("config_load_error").is_none());
}
#[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, &[])
}
@@ -893,6 +1057,25 @@ 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)
@@ -904,3 +1087,87 @@ 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"
);
}
}