Close the clawability backlog with deterministic CLI output and lane lineage

Finish the remaining roadmap work by making direct CLI JSON output deterministic across the non-interactive surface, restoring the degraded-startup MCP test as a real workspace test, and adding branch-lock plus commit-lineage primitives so downstream lane consumers can distinguish superseded worktree commits from canonical lineage.

Constraint: Keep the user-facing config namespace centered on .claw while preserving legacy fallback discovery for compatibility
Constraint: Verification needed to stay clean-room and reproducible from the checked-in workspace alone
Rejected: Leave the output-format contract implied by ad-hoc smoke runs only | too easy for direct CLI regressions to slip back into prose-only output
Rejected: Keep commit provenance as free-form detail text | downstream consumers need structured branch/worktree/supersession metadata
Confidence: medium
Scope-risk: moderate
Directive: Extend the JSON contract through the same direct CLI entrypoints instead of adding one-off serializers on parallel code paths
Tested: python .github/scripts/check_doc_source_of_truth.py
Tested: cd rust && cargo fmt --all --check
Tested: cd rust && cargo test --workspace
Tested: cd rust && cargo clippy -p commands -p tools -p rusty-claude-cli --all-targets --no-deps -- -D warnings
Not-tested: full cargo clippy --workspace --all-targets -- -D warnings still reports unrelated pre-existing runtime lint debt outside this change set
This commit is contained in:
Yeachan-Heo
2026-04-05 18:40:33 +00:00
parent 93e979261e
commit 19c6b29524
14 changed files with 954 additions and 138 deletions

45
.github/scripts/check_doc_source_of_truth.py vendored Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
import re
import sys
ROOT = Path(__file__).resolve().parents[2]
FILES = [
ROOT / 'README.md',
ROOT / 'USAGE.md',
ROOT / 'PARITY.md',
ROOT / 'PHILOSOPHY.md',
ROOT / 'ROADMAP.md',
ROOT / '.github' / 'FUNDING.yml',
]
FILES.extend(sorted((ROOT / 'docs').rglob('*.md')) if (ROOT / 'docs').exists() else [])
FORBIDDEN = {
r'github\.com/Yeachan-Heo/claw-code(?!-parity)': 'replace old claw-code GitHub links with ultraworkers/claw-code',
r'github\.com/code-yeongyu/claw-code': 'replace stale alternate claw-code GitHub links with ultraworkers/claw-code',
r'discord\.gg/6ztZB9jvWq': 'replace the stale UltraWorkers Discord invite with the current invite',
r'api\.star-history\.com/svg\?repos=Yeachan-Heo/claw-code': 'update star-history embeds to ultraworkers/claw-code',
r'star-history\.com/#Yeachan-Heo/claw-code': 'update star-history links to ultraworkers/claw-code',
r'assets/clawd-hero\.jpeg': 'rename stale hero asset references to assets/claw-hero.jpeg',
r'assets/instructkr\.png': 'remove stale instructkr image references',
}
errors: list[str] = []
for path in FILES:
if not path.exists():
continue
text = path.read_text(encoding='utf-8')
for pattern, message in FORBIDDEN.items():
for match in re.finditer(pattern, text):
line = text.count('\n', 0, match.start()) + 1
errors.append(f'{path.relative_to(ROOT)}:{line}: {message}')
if errors:
print('doc source-of-truth check failed:', file=sys.stderr)
for error in errors:
print(f' - {error}', file=sys.stderr)
sys.exit(1)
print('doc source-of-truth check passed')

View File

