feat: 合并上游 Rust 实现,扩展 API/运行时/工具链能力

将 claw-code/rust/crates 的完整实现合并到主 workspace,涵盖
  9 个 crate 的更新与 2 个新 crate 的引入。

  API 层:
  - 用原生 Anthropic 客户端(anthropic.rs)替换 claw_provider,
    新增 prompt cache 减少重复请求开销
  - 新增 HTTP 客户端构建器统一代理配置,OpenAI 兼容端增加
    DashScope/Qwen 支持与抖动重试
  - MessageRequest 扩展 temperature/top_p 等模型调参字段
  - SSE 解析器增加 provider 上下文感知的错误信息

  运行时(~11,000 行新增):
  - 新增 bash 命令安全校验、分支锁碰撞检测、配置文件校验
  - 新增会话存储与控制面、MCP 生命周期状态机与服务端实现
  - 新增权限执行引擎、策略引擎、插件生命周期管理
  - 新增 worker 启动编排、任务/定时任务注册表、信任解析器
  - 保留 Windows cmd /C fallback

  命令/插件/工具:
  - commands 大幅重写,扩展 sandbox、doctor、plan 等 slash 命令
  - plugins 新增 PostToolUseFailure hook 与宽容加载机制
  - tools 新增 PDF 提取与 lane 补全工具

  新增 crate:mock-anthropic-service(测试)、telemetry(遥测)

  适配 claw-cli/server:ClawApiClient→AnthropicClient 重命名,
  SlashCommand::parse 返回 Result,移除 session 级 Thinking 变体,
  TokenUsage/ConversationMessage 补充序列化支持
