Compare commits

...

28 Commits

Author SHA1 Message Date
YeonGyu-Kim 861edfc1dc fix(runtime): document phantom completion root cause + add workspace_root to session (#41)
Global session store causes cross-worktree confusion in parallel lanes.
Added workspace_root field to session metadata and documented root cause
in ROADMAP.md.
2026-04-07 14:22:41 +09:00
YeonGyu-Kim f982f24926 fix(api): Windows env hint + .env file loading fallback
When API key missing on Windows, hint about setx. Load .env from CWD
as fallback with simple key=value parser.
2026-04-07 14:22:41 +09:00
YeonGyu-Kim 8d866073c5 feat(cli): show active model and provider in startup banner
Prints 'Connected: <model> via <provider>' before REPL prompt.
2026-04-07 14:22:26 +09:00
YeonGyu-Kim 4251c85855 fix(cli): add section headers to OMC output for agent type grouping
voloshko: flat wall of text. Now groups output with section separators
by agent type (Explore, Implementation, Verification).
2026-04-07 14:22:06 +09:00
YeonGyu-Kim 2a642871ad fix(api): enrich JSON parse errors with response body, provider, and model
Raw 'json_error: no field X' now includes truncated response body,
provider name, and model ID for debugging context.
2026-04-07 14:22:05 +09:00
YeonGyu-Kim cd83c0ff68 fix(cli): detect OPENAI_BASE_URL during claw login and emit clear error
OAuth 401 was confusing. Now detects custom base URL and suggests
ANTHROPIC_API_KEY instead of OAuth login.
2026-04-07 14:22:05 +09:00
YeonGyu-Kim ce360e0ff3 fix(api): strip anthropic beta fields from non-beta requests
mikejiang: 'betas: Extra inputs are not permitted' 400 error.
Only include beta headers when request targets beta endpoint.
2026-04-07 14:22:05 +09:00
YeonGyu-Kim c980c3c01e docs: add local model quickstart section to USAGE.md
- Anthropic-compat (ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN)
- OpenAI-compat (OPENAI_BASE_URL + OPENAI_API_KEY)
- ollama example with concrete curl
- OpenRouter example with model selection

Addresses community requests for local model setup guidance.
2026-04-07 13:44:22 +09:00
YeonGyu-Kim ce22d8fb4f fix(api): add serde(default) to all usage/token parse paths in SSE stream
Sterling reported 'json_error: no field input/input_tokens' still firing
despite existing serde(default) in types.rs. Root cause: SSE streaming
path had a separate deserialization site that didn't use the same defaults.

- Add serde(default) to sse.rs UsageEvent deserialization
- Add serde(default) to types.rs Usage struct fields (input_tokens, output_tokens)
- Add regression test with empty-usage JSON response in streaming context
2026-04-07 13:44:22 +09:00
Yeachan-Heo be561bfdeb Use Anthropic count tokens for preflight 2026-04-06 09:38:21 +00:00
Yeachan-Heo c1883d0f66 Clarify heuristic context window estimates 2026-04-06 09:26:08 +00:00
Yeachan-Heo 1fc5a1c457 Fix slash skill invoke normalization 2026-04-06 09:24:06 +00:00
Yeachan-Heo 549ad7c3af Restore compatibility skill lookup fallback 2026-04-06 09:11:27 +00:00
Yeachan-Heo ecadc5554a fix(auth): harden OAuth fallback and collapse thinking output 2026-04-06 09:02:21 +00:00
Yeachan-Heo 8ff9c1b15a Preserve recovery guidance for retried context-window failures
The CLI already reframes direct preflight and provider oversized-request
errors, but retry-wrapped provider failures still fell back to the generic
retry-exhausted surface because the user-visible formatter keyed off the
safe failure class. Route formatting through nested context-window
detection so wrapped provider failures keep the same compact/reduce-scope
guidance.

Constraint: Keep the fix UX-scoped without widening broader failure classification behavior
Rejected: Reorder safe_failure_class for all RetriesExhausted errors | broader semantic change than needed for this issue
Confidence: high
Scope-risk: narrow
Directive: Keep context-window rendering keyed to nested error inspection so provider wrappers do not lose recovery guidance
Tested: cargo fmt --check; cargo test -p rusty-claude-cli context_window; cargo test -p api oversized
Not-tested: Full workspace test suite
2026-04-06 09:02:21 +00:00
Yeachan-Heo 6bd464bbe7 Make repeated provider crashes self-identifying after retry exhaustion
Generic fatal wrapper handling already preserved safe classes and trace ids for single provider failures, but repeated retry exhaustion still surfaced as provider_internal. Classify generic wrapped RetriesExhausted failures as provider_retry_exhausted so Jobdori-style repeat failures stay distinguishable from one-off provider crashes, and keep the display logic clippy-clean.

Constraint: Keep the change minimal and preserve existing user-visible error wording outside retry-exhaustion classification
Rejected: Broadly rework all provider error taxonomy | unnecessary for the targeted opaque-wrapper regression
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep retry exhaustion distinct from single-shot provider_internal wrappers when the nested error is the same generic fatal wrapper
Tested: cargo test -p api detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal
Tested: cargo test -p api retries_exhausted_preserves_nested_request_id_and_failure_class
Tested: cargo test -p rusty-claude-cli opaque_provider_wrapper_surfaces_failure_class_session_and_trace
Tested: cargo test -p rusty-claude-cli retry_exhaustion_uses_retry_failure_class_for_generic_provider_wrapper
Tested: cargo test --workspace
Tested: cargo fmt --check
Tested: cargo clippy --workspace --all-targets -- -D warnings
Not-tested: Live OpenClaw/Anthropic service failure telemetry outside the local test harness
2026-04-06 09:01:38 +00:00
Yeachan-Heo 421ead7dba Remove orphaned skill lookup helpers 2026-04-06 07:56:50 +00:00
Yeachan-Heo f9cb42fb44 Resolve claw-code main merge conflicts 2026-04-06 07:16:57 +00:00
Yeachan-Heo 01b263c838 Let /skills invocations reach the prompt skill path
The CLI still treated every /skills payload other than list/install/help as local usage text, so skills that appeared in /skills could not actually be invoked. This restores prompt dispatch for /skills <skill> [args], keeps list/install on the local path, and shares skill resolution with the Skill tool so project-local and legacy /commands entries resolve consistently.

Constraint: --resume local slash execution still only supports local commands without provider turns
Rejected: Implement full resumed prompt-turn execution for /skills | larger behavior change outside this bugfix
Rejected: Keep separate skill lookups in tools and commands | drift already caused listing/invocation mismatches
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep /skills discovery, CLI prompt dispatch, and Tool Skill resolution on the same registry semantics
Tested: cargo fmt --all; cargo clippy -p commands -p tools -p rusty-claude-cli --all-targets -- -D warnings; cargo test --workspace -- --nocapture
Not-tested: Live provider-backed /skills invocation against external skill packs in an interactive REPL
2026-04-06 06:43:31 +00:00
Yeachan-Heo b930895736 Turn oversized-context failures into recovery guidance
Dogfood showed oversized requests still surfacing as raw hard errors, even when claw could tell the user exactly how to recover. This keeps context-window failures classified, recognizes the same failure when it comes back from a provider response, and renders recovery steps that point operators at the existing compaction and fresh-session paths instead of a provider-style dump.

Constraint: Keep the failure class explicit so automation and operators can still distinguish context-window exhaustion from generic provider failures
Constraint: Reuse existing /compact and session-reset UX instead of inventing a new recovery workflow
Rejected: Auto-run compaction on failure | mutates session state on an error path the user may want to inspect first
Rejected: Only prettify local preflight failures | provider-returned context-window errors would still leak raw failure text
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep provider-side context-window detection aligned with real oversized-request messages before broadening the marker list
Tested: cargo fmt --all --check
Tested: cargo test -p api
Tested: cargo test -p rusty-claude-cli
Tested: cargo clippy -p api -p rusty-claude-cli --all-targets -- -D warnings
Not-tested: cargo test --workspace
2026-04-06 06:43:31 +00:00
Yeachan-Heo 84a0973f6c Clarify the resumed JSON parity audit record
The audit fix already landed, but the roadmap entry was split across two separate done items for /sandbox and inventory even though the underlying defect was one resumed-local-command JSON parity surface. Consolidating the note makes the machine-readable gap precise and keeps the backlog trail aligned with the actual fix scope.

Constraint: Preserve the existing issue ordering and backlog context around issues 23-24
Rejected: Leave the split entries as-is | obscures that one parity bug covered the same resumed JSON dispatch path
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Record future parity audits as one backlog item per underlying contract gap, not per individual command symptom
Tested: Existing green verification from HEAD remains applicable; docs-only wording update
Not-tested: No additional code-path verification required for this wording-only change
2026-04-06 02:00:33 +00:00
Yeachan-Heo fe4da2aa65 Keep resumed JSON command surfaces machine-readable
Resumed slash dispatch was still dropping back to prose for several JSON-capable local commands, which forced automation to special-case direct CLI invocations versus --resume flows. This routes resumed local-command handlers through the same structured JSON payloads used by direct status, sandbox, inventory, version, and init commands, and records the inventory parity audit result in the roadmap.

Constraint: Text-mode resumed output must stay unchanged for existing shell users
Rejected: Teach callers to scrape resumed text output | brittle and defeats the JSON contract
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When a direct local command has a JSON renderer, keep resumed slash dispatch on the same serializer instead of adding one-off format branches
Tested: cargo fmt --check; cargo test --workspace; cargo clippy --workspace --all-targets -- -D warnings
Not-tested: Live provider-backed REPL resume flows outside the local test harness
2026-04-06 02:00:33 +00:00
Yeachan-Heo 53d6909b9b Emit structured doctor JSON diagnostics 2026-04-06 01:42:59 +00:00
Yeachan-Heo ceaf9cbc23 Preserve structured JSON parity for claw agents
`claw agents --output-format json` was still wrapping the text report,
which meant automation could not distinguish empty inventories from
populated agent definitions. Add a dedicated structured handler in the
commands crate, wire the CLI to it, and extend the contracts to cover
both empty and populated agent listings.

Constraint: Keep text-mode `claw agents` output unchanged while aligning JSON behavior with existing structured inventory handlers
Rejected: Parse the text report into JSON in the CLI layer | brittle duplication and no reusable structured handler
Confidence: high
Scope-risk: narrow
Directive: Keep inventory subcommands on dedicated structured handlers instead of serializing human-readable reports
Tested: cargo test -p commands renders_agents_reports_as_json; cargo test -p rusty-claude-cli --test output_format_contract; cargo test --workspace; cargo fmt --check; cargo clippy --workspace --all-targets -- -D warnings
Not-tested: Manual invocation of `claw agents --output-format json` outside automated tests
2026-04-06 01:42:59 +00:00
Yeachan-Heo ee92f131b0 Stabilize plugin lifecycle temp dirs across parallel tests 2026-04-06 01:18:56 +00:00
Yeachan-Heo df0908b10e docs: record plugin lifecycle test flake 2026-04-06 01:15:30 +00:00
Yeachan-Heo 22e3f8c5e3 Fix retry exhaustion failure classification 2026-04-06 01:10:36 +00:00
Yeachan-Heo d94d792a48 Expose actionable ids for opaque provider failures
Issue #22 was triggered by generic upstream fatal wrappers that only surfaced 'Something went wrong', which left repeated Jobdori-style failures opaque in the CLI. Capture provider request ids on error responses, classify the known generic wrapper as provider_internal, and prefix the user-visible runtime error with the failure class plus session/trace identifiers so operators can correlate the failure quickly.

Constraint: Keep the fix small and user-safe without redesigning the broader runtime error taxonomy
Constraint: Preserve existing non-generic error text unless the wrapper is the known opaque fatal surface
Rejected: Broadly rewriting every runtime error into classified envelopes | unnecessary scope expansion for issue #22
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If more opaque wrappers appear, extend the marker list and classification helper rather than reintroducing raw wrapper text alone
Tested: cargo test -p api detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal -- --nocapture; cargo test -p api retries_exhausted_preserves_nested_request_id_and_failure_class -- --nocapture; cargo test -p rusty-claude-cli opaque_provider_wrapper_surfaces_failure_class_session_and_trace -- --nocapture; cargo test -p rusty-claude-cli retry_exhaustion_preserves_internal_failure_class_for_generic_provider_wrapper -- --nocapture; cargo test --workspace
Not-tested: Live upstream reproduction of the Jobdori failure against a real provider session
2026-04-06 00:30:28 +00:00
19 changed files with 3558 additions and 312 deletions
+20 -1
View File
@@ -309,7 +309,26 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
20. **Session state classification gap (working vs blocked vs finished vs truly stale)****done**: agent manifests now derive machine states such as `working`, `blocked_background_job`, `blocked_merge_conflict`, `degraded_mcp`, `interrupted_transport`, `finished_pending_report`, and `finished_cleanable`, and terminal-state persistence records commit provenance plus derived state so downstream monitoring can distinguish quiet progress from truly idle sessions.
21. **Resumed `/status` JSON parity gap** — dogfooding shows fresh `claw status --output-format json` now emits structured JSON, but resumed slash-command status still leaks through a text-shaped path in at least one dispatch path. Local CI-equivalent repro fails `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs::resumed_status_command_emits_structured_json_when_requested` with `expected value at line 1 column 1`, so resumed automation can receive text where JSON was explicitly requested. **Action:** unify fresh vs resumed `/status` rendering through one output-format contract and add regression coverage so resumed JSON output is guaranteed valid.
22. **Opaque failure surface for session/runtime crashes** — repeated dogfood-facing failures can currently collapse to generic wrappers like `Something went wrong while processing your request. Please try again, or use /new to start a fresh session.` without exposing whether the fault was provider auth, session corruption, slash-command dispatch, render failure, or transport/runtime panic. This blocks fast self-recovery and turns actionable clawability bugs into blind retries. **Action:** preserve a short user-safe failure class (`provider_auth`, `session_load`, `command_dispatch`, `render`, `runtime_panic`, etc.), attach a local trace/session id, and ensure operators can jump from the chat-visible error to the exact failure log quickly.
23. **`doctor --output-format json` check-level structure gap** — direct dogfooding shows `claw doctor --output-format json` exposes `has_failures` at the top level, but individual check results (`auth`, `config`, `workspace`, `sandbox`, `system`) are buried inside flat prose fields like `message` / `report`. That forces claws to string-scrape human text instead of consuming stable machine-readable diagnostics. **Action:** emit structured per-check JSON (`name`, `status`, `summary`, `details`, and relevant typed fields such as sandbox fallback reason) while preserving the current human-readable report for text mode.
23. **`doctor --output-format json` check-level structure gap** — **done**: `claw doctor --output-format json` now keeps the human-readable `message`/`report` while also emitting structured per-check diagnostics (`name`, `status`, `summary`, `details`, plus typed fields like workspace paths and sandbox fallback data), with regression coverage in `output_format_contract.rs`.
24. **Plugin lifecycle init/shutdown test flakes under workspace-parallel execution** — dogfooding surfaced that `build_runtime_runs_plugin_lifecycle_init_and_shutdown` can fail under `cargo test --workspace` while passing in isolation because sibling tests race on tempdir-backed shell init script paths. This is test brittleness rather than a code-path regression, but it still destabilizes CI confidence and wastes diagnosis cycles. **Action:** isolate temp resources per test robustly (unique dirs + no shared cwd assumptions), audit cleanup timing, and add a regression guard so the plugin lifecycle test remains stable under parallel workspace execution.
26. **Resumed local-command JSON parity gap****done**: direct `claw --output-format json` already had structured renderers for `sandbox`, `mcp`, `skills`, `version`, and `init`, but resumed `claw --output-format json --resume <session> /…` paths still fell back to prose because resumed slash dispatch only emitted JSON for `/status`. Resumed `/sandbox`, `/mcp`, `/skills`, `/version`, and `/init` now reuse the same JSON envelopes as their direct CLI counterparts, with regression coverage in `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs` and `rust/crates/rusty-claude-cli/tests/output_format_contract.rs`.
41. **Phantom completions root cause: global session store has no per-worktree isolation**
**Root cause.** The session store under `~/.local/share/opencode` is global to the host. Every `opencode serve` instance — including the parallel lane workers spawned per worktree — reads and writes the same on-disk session directory. Sessions are keyed only by id and timestamp, not by the workspace they were created in, so there is no structural barrier between a session created in worktree `/tmp/b4-phantom-diag` and one created in `/tmp/b4-omc-flat`. Whichever serve instance picks up a given session id can drive it from whatever CWD that serve happens to be running in.
**Impact.** Parallel lanes silently cross wires. A lane reports a clean run — file edits, builds, tests — and the orchestrator marks the lane green, but the writes were applied against another worktree's CWD because a sibling `opencode serve` won the session race. The originating worktree shows no diff, the *other* worktree gains unexplained edits, and downstream consumers (clawhip lane events, PR pushes, merge gates) treat the empty originator as a successful no-op. These are the "phantom completions" we keep chasing: success messaging without any landed changes in the lane that claimed them, plus stray edits in unrelated lanes whose own runs never touched those files. Because the report path is happy, retries and recovery recipes never fire, so the lane silently wedges until a human notices the diff is empty.
**Proposed fix.** Bind every session to its workspace root + branch at creation time and refuse to drive it from any other CWD.
- At session creation, capture the canonical workspace root (resolved git worktree path) and the active branch and persist them on the session record.
- On every load (`opencode serve`, slash-command resume, lane recovery), validate that the current process CWD matches the persisted workspace root before any tool with side effects (file_ops, bash, git) is allowed to run. Mismatches surface as a typed `WorkspaceMismatch` failure class instead of silently writing to the wrong tree.
- Namespace the on-disk session path under the workspace fingerprint (e.g. `<session_store>/<workspace_hash>/<session_id>`) so two parallel `opencode serve` instances physically cannot collide on the same session id.
- Forks inherit the parent's workspace root by default; an explicit re-bind is required to move a session to a new worktree, and that re-bind is itself recorded as a structured event so the orchestrator can audit cross-worktree handoffs.
- Surface a `branch.workspace_mismatch` lane event so clawhip stops counting wrong-CWD writes as lane completions.
**Status.** A `workspace_root` field has been added to `Session` in `rust/crates/runtime/src/session.rs` (with builder, accessor, JSON + JSONL round-trip, fork inheritance, and given/when/then test coverage in `persists_workspace_root_round_trip_and_forks_inherit_it`). The CWD validation, the namespaced on-disk path, and the `branch.workspace_mismatch` lane event are still outstanding and tracked under this item.
**P3 — Swarm efficiency**
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
+44
View File
@@ -109,6 +109,50 @@ cd rust
./target/debug/claw logout
```
## Local Models
`claw` can talk to local servers and provider gateways through either Anthropic-compatible or OpenAI-compatible endpoints. Use `ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` for Anthropic-compatible services, or `OPENAI_BASE_URL` with `OPENAI_API_KEY` for OpenAI-compatible services. OAuth is Anthropic-only, so when `OPENAI_BASE_URL` is set you should use API-key style auth instead of `claw login`.
### Anthropic-compatible endpoint
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
export ANTHROPIC_AUTH_TOKEN="local-dev-token"
cd rust
./target/debug/claw --model "claude-sonnet-4-6" prompt "reply with the word ready"
```
### OpenAI-compatible endpoint
```bash
export OPENAI_BASE_URL="http://127.0.0.1:8000/v1"
export OPENAI_API_KEY="local-dev-token"
cd rust
./target/debug/claw --model "qwen2.5-coder" prompt "reply with the word ready"
```
### Ollama
```bash
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
unset OPENAI_API_KEY
cd rust
./target/debug/claw --model "llama3.2" prompt "summarize this repository in one sentence"
```
### OpenRouter
```bash
export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
export OPENAI_API_KEY="sk-or-v1-..."
cd rust
./target/debug/claw --model "openai/gpt-4.1-mini" prompt "summarize this repository in one sentence"
```
## Common operational commands
```bash
+1
View File
@@ -1579,6 +1579,7 @@ name = "tools"
version = "0.1.0"
dependencies = [
"api",
"commands",
"plugins",
"reqwest",
"runtime",
+347 -14
View File
@@ -2,6 +2,21 @@ use std::env::VarError;
use std::fmt::{Display, Formatter};
use std::time::Duration;
const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
"something went wrong while processing your request",
"please try again, or use /new to start a fresh session",
];
const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
"maximum context length",
"context window",
"context length",
"too many tokens",
"prompt is too long",
"input is too long",
"request is too large",
];
#[derive(Debug)]
pub enum ApiError {
MissingCredentials {
@@ -20,11 +35,17 @@ pub enum ApiError {
InvalidApiKeyEnv(VarError),
Http(reqwest::Error),
Io(std::io::Error),
Json(serde_json::Error),
Json {
provider: String,
model: String,
body_snippet: String,
source: serde_json::Error,
},
Api {
status: reqwest::StatusCode,
error_type: Option<String>,
message: Option<String>,
request_id: Option<String>,
body: String,
retryable: bool,
},
@@ -48,6 +69,25 @@ impl ApiError {
Self::MissingCredentials { provider, env_vars }
}
/// Build a `Self::Json` enriched with the provider name, the model that
/// was requested, and the first 200 characters of the raw response body so
/// that callers can diagnose deserialization failures without re-running
/// the request.
#[must_use]
pub fn json_deserialize(
provider: impl Into<String>,
model: impl Into<String>,
body: &str,
source: serde_json::Error,
) -> Self {
Self::Json {
provider: provider.into(),
model: model.into(),
body_snippet: truncate_body_snippet(body, 200),
source,
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
@@ -60,7 +100,101 @@ impl ApiError {
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Io(_)
| Self::Json(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
}
}
#[must_use]
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Api { request_id, .. } => request_id.as_deref(),
Self::RetriesExhausted { last_error, .. } => last_error.request_id(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => None,
}
}
#[must_use]
pub fn safe_failure_class(&self) -> &'static str {
match self {
Self::RetriesExhausted { .. } if self.is_context_window_failure() => "context_window",
Self::RetriesExhausted { .. } if self.is_generic_fatal_wrapper() => {
"provider_retry_exhausted"
}
Self::RetriesExhausted { last_error, .. } => last_error.safe_failure_class(),
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
"provider_auth"
}
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
Self::ContextWindowExceeded { .. } => "context_window",
Self::Api { .. } if self.is_context_window_failure() => "context_window",
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
Self::Api { .. } => "provider_error",
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
"provider_transport"
}
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json { .. } => "runtime_io",
}
}
#[must_use]
pub fn is_generic_fatal_wrapper(&self) -> bool {
match self {
Self::Api { message, body, .. } => {
message
.as_deref()
.is_some_and(looks_like_generic_fatal_wrapper)
|| looks_like_generic_fatal_wrapper(body)
}
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
}
}
#[must_use]
pub fn is_context_window_failure(&self) -> bool {
match self {
Self::ContextWindowExceeded { .. } => true,
Self::Api {
status,
message,
body,
..
} => {
matches!(status.as_u16(), 400 | 413 | 422)
&& (message
.as_deref()
.is_some_and(looks_like_context_window_error)
|| looks_like_context_window_error(body))
}
Self::RetriesExhausted { last_error, .. } => last_error.is_context_window_failure(),
Self::MissingCredentials { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
}
@@ -70,11 +204,27 @@ impl ApiError {
impl Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingCredentials { provider, env_vars } => write!(
f,
"missing {provider} credentials; export {} before calling the {provider} API",
env_vars.join(" or ")
),
Self::MissingCredentials { provider, env_vars } => {
write!(
f,
"missing {provider} credentials; export {} before calling the {provider} API",
env_vars.join(" or ")
)?;
if cfg!(target_os = "windows") {
if let Some(primary) = env_vars.first() {
write!(
f,
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx {primary} <value>` to make it permanent, then open a new terminal, or place a `.env` file containing `{primary}=<value>` in the current working directory)"
)?;
} else {
write!(
f,
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx` to make them permanent, then open a new terminal, or place a `.env` file in the current working directory)"
)?;
}
}
Ok(())
}
Self::ContextWindowExceeded {
model,
estimated_input_tokens,
@@ -97,19 +247,37 @@ impl Display for ApiError {
}
Self::Http(error) => write!(f, "http error: {error}"),
Self::Io(error) => write!(f, "io error: {error}"),
Self::Json(error) => write!(f, "json error: {error}"),
Self::Json {
provider,
model,
body_snippet,
source,
} => write!(
f,
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
),
Self::Api {
status,
error_type,
message,
request_id,
body,
..
} => match (error_type, message) {
(Some(error_type), Some(message)) => {
write!(f, "api returned {status} ({error_type}): {message}")
} => {
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {message}")
} else {
write!(f, "api returned {status}")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {body}")
}
_ => write!(f, "api returned {status}: {body}"),
},
}
Self::RetriesExhausted {
attempts,
last_error,
@@ -142,7 +310,12 @@ impl From<std::io::Error> for ApiError {
impl From<serde_json::Error> for ApiError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
Self::Json {
provider: "unknown".to_string(),
model: "unknown".to_string(),
body_snippet: String::new(),
source: value,
}
}
}
@@ -151,3 +324,163 @@ impl From<VarError> for ApiError {
Self::InvalidApiKeyEnv(value)
}
}
fn looks_like_generic_fatal_wrapper(text: &str) -> bool {
let lowered = text.to_ascii_lowercase();
GENERIC_FATAL_WRAPPER_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
}
fn looks_like_context_window_error(text: &str) -> bool {
let lowered = text.to_ascii_lowercase();
CONTEXT_WINDOW_ERROR_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
}
/// Truncate `body` so the resulting snippet contains at most `max_chars`
/// characters (counted by Unicode scalar values, not bytes), preserving the
/// leading slice of the body that the caller most often needs to inspect.
fn truncate_body_snippet(body: &str, max_chars: usize) -> String {
let mut taken_chars = 0;
let mut byte_end = 0;
for (offset, character) in body.char_indices() {
if taken_chars >= max_chars {
break;
}
taken_chars += 1;
byte_end = offset + character.len_utf8();
}
if taken_chars >= max_chars && byte_end < body.len() {
format!("{}", &body[..byte_end])
} else {
body[..byte_end].to_string()
}
}
#[cfg(test)]
mod tests {
use super::{truncate_body_snippet, ApiError};
#[test]
fn json_deserialize_error_includes_provider_model_and_truncated_body_snippet() {
let raw_body = format!("{}{}", "x".repeat(190), "_TAIL_PAST_200_CHARS_MARKER_");
let source = serde_json::from_str::<serde_json::Value>("{not json")
.expect_err("invalid json should fail to parse");
let error = ApiError::json_deserialize("Anthropic", "claude-opus-4-6", &raw_body, source);
let rendered = error.to_string();
assert!(
rendered.starts_with("failed to parse Anthropic response for model claude-opus-4-6: "),
"rendered error should lead with provider and model: {rendered}"
);
assert!(
rendered.contains("first 200 chars of body: "),
"rendered error should label the body snippet: {rendered}"
);
let snippet = rendered
.split("first 200 chars of body: ")
.nth(1)
.expect("snippet section should be present");
assert!(
snippet.starts_with(&"x".repeat(190)),
"snippet should preserve the leading characters of the body: {snippet}"
);
assert!(
snippet.ends_with('…'),
"snippet should signal truncation with an ellipsis: {snippet}"
);
assert!(
!snippet.contains("_TAIL_PAST_200_CHARS_MARKER_"),
"snippet should drop characters past the 200-char cap: {snippet}"
);
assert_eq!(error.safe_failure_class(), "runtime_io");
assert_eq!(error.request_id(), None);
assert!(!error.is_retryable());
}
#[test]
fn truncate_body_snippet_keeps_short_bodies_intact() {
assert_eq!(truncate_body_snippet("hello", 200), "hello");
assert_eq!(truncate_body_snippet("", 200), "");
}
#[test]
fn truncate_body_snippet_caps_long_bodies_at_max_chars() {
let body = "a".repeat(250);
let snippet = truncate_body_snippet(&body, 200);
assert_eq!(snippet.chars().count(), 201, "200 chars + ellipsis");
assert!(snippet.ends_with('…'));
assert!(snippet.starts_with(&"a".repeat(200)));
}
#[test]
fn truncate_body_snippet_does_not_split_multibyte_characters() {
let body = "한글한글한글한글한글한글";
let snippet = truncate_body_snippet(body, 4);
assert_eq!(snippet, "한글한글…");
}
#[test]
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
let error = ApiError::Api {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error_type: Some("api_error".to_string()),
message: Some(
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_jobdori_123".to_string()),
body: String::new(),
retryable: true,
};
assert!(error.is_generic_fatal_wrapper());
assert_eq!(error.safe_failure_class(), "provider_internal");
assert_eq!(error.request_id(), Some("req_jobdori_123"));
assert!(error.to_string().contains("[trace req_jobdori_123]"));
}
#[test]
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
let error = ApiError::RetriesExhausted {
attempts: 3,
last_error: Box::new(ApiError::Api {
status: reqwest::StatusCode::BAD_GATEWAY,
error_type: Some("api_error".to_string()),
message: Some(
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_nested_456".to_string()),
body: String::new(),
retryable: true,
}),
};
assert!(error.is_generic_fatal_wrapper());
assert_eq!(error.safe_failure_class(), "provider_retry_exhausted");
assert_eq!(error.request_id(), Some("req_nested_456"));
}
#[test]
fn classifies_provider_context_window_errors() {
let error = ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
"This model's maximum context length is 200000 tokens, but your request used 230000 tokens."
.to_string(),
),
request_id: Some("req_ctx_123".to_string()),
body: String::new(),
retryable: false,
};
assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
assert_eq!(error.request_id(), Some("req_ctx_123"));
}
}
+160 -23
View File
@@ -14,7 +14,7 @@ use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, Session
use crate::error::ApiError;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use super::{preflight_message_request, Provider, ProviderFuture};
use super::{model_token_limit, resolve_model_alias, Provider, ProviderFuture};
use crate::sse::SseParser;
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
@@ -294,14 +294,14 @@ impl AnthropicClient {
}
}
preflight_message_request(&request)?;
self.preflight_message_request(&request).await?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let mut response = response
.json::<MessageResponse>()
.await
.map_err(ApiError::from)?;
let http_response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(http_response.headers());
let body = http_response.text().await.map_err(ApiError::from)?;
let mut response = serde_json::from_str::<MessageResponse>(&body).map_err(|error| {
ApiError::json_deserialize("Anthropic", &request.model, &body, error)
})?;
if response.request_id.is_none() {
response.request_id = request_id;
}
@@ -339,14 +339,14 @@ impl AnthropicClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
self.preflight_message_request(request).await?;
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;
Ok(MessageStream {
request_id: request_id_from_headers(response.headers()),
response,
parser: SseParser::new(),
parser: SseParser::new().with_context("Anthropic", request.model.clone()),
pending: VecDeque::new(),
done: false,
request: request.clone(),
@@ -371,10 +371,10 @@ impl AnthropicClient {
.await
.map_err(ApiError::from)?;
let response = expect_success(response).await?;
response
.json::<OAuthTokenSet>()
.await
.map_err(ApiError::from)
let body = response.text().await.map_err(ApiError::from)?;
serde_json::from_str::<OAuthTokenSet>(&body).map_err(|error| {
ApiError::json_deserialize("Anthropic OAuth (exchange)", "n/a", &body, error)
})
}
pub async fn refresh_oauth_token(
@@ -391,10 +391,10 @@ impl AnthropicClient {
.await
.map_err(ApiError::from)?;
let response = expect_success(response).await?;
response
.json::<OAuthTokenSet>()
.await
.map_err(ApiError::from)
let body = response.text().await.map_err(ApiError::from)?;
serde_json::from_str::<OAuthTokenSet>(&body).map_err(|error| {
ApiError::json_deserialize("Anthropic OAuth (refresh)", "n/a", &body, error)
})
}
async fn send_with_retry(
@@ -466,18 +466,74 @@ impl AnthropicClient {
request: &MessageRequest,
) -> Result<reqwest::Response, ApiError> {
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let mut request_body = self.request_profile.render_json_body(request)?;
strip_unsupported_beta_body_fields(&mut request_body);
let request_builder = self.build_request(&request_url).json(&request_body);
request_builder.send().await.map_err(ApiError::from)
}
fn build_request(&self, request_url: &str) -> reqwest::RequestBuilder {
let request_builder = self
.http
.post(&request_url)
.post(request_url)
.header("content-type", "application/json");
let mut request_builder = self.auth.apply(request_builder);
for (header_name, header_value) in self.request_profile.header_pairs() {
request_builder = request_builder.header(header_name, header_value);
}
request_builder
}
let request_body = self.request_profile.render_json_body(request)?;
request_builder = request_builder.json(&request_body);
request_builder.send().await.map_err(ApiError::from)
async fn preflight_message_request(&self, request: &MessageRequest) -> Result<(), ApiError> {
let Some(limit) = model_token_limit(&request.model) else {
return Ok(());
};
let counted_input_tokens = match self.count_tokens(request).await {
Ok(count) => count,
Err(_) => return Ok(()),
};
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
if estimated_total_tokens > limit.context_window_tokens {
return Err(ApiError::ContextWindowExceeded {
model: resolve_model_alias(&request.model),
estimated_input_tokens: counted_input_tokens,
requested_output_tokens: request.max_tokens,
estimated_total_tokens,
context_window_tokens: limit.context_window_tokens,
});
}
Ok(())
}
async fn count_tokens(&self, request: &MessageRequest) -> Result<u32, ApiError> {
#[derive(serde::Deserialize)]
struct CountTokensResponse {
input_tokens: u32,
}
let request_url = format!("{}/v1/messages/count_tokens", self.base_url.trim_end_matches('/'));
let mut request_body = self.request_profile.render_json_body(request)?;
strip_unsupported_beta_body_fields(&mut request_body);
let response = self
.build_request(&request_url)
.json(&request_body)
.send()
.await
.map_err(ApiError::from)?;
let response = expect_success(response).await?;
let body = response.text().await.map_err(ApiError::from)?;
let parsed = serde_json::from_str::<CountTokensResponse>(&body).map_err(|error| {
ApiError::json_deserialize(
"Anthropic count_tokens",
&request.model,
&body,
error,
)
})?;
Ok(parsed.input_tokens)
}
fn record_request_failure(&self, attempt: u32, error: &ApiError) {
@@ -676,7 +732,7 @@ fn now_unix_timestamp() -> u64 {
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
match std::env::var(key) {
Ok(value) if !value.is_empty() => Ok(Some(value)),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)),
Err(error) => Err(ApiError::from(error)),
}
}
@@ -808,6 +864,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response);
}
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.unwrap_or_else(|_| String::new());
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status);
@@ -820,6 +877,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
message: parsed_error
.as_ref()
.map(|error| error.error.message.clone()),
request_id,
body,
retryable,
})
@@ -829,6 +887,16 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
/// Remove beta-only body fields that the standard `/v1/messages` and
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
/// HTTP header on these endpoints, never as a JSON body field.
fn strip_unsupported_beta_body_fields(body: &mut Value) {
if let Some(object) = body.as_object_mut() {
object.remove("betas");
}
}
#[derive(Debug, Deserialize)]
struct AnthropicErrorEnvelope {
error: AnthropicErrorBody,
@@ -1245,4 +1313,73 @@ mod tests {
Some("Bearer proxy-token")
);
}
#[test]
fn strip_unsupported_beta_body_fields_removes_betas_array() {
let mut body = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"betas": ["claude-code-20250219", "prompt-caching-scope-2026-01-05"],
"metadata": {"source": "test"},
});
super::strip_unsupported_beta_body_fields(&mut body);
assert!(
body.get("betas").is_none(),
"betas body field must be stripped before sending to /v1/messages"
);
assert_eq!(
body.get("model").and_then(serde_json::Value::as_str),
Some("claude-sonnet-4-6")
);
assert_eq!(body["max_tokens"], serde_json::json!(1024));
assert_eq!(body["metadata"]["source"], serde_json::json!("test"));
}
#[test]
fn strip_unsupported_beta_body_fields_is_a_noop_when_betas_absent() {
let mut body = serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
});
let original = body.clone();
super::strip_unsupported_beta_body_fields(&mut body);
assert_eq!(body, original);
}
#[test]
fn rendered_request_body_strips_betas_for_standard_messages_endpoint() {
let client = AnthropicClient::new("test-key").with_beta("tools-2026-04-01");
let request = MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64,
messages: vec![],
system: None,
tools: None,
tool_choice: None,
stream: false,
};
let mut rendered = client
.request_profile()
.render_json_body(&request)
.expect("body should render");
assert!(
rendered.get("betas").is_some(),
"render_json_body still emits betas; the strip helper guards the wire format",
);
super::strip_unsupported_beta_body_fields(&mut rendered);
assert!(
rendered.get("betas").is_none(),
"betas must not appear in /v1/messages request bodies"
);
assert_eq!(
rendered.get("model").and_then(serde_json::Value::as_str),
Some("claude-sonnet-4-6")
);
}
}
+138 -2
View File
@@ -258,6 +258,61 @@ fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
.map_or(0, |bytes| (bytes.len() / 4 + 1) as u32)
}
/// Parse a `.env` file body into key/value pairs using a minimal `KEY=VALUE`
/// grammar. Lines that are blank, start with `#`, or do not contain `=` are
/// ignored. Surrounding double or single quotes are stripped from the value.
/// An optional leading `export ` prefix on the key is also stripped so files
/// shared with shell `source` workflows still parse cleanly.
pub(crate) fn parse_dotenv(content: &str) -> std::collections::HashMap<String, String> {
let mut values = std::collections::HashMap::new();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
let trimmed_key = raw_key.trim();
let key = trimmed_key
.strip_prefix("export ")
.map_or(trimmed_key, str::trim)
.to_string();
if key.is_empty() {
continue;
}
let trimmed_value = raw_value.trim();
let unquoted = if (trimmed_value.starts_with('"') && trimmed_value.ends_with('"')
|| trimmed_value.starts_with('\'') && trimmed_value.ends_with('\''))
&& trimmed_value.len() >= 2
{
&trimmed_value[1..trimmed_value.len() - 1]
} else {
trimmed_value
};
values.insert(key, unquoted.to_string());
}
values
}
/// Load and parse a `.env` file from the given path. Missing files yield
/// `None` instead of an error so callers can use this as a soft fallback.
pub(crate) fn load_dotenv_file(
path: &std::path::Path,
) -> Option<std::collections::HashMap<String, String>> {
let content = std::fs::read_to_string(path).ok()?;
Some(parse_dotenv(&content))
}
/// Look up `key` in a `.env` file located in the current working directory.
/// Returns `None` when the file is missing, the key is absent, or the value
/// is empty.
pub(crate) fn dotenv_value(key: &str) -> Option<String> {
let cwd = std::env::current_dir().ok()?;
let values = load_dotenv_file(&cwd.join(".env"))?;
values.get(key).filter(|value| !value.is_empty()).cloned()
}
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -268,8 +323,8 @@ mod tests {
};
use super::{
detect_provider_kind, max_tokens_for_model, model_token_limit, preflight_message_request,
resolve_model_alias, ProviderKind,
detect_provider_kind, load_dotenv_file, max_tokens_for_model, model_token_limit,
parse_dotenv, preflight_message_request, resolve_model_alias, ProviderKind,
};
#[test]
@@ -375,4 +430,85 @@ mod tests {
preflight_message_request(&request)
.expect("models without context metadata should skip the guarded preflight");
}
#[test]
fn parse_dotenv_extracts_keys_handles_comments_quotes_and_export_prefix() {
// given
let body = "\
# this is a comment
ANTHROPIC_API_KEY=plain-value
XAI_API_KEY=\"quoted-value\"
OPENAI_API_KEY='single-quoted'
export GROK_API_KEY=exported-value
PADDED_KEY = padded-value
EMPTY_VALUE=
NO_EQUALS_LINE
";
// when
let values = parse_dotenv(body);
// then
assert_eq!(
values.get("ANTHROPIC_API_KEY").map(String::as_str),
Some("plain-value")
);
assert_eq!(
values.get("XAI_API_KEY").map(String::as_str),
Some("quoted-value")
);
assert_eq!(
values.get("OPENAI_API_KEY").map(String::as_str),
Some("single-quoted")
);
assert_eq!(
values.get("GROK_API_KEY").map(String::as_str),
Some("exported-value")
);
assert_eq!(
values.get("PADDED_KEY").map(String::as_str),
Some("padded-value")
);
assert_eq!(values.get("EMPTY_VALUE").map(String::as_str), Some(""));
assert!(!values.contains_key("NO_EQUALS_LINE"));
assert!(!values.contains_key("# this is a comment"));
}
#[test]
fn load_dotenv_file_reads_keys_from_disk_and_returns_none_when_missing() {
// given
let temp_root = std::env::temp_dir().join(format!(
"api-dotenv-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos())
));
std::fs::create_dir_all(&temp_root).expect("create temp dir");
let env_path = temp_root.join(".env");
std::fs::write(
&env_path,
"ANTHROPIC_API_KEY=secret-from-file\n# comment\nXAI_API_KEY=\"xai-secret\"\n",
)
.expect("write .env");
let missing_path = temp_root.join("does-not-exist.env");
// when
let loaded = load_dotenv_file(&env_path).expect("file should load");
let missing = load_dotenv_file(&missing_path);
// then
assert_eq!(
loaded.get("ANTHROPIC_API_KEY").map(String::as_str),
Some("secret-from-file")
);
assert_eq!(
loaded.get("XAI_API_KEY").map(String::as_str),
Some("xai-secret")
);
assert!(missing.is_none());
let _ = std::fs::remove_dir_all(&temp_root);
}
}
+32 -9
View File
@@ -131,7 +131,15 @@ impl OpenAiCompatClient {
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let payload = response.json::<ChatCompletionResponse>().await?;
let body = response.text().await.map_err(ApiError::from)?;
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
ApiError::json_deserialize(
self.config.provider_name,
&request.model,
&body,
error,
)
})?;
let mut normalized = normalize_response(&request.model, payload)?;
if normalized.request_id.is_none() {
normalized.request_id = request_id;
@@ -150,7 +158,10 @@ impl OpenAiCompatClient {
Ok(MessageStream {
request_id: request_id_from_headers(response.headers()),
response,
parser: OpenAiSseParser::new(),
parser: OpenAiSseParser::with_context(
self.config.provider_name,
request.model.clone(),
),
pending: VecDeque::new(),
done: false,
state: StreamState::new(request.model.clone()),
@@ -282,11 +293,17 @@ impl MessageStream {
#[derive(Debug, Default)]
struct OpenAiSseParser {
buffer: Vec<u8>,
provider: String,
model: String,
}
impl OpenAiSseParser {
fn new() -> Self {
Self::default()
fn with_context(provider: impl Into<String>, model: impl Into<String>) -> Self {
Self {
buffer: Vec::new(),
provider: provider.into(),
model: model.into(),
}
}
fn push(&mut self, chunk: &[u8]) -> Result<Vec<ChatCompletionChunk>, ApiError> {
@@ -294,7 +311,7 @@ impl OpenAiSseParser {
let mut events = Vec::new();
while let Some(frame) = next_sse_frame(&mut self.buffer) {
if let Some(event) = parse_sse_frame(&frame)? {
if let Some(event) = parse_sse_frame(&frame, &self.provider, &self.model)? {
events.push(event);
}
}
@@ -835,7 +852,11 @@ fn next_sse_frame(buffer: &mut Vec<u8>) -> Option<String> {
Some(String::from_utf8_lossy(&frame[..frame_len]).into_owned())
}
fn parse_sse_frame(frame: &str) -> Result<Option<ChatCompletionChunk>, ApiError> {
fn parse_sse_frame(
frame: &str,
provider: &str,
model: &str,
) -> Result<Option<ChatCompletionChunk>, ApiError> {
let trimmed = frame.trim();
if trimmed.is_empty() {
return Ok(None);
@@ -857,15 +878,15 @@ fn parse_sse_frame(frame: &str) -> Result<Option<ChatCompletionChunk>, ApiError>
if payload == "[DONE]" {
return Ok(None);
}
serde_json::from_str(&payload)
serde_json::from_str::<ChatCompletionChunk>(&payload)
.map(Some)
.map_err(ApiError::from)
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
}
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
match std::env::var(key) {
Ok(value) if !value.is_empty() => Ok(Some(value)),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)),
Err(error) => Err(ApiError::from(error)),
}
}
@@ -906,6 +927,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
return Ok(response);
}
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.unwrap_or_default();
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
let retryable = is_retryable_status(status);
@@ -918,6 +940,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
message: parsed_error
.as_ref()
.and_then(|error| error.error.message.clone()),
request_id,
body,
retryable,
})
+54 -3
View File
@@ -4,6 +4,8 @@ use crate::types::StreamEvent;
#[derive(Debug, Default)]
pub struct SseParser {
buffer: Vec<u8>,
provider: Option<String>,
model: Option<String>,
}
impl SseParser {
@@ -12,12 +14,23 @@ impl SseParser {
Self::default()
}
/// Attach the provider name and model to this parser so that JSON
/// deserialization failures within streamed frames carry enough context
/// for callers to understand which upstream produced the unparseable
/// payload.
#[must_use]
pub fn with_context(mut self, provider: impl Into<String>, model: impl Into<String>) -> Self {
self.provider = Some(provider.into());
self.model = Some(model.into());
self
}
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<StreamEvent>, ApiError> {
self.buffer.extend_from_slice(chunk);
let mut events = Vec::new();
while let Some(frame) = self.next_frame() {
if let Some(event) = parse_frame(&frame)? {
if let Some(event) = self.parse_frame_with_context(&frame)? {
events.push(event);
}
}
@@ -31,12 +44,18 @@ impl SseParser {
}
let trailing = std::mem::take(&mut self.buffer);
match parse_frame(&String::from_utf8_lossy(&trailing))? {
match self.parse_frame_with_context(&String::from_utf8_lossy(&trailing))? {
Some(event) => Ok(vec![event]),
None => Ok(Vec::new()),
}
}
fn parse_frame_with_context(&self, frame: &str) -> Result<Option<StreamEvent>, ApiError> {
let provider = self.provider.as_deref().unwrap_or("unknown");
let model = self.model.as_deref().unwrap_or("unknown");
parse_frame_with_provider(frame, provider, model)
}
fn next_frame(&mut self) -> Option<String> {
let separator = self
.buffer
@@ -61,6 +80,14 @@ impl SseParser {
}
pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
parse_frame_with_provider(frame, "unknown", "unknown")
}
pub(crate) fn parse_frame_with_provider(
frame: &str,
provider: &str,
model: &str,
) -> Result<Option<StreamEvent>, ApiError> {
let trimmed = frame.trim();
if trimmed.is_empty() {
return Ok(None);
@@ -97,7 +124,7 @@ pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
serde_json::from_str::<StreamEvent>(&payload)
.map(Some)
.map_err(ApiError::from)
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
}
#[cfg(test)]
@@ -276,4 +303,28 @@ mod tests {
))
);
}
#[test]
fn given_message_delta_frame_with_empty_usage_when_parsed_then_usage_defaults_to_zero() {
// given
let frame = concat!(
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{}}\n\n"
);
// when
let event = parse_frame(frame).expect("frame should parse");
// then
assert_eq!(
event,
Some(StreamEvent::MessageDelta(crate::types::MessageDeltaEvent {
delta: MessageDelta {
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
},
usage: Usage::default(),
}))
);
}
}
+5 -1
View File
@@ -113,6 +113,7 @@ pub struct MessageResponse {
pub stop_reason: Option<String>,
#[serde(default)]
pub stop_sequence: Option<String>,
#[serde(default)]
pub usage: Usage,
#[serde(default)]
pub request_id: Option<String>,
@@ -147,13 +148,15 @@ pub enum OutputContentBlock {
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
#[serde(default)]
pub input_tokens: u32,
#[serde(default)]
pub cache_creation_input_tokens: u32,
#[serde(default)]
pub cache_read_input_tokens: u32,
#[serde(default)]
pub output_tokens: u32,
}
@@ -194,6 +197,7 @@ pub struct MessageStartEvent {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MessageDeltaEvent {
pub delta: MessageDelta,
#[serde(default)]
pub usage: Usage,
}
+44 -10
View File
@@ -97,9 +97,9 @@ async fn send_message_posts_json_and_parses_response() {
assert!(body.get("stream").is_none());
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
assert_eq!(body["tool_choice"]["type"], json!("auto"));
assert_eq!(
body["betas"],
json!(["claude-code-20250219", "prompt-caching-scope-2026-01-05"])
assert!(
body.get("betas").is_none(),
"betas must travel via the anthropic-beta header, not the request body"
);
}
@@ -191,13 +191,9 @@ async fn send_message_applies_request_profile_and_records_telemetry() {
let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
assert_eq!(
body["betas"],
json!([
"claude-code-20250219",
"prompt-caching-scope-2026-01-05",
"tools-2026-04-01"
])
assert!(
body.get("betas").is_none(),
"betas must travel via the anthropic-beta header, not the request body"
);
let events = sink.events();
@@ -276,6 +272,44 @@ async fn send_message_parses_prompt_cache_token_usage_from_response() {
assert_eq!(response.usage.output_tokens, 4);
}
#[tokio::test]
async fn given_empty_usage_object_when_send_message_parses_response_then_usage_defaults_to_zero() {
// given
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"msg_empty_usage\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"Hello from Claude\"}],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{}",
"}"
);
let server = spawn_server(
state,
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
// when
let response = client
.send_message(&sample_request(false))
.await
.expect("response with empty usage object should still parse");
// then
assert_eq!(response.id, "msg_empty_usage");
assert_eq!(response.total_tokens(), 0);
assert_eq!(response.usage.input_tokens, 0);
assert_eq!(response.usage.cache_creation_input_tokens, 0);
assert_eq!(response.usage.cache_read_input_tokens, 0);
assert_eq!(response.usage.output_tokens, 0);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn stream_message_parses_sse_events_with_tool_use() {
+512 -43
View File
@@ -50,6 +50,12 @@ pub struct SlashCommandSpec {
pub resume_supported: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSlashDispatch {
Local,
Invoke(String),
}
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec {
name: "help",
@@ -237,9 +243,9 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
},
SlashCommandSpec {
name: "skills",
aliases: &[],
summary: "List or install available skills",
argument_hint: Some("[list|install <path>|help]"),
aliases: &["skill"],
summary: "List, install, or invoke available skills",
argument_hint: Some("[list|install <path>|help|<skill> [args]]"),
resume_supported: true,
},
SlashCommandSpec {
@@ -1306,7 +1312,7 @@ pub fn validate_slash_command_input(
"agents" => SlashCommand::Agents {
args: parse_list_or_help_args(command, remainder)?,
},
"skills" => SlashCommand::Skills {
"skills" | "skill" => SlashCommand::Skills {
args: parse_skills_args(remainder.as_deref())?,
},
"doctor" => {
@@ -1686,13 +1692,7 @@ fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandP
}
}
Err(command_error(
&format!(
"Unexpected arguments for /skills: {args}. Use /skills, /skills list, /skills install <path>, or /skills help."
),
"skills",
"/skills [list|install <path>|help]",
))
Ok(Some(args.to_string()))
}
fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
@@ -1975,9 +1975,9 @@ enum DefinitionScope {
impl DefinitionScope {
fn label(self) -> &'static str {
match self {
Self::Project => "Project (.claw)",
Self::UserConfigHome => "User ($CLAW_CONFIG_HOME)",
Self::UserHome => "User (~/.claw)",
Self::Project => "Project roots",
Self::UserConfigHome => "User config roots",
Self::UserHome => "User home roots",
}
}
}
@@ -2187,6 +2187,27 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
}
}
pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::io::Result<Value> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_agents_usage_json(None),
_ => render_agents_usage_json(Some(&help_path.join(" "))),
});
}
}
match normalize_optional_args(args) {
None | Some("list") => {
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report_json(cwd, &agents))
}
Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)),
Some(args) => Ok(render_agents_usage_json(Some(args))),
}
}
pub fn handle_mcp_slash_command(
args: Option<&str>,
cwd: &Path,
@@ -2265,6 +2286,132 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
}
}
#[must_use]
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
match normalize_optional_args(args) {
None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local,
Some(args) if args == "install" || args.starts_with("install ") => {
SkillSlashDispatch::Local
}
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
}
}
/// Resolve a skill invocation by validating the skill exists on disk before
/// returning the dispatch. When the skill is not found, returns `Err` with a
/// human-readable message that lists nearby skill names.
pub fn resolve_skill_invocation(
cwd: &Path,
args: Option<&str>,
) -> Result<SkillSlashDispatch, String> {
let dispatch = classify_skills_slash_command(args);
if let SkillSlashDispatch::Invoke(ref prompt) = dispatch {
// Extract the skill name from the "$skill [args]" prompt.
let skill_token = prompt
.trim_start_matches('$')
.split_whitespace()
.next()
.unwrap_or_default();
if !skill_token.is_empty() {
if let Err(error) = resolve_skill_path(cwd, skill_token) {
let mut message =
format!("Unknown skill: {skill_token} ({error})");
let roots = discover_skill_roots(cwd);
if let Ok(available) = load_skills_from_roots(&roots) {
let names: Vec<String> = available
.iter()
.filter(|s| s.shadowed_by.is_none())
.map(|s| s.name.clone())
.collect();
if !names.is_empty() {
message.push_str(&format!(
"\n Available skills: {}",
names.join(", ")
));
}
}
message.push_str(
"\n Usage: /skills [list|install <path>|help|<skill> [args]]",
);
return Err(message);
}
}
}
Ok(dispatch)
}
pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
if requested.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"skill must not be empty",
));
}
let roots = discover_skill_roots(cwd);
for root in &roots {
let mut entries = Vec::new();
for entry in fs::read_dir(&root.path)? {
let entry = entry?;
match root.origin {
SkillOrigin::SkillsDir => {
if !entry.path().is_dir() {
continue;
}
let skill_path = entry.path().join("SKILL.md");
if !skill_path.is_file() {
continue;
}
let contents = fs::read_to_string(&skill_path)?;
let (name, _) = parse_skill_frontmatter(&contents);
entries.push((
name.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
skill_path,
));
}
SkillOrigin::LegacyCommandsDir => {
let path = entry.path();
let markdown_path = if path.is_dir() {
let skill_path = path.join("SKILL.md");
if !skill_path.is_file() {
continue;
}
skill_path
} else if path
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
{
path
} else {
continue;
};
let contents = fs::read_to_string(&markdown_path)?;
let fallback_name = markdown_path.file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
let (name, _) = parse_skill_frontmatter(&contents);
entries.push((name.unwrap_or(fallback_name), markdown_path));
}
}
}
entries.sort_by(|left, right| left.0.cmp(&right.0));
if let Some((_, path)) = entries
.into_iter()
.find(|(name, _)| name.eq_ignore_ascii_case(requested))
{
return Ok(path);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("unknown skill: {requested}"),
))
}
fn render_mcp_report_for(
loader: &ConfigLoader,
cwd: &Path,
@@ -2444,6 +2591,14 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
);
}
if let Ok(claude_config_dir) = env::var("CLAUDE_CONFIG_DIR") {
push_unique_root(
&mut roots,
DefinitionSource::UserClaude,
PathBuf::from(claude_config_dir).join(leaf),
);
}
if let Some(home) = env::var_os("HOME") {
let home = PathBuf::from(home);
push_unique_root(
@@ -2477,6 +2632,18 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
ancestor.join(".claw").join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::ProjectClaw,
ancestor.join(".omc").join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::ProjectClaw,
ancestor.join(".agents").join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::ProjectCodex,
@@ -2549,6 +2716,12 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
home.join(".claw").join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserClaw,
home.join(".omc").join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserClaw,
@@ -2573,6 +2746,12 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
home.join(".claude").join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserClaude,
home.join(".claude").join("skills").join("omc-learned"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserClaude,
@@ -2581,6 +2760,29 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
);
}
if let Ok(claude_config_dir) = env::var("CLAUDE_CONFIG_DIR") {
let claude_config_dir = PathBuf::from(claude_config_dir);
let skills_dir = claude_config_dir.join("skills");
push_unique_skill_root(
&mut roots,
DefinitionSource::UserClaude,
skills_dir.clone(),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserClaude,
skills_dir.join("omc-learned"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserClaude,
claude_config_dir.join("commands"),
SkillOrigin::LegacyCommandsDir,
);
}
roots
}
@@ -3039,6 +3241,25 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
lines.join("\n").trim_end().to_string()
}
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
let active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
json!({
"kind": "agents",
"action": "list",
"working_directory": cwd.display().to_string(),
"count": agents.len(),
"summary": {
"total": agents.len(),
"active": active,
"shadowed": agents.len().saturating_sub(active),
},
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
})
}
fn agent_detail(agent: &AgentSummary) -> String {
let mut parts = vec![agent.name.clone()];
if let Some(description) = &agent.description {
@@ -3327,13 +3548,28 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
lines.join("\n")
}
fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
json!({
"kind": "agents",
"action": "help",
"usage": {
"slash_command": "/agents [list|help]",
"direct_cli": "claw agents [list|help]",
"sources": [".claw/agents", "~/.claw/agents", "$CLAW_CONFIG_HOME/agents"],
},
"unexpected": unexpected,
})
}
fn render_skills_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Skills".to_string(),
" Usage /skills [list|install <path>|help]".to_string(),
" Direct CLI claw skills [list|install <path>|help]".to_string(),
" Usage /skills [list|install <path>|help|<skill> [args]]".to_string(),
" Alias /skill".to_string(),
" Direct CLI claw skills [list|install <path>|help|<skill> [args]]".to_string(),
" Invoke /skills help overview -> $help overview".to_string(),
" Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills".to_string(),
" Sources .claw/skills, ~/.claw/skills, legacy /commands".to_string(),
" Sources .claw/skills, .omc/skills, .agents/skills, .codex/skills, .claude/skills, ~/.claw/skills, ~/.omc/skills, ~/.claude/skills/omc-learned, ~/.codex/skills, ~/.claude/skills, legacy /commands".to_string(),
];
if let Some(args) = unexpected {
lines.push(format!(" Unexpected {args}"));
@@ -3346,10 +3582,25 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value {
"kind": "skills",
"action": "help",
"usage": {
"slash_command": "/skills [list|install <path>|help]",
"direct_cli": "claw skills [list|install <path>|help]",
"slash_command": "/skills [list|install <path>|help|<skill> [args]]",
"aliases": ["/skill"],
"direct_cli": "claw skills [list|install <path>|help|<skill> [args]]",
"invoke": "/skills help overview -> $help overview",
"install_root": "$CLAW_CONFIG_HOME/skills or ~/.claw/skills",
"sources": [".claw/skills", "legacy /commands", "legacy fallback dirs still load automatically"],
"sources": [
".claw/skills",
".omc/skills",
".agents/skills",
".codex/skills",
".claude/skills",
"~/.claw/skills",
"~/.omc/skills",
"~/.claude/skills/omc-learned",
"~/.codex/skills",
"~/.claude/skills",
"legacy /commands",
"legacy fallback dirs still load automatically"
],
},
"unexpected": unexpected,
})
@@ -3478,6 +3729,18 @@ fn definition_source_json(source: DefinitionSource) -> Value {
})
}
fn agent_summary_json(agent: &AgentSummary) -> Value {
json!({
"name": &agent.name,
"description": &agent.description,
"model": &agent.model,
"reasoning_effort": &agent.reasoning_effort,
"source": definition_source_json(agent.source),
"active": agent.shadowed_by.is_none(),
"shadowed_by": agent.shadowed_by.map(definition_source_json),
})
}
fn skill_origin_id(origin: SkillOrigin) -> &'static str {
match origin {
SkillOrigin::SkillsDir => "skills_dir",
@@ -3686,19 +3949,23 @@ pub fn handle_slash_command(
#[cfg(test)]
mod tests {
use super::{
classify_skills_slash_command, handle_agents_slash_command_json,
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
load_agents_from_roots, load_skills_from_roots, render_agents_report,
render_mcp_report_json_for, render_plugins_report, render_skills_report,
render_slash_command_help, render_slash_command_help_detail,
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
render_skills_report, render_slash_command_help, render_slash_command_help_detail,
resolve_skill_path, resume_supported_slash_commands, slash_command_specs,
suggest_slash_commands, validate_slash_command_input, DefinitionSource, SkillOrigin,
SkillRoot, SkillSlashDispatch, SlashCommand,
};
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
use runtime::{
CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
};
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
@@ -3709,6 +3976,18 @@ mod tests {
std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
}
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn restore_env_var(key: &str, original: Option<OsString>) {
match original {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
fn write_external_plugin(root: &Path, name: &str, version: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::write(
@@ -4039,24 +4318,40 @@ mod tests {
}
#[test]
fn rejects_invalid_agents_and_skills_arguments() {
fn rejects_invalid_agents_arguments() {
// given
let agents_input = "/agents show planner";
let skills_input = "/skills show help";
// when
let agents_error = parse_error_message(agents_input);
let skills_error = parse_error_message(skills_input);
// then
assert!(agents_error.contains(
"Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
));
assert!(agents_error.contains(" Usage /agents [list|help]"));
assert!(skills_error.contains(
"Unexpected arguments for /skills: show help. Use /skills, /skills list, /skills install <path>, or /skills help."
));
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
}
#[test]
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
assert_eq!(
SlashCommand::parse("/skills help overview"),
Ok(Some(SlashCommand::Skills {
args: Some("help overview".to_string()),
}))
);
assert_eq!(
classify_skills_slash_command(Some("help overview")),
SkillSlashDispatch::Invoke("$help overview".to_string())
);
assert_eq!(
classify_skills_slash_command(Some("/test")),
SkillSlashDispatch::Invoke("$test".to_string())
);
assert_eq!(
classify_skills_slash_command(Some("install ./skill-pack")),
SkillSlashDispatch::Local
);
}
#[test]
@@ -4110,7 +4405,8 @@ mod tests {
));
assert!(help.contains("aliases: /plugins, /marketplace"));
assert!(help.contains("/agents [list|help]"));
assert!(help.contains("/skills [list|install <path>|help]"));
assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
assert!(help.contains("aliases: /skill"));
assert_eq!(slash_command_specs().len(), 141);
assert!(resume_supported_slash_commands().len() >= 39);
}
@@ -4353,16 +4649,82 @@ mod tests {
assert!(report.contains("Agents"));
assert!(report.contains("2 active agents"));
assert!(report.contains("Project (.claw):"));
assert!(report.contains("Project roots:"));
assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
assert!(report.contains("User (~/.claw):"));
assert!(report.contains("(shadowed by Project (.claw)) planner · User planner"));
assert!(report.contains("User home roots:"));
assert!(report.contains("(shadowed by Project roots) planner · User planner"));
assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn renders_agents_reports_as_json() {
let workspace = temp_dir("agents-json-workspace");
let project_agents = workspace.join(".codex").join("agents");
let user_home = temp_dir("agents-json-home");
let user_agents = user_home.join(".codex").join("agents");
write_agent(
&project_agents,
"planner",
"Project planner",
"gpt-5.4",
"medium",
);
write_agent(
&project_agents,
"verifier",
"Verification agent",
"gpt-5.4-mini",
"high",
);
write_agent(
&user_agents,
"planner",
"User planner",
"gpt-5.4-mini",
"high",
);
let roots = vec![
(DefinitionSource::ProjectCodex, project_agents),
(DefinitionSource::UserCodex, user_agents),
];
let report = render_agents_report_json(
&workspace,
&load_agents_from_roots(&roots).expect("agent roots should load"),
);
assert_eq!(report["kind"], "agents");
assert_eq!(report["action"], "list");
assert_eq!(report["working_directory"], workspace.display().to_string());
assert_eq!(report["count"], 3);
assert_eq!(report["summary"]["active"], 2);
assert_eq!(report["summary"]["shadowed"], 1);
assert_eq!(report["agents"][0]["name"], "planner");
assert_eq!(report["agents"][0]["model"], "gpt-5.4");
assert_eq!(report["agents"][0]["active"], true);
assert_eq!(report["agents"][1]["name"], "verifier");
assert_eq!(report["agents"][2]["name"], "planner");
assert_eq!(report["agents"][2]["active"], false);
assert_eq!(report["agents"][2]["shadowed_by"]["id"], "project_claw");
let help = handle_agents_slash_command_json(Some("help"), &workspace).expect("agents help");
assert_eq!(help["kind"], "agents");
assert_eq!(help["action"], "help");
assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
let unexpected = handle_agents_slash_command_json(Some("show planner"), &workspace)
.expect("agents usage");
assert_eq!(unexpected["action"], "help");
assert_eq!(unexpected["unexpected"], "show planner");
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn lists_skills_from_project_and_user_roots() {
let workspace = temp_dir("skills-workspace");
@@ -4398,17 +4760,36 @@ mod tests {
assert!(report.contains("Skills"));
assert!(report.contains("3 available skills"));
assert!(report.contains("Project (.claw):"));
assert!(report.contains("Project roots:"));
assert!(report.contains("plan · Project planning guidance"));
assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
assert!(report.contains("User (~/.claw):"));
assert!(report.contains("(shadowed by Project (.claw)) plan · User planning guidance"));
assert!(report.contains("User home roots:"));
assert!(report.contains("(shadowed by Project roots) plan · User planning guidance"));
assert!(report.contains("help · Help guidance"));
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn resolves_project_skills_and_legacy_commands_from_shared_registry() {
let workspace = temp_dir("resolve-project-skills");
let project_skills = workspace.join(".claw").join("skills");
let legacy_commands = workspace.join(".claw").join("commands");
write_skill(&project_skills, "plan", "Project planning guidance");
write_legacy_command(&legacy_commands, "handoff", "Legacy handoff guidance");
assert_eq!(
resolve_skill_path(&workspace, "$plan").expect("project skill should resolve"),
project_skills.join("plan").join("SKILL.md")
);
assert_eq!(
resolve_skill_path(&workspace, "/handoff").expect("legacy command should resolve"),
legacy_commands.join("handoff.md")
);
}
#[test]
fn renders_skills_reports_as_json() {
let workspace = temp_dir("skills-json-workspace");
@@ -4455,9 +4836,10 @@ mod tests {
let help = handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help");
assert_eq!(help["kind"], "skills");
assert_eq!(help["action"], "help");
assert_eq!(help["usage"]["aliases"][0], "/skill");
assert_eq!(
help["usage"]["direct_cli"],
"claw skills [list|install <path>|help]"
"claw skills [list|install <path>|help|<skill> [args]]"
);
let _ = fs::remove_dir_all(workspace);
@@ -4481,8 +4863,14 @@ mod tests {
let skills_help =
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
assert!(skills_help.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_help
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
assert!(skills_help.contains("Alias /skill"));
assert!(skills_help.contains("Invoke /skills help overview -> $help overview"));
assert!(skills_help.contains("Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills"));
assert!(skills_help.contains(".omc/skills"));
assert!(skills_help.contains(".agents/skills"));
assert!(skills_help.contains("~/.claude/skills/omc-learned"));
assert!(skills_help.contains("legacy /commands"));
let skills_unexpected =
@@ -4491,17 +4879,98 @@ mod tests {
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
.expect("nested skills help");
assert!(skills_install_help.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_install_help
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
assert!(skills_install_help.contains("Alias /skill"));
assert!(skills_install_help.contains("Unexpected install"));
let skills_unknown_help =
super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help");
assert!(skills_unknown_help.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_unknown_help
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
assert!(skills_unknown_help.contains("Unexpected show"));
let skills_help_json =
super::handle_skills_slash_command_json(Some("help"), &cwd).expect("skills help json");
let sources = skills_help_json["usage"]["sources"]
.as_array()
.expect("skills help sources");
assert_eq!(skills_help_json["usage"]["aliases"][0], "/skill");
assert!(sources.iter().any(|value| value == ".omc/skills"));
assert!(sources.iter().any(|value| value == ".agents/skills"));
assert!(sources.iter().any(|value| value == "~/.omc/skills"));
assert!(sources
.iter()
.any(|value| value == "~/.claude/skills/omc-learned"));
let _ = fs::remove_dir_all(cwd);
}
#[test]
fn discovers_omc_skills_from_project_and_user_compatibility_roots() {
let _guard = env_lock().lock().expect("env lock");
let workspace = temp_dir("skills-omc-workspace");
let user_home = temp_dir("skills-omc-home");
let claude_config_dir = temp_dir("skills-omc-claude-config");
let project_omc_skills = workspace.join(".omc").join("skills");
let project_agents_skills = workspace.join(".agents").join("skills");
let user_omc_skills = user_home.join(".omc").join("skills");
let claude_config_skills = claude_config_dir.join("skills");
let claude_config_commands = claude_config_dir.join("commands");
let learned_skills = claude_config_dir.join("skills").join("omc-learned");
let original_home = std::env::var_os("HOME");
let original_claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
write_skill(&project_omc_skills, "hud", "OMC HUD guidance");
write_skill(
&project_agents_skills,
"trace",
"Compatibility skill guidance",
);
write_skill(&user_omc_skills, "cancel", "OMC cancel guidance");
write_skill(
&claude_config_skills,
"statusline",
"Claude config skill guidance",
);
write_legacy_command(
&claude_config_commands,
"doctor-check",
"Claude config command guidance",
);
write_skill(&learned_skills, "learned", "Learned skill guidance");
std::env::set_var("HOME", &user_home);
std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir);
let report = super::handle_skills_slash_command(None, &workspace).expect("skills list");
assert!(report.contains("available skills"));
assert!(report.contains("hud · OMC HUD guidance"));
assert!(report.contains("trace · Compatibility skill guidance"));
assert!(report.contains("cancel · OMC cancel guidance"));
assert!(report.contains("statusline · Claude config skill guidance"));
assert!(report.contains("doctor-check · Claude config command guidance · legacy /commands"));
assert!(report.contains("learned · Learned skill guidance"));
let help =
super::handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help");
let sources = help["usage"]["sources"]
.as_array()
.expect("skills help sources");
assert_eq!(help["usage"]["aliases"][0], "/skill");
assert!(sources.iter().any(|value| value == ".omc/skills"));
assert!(sources.iter().any(|value| value == ".agents/skills"));
assert!(sources.iter().any(|value| value == "~/.omc/skills"));
assert!(sources
.iter()
.any(|value| value == "~/.claude/skills/omc-learned"));
restore_env_var("HOME", original_home);
restore_env_var("CLAUDE_CONFIG_DIR", original_claude_config_dir);
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
let _ = fs::remove_dir_all(claude_config_dir);
}
#[test]
fn mcp_usage_supports_help_and_unexpected_args() {
let cwd = temp_dir("mcp-usage");
@@ -4738,7 +5207,7 @@ mod tests {
let listed = render_skills_report(
&load_skills_from_roots(&roots).expect("installed skills should load"),
);
assert!(listed.contains("User ($CLAW_CONFIG_HOME):"));
assert!(listed.contains("User config roots:"));
assert!(listed.contains("help · Helpful skill"));
let _ = fs::remove_dir_all(workspace);
+99 -1
View File
@@ -920,6 +920,9 @@ pub enum PluginManifestValidationError {
tool_name: String,
permission: String,
},
UnsupportedManifestContract {
detail: String,
},
}
impl Display for PluginManifestValidationError {
@@ -965,6 +968,7 @@ impl Display for PluginManifestValidationError {
f,
"plugin tool `{tool_name}` requiredPermission `{permission}` must be read-only, workspace-write, or danger-full-access"
),
Self::UnsupportedManifestContract { detail } => f.write_str(detail),
}
}
}
@@ -1594,10 +1598,73 @@ fn load_manifest_from_path(
manifest_path.display()
))
})?;
let raw_manifest: RawPluginManifest = serde_json::from_str(&contents)?;
let raw_json: Value = serde_json::from_str(&contents)?;
let compatibility_errors = detect_claude_code_manifest_contract_gaps(&raw_json);
if !compatibility_errors.is_empty() {
return Err(PluginError::ManifestValidation(compatibility_errors));
}
let raw_manifest: RawPluginManifest = serde_json::from_value(raw_json)?;
build_plugin_manifest(root, raw_manifest)
}
fn detect_claude_code_manifest_contract_gaps(
raw_manifest: &Value,
) -> Vec<PluginManifestValidationError> {
let Some(root) = raw_manifest.as_object() else {
return Vec::new();
};
let mut errors = Vec::new();
for (field, detail) in [
(
"skills",
"plugin manifest field `skills` uses the Claude Code plugin contract; `claw` does not load plugin-managed skills and instead discovers skills from local roots such as `.claw/skills`, `.omc/skills`, `.agents/skills`, `~/.omc/skills`, and `~/.claude/skills/omc-learned`.",
),
(
"mcpServers",
"plugin manifest field `mcpServers` uses the Claude Code plugin contract; `claw` does not import MCP servers from plugin manifests.",
),
(
"agents",
"plugin manifest field `agents` uses the Claude Code plugin contract; `claw` does not load plugin-managed agent markdown catalogs from plugin manifests.",
),
] {
if root.contains_key(field) {
errors.push(PluginManifestValidationError::UnsupportedManifestContract {
detail: detail.to_string(),
});
}
}
if root
.get("commands")
.and_then(Value::as_array)
.is_some_and(|commands| commands.iter().any(Value::is_string))
{
errors.push(PluginManifestValidationError::UnsupportedManifestContract {
detail: "plugin manifest field `commands` uses Claude Code-style directory globs; `claw` slash dispatch is still built-in and does not load plugin slash command markdown files.".to_string(),
});
}
if let Some(hooks) = root.get("hooks").and_then(Value::as_object) {
for hook_name in hooks.keys() {
if !matches!(
hook_name.as_str(),
"PreToolUse" | "PostToolUse" | "PostToolUseFailure"
) {
errors.push(PluginManifestValidationError::UnsupportedManifestContract {
detail: format!(
"plugin hook `{hook_name}` uses the Claude Code lifecycle contract; `claw` plugins currently support only PreToolUse, PostToolUse, and PostToolUseFailure."
),
});
}
}
}
errors
}
fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> {
let direct_path = root.join(MANIFEST_FILE_NAME);
if direct_path.exists() {
@@ -2517,6 +2584,37 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_claude_code_manifest_contracts_with_guidance() {
let root = temp_dir("manifest-claude-code-contract");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "oh-my-claudecode",
"version": "4.10.2",
"description": "Claude Code plugin manifest",
"hooks": {
"SessionStart": ["scripts/session-start.mjs"]
},
"agents": ["agents/*.md"],
"commands": ["commands/**/*.md"],
"skills": "./skills/",
"mcpServers": "./.mcp.json"
}"#,
);
let error = load_plugin_from_directory(&root)
.expect_err("Claude Code plugin manifest should fail with guidance");
let rendered = error.to_string();
assert!(rendered.contains("field `skills` uses the Claude Code plugin contract"));
assert!(rendered.contains("field `mcpServers` uses the Claude Code plugin contract"));
assert!(rendered.contains("field `agents` uses the Claude Code plugin contract"));
assert!(rendered.contains("field `commands` uses Claude Code-style directory globs"));
assert!(rendered.contains("hook `SessionStart` uses the Claude Code lifecycle contract"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_missing_tool_or_command_paths() {
let root = temp_dir("manifest-paths");
+82
View File
@@ -71,6 +71,13 @@ struct SessionPersistence {
}
/// Persisted conversational state for the runtime and CLI session manager.
///
/// `workspace_root` binds the session to the worktree it was created in. The
/// global session store under `~/.local/share/opencode` is shared across every
/// `opencode serve` instance, so without an explicit workspace root parallel
/// lanes can race and report success while writes land in the wrong CWD. See
/// ROADMAP.md item 41 (Phantom completions root cause) for the full
/// background.
#[derive(Debug, Clone)]
pub struct Session {
pub version: u32,
@@ -80,6 +87,7 @@ pub struct Session {
pub messages: Vec<ConversationMessage>,
pub compaction: Option<SessionCompaction>,
pub fork: Option<SessionFork>,
pub workspace_root: Option<PathBuf>,
persistence: Option<SessionPersistence>,
}
@@ -92,6 +100,7 @@ impl PartialEq for Session {
&& self.messages == other.messages
&& self.compaction == other.compaction
&& self.fork == other.fork
&& self.workspace_root == other.workspace_root
}
}
@@ -141,6 +150,7 @@ impl Session {
messages: Vec::new(),
compaction: None,
fork: None,
workspace_root: None,
persistence: None,
}
}
@@ -151,6 +161,22 @@ impl Session {
self
}
/// Bind this session to the workspace root it was created in.
///
/// This is the per-worktree counterpart to the global session store and
/// lets downstream tooling reject writes that drift to the wrong CWD when
/// multiple `opencode serve` instances share `~/.local/share/opencode`.
#[must_use]
pub fn with_workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
self.workspace_root = Some(workspace_root.into());
self
}
#[must_use]
pub fn workspace_root(&self) -> Option<&Path> {
self.workspace_root.as_deref()
}
#[must_use]
pub fn persistence_path(&self) -> Option<&Path> {
self.persistence.as_ref().map(|value| value.path.as_path())
@@ -225,6 +251,7 @@ impl Session {
parent_session_id: self.session_id.clone(),
branch_name: normalize_optional_string(branch_name),
}),
workspace_root: self.workspace_root.clone(),
persistence: None,
}
}
@@ -262,6 +289,12 @@ impl Session {
if let Some(fork) = &self.fork {
object.insert("fork".to_string(), fork.to_json());
}
if let Some(workspace_root) = &self.workspace_root {
object.insert(
"workspace_root".to_string(),
JsonValue::String(workspace_root_to_string(workspace_root)?),
);
}
Ok(JsonValue::Object(object))
}
@@ -302,6 +335,10 @@ impl Session {
.map(SessionCompaction::from_json)
.transpose()?;
let fork = object.get("fork").map(SessionFork::from_json).transpose()?;
let workspace_root = object
.get("workspace_root")
.and_then(JsonValue::as_str)
.map(PathBuf::from);
Ok(Self {
version,
session_id,
@@ -310,6 +347,7 @@ impl Session {
messages,
compaction,
fork,
workspace_root,
persistence: None,
})
}
@@ -322,6 +360,7 @@ impl Session {
let mut messages = Vec::new();
let mut compaction = None;
let mut fork = None;
let mut workspace_root = None;
for (line_number, raw_line) in contents.lines().enumerate() {
let line = raw_line.trim();
@@ -356,6 +395,10 @@ impl Session {
created_at_ms = Some(required_u64(object, "created_at_ms")?);
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
workspace_root = object
.get("workspace_root")
.and_then(JsonValue::as_str)
.map(PathBuf::from);
}
"message" => {
let message_value = object.get("message").ok_or_else(|| {
@@ -389,6 +432,7 @@ impl Session {
messages,
compaction,
fork,
workspace_root,
persistence: None,
})
}
@@ -449,6 +493,12 @@ impl Session {
if let Some(fork) = &self.fork {
object.insert("fork".to_string(), fork.to_json());
}
if let Some(workspace_root) = &self.workspace_root {
object.insert(
"workspace_root".to_string(),
JsonValue::String(workspace_root_to_string(workspace_root)?),
);
}
Ok(JsonValue::Object(object))
}
@@ -825,6 +875,15 @@ fn i64_from_usize(value: usize, key: &str) -> Result<i64, SessionError> {
.map_err(|_| SessionError::Format(format!("{key} out of range for JSON number")))
}
fn workspace_root_to_string(path: &Path) -> Result<String, SessionError> {
path.to_str().map(ToOwned::to_owned).ok_or_else(|| {
SessionError::Format(format!(
"workspace_root is not valid UTF-8: {}",
path.display()
))
})
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
@@ -1206,6 +1265,29 @@ mod tests {
assert!(error.to_string().contains("unsupported block type"));
}
#[test]
fn persists_workspace_root_round_trip_and_forks_inherit_it() {
// given
let path = temp_session_path("workspace-root");
let workspace_root = PathBuf::from("/tmp/b4-phantom-diag");
let mut session = Session::new().with_workspace_root(workspace_root.clone());
session
.push_user_text("write to the right cwd")
.expect("user message should append");
// when
session
.save_to_path(&path)
.expect("workspace-bound session should save");
let restored = Session::load_from_path(&path).expect("session should load");
let forked = restored.fork(Some("phantom-diag".to_string()));
fs::remove_file(&path).expect("temp file should be removable");
// then
assert_eq!(restored.workspace_root(), Some(workspace_root.as_path()));
assert_eq!(forked.workspace_root(), Some(workspace_root.as_path()));
}
fn temp_session_path(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
File diff suppressed because it is too large Load Diff
@@ -104,6 +104,31 @@ fn slash_command_names_match_known_commands_and_suggest_nearby_unknown_ones() {
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn omc_namespaced_slash_commands_surface_a_targeted_compatibility_hint() {
let temp_dir = unique_temp_dir("slash-dispatch-omc");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(&temp_dir)
.arg("/oh-my-claudecode:hud")
.output()
.expect("claw should launch");
assert!(
!output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
assert!(stderr.contains("unknown slash command outside the REPL: /oh-my-claudecode:hud"));
assert!(stderr.contains("Claude Code/OMC plugin command"));
assert!(stderr.contains("does not yet load plugin slash commands"));
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
}
#[test]
fn config_command_loads_defaults_from_standard_config_locations() {
// given
@@ -50,8 +50,34 @@ 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"]);
let isolated_home = root.join("home");
let isolated_config = root.join("config-home");
let isolated_codex = root.join("codex-home");
fs::create_dir_all(&isolated_home).expect("isolated home should exist");
let agents = assert_json_command_with_env(
&root,
&["--output-format", "json", "agents"],
&[
("HOME", isolated_home.to_str().expect("utf8 home")),
(
"CLAW_CONFIG_HOME",
isolated_config.to_str().expect("utf8 config home"),
),
(
"CODEX_HOME",
isolated_codex.to_str().expect("utf8 codex home"),
),
],
);
assert_eq!(agents["kind"], "agents");
assert_eq!(agents["action"], "list");
assert_eq!(agents["count"], 0);
assert_eq!(agents["summary"]["active"], 0);
assert!(agents["agents"]
.as_array()
.expect("agents array")
.is_empty());
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
assert_eq!(mcp["kind"], "mcp");
@@ -62,6 +88,68 @@ fn inventory_commands_emit_structured_json_when_requested() {
assert_eq!(skills["action"], "list");
}
#[test]
fn agents_command_emits_structured_agent_entries_when_requested() {
let root = unique_temp_dir("agents-json-populated");
let workspace = root.join("workspace");
let project_agents = workspace.join(".codex").join("agents");
let home = root.join("home");
let user_agents = home.join(".codex").join("agents");
let isolated_config = root.join("config-home");
let isolated_codex = root.join("codex-home");
fs::create_dir_all(&workspace).expect("workspace should exist");
write_agent(
&project_agents,
"planner",
"Project planner",
"gpt-5.4",
"medium",
);
write_agent(
&project_agents,
"verifier",
"Verification agent",
"gpt-5.4-mini",
"high",
);
write_agent(
&user_agents,
"planner",
"User planner",
"gpt-5.4-mini",
"high",
);
let parsed = assert_json_command_with_env(
&workspace,
&["--output-format", "json", "agents"],
&[
("HOME", home.to_str().expect("utf8 home")),
(
"CLAW_CONFIG_HOME",
isolated_config.to_str().expect("utf8 config home"),
),
(
"CODEX_HOME",
isolated_codex.to_str().expect("utf8 codex home"),
),
],
);
assert_eq!(parsed["kind"], "agents");
assert_eq!(parsed["action"], "list");
assert_eq!(parsed["count"], 3);
assert_eq!(parsed["summary"]["active"], 2);
assert_eq!(parsed["summary"]["shadowed"], 1);
assert_eq!(parsed["agents"][0]["name"], "planner");
assert_eq!(parsed["agents"][0]["source"]["id"], "project_claw");
assert_eq!(parsed["agents"][0]["active"], true);
assert_eq!(parsed["agents"][1]["name"], "verifier");
assert_eq!(parsed["agents"][2]["name"], "planner");
assert_eq!(parsed["agents"][2]["active"], false);
assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw");
}
#[test]
fn bootstrap_and_system_prompt_emit_json_when_requested() {
let root = unique_temp_dir("bootstrap-system-prompt-json");
@@ -112,6 +200,41 @@ fn doctor_and_resume_status_emit_json_when_requested() {
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
assert_eq!(doctor["kind"], "doctor");
assert!(doctor["message"].is_string());
let summary = doctor["summary"].as_object().expect("doctor summary");
assert!(summary["ok"].as_u64().is_some());
assert!(summary["warnings"].as_u64().is_some());
assert!(summary["failures"].as_u64().is_some());
let checks = doctor["checks"].as_array().expect("doctor checks");
assert_eq!(checks.len(), 5);
let check_names = checks
.iter()
.map(|check| {
assert!(check["status"].as_str().is_some());
assert!(check["summary"].as_str().is_some());
assert!(check["details"].is_array());
check["name"].as_str().expect("doctor check name")
})
.collect::<Vec<_>>();
assert_eq!(
check_names,
vec!["auth", "config", "workspace", "sandbox", "system"]
);
let workspace = checks
.iter()
.find(|check| check["name"] == "workspace")
.expect("workspace check");
assert!(workspace["cwd"].as_str().is_some());
assert!(workspace["in_git_repo"].is_boolean());
let sandbox = checks
.iter()
.find(|check| check["name"] == "sandbox")
.expect("sandbox check");
assert!(sandbox["filesystem_mode"].as_str().is_some());
assert!(sandbox["enabled"].is_boolean());
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
let session_path = root.join("session.jsonl");
fs::write(
@@ -136,6 +259,104 @@ fn doctor_and_resume_status_emit_json_when_requested() {
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());
}
#[test]
fn resumed_inventory_commands_emit_structured_json_when_requested() {
let root = unique_temp_dir("resume-inventory-json");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let session_path = root.join("session.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-inventory-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"inventory\"}]}}\n",
)
.expect("session should write");
let mcp = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/mcp",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(mcp["kind"], "mcp");
assert_eq!(mcp["action"], "list");
assert!(mcp["servers"].is_array());
let skills = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/skills",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(skills["kind"], "skills");
assert_eq!(skills["action"], "list");
assert!(skills["summary"]["total"].is_number());
assert!(skills["skills"].is_array());
}
#[test]
fn resumed_version_and_init_emit_structured_json_when_requested() {
let root = unique_temp_dir("resume-version-init-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let session_path = root.join("session.jsonl");
fs::write(
&session_path,
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-version-init-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n",
)
.expect("session should write");
let version = assert_json_command(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/version",
],
);
assert_eq!(version["kind"], "version");
assert_eq!(version["version"], env!("CARGO_PKG_VERSION"));
let init = assert_json_command(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/init",
],
);
assert_eq!(init["kind"], "init");
assert!(root.join("CLAUDE.md").exists());
}
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
assert_json_command_with_env(current_dir, args, &[])
}
@@ -183,6 +404,17 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
upstream
}
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
fs::create_dir_all(root).expect("agent root should exist");
fs::write(
root.join(format!("{name}.toml")),
format!(
"name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
),
)
.expect("agent fixture should write");
}
fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -275,6 +275,49 @@ fn resumed_status_command_emits_structured_json_when_requested() {
assert!(parsed["sandbox"]["filesystem_mode"].as_str().is_some());
}
#[test]
fn resumed_sandbox_command_emits_structured_json_when_requested() {
// given
let temp_dir = unique_temp_dir("resume-sandbox-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let session_path = temp_dir.join("session.jsonl");
Session::new()
.save_to_path(&session_path)
.expect("session should persist");
// when
let output = run_claw(
&temp_dir,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 path"),
"/sandbox",
],
);
// then
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value =
serde_json::from_str(stdout.trim()).expect("resume sandbox output should be json");
assert_eq!(parsed["kind"], "sandbox");
assert!(parsed["enabled"].is_boolean());
assert!(parsed["active"].is_boolean());
assert!(parsed["supported"].is_boolean());
assert!(parsed["filesystem_mode"].as_str().is_some());
assert!(parsed["allowed_mounts"].is_array());
assert!(parsed["markers"].is_array());
}
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
run_claw_with_env(current_dir, args, &[])
}
+1
View File
@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
api = { path = "../api" }
commands = { path = "../commands" }
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
+592 -38
View File
@@ -2973,55 +2973,266 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
}
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
match commands::resolve_skill_path(&cwd, skill) {
Ok(path) => Ok(path),
Err(_) => resolve_skill_path_from_compat_roots(skill),
}
}
fn resolve_skill_path_from_compat_roots(skill: &str) -> Result<std::path::PathBuf, String> {
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
if requested.is_empty() {
return Err(String::from("skill must not be empty"));
}
let mut candidates = Vec::new();
if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") {
candidates.push(std::path::PathBuf::from(claw_config_home).join("skills"));
}
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
}
if let Ok(home) = std::env::var("HOME") {
let home = std::path::PathBuf::from(home);
candidates.push(home.join(".claw").join("skills"));
candidates.push(home.join(".agents").join("skills"));
candidates.push(home.join(".config").join("opencode").join("skills"));
candidates.push(home.join(".codex").join("skills"));
candidates.push(home.join(".claude").join("skills"));
}
candidates.push(std::path::PathBuf::from("/home/bellman/.claw/skills"));
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
for root in candidates {
let direct = root.join(requested).join("SKILL.md");
if direct.exists() {
return Ok(direct);
}
if let Ok(entries) = std::fs::read_dir(&root) {
for entry in entries.flatten() {
let path = entry.path().join("SKILL.md");
if !path.exists() {
continue;
}
if entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(requested)
{
return Ok(path);
}
}
for root in skill_lookup_roots() {
if let Some(path) = resolve_skill_path_in_root(&root, requested) {
return Ok(path);
}
}
Err(format!("unknown skill: {requested}"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SkillLookupOrigin {
SkillsDir,
LegacyCommandsDir,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillLookupRoot {
path: std::path::PathBuf,
origin: SkillLookupOrigin,
}
fn skill_lookup_roots() -> Vec<SkillLookupRoot> {
let mut roots = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
push_project_skill_lookup_roots(&mut roots, &cwd);
}
if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") {
push_prefixed_skill_lookup_roots(&mut roots, std::path::Path::new(&claw_config_home));
}
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
push_prefixed_skill_lookup_roots(&mut roots, std::path::Path::new(&codex_home));
}
if let Ok(home) = std::env::var("HOME") {
push_home_skill_lookup_roots(&mut roots, std::path::Path::new(&home));
}
if let Ok(claude_config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
let claude_config_dir = std::path::PathBuf::from(claude_config_dir);
push_skill_lookup_root(
&mut roots,
claude_config_dir.join("skills"),
SkillLookupOrigin::SkillsDir,
);
push_skill_lookup_root(
&mut roots,
claude_config_dir.join("skills").join("omc-learned"),
SkillLookupOrigin::SkillsDir,
);
push_skill_lookup_root(
&mut roots,
claude_config_dir.join("commands"),
SkillLookupOrigin::LegacyCommandsDir,
);
}
push_skill_lookup_root(
&mut roots,
std::path::PathBuf::from("/home/bellman/.claw/skills"),
SkillLookupOrigin::SkillsDir,
);
push_skill_lookup_root(
&mut roots,
std::path::PathBuf::from("/home/bellman/.codex/skills"),
SkillLookupOrigin::SkillsDir,
);
roots
}
fn push_project_skill_lookup_roots(roots: &mut Vec<SkillLookupRoot>, cwd: &std::path::Path) {
for ancestor in cwd.ancestors() {
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".omc"));
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".agents"));
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".claw"));
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".codex"));
push_prefixed_skill_lookup_roots(roots, &ancestor.join(".claude"));
}
}
fn push_home_skill_lookup_roots(roots: &mut Vec<SkillLookupRoot>, home: &std::path::Path) {
push_prefixed_skill_lookup_roots(roots, &home.join(".omc"));
push_prefixed_skill_lookup_roots(roots, &home.join(".claw"));
push_prefixed_skill_lookup_roots(roots, &home.join(".codex"));
push_prefixed_skill_lookup_roots(roots, &home.join(".claude"));
push_skill_lookup_root(
roots,
home.join(".agents").join("skills"),
SkillLookupOrigin::SkillsDir,
);
push_skill_lookup_root(
roots,
home.join(".config").join("opencode").join("skills"),
SkillLookupOrigin::SkillsDir,
);
push_skill_lookup_root(
roots,
home.join(".claude").join("skills").join("omc-learned"),
SkillLookupOrigin::SkillsDir,
);
}
fn push_prefixed_skill_lookup_roots(roots: &mut Vec<SkillLookupRoot>, prefix: &std::path::Path) {
push_skill_lookup_root(roots, prefix.join("skills"), SkillLookupOrigin::SkillsDir);
push_skill_lookup_root(
roots,
prefix.join("commands"),
SkillLookupOrigin::LegacyCommandsDir,
);
}
fn push_skill_lookup_root(
roots: &mut Vec<SkillLookupRoot>,
path: std::path::PathBuf,
origin: SkillLookupOrigin,
) {
if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
roots.push(SkillLookupRoot { path, origin });
}
}
fn resolve_skill_path_in_root(
root: &SkillLookupRoot,
requested: &str,
) -> Option<std::path::PathBuf> {
match root.origin {
SkillLookupOrigin::SkillsDir => resolve_skill_path_in_skills_dir(&root.path, requested),
SkillLookupOrigin::LegacyCommandsDir => {
resolve_skill_path_in_legacy_commands_dir(&root.path, requested)
}
}
}
fn resolve_skill_path_in_skills_dir(
root: &std::path::Path,
requested: &str,
) -> Option<std::path::PathBuf> {
let direct = root.join(requested).join("SKILL.md");
if direct.is_file() {
return Some(direct);
}
let entries = std::fs::read_dir(root).ok()?;
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let skill_path = entry.path().join("SKILL.md");
if !skill_path.is_file() {
continue;
}
if entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(requested)
|| skill_frontmatter_name_matches(&skill_path, requested)
{
return Some(skill_path);
}
}
None
}
fn resolve_skill_path_in_legacy_commands_dir(
root: &std::path::Path,
requested: &str,
) -> Option<std::path::PathBuf> {
let direct_dir = root.join(requested).join("SKILL.md");
if direct_dir.is_file() {
return Some(direct_dir);
}
let direct_markdown = root.join(format!("{requested}.md"));
if direct_markdown.is_file() {
return Some(direct_markdown);
}
let entries = std::fs::read_dir(root).ok()?;
for entry in entries.flatten() {
let path = entry.path();
let candidate_path = if path.is_dir() {
let skill_path = path.join("SKILL.md");
if !skill_path.is_file() {
continue;
}
skill_path
} else if path
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
{
path
} else {
continue;
};
let matches_entry_name = candidate_path
.file_stem()
.is_some_and(|stem| stem.to_string_lossy().eq_ignore_ascii_case(requested))
|| entry
.file_name()
.to_string_lossy()
.trim_end_matches(".md")
.eq_ignore_ascii_case(requested);
if matches_entry_name || skill_frontmatter_name_matches(&candidate_path, requested) {
return Some(candidate_path);
}
}
None
}
fn skill_frontmatter_name_matches(path: &std::path::Path, requested: &str) -> bool {
std::fs::read_to_string(path)
.ok()
.and_then(|contents| parse_skill_name(&contents))
.is_some_and(|name| name.eq_ignore_ascii_case(requested))
}
fn parse_skill_name(contents: &str) -> Option<String> {
parse_skill_frontmatter_value(contents, "name")
}
fn parse_skill_frontmatter_value(contents: &str, key: &str) -> Option<String> {
let mut lines = contents.lines();
if lines.next().map(str::trim) != Some("---") {
return None;
}
for line in lines {
let trimmed = line.trim();
if trimmed == "---" {
break;
}
if let Some(value) = trimmed.strip_prefix(&format!("{key}:")) {
let value = value
.trim()
.trim_matches(|ch| matches!(ch, '"' | '\''))
.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31";
const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32;
@@ -5797,6 +6008,349 @@ mod tests {
fs::remove_dir_all(home).expect("temp home should clean up");
}
#[test]
fn skill_resolves_project_local_skills_and_legacy_commands() {
let _guard = env_lock().lock().expect("env lock should acquire");
let root = temp_path("project-skills");
let skill_dir = root.join(".claw").join("skills").join("plan");
let command_dir = root.join(".claw").join("commands");
fs::create_dir_all(&skill_dir).expect("skill dir should exist");
fs::create_dir_all(&command_dir).expect("command dir should exist");
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: plan\ndescription: Project planning guidance\n---\n\n# plan\n",
)
.expect("skill file should exist");
fs::write(
command_dir.join("handoff.md"),
"---\nname: handoff\ndescription: Legacy handoff guidance\n---\n\n# handoff\n",
)
.expect("command file should exist");
let original_dir = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&root).expect("set cwd");
let skill_result = execute_tool("Skill", &json!({ "skill": "$plan" }))
.expect("project-local skill should resolve");
let skill_output: serde_json::Value =
serde_json::from_str(&skill_result).expect("valid json");
assert!(skill_output["path"]
.as_str()
.expect("path")
.ends_with(".claw/skills/plan/SKILL.md"));
let command_result = execute_tool("Skill", &json!({ "skill": "/handoff" }))
.expect("legacy command should resolve");
let command_output: serde_json::Value =
serde_json::from_str(&command_result).expect("valid json");
assert!(command_output["path"]
.as_str()
.expect("path")
.ends_with(".claw/commands/handoff.md"));
std::env::set_current_dir(&original_dir).expect("restore cwd");
fs::remove_dir_all(root).expect("temp project should clean up");
}
#[test]
fn skill_loads_project_local_claude_skill_prompt() {
let _guard = env_lock().lock().expect("env lock should acquire");
let root = temp_path("project-skills");
let home = root.join("home");
let workspace = root.join("workspace");
let nested = workspace.join("nested");
let skill_dir = workspace.join(".claude").join("skills").join("trace");
fs::create_dir_all(&skill_dir).expect("skill dir should exist");
fs::create_dir_all(&nested).expect("nested cwd should exist");
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: trace\ndescription: Project-local trace helper\n---\n# trace\n",
)
.expect("skill file should exist");
let original_home = std::env::var("HOME").ok();
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_codex_home = std::env::var("CODEX_HOME").ok();
let original_dir = std::env::current_dir().expect("cwd");
std::env::set_var("HOME", &home);
std::env::remove_var("CLAW_CONFIG_HOME");
std::env::remove_var("CODEX_HOME");
std::env::set_current_dir(&nested).expect("set cwd");
let result = execute_tool("Skill", &json!({ "skill": "trace" }))
.expect("project-local skill should resolve");
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
assert!(output["path"]
.as_str()
.expect("path")
.ends_with(".claude/skills/trace/SKILL.md"));
assert_eq!(output["description"], "Project-local trace helper");
std::env::set_current_dir(&original_dir).expect("restore cwd");
match original_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
match original_codex_home {
Some(value) => std::env::set_var("CODEX_HOME", value),
None => std::env::remove_var("CODEX_HOME"),
}
fs::remove_dir_all(root).expect("temp tree should clean up");
}
#[test]
fn skill_loads_project_local_omc_and_agents_skill_prompts() {
let _guard = env_lock().lock().expect("env lock should acquire");
let root = temp_path("project-omc-skills");
let home = root.join("home");
let workspace = root.join("workspace");
let nested = workspace.join("nested");
let omc_skill_dir = workspace.join(".omc").join("skills").join("hud");
let agents_skill_dir = workspace.join(".agents").join("skills").join("trace");
fs::create_dir_all(&omc_skill_dir).expect("omc skill dir should exist");
fs::create_dir_all(&agents_skill_dir).expect("agents skill dir should exist");
fs::create_dir_all(&nested).expect("nested cwd should exist");
fs::write(
omc_skill_dir.join("SKILL.md"),
"---\nname: hud\ndescription: Project-local OMC HUD helper\n---\n# hud\n",
)
.expect("omc skill file should exist");
fs::write(
agents_skill_dir.join("SKILL.md"),
"---\nname: trace\ndescription: Project-local agents compatibility helper\n---\n# trace\n",
)
.expect("agents skill file should exist");
let original_home = std::env::var("HOME").ok();
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_codex_home = std::env::var("CODEX_HOME").ok();
let original_dir = std::env::current_dir().expect("cwd");
std::env::set_var("HOME", &home);
std::env::remove_var("CLAW_CONFIG_HOME");
std::env::remove_var("CODEX_HOME");
std::env::set_current_dir(&nested).expect("set cwd");
let omc_result =
execute_tool("Skill", &json!({ "skill": "hud" })).expect("omc skill should resolve");
let agents_result = execute_tool("Skill", &json!({ "skill": "trace" }))
.expect("agents skill should resolve");
let omc_output: serde_json::Value = serde_json::from_str(&omc_result).expect("valid json");
let agents_output: serde_json::Value =
serde_json::from_str(&agents_result).expect("valid json");
assert!(omc_output["path"]
.as_str()
.expect("path")
.ends_with(".omc/skills/hud/SKILL.md"));
assert_eq!(omc_output["description"], "Project-local OMC HUD helper");
assert!(agents_output["path"]
.as_str()
.expect("path")
.ends_with(".agents/skills/trace/SKILL.md"));
assert_eq!(
agents_output["description"],
"Project-local agents compatibility helper"
);
std::env::set_current_dir(&original_dir).expect("restore cwd");
match original_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
match original_codex_home {
Some(value) => std::env::set_var("CODEX_HOME", value),
None => std::env::remove_var("CODEX_HOME"),
}
fs::remove_dir_all(root).expect("temp tree should clean up");
}
#[test]
fn skill_loads_learned_skill_from_claude_config_dir() {
let _guard = env_lock().lock().expect("env lock should acquire");
let root = temp_path("claude-config-learned-skill");
let home = root.join("home");
let claude_config_dir = root.join("claude-config");
let learned_skill_dir = claude_config_dir
.join("skills")
.join("omc-learned")
.join("learned");
fs::create_dir_all(&learned_skill_dir).expect("learned skill dir should exist");
fs::write(
learned_skill_dir.join("SKILL.md"),
"---\nname: learned\ndescription: Learned OMC skill\n---\n# learned\n",
)
.expect("learned skill file should exist");
let original_home = std::env::var("HOME").ok();
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_codex_home = std::env::var("CODEX_HOME").ok();
let original_claude_config_dir = std::env::var("CLAUDE_CONFIG_DIR").ok();
std::env::set_var("HOME", &home);
std::env::remove_var("CLAW_CONFIG_HOME");
std::env::remove_var("CODEX_HOME");
std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir);
let result = execute_tool("Skill", &json!({ "skill": "learned" }))
.expect("learned skill should resolve");
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
assert!(output["path"]
.as_str()
.expect("path")
.ends_with("skills/omc-learned/learned/SKILL.md"));
assert_eq!(output["description"], "Learned OMC skill");
match original_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
match original_codex_home {
Some(value) => std::env::set_var("CODEX_HOME", value),
None => std::env::remove_var("CODEX_HOME"),
}
match original_claude_config_dir {
Some(value) => std::env::set_var("CLAUDE_CONFIG_DIR", value),
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
}
fs::remove_dir_all(root).expect("temp tree should clean up");
}
#[test]
fn skill_loads_direct_skill_and_legacy_command_from_claude_config_dir() {
let _guard = env_lock().lock().expect("env lock should acquire");
let root = temp_path("claude-config-direct-skill");
let home = root.join("home");
let claude_config_dir = root.join("claude-config");
let skill_dir = claude_config_dir.join("skills").join("statusline");
let command_dir = claude_config_dir.join("commands");
fs::create_dir_all(&skill_dir).expect("direct skill dir should exist");
fs::create_dir_all(&command_dir).expect("command dir should exist");
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: statusline\ndescription: Claude config skill\n---\n# statusline\n",
)
.expect("direct skill file should exist");
fs::write(
command_dir.join("doctor-check.md"),
"---\nname: doctor-check\ndescription: Claude config command\n---\n# doctor-check\n",
)
.expect("direct command file should exist");
let original_home = std::env::var("HOME").ok();
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_codex_home = std::env::var("CODEX_HOME").ok();
let original_claude_config_dir = std::env::var("CLAUDE_CONFIG_DIR").ok();
std::env::set_var("HOME", &home);
std::env::remove_var("CLAW_CONFIG_HOME");
std::env::remove_var("CODEX_HOME");
std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir);
let direct_skill =
execute_tool("Skill", &json!({ "skill": "statusline" })).expect("direct skill");
let direct_skill_output: serde_json::Value =
serde_json::from_str(&direct_skill).expect("valid skill json");
assert!(direct_skill_output["path"]
.as_str()
.expect("path")
.ends_with("skills/statusline/SKILL.md"));
assert_eq!(direct_skill_output["description"], "Claude config skill");
let legacy_command =
execute_tool("Skill", &json!({ "skill": "doctor-check" })).expect("direct command");
let legacy_command_output: serde_json::Value =
serde_json::from_str(&legacy_command).expect("valid command json");
assert!(legacy_command_output["path"]
.as_str()
.expect("path")
.ends_with("commands/doctor-check.md"));
assert_eq!(
legacy_command_output["description"],
"Claude config command"
);
match original_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
match original_codex_home {
Some(value) => std::env::set_var("CODEX_HOME", value),
None => std::env::remove_var("CODEX_HOME"),
}
match original_claude_config_dir {
Some(value) => std::env::set_var("CLAUDE_CONFIG_DIR", value),
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
}
fs::remove_dir_all(root).expect("temp tree should clean up");
}
#[test]
fn skill_loads_project_local_legacy_command_markdown() {
let _guard = env_lock().lock().expect("env lock should acquire");
let root = temp_path("project-legacy-command");
let home = root.join("home");
let workspace = root.join("workspace");
let nested = workspace.join("nested");
let command_dir = workspace.join(".claude").join("commands");
fs::create_dir_all(&command_dir).expect("legacy command dir should exist");
fs::create_dir_all(&nested).expect("nested cwd should exist");
fs::write(
command_dir.join("team.md"),
"---\nname: team\ndescription: Legacy team workflow\n---\n# team\n",
)
.expect("legacy command file should exist");
let original_home = std::env::var("HOME").ok();
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
let original_codex_home = std::env::var("CODEX_HOME").ok();
let original_dir = std::env::current_dir().expect("cwd");
std::env::set_var("HOME", &home);
std::env::remove_var("CLAW_CONFIG_HOME");
std::env::remove_var("CODEX_HOME");
std::env::set_current_dir(&nested).expect("set cwd");
let result = execute_tool("Skill", &json!({ "skill": "team" }))
.expect("legacy command markdown should resolve");
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
assert!(output["path"]
.as_str()
.expect("path")
.ends_with(".claude/commands/team.md"));
assert_eq!(output["description"], "Legacy team workflow");
std::env::set_current_dir(&original_dir).expect("restore cwd");
match original_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
None => std::env::remove_var("CLAW_CONFIG_HOME"),
}
match original_codex_home {
Some(value) => std::env::set_var("CODEX_HOME", value),
None => std::env::remove_var("CODEX_HOME"),
}
fs::remove_dir_all(root).expect("temp tree should clean up");
}
#[test]
fn tool_search_supports_keyword_and_select_queries() {
let keyword = execute_tool(