@@ -8,12 +8,28 @@ on:
- 'omx-issue-*'
paths:
- .github/workflows/rust-ci.yml
- .github/scripts/check_doc_source_of_truth.py
- .github/FUNDING.yml
- README.md
- USAGE.md
- PARITY.md
- PHILOSOPHY.md
- ROADMAP.md
- docs/**
- rust/**
pull_request:
branches:
- main
paths:
- .github/workflows/rust-ci.yml
- .github/scripts/check_doc_source_of_truth.py
- .github/FUNDING.yml
- README.md
- USAGE.md
- PARITY.md
- PHILOSOPHY.md
- ROADMAP.md
- docs/**
- rust/**
workflow_dispatch:
@@ -29,6 +45,20 @@ env:
CARGO_TERM_COLOR: always
jobs:
doc-source-of-truth:
name: docs source-of-truth
runs-on: ubuntu-latest
defaults:
run:
working-directory: .
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Check docs and metadata for stale branding
run: python .github/scripts/check_doc_source_of_truth.py
fmt:
name: cargo fmt
runs-on: ubuntu-latest

View File

@@ -32,7 +32,7 @@ Claw Code is the public Rust implementation of the `claw` CLI agent harness.
The canonical implementation lives in [`rust/`](./rust), and the current source of truth for this repository is **ultraworkers/claw-code**.
> [!IMPORTANT]
> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Use [`rust/README.md`](./rust/README.md) for crate-level details and [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint.
> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow.
## Current repository shape

View File

@@ -271,18 +271,18 @@ Acceptance:
Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 = clawability hardening, P3 = swarm-efficiency improvements.
**P0 — Fix first (CI reliability)**
1. Isolate `render_diff_report` tests into tmpdir — flaky under `cargo test --workspace`; reads real working-tree state; breaks CI during active worktree ops
2. Expand GitHub CI from single-crate coverage to workspace-grade verification — current `rust-ci.yml` runs `cargo fmt` and `cargo test -p rusty-claude-cli`, but misses broader `cargo test --workspace` coverage that already passes locally
3. Add release-grade binary workflow — repo has a Rust CLI and release intent, but no GitHub Actions path that builds tagged artifacts / checks release packaging before a publish step
4. Add container-first test/run docs — runtime detects Docker/Podman/container state, but docs do not show a canonical container workflow for `cargo test --workspace`, binary execution, or bind-mounted repo usage
5. Surface `doctor` / preflight diagnostics in onboarding docs and help — the CLI already has setup-diagnosis commands and branch preflight machinery, but they are not prominent enough in README/USAGE, so new users still ask manual setup questions instead of running a built-in health check first
6. Automate branding/source-of-truth residue checks in CI — the manual doc pass is done, but badges, Discord invites, copied repo URLs, and org-name drift can regress unless a cheap lint/check keeps README/docs aligned with `ultraworkers/claw-code`
7. Eliminate warning spam from first-run help/build path — `cargo run -p rusty-claude-cli -- --help` currently prints a wall of compile warnings before the actual help text, which pollutes the first-touch UX and hides the product surface behind unrelated noise
8. Promote `doctor` from slash-only to top-level CLI entrypoint — users naturally try `claw doctor`, but today it errors and tells them to enter a REPL or resume path first; healthcheck flows should be callable directly from the shell
9. Make machine-readable status commands actually machine-readable — `status` and `sandbox` accept the global `--output-format json` flag path, but currently still render prose tables, which breaks shell automation and agent-friendly health polling
10. Unify legacy config/skill namespaces in user-facing output — `skills` currently surfaces mixed project roots like `.codex` and `.claude`, which leaks historical layers into the current product and makes it unclear which config namespace is canonical
11. Honor JSON output on inventory commands like `skills` and `mcp`these are exactly the commands agents and shell scripts want to inspect programmatically, but `--output-format json` still yields prose, forcing text scraping where structured inventory should exist
12. Audit `--output-format` contract across the whole CLI surface — current behavior is inconsistent by subcommand, so agents cannot trust the global flag without command-by-command probing; the format contract itself needs to become deterministic
1. Isolate `render_diff_report` tests into tmpdir — **done**: `render_diff_report_for()` tests run in temp git repos instead of the live working tree, and targeted `cargo test -p rusty-claude-cli render_diff_report -- --nocapture` now stays green during branch/worktree activity
2. Expand GitHub CI from single-crate coverage to workspace-grade verification — **done**: `.github/workflows/rust-ci.yml` now runs `cargo test --workspace` plus fmt/clippy at the workspace level
3. Add release-grade binary workflow — **done**: `.github/workflows/release.yml` now builds tagged Rust release artifacts for the CLI
4. Add container-first test/run docs — **done**: `Containerfile` + `docs/container.md` document the canonical Docker/Podman workflow for build, bind-mount, and `cargo test --workspace` usage
5. Surface `doctor` / preflight diagnostics in onboarding docs and help — **done**: README + USAGE now put `claw doctor` / `/doctor` in the first-run path and point at the built-in preflight report
6. Automate branding/source-of-truth residue checks in CI — **done**: `.github/scripts/check_doc_source_of_truth.py` and the `doc-source-of-truth` CI job now block stale repo/org/invite residue in tracked docs and metadata
7. Eliminate warning spam from first-run help/build path — **done**: current `cargo run -q -p rusty-claude-cli -- --help` renders clean help output without a warning wall before the product surface
8. Promote `doctor` from slash-only to top-level CLI entrypoint — **done**: `claw doctor` is now a local shell entrypoint with regression coverage for direct help and health-report output
9. Make machine-readable status commands actually machine-readable — **done**: `claw --output-format json status` and `claw --output-format json sandbox` now emit structured JSON snapshots instead of prose tables
10. Unify legacy config/skill namespaces in user-facing output — **done**: skills/help JSON/text output now present `.claw` as the canonical namespace and collapse legacy roots behind `.claw`-shaped source ids/labels
11. Honor JSON output on inventory commands like `skills` and `mcp`**done**: direct CLI inventory commands now honor `--output-format json` with structured payloads for both skills and MCP inventory
12. Audit `--output-format` contract across the whole CLI surface — **done**: direct CLI commands now honor deterministic JSON/text handling across help/version/status/sandbox/agents/mcp/skills/bootstrap-plan/system-prompt/init/doctor, with regression coverage in `output_format_contract.rs` and resumed `/status` JSON coverage
**P1 — Next (integration wiring, unblocks verification)**
2. Add cross-module integration tests — **done**: 12 integration tests covering worker→recovery→policy, stale_branch→policy, green_contract→policy, reconciliation flows
@@ -300,17 +300,15 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
12. Lane board / machine-readable status API — **done**: Lane completion hardening + `LaneContext::completed` auto-detection + MCP degraded reporting surface machine-readable state
13. **Session completion failure classification****done**: `WorkerFailureKind::Provider` + `observe_completion()` + recovery recipe bridge landed
14. **Config merge validation gap****done**: `config.rs` hook validation before deep-merge (+56 lines), malformed entries fail with source-path context instead of merged parse errors
15. **MCP manager discovery flaky test**`manager_discovery_report_keeps_healthy_servers_when_one_server_fails` has intermittent timing issues in CI; temporarily ignored, needs root cause fix
16. **Commit provenance / worktree-aware push events** — clawhip build stream shows duplicate-looking commit messages and worktree-originated pushes without clear supersession indicators; add worktree/branch metadata to push events and de-dup superseded commits in build stream display
17. **Orphaned module integration audit**`session_control` is `pub mod` exported from `runtime` but has zero consumers across the entire workspace (no import, no call site outside its own file). `trust_resolver` types are re-exported from `lib.rs` but never instantiated outside unit tests. These modules implement core clawability contracts (session management, trust resolution) that are structurally dead — built but not wired into the CLI or tools crate. **Action:** audit all `pub mod` / `pub use` exports from `runtime` for actual call sites; either wire orphaned modules into the real execution path or demote to `pub(crate)` / `cfg(test)` to prevent false clawability surface.
18. **Context-window preflight gap** — claw-code auto-compacts only after cumulative input crosses a static `100_000`-token threshold, while provider requests derive `max_tokens` from a naive model-name heuristic (`opus` => 32k, else 64k) and do not appear to preflight `estimated_prompt_tokens + requested_output_tokens` against the selected models actual context window. Result: giant sessions can be sent upstream and fail hard with provider-side `input_exceeds_context_by_*` errors instead of local preflight compaction/rejection. **Action:** add a model-context registry + request-size preflight before provider call; if projected request exceeds context, emit a structured `context_window_blocked` event and auto-compact or force `/compact` before retry.
19. **Subcommand help falls through into runtime/API path** — direct dogfood shows `./target/debug/claw doctor --help` and `./target/debug/claw status --help` do not render local subcommand help. Instead they enter the request path, show `🦀 Thinking...`, then fail with `api returned 500 ... auth_unavailable: no auth available`. Help/usage surfaces must be pure local parsing and never require auth or provider reachability. **Action:** fix argv dispatch so `<subcommand> --help` is intercepted before runtime startup/API client initialization; add regression tests for `doctor --help`, `status --help`, and similar local-info commands.
20. **Session state classification gap (working vs blocked vs finished vs truly stale)** — dogfooding with 14 parallel tmux/OMX lanes exposed that text-idle stale detection is far too coarse. Sessions were repeatedly flagged as stale even when they were already **finished/reportable** (P0.9, P0.10, P2.18, P2.19), **working but quiet** (doc/branding/audit passes), or **blocked on a specific recoverable state** (background terminal still running, cherry-pick conflict, MCP startup noise, transport interruption after partial progress). **Action:** add explicit machine states above prose scraping such as `working`, `blocked_background_job`, `blocked_merge_conflict`, `degraded_mcp`, `interrupted_transport`, `finished_pending_report`, `finished_cleanable`, and `truly_idle`; update clawhip/session monitoring so quiet work is not paged as stale and completed sessions can auto-report + auto-clean.
15. **MCP manager discovery flaky test** **done**: `manager_discovery_report_keeps_healthy_servers_when_one_server_fails` now runs as a normal workspace test again after repeated stable passes, so degraded-startup coverage is no longer hidden behind `#[ignore]`
16. **Commit provenance / worktree-aware push events****done**: `LaneCommitProvenance` now carries branch/worktree/canonical-commit/supersession metadata in lane events, and `dedupe_superseded_commit_events()` is applied before agent manifests are written so superseded commit events collapse to the latest canonical lineage
17. **Orphaned module integration audit****done**: `runtime` now keeps `session_control` and `trust_resolver` behind `#[cfg(test)]` until they are wired into a real non-test execution path, so normal builds no longer advertise dead clawability surface area.
18. **Context-window preflight gap****done**: provider request sizing now emits `context_window_blocked` before oversized requests leave the process, using a model-context registry instead of the old naive max-token heuristic.
19. **Subcommand help falls through into runtime/API path****done**: `claw doctor --help`, `claw status --help`, `claw sandbox --help`, and nested `mcp`/`skills` help are now intercepted locally without runtime/provider startup, with regression tests covering the direct CLI paths.
**P3 — Swarm efficiency**
13. Swarm branch-lock protocol — detect same-module/same-branch collision before parallel workers drift into duplicate implementation
14. Commit provenance / worktree-aware push events — emit branch, worktree, superseded-by, and canonical commit lineage so parallel sessions stop producing duplicate-looking push summaries
13. Swarm branch-lock protocol — **done**: `branch_lock::detect_branch_lock_collisions()` now detects same-branch/same-scope and nested-module collisions before parallel lanes drift into duplicate implementation
14. Commit provenance / worktree-aware push events — **done**: lane event provenance now includes branch/worktree/superseded/canonical lineage metadata, and manifest persistence de-dupes superseded commit events before downstream consumers render them
## Suggested Session Split

View File

@@ -2466,6 +2466,7 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
roots
}
#[allow(clippy::too_many_lines)]
fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
let mut roots = Vec::new();

View File

@@ -0,0 +1,144 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchLockIntent {
#[serde(rename = "laneId")]
pub lane_id: String,
pub branch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub modules: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchLockCollision {
pub branch: String,
pub module: String,
#[serde(rename = "laneIds")]
pub lane_ids: Vec<String>,
}
#[must_use]
pub fn detect_branch_lock_collisions(intents: &[BranchLockIntent]) -> Vec<BranchLockCollision> {
let mut collisions = Vec::new();
for (index, left) in intents.iter().enumerate() {
for right in &intents[index + 1..] {
if left.branch != right.branch {
continue;
}
for module in overlapping_modules(&left.modules, &right.modules) {
collisions.push(BranchLockCollision {
branch: left.branch.clone(),
module,
lane_ids: vec![left.lane_id.clone(), right.lane_id.clone()],
});
}
}
}
collisions.sort_by(|a, b| {
a.branch
.cmp(&b.branch)
.then(a.module.cmp(&b.module))
.then(a.lane_ids.cmp(&b.lane_ids))
});
collisions.dedup();
collisions
}
fn overlapping_modules(left: &[String], right: &[String]) -> Vec<String> {
let mut overlaps = Vec::new();
for left_module in left {
for right_module in right {
if modules_overlap(left_module, right_module) {
overlaps.push(shared_scope(left_module, right_module));
}
}
}
overlaps.sort();
overlaps.dedup();
overlaps
}
fn modules_overlap(left: &str, right: &str) -> bool {
left == right
|| left.starts_with(&format!("{right}/"))
|| right.starts_with(&format!("{left}/"))
}
fn shared_scope(left: &str, right: &str) -> String {
if left.starts_with(&format!("{right}/")) || left == right {
right.to_string()
} else {
left.to_string()
}
}
#[cfg(test)]
mod tests {
use super::{detect_branch_lock_collisions, BranchLockIntent};
#[test]
fn detects_same_branch_same_module_collisions() {
let collisions = detect_branch_lock_collisions(&[
BranchLockIntent {
lane_id: "lane-a".to_string(),
branch: "feature/lock".to_string(),
worktree: Some("wt-a".to_string()),
modules: vec!["runtime/mcp".to_string()],
},
BranchLockIntent {
lane_id: "lane-b".to_string(),
branch: "feature/lock".to_string(),
worktree: Some("wt-b".to_string()),
modules: vec!["runtime/mcp".to_string()],
},
]);
assert_eq!(collisions.len(), 1);
assert_eq!(collisions[0].branch, "feature/lock");
assert_eq!(collisions[0].module, "runtime/mcp");
}
#[test]
fn detects_nested_module_scope_collisions() {
let collisions = detect_branch_lock_collisions(&[
BranchLockIntent {
lane_id: "lane-a".to_string(),
branch: "feature/lock".to_string(),
worktree: None,
modules: vec!["runtime".to_string()],
},
BranchLockIntent {
lane_id: "lane-b".to_string(),
branch: "feature/lock".to_string(),
worktree: None,
modules: vec!["runtime/mcp".to_string()],
},
]);
assert_eq!(collisions[0].module, "runtime");
}
#[test]
fn ignores_different_branches() {
let collisions = detect_branch_lock_collisions(&[
BranchLockIntent {
lane_id: "lane-a".to_string(),
branch: "feature/a".to_string(),
worktree: None,
modules: vec!["runtime/mcp".to_string()],
},
BranchLockIntent {
lane_id: "lane-b".to_string(),
branch: "feature/b".to_string(),
worktree: None,
modules: vec!["runtime/mcp".to_string()],
},
]);
assert!(collisions.is_empty());
}
}

View File

@@ -76,6 +76,20 @@ pub struct LaneEventBlocker {
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneCommitProvenance {
pub commit: String,
pub branch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<String>,
#[serde(rename = "canonicalCommit", skip_serializing_if = "Option::is_none")]
pub canonical_commit: Option<String>,
#[serde(rename = "supersededBy", skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lineage: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneEvent {
pub event: LaneEventName,
@@ -122,6 +136,36 @@ impl LaneEvent {
.with_optional_detail(detail)
}
#[must_use]
pub fn commit_created(
emitted_at: impl Into<String>,
detail: Option<String>,
provenance: LaneCommitProvenance,
) -> Self {
Self::new(
LaneEventName::CommitCreated,
LaneEventStatus::Completed,
emitted_at,
)
.with_optional_detail(detail)
.with_data(serde_json::to_value(provenance).expect("commit provenance should serialize"))
}
#[must_use]
pub fn superseded(
emitted_at: impl Into<String>,
detail: Option<String>,
provenance: LaneCommitProvenance,
) -> Self {
Self::new(
LaneEventName::Superseded,
LaneEventStatus::Superseded,
emitted_at,
)
.with_optional_detail(detail)
.with_data(serde_json::to_value(provenance).expect("commit provenance should serialize"))
}
#[must_use]
pub fn blocked(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
@@ -161,11 +205,54 @@ impl LaneEvent {
}
}
#[must_use]
pub fn dedupe_superseded_commit_events(events: &[LaneEvent]) -> Vec<LaneEvent> {
let mut keep = vec![true; events.len()];
let mut latest_by_key = std::collections::BTreeMap::<String, usize>::new();
for (index, event) in events.iter().enumerate() {
if event.event != LaneEventName::CommitCreated {
continue;
}
let Some(data) = event.data.as_ref() else {
continue;
};
let key = data
.get("canonicalCommit")
.or_else(|| data.get("commit"))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let superseded = data
.get("supersededBy")
.and_then(serde_json::Value::as_str)
.is_some();
if superseded {
keep[index] = false;
continue;
}
if let Some(key) = key {
if let Some(previous) = latest_by_key.insert(key, index) {
keep[previous] = false;
}
}
}
events
.iter()
.cloned()
.zip(keep)
.filter_map(|(event, retain)| retain.then_some(event))
.collect()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass};
use super::{
dedupe_superseded_commit_events, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventName, LaneEventStatus, LaneFailureClass,
};
#[test]
fn canonical_lane_event_names_serialize_to_expected_wire_values() {
@@ -240,4 +327,56 @@ mod tests {
assert_eq!(failed.status, LaneEventStatus::Failed);
assert_eq!(failed.detail.as_deref(), Some("broken server"));
}
#[test]
fn commit_events_can_carry_worktree_and_supersession_metadata() {
let event = LaneEvent::commit_created(
"2026-04-04T00:00:00Z",
Some("commit created".to_string()),
LaneCommitProvenance {
commit: "abc123".to_string(),
branch: "feature/provenance".to_string(),
worktree: Some("wt-a".to_string()),
canonical_commit: Some("abc123".to_string()),
superseded_by: None,
lineage: vec!["abc123".to_string()],
},
);
let event_json = serde_json::to_value(&event).expect("lane event should serialize");
assert_eq!(event_json["event"], "lane.commit.created");
assert_eq!(event_json["data"]["branch"], "feature/provenance");
assert_eq!(event_json["data"]["worktree"], "wt-a");
}
#[test]
fn dedupes_superseded_commit_events_by_canonical_commit() {
let retained = dedupe_superseded_commit_events(&[
LaneEvent::commit_created(
"2026-04-04T00:00:00Z",
Some("old".to_string()),
LaneCommitProvenance {
commit: "old123".to_string(),
branch: "feature/provenance".to_string(),
worktree: Some("wt-a".to_string()),
canonical_commit: Some("canon123".to_string()),
superseded_by: Some("new123".to_string()),
lineage: vec!["old123".to_string(), "new123".to_string()],
},
),
LaneEvent::commit_created(
"2026-04-04T00:00:01Z",
Some("new".to_string()),
LaneCommitProvenance {
commit: "new123".to_string(),
branch: "feature/provenance".to_string(),
worktree: Some("wt-b".to_string()),
canonical_commit: Some("canon123".to_string()),
superseded_by: None,
lineage: vec!["old123".to_string(), "new123".to_string()],
},
),
]);
assert_eq!(retained.len(), 1);
assert_eq!(retained[0].detail.as_deref(), Some("new"));
}
}

View File

@@ -7,6 +7,7 @@
mod bash;
pub mod bash_validation;
mod bootstrap;
pub mod branch_lock;
mod compact;
mod config;
mod conversation;
@@ -46,6 +47,7 @@ pub mod worker_boot;
pub use bash::{execute_bash, BashCommandInput, BashCommandOutput};
pub use bootstrap::{BootstrapPhase, BootstrapPlan};
pub use branch_lock::{detect_branch_lock_collisions, BranchLockCollision, BranchLockIntent};
pub use compact::{
compact_session, estimate_session_tokens, format_compact_summary,
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
@@ -72,7 +74,8 @@ pub use hooks::{
HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner,
};
pub use lane_events::{
LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass,
dedupe_superseded_commit_events, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventName, LaneEventStatus, LaneFailureClass,
};
pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,

View File

@@ -2652,8 +2652,37 @@ mod tests {
});
}
fn write_initialize_disconnect_script() -> PathBuf {
let root = temp_dir();
fs::create_dir_all(&root).expect("temp dir");
let script_path = root.join("initialize-disconnect.py");
let script = [
"#!/usr/bin/env python3",
"import sys",
"header = b''",
r"while not header.endswith(b'\r\n\r\n'):",
" chunk = sys.stdin.buffer.read(1)",
" if not chunk:",
" raise SystemExit(1)",
" header += chunk",
"length = 0",
r"for line in header.decode().split('\r\n'):",
r" if line.lower().startswith('content-length:'):",
r" length = int(line.split(':', 1)[1].strip())",
"if length:",
" sys.stdin.buffer.read(length)",
"raise SystemExit(0)",
"",
]
.join("\n");
fs::write(&script_path, script).expect("write script");
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions).expect("chmod");
script_path
}
#[test]
#[ignore = "flaky: intermittent timing issues in CI, see ROADMAP P2.15"]
fn manager_discovery_report_keeps_healthy_servers_when_one_server_fails() {
let runtime = Builder::new_current_thread()
.enable_all()
@@ -2663,6 +2692,7 @@ mod tests {
let script_path = write_manager_mcp_server_script();
let root = script_path.parent().expect("script parent");
let alpha_log = root.join("alpha.log");
let broken_script_path = write_initialize_disconnect_script();
let servers = BTreeMap::from([
(
"alpha".to_string(),
@@ -2673,8 +2703,8 @@ mod tests {
ScopedMcpServerConfig {
scope: ConfigSource::Local,
config: McpServerConfig::Stdio(McpStdioServerConfig {
command: "python3".to_string(),
args: vec!["-c".to_string(), "import sys; sys.exit(0)".to_string()],
command: broken_script_path.display().to_string(),
args: Vec::new(),
env: BTreeMap::new(),
tool_call_timeout_ms: None,
}),
@@ -2737,6 +2767,7 @@ mod tests {
manager.shutdown().await.expect("shutdown");
cleanup_script(&script_path);
cleanup_script(&broken_script_path);
});
}

View File

@@ -31,3 +31,4 @@ workspace = true
mock-anthropic-service = { path = "../mock-anthropic-service" }
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread"] }

View File

@@ -89,6 +89,10 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[
];
type AllowedToolSet = BTreeSet<String>;
type RuntimePluginStateBuildOutput = (
Option<Arc<Mutex<RuntimeMcpState>>>,
Vec<RuntimeToolDefinition>,
);
fn main() {
if let Err(error) = run() {
@@ -109,9 +113,12 @@ Run `claw --help` for usage."
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect();
match parse_args(&args)? {
CliAction::DumpManifests => dump_manifests(),
CliAction::BootstrapPlan => print_bootstrap_plan(),
CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?,
CliAction::DumpManifests { output_format } => dump_manifests(output_format)?,
CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?,
CliAction::Agents {
args,
output_format,
} => LiveCli::print_agents(args.as_deref(), output_format)?,
CliAction::Mcp {
args,
output_format,
@@ -120,12 +127,17 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
args,
output_format,
} => LiveCli::print_skills(args.as_deref(), output_format)?,
CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date),
CliAction::Version => print_version(),
CliAction::PrintSystemPrompt {
cwd,
date,
output_format,
} => print_system_prompt(cwd, date, output_format)?,
CliAction::Version { output_format } => print_version(output_format)?,
CliAction::ResumeSession {
session_path,
commands,
} => resume_session(&session_path, &commands),
output_format,
} => resume_session(&session_path, &commands, output_format),
CliAction::Status {
model,
permission_mode,
@@ -140,27 +152,32 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
permission_mode,
} => LiveCli::new(model, true, allowed_tools, permission_mode)?
.run_turn_with_output(&prompt, output_format)?,
CliAction::Login => run_login()?,
CliAction::Logout => run_logout()?,
CliAction::Doctor => run_doctor()?,
CliAction::Init => run_init()?,
CliAction::Login { output_format } => run_login(output_format)?,
CliAction::Logout { output_format } => run_logout(output_format)?,
CliAction::Doctor { output_format } => run_doctor(output_format)?,
CliAction::Init { output_format } => run_init(output_format)?,
CliAction::Repl {
model,
allowed_tools,
permission_mode,
} => run_repl(model, allowed_tools, permission_mode)?,
CliAction::HelpTopic(topic) => print_help_topic(topic),
CliAction::Help => print_help(),
CliAction::Help { output_format } => print_help(output_format)?,
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CliAction {
DumpManifests,
BootstrapPlan,
DumpManifests {
output_format: CliOutputFormat,
},
BootstrapPlan {
output_format: CliOutputFormat,
},
Agents {
args: Option<String>,
output_format: CliOutputFormat,
},
Mcp {
args: Option<String>,
@@ -173,11 +190,15 @@ enum CliAction {
PrintSystemPrompt {
cwd: PathBuf,
date: String,
output_format: CliOutputFormat,
},
Version {
output_format: CliOutputFormat,
},
Version,
ResumeSession {
session_path: PathBuf,
commands: Vec<String>,
output_format: CliOutputFormat,
},
Status {
model: String,
@@ -194,10 +215,18 @@ enum CliAction {
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
},
Login,
Logout,
Doctor,
Init,
Login {
output_format: CliOutputFormat,
},
Logout {
output_format: CliOutputFormat,
},
Doctor {
output_format: CliOutputFormat,
},
Init {
output_format: CliOutputFormat,
},
Repl {
model: String,
allowed_tools: Option<AllowedToolSet>,
@@ -205,7 +234,9 @@ enum CliAction {
},
HelpTopic(LocalHelpTopic),
// prompt-mode formatting is only supported for non-interactive runs
Help,
Help {
output_format: CliOutputFormat,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -346,11 +377,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}
if wants_help {
return Ok(CliAction::Help);
return Ok(CliAction::Help { output_format });
}
if wants_version {
return Ok(CliAction::Version);
return Ok(CliAction::Version { output_format });
}
let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?;
@@ -364,22 +395,25 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
});
}
if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..]);
return parse_resume_args(&rest[1..], output_format);
}
if let Some(action) = parse_local_help_action(&rest) {
return action;
}
if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode_override, output_format) {
if let Some(action) =
parse_single_word_command_alias(&rest, &model, permission_mode_override, output_format)
{
return action;
}
let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode);
match rest[0].as_str() {
"dump-manifests" => Ok(CliAction::DumpManifests),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan),
"dump-manifests" => Ok(CliAction::DumpManifests { output_format }),
"bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }),
"agents" => Ok(CliAction::Agents {
args: join_optional_args(&rest[1..]),
output_format,
}),
"mcp" => Ok(CliAction::Mcp {
args: join_optional_args(&rest[1..]),
@@ -389,10 +423,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
args: join_optional_args(&rest[1..]),
output_format,
}),
"system-prompt" => parse_system_prompt_args(&rest[1..]),
"login" => Ok(CliAction::Login),
"logout" => Ok(CliAction::Logout),
"init" => Ok(CliAction::Init),
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
"login" => Ok(CliAction::Login { output_format }),
"logout" => Ok(CliAction::Logout { output_format }),
"init" => Ok(CliAction::Init { output_format }),
"prompt" => {
let prompt = rest[1..].join(" ");
if prompt.trim().is_empty() {
@@ -446,15 +480,15 @@ fn parse_single_word_command_alias(
}
match rest[0].as_str() {
"help" => Some(Ok(CliAction::Help)),
"version" => Some(Ok(CliAction::Version)),
"help" => Some(Ok(CliAction::Help { output_format })),
"version" => Some(Ok(CliAction::Version { output_format })),
"status" => Some(Ok(CliAction::Status {
model: model.to_string(),
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
output_format,
})),
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
"doctor" => Some(Ok(CliAction::Doctor)),
"doctor" => Some(Ok(CliAction::Doctor { output_format })),
other => bare_slash_command_guidance(other).map(Err),
}
}
@@ -502,8 +536,11 @@ fn parse_direct_slash_cli_action(
) -> Result<CliAction, String> {
let raw = rest.join(" ");
match SlashCommand::parse(&raw) {
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help),
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }),
Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }),
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents {
args,
output_format,
}),
Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp {
args: match (action, target) {
(None, None) => None,
@@ -727,7 +764,10 @@ fn filter_tool_specs(
tool_registry.definitions(allowed_tools)
}
fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
fn parse_system_prompt_args(
args: &[String],
output_format: CliOutputFormat,
) -> Result<CliAction, String> {
let mut cwd = env::current_dir().map_err(|error| error.to_string())?;
let mut date = DEFAULT_DATE.to_string();
let mut index = 0;
@@ -752,10 +792,14 @@ fn parse_system_prompt_args(args: &[String]) -> Result<CliAction, String> {
}
}
Ok(CliAction::PrintSystemPrompt { cwd, date })
Ok(CliAction::PrintSystemPrompt {
cwd,
date,
output_format,
})
}
fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result<CliAction, String> {
let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() {
None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]),
Some(first) if looks_like_slash_command_token(first) => {
@@ -795,6 +839,7 @@ fn parse_resume_args(args: &[String]) -> Result<CliAction, String> {
Ok(CliAction::ResumeSession {
session_path,
commands,
output_format,
})
}
@@ -930,9 +975,21 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
})
}
fn run_doctor() -> Result<(), Box<dyn std::error::Error>> {
fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let report = render_doctor_report()?;
println!("{}", report.render());
let message = report.render();
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "doctor",
"message": message,
"report": message,
"has_failures": report.has_failures(),
}))?
),
}
if report.has_failures() {
return Err("doctor found failing checks".into());
}
@@ -1212,26 +1269,54 @@ fn looks_like_slash_command_token(token: &str) -> bool {
.any(|spec| spec.name == name || spec.aliases.contains(&name))
}
fn dump_manifests() {
fn dump_manifests(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let paths = UpstreamPaths::from_workspace_dir(&workspace_dir);
match extract_manifest(&paths) {
Ok(manifest) => {
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
}
Err(error) => {
eprintln!("failed to extract manifests: {error}");
std::process::exit(1);
match output_format {
CliOutputFormat::Text => {
println!("commands: {}", manifest.commands.entries().len());
println!("tools: {}", manifest.tools.entries().len());
println!("bootstrap phases: {}", manifest.bootstrap.phases().len());
}
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "dump-manifests",
"commands": manifest.commands.entries().len(),
"tools": manifest.tools.entries().len(),
"bootstrap_phases": manifest.bootstrap.phases().len(),
}))?
),
}
Ok(())
}
Err(error) => Err(format!("failed to extract manifests: {error}").into()),
}
}
fn print_bootstrap_plan() {
for phase in runtime::BootstrapPlan::claude_code_default().phases() {
println!("- {phase:?}");
fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let phases = runtime::BootstrapPlan::claude_code_default()
.phases()
.iter()
.map(|phase| format!("{phase:?}"))
.collect::<Vec<_>>();
match output_format {
CliOutputFormat::Text => {
for phase in &phases {
println!("- {phase}");
}
}
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "bootstrap-plan",
"phases": phases,
}))?
),
}
Ok(())
}
fn default_oauth_config() -> OAuthConfig {
@@ -1249,7 +1334,7 @@ fn default_oauth_config() -> OAuthConfig {
}
}
fn run_login() -> Result<(), Box<dyn std::error::Error>> {
fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let config = ConfigLoader::default_for(&cwd).load()?;
let default_oauth = default_oauth_config();
@@ -1262,8 +1347,10 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
.build_url();
println!("Starting Claude OAuth login...");
println!("Listening for callback on {redirect_uri}");
if output_format == CliOutputFormat::Text {
println!("Starting Claude OAuth login...");
println!("Listening for callback on {redirect_uri}");
}
if let Err(error) = open_browser(&authorize_url) {
eprintln!("warning: failed to open browser automatically: {error}");
println!("Open this URL manually:\n{authorize_url}");
@@ -1287,8 +1374,13 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
}
let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
let exchange_request =
OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri);
let exchange_request = OAuthTokenExchangeRequest::from_config(
oauth,
code,
state,
pkce.verifier,
redirect_uri.clone(),
);
let runtime = tokio::runtime::Runtime::new()?;
let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
save_oauth_credentials(&runtime::OAuthTokenSet {
@@ -1297,13 +1389,33 @@ fn run_login() -> Result<(), Box<dyn std::error::Error>> {
expires_at: token_set.expires_at,
scopes: token_set.scopes,
})?;
println!("Claude OAuth login complete.");
match output_format {
CliOutputFormat::Text => println!("Claude OAuth login complete."),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "login",
"callback_port": callback_port,
"redirect_uri": redirect_uri,
"message": "Claude OAuth login complete.",
}))?
),
}
Ok(())
}
fn run_logout() -> Result<(), Box<dyn std::error::Error>> {
fn run_logout(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
clear_oauth_credentials()?;
println!("Claude OAuth credentials cleared.");
match output_format {
CliOutputFormat::Text => println!("Claude OAuth credentials cleared."),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "logout",
"message": "Claude OAuth credentials cleared.",
}))?
),
}
Ok(())
}
@@ -1361,21 +1473,50 @@ fn wait_for_oauth_callback(
Ok(callback)
}
fn print_system_prompt(cwd: PathBuf, date: String) {
match load_system_prompt(cwd, date, env::consts::OS, "unknown") {
Ok(sections) => println!("{}", sections.join("\n\n")),
Err(error) => {
eprintln!("failed to build system prompt: {error}");
std::process::exit(1);
}
fn print_system_prompt(
cwd: PathBuf,
date: String,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?;
let message = sections.join(
"
",
);
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "system-prompt",
"message": message,
"sections": sections,
}))?
),
}
Ok(())
}
fn print_version() {
println!("{}", render_version_report());
fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let report = render_version_report();
match output_format {
CliOutputFormat::Text => println!("{report}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "version",
"message": report,
"version": VERSION,
"git_sha": GIT_SHA,
"target": BUILD_TARGET,
}))?
),
}
Ok(())
}
fn resume_session(session_path: &Path, commands: &[String]) {
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
let resolved_path = if session_path.exists() {
session_path.to_path_buf()
} else {
@@ -1425,7 +1566,35 @@ fn resume_session(session_path: &Path, commands: &[String]) {
}) => {
session = next_session;
if let Some(message) = message {
println!("{message}");
if output_format == CliOutputFormat::Json
&& matches!(command, SlashCommand::Status)
{
let tracker = UsageTracker::from_session(&session);
let usage = tracker.cumulative_usage();
let context = status_context(Some(&resolved_path)).expect("status context");
let value = json!({
"kind": "status",
"messages": session.messages.len(),
"turns": tracker.turns(),
"latest_total": tracker.current_turn_usage().total_tokens(),
"cumulative_input": usage.input_tokens,
"cumulative_output": usage.output_tokens,
"cumulative_total": usage.total_tokens(),
"workspace": {
"cwd": context.cwd,
"project_root": context.project_root,
"git_branch": context.git_branch,
"git_state": context.git_summary.headline(),
"session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()),
}
});
println!(
"{}",
serde_json::to_string_pretty(&value).expect("status json")
);
} else {
println!("{message}");
}
}
}
Err(error) => {
@@ -2357,13 +2526,7 @@ impl RuntimeMcpState {
fn build_runtime_mcp_state(
runtime_config: &runtime::RuntimeConfig,
) -> Result<
(
Option<Arc<Mutex<RuntimeMcpState>>>,
Vec<RuntimeToolDefinition>,
),
Box<dyn std::error::Error>,
> {
) -> Result<RuntimePluginStateBuildOutput, Box<dyn std::error::Error>> {
let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else {
return Ok((None, Vec::new()));
};
@@ -2809,7 +2972,7 @@ impl LiveCli {
false
}
SlashCommand::Init => {
run_init()?;
run_init(CliOutputFormat::Text)?;
false
}
SlashCommand::Diff => {
@@ -2817,7 +2980,7 @@ impl LiveCli {
false
}
SlashCommand::Version => {
Self::print_version();
Self::print_version(CliOutputFormat::Text);
false
}
SlashCommand::Export { path } => {
@@ -2831,7 +2994,7 @@ impl LiveCli {
self.handle_plugins_command(action.as_deref(), target.as_deref())?
}
SlashCommand::Agents { args } => {
Self::print_agents(args.as_deref())?;
Self::print_agents(args.as_deref(), CliOutputFormat::Text)?;
false
}
SlashCommand::Skills { args } => {
@@ -3113,9 +3276,23 @@ impl LiveCli {
Ok(())
}
fn print_agents(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
fn print_agents(
args: Option<&str>,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
println!("{}", handle_agents_slash_command(args, &cwd)?);
let message = handle_agents_slash_command(args, &cwd)?;
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "agents",
"message": message,
"args": args,
}))?
),
}
Ok(())
}
@@ -3154,8 +3331,8 @@ impl LiveCli {
Ok(())
}
fn print_version() {
println!("{}", render_version_report());
fn print_version(output_format: CliOutputFormat) {
let _ = crate::print_version(output_format);
}
fn export_session(
@@ -4060,8 +4237,18 @@ fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
Ok(initialize_repo(&cwd)?.render())
}
fn run_init() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", init_claude_md()?);
fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let message = init_claude_md()?;
match output_format {
CliOutputFormat::Text => println!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "init",
"message": message,
}))?
),
}
Ok(())
}
@@ -5916,16 +6103,13 @@ impl CliToolExecutor {
fn execute_search_tool(&self, value: serde_json::Value) -> Result<String, ToolError> {
let input: ToolSearchRequest = serde_json::from_value(value)
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?;
let (pending_mcp_servers, mcp_degraded) = self
.mcp_state
.as_ref()
.map(|state| {
let (pending_mcp_servers, mcp_degraded) =
self.mcp_state.as_ref().map_or((None, None), |state| {
let state = state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(state.pending_servers(), state.degraded_report())
})
.unwrap_or((None, None));
});
serde_json::to_string_pretty(&self.tool_registry.search(
&input.query,
input.max_results.unwrap_or(5),
@@ -6203,8 +6387,21 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
Ok(())
}
fn print_help() {
let _ = print_help_to(&mut io::stdout());
fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let mut buffer = Vec::new();
print_help_to(&mut buffer)?;
let message = String::from_utf8(buffer)?;
match output_format {
CliOutputFormat::Text => print!("{message}"),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "help",
"message": message,
}))?
),
}
Ok(())
}
#[cfg(test)]
@@ -6513,11 +6710,15 @@ mod tests {
fn parses_version_flags_without_initializing_prompt_mode() {
assert_eq!(
parse_args(&["--version".to_string()]).expect("args should parse"),
CliAction::Version
CliAction::Version {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["-V".to_string()]).expect("args should parse"),
CliAction::Version
CliAction::Version {
output_format: CliOutputFormat::Text,
}
);
}
@@ -6579,6 +6780,7 @@ mod tests {
CliAction::PrintSystemPrompt {
cwd: PathBuf::from("/tmp/project"),
date: "2026-04-01".to_string(),
output_format: CliOutputFormat::Text,
}
);
}
@@ -6587,23 +6789,34 @@ mod tests {
fn parses_login_and_logout_subcommands() {
assert_eq!(
parse_args(&["login".to_string()]).expect("login should parse"),
CliAction::Login
CliAction::Login {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["logout".to_string()]).expect("logout should parse"),
CliAction::Logout
CliAction::Logout {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["doctor".to_string()]).expect("doctor should parse"),
CliAction::Doctor
CliAction::Doctor {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["init".to_string()]).expect("init should parse"),
CliAction::Init
CliAction::Init {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["agents".to_string()]).expect("agents should parse"),
CliAction::Agents { args: None }
CliAction::Agents {
args: None,
output_format: CliOutputFormat::Text
}
);
assert_eq!(
parse_args(&["mcp".to_string()]).expect("mcp should parse"),
@@ -6623,7 +6836,8 @@ mod tests {
parse_args(&["agents".to_string(), "--help".to_string()])
.expect("agents help should parse"),
CliAction::Agents {
args: Some("--help".to_string())
args: Some("--help".to_string()),
output_format: CliOutputFormat::Text,
}
);
}
@@ -6653,11 +6867,15 @@ mod tests {
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
assert_eq!(
parse_args(&["help".to_string()]).expect("help should parse"),
CliAction::Help
CliAction::Help {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["version".to_string()]).expect("version should parse"),
CliAction::Version
CliAction::Version {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["status".to_string()]).expect("status should parse"),
@@ -6727,7 +6945,10 @@ mod tests {
fn parses_direct_agents_mcp_and_skills_slash_commands() {
assert_eq!(
parse_args(&["/agents".to_string()]).expect("/agents should parse"),
CliAction::Agents { args: None }
CliAction::Agents {
args: None,
output_format: CliOutputFormat::Text
}
);
assert_eq!(
parse_args(&["/mcp".to_string(), "show".to_string(), "demo".to_string()])
@@ -6807,6 +7028,7 @@ mod tests {
CliAction::ResumeSession {
session_path: PathBuf::from("session.jsonl"),
commands: vec!["/compact".to_string()],
output_format: CliOutputFormat::Text,
}
);
}
@@ -6818,6 +7040,7 @@ mod tests {
CliAction::ResumeSession {
session_path: PathBuf::from("latest"),
commands: vec![],
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
@@ -6826,6 +7049,7 @@ mod tests {
CliAction::ResumeSession {
session_path: PathBuf::from("latest"),
commands: vec!["/status".to_string()],
output_format: CliOutputFormat::Text,
}
);
}
@@ -6848,6 +7072,7 @@ mod tests {
"/compact".to_string(),
"/cost".to_string(),
],
output_format: CliOutputFormat::Text,
}
);
}
@@ -6878,6 +7103,7 @@ mod tests {
"/export notes.txt".to_string(),
"/clear --confirm".to_string(),
],
output_format: CliOutputFormat::Text,
}
);
}
@@ -6896,6 +7122,7 @@ mod tests {
CliAction::ResumeSession {
session_path: PathBuf::from("session.jsonl"),
commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()],
output_format: CliOutputFormat::Text,
}
);
}
@@ -8005,6 +8232,7 @@ UU conflicted.rs",
}
#[test]
#[allow(clippy::too_many_lines)]
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {
let config_home = temp_dir();
let workspace = temp_dir();

View File

@@ -729,8 +729,7 @@ fn assert_token_cost_reporting(_: &HarnessWorkspace, run: &ScenarioRun) {
assert!(
run.response["estimated_cost"]
.as_str()
.map(|cost| cost.starts_with('$'))
.unwrap_or(false),
.is_some_and(|cost| cost.starts_with('$')),
"estimated_cost should be a dollar-prefixed string"
);
}

View File

@@ -0,0 +1,193 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn help_emits_json_when_requested() {
let root = unique_temp_dir("help-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let parsed = assert_json_command(&root, &["--output-format", "json", "help"]);
assert_eq!(parsed["kind"], "help");
assert!(parsed["message"]
.as_str()
.expect("help text")
.contains("Usage:"));
}
#[test]
fn version_emits_json_when_requested() {
let root = unique_temp_dir("version-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
assert_eq!(parsed["kind"], "version");
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
}
#[test]
fn status_and_sandbox_emit_json_when_requested() {
let root = unique_temp_dir("status-sandbox-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
assert_eq!(status["kind"], "status");
assert!(status["workspace"]["cwd"].as_str().is_some());
let sandbox = assert_json_command(&root, &["--output-format", "json", "sandbox"]);
assert_eq!(sandbox["kind"], "sandbox");
assert!(sandbox["filesystem_mode"].as_str().is_some());
}
#[test]
fn inventory_commands_emit_structured_json_when_requested() {
let root = unique_temp_dir("inventory-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let agents = assert_json_command(&root, &["--output-format", "json", "agents"]);
assert_eq!(agents["kind"], "agents");
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
assert_eq!(mcp["kind"], "mcp");
assert_eq!(mcp["action"], "list");
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
assert_eq!(skills["kind"], "skills");
assert_eq!(skills["action"], "list");
}
#[test]
fn bootstrap_and_system_prompt_emit_json_when_requested() {
let root = unique_temp_dir("bootstrap-system-prompt-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let plan = assert_json_command(&root, &["--output-format", "json", "bootstrap-plan"]);
assert_eq!(plan["kind"], "bootstrap-plan");
assert!(plan["phases"].as_array().expect("phases").len() > 1);
let prompt = assert_json_command(&root, &["--output-format", "json", "system-prompt"]);
assert_eq!(prompt["kind"], "system-prompt");
assert!(prompt["message"]
.as_str()
.expect("prompt text")
.contains("interactive agent"));
}
#[test]
fn dump_manifests_and_init_emit_json_when_requested() {
let root = unique_temp_dir("manifest-init-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let upstream = write_upstream_fixture(&root);
let manifests = assert_json_command_with_env(
&root,
&["--output-format", "json", "dump-manifests"],
&[(
"CLAUDE_CODE_UPSTREAM",
upstream.to_str().expect("utf8 upstream"),
)],
);
assert_eq!(manifests["kind"], "dump-manifests");
assert_eq!(manifests["commands"], 1);
assert_eq!(manifests["tools"], 1);
let workspace = root.join("workspace");
fs::create_dir_all(&workspace).expect("workspace should exist");
let init = assert_json_command(&workspace, &["--output-format", "json", "init"]);
assert_eq!(init["kind"], "init");
assert!(workspace.join("CLAUDE.md").exists());
}
#[test]
fn doctor_and_resume_status_emit_json_when_requested() {
let root = unique_temp_dir("doctor-resume-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
assert_eq!(doctor["kind"], "doctor");
assert!(doctor["message"].is_string());
let session_path = root.join("session.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n",
)
.expect("session should write");
let resumed = assert_json_command(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/status",
],
);
assert_eq!(resumed["kind"], "status");
assert_eq!(resumed["messages"], 1);
}
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
assert_json_command_with_env(current_dir, args, &[])
}
fn assert_json_command_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Value {
let output = run_claw(current_dir, args, envs);
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("stdout should be valid json")
}
fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
command.current_dir(current_dir).args(args);
for (key, value) in envs {
command.env(key, value);
}
command.output().expect("claw should launch")
}
fn write_upstream_fixture(root: &Path) -> PathBuf {
let upstream = root.join("claw-code");
let src = upstream.join("src");
let entrypoints = src.join("entrypoints");
fs::create_dir_all(&entrypoints).expect("upstream entrypoints dir should exist");
fs::write(
src.join("commands.ts"),
"import FooCommand from './commands/foo'\n",
)
.expect("commands fixture should write");
fs::write(
src.join("tools.ts"),
"import ReadTool from './tools/read'\n",
)
.expect("tools fixture should write");
fs::write(
entrypoints.join("cli.tsx"),
"if (args[0] === '--version') {}\nstartupProfiler()\n",
)
.expect("cli fixture should write");
upstream
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_millis();
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"claw-output-format-{label}-{}-{millis}-{counter}",
std::process::id()
))
}

View File

@@ -11,7 +11,8 @@ use api::{
use plugins::PluginTool;
use reqwest::blocking::Client;
use runtime::{
check_freshness, edit_file, execute_bash, glob_search, grep_search, load_system_prompt,
check_freshness, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search,
grep_search, load_system_prompt,
lsp_client::LspRegistry,
mcp_tool_bridge::McpToolRegistry,
permission_enforcer::{EnforcementResult, PermissionEnforcer},
@@ -1704,7 +1705,7 @@ fn run_remote_trigger(input: RemoteTriggerInput) -> Result<String, String> {
"method": method,
"status_code": status,
"body": truncated_body,
"success": status >= 200 && status < 300
"success": (200..300).contains(&status)
}))
}
Err(e) => to_pretty_json(json!({
@@ -3276,9 +3277,11 @@ fn agent_permission_policy() -> PermissionPolicy {
}
fn write_agent_manifest(manifest: &AgentOutput) -> Result<(), String> {
let mut normalized = manifest.clone();
normalized.lane_events = dedupe_superseded_commit_events(&normalized.lane_events);
std::fs::write(
&manifest.manifest_file,
serde_json::to_string_pretty(manifest).map_err(|error| error.to_string())?,
&normalized.manifest_file,
serde_json::to_string_pretty(&normalized).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())
}
@@ -3297,7 +3300,7 @@ fn persist_agent_terminal_state(
let mut next_manifest = manifest.clone();
next_manifest.status = status.to_string();
next_manifest.completed_at = Some(iso8601_now());
next_manifest.current_blocker = blocker.clone();
next_manifest.current_blocker.clone_from(&blocker);
next_manifest.error = error;
if let Some(blocker) = blocker {
next_manifest
@@ -5823,6 +5826,7 @@ mod tests {
}
#[test]
#[allow(clippy::too_many_lines)]
fn agent_fake_runner_can_persist_completion_and_failure() {
let _guard = env_lock()
.lock()