This commit is contained in:
fengmengqi
2026-04-13 14:39:17 +08:00
parent 4a04faf926
commit d8d77824f4
94 changed files with 49049 additions and 4429 deletions
+198 -31
View File
@@ -1,6 +1,4 @@
use std::ffi::OsStr;
#[cfg(not(windows))]
use std::path::Path;
use std::process::Command;
use serde_json::json;
@@ -11,6 +9,7 @@ use crate::{PluginError, PluginHooks, PluginRegistry};
pub enum HookEvent {
PreToolUse,
PostToolUse,
PostToolUseFailure,
}
impl HookEvent {
@@ -18,6 +17,7 @@ impl HookEvent {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::PostToolUseFailure => "PostToolUseFailure",
}
}
}
@@ -25,6 +25,7 @@ impl HookEvent {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
failed: bool,
messages: Vec<String>,
}
@@ -33,6 +34,7 @@ impl HookRunResult {
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
failed: false,
messages,
}
}
@@ -42,6 +44,11 @@ impl HookRunResult {
self.denied
}
#[must_use]
pub fn is_failed(&self) -> bool {
self.failed
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
@@ -65,7 +72,7 @@ impl HookRunner {
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
self.run_commands(
Self::run_commands(
HookEvent::PreToolUse,
&self.hooks.pre_tool_use,
tool_name,
@@ -83,7 +90,7 @@ impl HookRunner {
tool_output: &str,
is_error: bool,
) -> HookRunResult {
self.run_commands(
Self::run_commands(
HookEvent::PostToolUse,
&self.hooks.post_tool_use,
tool_name,
@@ -93,8 +100,24 @@ impl HookRunner {
)
}
fn run_commands(
#[must_use]
pub fn run_post_tool_use_failure(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUseFailure,
&self.hooks.post_tool_use_failure,
tool_name,
tool_input,
Some(tool_error),
true,
)
}
fn run_commands(
event: HookEvent,
commands: &[String],
tool_name: &str,
@@ -106,20 +129,12 @@ impl HookRunner {
return HookRunResult::allow(Vec::new());
}
let payload = json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
})
.to_string();
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
let mut messages = Vec::new();
for command in commands {
match self.run_command(
match Self::run_command(
command,
event,
tool_name,
@@ -139,19 +154,26 @@ impl HookRunner {
}));
return HookRunResult {
denied: true,
failed: false,
messages,
};
}
HookCommandOutcome::Failed { message } => {
messages.push(message);
return HookRunResult {
denied: false,
failed: true,
messages,
};
}
HookCommandOutcome::Warn { message } => messages.push(message),
}
}
HookRunResult::allow(messages)
}
#[allow(clippy::too_many_arguments, clippy::unused_self)]
#[allow(clippy::too_many_arguments)]
fn run_command(
&self,
command: &str,
event: HookEvent,
tool_name: &str,
@@ -180,7 +202,7 @@ impl HookRunner {
match output.status.code() {
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(code) => HookCommandOutcome::Warn {
Some(code) => HookCommandOutcome::Failed {
message: format_hook_warning(
command,
code,
@@ -188,7 +210,7 @@ impl HookRunner {
stderr.as_str(),
),
},
None => HookCommandOutcome::Warn {
None => HookCommandOutcome::Failed {
message: format!(
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
event.as_str()
@@ -196,7 +218,7 @@ impl HookRunner {
},
}
}
Err(error) => HookCommandOutcome::Warn {
Err(error) => HookCommandOutcome::Failed {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
event.as_str()
@@ -209,7 +231,34 @@ impl HookRunner {
enum HookCommandOutcome {
Allow { message: Option<String> },
Deny { message: Option<String> },
Warn { message: String },
Failed { message: String },
}
fn hook_payload(
event: HookEvent,
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> serde_json::Value {
match event {
HookEvent::PostToolUseFailure => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_error": tool_output,
"tool_result_is_error": true,
}),
_ => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
}),
}
}
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
@@ -217,8 +266,7 @@ fn parse_tool_input(tool_input: &str) -> serde_json::Value {
}
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
let mut message =
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
let mut message = format!("Hook `{command}` exited with status {code}");
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
message.push_str(": ");
message.push_str(stdout);
@@ -288,7 +336,28 @@ impl CommandWithStdin {
let mut child = self.command.spawn()?;
if let Some(mut child_stdin) = child.stdin.take() {
use std::io::Write as _;
child_stdin.write_all(stdin)?;
// Tolerate BrokenPipe: a hook script that runs to completion
// (or exits early without reading stdin) closes its stdin
// before the parent finishes writing the JSON payload, and
// the kernel raises EPIPE on the parent's write_all. That is
// not a hook failure — the child still exited cleanly and we
// still need to wait_with_output() to capture stdout/stderr
// and the real exit code. Other write errors (e.g. EIO,
// permission, OOM) still propagate.
//
// This was the root cause of the Linux CI flake on
// hooks::tests::collects_and_runs_hooks_from_enabled_plugins
// (ROADMAP #25, runs 24120271422 / 24120538408 / 24121392171
// / 24121776826): the test hook scripts run in microseconds
// and the parent's stdin write races against child exit.
// macOS pipes happen to buffer the small payload before the
// child exits; Linux pipes do not, so the race shows up
// deterministically on ubuntu runners.
match child_stdin.write_all(stdin) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(error) => return Err(error),
}
}
child.wait_with_output()
}
@@ -310,23 +379,55 @@ mod tests {
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
}
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
fs::create_dir_all(root.join(".claw-plugin")).expect("manifest dir");
fn make_executable(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o755);
fs::set_permissions(path, perms)
.unwrap_or_else(|e| panic!("chmod +x {}: {e}", path.display()));
}
#[cfg(not(unix))]
let _ = path;
}
fn write_hook_plugin(
root: &Path,
name: &str,
pre_message: &str,
post_message: &str,
failure_message: &str,
) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
let pre_path = root.join("hooks").join("pre.sh");
fs::write(
root.join("hooks").join("pre.sh"),
&pre_path,
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
)
.expect("write pre hook");
make_executable(&pre_path);
let post_path = root.join("hooks").join("post.sh");
fs::write(
root.join("hooks").join("post.sh"),
&post_path,
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
)
.expect("write post hook");
make_executable(&post_path);
let failure_path = root.join("hooks").join("failure.sh");
fs::write(
root.join(".claw-plugin").join("plugin.json"),
&failure_path,
format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
)
.expect("write failure hook");
make_executable(&failure_path);
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"],\n \"PostToolUseFailure\": [\"./hooks/failure.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
@@ -334,6 +435,7 @@ mod tests {
#[test]
fn collects_and_runs_hooks_from_enabled_plugins() {
// given
let config_home = temp_dir("config");
let first_source_root = temp_dir("source-a");
let second_source_root = temp_dir("source-b");
@@ -342,12 +444,14 @@ mod tests {
"first",
"plugin pre one",
"plugin post one",
"plugin failure one",
);
write_hook_plugin(
&second_source_root,
"second",
"plugin pre two",
"plugin post two",
"plugin failure two",
);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
@@ -359,8 +463,10 @@ mod tests {
.expect("second plugin install should succeed");
let registry = manager.plugin_registry().expect("registry should build");
// when
let runner = HookRunner::from_registry(&registry).expect("plugin hooks should load");
// then
assert_eq!(
runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
HookRunResult::allow(vec![
@@ -375,6 +481,13 @@ mod tests {
"plugin post two".to_string(),
])
);
assert_eq!(
runner.run_post_tool_use_failure("Read", r#"{"path":"README.md"}"#, "tool failed",),
HookRunResult::allow(vec![
"plugin failure one".to_string(),
"plugin failure two".to_string(),
])
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(first_source_root);
@@ -383,14 +496,68 @@ mod tests {
#[test]
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
// given
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
post_tool_use: Vec::new(),
post_tool_use_failure: Vec::new(),
});
// when
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
// then
assert!(result.is_denied());
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
}
#[test]
fn propagates_plugin_hook_failures() {
// given
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec![
"printf 'broken plugin hook'; exit 1".to_string(),
"printf 'later plugin hook'".to_string(),
],
post_tool_use: Vec::new(),
post_tool_use_failure: Vec::new(),
});
// when
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
// then
assert!(result.is_failed());
assert!(result
.messages()
.iter()
.any(|message| message.contains("broken plugin hook")));
assert!(!result
.messages()
.iter()
.any(|message| message == "later plugin hook"));
}
#[test]
#[cfg(unix)]
fn generated_hook_scripts_are_executable() {
use std::os::unix::fs::PermissionsExt;
// given
let root = temp_dir("exec-guard");
write_hook_plugin(&root, "exec-check", "pre", "post", "fail");
// then
for script in ["pre.sh", "post.sh", "failure.sh"] {
let path = root.join("hooks").join(script);
let mode = fs::metadata(&path)
.unwrap_or_else(|e| panic!("{script} metadata: {e}"))
.permissions()
.mode();
assert!(
mode & 0o111 != 0,
"{script} must have at least one execute bit set, got mode {mode:#o}"
);
}
}
}
+576 -60
View File
@@ -18,7 +18,7 @@ const BUNDLED_MARKETPLACE: &str = "bundled";
const SETTINGS_FILE_NAME: &str = "settings.json";
const REGISTRY_FILE_NAME: &str = "installed.json";
const MANIFEST_FILE_NAME: &str = "plugin.json";
const MANIFEST_RELATIVE_PATH: &str = ".claw-plugin/plugin.json";
const MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -67,12 +67,16 @@ pub struct PluginHooks {
pub pre_tool_use: Vec<String>,
#[serde(rename = "PostToolUse", default)]
pub post_tool_use: Vec<String>,
#[serde(rename = "PostToolUseFailure", default)]
pub post_tool_use_failure: Vec<String>,
}
impl PluginHooks {
#[must_use]
pub fn is_empty(&self) -> bool {
self.pre_tool_use.is_empty() && self.post_tool_use.is_empty()
self.pre_tool_use.is_empty()
&& self.post_tool_use.is_empty()
&& self.post_tool_use_failure.is_empty()
}
#[must_use]
@@ -85,6 +89,9 @@ impl PluginHooks {
.post_tool_use
.extend(other.post_tool_use.iter().cloned());
merged
.post_tool_use_failure
.extend(other.post_tool_use_failure.iter().cloned());
merged
}
}
@@ -302,14 +309,14 @@ impl PluginTool {
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("CLAW_PLUGIN_ID", &self.plugin_id)
.env("CLAW_PLUGIN_NAME", &self.plugin_name)
.env("CLAW_TOOL_NAME", &self.definition.name)
.env("CLAW_TOOL_INPUT", &input_json);
.env("CLAWD_PLUGIN_ID", &self.plugin_id)
.env("CLAWD_PLUGIN_NAME", &self.plugin_name)
.env("CLAWD_TOOL_NAME", &self.definition.name)
.env("CLAWD_TOOL_INPUT", &input_json);
if let Some(root) = &self.root {
process
.current_dir(root)
.env("CLAW_PLUGIN_ROOT", root.display().to_string());
.env("CLAWD_PLUGIN_ROOT", root.display().to_string());
}
let mut child = process.spawn()?;
@@ -648,6 +655,106 @@ pub struct PluginSummary {
pub enabled: bool,
}
#[derive(Debug)]
pub struct PluginLoadFailure {
pub plugin_root: PathBuf,
pub kind: PluginKind,
pub source: String,
error: Box<PluginError>,
}
impl PluginLoadFailure {
#[must_use]
pub fn new(plugin_root: PathBuf, kind: PluginKind, source: String, error: PluginError) -> Self {
Self {
plugin_root,
kind,
source,
error: Box::new(error),
}
}
#[must_use]
pub fn error(&self) -> &PluginError {
self.error.as_ref()
}
}
impl Display for PluginLoadFailure {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"failed to load {} plugin from `{}` (source: {}): {}",
self.kind,
self.plugin_root.display(),
self.source,
self.error()
)
}
}
#[derive(Debug)]
pub struct PluginRegistryReport {
registry: PluginRegistry,
failures: Vec<PluginLoadFailure>,
}
impl PluginRegistryReport {
#[must_use]
pub fn new(registry: PluginRegistry, failures: Vec<PluginLoadFailure>) -> Self {
Self { registry, failures }
}
#[must_use]
pub fn registry(&self) -> &PluginRegistry {
&self.registry
}
#[must_use]
pub fn failures(&self) -> &[PluginLoadFailure] {
&self.failures
}
#[must_use]
pub fn has_failures(&self) -> bool {
!self.failures.is_empty()
}
#[must_use]
pub fn summaries(&self) -> Vec<PluginSummary> {
self.registry.summaries()
}
pub fn into_registry(self) -> Result<PluginRegistry, PluginError> {
if self.failures.is_empty() {
Ok(self.registry)
} else {
Err(PluginError::LoadFailures(self.failures))
}
}
}
#[derive(Debug, Default)]
struct PluginDiscovery {
plugins: Vec<PluginDefinition>,
failures: Vec<PluginLoadFailure>,
}
impl PluginDiscovery {
fn push_plugin(&mut self, plugin: PluginDefinition) {
self.plugins.push(plugin);
}
fn push_failure(&mut self, failure: PluginLoadFailure) {
self.failures.push(failure);
}
fn extend(&mut self, other: Self) {
self.plugins.extend(other.plugins);
self.failures.extend(other.failures);
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PluginRegistry {
plugins: Vec<RegisteredPlugin>,
@@ -802,6 +909,10 @@ pub enum PluginManifestValidationError {
kind: &'static str,
path: PathBuf,
},
PathIsDirectory {
kind: &'static str,
path: PathBuf,
},
InvalidToolInputSchema {
tool_name: String,
},
@@ -809,6 +920,9 @@ pub enum PluginManifestValidationError {
tool_name: String,
permission: String,
},
UnsupportedManifestContract {
detail: String,
},
}
impl Display for PluginManifestValidationError {
@@ -838,6 +952,9 @@ impl Display for PluginManifestValidationError {
Self::MissingPath { kind, path } => {
write!(f, "{kind} path `{}` does not exist", path.display())
}
Self::PathIsDirectory { kind, path } => {
write!(f, "{kind} path `{}` must point to a file", path.display())
}
Self::InvalidToolInputSchema { tool_name } => {
write!(
f,
@@ -851,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),
}
}
}
@@ -860,6 +978,7 @@ pub enum PluginError {
Io(std::io::Error),
Json(serde_json::Error),
ManifestValidation(Vec<PluginManifestValidationError>),
LoadFailures(Vec<PluginLoadFailure>),
InvalidManifest(String),
NotFound(String),
CommandFailed(String),
@@ -879,6 +998,15 @@ impl Display for PluginError {
}
Ok(())
}
Self::LoadFailures(failures) => {
for (index, failure) in failures.iter().enumerate() {
if index > 0 {
write!(f, "; ")?;
}
write!(f, "{failure}")?;
}
Ok(())
}
Self::InvalidManifest(message)
| Self::NotFound(message)
| Self::CommandFailed(message) => write!(f, "{message}"),
@@ -935,15 +1063,23 @@ impl PluginManager {
}
pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
Ok(PluginRegistry::new(
self.discover_plugins()?
.into_iter()
.map(|plugin| {
let enabled = self.is_enabled(plugin.metadata());
RegisteredPlugin::new(plugin, enabled)
})
.collect(),
))
self.plugin_registry_report()?.into_registry()
}
pub fn plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
self.sync_bundled_plugins()?;
let mut discovery = PluginDiscovery::default();
discovery.plugins.extend(builtin_plugins());
let installed = self.discover_installed_plugins_with_failures()?;
discovery.extend(installed);
let external =
self.discover_external_directory_plugins_with_failures(&discovery.plugins)?;
discovery.extend(external);
Ok(self.build_registry_report(discovery))
}
pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
@@ -955,11 +1091,12 @@ impl PluginManager {
}
pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
self.sync_bundled_plugins()?;
let mut plugins = builtin_plugins();
plugins.extend(self.discover_installed_plugins()?);
plugins.extend(self.discover_external_directory_plugins(&plugins)?);
Ok(plugins)
Ok(self
.plugin_registry()?
.plugins
.into_iter()
.map(|plugin| plugin.definition)
.collect())
}
pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
@@ -1094,9 +1231,9 @@ impl PluginManager {
})
}
fn discover_installed_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
fn discover_installed_plugins_with_failures(&self) -> Result<PluginDiscovery, PluginError> {
let mut registry = self.load_registry()?;
let mut plugins = Vec::new();
let mut discovery = PluginDiscovery::default();
let mut seen_ids = BTreeSet::<String>::new();
let mut seen_paths = BTreeSet::<PathBuf>::new();
let mut stale_registry_ids = Vec::new();
@@ -1111,10 +1248,21 @@ impl PluginManager {
|| install_path.display().to_string(),
|record| describe_install_source(&record.source),
);
let plugin = load_plugin_definition(&install_path, kind, source, kind.marketplace())?;
if seen_ids.insert(plugin.metadata().id.clone()) {
seen_paths.insert(install_path);
plugins.push(plugin);
match load_plugin_definition(&install_path, kind, source.clone(), kind.marketplace()) {
Ok(plugin) => {
if seen_ids.insert(plugin.metadata().id.clone()) {
seen_paths.insert(install_path);
discovery.push_plugin(plugin);
}
}
Err(error) => {
discovery.push_failure(PluginLoadFailure::new(
install_path,
kind,
source,
error,
));
}
}
}
@@ -1127,15 +1275,27 @@ impl PluginManager {
stale_registry_ids.push(record.id.clone());
continue;
}
let plugin = load_plugin_definition(
let source = describe_install_source(&record.source);
match load_plugin_definition(
&record.install_path,
record.kind,
describe_install_source(&record.source),
source.clone(),
record.kind.marketplace(),
)?;
if seen_ids.insert(plugin.metadata().id.clone()) {
seen_paths.insert(record.install_path.clone());
plugins.push(plugin);
) {
Ok(plugin) => {
if seen_ids.insert(plugin.metadata().id.clone()) {
seen_paths.insert(record.install_path.clone());
discovery.push_plugin(plugin);
}
}
Err(error) => {
discovery.push_failure(PluginLoadFailure::new(
record.install_path.clone(),
record.kind,
source,
error,
));
}
}
}
@@ -1146,47 +1306,51 @@ impl PluginManager {
self.store_registry(&registry)?;
}
Ok(plugins)
Ok(discovery)
}
fn discover_external_directory_plugins(
fn discover_external_directory_plugins_with_failures(
&self,
existing_plugins: &[PluginDefinition],
) -> Result<Vec<PluginDefinition>, PluginError> {
let mut plugins = Vec::new();
) -> Result<PluginDiscovery, PluginError> {
let mut discovery = PluginDiscovery::default();
for directory in &self.config.external_dirs {
for root in discover_plugin_dirs(directory)? {
let plugin = load_plugin_definition(
let source = root.display().to_string();
match load_plugin_definition(
&root,
PluginKind::External,
root.display().to_string(),
source.clone(),
EXTERNAL_MARKETPLACE,
)?;
if existing_plugins
.iter()
.chain(plugins.iter())
.all(|existing| existing.metadata().id != plugin.metadata().id)
{
plugins.push(plugin);
) {
Ok(plugin) => {
if existing_plugins
.iter()
.chain(discovery.plugins.iter())
.all(|existing| existing.metadata().id != plugin.metadata().id)
{
discovery.push_plugin(plugin);
}
}
Err(error) => {
discovery.push_failure(PluginLoadFailure::new(
root,
PluginKind::External,
source,
error,
));
}
}
}
}
Ok(plugins)
Ok(discovery)
}
fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
pub fn installed_plugin_registry_report(&self) -> Result<PluginRegistryReport, PluginError> {
self.sync_bundled_plugins()?;
Ok(PluginRegistry::new(
self.discover_installed_plugins()?
.into_iter()
.map(|plugin| {
let enabled = self.is_enabled(plugin.metadata());
RegisteredPlugin::new(plugin, enabled)
})
.collect(),
))
Ok(self.build_registry_report(self.discover_installed_plugins_with_failures()?))
}
fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
@@ -1332,6 +1496,26 @@ impl PluginManager {
}
})
}
fn installed_plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
self.installed_plugin_registry_report()?.into_registry()
}
fn build_registry_report(&self, discovery: PluginDiscovery) -> PluginRegistryReport {
PluginRegistryReport::new(
PluginRegistry::new(
discovery
.plugins
.into_iter()
.map(|plugin| {
let enabled = self.is_enabled(plugin.metadata());
RegisteredPlugin::new(plugin, enabled)
})
.collect(),
),
discovery.failures,
)
}
}
#[must_use]
@@ -1414,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() {
@@ -1449,6 +1696,12 @@ fn build_plugin_manifest(
let permissions = build_manifest_permissions(&raw.permissions, &mut errors);
validate_command_entries(root, raw.hooks.pre_tool_use.iter(), "hook", &mut errors);
validate_command_entries(root, raw.hooks.post_tool_use.iter(), "hook", &mut errors);
validate_command_entries(
root,
raw.hooks.post_tool_use_failure.iter(),
"hook",
&mut errors,
);
validate_command_entries(
root,
raw.lifecycle.init.iter(),
@@ -1676,6 +1929,8 @@ fn validate_command_entry(
};
if !path.exists() {
errors.push(PluginManifestValidationError::MissingPath { kind, path });
} else if !path.is_file() {
errors.push(PluginManifestValidationError::PathIsDirectory { kind, path });
}
}
@@ -1691,6 +1946,11 @@ fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
.iter()
.map(|entry| resolve_hook_entry(root, entry))
.collect(),
post_tool_use_failure: hooks
.post_tool_use_failure
.iter()
.map(|entry| resolve_hook_entry(root, entry))
.collect(),
}
}
@@ -1739,7 +1999,12 @@ fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), P
let Some(root) = root else {
return Ok(());
};
for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) {
for entry in hooks
.pre_tool_use
.iter()
.chain(hooks.post_tool_use.iter())
.chain(hooks.post_tool_use_failure.iter())
{
validate_command_path(root, entry, "hook")?;
}
Ok(())
@@ -1783,6 +2048,12 @@ fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), Plu
path.display()
)));
}
if !path.is_file() {
return Err(PluginError::InvalidManifest(format!(
"{kind} path `{}` must point to a file",
path.display()
)));
}
Ok(())
}
@@ -2094,6 +2365,30 @@ mod tests {
);
}
fn write_directory_path_plugin(root: &Path, name: &str) {
fs::create_dir_all(root.join("hooks").join("pre-dir")).expect("hook dir");
fs::create_dir_all(root.join("tools").join("tool-dir")).expect("tool dir");
fs::create_dir_all(root.join("commands").join("sync-dir")).expect("command dir");
fs::create_dir_all(root.join("lifecycle").join("init-dir")).expect("lifecycle dir");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"directory path plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre-dir\"]\n }},\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init-dir\"]\n }},\n \"tools\": [\n {{\n \"name\": \"dir_tool\",\n \"description\": \"Directory tool\",\n \"inputSchema\": {{\"type\": \"object\"}},\n \"command\": \"./tools/tool-dir\"\n }}\n ],\n \"commands\": [\n {{\n \"name\": \"sync\",\n \"description\": \"Directory command\",\n \"command\": \"./commands/sync-dir\"\n }}\n ]\n}}"
)
.as_str(),
);
}
fn write_broken_failure_hook_plugin(root: &Path, name: &str) {
write_file(
root.join(MANIFEST_RELATIVE_PATH).as_path(),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"broken plugin\",\n \"hooks\": {{\n \"PostToolUseFailure\": [\"./hooks/missing-failure.sh\"]\n }}\n}}"
)
.as_str(),
);
}
fn write_lifecycle_plugin(root: &Path, name: &str, version: &str) -> PathBuf {
let log_path = root.join("lifecycle.log");
write_file(
@@ -2122,7 +2417,7 @@ mod tests {
let script_path = root.join("tools").join("echo-json.sh");
write_file(
&script_path,
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAW_PLUGIN_ID\" \"$CLAW_TOOL_NAME\" \"$INPUT\"\n",
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
);
#[cfg(unix)]
{
@@ -2289,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");
@@ -2315,6 +2641,90 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_missing_lifecycle_paths() {
// given
let root = temp_dir("manifest-lifecycle-paths");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "missing-lifecycle-paths",
"version": "1.0.0",
"description": "Missing lifecycle path validation",
"lifecycle": {
"Init": ["./lifecycle/init.sh"],
"Shutdown": ["./lifecycle/shutdown.sh"]
}
}"#,
);
// when
let error =
load_plugin_from_directory(&root).expect_err("missing lifecycle paths should fail");
// then
match error {
PluginError::ManifestValidation(errors) => {
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::MissingPath { kind, path }
if *kind == "lifecycle command"
&& path.ends_with(Path::new("lifecycle/init.sh"))
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::MissingPath { kind, path }
if *kind == "lifecycle command"
&& path.ends_with(Path::new("lifecycle/shutdown.sh"))
)));
}
other => panic!("expected manifest validation errors, got {other}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_directory_command_paths() {
// given
let root = temp_dir("manifest-directory-paths");
write_directory_path_plugin(&root, "directory-paths");
// when
let error =
load_plugin_from_directory(&root).expect_err("directory command paths should fail");
// then
match error {
PluginError::ManifestValidation(errors) => {
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::PathIsDirectory { kind, path }
if *kind == "hook" && path.ends_with(Path::new("hooks/pre-dir"))
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::PathIsDirectory { kind, path }
if *kind == "lifecycle command"
&& path.ends_with(Path::new("lifecycle/init-dir"))
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::PathIsDirectory { kind, path }
if *kind == "tool" && path.ends_with(Path::new("tools/tool-dir"))
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::PathIsDirectory { kind, path }
if *kind == "command" && path.ends_with(Path::new("commands/sync-dir"))
)));
}
other => panic!("expected manifest validation errors, got {other}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_invalid_permissions() {
let root = temp_dir("manifest-invalid-permissions");
@@ -2806,16 +3216,95 @@ mod tests {
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn plugin_registry_report_collects_load_failures_without_dropping_valid_plugins() {
// given
let config_home = temp_dir("report-home");
let external_root = temp_dir("report-external");
write_external_plugin(&external_root.join("valid"), "valid-report", "1.0.0");
write_broken_plugin(&external_root.join("broken"), "broken-report");
let mut config = PluginManagerConfig::new(&config_home);
config.external_dirs = vec![external_root.clone()];
let manager = PluginManager::new(config);
// when
let report = manager
.plugin_registry_report()
.expect("report should tolerate invalid external plugins");
// then
assert!(report.registry().contains("valid-report@external"));
assert_eq!(report.failures().len(), 1);
assert_eq!(report.failures()[0].kind, PluginKind::External);
assert!(report.failures()[0]
.plugin_root
.ends_with(Path::new("broken")));
assert!(report.failures()[0]
.error()
.to_string()
.contains("does not exist"));
let error = manager
.plugin_registry()
.expect_err("strict registry should surface load failures");
match error {
PluginError::LoadFailures(failures) => {
assert_eq!(failures.len(), 1);
assert!(failures[0].plugin_root.ends_with(Path::new("broken")));
}
other => panic!("expected load failures, got {other}"),
}
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(external_root);
}
#[test]
fn installed_plugin_registry_report_collects_load_failures_from_install_root() {
// given
let config_home = temp_dir("installed-report-home");
let bundled_root = temp_dir("installed-report-bundled");
let install_root = config_home.join("plugins").join("installed");
write_external_plugin(&install_root.join("valid"), "installed-valid", "1.0.0");
write_broken_plugin(&install_root.join("broken"), "installed-broken");
let mut config = PluginManagerConfig::new(&config_home);
config.bundled_root = Some(bundled_root.clone());
config.install_root = Some(install_root);
let manager = PluginManager::new(config);
// when
let report = manager
.installed_plugin_registry_report()
.expect("installed report should tolerate invalid installed plugins");
// then
assert!(report.registry().contains("installed-valid@external"));
assert_eq!(report.failures().len(), 1);
assert!(report.failures()[0]
.plugin_root
.ends_with(Path::new("broken")));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(bundled_root);
}
#[test]
fn rejects_plugin_sources_with_missing_hook_paths() {
// given
let config_home = temp_dir("broken-home");
let source_root = temp_dir("broken-source");
write_broken_plugin(&source_root, "broken");
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
// when
let error = manager
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
.expect_err("missing hook file should fail validation");
// then
assert!(error.to_string().contains("does not exist"));
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
@@ -2828,6 +3317,33 @@ mod tests {
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn rejects_plugin_sources_with_missing_failure_hook_paths() {
// given
let config_home = temp_dir("broken-failure-home");
let source_root = temp_dir("broken-failure-source");
write_broken_failure_hook_plugin(&source_root, "broken-failure");
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
// when
let error = manager
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
.expect_err("missing failure hook file should fail validation");
// then
assert!(error.to_string().contains("does not exist"));
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let install_error = manager
.install(source_root.to_str().expect("utf8 path"))
.expect_err("install should reject invalid failure hook paths");
assert!(install_error.to_string().contains("does not exist"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
let config_home = temp_dir("lifecycle-home");