mirror of
https://github.com/instructkr/claude-code.git
synced 2026-05-25 15:06:44 +00:00
Compare commits
1 Commits
main
...
fix/roadma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5d904edaf |
18
ROADMAP.md
18
ROADMAP.md
@@ -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 1–3 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
1
rust/Cargo.lock
generated
@@ -2124,7 +2124,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"telemetry",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user