mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-06 01:42:47 -04:00
Compare commits
28 Commits
47c0226a61
...
49d5b3fcdc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49d5b3fcdc | ||
|
|
25ee5f3d30 | ||
|
|
922c239863 | ||
|
|
d8a6109085 | ||
|
|
6e44da10fe | ||
|
|
02d1f6a04d | ||
|
|
fe2b13a46a | ||
|
|
92539cad68 | ||
|
|
556a598f2d | ||
|
|
8d80f2ffe7 | ||
|
|
8280f66aa1 | ||
|
|
a0b375c157 | ||
|
|
6a007344ae | ||
|
|
920d5c6c3a | ||
|
|
98f8926998 | ||
|
|
76c8d4801e | ||
|
|
4b8731ba11 | ||
|
|
789ea9aac8 | ||
|
|
590b5b614c | ||
|
|
45dc4f6ff0 | ||
|
|
7037d84d52 | ||
|
|
7d6b2044d5 | ||
|
|
fdde5e45cf | ||
|
|
bae0099c7c | ||
|
|
42c17bc4bf | ||
|
|
f8a901c2a5 | ||
|
|
a30624d6d4 | ||
|
|
8f8eb41e0f |
15
.github/hooks/pre-push
vendored
15
.github/hooks/pre-push
vendored
@@ -11,10 +11,21 @@ set -euo pipefail
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"
|
||||
cd "$repo_root"
|
||||
|
||||
if [[ -x scripts/roadmap-check-ids.sh ]]; then
|
||||
echo "pre-push: scripts/roadmap-check-ids.sh" >&2
|
||||
scripts/roadmap-check-ids.sh
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then
|
||||
echo "pre-push: SKIP_CLAW_PRE_PUSH_BUILD=1 set; skipping cargo workspace build" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f rust/Cargo.toml ]]; then
|
||||
echo "pre-push: rust/Cargo.toml not found; skipping cargo workspace build" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "pre-push: cargo build --manifest-path rust/Cargo.toml --workspace" >&2
|
||||
cargo build --manifest-path rust/Cargo.toml --workspace
|
||||
build_cmd=(cargo build --manifest-path rust/Cargo.toml --workspace --locked)
|
||||
echo "pre-push: ${build_cmd[*]}" >&2
|
||||
"${build_cmd[@]}"
|
||||
|
||||
8
.github/workflows/rust-ci.yml
vendored
8
.github/workflows/rust-ci.yml
vendored
@@ -21,6 +21,8 @@ on:
|
||||
- PARITY.md
|
||||
- PHILOSOPHY.md
|
||||
- ROADMAP.md
|
||||
- scripts/roadmap-*.sh
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
pull_request:
|
||||
@@ -41,6 +43,8 @@ on:
|
||||
- PARITY.md
|
||||
- PHILOSOPHY.md
|
||||
- ROADMAP.md
|
||||
- scripts/roadmap-*.sh
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
workflow_dispatch:
|
||||
@@ -72,6 +76,10 @@ jobs:
|
||||
run: python .github/scripts/check_doc_source_of_truth.py
|
||||
- name: Check release policy docs and local links
|
||||
run: python .github/scripts/check_release_readiness.py
|
||||
- name: Check ROADMAP ids
|
||||
run: scripts/roadmap-check-ids.sh
|
||||
- name: Check ROADMAP helper behavior
|
||||
run: python -m unittest discover -s tests -p test_roadmap_helpers.py
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
|
||||
@@ -33,6 +33,41 @@ cargo build --workspace
|
||||
.\target\debug\claw.exe --help
|
||||
```
|
||||
|
||||
## Local pre-push build gate
|
||||
|
||||
Install the repository-local hook to catch stale compile errors before pushing:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .github/hooks
|
||||
```
|
||||
|
||||
This sets the repo's Git hook directory to `.github/hooks`; if you already use a
|
||||
custom `core.hooksPath`, copy or chain `.github/hooks/pre-push` instead. The hook
|
||||
runs the ROADMAP id guard, then runs
|
||||
`cargo build --manifest-path rust/Cargo.toml --workspace --locked` from the
|
||||
repository root. If you must bypass the cargo build for a docs-only push, set
|
||||
`SKIP_CLAW_PRE_PUSH_BUILD=1`; the hook still runs the ROADMAP guard and prints
|
||||
when the cargo-build escape hatch is used.
|
||||
|
||||
## ROADMAP id allocation
|
||||
|
||||
Before appending a new numeric ROADMAP entry, pull/rebase onto the latest
|
||||
`main`, allocate the id from the file you are about to edit, and run the duplicate
|
||||
id guard before pushing:
|
||||
|
||||
```bash
|
||||
git pull --rebase
|
||||
NEXT=$(scripts/roadmap-next-id.sh)
|
||||
# append "${NEXT}. **...**" to ROADMAP.md
|
||||
scripts/roadmap-check-ids.sh
|
||||
```
|
||||
|
||||
The duplicate guard currently checks helper-era ids (`>=723`) by default so it
|
||||
catches new optimistic-append collisions without failing on legacy numbered lists
|
||||
already present in the historical roadmap. Use `scripts/roadmap-check-ids.sh
|
||||
--min-id 1` for a strict whole-file audit after those legacy collisions are
|
||||
cleaned up.
|
||||
|
||||
## Checks before opening a pull request
|
||||
|
||||
Run the smallest relevant tests for your change, then the broader checks when
|
||||
|
||||
34
ROADMAP.md
34
ROADMAP.md
@@ -7583,3 +7583,37 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||
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.
|
||||
|
||||
709. **`render_agents_report_json` and `render_skill_install_report_json` in `commands/src/lib.rs` contained duplicate `"status":"ok"` keys in the same JSON object literal — second key silently overwrites first in serde_json** — found during #708 dogfood sweep on `47c0226a`. Rust `serde_json::json!` macros accept duplicate keys but `serde_json` silently keeps the last occurrence; any consumer relying on the first occurrence gets the wrong value if they are ever different. Fixed by removing the duplicate `status` key from `render_agents_report_json` and `render_skill_install_report_json`. Source: Jobdori dogfood on `47c0226a`, 2026-05-26.
|
||||
|
||||
710. **`claw diff --output-format json` missing `action` and `working_directory` fields — both the ok and error paths in `render_diff_json_for` omitted `action` entirely (returning `null` at parse time) and omitted `working_directory`** — dogfooded 2026-05-26 on `8f8eb41e`. Both the clean/changes success path and the no-git-repo error path were missing the two envelope fields that automation uses for routing and provenance. Fix: added `action:"diff"` and `working_directory: cwd.display()` to both branches of `render_diff_json_for`; added contract-test assertions for both fields. Source: Jobdori dogfood on `8f8eb41e`, 2026-05-26.
|
||||
|
||||
711. **`version`, `system-prompt`, `export`, and `init` `--output-format json` responses all lacked an `action` field — returned `null` or omitted entirely** — dogfooded 2026-05-26 on `42c17bc4`. All four commands emitted `kind` + `status` but no `action`, making them inconsistent with all other JSON surfaces that include the verb. Fix: added `action:"show"` to `version` and `system-prompt`, `action:"export"` to all three `export` paths, `action:"init"` to `init`; added contract-test assertions for each. Source: Jobdori dogfood on `42c17bc4`, 2026-05-26.
|
||||
|
||||
712. **`doctor`, `status`, `bootstrap-plan`, and `dump-manifests` `--output-format json` responses missing `action` field — consistent with batch of missing-action fixes in #710/#711** — dogfooded 2026-05-26 on `bae0099c`. `doctor` returned no `action`; `status` and `bootstrap-plan` same. `dump-manifests` had empty string. Fix: added `action:"doctor"` to doctor JSON, `action:"show"` to status and bootstrap-plan, `action:"dump"` to dump-manifests happy path. Source: Jobdori dogfood on `bae0099c`, 2026-05-26.
|
||||
|
||||
713. **`acp` and `config` (bare and section-show) `--output-format json` responses missing `action` field — continues sweep from #710–#712** — dogfooded 2026-05-26 on `fdde5e45`. `acp` had no action; `config` bare had no action; `config <section>` and the unknown-section error path both had no action. Fix: added `action:"status"` to acp, `action:"list"` to config bare, `action:"show"` to config section-show and unknown-section error path. Source: Jobdori dogfood on `fdde5e45`, 2026-05-26.
|
||||
|
||||
714. **`help --output-format json` missing `action` field; resume `/help` JSON path also missing `action` and `status`** — dogfooded 2026-05-26 on `7d6b2044`. Top-level `claw help --output-format json` returned `{kind:"help", status:"ok"}` with no `action`. The `render_export_help_json` and `render_help_topic_json` resume-path helpers were also missing `action`. The resume REPL help JSON object had neither `action` nor `status`. Fix: added `action:"help"` (and `status:"ok"` where missing) to all 4 help JSON sites. Source: Jobdori dogfood on `7d6b2044`, 2026-05-26.
|
||||
|
||||
715. **Resume-path slash commands (`/compact`, `/clear`, `/cost`, `/stats`, `/history`, `/session exists`, `/session delete`, `memory`) JSON responses missing `action` and `status` fields** — dogfooded 2026-05-26 on `590b5b61`. The `assert_non_empty_action` guardrail added by #3109 only covers `assert_json_command` (top-level CLI surfaces); resume-path commands that emit JSON via `ResumeCommandOutcome.json` were not covered. 8 resume-path JSON sites all lacked `action` and `status`. Fix: added `action` + `status:"ok"` to `compact`, `clear`, `cost`, `stats`, `history`, `session_exists`, `session_delete`, `memory`, and `restored`. Source: Jobdori dogfood on `590b5b61`, 2026-05-26.
|
||||
|
||||
716. **Resume-path error JSON used legacy `{type:"error", error:...}` shape instead of standard `{kind, action, status:"error", error_kind, exit_code}` envelope — 5 error paths affected** — dogfooded 2026-05-26 on `76c8d480`. Session load failure, unsupported command, unsupported resumed command, SlashCommand parse error, and broad-cwd abort all emitted the old two-key shape. Fix: aligned all 5 to `{kind, action:"resume"|"abort", status:"error", error_kind, error, exit_code}`. Updated `resumed_stub_command_emits_not_implemented_json` test to assert `status:"error"` + `kind:"unsupported_command"`. Source: Jobdori dogfood on `76c8d480`, 2026-05-26.
|
||||
|
||||
717. **`claw agents show <name>` missing — `handle_agents_slash_command_json` only accepted `list`; `show/info/describe` was unimplemented unlike skills which had parity** — dogfooded 2026-05-26 on `6a007344`. `claw agents show claw-code --output-format json` returned `unknown_agents_subcommand` error. Fix: added `show/info/describe` and `list <filter>` arms to both `handle_agents_slash_command` and `handle_agents_slash_command_json`, mirroring the skills handler; renamed `render_agents_report_json` → `render_agents_report_json_with_action`; not-found path returns `{kind:"agents", action:"show", status:"error", error_kind:"agent_not_found", requested:"<name>"}` + Ok; added `classify_error_kind` branch for `agent_not_found`. Updated 2 tests. Source: Jobdori dogfood on `6a007344`, 2026-05-26.
|
||||
|
||||
718. **`claw plugins show <name>` unimplemented — unlike `agents show` and `skills show`, `/plugins show` returned `unknown_plugins_action` error** — dogfooded 2026-05-26 on `8d80f2ff`. Fix: added `show/info/describe` arm to `handle_plugins_slash_command` that filters installed plugins by name; `print_plugins` JSON path filters `payload.plugins` when action is `show/info/describe` and emits `{kind:"plugin", action:"show", status:"error", error_kind:"plugin_not_found", requested:"<name>"}` for missing names. Updated error message in catch-all to name `show` as supported. Source: Jobdori dogfood on `8d80f2ff`, 2026-05-26.
|
||||
|
||||
719. **`plugins list <filter>` silently returned all plugins instead of filtering — unlike `agents list <filter>` and `skills list <filter>` which do substring filter** — dogfooded 2026-05-26 on `556a598f`. `claw plugins list nonexistent-filter-xyz --output-format json` returned both installed plugins. Fix: `handle_plugins_slash_command` `list` arm now treats `target` as a substring filter; `print_plugins` JSON path applies `target` filter for `list` action. Source: Jobdori dogfood on `556a598f`, 2026-05-26.
|
||||
|
||||
720. **`claw help agents` (and `help skills|plugins|mcp|config|diff|sandbox|doctor|etc.`) errored with `cli_parse: unrecognized argument` instead of routing to the subsystem's help** — dogfooded 2026-05-26 on `fe2b13a4`. `claw help agents --output-format json` returned `{status:"error", error_kind:"cli_parse"}`. The `is_diagnostic` guard for `"help"` verb rejected any trailing non-flag argument. Fix: when `verb == "help"` and exactly one topic argument follows, match it against all known `LocalHelpTopic` variants (including new `Agents`, `Skills`, `Plugins`, `Mcp`, `Config`, `Diff`) and route to `HelpTopic`; `agents` and `skills` delegate to their subsystem usage JSON in `print_help_topic`. Unknown topics fall through to generic `Help`. Source: Jobdori dogfood on `fe2b13a4`, 2026-05-26.
|
||||
|
||||
721. **`claw config mcp|sandbox|permissions|skills|agents` returned `{status:"error", error_kind:"unsupported_config_section"}` with error message "Use env, hooks, model, or plugins" — 5 valid config sections were not mapped in the JSON path** — dogfooded 2026-05-26 on `02d1f6a0`. `claw config agents --output-format json` → `{status:"error", error_kind:"unsupported_config_section", error:"...Use env, hooks, model, or plugins."}`. The `match section` arms in both text and JSON paths only handled `env/hooks/model/plugins`; all others fell to the error arm. Fix: added `mcp|mcp_servers|mcpServers`, `sandbox`, `permissions`, `skills`, `agents` arms to both text and JSON config section handlers. `unsupported_config_section` error envelope now includes `supported_sections:[]` array. Source: Jobdori dogfood on `02d1f6a0`, 2026-05-26.
|
||||
|
||||
722. **ROADMAP #721 re-entry after rebase conflict: `claw config mcp|sandbox|permissions|skills|agents` returned `unsupported_config_section` — code fix is in `6e44da10`** (main.rs changes preserved through rebase, only ROADMAP.md was conflict-resolved to Gaebal's version). Both text and JSON config section handlers now support `mcp`, `sandbox`, `permissions`, `skills`, `agents`; error envelope includes `supported_sections:[]`. Source: Jobdori dogfood on `02d1f6a0`, 2026-05-26.
|
||||
|
||||
723. **Concurrent dogfood claws allocate ROADMAP ids manually and collide — same id reused by two contributors simultaneously, causing PR ROADMAP.md conflicts and lost entries** — observed live 2026-05-26 during Jobdori+Gaebal parallel dogfood session: Gaebal filed stale-local-probe as #719; Jobdori landed `plugins list <filter>` as #719 on main first; Gaebal shifted to #720; Jobdori landed `claw help <topic>` as #720; stale-local-probe eventually landed as #721 after two forced rebase cycles. The ROADMAP append workflow has no reservation or conflict-aware id allocation. **Required fix shape:** (a) add `scripts/roadmap-next-id.sh` that reads the highest id from ROADMAP.md and prints `highest+1` — claws should call this immediately before appending any new entry; (b) document in CONTRIBUTING.md that id allocation is optimistic-append: call `roadmap-next-id.sh` immediately before the append, git-pull first, resolve collisions at push time by re-numbering the appended entry; (c) long-term: a GitHub Action that validates no duplicate ROADMAP ids on PR would catch this before merge. Added `scripts/roadmap-next-id.sh` (this commit). Source: Gaebal Gajae live observation, 2026-05-26.
|
||||
|
||||
724. **DONE — ROADMAP duplicate-id validation guard for helper-era append collisions** — follow-up to #723 after dogfood showed `scripts/roadmap-next-id.sh` still printed 724 and exited 0 when a temp ROADMAP copy already contained a second `723. ...` line. This PR closes the gap for new optimistic-append collisions by adding `scripts/roadmap-check-ids.sh`, wiring it into docs CI and the local pre-push hook, documenting the pre-push command in CONTRIBUTING, and mentioning the guard from `roadmap-next-id.sh`. The guard defaults to ids >=723 so current historical roadmap content and old numbered lists do not block docs-only PRs; `--min-id 1` is available for a strict whole-file audit once legacy collisions are cleaned up. **Verification:** `scripts/roadmap-check-ids.sh` passes on current ROADMAP; a temp copy with an appended duplicate `723.` fails nonzero and lists duplicate id 723 with line numbers. Source: Jobdori dogfood follow-up on origin/main `922c2398`, 2026-05-25. [SCOPE: docs/scripts]
|
||||
|
||||
725. **DONE — roadmap-next-id helper now fails closed on helper-era duplicate ids before printing a next id** — follow-up to #724 after dogfood on origin/main 25ee5f3d showed `scripts/roadmap-next-id.sh` could print `1000` and exit 0 when a temp ROADMAP copy already contained two `999.` helper-era entries. This PR makes `roadmap-next-id.sh` resolve `roadmap-check-ids.sh` by its own script directory, run the checker with default helper-era min-id semantics before computing `highest+1`, keep stdout reserved for the single next id on success, and fail closed with a useful error if the checker is unavailable. Added focused pytest coverage for clean next-id output, duplicate fail-fast behavior, and missing-checker fail-closed behavior. **Verification:** `scripts/roadmap-next-id.sh ROADMAP.md` prints `725`; `scripts/roadmap-check-ids.sh ROADMAP.md` passes; a temp ROADMAP with duplicate `999.` exits nonzero and lists duplicate id 999 without printing a next id; `bash -n scripts/roadmap-next-id.sh scripts/roadmap-check-ids.sh` passes; `python -m pytest tests/test_roadmap_helpers.py -q` passes. Source: Jobdori dogfood follow-up on origin/main 25ee5f3d. [SCOPE: docs/scripts]
|
||||
|
||||
@@ -17,7 +17,10 @@ covered by the Claw Code 2.0 board.
|
||||
|
||||
- Hook: `.github/hooks/pre-push`
|
||||
- Install command: `git config core.hooksPath .github/hooks`
|
||||
- Gate: `cargo build --manifest-path rust/Cargo.toml --workspace`
|
||||
- Gate: `cargo build --manifest-path rust/Cargo.toml --workspace --locked`
|
||||
- Escape hatch: `SKIP_CLAW_PRE_PUSH_BUILD=1` prints an explicit skip message.
|
||||
- Regression test: `tests/test_pre_push_hook_contract.py` locks the skip
|
||||
hatch and `--locked` build command contract.
|
||||
- Purpose: mirror the CI build job locally so stale field/variant references are
|
||||
caught before push.
|
||||
|
||||
@@ -40,8 +43,9 @@ python3 scripts/generate_cc2_board.py
|
||||
python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json
|
||||
python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json
|
||||
bash -n .github/hooks/pre-push
|
||||
python3 tests/test_pre_push_hook_contract.py -v
|
||||
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
|
||||
cargo test --manifest-path rust/Cargo.toml -p claw-analog rag_response_ -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p runtime startup_preflight -- --nocapture
|
||||
cargo build --manifest-path rust/Cargo.toml --workspace
|
||||
cargo build --manifest-path rust/Cargo.toml --workspace --locked
|
||||
```
|
||||
|
||||
@@ -1109,25 +1109,33 @@ enum BlockKind {
|
||||
},
|
||||
}
|
||||
|
||||
const KNOWN_RAG_BOOTSTRAP_PHASES: &[&str] =
|
||||
&["1-sqlite-no-db", "1-sqlite-empty", "1-sqlite", "2-qdrant"];
|
||||
|
||||
fn unknown_bootstrap_phase_error(received_value: Value, message: &str) -> String {
|
||||
json!({
|
||||
"kind": "unknown_bootstrap_phase",
|
||||
"field": "phase",
|
||||
"received_value": received_value,
|
||||
"allowed_values": KNOWN_RAG_BOOTSTRAP_PHASES,
|
||||
"message": message,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn format_rag_query_json_for_model(body: &str) -> Result<String, String> {
|
||||
let v: Value = serde_json::from_str(body).map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
let phase = v.get("phase").and_then(|x| x.as_str()).ok_or_else(|| {
|
||||
json!({
|
||||
"kind": "unknown_bootstrap_phase",
|
||||
"field": "phase",
|
||||
"received_value": v.get("phase").cloned().unwrap_or(Value::Null),
|
||||
"message": "RAG response is missing a string phase; refusing to silently render phase as unknown"
|
||||
})
|
||||
.to_string()
|
||||
unknown_bootstrap_phase_error(
|
||||
v.get("phase").cloned().unwrap_or(Value::Null),
|
||||
"RAG response is missing a string phase; refusing to silently render phase as unknown",
|
||||
)
|
||||
})?;
|
||||
if phase.trim().is_empty() || phase == "unknown" {
|
||||
return Err(json!({
|
||||
"kind": "unknown_bootstrap_phase",
|
||||
"field": "phase",
|
||||
"received_value": phase,
|
||||
"message": "RAG response phase must be a concrete phase name"
|
||||
})
|
||||
.to_string());
|
||||
if !KNOWN_RAG_BOOTSTRAP_PHASES.contains(&phase) {
|
||||
return Err(unknown_bootstrap_phase_error(
|
||||
Value::String(phase.to_string()),
|
||||
"RAG response phase is not a recognized bootstrap phase",
|
||||
));
|
||||
}
|
||||
let hits = v
|
||||
.get("hits")
|
||||
@@ -2586,6 +2594,16 @@ mod tests {
|
||||
let err = format_rag_query_json_for_model(r#"{"hits":[],"phase":"unknown"}"#).unwrap_err();
|
||||
assert!(err.contains(r#""kind":"unknown_bootstrap_phase""#));
|
||||
assert!(err.contains(r#""received_value":"unknown""#));
|
||||
assert!(err.contains(r#""field":"phase""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_response_unrecognized_phase_returns_typed_error() {
|
||||
let err =
|
||||
format_rag_query_json_for_model(r#"{"hits":[],"phase":"3-drifted"}"#).unwrap_err();
|
||||
assert!(err.contains(r#""kind":"unknown_bootstrap_phase""#));
|
||||
assert!(err.contains(r#""received_value":"3-drifted""#));
|
||||
assert!(err.contains(r#""allowed_values""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2202,7 +2202,16 @@ pub fn handle_plugins_slash_command(
|
||||
match action {
|
||||
None | Some("list") => {
|
||||
let report = manager.installed_plugin_registry_report()?;
|
||||
let plugins = report.summaries();
|
||||
let plugins: Vec<_> = if let Some(filter) = target {
|
||||
let needle = filter.to_lowercase();
|
||||
report
|
||||
.summaries()
|
||||
.into_iter()
|
||||
.filter(|p| p.metadata.id.to_lowercase().contains(&needle))
|
||||
.collect()
|
||||
} else {
|
||||
report.summaries().into_iter().collect()
|
||||
};
|
||||
let failures = report.failures();
|
||||
Ok(PluginsCommandResult {
|
||||
message: render_plugins_report_with_failures(&plugins, failures),
|
||||
@@ -2301,8 +2310,28 @@ pub fn handle_plugins_slash_command(
|
||||
reload_runtime: true,
|
||||
})
|
||||
}
|
||||
Some("show" | "info" | "describe") => {
|
||||
// Show a named plugin by filtering the installed registry.
|
||||
// Without a target, shows all (same as list).
|
||||
let report = manager.installed_plugin_registry_report()?;
|
||||
let plugins: Vec<_> = if let Some(name) = target {
|
||||
let needle = name.to_lowercase();
|
||||
report
|
||||
.summaries()
|
||||
.into_iter()
|
||||
.filter(|p| p.metadata.id.to_lowercase() == needle)
|
||||
.collect()
|
||||
} else {
|
||||
report.summaries().into_iter().collect()
|
||||
};
|
||||
let failures = report.failures();
|
||||
Ok(PluginsCommandResult {
|
||||
message: render_plugins_report_with_failures(&plugins, failures),
|
||||
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."
|
||||
"unknown_plugins_action: '{other}' is not a supported /plugins action. Use list, show, install, enable, disable, uninstall, or update."
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -2323,10 +2352,50 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report(&agents))
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
Ok(render_agents_report(&filtered))
|
||||
}
|
||||
Some("show" | "info" | "describe") => {
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report(&agents))
|
||||
}
|
||||
Some(args)
|
||||
if args.starts_with("show ")
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
let name = args
|
||||
.split_once(' ')
|
||||
.map(|(_, name)| name)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let matched: Vec<_> = agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase() == name)
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("agent not found: {name}"),
|
||||
));
|
||||
}
|
||||
Ok(render_agents_report(&matched))
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)),
|
||||
Some(args) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown agents subcommand: {args}. Supported: list, help"),
|
||||
format!("unknown agents subcommand: {args}. Supported: list, show, help"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -2347,10 +2416,53 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report_json(cwd, &agents))
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
Ok(render_agents_report_json(cwd, &filtered))
|
||||
}
|
||||
Some("show" | "info" | "describe") => {
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report_json_with_action(cwd, &agents, "show"))
|
||||
}
|
||||
Some(args)
|
||||
if args.starts_with("show ")
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
let name = args
|
||||
.split_once(' ')
|
||||
.map(|(_, name)| name)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let matched: Vec<_> = agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase() == name)
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "agents",
|
||||
"action": "show",
|
||||
"status": "error",
|
||||
"error_kind": "agent_not_found",
|
||||
"requested": name,
|
||||
}));
|
||||
}
|
||||
Ok(render_agents_report_json_with_action(cwd, &matched, "show"))
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)),
|
||||
Some(args) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown agents subcommand: {args}. Supported: list, help"),
|
||||
format!("unknown agents subcommand: {args}. Supported: list, show, help"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -3636,6 +3748,14 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
|
||||
}
|
||||
|
||||
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
|
||||
render_agents_report_json_with_action(cwd, agents, "list")
|
||||
}
|
||||
|
||||
fn render_agents_report_json_with_action(
|
||||
cwd: &Path,
|
||||
agents: &[AgentSummary],
|
||||
action: &str,
|
||||
) -> Value {
|
||||
let active = agents
|
||||
.iter()
|
||||
.filter(|agent| agent.shadowed_by.is_none())
|
||||
@@ -3643,8 +3763,7 @@ fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
|
||||
json!({
|
||||
"kind": "agents",
|
||||
"status": "ok",
|
||||
"action": "list",
|
||||
"status": "ok",
|
||||
"action": action,
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"count": agents.len(),
|
||||
"summary": {
|
||||
@@ -3763,7 +3882,6 @@ fn render_skill_install_report_json(skill: &InstalledSkill) -> Value {
|
||||
"kind": "skills",
|
||||
"status": "ok",
|
||||
"action": "install",
|
||||
"status": "ok",
|
||||
"result": "installed",
|
||||
"invocation_name": &skill.invocation_name,
|
||||
"invoke_as": format!("${}", skill.invocation_name),
|
||||
@@ -5362,13 +5480,23 @@ mod tests {
|
||||
assert_eq!(help["status"], "ok");
|
||||
assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
|
||||
|
||||
// Unknown agents subcommands now return Err so CLI layer can exit 1.
|
||||
let unexpected_err = handle_agents_slash_command_json(Some("show planner"), &workspace);
|
||||
// `show <name>` is now valid. Known agent returns ok with matching entry.
|
||||
let show_planner = handle_agents_slash_command_json(Some("show planner"), &workspace)
|
||||
.expect("show planner should return Ok");
|
||||
assert_eq!(show_planner["status"], "ok");
|
||||
let show_agents = show_planner["agents"].as_array().expect("agents array");
|
||||
assert_eq!(show_agents.len(), 1, "show by exact name returns one entry");
|
||||
assert_eq!(show_agents[0]["name"], "planner");
|
||||
// Missing agent returns Ok(json error) with error_kind:agent_not_found.
|
||||
let show_missing =
|
||||
handle_agents_slash_command_json(Some("show nonexistent-xyz"), &workspace)
|
||||
.expect("show missing agent should return Ok");
|
||||
assert_eq!(show_missing["status"], "error");
|
||||
assert_eq!(show_missing["error_kind"], "agent_not_found");
|
||||
assert_eq!(show_missing["requested"], "nonexistent-xyz");
|
||||
// Truly unknown subcommands still Err.
|
||||
let unexpected_err = handle_agents_slash_command_json(Some("frobnicate"), &workspace);
|
||||
assert!(unexpected_err.is_err());
|
||||
assert!(unexpected_err
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("show planner"));
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(user_home);
|
||||
@@ -5520,14 +5648,23 @@ mod tests {
|
||||
assert!(agents_help
|
||||
.contains("Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents"));
|
||||
|
||||
// Unknown agents subcommands now return Err (typed error) instead of Ok+help text
|
||||
// so that the CLI layer can exit 1. The error message names the unexpected input.
|
||||
let agents_unexpected_err = super::handle_agents_slash_command(Some("show planner"), &cwd);
|
||||
assert!(agents_unexpected_err.is_err());
|
||||
assert!(agents_unexpected_err
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("show planner"));
|
||||
// `show <name>` is now valid. For an agent that doesn't exist it returns Err(NotFound).
|
||||
let agents_show_missing = super::handle_agents_slash_command(Some("show planner"), &cwd);
|
||||
assert!(
|
||||
agents_show_missing.is_err(),
|
||||
"show of a missing agent should Err"
|
||||
);
|
||||
assert_eq!(
|
||||
agents_show_missing.unwrap_err().kind(),
|
||||
std::io::ErrorKind::NotFound
|
||||
);
|
||||
// Truly unknown subcommands still Err with InvalidInput.
|
||||
let agents_unknown_err = super::handle_agents_slash_command(Some("frobnicate"), &cwd);
|
||||
assert!(agents_unknown_err.is_err());
|
||||
assert_eq!(
|
||||
agents_unknown_err.unwrap_err().kind(),
|
||||
std::io::ErrorKind::InvalidInput
|
||||
);
|
||||
|
||||
let skills_help =
|
||||
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
|
||||
|
||||
@@ -413,6 +413,7 @@ impl Session {
|
||||
.get("created_at_ms")
|
||||
.map(|value| required_u64_from_value(value, "created_at_ms"))
|
||||
.transpose()?
|
||||
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
|
||||
.unwrap_or(now);
|
||||
let updated_at_ms = object
|
||||
.get("updated_at_ms")
|
||||
@@ -500,7 +501,10 @@ impl Session {
|
||||
"session_meta" => {
|
||||
version = required_u32(object, "version")?;
|
||||
session_id = Some(required_string(object, "session_id")?);
|
||||
created_at_ms = Some(required_u64(object, "created_at_ms")?);
|
||||
created_at_ms = object
|
||||
.get("created_at_ms")
|
||||
.map(|value| required_u64_from_value(value, "created_at_ms"))
|
||||
.transpose()?;
|
||||
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
|
||||
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
|
||||
workspace_root = object
|
||||
@@ -543,11 +547,15 @@ impl Session {
|
||||
}
|
||||
|
||||
let now = current_time_millis();
|
||||
let session_id = session_id.unwrap_or_else(generate_session_id);
|
||||
let created_at_ms = created_at_ms
|
||||
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
|
||||
.unwrap_or(now);
|
||||
Ok(Self {
|
||||
version,
|
||||
session_id: session_id.unwrap_or_else(generate_session_id),
|
||||
created_at_ms: created_at_ms.unwrap_or(now),
|
||||
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms.unwrap_or(now)),
|
||||
session_id,
|
||||
created_at_ms,
|
||||
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms),
|
||||
messages,
|
||||
compaction,
|
||||
fork,
|
||||
@@ -1291,6 +1299,15 @@ fn current_time_millis() -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_created_at_ms_from_session_id(session_id: &str) -> Option<u64> {
|
||||
let timestamp_and_suffix = session_id.strip_prefix("session-")?;
|
||||
let (timestamp, suffix) = timestamp_and_suffix.split_once('-')?;
|
||||
if suffix.is_empty() {
|
||||
return None;
|
||||
}
|
||||
timestamp.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let millis = current_time_millis();
|
||||
let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -1380,8 +1397,9 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
cleanup_rotated_logs, current_time_millis, rotate_session_file_if_needed, ContentBlock,
|
||||
ConversationMessage, MessageRole, Session, SessionFork,
|
||||
cleanup_rotated_logs, current_time_millis, parse_created_at_ms_from_session_id,
|
||||
rotate_session_file_if_needed, ContentBlock, ConversationMessage, MessageRole, Session,
|
||||
SessionFork,
|
||||
};
|
||||
use crate::json::JsonValue;
|
||||
use crate::usage::TokenUsage;
|
||||
@@ -1502,6 +1520,44 @@ mod tests {
|
||||
assert!(!restored.session_id.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_at_parser_requires_full_session_id_shape() {
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("session-1743724800123-0"),
|
||||
Some(1_743_724_800_123)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("session-1743724800123"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("session-1743724800123-"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("other-1743724800123-0"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_legacy_jsonl_created_at_from_session_id_when_meta_omits_it() {
|
||||
let path = temp_session_path("legacy-jsonl-created-at");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"type":"session_meta","version":3,"session_id":"session-1743724800123-0","updated_at_ms":1743724800456}
|
||||
"#,
|
||||
)
|
||||
.expect("legacy jsonl should write");
|
||||
|
||||
let restored = Session::load_from_path(&path).expect("legacy jsonl should load");
|
||||
fs::remove_file(&path).expect("temp file should be removable");
|
||||
|
||||
assert_eq!(restored.session_id, "session-1743724800123-0");
|
||||
assert_eq!(restored.created_at_ms, 1_743_724_800_123);
|
||||
assert_eq!(restored.updated_at_ms, 1_743_724_800_456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_messages_to_persisted_jsonl_session() {
|
||||
let path = temp_session_path("append");
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use crate::session::{Session, SessionError};
|
||||
use crate::session::{parse_created_at_ms_from_session_id, Session, SessionError};
|
||||
|
||||
/// Per-worktree session store that namespaces on-disk session files by
|
||||
/// workspace fingerprint so that parallel `opencode serve` instances never
|
||||
@@ -345,6 +345,9 @@ impl SessionStore {
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string());
|
||||
let fallback_created_at_ms =
|
||||
parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0);
|
||||
let summary = match Session::load_from_path(&path) {
|
||||
Ok(session) => {
|
||||
if self.validate_loaded_session(&path, &session).is_err() {
|
||||
@@ -353,6 +356,7 @@ impl SessionStore {
|
||||
ManagedSessionSummary {
|
||||
id: session.session_id,
|
||||
path,
|
||||
created_at_ms: session.created_at_ms,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis,
|
||||
message_count: session.messages.len(),
|
||||
@@ -367,12 +371,9 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
Err(_) => ManagedSessionSummary {
|
||||
id: path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
id: fallback_id,
|
||||
path,
|
||||
created_at_ms: fallback_created_at_ms,
|
||||
updated_at_ms: 0,
|
||||
modified_epoch_millis,
|
||||
message_count: 0,
|
||||
@@ -409,10 +410,14 @@ impl SessionStore {
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string());
|
||||
let fallback_created_at_ms =
|
||||
parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0);
|
||||
let summary = match Session::load_from_path(&path) {
|
||||
Ok(session) => ManagedSessionSummary {
|
||||
id: session.session_id,
|
||||
path,
|
||||
created_at_ms: session.created_at_ms,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis,
|
||||
message_count: session.messages.len(),
|
||||
@@ -426,12 +431,9 @@ impl SessionStore {
|
||||
.and_then(|fork| fork.branch_name.clone()),
|
||||
},
|
||||
Err(_) => ManagedSessionSummary {
|
||||
id: path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
id: fallback_id,
|
||||
path,
|
||||
created_at_ms: fallback_created_at_ms,
|
||||
updated_at_ms: 0,
|
||||
modified_epoch_millis,
|
||||
message_count: 0,
|
||||
@@ -483,6 +485,7 @@ pub struct SessionHandle {
|
||||
pub struct ManagedSessionSummary {
|
||||
pub id: String,
|
||||
pub path: PathBuf,
|
||||
pub created_at_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
pub modified_epoch_millis: u128,
|
||||
pub message_count: usize,
|
||||
@@ -810,6 +813,7 @@ mod tests {
|
||||
ManagedSessionSummary {
|
||||
id: "older-file-newer-session".to_string(),
|
||||
path: PathBuf::from("/tmp/older"),
|
||||
created_at_ms: 100,
|
||||
updated_at_ms: 200,
|
||||
modified_epoch_millis: 100,
|
||||
message_count: 2,
|
||||
@@ -819,6 +823,7 @@ mod tests {
|
||||
ManagedSessionSummary {
|
||||
id: "newer-file-older-session".to_string(),
|
||||
path: PathBuf::from("/tmp/newer"),
|
||||
created_at_ms: 50,
|
||||
updated_at_ms: 100,
|
||||
modified_epoch_millis: 200,
|
||||
message_count: 1,
|
||||
|
||||
@@ -1193,7 +1193,7 @@ fn git_tracks_path(cwd: &Path, path: &str) -> bool {
|
||||
|
||||
fn git_metadata_path(cwd: &Path) -> Option<PathBuf> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--git-path", "."])
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.ok()?;
|
||||
@@ -1214,17 +1214,27 @@ fn git_metadata_path(cwd: &Path) -> Option<PathBuf> {
|
||||
|
||||
fn path_is_writable(path: &Path) -> bool {
|
||||
let probe_dir = if path.is_dir() {
|
||||
path.to_path_buf()
|
||||
path
|
||||
} else {
|
||||
path.parent().unwrap_or(path).to_path_buf()
|
||||
path.parent().unwrap_or(path)
|
||||
};
|
||||
let probe = probe_dir.join(format!(".claw-write-probe-{}", now_secs()));
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&probe)
|
||||
.and_then(|_| std::fs::remove_file(&probe))
|
||||
.is_ok()
|
||||
std::fs::metadata(probe_dir)
|
||||
.ok()
|
||||
.filter(std::fs::Metadata::is_dir)
|
||||
.is_some_and(|metadata| metadata_allows_directory_writes(&metadata))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn metadata_allows_directory_writes(metadata: &std::fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = metadata.permissions().mode();
|
||||
mode & 0o222 != 0 && mode & 0o111 != 0
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn metadata_allows_directory_writes(metadata: &std::fs::Metadata) -> bool {
|
||||
!metadata.permissions().readonly()
|
||||
}
|
||||
|
||||
fn detect_trust_prompt(lowered: &str) -> bool {
|
||||
@@ -1627,6 +1637,56 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn startup_preflight_warns_when_git_metadata_is_not_writable() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let worktree = tmp.path().join("worktree");
|
||||
let git_dir = tmp.path().join("external-gitdir");
|
||||
fs::create_dir_all(&worktree).expect("worktree dir");
|
||||
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
|
||||
fs::create_dir_all(git_dir.join("refs/heads")).expect("refs dir");
|
||||
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
|
||||
fs::write(
|
||||
worktree.join(".git"),
|
||||
format!("gitdir: {}\n", git_dir.display()),
|
||||
)
|
||||
.expect(".git file");
|
||||
|
||||
let original_permissions = fs::metadata(&git_dir)
|
||||
.expect("gitdir metadata")
|
||||
.permissions();
|
||||
let mut read_only_permissions = original_permissions.clone();
|
||||
read_only_permissions.set_mode(0o555);
|
||||
fs::set_permissions(&git_dir, read_only_permissions).expect("make gitdir read-only");
|
||||
|
||||
let warnings = startup_preflight_warnings(&worktree, "Audit repository.");
|
||||
let registry = WorkerRegistry::new();
|
||||
let worker = registry.create(&worktree.display().to_string(), &[], true);
|
||||
let observed = registry
|
||||
.observe_startup_preflight(&worker.worker_id, "Audit repository.")
|
||||
.expect("preflight should run");
|
||||
|
||||
fs::set_permissions(&git_dir, original_permissions).expect("restore gitdir permissions");
|
||||
|
||||
assert!(warnings.iter().any(|warning| {
|
||||
warning.kind == WorkerStartupPreflightWarningKind::GitMetadataNotWritable
|
||||
&& warning.path.as_deref() == Some(git_dir.to_string_lossy().as_ref())
|
||||
}));
|
||||
assert!(observed.events.iter().any(|event| {
|
||||
matches!(
|
||||
&event.payload,
|
||||
Some(WorkerEventPayload::StartupPreflightWarning {
|
||||
kind: WorkerStartupPreflightWarningKind::GitMetadataNotWritable,
|
||||
path: Some(path),
|
||||
..
|
||||
}) if path == git_dir.to_string_lossy().as_ref()
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_preflight_records_structured_warning_event() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
@@ -302,6 +302,8 @@ fn classify_error_kind(message: &str) -> &'static str {
|
||||
"interactive_only"
|
||||
} else if message.starts_with("unknown agents subcommand:") {
|
||||
"unknown_agents_subcommand"
|
||||
} else if message.starts_with("agent not found:") {
|
||||
"agent_not_found"
|
||||
} else if message.contains("is not installed") {
|
||||
"plugin_not_found"
|
||||
} else if (message.contains("skill source") && message.contains("not found"))
|
||||
@@ -688,6 +690,13 @@ enum LocalHelpTopic {
|
||||
SystemPrompt,
|
||||
DumpManifests,
|
||||
BootstrapPlan,
|
||||
// #720: subsystem help topics so `claw help agents` etc. route to usage JSON
|
||||
Agents,
|
||||
Skills,
|
||||
Plugins,
|
||||
Mcp,
|
||||
Config,
|
||||
Diff,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -921,6 +930,12 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
"system-prompt" => Some(LocalHelpTopic::SystemPrompt),
|
||||
"dump-manifests" => Some(LocalHelpTopic::DumpManifests),
|
||||
"bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan),
|
||||
"agents" | "agent" => Some(LocalHelpTopic::Agents),
|
||||
"skills" | "skill" => Some(LocalHelpTopic::Skills),
|
||||
"plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins),
|
||||
"mcp" => Some(LocalHelpTopic::Mcp),
|
||||
"config" => Some(LocalHelpTopic::Config),
|
||||
"diff" => Some(LocalHelpTopic::Diff),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(topic) = topic {
|
||||
@@ -1257,6 +1272,39 @@ fn parse_single_word_command_alias(
|
||||
// "doctor --help -h" is valid, routed to parse_local_help_action() instead
|
||||
return None;
|
||||
}
|
||||
// #720: `claw help <topic>` — when the verb is "help" and exactly one
|
||||
// non-flag argument follows, try to route to the topic's handler.
|
||||
if verb == "help" && rest.len() == 2 {
|
||||
let topic_name = rest[1].as_str();
|
||||
let topic = match topic_name {
|
||||
"status" => Some(LocalHelpTopic::Status),
|
||||
"sandbox" => Some(LocalHelpTopic::Sandbox),
|
||||
"doctor" => Some(LocalHelpTopic::Doctor),
|
||||
"acp" => Some(LocalHelpTopic::Acp),
|
||||
"init" => Some(LocalHelpTopic::Init),
|
||||
"state" => Some(LocalHelpTopic::State),
|
||||
"export" => Some(LocalHelpTopic::Export),
|
||||
"version" => Some(LocalHelpTopic::Version),
|
||||
"system-prompt" => Some(LocalHelpTopic::SystemPrompt),
|
||||
"dump-manifests" => Some(LocalHelpTopic::DumpManifests),
|
||||
"bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan),
|
||||
"agents" | "agent" => Some(LocalHelpTopic::Agents),
|
||||
"skills" | "skill" => Some(LocalHelpTopic::Skills),
|
||||
"plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins),
|
||||
"mcp" => Some(LocalHelpTopic::Mcp),
|
||||
"config" => Some(LocalHelpTopic::Config),
|
||||
"diff" => Some(LocalHelpTopic::Diff),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(t) = topic {
|
||||
return Some(Ok(CliAction::HelpTopic {
|
||||
topic: t,
|
||||
output_format,
|
||||
}));
|
||||
}
|
||||
// Unknown topic: fall through to generic help.
|
||||
return Some(Ok(CliAction::Help { output_format }));
|
||||
}
|
||||
// Unrecognized suffix like "--json"
|
||||
let mut msg = format!(
|
||||
"unrecognized argument `{}` for subcommand `{}`",
|
||||
@@ -1270,6 +1318,40 @@ fn parse_single_word_command_alias(
|
||||
return Some(Err(msg));
|
||||
}
|
||||
|
||||
// #720: `claw help <topic>` — when `help` is the verb and a topic follows,
|
||||
// try to route to the topic's help handler instead of erroring.
|
||||
if rest.len() == 2 && rest[0] == "help" {
|
||||
let topic_name = rest[1].as_str();
|
||||
let topic = match topic_name {
|
||||
"status" => Some(LocalHelpTopic::Status),
|
||||
"sandbox" => Some(LocalHelpTopic::Sandbox),
|
||||
"doctor" => Some(LocalHelpTopic::Doctor),
|
||||
"acp" => Some(LocalHelpTopic::Acp),
|
||||
"init" => Some(LocalHelpTopic::Init),
|
||||
"state" => Some(LocalHelpTopic::State),
|
||||
"export" => Some(LocalHelpTopic::Export),
|
||||
"version" => Some(LocalHelpTopic::Version),
|
||||
"system-prompt" => Some(LocalHelpTopic::SystemPrompt),
|
||||
"dump-manifests" => Some(LocalHelpTopic::DumpManifests),
|
||||
"bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan),
|
||||
"agents" | "agent" => Some(LocalHelpTopic::Agents),
|
||||
"skills" | "skill" => Some(LocalHelpTopic::Skills),
|
||||
"plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins),
|
||||
"mcp" => Some(LocalHelpTopic::Mcp),
|
||||
"config" => Some(LocalHelpTopic::Config),
|
||||
"diff" => Some(LocalHelpTopic::Diff),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(t) = topic {
|
||||
return Some(Ok(CliAction::HelpTopic {
|
||||
topic: t,
|
||||
output_format,
|
||||
}));
|
||||
}
|
||||
// Unknown topic falls through to the generic help action.
|
||||
return Some(Ok(CliAction::Help { output_format }));
|
||||
}
|
||||
|
||||
if rest.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
@@ -2139,6 +2221,7 @@ impl DoctorReport {
|
||||
let (ok_count, warn_count, fail_count) = self.counts();
|
||||
json!({
|
||||
"kind": "doctor",
|
||||
"action": "doctor",
|
||||
"status": self.status(),
|
||||
"message": report,
|
||||
"report": report,
|
||||
@@ -2890,6 +2973,7 @@ fn dump_manifests_at_path(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "dump-manifests",
|
||||
"action": "dump",
|
||||
"commands": manifest.commands.entries().len(),
|
||||
"tools": manifest.tools.entries().len(),
|
||||
"bootstrap_phases": manifest.bootstrap.phases().len(),
|
||||
@@ -2922,6 +3006,7 @@ fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box<dyn st
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "bootstrap-plan",
|
||||
"action": "show",
|
||||
"status": "ok",
|
||||
"phases": phases,
|
||||
}))?
|
||||
@@ -2954,6 +3039,7 @@ fn print_system_prompt(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "system-prompt",
|
||||
"action": "show",
|
||||
"status": "ok",
|
||||
"message": message,
|
||||
"sections": sections,
|
||||
@@ -2977,6 +3063,7 @@ fn version_json_value() -> serde_json::Value {
|
||||
let executable_path = env::current_exe().ok().map(|p| p.display().to_string());
|
||||
json!({
|
||||
"kind": "version",
|
||||
"action": "show",
|
||||
"status": "ok",
|
||||
"message": render_version_report(),
|
||||
"version": VERSION,
|
||||
@@ -3001,9 +3088,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"error": short_reason,
|
||||
"kind": kind,
|
||||
"action": "restore",
|
||||
"status": "error",
|
||||
"error_kind": kind,
|
||||
"error": short_reason,
|
||||
"exit_code": 1,
|
||||
"hint": hint,
|
||||
})
|
||||
);
|
||||
@@ -3021,6 +3111,8 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"kind": "restored",
|
||||
"action": "restore",
|
||||
"status": "ok",
|
||||
"session_id": session.session_id,
|
||||
"path": handle.path.display().to_string(),
|
||||
"message_count": session.messages.len(),
|
||||
@@ -3054,9 +3146,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"error": format!("/{cmd_root} is not yet implemented in this build"),
|
||||
"kind": "unsupported_command",
|
||||
"action": "resume",
|
||||
"status": "error",
|
||||
"error_kind": "unsupported_command",
|
||||
"error": format!("/{cmd_root} is not yet implemented in this build"),
|
||||
"exit_code": 2,
|
||||
"command": raw_command,
|
||||
})
|
||||
);
|
||||
@@ -3073,9 +3168,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"error": format!("unsupported resumed command: {raw_command}"),
|
||||
"kind": "unsupported_resumed_command",
|
||||
"action": "resume",
|
||||
"status": "error",
|
||||
"error_kind": "unsupported_resumed_command",
|
||||
"error": format!("unsupported resumed command: {raw_command}"),
|
||||
"exit_code": 2,
|
||||
"command": raw_command,
|
||||
})
|
||||
);
|
||||
@@ -3089,8 +3187,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"kind": "cli_parse",
|
||||
"action": "resume",
|
||||
"status": "error",
|
||||
"error_kind": "cli_parse",
|
||||
"error": error.to_string(),
|
||||
"exit_code": 2,
|
||||
"command": raw_command,
|
||||
})
|
||||
);
|
||||
@@ -3126,8 +3228,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"kind": "resume_command_error",
|
||||
"action": "resume",
|
||||
"status": "error",
|
||||
"error_kind": "resume_command_error",
|
||||
"error": error.to_string(),
|
||||
"exit_code": 2,
|
||||
"command": raw_command,
|
||||
})
|
||||
);
|
||||
@@ -3960,18 +4066,7 @@ fn run_resume_command(
|
||||
let session_list_outcome = || -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
|
||||
let sessions = list_managed_sessions().unwrap_or_default();
|
||||
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
|
||||
let session_details: Vec<serde_json::Value> = sessions
|
||||
.iter()
|
||||
.map(|session| {
|
||||
serde_json::json!({
|
||||
"id": session.id,
|
||||
"path": session.path.display().to_string(),
|
||||
"message_count": session.message_count,
|
||||
"updated_at_ms": session.updated_at_ms,
|
||||
"lifecycle": session.lifecycle.json_value(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let session_details = session_details_json(&sessions);
|
||||
let active_id = session.session_id.clone();
|
||||
let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}"));
|
||||
Ok(ResumeCommandOutcome {
|
||||
@@ -3992,7 +4087,9 @@ fn run_resume_command(
|
||||
SlashCommand::Help => Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(render_repl_help()),
|
||||
json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })),
|
||||
json: Some(
|
||||
serde_json::json!({ "kind": "help", "action": "help", "status": "ok", "text": render_repl_help() }),
|
||||
),
|
||||
}),
|
||||
SlashCommand::Compact => {
|
||||
let result = runtime::trident::trident_compact_session(
|
||||
@@ -4107,6 +4204,8 @@ fn run_resume_command(
|
||||
message: Some(format_cost_report(usage)),
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "cost",
|
||||
"action": "show",
|
||||
"status": "ok",
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
|
||||
@@ -4185,6 +4284,7 @@ fn run_resume_command(
|
||||
)),
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "export",
|
||||
"action": "export",
|
||||
"status": "ok",
|
||||
"file": export_path.display().to_string(),
|
||||
"message_count": msg_count,
|
||||
@@ -4275,6 +4375,8 @@ fn run_resume_command(
|
||||
message: Some(format_cost_report(usage)),
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "stats",
|
||||
"action": "show",
|
||||
"status": "ok",
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
|
||||
@@ -4295,6 +4397,8 @@ fn run_resume_command(
|
||||
message: Some(render_prompt_history_report(&entries, limit)),
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "history",
|
||||
"action": "list",
|
||||
"status": "ok",
|
||||
"total": entries.len(),
|
||||
"showing": shown.len(),
|
||||
"entries": shown.iter().map(|e| serde_json::json!({
|
||||
@@ -4429,8 +4533,12 @@ fn enforce_broad_cwd_policy(
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"kind": "broad_cwd",
|
||||
"action": "abort",
|
||||
"status": "error",
|
||||
"error_kind": "broad_cwd",
|
||||
"error": message,
|
||||
"exit_code": 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -4550,6 +4658,7 @@ struct SessionHandle {
|
||||
struct ManagedSessionSummary {
|
||||
id: String,
|
||||
path: PathBuf,
|
||||
created_at_ms: u64,
|
||||
updated_at_ms: u64,
|
||||
modified_epoch_millis: u128,
|
||||
message_count: usize,
|
||||
@@ -5970,28 +6079,84 @@ impl LiveCli {
|
||||
CliOutputFormat::Text => println!("{}", payload.message),
|
||||
CliOutputFormat::Json => {
|
||||
let action_str = action.unwrap_or("list");
|
||||
let enabled_count = payload
|
||||
.plugins
|
||||
// For show/info/describe, filter to the named plugin (exact match).
|
||||
// For list with a target, treat target as a substring filter.
|
||||
let is_show_action = matches!(action_str, "show" | "info" | "describe");
|
||||
let is_list_action = action_str == "list";
|
||||
let filtered_plugins: Vec<_> = if is_show_action {
|
||||
if let Some(name) = target {
|
||||
let needle = name.to_lowercase();
|
||||
payload
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|id| id.to_lowercase() == needle)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
payload.plugins.clone()
|
||||
}
|
||||
} else if is_list_action {
|
||||
if let Some(filter) = target {
|
||||
let needle = filter.to_lowercase();
|
||||
payload
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|id| id.to_lowercase().contains(&needle))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
payload.plugins.clone()
|
||||
}
|
||||
} else {
|
||||
payload.plugins.clone()
|
||||
};
|
||||
// Return not-found error for show with missing target.
|
||||
if is_show_action {
|
||||
if let Some(name) = target {
|
||||
if filtered_plugins.is_empty() {
|
||||
let obj = json!({
|
||||
"kind": "plugin",
|
||||
"action": action_str,
|
||||
"status": "error",
|
||||
"error_kind": "plugin_not_found",
|
||||
"requested": name,
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&obj)?);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
let enabled_count = filtered_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 disabled_count = filtered_plugins.len().saturating_sub(enabled_count);
|
||||
let mut obj = json!({
|
||||
"kind": "plugin",
|
||||
"action": action_str,
|
||||
"status": payload.status,
|
||||
"summary": {
|
||||
"total": payload.plugins.len(),
|
||||
"total": filtered_plugins.len(),
|
||||
"enabled": enabled_count,
|
||||
"disabled": disabled_count,
|
||||
"load_failures": payload.load_failures.len(),
|
||||
},
|
||||
"config_load_error": payload.config_load_error,
|
||||
"plugins": payload.plugins,
|
||||
"plugins": filtered_plugins,
|
||||
"load_failures": payload.load_failures,
|
||||
});
|
||||
// Only include operation-result fields for mutating actions
|
||||
if action_str != "list" {
|
||||
// Only include operation-result fields for mutating actions (not list/show)
|
||||
if action_str != "list" && !is_show_action {
|
||||
obj["target"] = json!(target);
|
||||
obj["reload_runtime"] = json!(payload.reload_runtime);
|
||||
obj["message"] = json!(payload.message);
|
||||
@@ -6367,6 +6532,7 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
|
||||
.map(|session| ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
created_at_ms: session.created_at_ms,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis: session.modified_epoch_millis,
|
||||
message_count: session.message_count,
|
||||
@@ -6386,6 +6552,7 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
|
||||
Ok(ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
created_at_ms: session.created_at_ms,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis: session.modified_epoch_millis,
|
||||
message_count: session.message_count,
|
||||
@@ -6443,6 +6610,7 @@ fn session_details_json(sessions: &[ManagedSessionSummary]) -> Vec<serde_json::V
|
||||
"id": session.id,
|
||||
"path": session.path.display().to_string(),
|
||||
"message_count": session.message_count,
|
||||
"created_at_ms": session.created_at_ms,
|
||||
"updated_at_ms": session.updated_at_ms,
|
||||
"modified_epoch_millis": session.modified_epoch_millis,
|
||||
"parent_session_id": session.parent_session_id,
|
||||
@@ -6465,6 +6633,8 @@ fn session_exists_json(
|
||||
.map_or(target, |handle| handle.id.as_str());
|
||||
Ok(serde_json::json!({
|
||||
"kind": "session_exists",
|
||||
"action": "exists",
|
||||
"status": "ok",
|
||||
"session_id": resolved_id,
|
||||
"session": target,
|
||||
"requested": target,
|
||||
@@ -6560,6 +6730,8 @@ fn run_resumed_session_command(
|
||||
)),
|
||||
json: Some(serde_json::json!({
|
||||
"kind": "session_delete",
|
||||
"action": "delete",
|
||||
"status": "ok",
|
||||
"deleted": true,
|
||||
"session_id": handle.id,
|
||||
"path": handle.path.display().to_string(),
|
||||
@@ -6752,6 +6924,7 @@ fn status_json_value(
|
||||
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
|
||||
json!({
|
||||
"kind": "status",
|
||||
"action": "show",
|
||||
"status": if degraded { "degraded" } else { "ok" },
|
||||
"config_load_error": context.config_load_error,
|
||||
"config_load_error_kind": context.config_load_error_kind,
|
||||
@@ -7109,6 +7282,7 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
||||
};
|
||||
json!({
|
||||
"kind": "sandbox",
|
||||
"action": "status",
|
||||
"status": top_status,
|
||||
"enabled": status.enabled,
|
||||
"active": status.active,
|
||||
@@ -7209,6 +7383,40 @@ fn render_help_topic(topic: LocalHelpTopic) -> String {
|
||||
Formats text (default), json
|
||||
Related claw doctor · claw status"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Agents => commands::handle_agents_slash_command(
|
||||
Some("--help"),
|
||||
&env::current_dir().unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_else(|_| "agents help unavailable".to_string()),
|
||||
LocalHelpTopic::Skills => commands::handle_skills_slash_command(
|
||||
Some("--help"),
|
||||
&env::current_dir().unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_else(|_| "skills help unavailable".to_string()),
|
||||
LocalHelpTopic::Plugins => "Plugins
|
||||
Usage claw plugins [list|show <name>|install <path>|enable <name>|disable <name>|uninstall <name>]
|
||||
Purpose manage lifecycle of plugins that extend tool and hook capabilities
|
||||
Formats text (default), json
|
||||
Related /plugins · claw plugins --help"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Mcp => "MCP Servers
|
||||
Usage claw mcp [list|show <server>] [--output-format <format>]
|
||||
Purpose inspect configured MCP servers and their connection status
|
||||
Formats text (default), json
|
||||
Related /mcp · claw mcp list"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Config => "Config
|
||||
Usage claw config [section] [--output-format <format>]
|
||||
Purpose show effective runtime configuration (model, hooks, plugins, env)
|
||||
Formats text (default), json
|
||||
Related /config · claw doctor"
|
||||
.to_string(),
|
||||
LocalHelpTopic::Diff => "Diff
|
||||
Usage claw diff [--output-format <format>]
|
||||
Purpose show the diff of changes relative to the expected base commit
|
||||
Formats text (default), json
|
||||
Related /diff · ROADMAP #148"
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7225,12 +7433,19 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str {
|
||||
LocalHelpTopic::SystemPrompt => "system-prompt",
|
||||
LocalHelpTopic::DumpManifests => "dump-manifests",
|
||||
LocalHelpTopic::BootstrapPlan => "bootstrap-plan",
|
||||
LocalHelpTopic::Agents => "agents",
|
||||
LocalHelpTopic::Skills => "skills",
|
||||
LocalHelpTopic::Plugins => "plugins",
|
||||
LocalHelpTopic::Mcp => "mcp",
|
||||
LocalHelpTopic::Config => "config",
|
||||
LocalHelpTopic::Diff => "diff",
|
||||
}
|
||||
}
|
||||
|
||||
fn render_export_help_json() -> serde_json::Value {
|
||||
json!({
|
||||
"kind": "help",
|
||||
"action": "help",
|
||||
"status": "ok",
|
||||
"topic": "export",
|
||||
"command": "export",
|
||||
@@ -7279,6 +7494,7 @@ fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value {
|
||||
|
||||
json!({
|
||||
"kind": "help",
|
||||
"action": "help",
|
||||
"status": "ok",
|
||||
"topic": local_help_topic_command(topic),
|
||||
"command": local_help_topic_command(topic),
|
||||
@@ -7290,6 +7506,29 @@ fn print_help_topic(
|
||||
topic: LocalHelpTopic,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir().unwrap_or_default();
|
||||
// For subsystem topics in JSON mode, delegate to the subsystem's usage JSON.
|
||||
if output_format == CliOutputFormat::Json {
|
||||
match topic {
|
||||
LocalHelpTopic::Agents => {
|
||||
let json = commands::handle_agents_slash_command_json(Some("--help"), &cwd)
|
||||
.unwrap_or_else(
|
||||
|_| serde_json::json!({"kind":"agents","action":"help","status":"error"}),
|
||||
);
|
||||
println!("{}", serde_json::to_string_pretty(&json)?);
|
||||
return Ok(());
|
||||
}
|
||||
LocalHelpTopic::Skills => {
|
||||
let json = commands::handle_skills_slash_command_json(Some("--help"), &cwd)
|
||||
.unwrap_or_else(
|
||||
|_| serde_json::json!({"kind":"skills","action":"help","status":"error"}),
|
||||
);
|
||||
println!("{}", serde_json::to_string_pretty(&json)?);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{}", render_help_topic(topic)),
|
||||
CliOutputFormat::Json => println!(
|
||||
@@ -7308,6 +7547,7 @@ fn acp_status_json() -> serde_json::Value {
|
||||
json!({
|
||||
"schema_version": "1.0",
|
||||
"kind": "acp",
|
||||
"action": "status",
|
||||
"status": "unsupported",
|
||||
"phase": "discoverability_only",
|
||||
"supported": false,
|
||||
@@ -7405,9 +7645,17 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er
|
||||
"plugins" => runtime_config
|
||||
.get("plugins")
|
||||
.or_else(|| runtime_config.get("enabledPlugins")),
|
||||
"mcp" | "mcp_servers" | "mcpServers" => runtime_config
|
||||
.get("mcp")
|
||||
.or_else(|| runtime_config.get("mcp_servers"))
|
||||
.or_else(|| runtime_config.get("mcpServers")),
|
||||
"sandbox" => runtime_config.get("sandbox"),
|
||||
"permissions" => runtime_config.get("permissions"),
|
||||
"skills" => runtime_config.get("skills"),
|
||||
"agents" => runtime_config.get("agents"),
|
||||
other => {
|
||||
lines.push(format!(
|
||||
" Unsupported config section '{other}'. Use env, hooks, model, or plugins."
|
||||
" Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, or agents."
|
||||
));
|
||||
return Ok(lines.join(
|
||||
"
|
||||
@@ -7472,6 +7720,7 @@ fn render_config_json(
|
||||
|
||||
let base = serde_json::json!({
|
||||
"kind": "config",
|
||||
"action": if section.is_some() { "show" } else { "list" },
|
||||
"status": "ok",
|
||||
"cwd": cwd.display().to_string(),
|
||||
"loaded_files": loaded_paths.len(),
|
||||
@@ -7488,14 +7737,27 @@ fn render_config_json(
|
||||
.get("plugins")
|
||||
.or_else(|| runtime_config.get("enabledPlugins"))
|
||||
.map(|v| v.render()),
|
||||
// These sections are structurally present in config files but may not have
|
||||
// dedicated runtime_config keys yet; return null section_value rather than error.
|
||||
"mcp" | "mcp_servers" | "mcpServers" => runtime_config
|
||||
.get("mcp")
|
||||
.or_else(|| runtime_config.get("mcp_servers"))
|
||||
.or_else(|| runtime_config.get("mcpServers"))
|
||||
.map(|v| v.render()),
|
||||
"sandbox" => runtime_config.get("sandbox").map(|v| v.render()),
|
||||
"permissions" => runtime_config.get("permissions").map(|v| v.render()),
|
||||
"skills" => runtime_config.get("skills").map(|v| v.render()),
|
||||
"agents" => runtime_config.get("agents").map(|v| v.render()),
|
||||
other => {
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "config",
|
||||
"action": "show",
|
||||
"status": "error",
|
||||
"error_kind": "unsupported_config_section",
|
||||
"section": other,
|
||||
"ok": false,
|
||||
"error": format!("Unsupported config section '{other}'. Use env, hooks, model, or plugins."),
|
||||
"error": format!("Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, or agents."),
|
||||
"supported_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents"],
|
||||
"cwd": cwd.display().to_string(),
|
||||
"loaded_files": loaded_paths.len(),
|
||||
"files": files,
|
||||
@@ -7576,6 +7838,8 @@ fn render_memory_json() -> Result<serde_json::Value, Box<dyn std::error::Error>>
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"kind": "memory",
|
||||
"action": "list",
|
||||
"status": "ok",
|
||||
"cwd": cwd.display().to_string(),
|
||||
"instruction_files": files.len(),
|
||||
"files": files,
|
||||
@@ -7610,6 +7874,7 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso
|
||||
let status = "ok";
|
||||
json!({
|
||||
"kind": "init",
|
||||
"action": "init",
|
||||
"status": status,
|
||||
"project_path": report.project_root.display().to_string(),
|
||||
"created": report.artifacts_with_status(InitStatus::Created),
|
||||
@@ -7680,8 +7945,10 @@ 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",
|
||||
"action": "diff",
|
||||
"status": "error",
|
||||
"result": "no_git_repo",
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"detail": format!("{} is not inside a git project", cwd.display()),
|
||||
}));
|
||||
}
|
||||
@@ -7689,7 +7956,9 @@ 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",
|
||||
"action": "diff",
|
||||
"status": "ok",
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
|
||||
"staged": staged.trim(),
|
||||
"unstaged": unstaged.trim(),
|
||||
@@ -8212,6 +8481,7 @@ fn run_export(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "export",
|
||||
"action": "export",
|
||||
"status": "ok",
|
||||
"message": report,
|
||||
"session_id": handle.id,
|
||||
@@ -8234,6 +8504,7 @@ fn run_export(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "export",
|
||||
"action": "export",
|
||||
"status": "ok",
|
||||
"session_id": handle.id,
|
||||
"file": handle.path.display().to_string(),
|
||||
@@ -10605,6 +10876,7 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"kind": "help",
|
||||
"action": "help",
|
||||
"status": "ok",
|
||||
"message": message,
|
||||
}))?
|
||||
@@ -14266,6 +14538,33 @@ UU conflicted.rs",
|
||||
assert_eq!(missing["session_id"], "missing-session");
|
||||
assert!(missing["candidate_path"].as_str().is_some());
|
||||
|
||||
let list_command = SlashCommand::parse("/session list")
|
||||
.expect("parse should succeed")
|
||||
.expect("command should exist");
|
||||
let list = run_resume_command(&active.path, &active_session, &list_command)
|
||||
.expect("list should run")
|
||||
.json
|
||||
.expect("list should return json");
|
||||
assert_eq!(list["kind"], "sessions");
|
||||
let details = list["session_details"]
|
||||
.as_array()
|
||||
.expect("session_details should be an array");
|
||||
let saved_path = saved.path.display().to_string();
|
||||
let saved_detail = details
|
||||
.iter()
|
||||
.find(|detail| detail["path"] == saved_path)
|
||||
.expect("saved session detail should exist");
|
||||
let created_at_ms = saved_detail["created_at_ms"]
|
||||
.as_u64()
|
||||
.expect("created_at_ms should be present");
|
||||
let updated_at_ms = saved_detail["updated_at_ms"]
|
||||
.as_u64()
|
||||
.expect("updated_at_ms should be present");
|
||||
assert!(
|
||||
created_at_ms <= updated_at_ms,
|
||||
"created_at_ms should not be after updated_at_ms"
|
||||
);
|
||||
|
||||
let delete_command = SlashCommand::parse("/session delete session-saved --force")
|
||||
.expect("parse should succeed")
|
||||
.expect("command should exist");
|
||||
|
||||
@@ -73,6 +73,10 @@ fn version_emits_json_when_requested() {
|
||||
|
||||
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
|
||||
assert_eq!(parsed["kind"], "version");
|
||||
assert_eq!(
|
||||
parsed["action"], "show",
|
||||
"version JSON must have action:show (#711)"
|
||||
);
|
||||
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
|
||||
// Provenance fields must be present for binary identification (#507).
|
||||
assert!(
|
||||
@@ -187,6 +191,72 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
.expect("agents array")
|
||||
.is_empty());
|
||||
|
||||
// #717: agents show <name> and agents list <filter> should be valid subcommands
|
||||
let agents_show_env = [
|
||||
("HOME", isolated_home.to_str().expect("utf8 home")),
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
isolated_config.to_str().expect("utf8 config home"),
|
||||
),
|
||||
(
|
||||
"CODEX_HOME",
|
||||
isolated_codex.to_str().expect("utf8 codex home"),
|
||||
),
|
||||
];
|
||||
let agents_show_missing = assert_json_command_with_env(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"agents",
|
||||
"show",
|
||||
"nonexistent-xyz",
|
||||
],
|
||||
&agents_show_env,
|
||||
);
|
||||
assert_eq!(agents_show_missing["kind"], "agents", "agents show kind");
|
||||
assert_eq!(agents_show_missing["action"], "show", "agents show action");
|
||||
assert_eq!(
|
||||
agents_show_missing["status"], "error",
|
||||
"agents show not-found status"
|
||||
);
|
||||
assert_eq!(
|
||||
agents_show_missing["error_kind"], "agent_not_found",
|
||||
"agents show error_kind"
|
||||
);
|
||||
assert_eq!(
|
||||
agents_show_missing["requested"], "nonexistent-xyz",
|
||||
"agents show requested"
|
||||
);
|
||||
|
||||
let agents_list_filtered = assert_json_command_with_env(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"agents",
|
||||
"list",
|
||||
"nonexistent-filter-xyz",
|
||||
],
|
||||
&agents_show_env,
|
||||
);
|
||||
assert_eq!(
|
||||
agents_list_filtered["kind"], "agents",
|
||||
"agents list filter kind"
|
||||
);
|
||||
assert_eq!(
|
||||
agents_list_filtered["action"], "list",
|
||||
"agents list filter action"
|
||||
);
|
||||
assert_eq!(
|
||||
agents_list_filtered["status"], "ok",
|
||||
"agents list filter status"
|
||||
);
|
||||
assert!(agents_list_filtered["agents"]
|
||||
.as_array()
|
||||
.expect("agents array")
|
||||
.is_empty());
|
||||
|
||||
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
|
||||
assert_eq!(mcp["kind"], "mcp");
|
||||
assert_eq!(mcp["action"], "list");
|
||||
@@ -478,6 +548,10 @@ fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||
|
||||
let prompt = assert_json_command(&root, &["--output-format", "json", "system-prompt"]);
|
||||
assert_eq!(prompt["kind"], "system-prompt");
|
||||
assert_eq!(
|
||||
prompt["action"], "show",
|
||||
"system-prompt JSON must have action:show (#711)"
|
||||
);
|
||||
assert!(prompt["message"]
|
||||
.as_str()
|
||||
.expect("prompt text")
|
||||
@@ -508,6 +582,10 @@ fn dump_manifests_and_init_emit_json_when_requested() {
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
let init = assert_json_command(&workspace, &["--output-format", "json", "init"]);
|
||||
assert_eq!(init["kind"], "init");
|
||||
assert_eq!(
|
||||
init["action"], "init",
|
||||
"init JSON must have action:init (#711)"
|
||||
);
|
||||
assert!(workspace.join("CLAUDE.md").exists());
|
||||
}
|
||||
|
||||
@@ -941,6 +1019,124 @@ fn mcp_degraded_config_and_failed_usage_are_distinct_json_contracts() {
|
||||
assert!(failed.get("config_load_error").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_json_surfaces_have_non_empty_action_contract_714() {
|
||||
let root = unique_temp_dir("json-action-sweep-714");
|
||||
let workspace = root.join("workspace");
|
||||
let init_workspace = root.join("init-workspace");
|
||||
let git_workspace = root.join("git-workspace");
|
||||
let home = root.join("home");
|
||||
let config_home = root.join("config-home");
|
||||
let codex_home = root.join("codex-home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&init_workspace).expect("init workspace should exist");
|
||||
fs::create_dir_all(&git_workspace).expect("git workspace should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&codex_home).expect("codex home should exist");
|
||||
|
||||
let session_path = write_session_fixture(&workspace, "action-sweep-export", Some("export me"));
|
||||
let export_output = root.join("export.md");
|
||||
let upstream = write_upstream_fixture(&root);
|
||||
let git_init = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(&git_workspace)
|
||||
.output()
|
||||
.expect("git init should launch");
|
||||
assert!(
|
||||
git_init.status.success(),
|
||||
"git init stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&git_init.stdout),
|
||||
String::from_utf8_lossy(&git_init.stderr)
|
||||
);
|
||||
|
||||
let envs = [
|
||||
("HOME", home.to_str().expect("home utf8")),
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("config utf8"),
|
||||
),
|
||||
("CODEX_HOME", codex_home.to_str().expect("codex utf8")),
|
||||
];
|
||||
|
||||
let surfaces: Vec<(&Path, Vec<String>)> = vec![
|
||||
(&workspace, strings(&["--output-format", "json", "help"])),
|
||||
(&workspace, strings(&["--output-format", "json", "version"])),
|
||||
(&workspace, strings(&["--output-format", "json", "doctor"])),
|
||||
(&workspace, strings(&["--output-format", "json", "status"])),
|
||||
(&workspace, strings(&["--output-format", "json", "sandbox"])),
|
||||
(
|
||||
&workspace,
|
||||
strings(&["--output-format", "json", "bootstrap-plan"]),
|
||||
),
|
||||
(
|
||||
&workspace,
|
||||
strings(&["--output-format", "json", "system-prompt"]),
|
||||
),
|
||||
(
|
||||
&workspace,
|
||||
vec![
|
||||
"--output-format".into(),
|
||||
"json".into(),
|
||||
"dump-manifests".into(),
|
||||
"--manifests-dir".into(),
|
||||
upstream.to_str().expect("upstream utf8").into(),
|
||||
],
|
||||
),
|
||||
(
|
||||
&workspace,
|
||||
vec![
|
||||
"--output-format".into(),
|
||||
"json".into(),
|
||||
"export".into(),
|
||||
"--session".into(),
|
||||
session_path.to_str().expect("session utf8").into(),
|
||||
],
|
||||
),
|
||||
(
|
||||
&workspace,
|
||||
vec![
|
||||
"--output-format".into(),
|
||||
"json".into(),
|
||||
"export".into(),
|
||||
"--session".into(),
|
||||
session_path.to_str().expect("session utf8").into(),
|
||||
"--output".into(),
|
||||
export_output.to_str().expect("export output utf8").into(),
|
||||
],
|
||||
),
|
||||
(
|
||||
&init_workspace,
|
||||
strings(&["--output-format", "json", "init"]),
|
||||
),
|
||||
(&workspace, strings(&["--output-format", "json", "diff"])),
|
||||
(
|
||||
&git_workspace,
|
||||
strings(&["--output-format", "json", "diff"]),
|
||||
),
|
||||
(&workspace, strings(&["--output-format", "json", "acp"])),
|
||||
(&workspace, strings(&["--output-format", "json", "config"])),
|
||||
(
|
||||
&workspace,
|
||||
strings(&["--output-format", "json", "config", "model"]),
|
||||
),
|
||||
(
|
||||
&workspace,
|
||||
strings(&["--output-format", "json", "config", "unknown"]),
|
||||
),
|
||||
(&workspace, strings(&["--output-format", "json", "skills"])),
|
||||
(&workspace, strings(&["--output-format", "json", "agents"])),
|
||||
(&workspace, strings(&["--output-format", "json", "plugins"])),
|
||||
(&workspace, strings(&["--output-format", "json", "mcp"])),
|
||||
];
|
||||
|
||||
for (current_dir, args) in surfaces {
|
||||
let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let parsed = assert_json_command_with_env(current_dir, &arg_refs, &envs);
|
||||
assert_non_empty_action(&parsed, &arg_refs);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_commands_deduplicate_config_deprecation_warnings_per_process() {
|
||||
let root = unique_temp_dir("config-warning-dedup");
|
||||
@@ -993,7 +1189,21 @@ fn assert_json_command_with_env(current_dir: &Path, args: &[&str], envs: &[(&str
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("stdout should be valid json")
|
||||
let parsed: Value =
|
||||
serde_json::from_slice(&output.stdout).expect("stdout should be valid json");
|
||||
assert_non_empty_action(&parsed, args);
|
||||
parsed
|
||||
}
|
||||
|
||||
fn assert_non_empty_action(parsed: &Value, args: &[&str]) {
|
||||
let action = parsed
|
||||
.get("action")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!action.trim().is_empty(),
|
||||
"JSON output for args={args:?} must include a non-empty stable action field: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
||||
@@ -1005,6 +1215,10 @@ fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output
|
||||
command.output().expect("claw should launch")
|
||||
}
|
||||
|
||||
fn strings(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(|item| (*item).to_string()).collect()
|
||||
}
|
||||
|
||||
fn write_upstream_fixture(root: &Path) -> PathBuf {
|
||||
let upstream = root.join("claw-code");
|
||||
let src = upstream.join("src");
|
||||
@@ -1114,6 +1328,18 @@ fn diff_json_has_status_and_result_field_702() {
|
||||
parsed.get("result").is_some(),
|
||||
"diff JSON must have result field"
|
||||
);
|
||||
// #710: diff JSON must have action:diff and working_directory
|
||||
assert_eq!(
|
||||
parsed["action"], "diff",
|
||||
"diff JSON must have action:diff (#710)"
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.get("working_directory")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some(),
|
||||
"diff JSON must have working_directory field (#710)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -523,7 +523,14 @@ fn resumed_stub_command_emits_not_implemented_json() {
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json");
|
||||
assert_eq!(parsed["type"], "error");
|
||||
assert_eq!(
|
||||
parsed["status"], "error",
|
||||
"stub command should emit status:error"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["kind"], "unsupported_command",
|
||||
"stub command should emit kind:unsupported_command"
|
||||
);
|
||||
assert!(
|
||||
parsed["error"]
|
||||
.as_str()
|
||||
|
||||
72
scripts/roadmap-check-ids.sh
Executable file
72
scripts/roadmap-check-ids.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# roadmap-check-ids.sh — fail when helper-era ROADMAP item ids are duplicated.
|
||||
# Usage: scripts/roadmap-check-ids.sh [--min-id N] [path/to/ROADMAP.md]
|
||||
#
|
||||
# By default this validates ids >= 723, the point where ROADMAP appends started
|
||||
# using scripts/roadmap-next-id.sh. Earlier ROADMAP content contains historical
|
||||
# numbered lists and already-landed duplicate low ids, so the default guard is
|
||||
# intentionally scoped to new helper-era append collisions. Use --min-id 1 for a
|
||||
# strict whole-file audit after legacy numbering is cleaned up.
|
||||
set -euo pipefail
|
||||
|
||||
MIN_ID=723
|
||||
ROADMAP="ROADMAP.md"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--min-id)
|
||||
if [[ $# -lt 2 || ! "$2" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: --min-id requires a non-negative integer" >&2
|
||||
exit 2
|
||||
fi
|
||||
MIN_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
sed -n '2,9p' "$0" | sed 's/^# //; s/^#//'
|
||||
exit 0
|
||||
;;
|
||||
--*)
|
||||
echo "error: unknown option: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
ROADMAP="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$ROADMAP" ]]; then
|
||||
echo "error: ROADMAP not found at $ROADMAP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
awk -v min_id="$MIN_ID" -v path="$ROADMAP" '
|
||||
/^[0-9]+\./ {
|
||||
id = $0
|
||||
sub(/\..*/, "", id)
|
||||
id += 0
|
||||
if (id >= min_id) {
|
||||
count[id]++
|
||||
lines[id] = lines[id] (lines[id] ? ", " : "") FNR
|
||||
}
|
||||
}
|
||||
END {
|
||||
for (id in count) {
|
||||
if (count[id] > 1) {
|
||||
duplicate_count++
|
||||
duplicate_ids[duplicate_count] = id
|
||||
}
|
||||
}
|
||||
if (duplicate_count) {
|
||||
print "error: duplicate ROADMAP numeric id(s) in " path " (min id " min_id "):" > "/dev/stderr"
|
||||
for (i = 1; i <= duplicate_count; i++) {
|
||||
id = duplicate_ids[i]
|
||||
print " - " id " at line(s) " lines[id] > "/dev/stderr"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
print "roadmap id check passed: no duplicate ids >= " min_id " in " path
|
||||
}
|
||||
' "$ROADMAP"
|
||||
57
scripts/roadmap-next-id.sh
Executable file
57
scripts/roadmap-next-id.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# roadmap-next-id.sh — print the next available ROADMAP item id.
|
||||
# Usage: scripts/roadmap-next-id.sh [path/to/ROADMAP.md]
|
||||
#
|
||||
# Designed to be used before appending a new entry so that concurrent
|
||||
# dogfood claws do not accidentally reuse the same id:
|
||||
#
|
||||
# NEXT=$(scripts/roadmap-next-id.sh)
|
||||
# cat >> ROADMAP.md << EOF
|
||||
# ${NEXT}. **...description...**
|
||||
# EOF
|
||||
#
|
||||
# The script first validates helper-era ids with roadmap-check-ids.sh, then
|
||||
# reads the highest numeric id prefix from ROADMAP.md and prints highest+1. It
|
||||
# does not lock the file; callers working in parallel should git-pull
|
||||
# immediately before appending, run scripts/roadmap-check-ids.sh before push,
|
||||
# and resolve any append collision at git-push time.
|
||||
set -euo pipefail
|
||||
|
||||
ROADMAP="${1:-ROADMAP.md}"
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
CHECKER="$SCRIPT_DIR/roadmap-check-ids.sh"
|
||||
|
||||
if [[ ! -f "$ROADMAP" ]]; then
|
||||
echo "error: ROADMAP not found at $ROADMAP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CHECKER" || ! -r "$CHECKER" ]]; then
|
||||
echo "error: required ROADMAP id checker not found or not readable at $CHECKER" >&2
|
||||
echo "error: refusing to print a next id without duplicate-id validation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! checker_output="$(bash "$CHECKER" "$ROADMAP" 2>&1)"; then
|
||||
printf '%s\n' "$checker_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the highest leading integer from lines that start with a number + '.'.
|
||||
highest=$(awk '
|
||||
/^[0-9]+\./ {
|
||||
id = $0
|
||||
sub(/\..*/, "", id)
|
||||
id += 0
|
||||
if (id > highest) {
|
||||
highest = id
|
||||
}
|
||||
}
|
||||
END { print highest + 0 }
|
||||
' "$ROADMAP")
|
||||
|
||||
if [[ "$highest" -eq 0 ]]; then
|
||||
echo 1
|
||||
else
|
||||
echo $(( highest + 1 ))
|
||||
fi
|
||||
45
tests/test_pre_push_hook_contract.py
Normal file
45
tests/test_pre_push_hook_contract.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PRE_PUSH_HOOK = REPO_ROOT / '.github' / 'hooks' / 'pre-push'
|
||||
|
||||
|
||||
class PrePushHookContractTests(unittest.TestCase):
|
||||
def test_skip_escape_hatch_exits_successfully_with_stderr_notice(self) -> None:
|
||||
env = os.environ.copy()
|
||||
env['SKIP_CLAW_PRE_PUSH_BUILD'] = '1'
|
||||
|
||||
result = subprocess.run(
|
||||
['bash', str(PRE_PUSH_HOOK)],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('SKIP_CLAW_PRE_PUSH_BUILD=1', result.stderr)
|
||||
self.assertIn('skipping cargo workspace build', result.stderr)
|
||||
|
||||
def test_default_build_gate_uses_workspace_locked_cargo_build(self) -> None:
|
||||
hook = PRE_PUSH_HOOK.read_text()
|
||||
|
||||
self.assertIn(
|
||||
'cargo build --manifest-path rust/Cargo.toml --workspace --locked',
|
||||
hook,
|
||||
)
|
||||
self.assertIn(
|
||||
'build_cmd=(cargo build --manifest-path rust/Cargo.toml --workspace --locked)',
|
||||
hook,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
67
tests/test_roadmap_helpers.py
Normal file
67
tests/test_roadmap_helpers.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh'
|
||||
|
||||
|
||||
def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
['bash', str(script), str(roadmap)],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class RoadmapHelperTests(unittest.TestCase):
|
||||
def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('721. old\n723. helper era\n724. guard\n')
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertEqual(0, result.returncode)
|
||||
self.assertEqual('725\n', result.stdout)
|
||||
self.assertEqual('', result.stderr)
|
||||
|
||||
def test_roadmap_next_id_fails_fast_on_helper_era_duplicate(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('722. legacy\n999. first\n999. duplicate\n')
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('duplicate ROADMAP numeric id(s)', result.stderr)
|
||||
self.assertIn('999', result.stderr)
|
||||
self.assertNotIn('1000', result.stdout)
|
||||
|
||||
def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
script_dir = Path(temp_dir) / 'scripts'
|
||||
script_dir.mkdir()
|
||||
copied_next_id = script_dir / 'roadmap-next-id.sh'
|
||||
shutil.copy2(NEXT_ID, copied_next_id)
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('724. guard\n')
|
||||
|
||||
result = run_next_id(roadmap, copied_next_id)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('required ROADMAP id checker not found or not readable', result.stderr)
|
||||
self.assertIn('refusing to print a next id', result.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user