mirror of
https://github.com/instructkr/claw-code.git
synced 2026-07-07 02:18:57 +02:00
Compare commits
12 Commits
2bab4080d6
...
421ead7dba
| Author | SHA1 | Date | |
|---|---|---|---|
| 421ead7dba | |||
| f9cb42fb44 | |||
| 01b263c838 | |||
| b930895736 | |||
| 84a0973f6c | |||
| fe4da2aa65 | |||
| 53d6909b9b | |||
| ceaf9cbc23 | |||
| ee92f131b0 | |||
| df0908b10e | |||
| 22e3f8c5e3 | |||
| d94d792a48 |
+3
-1
@@ -309,7 +309,9 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
|
||||
20. **Session state classification gap (working vs blocked vs finished vs truly stale)** — **done**: agent manifests now derive machine states such as `working`, `blocked_background_job`, `blocked_merge_conflict`, `degraded_mcp`, `interrupted_transport`, `finished_pending_report`, and `finished_cleanable`, and terminal-state persistence records commit provenance plus derived state so downstream monitoring can distinguish quiet progress from truly idle sessions.
|
||||
21. **Resumed `/status` JSON parity gap** — dogfooding shows fresh `claw status --output-format json` now emits structured JSON, but resumed slash-command status still leaks through a text-shaped path in at least one dispatch path. Local CI-equivalent repro fails `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs::resumed_status_command_emits_structured_json_when_requested` with `expected value at line 1 column 1`, so resumed automation can receive text where JSON was explicitly requested. **Action:** unify fresh vs resumed `/status` rendering through one output-format contract and add regression coverage so resumed JSON output is guaranteed valid.
|
||||
22. **Opaque failure surface for session/runtime crashes** — repeated dogfood-facing failures can currently collapse to generic wrappers like `Something went wrong while processing your request. Please try again, or use /new to start a fresh session.` without exposing whether the fault was provider auth, session corruption, slash-command dispatch, render failure, or transport/runtime panic. This blocks fast self-recovery and turns actionable clawability bugs into blind retries. **Action:** preserve a short user-safe failure class (`provider_auth`, `session_load`, `command_dispatch`, `render`, `runtime_panic`, etc.), attach a local trace/session id, and ensure operators can jump from the chat-visible error to the exact failure log quickly.
|
||||
23. **`doctor --output-format json` check-level structure gap** — direct dogfooding shows `claw doctor --output-format json` exposes `has_failures` at the top level, but individual check results (`auth`, `config`, `workspace`, `sandbox`, `system`) are buried inside flat prose fields like `message` / `report`. That forces claws to string-scrape human text instead of consuming stable machine-readable diagnostics. **Action:** emit structured per-check JSON (`name`, `status`, `summary`, `details`, and relevant typed fields such as sandbox fallback reason) while preserving the current human-readable report for text mode.
|
||||
23. **`doctor --output-format json` check-level structure gap** — **done**: `claw doctor --output-format json` now keeps the human-readable `message`/`report` while also emitting structured per-check diagnostics (`name`, `status`, `summary`, `details`, plus typed fields like workspace paths and sandbox fallback data), with regression coverage in `output_format_contract.rs`.
|
||||
24. **Plugin lifecycle init/shutdown test flakes under workspace-parallel execution** — dogfooding surfaced that `build_runtime_runs_plugin_lifecycle_init_and_shutdown` can fail under `cargo test --workspace` while passing in isolation because sibling tests race on tempdir-backed shell init script paths. This is test brittleness rather than a code-path regression, but it still destabilizes CI confidence and wastes diagnosis cycles. **Action:** isolate temp resources per test robustly (unique dirs + no shared cwd assumptions), audit cleanup timing, and add a regression guard so the plugin lifecycle test remains stable under parallel workspace execution.
|
||||
26. **Resumed local-command JSON parity gap** — **done**: direct `claw --output-format json` already had structured renderers for `sandbox`, `mcp`, `skills`, `version`, and `init`, but resumed `claw --output-format json --resume <session> /…` paths still fell back to prose because resumed slash dispatch only emitted JSON for `/status`. Resumed `/sandbox`, `/mcp`, `/skills`, `/version`, and `/init` now reuse the same JSON envelopes as their direct CLI counterparts, with regression coverage in `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs` and `rust/crates/rusty-claude-cli/tests/output_format_contract.rs`.
|
||||
**P3 — Swarm efficiency**
|
||||
13. Swarm branch-lock protocol — **done**: `branch_lock::detect_branch_lock_collisions()` now detects same-branch/same-scope and nested-module collisions before parallel lanes drift into duplicate implementation
|
||||
14. Commit provenance / worktree-aware push events — **done**: lane event provenance now includes branch/worktree/superseded/canonical lineage metadata, and manifest persistence de-dupes superseded commit events before downstream consumers render them
|
||||
|
||||
Generated
+1
@@ -1579,6 +1579,7 @@ name = "tools"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
"plugins",
|
||||
"reqwest",
|
||||
"runtime",
|
||||
|
||||
@@ -2,6 +2,21 @@ use std::env::VarError;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::time::Duration;
|
||||
|
||||
const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
|
||||
"something went wrong while processing your request",
|
||||
"please try again, or use /new to start a fresh session",
|
||||
];
|
||||
|
||||
const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
|
||||
"maximum context length",
|
||||
"context window",
|
||||
"context length",
|
||||
"too many tokens",
|
||||
"prompt is too long",
|
||||
"input is too long",
|
||||
"request is too large",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
MissingCredentials {
|
||||
@@ -25,6 +40,7 @@ pub enum ApiError {
|
||||
status: reqwest::StatusCode,
|
||||
error_type: Option<String>,
|
||||
message: Option<String>,
|
||||
request_id: Option<String>,
|
||||
body: String,
|
||||
retryable: bool,
|
||||
},
|
||||
@@ -65,6 +81,96 @@ impl ApiError {
|
||||
| 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 { .. } => "provider_retry_exhausted",
|
||||
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
|
||||
"provider_auth"
|
||||
}
|
||||
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
|
||||
Self::ContextWindowExceeded { .. } => "context_window",
|
||||
Self::Api { .. } if self.is_context_window_failure() => "context_window",
|
||||
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
|
||||
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
|
||||
Self::Api { .. } => "provider_error",
|
||||
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
|
||||
"provider_transport"
|
||||
}
|
||||
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json(_) => "runtime_io",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_generic_fatal_wrapper(&self) -> bool {
|
||||
match self {
|
||||
Self::Api { message, body, .. } => {
|
||||
message
|
||||
.as_deref()
|
||||
.is_some_and(looks_like_generic_fatal_wrapper)
|
||||
|| looks_like_generic_fatal_wrapper(body)
|
||||
}
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
| Self::ExpiredOAuthToken
|
||||
| Self::Auth(_)
|
||||
| Self::InvalidApiKeyEnv(_)
|
||||
| Self::Http(_)
|
||||
| Self::Io(_)
|
||||
| Self::Json(_)
|
||||
| Self::InvalidSseFrame(_)
|
||||
| Self::BackoffOverflow { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_context_window_failure(&self) -> bool {
|
||||
match self {
|
||||
Self::ContextWindowExceeded { .. } => true,
|
||||
Self::Api {
|
||||
status,
|
||||
message,
|
||||
body,
|
||||
..
|
||||
} => {
|
||||
matches!(status.as_u16(), 400 | 413 | 422)
|
||||
&& (message
|
||||
.as_deref()
|
||||
.is_some_and(looks_like_context_window_error)
|
||||
|| looks_like_context_window_error(body))
|
||||
}
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_context_window_failure(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ExpiredOAuthToken
|
||||
| Self::Auth(_)
|
||||
| Self::InvalidApiKeyEnv(_)
|
||||
| Self::Http(_)
|
||||
| Self::Io(_)
|
||||
| Self::Json(_)
|
||||
| Self::InvalidSseFrame(_)
|
||||
| Self::BackoffOverflow { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ApiError {
|
||||
@@ -102,14 +208,24 @@ impl Display for ApiError {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
..
|
||||
} => match (error_type, message) {
|
||||
(Some(error_type), Some(message)) => {
|
||||
write!(f, "api returned {status} ({error_type}): {message}")
|
||||
} => {
|
||||
if let (Some(error_type), Some(message)) = (error_type, message) {
|
||||
write!(f, "api returned {status} ({error_type})")?;
|
||||
if let Some(request_id) = request_id {
|
||||
write!(f, " [trace {request_id}]")?;
|
||||
}
|
||||
write!(f, ": {message}")
|
||||
} else {
|
||||
write!(f, "api returned {status}")?;
|
||||
if let Some(request_id) = request_id {
|
||||
write!(f, " [trace {request_id}]")?;
|
||||
}
|
||||
write!(f, ": {body}")
|
||||
}
|
||||
_ => write!(f, "api returned {status}: {body}"),
|
||||
},
|
||||
}
|
||||
Self::RetriesExhausted {
|
||||
attempts,
|
||||
last_error,
|
||||
@@ -151,3 +267,83 @@ impl From<VarError> for ApiError {
|
||||
Self::InvalidApiKeyEnv(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_generic_fatal_wrapper(text: &str) -> bool {
|
||||
let lowered = text.to_ascii_lowercase();
|
||||
GENERIC_FATAL_WRAPPER_MARKERS
|
||||
.iter()
|
||||
.any(|marker| lowered.contains(marker))
|
||||
}
|
||||
|
||||
fn looks_like_context_window_error(text: &str) -> bool {
|
||||
let lowered = text.to_ascii_lowercase();
|
||||
CONTEXT_WINDOW_ERROR_MARKERS
|
||||
.iter()
|
||||
.any(|marker| lowered.contains(marker))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ApiError;
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,6 +808,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
@@ -820,6 +821,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
message: parsed_error
|
||||
.as_ref()
|
||||
.map(|error| error.error.message.clone()),
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
})
|
||||
|
||||
@@ -906,6 +906,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
@@ -918,6 +919,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
message: parsed_error
|
||||
.as_ref()
|
||||
.and_then(|error| error.error.message.clone()),
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
})
|
||||
|
||||
+465
-43
@@ -50,6 +50,12 @@ pub struct SlashCommandSpec {
|
||||
pub resume_supported: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SkillSlashDispatch {
|
||||
Local,
|
||||
Invoke(String),
|
||||
}
|
||||
|
||||
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
SlashCommandSpec {
|
||||
name: "help",
|
||||
@@ -237,9 +243,9 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "skills",
|
||||
aliases: &[],
|
||||
summary: "List or install available skills",
|
||||
argument_hint: Some("[list|install <path>|help]"),
|
||||
aliases: &["skill"],
|
||||
summary: "List, install, or invoke available skills",
|
||||
argument_hint: Some("[list|install <path>|help|<skill> [args]]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
@@ -1306,7 +1312,7 @@ pub fn validate_slash_command_input(
|
||||
"agents" => SlashCommand::Agents {
|
||||
args: parse_list_or_help_args(command, remainder)?,
|
||||
},
|
||||
"skills" => SlashCommand::Skills {
|
||||
"skills" | "skill" => SlashCommand::Skills {
|
||||
args: parse_skills_args(remainder.as_deref())?,
|
||||
},
|
||||
"doctor" => {
|
||||
@@ -1686,13 +1692,7 @@ fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandP
|
||||
}
|
||||
}
|
||||
|
||||
Err(command_error(
|
||||
&format!(
|
||||
"Unexpected arguments for /skills: {args}. Use /skills, /skills list, /skills install <path>, or /skills help."
|
||||
),
|
||||
"skills",
|
||||
"/skills [list|install <path>|help]",
|
||||
))
|
||||
Ok(Some(args.to_string()))
|
||||
}
|
||||
|
||||
fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
|
||||
@@ -1975,9 +1975,9 @@ enum DefinitionScope {
|
||||
impl DefinitionScope {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Project => "Project (.claw)",
|
||||
Self::UserConfigHome => "User ($CLAW_CONFIG_HOME)",
|
||||
Self::UserHome => "User (~/.claw)",
|
||||
Self::Project => "Project roots",
|
||||
Self::UserConfigHome => "User config roots",
|
||||
Self::UserHome => "User home roots",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2187,6 +2187,27 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::io::Result<Value> {
|
||||
if let Some(args) = normalize_optional_args(args) {
|
||||
if let Some(help_path) = help_path_from_args(args) {
|
||||
return Ok(match help_path.as_slice() {
|
||||
[] => render_agents_usage_json(None),
|
||||
_ => render_agents_usage_json(Some(&help_path.join(" "))),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match normalize_optional_args(args) {
|
||||
None | Some("list") => {
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report_json(cwd, &agents))
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)),
|
||||
Some(args) => Ok(render_agents_usage_json(Some(args))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_mcp_slash_command(
|
||||
args: Option<&str>,
|
||||
cwd: &Path,
|
||||
@@ -2265,6 +2286,89 @@ 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}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
|
||||
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
||||
if requested.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"skill must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let roots = discover_skill_roots(cwd);
|
||||
for root in &roots {
|
||||
let mut entries = Vec::new();
|
||||
for entry in fs::read_dir(&root.path)? {
|
||||
let entry = entry?;
|
||||
match root.origin {
|
||||
SkillOrigin::SkillsDir => {
|
||||
if !entry.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let skill_path = entry.path().join("SKILL.md");
|
||||
if !skill_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let contents = fs::read_to_string(&skill_path)?;
|
||||
let (name, _) = parse_skill_frontmatter(&contents);
|
||||
entries.push((
|
||||
name.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
|
||||
skill_path,
|
||||
));
|
||||
}
|
||||
SkillOrigin::LegacyCommandsDir => {
|
||||
let path = entry.path();
|
||||
let markdown_path = if path.is_dir() {
|
||||
let skill_path = path.join("SKILL.md");
|
||||
if !skill_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
skill_path
|
||||
} else if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
|
||||
{
|
||||
path
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let contents = fs::read_to_string(&markdown_path)?;
|
||||
let fallback_name = markdown_path.file_stem().map_or_else(
|
||||
|| entry.file_name().to_string_lossy().to_string(),
|
||||
|stem| stem.to_string_lossy().to_string(),
|
||||
);
|
||||
let (name, _) = parse_skill_frontmatter(&contents);
|
||||
entries.push((name.unwrap_or(fallback_name), markdown_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
if let Some((_, path)) = entries
|
||||
.into_iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case(requested))
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("unknown skill: {requested}"),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_mcp_report_for(
|
||||
loader: &ConfigLoader,
|
||||
cwd: &Path,
|
||||
@@ -2444,6 +2548,14 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(claude_config_dir) = env::var("CLAUDE_CONFIG_DIR") {
|
||||
push_unique_root(
|
||||
&mut roots,
|
||||
DefinitionSource::UserClaude,
|
||||
PathBuf::from(claude_config_dir).join(leaf),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(home) = env::var_os("HOME") {
|
||||
let home = PathBuf::from(home);
|
||||
push_unique_root(
|
||||
@@ -2477,6 +2589,18 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
||||
ancestor.join(".claw").join("skills"),
|
||||
SkillOrigin::SkillsDir,
|
||||
);
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::ProjectClaw,
|
||||
ancestor.join(".omc").join("skills"),
|
||||
SkillOrigin::SkillsDir,
|
||||
);
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::ProjectClaw,
|
||||
ancestor.join(".agents").join("skills"),
|
||||
SkillOrigin::SkillsDir,
|
||||
);
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::ProjectCodex,
|
||||
@@ -2549,6 +2673,12 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
||||
home.join(".claw").join("skills"),
|
||||
SkillOrigin::SkillsDir,
|
||||
);
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::UserClaw,
|
||||
home.join(".omc").join("skills"),
|
||||
SkillOrigin::SkillsDir,
|
||||
);
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::UserClaw,
|
||||
@@ -2573,6 +2703,12 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
||||
home.join(".claude").join("skills"),
|
||||
SkillOrigin::SkillsDir,
|
||||
);
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::UserClaude,
|
||||
home.join(".claude").join("skills").join("omc-learned"),
|
||||
SkillOrigin::SkillsDir,
|
||||
);
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::UserClaude,
|
||||
@@ -2581,6 +2717,29 @@ fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(claude_config_dir) = env::var("CLAUDE_CONFIG_DIR") {
|
||||
let claude_config_dir = PathBuf::from(claude_config_dir);
|
||||
let skills_dir = claude_config_dir.join("skills");
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::UserClaude,
|
||||
skills_dir.clone(),
|
||||
SkillOrigin::SkillsDir,
|
||||
);
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::UserClaude,
|
||||
skills_dir.join("omc-learned"),
|
||||
SkillOrigin::SkillsDir,
|
||||
);
|
||||
push_unique_skill_root(
|
||||
&mut roots,
|
||||
DefinitionSource::UserClaude,
|
||||
claude_config_dir.join("commands"),
|
||||
SkillOrigin::LegacyCommandsDir,
|
||||
);
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
@@ -3039,6 +3198,25 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
|
||||
lines.join("\n").trim_end().to_string()
|
||||
}
|
||||
|
||||
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
|
||||
let active = agents
|
||||
.iter()
|
||||
.filter(|agent| agent.shadowed_by.is_none())
|
||||
.count();
|
||||
json!({
|
||||
"kind": "agents",
|
||||
"action": "list",
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"count": agents.len(),
|
||||
"summary": {
|
||||
"total": agents.len(),
|
||||
"active": active,
|
||||
"shadowed": agents.len().saturating_sub(active),
|
||||
},
|
||||
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_detail(agent: &AgentSummary) -> String {
|
||||
let mut parts = vec![agent.name.clone()];
|
||||
if let Some(description) = &agent.description {
|
||||
@@ -3327,13 +3505,28 @@ fn render_agents_usage(unexpected: Option<&str>) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
|
||||
json!({
|
||||
"kind": "agents",
|
||||
"action": "help",
|
||||
"usage": {
|
||||
"slash_command": "/agents [list|help]",
|
||||
"direct_cli": "claw agents [list|help]",
|
||||
"sources": [".claw/agents", "~/.claw/agents", "$CLAW_CONFIG_HOME/agents"],
|
||||
},
|
||||
"unexpected": unexpected,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_skills_usage(unexpected: Option<&str>) -> String {
|
||||
let mut lines = vec![
|
||||
"Skills".to_string(),
|
||||
" Usage /skills [list|install <path>|help]".to_string(),
|
||||
" Direct CLI claw skills [list|install <path>|help]".to_string(),
|
||||
" Usage /skills [list|install <path>|help|<skill> [args]]".to_string(),
|
||||
" Alias /skill".to_string(),
|
||||
" Direct CLI claw skills [list|install <path>|help|<skill> [args]]".to_string(),
|
||||
" Invoke /skills help overview -> $help overview".to_string(),
|
||||
" Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills".to_string(),
|
||||
" Sources .claw/skills, ~/.claw/skills, legacy /commands".to_string(),
|
||||
" Sources .claw/skills, .omc/skills, .agents/skills, .codex/skills, .claude/skills, ~/.claw/skills, ~/.omc/skills, ~/.claude/skills/omc-learned, ~/.codex/skills, ~/.claude/skills, legacy /commands".to_string(),
|
||||
];
|
||||
if let Some(args) = unexpected {
|
||||
lines.push(format!(" Unexpected {args}"));
|
||||
@@ -3346,10 +3539,25 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value {
|
||||
"kind": "skills",
|
||||
"action": "help",
|
||||
"usage": {
|
||||
"slash_command": "/skills [list|install <path>|help]",
|
||||
"direct_cli": "claw skills [list|install <path>|help]",
|
||||
"slash_command": "/skills [list|install <path>|help|<skill> [args]]",
|
||||
"aliases": ["/skill"],
|
||||
"direct_cli": "claw skills [list|install <path>|help|<skill> [args]]",
|
||||
"invoke": "/skills help overview -> $help overview",
|
||||
"install_root": "$CLAW_CONFIG_HOME/skills or ~/.claw/skills",
|
||||
"sources": [".claw/skills", "legacy /commands", "legacy fallback dirs still load automatically"],
|
||||
"sources": [
|
||||
".claw/skills",
|
||||
".omc/skills",
|
||||
".agents/skills",
|
||||
".codex/skills",
|
||||
".claude/skills",
|
||||
"~/.claw/skills",
|
||||
"~/.omc/skills",
|
||||
"~/.claude/skills/omc-learned",
|
||||
"~/.codex/skills",
|
||||
"~/.claude/skills",
|
||||
"legacy /commands",
|
||||
"legacy fallback dirs still load automatically"
|
||||
],
|
||||
},
|
||||
"unexpected": unexpected,
|
||||
})
|
||||
@@ -3478,6 +3686,18 @@ fn definition_source_json(source: DefinitionSource) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_summary_json(agent: &AgentSummary) -> Value {
|
||||
json!({
|
||||
"name": &agent.name,
|
||||
"description": &agent.description,
|
||||
"model": &agent.model,
|
||||
"reasoning_effort": &agent.reasoning_effort,
|
||||
"source": definition_source_json(agent.source),
|
||||
"active": agent.shadowed_by.is_none(),
|
||||
"shadowed_by": agent.shadowed_by.map(definition_source_json),
|
||||
})
|
||||
}
|
||||
|
||||
fn skill_origin_id(origin: SkillOrigin) -> &'static str {
|
||||
match origin {
|
||||
SkillOrigin::SkillsDir => "skills_dir",
|
||||
@@ -3686,19 +3906,23 @@ pub fn handle_slash_command(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
classify_skills_slash_command, handle_agents_slash_command_json,
|
||||
handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command,
|
||||
load_agents_from_roots, load_skills_from_roots, render_agents_report,
|
||||
render_mcp_report_json_for, render_plugins_report, render_skills_report,
|
||||
render_slash_command_help, render_slash_command_help_detail,
|
||||
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
|
||||
validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
||||
render_agents_report_json, render_mcp_report_json_for, render_plugins_report,
|
||||
render_skills_report, render_slash_command_help, render_slash_command_help_detail,
|
||||
resolve_skill_path, resume_supported_slash_commands, slash_command_specs,
|
||||
suggest_slash_commands, validate_slash_command_input, DefinitionSource, SkillOrigin,
|
||||
SkillRoot, SkillSlashDispatch, SlashCommand,
|
||||
};
|
||||
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
||||
use runtime::{
|
||||
CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
|
||||
};
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(label: &str) -> PathBuf {
|
||||
@@ -3709,6 +3933,18 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
|
||||
}
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn restore_env_var(key: &str, original: Option<OsString>) {
|
||||
match original {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_external_plugin(root: &Path, name: &str, version: &str) {
|
||||
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||
fs::write(
|
||||
@@ -4039,24 +4275,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_agents_and_skills_arguments() {
|
||||
fn rejects_invalid_agents_arguments() {
|
||||
// given
|
||||
let agents_input = "/agents show planner";
|
||||
let skills_input = "/skills show help";
|
||||
|
||||
// when
|
||||
let agents_error = parse_error_message(agents_input);
|
||||
let skills_error = parse_error_message(skills_input);
|
||||
|
||||
// then
|
||||
assert!(agents_error.contains(
|
||||
"Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
|
||||
));
|
||||
assert!(agents_error.contains(" Usage /agents [list|help]"));
|
||||
assert!(skills_error.contains(
|
||||
"Unexpected arguments for /skills: show help. Use /skills, /skills list, /skills install <path>, or /skills help."
|
||||
));
|
||||
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/skills help overview"),
|
||||
Ok(Some(SlashCommand::Skills {
|
||||
args: Some("help overview".to_string()),
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
classify_skills_slash_command(Some("help overview")),
|
||||
SkillSlashDispatch::Invoke("$help overview".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
classify_skills_slash_command(Some("install ./skill-pack")),
|
||||
SkillSlashDispatch::Local
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4110,7 +4358,8 @@ mod tests {
|
||||
));
|
||||
assert!(help.contains("aliases: /plugins, /marketplace"));
|
||||
assert!(help.contains("/agents [list|help]"));
|
||||
assert!(help.contains("/skills [list|install <path>|help]"));
|
||||
assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
|
||||
assert!(help.contains("aliases: /skill"));
|
||||
assert_eq!(slash_command_specs().len(), 141);
|
||||
assert!(resume_supported_slash_commands().len() >= 39);
|
||||
}
|
||||
@@ -4353,16 +4602,82 @@ mod tests {
|
||||
|
||||
assert!(report.contains("Agents"));
|
||||
assert!(report.contains("2 active agents"));
|
||||
assert!(report.contains("Project (.claw):"));
|
||||
assert!(report.contains("Project roots:"));
|
||||
assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
|
||||
assert!(report.contains("User (~/.claw):"));
|
||||
assert!(report.contains("(shadowed by Project (.claw)) planner · User planner"));
|
||||
assert!(report.contains("User home roots:"));
|
||||
assert!(report.contains("(shadowed by Project roots) planner · User planner"));
|
||||
assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(user_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_agents_reports_as_json() {
|
||||
let workspace = temp_dir("agents-json-workspace");
|
||||
let project_agents = workspace.join(".codex").join("agents");
|
||||
let user_home = temp_dir("agents-json-home");
|
||||
let user_agents = user_home.join(".codex").join("agents");
|
||||
|
||||
write_agent(
|
||||
&project_agents,
|
||||
"planner",
|
||||
"Project planner",
|
||||
"gpt-5.4",
|
||||
"medium",
|
||||
);
|
||||
write_agent(
|
||||
&project_agents,
|
||||
"verifier",
|
||||
"Verification agent",
|
||||
"gpt-5.4-mini",
|
||||
"high",
|
||||
);
|
||||
write_agent(
|
||||
&user_agents,
|
||||
"planner",
|
||||
"User planner",
|
||||
"gpt-5.4-mini",
|
||||
"high",
|
||||
);
|
||||
|
||||
let roots = vec![
|
||||
(DefinitionSource::ProjectCodex, project_agents),
|
||||
(DefinitionSource::UserCodex, user_agents),
|
||||
];
|
||||
let report = render_agents_report_json(
|
||||
&workspace,
|
||||
&load_agents_from_roots(&roots).expect("agent roots should load"),
|
||||
);
|
||||
|
||||
assert_eq!(report["kind"], "agents");
|
||||
assert_eq!(report["action"], "list");
|
||||
assert_eq!(report["working_directory"], workspace.display().to_string());
|
||||
assert_eq!(report["count"], 3);
|
||||
assert_eq!(report["summary"]["active"], 2);
|
||||
assert_eq!(report["summary"]["shadowed"], 1);
|
||||
assert_eq!(report["agents"][0]["name"], "planner");
|
||||
assert_eq!(report["agents"][0]["model"], "gpt-5.4");
|
||||
assert_eq!(report["agents"][0]["active"], true);
|
||||
assert_eq!(report["agents"][1]["name"], "verifier");
|
||||
assert_eq!(report["agents"][2]["name"], "planner");
|
||||
assert_eq!(report["agents"][2]["active"], false);
|
||||
assert_eq!(report["agents"][2]["shadowed_by"]["id"], "project_claw");
|
||||
|
||||
let help = handle_agents_slash_command_json(Some("help"), &workspace).expect("agents help");
|
||||
assert_eq!(help["kind"], "agents");
|
||||
assert_eq!(help["action"], "help");
|
||||
assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
|
||||
|
||||
let unexpected = handle_agents_slash_command_json(Some("show planner"), &workspace)
|
||||
.expect("agents usage");
|
||||
assert_eq!(unexpected["action"], "help");
|
||||
assert_eq!(unexpected["unexpected"], "show planner");
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(user_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_skills_from_project_and_user_roots() {
|
||||
let workspace = temp_dir("skills-workspace");
|
||||
@@ -4398,17 +4713,36 @@ mod tests {
|
||||
|
||||
assert!(report.contains("Skills"));
|
||||
assert!(report.contains("3 available skills"));
|
||||
assert!(report.contains("Project (.claw):"));
|
||||
assert!(report.contains("Project roots:"));
|
||||
assert!(report.contains("plan · Project planning guidance"));
|
||||
assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
|
||||
assert!(report.contains("User (~/.claw):"));
|
||||
assert!(report.contains("(shadowed by Project (.claw)) plan · User planning guidance"));
|
||||
assert!(report.contains("User home roots:"));
|
||||
assert!(report.contains("(shadowed by Project roots) plan · User planning guidance"));
|
||||
assert!(report.contains("help · Help guidance"));
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(user_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_project_skills_and_legacy_commands_from_shared_registry() {
|
||||
let workspace = temp_dir("resolve-project-skills");
|
||||
let project_skills = workspace.join(".claw").join("skills");
|
||||
let legacy_commands = workspace.join(".claw").join("commands");
|
||||
|
||||
write_skill(&project_skills, "plan", "Project planning guidance");
|
||||
write_legacy_command(&legacy_commands, "handoff", "Legacy handoff guidance");
|
||||
|
||||
assert_eq!(
|
||||
resolve_skill_path(&workspace, "$plan").expect("project skill should resolve"),
|
||||
project_skills.join("plan").join("SKILL.md")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_skill_path(&workspace, "/handoff").expect("legacy command should resolve"),
|
||||
legacy_commands.join("handoff.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_skills_reports_as_json() {
|
||||
let workspace = temp_dir("skills-json-workspace");
|
||||
@@ -4455,9 +4789,10 @@ mod tests {
|
||||
let help = handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help");
|
||||
assert_eq!(help["kind"], "skills");
|
||||
assert_eq!(help["action"], "help");
|
||||
assert_eq!(help["usage"]["aliases"][0], "/skill");
|
||||
assert_eq!(
|
||||
help["usage"]["direct_cli"],
|
||||
"claw skills [list|install <path>|help]"
|
||||
"claw skills [list|install <path>|help|<skill> [args]]"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
@@ -4481,8 +4816,14 @@ mod tests {
|
||||
|
||||
let skills_help =
|
||||
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
|
||||
assert!(skills_help.contains("Usage /skills [list|install <path>|help]"));
|
||||
assert!(skills_help
|
||||
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
|
||||
assert!(skills_help.contains("Alias /skill"));
|
||||
assert!(skills_help.contains("Invoke /skills help overview -> $help overview"));
|
||||
assert!(skills_help.contains("Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills"));
|
||||
assert!(skills_help.contains(".omc/skills"));
|
||||
assert!(skills_help.contains(".agents/skills"));
|
||||
assert!(skills_help.contains("~/.claude/skills/omc-learned"));
|
||||
assert!(skills_help.contains("legacy /commands"));
|
||||
|
||||
let skills_unexpected =
|
||||
@@ -4491,17 +4832,98 @@ mod tests {
|
||||
|
||||
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
|
||||
.expect("nested skills help");
|
||||
assert!(skills_install_help.contains("Usage /skills [list|install <path>|help]"));
|
||||
assert!(skills_install_help
|
||||
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
|
||||
assert!(skills_install_help.contains("Alias /skill"));
|
||||
assert!(skills_install_help.contains("Unexpected install"));
|
||||
|
||||
let skills_unknown_help =
|
||||
super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help");
|
||||
assert!(skills_unknown_help.contains("Usage /skills [list|install <path>|help]"));
|
||||
assert!(skills_unknown_help
|
||||
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
|
||||
assert!(skills_unknown_help.contains("Unexpected show"));
|
||||
|
||||
let skills_help_json =
|
||||
super::handle_skills_slash_command_json(Some("help"), &cwd).expect("skills help json");
|
||||
let sources = skills_help_json["usage"]["sources"]
|
||||
.as_array()
|
||||
.expect("skills help sources");
|
||||
assert_eq!(skills_help_json["usage"]["aliases"][0], "/skill");
|
||||
assert!(sources.iter().any(|value| value == ".omc/skills"));
|
||||
assert!(sources.iter().any(|value| value == ".agents/skills"));
|
||||
assert!(sources.iter().any(|value| value == "~/.omc/skills"));
|
||||
assert!(sources
|
||||
.iter()
|
||||
.any(|value| value == "~/.claude/skills/omc-learned"));
|
||||
|
||||
let _ = fs::remove_dir_all(cwd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_omc_skills_from_project_and_user_compatibility_roots() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let workspace = temp_dir("skills-omc-workspace");
|
||||
let user_home = temp_dir("skills-omc-home");
|
||||
let claude_config_dir = temp_dir("skills-omc-claude-config");
|
||||
let project_omc_skills = workspace.join(".omc").join("skills");
|
||||
let project_agents_skills = workspace.join(".agents").join("skills");
|
||||
let user_omc_skills = user_home.join(".omc").join("skills");
|
||||
let claude_config_skills = claude_config_dir.join("skills");
|
||||
let claude_config_commands = claude_config_dir.join("commands");
|
||||
let learned_skills = claude_config_dir.join("skills").join("omc-learned");
|
||||
let original_home = std::env::var_os("HOME");
|
||||
let original_claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
|
||||
|
||||
write_skill(&project_omc_skills, "hud", "OMC HUD guidance");
|
||||
write_skill(
|
||||
&project_agents_skills,
|
||||
"trace",
|
||||
"Compatibility skill guidance",
|
||||
);
|
||||
write_skill(&user_omc_skills, "cancel", "OMC cancel guidance");
|
||||
write_skill(
|
||||
&claude_config_skills,
|
||||
"statusline",
|
||||
"Claude config skill guidance",
|
||||
);
|
||||
write_legacy_command(
|
||||
&claude_config_commands,
|
||||
"doctor-check",
|
||||
"Claude config command guidance",
|
||||
);
|
||||
write_skill(&learned_skills, "learned", "Learned skill guidance");
|
||||
std::env::set_var("HOME", &user_home);
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir);
|
||||
|
||||
let report = super::handle_skills_slash_command(None, &workspace).expect("skills list");
|
||||
assert!(report.contains("available skills"));
|
||||
assert!(report.contains("hud · OMC HUD guidance"));
|
||||
assert!(report.contains("trace · Compatibility skill guidance"));
|
||||
assert!(report.contains("cancel · OMC cancel guidance"));
|
||||
assert!(report.contains("statusline · Claude config skill guidance"));
|
||||
assert!(report.contains("doctor-check · Claude config command guidance · legacy /commands"));
|
||||
assert!(report.contains("learned · Learned skill guidance"));
|
||||
|
||||
let help =
|
||||
super::handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help");
|
||||
let sources = help["usage"]["sources"]
|
||||
.as_array()
|
||||
.expect("skills help sources");
|
||||
assert_eq!(help["usage"]["aliases"][0], "/skill");
|
||||
assert!(sources.iter().any(|value| value == ".omc/skills"));
|
||||
assert!(sources.iter().any(|value| value == ".agents/skills"));
|
||||
assert!(sources.iter().any(|value| value == "~/.omc/skills"));
|
||||
assert!(sources
|
||||
.iter()
|
||||
.any(|value| value == "~/.claude/skills/omc-learned"));
|
||||
|
||||
restore_env_var("HOME", original_home);
|
||||
restore_env_var("CLAUDE_CONFIG_DIR", original_claude_config_dir);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(user_home);
|
||||
let _ = fs::remove_dir_all(claude_config_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_usage_supports_help_and_unexpected_args() {
|
||||
let cwd = temp_dir("mcp-usage");
|
||||
@@ -4738,7 +5160,7 @@ mod tests {
|
||||
let listed = render_skills_report(
|
||||
&load_skills_from_roots(&roots).expect("installed skills should load"),
|
||||
);
|
||||
assert!(listed.contains("User ($CLAW_CONFIG_HOME):"));
|
||||
assert!(listed.contains("User config roots:"));
|
||||
assert!(listed.contains("help · Helpful skill"));
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
|
||||
@@ -920,6 +920,9 @@ pub enum PluginManifestValidationError {
|
||||
tool_name: String,
|
||||
permission: String,
|
||||
},
|
||||
UnsupportedManifestContract {
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Display for PluginManifestValidationError {
|
||||
@@ -965,6 +968,7 @@ impl Display for PluginManifestValidationError {
|
||||
f,
|
||||
"plugin tool `{tool_name}` requiredPermission `{permission}` must be read-only, workspace-write, or danger-full-access"
|
||||
),
|
||||
Self::UnsupportedManifestContract { detail } => f.write_str(detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1594,10 +1598,73 @@ fn load_manifest_from_path(
|
||||
manifest_path.display()
|
||||
))
|
||||
})?;
|
||||
let raw_manifest: RawPluginManifest = serde_json::from_str(&contents)?;
|
||||
let raw_json: Value = serde_json::from_str(&contents)?;
|
||||
let compatibility_errors = detect_claude_code_manifest_contract_gaps(&raw_json);
|
||||
if !compatibility_errors.is_empty() {
|
||||
return Err(PluginError::ManifestValidation(compatibility_errors));
|
||||
}
|
||||
let raw_manifest: RawPluginManifest = serde_json::from_value(raw_json)?;
|
||||
build_plugin_manifest(root, raw_manifest)
|
||||
}
|
||||
|
||||
fn detect_claude_code_manifest_contract_gaps(
|
||||
raw_manifest: &Value,
|
||||
) -> Vec<PluginManifestValidationError> {
|
||||
let Some(root) = raw_manifest.as_object() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for (field, detail) in [
|
||||
(
|
||||
"skills",
|
||||
"plugin manifest field `skills` uses the Claude Code plugin contract; `claw` does not load plugin-managed skills and instead discovers skills from local roots such as `.claw/skills`, `.omc/skills`, `.agents/skills`, `~/.omc/skills`, and `~/.claude/skills/omc-learned`.",
|
||||
),
|
||||
(
|
||||
"mcpServers",
|
||||
"plugin manifest field `mcpServers` uses the Claude Code plugin contract; `claw` does not import MCP servers from plugin manifests.",
|
||||
),
|
||||
(
|
||||
"agents",
|
||||
"plugin manifest field `agents` uses the Claude Code plugin contract; `claw` does not load plugin-managed agent markdown catalogs from plugin manifests.",
|
||||
),
|
||||
] {
|
||||
if root.contains_key(field) {
|
||||
errors.push(PluginManifestValidationError::UnsupportedManifestContract {
|
||||
detail: detail.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if root
|
||||
.get("commands")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|commands| commands.iter().any(Value::is_string))
|
||||
{
|
||||
errors.push(PluginManifestValidationError::UnsupportedManifestContract {
|
||||
detail: "plugin manifest field `commands` uses Claude Code-style directory globs; `claw` slash dispatch is still built-in and does not load plugin slash command markdown files.".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(hooks) = root.get("hooks").and_then(Value::as_object) {
|
||||
for hook_name in hooks.keys() {
|
||||
if !matches!(
|
||||
hook_name.as_str(),
|
||||
"PreToolUse" | "PostToolUse" | "PostToolUseFailure"
|
||||
) {
|
||||
errors.push(PluginManifestValidationError::UnsupportedManifestContract {
|
||||
detail: format!(
|
||||
"plugin hook `{hook_name}` uses the Claude Code lifecycle contract; `claw` plugins currently support only PreToolUse, PostToolUse, and PostToolUseFailure."
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> {
|
||||
let direct_path = root.join(MANIFEST_FILE_NAME);
|
||||
if direct_path.exists() {
|
||||
@@ -2517,6 +2584,37 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugin_from_directory_rejects_claude_code_manifest_contracts_with_guidance() {
|
||||
let root = temp_dir("manifest-claude-code-contract");
|
||||
write_file(
|
||||
root.join(MANIFEST_FILE_NAME).as_path(),
|
||||
r#"{
|
||||
"name": "oh-my-claudecode",
|
||||
"version": "4.10.2",
|
||||
"description": "Claude Code plugin manifest",
|
||||
"hooks": {
|
||||
"SessionStart": ["scripts/session-start.mjs"]
|
||||
},
|
||||
"agents": ["agents/*.md"],
|
||||
"commands": ["commands/**/*.md"],
|
||||
"skills": "./skills/",
|
||||
"mcpServers": "./.mcp.json"
|
||||
}"#,
|
||||
);
|
||||
|
||||
let error = load_plugin_from_directory(&root)
|
||||
.expect_err("Claude Code plugin manifest should fail with guidance");
|
||||
let rendered = error.to_string();
|
||||
assert!(rendered.contains("field `skills` uses the Claude Code plugin contract"));
|
||||
assert!(rendered.contains("field `mcpServers` uses the Claude Code plugin contract"));
|
||||
assert!(rendered.contains("field `agents` uses the Claude Code plugin contract"));
|
||||
assert!(rendered.contains("field `commands` uses Claude Code-style directory globs"));
|
||||
assert!(rendered.contains("hook `SessionStart` uses the Claude Code lifecycle contract"));
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugin_from_directory_rejects_missing_tool_or_command_paths() {
|
||||
let root = temp_dir("manifest-paths");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,31 @@ fn slash_command_names_match_known_commands_and_suggest_nearby_unknown_ones() {
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omc_namespaced_slash_commands_surface_a_targeted_compatibility_hint() {
|
||||
let temp_dir = unique_temp_dir("slash-dispatch-omc");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(&temp_dir)
|
||||
.arg("/oh-my-claudecode:hud")
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||
assert!(stderr.contains("unknown slash command outside the REPL: /oh-my-claudecode:hud"));
|
||||
assert!(stderr.contains("Claude Code/OMC plugin command"));
|
||||
assert!(stderr.contains("does not yet load plugin slash commands"));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_command_loads_defaults_from_standard_config_locations() {
|
||||
// given
|
||||
|
||||
@@ -50,8 +50,34 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
let root = unique_temp_dir("inventory-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let agents = assert_json_command(&root, &["--output-format", "json", "agents"]);
|
||||
let isolated_home = root.join("home");
|
||||
let isolated_config = root.join("config-home");
|
||||
let isolated_codex = root.join("codex-home");
|
||||
fs::create_dir_all(&isolated_home).expect("isolated home should exist");
|
||||
|
||||
let agents = assert_json_command_with_env(
|
||||
&root,
|
||||
&["--output-format", "json", "agents"],
|
||||
&[
|
||||
("HOME", isolated_home.to_str().expect("utf8 home")),
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
isolated_config.to_str().expect("utf8 config home"),
|
||||
),
|
||||
(
|
||||
"CODEX_HOME",
|
||||
isolated_codex.to_str().expect("utf8 codex home"),
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(agents["kind"], "agents");
|
||||
assert_eq!(agents["action"], "list");
|
||||
assert_eq!(agents["count"], 0);
|
||||
assert_eq!(agents["summary"]["active"], 0);
|
||||
assert!(agents["agents"]
|
||||
.as_array()
|
||||
.expect("agents array")
|
||||
.is_empty());
|
||||
|
||||
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
|
||||
assert_eq!(mcp["kind"], "mcp");
|
||||
@@ -62,6 +88,68 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
assert_eq!(skills["action"], "list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_command_emits_structured_agent_entries_when_requested() {
|
||||
let root = unique_temp_dir("agents-json-populated");
|
||||
let workspace = root.join("workspace");
|
||||
let project_agents = workspace.join(".codex").join("agents");
|
||||
let home = root.join("home");
|
||||
let user_agents = home.join(".codex").join("agents");
|
||||
let isolated_config = root.join("config-home");
|
||||
let isolated_codex = root.join("codex-home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
write_agent(
|
||||
&project_agents,
|
||||
"planner",
|
||||
"Project planner",
|
||||
"gpt-5.4",
|
||||
"medium",
|
||||
);
|
||||
write_agent(
|
||||
&project_agents,
|
||||
"verifier",
|
||||
"Verification agent",
|
||||
"gpt-5.4-mini",
|
||||
"high",
|
||||
);
|
||||
write_agent(
|
||||
&user_agents,
|
||||
"planner",
|
||||
"User planner",
|
||||
"gpt-5.4-mini",
|
||||
"high",
|
||||
);
|
||||
|
||||
let parsed = assert_json_command_with_env(
|
||||
&workspace,
|
||||
&["--output-format", "json", "agents"],
|
||||
&[
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
isolated_config.to_str().expect("utf8 config home"),
|
||||
),
|
||||
(
|
||||
"CODEX_HOME",
|
||||
isolated_codex.to_str().expect("utf8 codex home"),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(parsed["kind"], "agents");
|
||||
assert_eq!(parsed["action"], "list");
|
||||
assert_eq!(parsed["count"], 3);
|
||||
assert_eq!(parsed["summary"]["active"], 2);
|
||||
assert_eq!(parsed["summary"]["shadowed"], 1);
|
||||
assert_eq!(parsed["agents"][0]["name"], "planner");
|
||||
assert_eq!(parsed["agents"][0]["source"]["id"], "project_claw");
|
||||
assert_eq!(parsed["agents"][0]["active"], true);
|
||||
assert_eq!(parsed["agents"][1]["name"], "verifier");
|
||||
assert_eq!(parsed["agents"][2]["name"], "planner");
|
||||
assert_eq!(parsed["agents"][2]["active"], false);
|
||||
assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("bootstrap-system-prompt-json");
|
||||
@@ -112,6 +200,41 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
|
||||
assert_eq!(doctor["kind"], "doctor");
|
||||
assert!(doctor["message"].is_string());
|
||||
let summary = doctor["summary"].as_object().expect("doctor summary");
|
||||
assert!(summary["ok"].as_u64().is_some());
|
||||
assert!(summary["warnings"].as_u64().is_some());
|
||||
assert!(summary["failures"].as_u64().is_some());
|
||||
|
||||
let checks = doctor["checks"].as_array().expect("doctor checks");
|
||||
assert_eq!(checks.len(), 5);
|
||||
let check_names = checks
|
||||
.iter()
|
||||
.map(|check| {
|
||||
assert!(check["status"].as_str().is_some());
|
||||
assert!(check["summary"].as_str().is_some());
|
||||
assert!(check["details"].is_array());
|
||||
check["name"].as_str().expect("doctor check name")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
check_names,
|
||||
vec!["auth", "config", "workspace", "sandbox", "system"]
|
||||
);
|
||||
|
||||
let workspace = checks
|
||||
.iter()
|
||||
.find(|check| check["name"] == "workspace")
|
||||
.expect("workspace check");
|
||||
assert!(workspace["cwd"].as_str().is_some());
|
||||
assert!(workspace["in_git_repo"].is_boolean());
|
||||
|
||||
let sandbox = checks
|
||||
.iter()
|
||||
.find(|check| check["name"] == "sandbox")
|
||||
.expect("sandbox check");
|
||||
assert!(sandbox["filesystem_mode"].as_str().is_some());
|
||||
assert!(sandbox["enabled"].is_boolean());
|
||||
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
|
||||
|
||||
let session_path = root.join("session.jsonl");
|
||||
fs::write(
|
||||
@@ -136,6 +259,104 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
||||
let root = unique_temp_dir("resume-inventory-json");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
let session_path = root.join("session.jsonl");
|
||||
fs::write(
|
||||
&session_path,
|
||||
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-inventory-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"inventory\"}]}}\n",
|
||||
)
|
||||
.expect("session should write");
|
||||
|
||||
let mcp = assert_json_command_with_env(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/mcp",
|
||||
],
|
||||
&[
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
],
|
||||
);
|
||||
assert_eq!(mcp["kind"], "mcp");
|
||||
assert_eq!(mcp["action"], "list");
|
||||
assert!(mcp["servers"].is_array());
|
||||
|
||||
let skills = assert_json_command_with_env(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/skills",
|
||||
],
|
||||
&[
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
],
|
||||
);
|
||||
assert_eq!(skills["kind"], "skills");
|
||||
assert_eq!(skills["action"], "list");
|
||||
assert!(skills["summary"]["total"].is_number());
|
||||
assert!(skills["skills"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_version_and_init_emit_structured_json_when_requested() {
|
||||
let root = unique_temp_dir("resume-version-init-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let session_path = root.join("session.jsonl");
|
||||
fs::write(
|
||||
&session_path,
|
||||
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-version-init-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n",
|
||||
)
|
||||
.expect("session should write");
|
||||
|
||||
let version = assert_json_command(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/version",
|
||||
],
|
||||
);
|
||||
assert_eq!(version["kind"], "version");
|
||||
assert_eq!(version["version"], env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let init = assert_json_command(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/init",
|
||||
],
|
||||
);
|
||||
assert_eq!(init["kind"], "init");
|
||||
assert!(root.join("CLAUDE.md").exists());
|
||||
}
|
||||
|
||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||
assert_json_command_with_env(current_dir, args, &[])
|
||||
}
|
||||
@@ -183,6 +404,17 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
|
||||
upstream
|
||||
}
|
||||
|
||||
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
|
||||
fs::create_dir_all(root).expect("agent root should exist");
|
||||
fs::write(
|
||||
root.join(format!("{name}.toml")),
|
||||
format!(
|
||||
"name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
|
||||
),
|
||||
)
|
||||
.expect("agent fixture should write");
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -275,6 +275,49 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
assert!(parsed["sandbox"]["filesystem_mode"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_sandbox_command_emits_structured_json_when_requested() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-sandbox-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
|
||||
Session::new()
|
||||
.save_to_path(&session_path)
|
||||
.expect("session should persist");
|
||||
|
||||
// when
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/sandbox",
|
||||
],
|
||||
);
|
||||
|
||||
// then
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value =
|
||||
serde_json::from_str(stdout.trim()).expect("resume sandbox output should be json");
|
||||
assert_eq!(parsed["kind"], "sandbox");
|
||||
assert!(parsed["enabled"].is_boolean());
|
||||
assert!(parsed["active"].is_boolean());
|
||||
assert!(parsed["supported"].is_boolean());
|
||||
assert!(parsed["filesystem_mode"].as_str().is_some());
|
||||
assert!(parsed["allowed_mounts"].is_array());
|
||||
assert!(parsed["markers"].is_array());
|
||||
}
|
||||
|
||||
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
||||
run_claw_with_env(current_dir, args, &[])
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
api = { path = "../api" }
|
||||
commands = { path = "../commands" }
|
||||
plugins = { path = "../plugins" }
|
||||
runtime = { path = "../runtime" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
|
||||
+345
-47
@@ -2973,53 +2973,8 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
|
||||
}
|
||||
|
||||
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
|
||||
let requested = skill.trim().trim_start_matches('/').trim_start_matches('$');
|
||||
if requested.is_empty() {
|
||||
return Err(String::from("skill must not be empty"));
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") {
|
||||
candidates.push(std::path::PathBuf::from(claw_config_home).join("skills"));
|
||||
}
|
||||
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
||||
candidates.push(std::path::PathBuf::from(codex_home).join("skills"));
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let home = std::path::PathBuf::from(home);
|
||||
candidates.push(home.join(".claw").join("skills"));
|
||||
candidates.push(home.join(".agents").join("skills"));
|
||||
candidates.push(home.join(".config").join("opencode").join("skills"));
|
||||
candidates.push(home.join(".codex").join("skills"));
|
||||
candidates.push(home.join(".claude").join("skills"));
|
||||
}
|
||||
candidates.push(std::path::PathBuf::from("/home/bellman/.claw/skills"));
|
||||
candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills"));
|
||||
|
||||
for root in candidates {
|
||||
let direct = root.join(requested).join("SKILL.md");
|
||||
if direct.exists() {
|
||||
return Ok(direct);
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(&root) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path().join("SKILL.md");
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
if entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(requested)
|
||||
{
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("unknown skill: {requested}"))
|
||||
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
commands::resolve_skill_path(&cwd, skill).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
|
||||
@@ -5797,6 +5752,349 @@ mod tests {
|
||||
fs::remove_dir_all(home).expect("temp home should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_resolves_project_local_skills_and_legacy_commands() {
|
||||
let _guard = env_lock().lock().expect("env lock should acquire");
|
||||
let root = temp_path("project-skills");
|
||||
let skill_dir = root.join(".claw").join("skills").join("plan");
|
||||
let command_dir = root.join(".claw").join("commands");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir should exist");
|
||||
fs::create_dir_all(&command_dir).expect("command dir should exist");
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: plan\ndescription: Project planning guidance\n---\n\n# plan\n",
|
||||
)
|
||||
.expect("skill file should exist");
|
||||
fs::write(
|
||||
command_dir.join("handoff.md"),
|
||||
"---\nname: handoff\ndescription: Legacy handoff guidance\n---\n\n# handoff\n",
|
||||
)
|
||||
.expect("command file should exist");
|
||||
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_current_dir(&root).expect("set cwd");
|
||||
|
||||
let skill_result = execute_tool("Skill", &json!({ "skill": "$plan" }))
|
||||
.expect("project-local skill should resolve");
|
||||
let skill_output: serde_json::Value =
|
||||
serde_json::from_str(&skill_result).expect("valid json");
|
||||
assert!(skill_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with(".claw/skills/plan/SKILL.md"));
|
||||
|
||||
let command_result = execute_tool("Skill", &json!({ "skill": "/handoff" }))
|
||||
.expect("legacy command should resolve");
|
||||
let command_output: serde_json::Value =
|
||||
serde_json::from_str(&command_result).expect("valid json");
|
||||
assert!(command_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with(".claw/commands/handoff.md"));
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
fs::remove_dir_all(root).expect("temp project should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_loads_project_local_claude_skill_prompt() {
|
||||
let _guard = env_lock().lock().expect("env lock should acquire");
|
||||
let root = temp_path("project-skills");
|
||||
let home = root.join("home");
|
||||
let workspace = root.join("workspace");
|
||||
let nested = workspace.join("nested");
|
||||
let skill_dir = workspace.join(".claude").join("skills").join("trace");
|
||||
fs::create_dir_all(&skill_dir).expect("skill dir should exist");
|
||||
fs::create_dir_all(&nested).expect("nested cwd should exist");
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: trace\ndescription: Project-local trace helper\n---\n# trace\n",
|
||||
)
|
||||
.expect("skill file should exist");
|
||||
|
||||
let original_home = std::env::var("HOME").ok();
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
let original_codex_home = std::env::var("CODEX_HOME").ok();
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_var("HOME", &home);
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
std::env::set_current_dir(&nested).expect("set cwd");
|
||||
|
||||
let result = execute_tool("Skill", &json!({ "skill": "trace" }))
|
||||
.expect("project-local skill should resolve");
|
||||
|
||||
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
|
||||
assert!(output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with(".claude/skills/trace/SKILL.md"));
|
||||
assert_eq!(output["description"], "Project-local trace helper");
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
match original_home {
|
||||
Some(value) => std::env::set_var("HOME", value),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
match original_codex_home {
|
||||
Some(value) => std::env::set_var("CODEX_HOME", value),
|
||||
None => std::env::remove_var("CODEX_HOME"),
|
||||
}
|
||||
fs::remove_dir_all(root).expect("temp tree should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_loads_project_local_omc_and_agents_skill_prompts() {
|
||||
let _guard = env_lock().lock().expect("env lock should acquire");
|
||||
let root = temp_path("project-omc-skills");
|
||||
let home = root.join("home");
|
||||
let workspace = root.join("workspace");
|
||||
let nested = workspace.join("nested");
|
||||
let omc_skill_dir = workspace.join(".omc").join("skills").join("hud");
|
||||
let agents_skill_dir = workspace.join(".agents").join("skills").join("trace");
|
||||
fs::create_dir_all(&omc_skill_dir).expect("omc skill dir should exist");
|
||||
fs::create_dir_all(&agents_skill_dir).expect("agents skill dir should exist");
|
||||
fs::create_dir_all(&nested).expect("nested cwd should exist");
|
||||
fs::write(
|
||||
omc_skill_dir.join("SKILL.md"),
|
||||
"---\nname: hud\ndescription: Project-local OMC HUD helper\n---\n# hud\n",
|
||||
)
|
||||
.expect("omc skill file should exist");
|
||||
fs::write(
|
||||
agents_skill_dir.join("SKILL.md"),
|
||||
"---\nname: trace\ndescription: Project-local agents compatibility helper\n---\n# trace\n",
|
||||
)
|
||||
.expect("agents skill file should exist");
|
||||
|
||||
let original_home = std::env::var("HOME").ok();
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
let original_codex_home = std::env::var("CODEX_HOME").ok();
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_var("HOME", &home);
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
std::env::set_current_dir(&nested).expect("set cwd");
|
||||
|
||||
let omc_result =
|
||||
execute_tool("Skill", &json!({ "skill": "hud" })).expect("omc skill should resolve");
|
||||
let agents_result = execute_tool("Skill", &json!({ "skill": "trace" }))
|
||||
.expect("agents skill should resolve");
|
||||
|
||||
let omc_output: serde_json::Value = serde_json::from_str(&omc_result).expect("valid json");
|
||||
let agents_output: serde_json::Value =
|
||||
serde_json::from_str(&agents_result).expect("valid json");
|
||||
assert!(omc_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with(".omc/skills/hud/SKILL.md"));
|
||||
assert_eq!(omc_output["description"], "Project-local OMC HUD helper");
|
||||
assert!(agents_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with(".agents/skills/trace/SKILL.md"));
|
||||
assert_eq!(
|
||||
agents_output["description"],
|
||||
"Project-local agents compatibility helper"
|
||||
);
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
match original_home {
|
||||
Some(value) => std::env::set_var("HOME", value),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
match original_codex_home {
|
||||
Some(value) => std::env::set_var("CODEX_HOME", value),
|
||||
None => std::env::remove_var("CODEX_HOME"),
|
||||
}
|
||||
fs::remove_dir_all(root).expect("temp tree should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_loads_learned_skill_from_claude_config_dir() {
|
||||
let _guard = env_lock().lock().expect("env lock should acquire");
|
||||
let root = temp_path("claude-config-learned-skill");
|
||||
let home = root.join("home");
|
||||
let claude_config_dir = root.join("claude-config");
|
||||
let learned_skill_dir = claude_config_dir
|
||||
.join("skills")
|
||||
.join("omc-learned")
|
||||
.join("learned");
|
||||
fs::create_dir_all(&learned_skill_dir).expect("learned skill dir should exist");
|
||||
fs::write(
|
||||
learned_skill_dir.join("SKILL.md"),
|
||||
"---\nname: learned\ndescription: Learned OMC skill\n---\n# learned\n",
|
||||
)
|
||||
.expect("learned skill file should exist");
|
||||
|
||||
let original_home = std::env::var("HOME").ok();
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
let original_codex_home = std::env::var("CODEX_HOME").ok();
|
||||
let original_claude_config_dir = std::env::var("CLAUDE_CONFIG_DIR").ok();
|
||||
std::env::set_var("HOME", &home);
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir);
|
||||
|
||||
let result = execute_tool("Skill", &json!({ "skill": "learned" }))
|
||||
.expect("learned skill should resolve");
|
||||
|
||||
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
|
||||
assert!(output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with("skills/omc-learned/learned/SKILL.md"));
|
||||
assert_eq!(output["description"], "Learned OMC skill");
|
||||
|
||||
match original_home {
|
||||
Some(value) => std::env::set_var("HOME", value),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
match original_codex_home {
|
||||
Some(value) => std::env::set_var("CODEX_HOME", value),
|
||||
None => std::env::remove_var("CODEX_HOME"),
|
||||
}
|
||||
match original_claude_config_dir {
|
||||
Some(value) => std::env::set_var("CLAUDE_CONFIG_DIR", value),
|
||||
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
|
||||
}
|
||||
fs::remove_dir_all(root).expect("temp tree should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_loads_direct_skill_and_legacy_command_from_claude_config_dir() {
|
||||
let _guard = env_lock().lock().expect("env lock should acquire");
|
||||
let root = temp_path("claude-config-direct-skill");
|
||||
let home = root.join("home");
|
||||
let claude_config_dir = root.join("claude-config");
|
||||
let skill_dir = claude_config_dir.join("skills").join("statusline");
|
||||
let command_dir = claude_config_dir.join("commands");
|
||||
fs::create_dir_all(&skill_dir).expect("direct skill dir should exist");
|
||||
fs::create_dir_all(&command_dir).expect("command dir should exist");
|
||||
fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: statusline\ndescription: Claude config skill\n---\n# statusline\n",
|
||||
)
|
||||
.expect("direct skill file should exist");
|
||||
fs::write(
|
||||
command_dir.join("doctor-check.md"),
|
||||
"---\nname: doctor-check\ndescription: Claude config command\n---\n# doctor-check\n",
|
||||
)
|
||||
.expect("direct command file should exist");
|
||||
|
||||
let original_home = std::env::var("HOME").ok();
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
let original_codex_home = std::env::var("CODEX_HOME").ok();
|
||||
let original_claude_config_dir = std::env::var("CLAUDE_CONFIG_DIR").ok();
|
||||
std::env::set_var("HOME", &home);
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir);
|
||||
|
||||
let direct_skill =
|
||||
execute_tool("Skill", &json!({ "skill": "statusline" })).expect("direct skill");
|
||||
let direct_skill_output: serde_json::Value =
|
||||
serde_json::from_str(&direct_skill).expect("valid skill json");
|
||||
assert!(direct_skill_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with("skills/statusline/SKILL.md"));
|
||||
assert_eq!(direct_skill_output["description"], "Claude config skill");
|
||||
|
||||
let legacy_command =
|
||||
execute_tool("Skill", &json!({ "skill": "doctor-check" })).expect("direct command");
|
||||
let legacy_command_output: serde_json::Value =
|
||||
serde_json::from_str(&legacy_command).expect("valid command json");
|
||||
assert!(legacy_command_output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with("commands/doctor-check.md"));
|
||||
assert_eq!(
|
||||
legacy_command_output["description"],
|
||||
"Claude config command"
|
||||
);
|
||||
|
||||
match original_home {
|
||||
Some(value) => std::env::set_var("HOME", value),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
match original_codex_home {
|
||||
Some(value) => std::env::set_var("CODEX_HOME", value),
|
||||
None => std::env::remove_var("CODEX_HOME"),
|
||||
}
|
||||
match original_claude_config_dir {
|
||||
Some(value) => std::env::set_var("CLAUDE_CONFIG_DIR", value),
|
||||
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
|
||||
}
|
||||
fs::remove_dir_all(root).expect("temp tree should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_loads_project_local_legacy_command_markdown() {
|
||||
let _guard = env_lock().lock().expect("env lock should acquire");
|
||||
let root = temp_path("project-legacy-command");
|
||||
let home = root.join("home");
|
||||
let workspace = root.join("workspace");
|
||||
let nested = workspace.join("nested");
|
||||
let command_dir = workspace.join(".claude").join("commands");
|
||||
fs::create_dir_all(&command_dir).expect("legacy command dir should exist");
|
||||
fs::create_dir_all(&nested).expect("nested cwd should exist");
|
||||
fs::write(
|
||||
command_dir.join("team.md"),
|
||||
"---\nname: team\ndescription: Legacy team workflow\n---\n# team\n",
|
||||
)
|
||||
.expect("legacy command file should exist");
|
||||
|
||||
let original_home = std::env::var("HOME").ok();
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
let original_codex_home = std::env::var("CODEX_HOME").ok();
|
||||
let original_dir = std::env::current_dir().expect("cwd");
|
||||
std::env::set_var("HOME", &home);
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
std::env::remove_var("CODEX_HOME");
|
||||
std::env::set_current_dir(&nested).expect("set cwd");
|
||||
|
||||
let result = execute_tool("Skill", &json!({ "skill": "team" }))
|
||||
.expect("legacy command markdown should resolve");
|
||||
|
||||
let output: serde_json::Value = serde_json::from_str(&result).expect("valid json");
|
||||
assert!(output["path"]
|
||||
.as_str()
|
||||
.expect("path")
|
||||
.ends_with(".claude/commands/team.md"));
|
||||
assert_eq!(output["description"], "Legacy team workflow");
|
||||
|
||||
std::env::set_current_dir(&original_dir).expect("restore cwd");
|
||||
match original_home {
|
||||
Some(value) => std::env::set_var("HOME", value),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
match original_codex_home {
|
||||
Some(value) => std::env::set_var("CODEX_HOME", value),
|
||||
None => std::env::remove_var("CODEX_HOME"),
|
||||
}
|
||||
fs::remove_dir_all(root).expect("temp tree should clean up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_supports_keyword_and_select_queries() {
|
||||
let keyword = execute_tool(
|
||||
|
||||
Reference in New Issue
Block a user