mirror of
https://github.com/instructkr/claw-code.git
synced 2026-07-03 16:46:25 +02:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 861edfc1dc | |||
| f982f24926 | |||
| 8d866073c5 | |||
| 4251c85855 | |||
| 2a642871ad | |||
| cd83c0ff68 | |||
| ce360e0ff3 | |||
| c980c3c01e | |||
| ce22d8fb4f | |||
| be561bfdeb | |||
| c1883d0f66 | |||
| 1fc5a1c457 | |||
| 549ad7c3af | |||
| ecadc5554a | |||
| 8ff9c1b15a | |||
| 6bd464bbe7 | |||
| 421ead7dba | |||
| f9cb42fb44 | |||
| 01b263c838 | |||
| b930895736 | |||
| 84a0973f6c | |||
| fe4da2aa65 | |||
| 53d6909b9b | |||
| ceaf9cbc23 | |||
| ee92f131b0 | |||
| df0908b10e | |||
| 22e3f8c5e3 | |||
| d94d792a48 |
+20
-1
@@ -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.
|
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.
|
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.
|
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**
|
**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
|
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
|
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
|
||||||
|
|||||||
@@ -109,6 +109,50 @@ cd rust
|
|||||||
./target/debug/claw logout
|
./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
|
## Common operational commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Generated
+1
@@ -1579,6 +1579,7 @@ name = "tools"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
"api",
|
||||||
|
"commands",
|
||||||
"plugins",
|
"plugins",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"runtime",
|
"runtime",
|
||||||
|
|||||||
+347
-14
@@ -2,6 +2,21 @@ use std::env::VarError;
|
|||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
use std::time::Duration;
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum ApiError {
|
pub enum ApiError {
|
||||||
MissingCredentials {
|
MissingCredentials {
|
||||||
@@ -20,11 +35,17 @@ pub enum ApiError {
|
|||||||
InvalidApiKeyEnv(VarError),
|
InvalidApiKeyEnv(VarError),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
Json(serde_json::Error),
|
Json {
|
||||||
|
provider: String,
|
||||||
|
model: String,
|
||||||
|
body_snippet: String,
|
||||||
|
source: serde_json::Error,
|
||||||
|
},
|
||||||
Api {
|
Api {
|
||||||
status: reqwest::StatusCode,
|
status: reqwest::StatusCode,
|
||||||
error_type: Option<String>,
|
error_type: Option<String>,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
|
request_id: Option<String>,
|
||||||
body: String,
|
body: String,
|
||||||
retryable: bool,
|
retryable: bool,
|
||||||
},
|
},
|
||||||
@@ -48,6 +69,25 @@ impl ApiError {
|
|||||||
Self::MissingCredentials { provider, env_vars }
|
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]
|
#[must_use]
|
||||||
pub fn is_retryable(&self) -> bool {
|
pub fn is_retryable(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
@@ -60,7 +100,101 @@ impl ApiError {
|
|||||||
| Self::Auth(_)
|
| Self::Auth(_)
|
||||||
| Self::InvalidApiKeyEnv(_)
|
| Self::InvalidApiKeyEnv(_)
|
||||||
| Self::Io(_)
|
| 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::InvalidSseFrame(_)
|
||||||
| Self::BackoffOverflow { .. } => false,
|
| Self::BackoffOverflow { .. } => false,
|
||||||
}
|
}
|
||||||
@@ -70,11 +204,27 @@ impl ApiError {
|
|||||||
impl Display for ApiError {
|
impl Display for ApiError {
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::MissingCredentials { provider, env_vars } => write!(
|
Self::MissingCredentials { provider, env_vars } => {
|
||||||
f,
|
write!(
|
||||||
"missing {provider} credentials; export {} before calling the {provider} API",
|
f,
|
||||||
env_vars.join(" or ")
|
"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 {
|
Self::ContextWindowExceeded {
|
||||||
model,
|
model,
|
||||||
estimated_input_tokens,
|
estimated_input_tokens,
|
||||||
@@ -97,19 +247,37 @@ impl Display for ApiError {
|
|||||||
}
|
}
|
||||||
Self::Http(error) => write!(f, "http error: {error}"),
|
Self::Http(error) => write!(f, "http error: {error}"),
|
||||||
Self::Io(error) => write!(f, "io 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 {
|
Self::Api {
|
||||||
status,
|
status,
|
||||||
error_type,
|
error_type,
|
||||||
message,
|
message,
|
||||||
|
request_id,
|
||||||
body,
|
body,
|
||||||
..
|
..
|
||||||
} => match (error_type, message) {
|
} => {
|
||||||
(Some(error_type), Some(message)) => {
|
if let (Some(error_type), Some(message)) = (error_type, message) {
|
||||||
write!(f, "api returned {status} ({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 {
|
Self::RetriesExhausted {
|
||||||
attempts,
|
attempts,
|
||||||
last_error,
|
last_error,
|
||||||
@@ -142,7 +310,12 @@ impl From<std::io::Error> for ApiError {
|
|||||||
|
|
||||||
impl From<serde_json::Error> for ApiError {
|
impl From<serde_json::Error> for ApiError {
|
||||||
fn from(value: serde_json::Error) -> Self {
|
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)
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, Session
|
|||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
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::sse::SseParser;
|
||||||
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
|
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 http_response = self.send_with_retry(&request).await?;
|
||||||
let request_id = request_id_from_headers(response.headers());
|
let request_id = request_id_from_headers(http_response.headers());
|
||||||
let mut response = response
|
let body = http_response.text().await.map_err(ApiError::from)?;
|
||||||
.json::<MessageResponse>()
|
let mut response = serde_json::from_str::<MessageResponse>(&body).map_err(|error| {
|
||||||
.await
|
ApiError::json_deserialize("Anthropic", &request.model, &body, error)
|
||||||
.map_err(ApiError::from)?;
|
})?;
|
||||||
if response.request_id.is_none() {
|
if response.request_id.is_none() {
|
||||||
response.request_id = request_id;
|
response.request_id = request_id;
|
||||||
}
|
}
|
||||||
@@ -339,14 +339,14 @@ impl AnthropicClient {
|
|||||||
&self,
|
&self,
|
||||||
request: &MessageRequest,
|
request: &MessageRequest,
|
||||||
) -> Result<MessageStream, ApiError> {
|
) -> Result<MessageStream, ApiError> {
|
||||||
preflight_message_request(request)?;
|
self.preflight_message_request(request).await?;
|
||||||
let response = self
|
let response = self
|
||||||
.send_with_retry(&request.clone().with_streaming())
|
.send_with_retry(&request.clone().with_streaming())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(MessageStream {
|
Ok(MessageStream {
|
||||||
request_id: request_id_from_headers(response.headers()),
|
request_id: request_id_from_headers(response.headers()),
|
||||||
response,
|
response,
|
||||||
parser: SseParser::new(),
|
parser: SseParser::new().with_context("Anthropic", request.model.clone()),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
done: false,
|
done: false,
|
||||||
request: request.clone(),
|
request: request.clone(),
|
||||||
@@ -371,10 +371,10 @@ impl AnthropicClient {
|
|||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
let response = expect_success(response).await?;
|
let response = expect_success(response).await?;
|
||||||
response
|
let body = response.text().await.map_err(ApiError::from)?;
|
||||||
.json::<OAuthTokenSet>()
|
serde_json::from_str::<OAuthTokenSet>(&body).map_err(|error| {
|
||||||
.await
|
ApiError::json_deserialize("Anthropic OAuth (exchange)", "n/a", &body, error)
|
||||||
.map_err(ApiError::from)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh_oauth_token(
|
pub async fn refresh_oauth_token(
|
||||||
@@ -391,10 +391,10 @@ impl AnthropicClient {
|
|||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?;
|
.map_err(ApiError::from)?;
|
||||||
let response = expect_success(response).await?;
|
let response = expect_success(response).await?;
|
||||||
response
|
let body = response.text().await.map_err(ApiError::from)?;
|
||||||
.json::<OAuthTokenSet>()
|
serde_json::from_str::<OAuthTokenSet>(&body).map_err(|error| {
|
||||||
.await
|
ApiError::json_deserialize("Anthropic OAuth (refresh)", "n/a", &body, error)
|
||||||
.map_err(ApiError::from)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_with_retry(
|
async fn send_with_retry(
|
||||||
@@ -466,18 +466,74 @@ impl AnthropicClient {
|
|||||||
request: &MessageRequest,
|
request: &MessageRequest,
|
||||||
) -> Result<reqwest::Response, ApiError> {
|
) -> Result<reqwest::Response, ApiError> {
|
||||||
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
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
|
let request_builder = self
|
||||||
.http
|
.http
|
||||||
.post(&request_url)
|
.post(request_url)
|
||||||
.header("content-type", "application/json");
|
.header("content-type", "application/json");
|
||||||
let mut request_builder = self.auth.apply(request_builder);
|
let mut request_builder = self.auth.apply(request_builder);
|
||||||
for (header_name, header_value) in self.request_profile.header_pairs() {
|
for (header_name, header_value) in self.request_profile.header_pairs() {
|
||||||
request_builder = request_builder.header(header_name, header_value);
|
request_builder = request_builder.header(header_name, header_value);
|
||||||
}
|
}
|
||||||
|
request_builder
|
||||||
|
}
|
||||||
|
|
||||||
let request_body = self.request_profile.render_json_body(request)?;
|
async fn preflight_message_request(&self, request: &MessageRequest) -> Result<(), ApiError> {
|
||||||
request_builder = request_builder.json(&request_body);
|
let Some(limit) = model_token_limit(&request.model) else {
|
||||||
request_builder.send().await.map_err(ApiError::from)
|
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) {
|
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> {
|
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
|
||||||
match std::env::var(key) {
|
match std::env::var(key) {
|
||||||
Ok(value) if !value.is_empty() => Ok(Some(value)),
|
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)),
|
Err(error) => Err(ApiError::from(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -808,6 +864,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let request_id = request_id_from_headers(response.headers());
|
||||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
||||||
let retryable = is_retryable_status(status);
|
let retryable = is_retryable_status(status);
|
||||||
@@ -820,6 +877,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||||||
message: parsed_error
|
message: parsed_error
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|error| error.error.message.clone()),
|
.map(|error| error.error.message.clone()),
|
||||||
|
request_id,
|
||||||
body,
|
body,
|
||||||
retryable,
|
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)
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AnthropicErrorEnvelope {
|
struct AnthropicErrorEnvelope {
|
||||||
error: AnthropicErrorBody,
|
error: AnthropicErrorBody,
|
||||||
@@ -1245,4 +1313,73 @@ mod tests {
|
|||||||
Some("Bearer proxy-token")
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,61 @@ fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
|
|||||||
.map_or(0, |bytes| (bytes.len() / 4 + 1) as 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -268,8 +323,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
detect_provider_kind, max_tokens_for_model, model_token_limit, preflight_message_request,
|
detect_provider_kind, load_dotenv_file, max_tokens_for_model, model_token_limit,
|
||||||
resolve_model_alias, ProviderKind,
|
parse_dotenv, preflight_message_request, resolve_model_alias, ProviderKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -375,4 +430,85 @@ mod tests {
|
|||||||
preflight_message_request(&request)
|
preflight_message_request(&request)
|
||||||
.expect("models without context metadata should skip the guarded preflight");
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,15 @@ impl OpenAiCompatClient {
|
|||||||
preflight_message_request(&request)?;
|
preflight_message_request(&request)?;
|
||||||
let response = self.send_with_retry(&request).await?;
|
let response = self.send_with_retry(&request).await?;
|
||||||
let request_id = request_id_from_headers(response.headers());
|
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)?;
|
let mut normalized = normalize_response(&request.model, payload)?;
|
||||||
if normalized.request_id.is_none() {
|
if normalized.request_id.is_none() {
|
||||||
normalized.request_id = request_id;
|
normalized.request_id = request_id;
|
||||||
@@ -150,7 +158,10 @@ impl OpenAiCompatClient {
|
|||||||
Ok(MessageStream {
|
Ok(MessageStream {
|
||||||
request_id: request_id_from_headers(response.headers()),
|
request_id: request_id_from_headers(response.headers()),
|
||||||
response,
|
response,
|
||||||
parser: OpenAiSseParser::new(),
|
parser: OpenAiSseParser::with_context(
|
||||||
|
self.config.provider_name,
|
||||||
|
request.model.clone(),
|
||||||
|
),
|
||||||
pending: VecDeque::new(),
|
pending: VecDeque::new(),
|
||||||
done: false,
|
done: false,
|
||||||
state: StreamState::new(request.model.clone()),
|
state: StreamState::new(request.model.clone()),
|
||||||
@@ -282,11 +293,17 @@ impl MessageStream {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct OpenAiSseParser {
|
struct OpenAiSseParser {
|
||||||
buffer: Vec<u8>,
|
buffer: Vec<u8>,
|
||||||
|
provider: String,
|
||||||
|
model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenAiSseParser {
|
impl OpenAiSseParser {
|
||||||
fn new() -> Self {
|
fn with_context(provider: impl Into<String>, model: impl Into<String>) -> Self {
|
||||||
Self::default()
|
Self {
|
||||||
|
buffer: Vec::new(),
|
||||||
|
provider: provider.into(),
|
||||||
|
model: model.into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push(&mut self, chunk: &[u8]) -> Result<Vec<ChatCompletionChunk>, ApiError> {
|
fn push(&mut self, chunk: &[u8]) -> Result<Vec<ChatCompletionChunk>, ApiError> {
|
||||||
@@ -294,7 +311,7 @@ impl OpenAiSseParser {
|
|||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
while let Some(frame) = next_sse_frame(&mut self.buffer) {
|
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);
|
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())
|
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();
|
let trimmed = frame.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -857,15 +878,15 @@ fn parse_sse_frame(frame: &str) -> Result<Option<ChatCompletionChunk>, ApiError>
|
|||||||
if payload == "[DONE]" {
|
if payload == "[DONE]" {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
serde_json::from_str(&payload)
|
serde_json::from_str::<ChatCompletionChunk>(&payload)
|
||||||
.map(Some)
|
.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> {
|
fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
|
||||||
match std::env::var(key) {
|
match std::env::var(key) {
|
||||||
Ok(value) if !value.is_empty() => Ok(Some(value)),
|
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)),
|
Err(error) => Err(ApiError::from(error)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -906,6 +927,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let request_id = request_id_from_headers(response.headers());
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
|
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
|
||||||
let retryable = is_retryable_status(status);
|
let retryable = is_retryable_status(status);
|
||||||
@@ -918,6 +940,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||||||
message: parsed_error
|
message: parsed_error
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|error| error.error.message.clone()),
|
.and_then(|error| error.error.message.clone()),
|
||||||
|
request_id,
|
||||||
body,
|
body,
|
||||||
retryable,
|
retryable,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use crate::types::StreamEvent;
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct SseParser {
|
pub struct SseParser {
|
||||||
buffer: Vec<u8>,
|
buffer: Vec<u8>,
|
||||||
|
provider: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SseParser {
|
impl SseParser {
|
||||||
@@ -12,12 +14,23 @@ impl SseParser {
|
|||||||
Self::default()
|
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> {
|
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<StreamEvent>, ApiError> {
|
||||||
self.buffer.extend_from_slice(chunk);
|
self.buffer.extend_from_slice(chunk);
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
while let Some(frame) = self.next_frame() {
|
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);
|
events.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,12 +44,18 @@ impl SseParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let trailing = std::mem::take(&mut self.buffer);
|
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]),
|
Some(event) => Ok(vec![event]),
|
||||||
None => Ok(Vec::new()),
|
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> {
|
fn next_frame(&mut self) -> Option<String> {
|
||||||
let separator = self
|
let separator = self
|
||||||
.buffer
|
.buffer
|
||||||
@@ -61,6 +80,14 @@ impl SseParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
|
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();
|
let trimmed = frame.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -97,7 +124,7 @@ pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
|
|||||||
|
|
||||||
serde_json::from_str::<StreamEvent>(&payload)
|
serde_json::from_str::<StreamEvent>(&payload)
|
||||||
.map(Some)
|
.map(Some)
|
||||||
.map_err(ApiError::from)
|
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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(),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ pub struct MessageResponse {
|
|||||||
pub stop_reason: Option<String>,
|
pub stop_reason: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stop_sequence: Option<String>,
|
pub stop_sequence: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub usage: Usage,
|
pub usage: Usage,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub request_id: Option<String>,
|
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 {
|
pub struct Usage {
|
||||||
|
#[serde(default)]
|
||||||
pub input_tokens: u32,
|
pub input_tokens: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_creation_input_tokens: u32,
|
pub cache_creation_input_tokens: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_read_input_tokens: u32,
|
pub cache_read_input_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
pub output_tokens: u32,
|
pub output_tokens: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +197,7 @@ pub struct MessageStartEvent {
|
|||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct MessageDeltaEvent {
|
pub struct MessageDeltaEvent {
|
||||||
pub delta: MessageDelta,
|
pub delta: MessageDelta,
|
||||||
|
#[serde(default)]
|
||||||
pub usage: Usage,
|
pub usage: Usage,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ async fn send_message_posts_json_and_parses_response() {
|
|||||||
assert!(body.get("stream").is_none());
|
assert!(body.get("stream").is_none());
|
||||||
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
|
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
|
||||||
assert_eq!(body["tool_choice"]["type"], json!("auto"));
|
assert_eq!(body["tool_choice"]["type"], json!("auto"));
|
||||||
assert_eq!(
|
assert!(
|
||||||
body["betas"],
|
body.get("betas").is_none(),
|
||||||
json!(["claude-code-20250219", "prompt-caching-scope-2026-01-05"])
|
"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 =
|
let body: serde_json::Value =
|
||||||
serde_json::from_str(&request.body).expect("request body should be json");
|
serde_json::from_str(&request.body).expect("request body should be json");
|
||||||
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
|
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
|
||||||
assert_eq!(
|
assert!(
|
||||||
body["betas"],
|
body.get("betas").is_none(),
|
||||||
json!([
|
"betas must travel via the anthropic-beta header, not the request body"
|
||||||
"claude-code-20250219",
|
|
||||||
"prompt-caching-scope-2026-01-05",
|
|
||||||
"tools-2026-04-01"
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let events = sink.events();
|
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);
|
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]
|
#[tokio::test]
|
||||||
#[allow(clippy::await_holding_lock)]
|
#[allow(clippy::await_holding_lock)]
|
||||||
async fn stream_message_parses_sse_events_with_tool_use() {
|
async fn stream_message_parses_sse_events_with_tool_use() {
|
||||||
|
|||||||
+512
-43
@@ -50,6 +50,12 @@ pub struct SlashCommandSpec {
|
|||||||
pub resume_supported: bool,
|
pub resume_supported: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SkillSlashDispatch {
|
||||||
|
Local,
|
||||||
|
Invoke(String),
|
||||||
|
}
|
||||||
|
|
||||||
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "help",
|
name: "help",
|
||||||
@@ -237,9 +243,9 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
},
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
name: "skills",
|
name: "skills",
|
||||||
aliases: &[],
|
aliases: &["skill"],
|
||||||
summary: "List or install available skills",
|
summary: "List, install, or invoke available skills",
|
||||||
argument_hint: Some("[list|install <path>|help]"),
|
argument_hint: Some("[list|install <path>|help|<skill> [args]]"),
|
||||||
resume_supported: true,
|
resume_supported: true,
|
||||||
},
|
},
|
||||||
SlashCommandSpec {
|
SlashCommandSpec {
|
||||||
@@ -1306,7 +1312,7 @@ pub fn validate_slash_command_input(
|
|||||||
"agents" => SlashCommand::Agents {
|
"agents" => SlashCommand::Agents {
|
||||||
args: parse_list_or_help_args(command, remainder)?,
|
args: parse_list_or_help_args(command, remainder)?,
|
||||||
},
|
},
|
||||||
"skills" => SlashCommand::Skills {
|
"skills" | "skill" => SlashCommand::Skills {
|
||||||
args: parse_skills_args(remainder.as_deref())?,
|
args: parse_skills_args(remainder.as_deref())?,
|
||||||
},
|
},
|
||||||
"doctor" => {
|
"doctor" => {
|
||||||
@@ -1686,13 +1692,7 @@ fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(command_error(
|
Ok(Some(args.to_string()))
|
||||||
&format!(
|
|
||||||
"Unexpected arguments for /skills: {args}. Use /skills, /skills list, /skills install <path>, or /skills help."
|
|
||||||
),
|
|
||||||
"skills",
|
|
||||||
"/skills [list|install <path>|help]",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
|
fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
|
||||||
@@ -1975,9 +1975,9 @@ enum DefinitionScope {
|
|||||||
impl DefinitionScope {
|
impl DefinitionScope {
|
||||||
fn label(self) -> &'static str {
|
fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Project => "Project (.claw)",
|
Self::Project => "Project roots",
|
||||||
Self::UserConfigHome => "User ($CLAW_CONFIG_HOME)",
|
Self::UserConfigHome => "User config roots",
|
||||||
Self::UserHome => "User (~/.claw)",
|
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(
|
pub fn handle_mcp_slash_command(
|
||||||
args: Option<&str>,
|
args: Option<&str>,
|
||||||
cwd: &Path,
|
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(
|
fn render_mcp_report_for(
|
||||||
loader: &ConfigLoader,
|
loader: &ConfigLoader,
|
||||||
cwd: &Path,
|
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") {
|
if let Some(home) = env::var_os("HOME") {
|
||||||
let home = PathBuf::from(home);
|
let home = PathBuf::from(home);
|
||||||
push_unique_root(
|
push_unique_root(
|
||||||
@@ -2477,6 +2632,18 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
|||||||
ancestor.join(".claw").join("skills"),
|
ancestor.join(".claw").join("skills"),
|
||||||
SkillOrigin::SkillsDir,
|
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(
|
push_unique_skill_root(
|
||||||
&mut roots,
|
&mut roots,
|
||||||
DefinitionSource::ProjectCodex,
|
DefinitionSource::ProjectCodex,
|
||||||
@@ -2549,6 +2716,12 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
|||||||
home.join(".claw").join("skills"),
|
home.join(".claw").join("skills"),
|
||||||
SkillOrigin::SkillsDir,
|
SkillOrigin::SkillsDir,
|
||||||
);
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::UserClaw,
|
||||||
|
home.join(".omc").join("skills"),
|
||||||
|
SkillOrigin::SkillsDir,
|
||||||
|
);
|
||||||
push_unique_skill_root(
|
push_unique_skill_root(
|
||||||
&mut roots,
|
&mut roots,
|
||||||
DefinitionSource::UserClaw,
|
DefinitionSource::UserClaw,
|
||||||
@@ -2573,6 +2746,12 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
|||||||
home.join(".claude").join("skills"),
|
home.join(".claude").join("skills"),
|
||||||
SkillOrigin::SkillsDir,
|
SkillOrigin::SkillsDir,
|
||||||
);
|
);
|
||||||
|
push_unique_skill_root(
|
||||||
|
&mut roots,
|
||||||
|
DefinitionSource::UserClaude,
|
||||||
|
home.join(".claude").join("skills").join("omc-learned"),
|
||||||
|
SkillOrigin::SkillsDir,
|
||||||
|
);
|
||||||
push_unique_skill_root(
|
push_unique_skill_root(
|
||||||
&mut roots,
|
&mut roots,
|
||||||
DefinitionSource::UserClaude,
|
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
|
roots
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3039,6 +3241,25 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
|
|||||||
lines.join("\n").trim_end().to_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 {
|
fn agent_detail(agent: &AgentSummary) -> String {
|
||||||
let mut parts = vec![agent.name.clone()];
|
let mut parts = vec![agent.name.clone()];
|
||||||
if let Some(description) = &agent.description {
|
if let Some(description) = &agent.description {
|
||||||
@@ -3327,13 +3548,28 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
|
|||||||
lines.join("\n")
|
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 {
|
fn render_skills_usage(unexpected: Option<&str>) -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"Skills".to_string(),
|
"Skills".to_string(),
|
||||||
" Usage /skills [list|install <path>|help]".to_string(),
|
" Usage /skills [list|install <path>|help|<skill> [args]]".to_string(),
|
||||||
" Direct CLI claw skills [list|install <path>|help]".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(),
|
" 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 {
|
if let Some(args) = unexpected {
|
||||||
lines.push(format!(" Unexpected {args}"));
|
lines.push(format!(" Unexpected {args}"));
|
||||||
@@ -3346,10 +3582,25 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value {
|
|||||||
"kind": "skills",
|
"kind": "skills",
|
||||||
"action": "help",
|
"action": "help",
|
||||||
"usage": {
|
"usage": {
|
||||||
"slash_command": "/skills [list|install <path>|help]",
|
"slash_command": "/skills [list|install <path>|help|<skill> [args]]",
|
||||||
"direct_cli": "claw skills [list|install <path>|help]",
|
"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",
|
"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,
|
"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 {
|
fn skill_origin_id(origin: SkillOrigin) -> &'static str {
|
||||||
match origin {
|
match origin {
|
||||||
SkillOrigin::SkillsDir => "skills_dir",
|
SkillOrigin::SkillsDir => "skills_dir",
|
||||||
@@ -3686,19 +3949,23 @@ pub fn handle_slash_command(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
|
classify_skills_slash_command, handle_agents_slash_command_json,
|
||||||
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
|
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
|
||||||
load_agents_from_roots, load_skills_from_roots, render_agents_report,
|
load_agents_from_roots, load_skills_from_roots, render_agents_report,
|
||||||
render_mcp_report_json_for, render_plugins_report, render_skills_report,
|
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
|
||||||
render_slash_command_help, render_slash_command_help_detail,
|
render_skills_report, render_slash_command_help, render_slash_command_help_detail,
|
||||||
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
|
resolve_skill_path, resume_supported_slash_commands, slash_command_specs,
|
||||||
validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
suggest_slash_commands, validate_slash_command_input, DefinitionSource, SkillOrigin,
|
||||||
|
SkillRoot, SkillSlashDispatch, SlashCommand,
|
||||||
};
|
};
|
||||||
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
|
CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
|
||||||
};
|
};
|
||||||
|
use std::ffi::OsString;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
fn temp_dir(label: &str) -> PathBuf {
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
@@ -3709,6 +3976,18 @@ mod tests {
|
|||||||
std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
|
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) {
|
fn write_external_plugin(root: &Path, name: &str, version: &str) {
|
||||||
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -4039,24 +4318,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_invalid_agents_and_skills_arguments() {
|
fn rejects_invalid_agents_arguments() {
|
||||||
// given
|
// given
|
||||||
let agents_input = "/agents show planner";
|
let agents_input = "/agents show planner";
|
||||||
let skills_input = "/skills show help";
|
|
||||||
|
|
||||||
// when
|
// when
|
||||||
let agents_error = parse_error_message(agents_input);
|
let agents_error = parse_error_message(agents_input);
|
||||||
let skills_error = parse_error_message(skills_input);
|
|
||||||
|
|
||||||
// then
|
// then
|
||||||
assert!(agents_error.contains(
|
assert!(agents_error.contains(
|
||||||
"Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
|
"Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
|
||||||
));
|
));
|
||||||
assert!(agents_error.contains(" Usage /agents [list|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."
|
|
||||||
));
|
#[test]
|
||||||
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
|
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]
|
#[test]
|
||||||
@@ -4110,7 +4405,8 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(help.contains("aliases: /plugins, /marketplace"));
|
assert!(help.contains("aliases: /plugins, /marketplace"));
|
||||||
assert!(help.contains("/agents [list|help]"));
|
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_eq!(slash_command_specs().len(), 141);
|
||||||
assert!(resume_supported_slash_commands().len() >= 39);
|
assert!(resume_supported_slash_commands().len() >= 39);
|
||||||
}
|
}
|
||||||
@@ -4353,16 +4649,82 @@ mod tests {
|
|||||||
|
|
||||||
assert!(report.contains("Agents"));
|
assert!(report.contains("Agents"));
|
||||||
assert!(report.contains("2 active 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("planner · Project planner · gpt-5.4 · medium"));
|
||||||
assert!(report.contains("User (~/.claw):"));
|
assert!(report.contains("User home roots:"));
|
||||||
assert!(report.contains("(shadowed by Project (.claw)) planner · User planner"));
|
assert!(report.contains("(shadowed by Project roots) planner · User planner"));
|
||||||
assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
|
assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
let _ = fs::remove_dir_all(user_home);
|
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]
|
#[test]
|
||||||
fn lists_skills_from_project_and_user_roots() {
|
fn lists_skills_from_project_and_user_roots() {
|
||||||
let workspace = temp_dir("skills-workspace");
|
let workspace = temp_dir("skills-workspace");
|
||||||
@@ -4398,17 +4760,36 @@ mod tests {
|
|||||||
|
|
||||||
assert!(report.contains("Skills"));
|
assert!(report.contains("Skills"));
|
||||||
assert!(report.contains("3 available 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("plan · Project planning guidance"));
|
||||||
assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
|
assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
|
||||||
assert!(report.contains("User (~/.claw):"));
|
assert!(report.contains("User home roots:"));
|
||||||
assert!(report.contains("(shadowed by Project (.claw)) plan · User planning guidance"));
|
assert!(report.contains("(shadowed by Project roots) plan · User planning guidance"));
|
||||||
assert!(report.contains("help · Help guidance"));
|
assert!(report.contains("help · Help guidance"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
let _ = fs::remove_dir_all(user_home);
|
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]
|
#[test]
|
||||||
fn renders_skills_reports_as_json() {
|
fn renders_skills_reports_as_json() {
|
||||||
let workspace = temp_dir("skills-json-workspace");
|
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");
|
let help = handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help");
|
||||||
assert_eq!(help["kind"], "skills");
|
assert_eq!(help["kind"], "skills");
|
||||||
assert_eq!(help["action"], "help");
|
assert_eq!(help["action"], "help");
|
||||||
|
assert_eq!(help["usage"]["aliases"][0], "/skill");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
help["usage"]["direct_cli"],
|
help["usage"]["direct_cli"],
|
||||||
"claw skills [list|install <path>|help]"
|
"claw skills [list|install <path>|help|<skill> [args]]"
|
||||||
);
|
);
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
@@ -4481,8 +4863,14 @@ mod tests {
|
|||||||
|
|
||||||
let skills_help =
|
let skills_help =
|
||||||
super::handle_skills_slash_command(Some("--help"), &cwd).expect("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("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"));
|
assert!(skills_help.contains("legacy /commands"));
|
||||||
|
|
||||||
let skills_unexpected =
|
let skills_unexpected =
|
||||||
@@ -4491,17 +4879,98 @@ mod tests {
|
|||||||
|
|
||||||
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
|
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
|
||||||
.expect("nested skills help");
|
.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"));
|
assert!(skills_install_help.contains("Unexpected install"));
|
||||||
|
|
||||||
let skills_unknown_help =
|
let skills_unknown_help =
|
||||||
super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills 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"));
|
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);
|
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]
|
#[test]
|
||||||
fn mcp_usage_supports_help_and_unexpected_args() {
|
fn mcp_usage_supports_help_and_unexpected_args() {
|
||||||
let cwd = temp_dir("mcp-usage");
|
let cwd = temp_dir("mcp-usage");
|
||||||
@@ -4738,7 +5207,7 @@ mod tests {
|
|||||||
let listed = render_skills_report(
|
let listed = render_skills_report(
|
||||||
&load_skills_from_roots(&roots).expect("installed skills should load"),
|
&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"));
|
assert!(listed.contains("help · Helpful skill"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
|
|||||||
@@ -920,6 +920,9 @@ pub enum PluginManifestValidationError {
|
|||||||
tool_name: String,
|
tool_name: String,
|
||||||
permission: String,
|
permission: String,
|
||||||
},
|
},
|
||||||
|
UnsupportedManifestContract {
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for PluginManifestValidationError {
|
impl Display for PluginManifestValidationError {
|
||||||
@@ -965,6 +968,7 @@ impl Display for PluginManifestValidationError {
|
|||||||
f,
|
f,
|
||||||
"plugin tool `{tool_name}` requiredPermission `{permission}` must be read-only, workspace-write, or danger-full-access"
|
"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()
|
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)
|
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> {
|
fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> {
|
||||||
let direct_path = root.join(MANIFEST_FILE_NAME);
|
let direct_path = root.join(MANIFEST_FILE_NAME);
|
||||||
if direct_path.exists() {
|
if direct_path.exists() {
|
||||||
@@ -2517,6 +2584,37 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(root);
|
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]
|
#[test]
|
||||||
fn load_plugin_from_directory_rejects_missing_tool_or_command_paths() {
|
fn load_plugin_from_directory_rejects_missing_tool_or_command_paths() {
|
||||||
let root = temp_dir("manifest-paths");
|
let root = temp_dir("manifest-paths");
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ struct SessionPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Persisted conversational state for the runtime and CLI session manager.
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub version: u32,
|
pub version: u32,
|
||||||
@@ -80,6 +87,7 @@ pub struct Session {
|
|||||||
pub messages: Vec<ConversationMessage>,
|
pub messages: Vec<ConversationMessage>,
|
||||||
pub compaction: Option<SessionCompaction>,
|
pub compaction: Option<SessionCompaction>,
|
||||||
pub fork: Option<SessionFork>,
|
pub fork: Option<SessionFork>,
|
||||||
|
pub workspace_root: Option<PathBuf>,
|
||||||
persistence: Option<SessionPersistence>,
|
persistence: Option<SessionPersistence>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +100,7 @@ impl PartialEq for Session {
|
|||||||
&& self.messages == other.messages
|
&& self.messages == other.messages
|
||||||
&& self.compaction == other.compaction
|
&& self.compaction == other.compaction
|
||||||
&& self.fork == other.fork
|
&& self.fork == other.fork
|
||||||
|
&& self.workspace_root == other.workspace_root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +150,7 @@ impl Session {
|
|||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
compaction: None,
|
compaction: None,
|
||||||
fork: None,
|
fork: None,
|
||||||
|
workspace_root: None,
|
||||||
persistence: None,
|
persistence: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +161,22 @@ impl Session {
|
|||||||
self
|
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]
|
#[must_use]
|
||||||
pub fn persistence_path(&self) -> Option<&Path> {
|
pub fn persistence_path(&self) -> Option<&Path> {
|
||||||
self.persistence.as_ref().map(|value| value.path.as_path())
|
self.persistence.as_ref().map(|value| value.path.as_path())
|
||||||
@@ -225,6 +251,7 @@ impl Session {
|
|||||||
parent_session_id: self.session_id.clone(),
|
parent_session_id: self.session_id.clone(),
|
||||||
branch_name: normalize_optional_string(branch_name),
|
branch_name: normalize_optional_string(branch_name),
|
||||||
}),
|
}),
|
||||||
|
workspace_root: self.workspace_root.clone(),
|
||||||
persistence: None,
|
persistence: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,6 +289,12 @@ impl Session {
|
|||||||
if let Some(fork) = &self.fork {
|
if let Some(fork) = &self.fork {
|
||||||
object.insert("fork".to_string(), fork.to_json());
|
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))
|
Ok(JsonValue::Object(object))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +335,10 @@ impl Session {
|
|||||||
.map(SessionCompaction::from_json)
|
.map(SessionCompaction::from_json)
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
let fork = object.get("fork").map(SessionFork::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 {
|
Ok(Self {
|
||||||
version,
|
version,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -310,6 +347,7 @@ impl Session {
|
|||||||
messages,
|
messages,
|
||||||
compaction,
|
compaction,
|
||||||
fork,
|
fork,
|
||||||
|
workspace_root,
|
||||||
persistence: None,
|
persistence: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -322,6 +360,7 @@ impl Session {
|
|||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
let mut compaction = None;
|
let mut compaction = None;
|
||||||
let mut fork = None;
|
let mut fork = None;
|
||||||
|
let mut workspace_root = None;
|
||||||
|
|
||||||
for (line_number, raw_line) in contents.lines().enumerate() {
|
for (line_number, raw_line) in contents.lines().enumerate() {
|
||||||
let line = raw_line.trim();
|
let line = raw_line.trim();
|
||||||
@@ -356,6 +395,10 @@ impl Session {
|
|||||||
created_at_ms = Some(required_u64(object, "created_at_ms")?);
|
created_at_ms = Some(required_u64(object, "created_at_ms")?);
|
||||||
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
|
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
|
||||||
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
|
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
|
||||||
|
workspace_root = object
|
||||||
|
.get("workspace_root")
|
||||||
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(PathBuf::from);
|
||||||
}
|
}
|
||||||
"message" => {
|
"message" => {
|
||||||
let message_value = object.get("message").ok_or_else(|| {
|
let message_value = object.get("message").ok_or_else(|| {
|
||||||
@@ -389,6 +432,7 @@ impl Session {
|
|||||||
messages,
|
messages,
|
||||||
compaction,
|
compaction,
|
||||||
fork,
|
fork,
|
||||||
|
workspace_root,
|
||||||
persistence: None,
|
persistence: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -449,6 +493,12 @@ impl Session {
|
|||||||
if let Some(fork) = &self.fork {
|
if let Some(fork) = &self.fork {
|
||||||
object.insert("fork".to_string(), fork.to_json());
|
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))
|
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")))
|
.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> {
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||||
value.and_then(|value| {
|
value.and_then(|value| {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
@@ -1206,6 +1265,29 @@ mod tests {
|
|||||||
assert!(error.to_string().contains("unsupported block type"));
|
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 {
|
fn temp_session_path(label: &str) -> PathBuf {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.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");
|
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]
|
#[test]
|
||||||
fn config_command_loads_defaults_from_standard_config_locations() {
|
fn config_command_loads_defaults_from_standard_config_locations() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -50,8 +50,34 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
|||||||
let root = unique_temp_dir("inventory-json");
|
let root = unique_temp_dir("inventory-json");
|
||||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
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["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"]);
|
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
|
||||||
assert_eq!(mcp["kind"], "mcp");
|
assert_eq!(mcp["kind"], "mcp");
|
||||||
@@ -62,6 +88,68 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
|||||||
assert_eq!(skills["action"], "list");
|
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]
|
#[test]
|
||||||
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||||
let root = unique_temp_dir("bootstrap-system-prompt-json");
|
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"]);
|
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
|
||||||
assert_eq!(doctor["kind"], "doctor");
|
assert_eq!(doctor["kind"], "doctor");
|
||||||
assert!(doctor["message"].is_string());
|
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");
|
let session_path = root.join("session.jsonl");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -136,6 +259,104 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());
|
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 {
|
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||||
assert_json_command_with_env(current_dir, args, &[])
|
assert_json_command_with_env(current_dir, args, &[])
|
||||||
}
|
}
|
||||||
@@ -183,6 +404,17 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
|
|||||||
upstream
|
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 {
|
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||||
let millis = SystemTime::now()
|
let millis = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.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());
|
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 {
|
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
||||||
run_claw_with_env(current_dir, args, &[])
|
run_claw_with_env(current_dir, args, &[])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
api = { path = "../api" }
|
api = { path = "../api" }
|
||||||
|
commands = { path = "../commands" }
|
||||||
plugins = { path = "../plugins" }
|
plugins = { path = "../plugins" }
|
||||||
runtime = { path = "../runtime" }
|
runtime = { path = "../runtime" }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
|
|||||||
+592
-38
@@ -2973,55 +2973,266 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_skill_path(skill: &str) -> 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('$');
|
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
||||||
if requested.is_empty() {
|
if requested.is_empty() {
|
||||||
return Err(String::from("skill must not be empty"));
|
return Err(String::from("skill must not be empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut candidates = Vec::new();
|
for root in skill_lookup_roots() {
|
||||||
if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") {
|
if let Some(path) = resolve_skill_path_in_root(&root, requested) {
|
||||||
candidates.push(std::path::PathBuf::from(claw_config_home).join("skills"));
|
return Ok(path);
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(format!("unknown skill: {requested}"))
|
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_MODEL: &str = "claude-opus-4-6";
|
||||||
const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31";
|
const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31";
|
||||||
const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32;
|
const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32;
|
||||||
@@ -5797,6 +6008,349 @@ mod tests {
|
|||||||
fs::remove_dir_all(home).expect("temp home should clean up");
|
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]
|
#[test]
|
||||||
fn tool_search_supports_keyword_and_select_queries() {
|
fn tool_search_supports_keyword_and_select_queries() {
|
||||||
let keyword = execute_tool(
|
let keyword = execute_tool(
|
||||||
|
|||||||
Reference in New Issue
Block a user