mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +08:00
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:
45
.github/scripts/check_doc_source_of_truth.py
vendored
Executable file
45
.github/scripts/check_doc_source_of_truth.py
vendored
Executable 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')
|
||||
30
.github/workflows/rust-ci.yml
vendored
30
.github/workflows/rust-ci.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
40
ROADMAP.md
40
ROADMAP.md
@@ -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 model’s 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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
144
rust/crates/runtime/src/branch_lock.rs
Normal file
144
rust/crates/runtime/src/branch_lock.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,3 +31,4 @@ workspace = true
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
193
rust/crates/rusty-claude-cli/tests/output_format_contract.rs
Normal file
193
rust/crates/rusty-claude-cli/tests/output_format_contract.rs
Normal 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()
|
||||
))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user