mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-03 05:41:53 +08:00
feat: plugin hooks + tool registry + CLI integration
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
use plugins::{PluginError, PluginKind, PluginManager, PluginSummary};
|
use plugins::{PluginError, PluginManager, PluginSummary};
|
||||||
use runtime::{compact_session, CompactionConfig, Session};
|
use runtime::{compact_session, CompactionConfig, Session};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -134,7 +134,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
name: "plugins",
|
name: "plugins",
|
||||||
summary: "List or manage plugins",
|
summary: "List or manage plugins",
|
||||||
argument_hint: Some(
|
argument_hint: Some(
|
||||||
"[list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]",
|
"[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
|
||||||
),
|
),
|
||||||
resume_supported: false,
|
resume_supported: false,
|
||||||
},
|
},
|
||||||
@@ -278,6 +278,7 @@ pub struct PluginsCommandResult {
|
|||||||
pub reload_runtime: bool,
|
pub reload_runtime: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn handle_plugins_slash_command(
|
pub fn handle_plugins_slash_command(
|
||||||
action: Option<&str>,
|
action: Option<&str>,
|
||||||
target: Option<&str>,
|
target: Option<&str>,
|
||||||
@@ -397,15 +398,14 @@ pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
let kind = match plugin.metadata.kind {
|
let enabled = if plugin.enabled {
|
||||||
PluginKind::Builtin => "builtin",
|
"enabled"
|
||||||
PluginKind::Bundled => "bundled",
|
} else {
|
||||||
PluginKind::External => "external",
|
"disabled"
|
||||||
};
|
};
|
||||||
let enabled = if plugin.enabled { "enabled" } else { "disabled" };
|
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
" {id:<24} {kind:<8} {enabled:<8} v{version}",
|
" {name:<20} v{version:<10} {enabled}",
|
||||||
id = plugin.metadata.id,
|
name = plugin.metadata.name,
|
||||||
version = plugin.metadata.version,
|
version = plugin.metadata.version,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -606,6 +606,20 @@ mod tests {
|
|||||||
target: None
|
target: None
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/plugins enable demo"),
|
||||||
|
Some(SlashCommand::Plugins {
|
||||||
|
action: Some("enable".to_string()),
|
||||||
|
target: Some("demo".to_string())
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/plugins disable demo"),
|
||||||
|
Some(SlashCommand::Plugins {
|
||||||
|
action: Some("disable".to_string()),
|
||||||
|
target: Some("demo".to_string())
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -628,7 +642,7 @@ mod tests {
|
|||||||
assert!(help.contains("/export [file]"));
|
assert!(help.contains("/export [file]"));
|
||||||
assert!(help.contains("/session [list|switch <session-id>]"));
|
assert!(help.contains("/session [list|switch <session-id>]"));
|
||||||
assert!(help.contains(
|
assert!(help.contains(
|
||||||
"/plugins [list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]"
|
"/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
||||||
));
|
));
|
||||||
assert_eq!(slash_command_specs().len(), 16);
|
assert_eq!(slash_command_specs().len(), 16);
|
||||||
assert_eq!(resume_supported_slash_commands().len(), 11);
|
assert_eq!(resume_supported_slash_commands().len(), 11);
|
||||||
@@ -748,10 +762,10 @@ mod tests {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert!(rendered.contains("demo@external"));
|
assert!(rendered.contains("demo"));
|
||||||
assert!(rendered.contains("v1.2.3"));
|
assert!(rendered.contains("v1.2.3"));
|
||||||
assert!(rendered.contains("enabled"));
|
assert!(rendered.contains("enabled"));
|
||||||
assert!(rendered.contains("sample@external"));
|
assert!(rendered.contains("sample"));
|
||||||
assert!(rendered.contains("v0.9.0"));
|
assert!(rendered.contains("v0.9.0"));
|
||||||
assert!(rendered.contains("disabled"));
|
assert!(rendered.contains("disabled"));
|
||||||
}
|
}
|
||||||
@@ -778,7 +792,7 @@ mod tests {
|
|||||||
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
||||||
.expect("list command should succeed");
|
.expect("list command should succeed");
|
||||||
assert!(!list.reload_runtime);
|
assert!(!list.reload_runtime);
|
||||||
assert!(list.message.contains("demo@external"));
|
assert!(list.message.contains("demo"));
|
||||||
assert!(list.message.contains("v1.0.0"));
|
assert!(list.message.contains("v1.0.0"));
|
||||||
assert!(list.message.contains("enabled"));
|
assert!(list.message.contains("enabled"));
|
||||||
|
|
||||||
@@ -809,7 +823,7 @@ mod tests {
|
|||||||
|
|
||||||
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
||||||
.expect("list command should succeed");
|
.expect("list command should succeed");
|
||||||
assert!(list.message.contains("demo@external"));
|
assert!(list.message.contains("demo"));
|
||||||
assert!(list.message.contains("disabled"));
|
assert!(list.message.contains("disabled"));
|
||||||
|
|
||||||
let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
|
let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
|
||||||
@@ -821,7 +835,7 @@ mod tests {
|
|||||||
|
|
||||||
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
||||||
.expect("list command should succeed");
|
.expect("list command should succeed");
|
||||||
assert!(list.message.contains("demo@external"));
|
assert!(list.message.contains("demo"));
|
||||||
assert!(list.message.contains("enabled"));
|
assert!(list.message.contains("enabled"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(config_home);
|
let _ = fs::remove_dir_all(config_home);
|
||||||
@@ -842,8 +856,8 @@ mod tests {
|
|||||||
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
||||||
.expect("list command should succeed");
|
.expect("list command should succeed");
|
||||||
assert!(!list.reload_runtime);
|
assert!(!list.reload_runtime);
|
||||||
assert!(list.message.contains("starter@bundled"));
|
assert!(list.message.contains("starter"));
|
||||||
assert!(list.message.contains("bundled"));
|
assert!(list.message.contains("v0.1.0"));
|
||||||
assert!(list.message.contains("disabled"));
|
assert!(list.message.contains("disabled"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(config_home);
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
|||||||
@@ -133,11 +133,9 @@ impl HookRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
HookCommandOutcome::Deny { message } => {
|
HookCommandOutcome::Deny { message } => {
|
||||||
messages.push(
|
messages.push(message.unwrap_or_else(|| {
|
||||||
message.unwrap_or_else(|| {
|
|
||||||
format!("{} hook denied tool `{tool_name}`", event.as_str())
|
format!("{} hook denied tool `{tool_name}`", event.as_str())
|
||||||
}),
|
}));
|
||||||
);
|
|
||||||
return HookRunResult {
|
return HookRunResult {
|
||||||
denied: true,
|
denied: true,
|
||||||
messages,
|
messages,
|
||||||
@@ -150,7 +148,7 @@ impl HookRunner {
|
|||||||
HookRunResult::allow(messages)
|
HookRunResult::allow(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments, clippy::unused_self)]
|
||||||
fn run_command(
|
fn run_command(
|
||||||
&self,
|
&self,
|
||||||
command: &str,
|
command: &str,
|
||||||
@@ -338,8 +336,18 @@ mod tests {
|
|||||||
let config_home = temp_dir("config");
|
let config_home = temp_dir("config");
|
||||||
let first_source_root = temp_dir("source-a");
|
let first_source_root = temp_dir("source-a");
|
||||||
let second_source_root = temp_dir("source-b");
|
let second_source_root = temp_dir("source-b");
|
||||||
write_hook_plugin(&first_source_root, "first", "plugin pre one", "plugin post one");
|
write_hook_plugin(
|
||||||
write_hook_plugin(&second_source_root, "second", "plugin pre two", "plugin post two");
|
&first_source_root,
|
||||||
|
"first",
|
||||||
|
"plugin pre one",
|
||||||
|
"plugin post one",
|
||||||
|
);
|
||||||
|
write_hook_plugin(
|
||||||
|
&second_source_root,
|
||||||
|
"second",
|
||||||
|
"plugin pre two",
|
||||||
|
"plugin post two",
|
||||||
|
);
|
||||||
|
|
||||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
manager
|
manager
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
mod hooks;
|
mod hooks;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -38,7 +38,6 @@ impl Display for PluginKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl PluginKind {
|
impl PluginKind {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
fn marketplace(self) -> &'static str {
|
fn marketplace(self) -> &'static str {
|
||||||
@@ -109,8 +108,7 @@ pub struct PluginManifest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default)]
|
pub permissions: Vec<PluginPermission>,
|
||||||
pub permissions: Vec<String>,
|
|
||||||
#[serde(rename = "defaultEnabled", default)]
|
#[serde(rename = "defaultEnabled", default)]
|
||||||
pub default_enabled: bool,
|
pub default_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -123,6 +121,34 @@ pub struct PluginManifest {
|
|||||||
pub commands: Vec<PluginCommandManifest>,
|
pub commands: Vec<PluginCommandManifest>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PluginPermission {
|
||||||
|
Read,
|
||||||
|
Write,
|
||||||
|
Execute,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginPermission {
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Read => "read",
|
||||||
|
Self::Write => "write",
|
||||||
|
Self::Execute => "execute",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"read" => Some(Self::Read),
|
||||||
|
"write" => Some(Self::Write),
|
||||||
|
"execute" => Some(Self::Execute),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct PluginToolManifest {
|
pub struct PluginToolManifest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -132,8 +158,35 @@ pub struct PluginToolManifest {
|
|||||||
pub command: String,
|
pub command: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
#[serde(rename = "requiredPermission", default = "default_tool_permission")]
|
pub required_permission: PluginToolPermission,
|
||||||
pub required_permission: String,
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum PluginToolPermission {
|
||||||
|
ReadOnly,
|
||||||
|
WorkspaceWrite,
|
||||||
|
DangerFullAccess,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginToolPermission {
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::ReadOnly => "read-only",
|
||||||
|
Self::WorkspaceWrite => "workspace-write",
|
||||||
|
Self::DangerFullAccess => "danger-full-access",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"read-only" => Some(Self::ReadOnly),
|
||||||
|
"workspace-write" => Some(Self::WorkspaceWrite),
|
||||||
|
"danger-full-access" => Some(Self::DangerFullAccess),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -152,6 +205,38 @@ pub struct PluginCommandManifest {
|
|||||||
pub command: String,
|
pub command: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct RawPluginManifest {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
#[serde(rename = "defaultEnabled", default)]
|
||||||
|
pub default_enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hooks: PluginHooks,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lifecycle: PluginLifecycle,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tools: Vec<RawPluginToolManifest>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub commands: Vec<PluginCommandManifest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
struct RawPluginToolManifest {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(rename = "inputSchema")]
|
||||||
|
pub input_schema: Value,
|
||||||
|
pub command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
#[serde(rename = "requiredPermission", default = "default_raw_tool_permission")]
|
||||||
|
pub required_permission: String,
|
||||||
|
}
|
||||||
|
|
||||||
type PluginPackageManifest = PluginManifest;
|
type PluginPackageManifest = PluginManifest;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -161,7 +246,7 @@ pub struct PluginTool {
|
|||||||
definition: PluginToolDefinition,
|
definition: PluginToolDefinition,
|
||||||
command: String,
|
command: String,
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
required_permission: String,
|
required_permission: PluginToolPermission,
|
||||||
root: Option<PathBuf>,
|
root: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +258,7 @@ impl PluginTool {
|
|||||||
definition: PluginToolDefinition,
|
definition: PluginToolDefinition,
|
||||||
command: impl Into<String>,
|
command: impl Into<String>,
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
required_permission: impl Into<String>,
|
required_permission: PluginToolPermission,
|
||||||
root: Option<PathBuf>,
|
root: Option<PathBuf>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -182,7 +267,7 @@ impl PluginTool {
|
|||||||
definition,
|
definition,
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
args,
|
args,
|
||||||
required_permission: required_permission.into(),
|
required_permission,
|
||||||
root,
|
root,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +284,7 @@ impl PluginTool {
|
|||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn required_permission(&self) -> &str {
|
pub fn required_permission(&self) -> &str {
|
||||||
&self.required_permission
|
self.required_permission.as_str()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn execute(&self, input: &Value) -> Result<String, PluginError> {
|
pub fn execute(&self, input: &Value) -> Result<String, PluginError> {
|
||||||
@@ -246,7 +331,7 @@ impl PluginTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_tool_permission() -> String {
|
fn default_raw_tool_permission() -> String {
|
||||||
"danger-full-access".to_string()
|
"danger-full-access".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,10 +771,74 @@ pub struct UpdateOutcome {
|
|||||||
pub install_path: PathBuf,
|
pub install_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PluginManifestValidationError {
|
||||||
|
EmptyField { field: &'static str },
|
||||||
|
EmptyEntryField {
|
||||||
|
kind: &'static str,
|
||||||
|
field: &'static str,
|
||||||
|
name: Option<String>,
|
||||||
|
},
|
||||||
|
InvalidPermission { permission: String },
|
||||||
|
DuplicatePermission { permission: String },
|
||||||
|
DuplicateEntry { kind: &'static str, name: String },
|
||||||
|
MissingPath { kind: &'static str, path: PathBuf },
|
||||||
|
InvalidToolInputSchema { tool_name: String },
|
||||||
|
InvalidToolRequiredPermission {
|
||||||
|
tool_name: String,
|
||||||
|
permission: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for PluginManifestValidationError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::EmptyField { field } => {
|
||||||
|
write!(f, "plugin manifest {field} cannot be empty")
|
||||||
|
}
|
||||||
|
Self::EmptyEntryField { kind, field, name } => match name {
|
||||||
|
Some(name) if !name.is_empty() => {
|
||||||
|
write!(f, "plugin {kind} `{name}` {field} cannot be empty")
|
||||||
|
}
|
||||||
|
_ => write!(f, "plugin {kind} {field} cannot be empty"),
|
||||||
|
},
|
||||||
|
Self::InvalidPermission { permission } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"plugin manifest permission `{permission}` must be one of read, write, or execute"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::DuplicatePermission { permission } => {
|
||||||
|
write!(f, "plugin manifest permission `{permission}` is duplicated")
|
||||||
|
}
|
||||||
|
Self::DuplicateEntry { kind, name } => {
|
||||||
|
write!(f, "plugin {kind} `{name}` is duplicated")
|
||||||
|
}
|
||||||
|
Self::MissingPath { kind, path } => {
|
||||||
|
write!(f, "{kind} path `{}` does not exist", path.display())
|
||||||
|
}
|
||||||
|
Self::InvalidToolInputSchema { tool_name } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"plugin tool `{tool_name}` inputSchema must be a JSON object"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::InvalidToolRequiredPermission {
|
||||||
|
tool_name,
|
||||||
|
permission,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"plugin tool `{tool_name}` requiredPermission `{permission}` must be read-only, workspace-write, or danger-full-access"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum PluginError {
|
pub enum PluginError {
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
Json(serde_json::Error),
|
Json(serde_json::Error),
|
||||||
|
ManifestValidation(Vec<PluginManifestValidationError>),
|
||||||
InvalidManifest(String),
|
InvalidManifest(String),
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
CommandFailed(String),
|
CommandFailed(String),
|
||||||
@@ -700,6 +849,15 @@ impl Display for PluginError {
|
|||||||
match self {
|
match self {
|
||||||
Self::Io(error) => write!(f, "{error}"),
|
Self::Io(error) => write!(f, "{error}"),
|
||||||
Self::Json(error) => write!(f, "{error}"),
|
Self::Json(error) => write!(f, "{error}"),
|
||||||
|
Self::ManifestValidation(errors) => {
|
||||||
|
for (index, error) in errors.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
write!(f, "; ")?;
|
||||||
|
}
|
||||||
|
write!(f, "{error}")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Self::InvalidManifest(message)
|
Self::InvalidManifest(message)
|
||||||
| Self::NotFound(message)
|
| Self::NotFound(message)
|
||||||
| Self::CommandFailed(message) => write!(f, "{message}"),
|
| Self::CommandFailed(message) => write!(f, "{message}"),
|
||||||
@@ -992,7 +1150,7 @@ impl PluginManager {
|
|||||||
let install_path = install_root.join(sanitize_plugin_id(&plugin_id));
|
let install_path = install_root.join(sanitize_plugin_id(&plugin_id));
|
||||||
let now = unix_time_ms();
|
let now = unix_time_ms();
|
||||||
let existing_record = registry.plugins.get(&plugin_id);
|
let existing_record = registry.plugins.get(&plugin_id);
|
||||||
let needs_sync = existing_record.map_or(true, |record| {
|
let needs_sync = existing_record.is_none_or(|record| {
|
||||||
record.kind != PluginKind::Bundled
|
record.kind != PluginKind::Bundled
|
||||||
|| record.version != manifest.version
|
|| record.version != manifest.version
|
||||||
|| record.name != manifest.name
|
|| record.name != manifest.name
|
||||||
@@ -1010,7 +1168,8 @@ impl PluginManager {
|
|||||||
}
|
}
|
||||||
copy_dir_all(&source_root, &install_path)?;
|
copy_dir_all(&source_root, &install_path)?;
|
||||||
|
|
||||||
let installed_at_unix_ms = existing_record.map_or(now, |record| record.installed_at_unix_ms);
|
let installed_at_unix_ms =
|
||||||
|
existing_record.map_or(now, |record| record.installed_at_unix_ms);
|
||||||
registry.plugins.insert(
|
registry.plugins.insert(
|
||||||
plugin_id.clone(),
|
plugin_id.clone(),
|
||||||
InstalledPluginRecord {
|
InstalledPluginRecord {
|
||||||
@@ -1261,7 +1420,7 @@ fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_named_strings(entries: &[String], kind: &str) -> Result<(), PluginError> {
|
fn validate_named_strings(entries: &[String], kind: &str) -> Result<(), PluginError> {
|
||||||
let mut seen = BTreeMap::<&str, ()>::new();
|
let mut seen = BTreeSet::<&str>::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let trimmed = entry.trim();
|
let trimmed = entry.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -1269,7 +1428,7 @@ fn validate_named_strings(entries: &[String], kind: &str) -> Result<(), PluginEr
|
|||||||
"plugin manifest {kind} cannot be empty"
|
"plugin manifest {kind} cannot be empty"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if seen.insert(trimmed, ()).is_some() {
|
if !seen.insert(trimmed) {
|
||||||
return Err(PluginError::InvalidManifest(format!(
|
return Err(PluginError::InvalidManifest(format!(
|
||||||
"plugin manifest {kind} `{trimmed}` is duplicated"
|
"plugin manifest {kind} `{trimmed}` is duplicated"
|
||||||
)));
|
)));
|
||||||
@@ -1283,7 +1442,7 @@ fn validate_named_commands(
|
|||||||
entries: &[impl NamedCommand],
|
entries: &[impl NamedCommand],
|
||||||
kind: &str,
|
kind: &str,
|
||||||
) -> Result<(), PluginError> {
|
) -> Result<(), PluginError> {
|
||||||
let mut seen = BTreeMap::<&str, ()>::new();
|
let mut seen = BTreeSet::<&str>::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let name = entry.name().trim();
|
let name = entry.name().trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
@@ -1291,7 +1450,7 @@ fn validate_named_commands(
|
|||||||
"plugin {kind} name cannot be empty"
|
"plugin {kind} name cannot be empty"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if seen.insert(name, ()).is_some() {
|
if !seen.insert(name) {
|
||||||
return Err(PluginError::InvalidManifest(format!(
|
return Err(PluginError::InvalidManifest(format!(
|
||||||
"plugin {kind} `{name}` is duplicated"
|
"plugin {kind} `{name}` is duplicated"
|
||||||
)));
|
)));
|
||||||
@@ -1796,6 +1955,59 @@ mod tests {
|
|||||||
log_path
|
log_path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_tool_plugin(root: &Path, name: &str, version: &str) {
|
||||||
|
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' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
|
||||||
|
);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let mut permissions = fs::metadata(&script_path).expect("metadata").permissions();
|
||||||
|
permissions.set_mode(0o755);
|
||||||
|
fs::set_permissions(&script_path, permissions).expect("chmod");
|
||||||
|
}
|
||||||
|
write_file(
|
||||||
|
root.join(MANIFEST_RELATIVE_PATH).as_path(),
|
||||||
|
format!(
|
||||||
|
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"tool plugin\",\n \"tools\": [\n {{\n \"name\": \"plugin_echo\",\n \"description\": \"Echo JSON input\",\n \"inputSchema\": {{\"type\": \"object\", \"properties\": {{\"message\": {{\"type\": \"string\"}}}}, \"required\": [\"message\"], \"additionalProperties\": false}},\n \"command\": \"./tools/echo-json.sh\",\n \"requiredPermission\": \"workspace-write\"\n }}\n ]\n}}"
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
|
||||||
|
write_file(
|
||||||
|
root.join(MANIFEST_RELATIVE_PATH).as_path(),
|
||||||
|
format!(
|
||||||
|
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled plugin\",\n \"defaultEnabled\": {}\n}}",
|
||||||
|
if default_enabled { "true" } else { "false" }
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_enabled_plugins(path: &Path) -> BTreeMap<String, bool> {
|
||||||
|
let contents = fs::read_to_string(path).expect("settings should exist");
|
||||||
|
let root: Value = serde_json::from_str(&contents).expect("settings json");
|
||||||
|
root.get("enabledPlugins")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.map(|enabled_plugins| {
|
||||||
|
enabled_plugins
|
||||||
|
.iter()
|
||||||
|
.map(|(plugin_id, value)| {
|
||||||
|
(
|
||||||
|
plugin_id.clone(),
|
||||||
|
value.as_bool().expect("plugin state should be a bool"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_plugin_from_directory_validates_required_fields() {
|
fn load_plugin_from_directory_validates_required_fields() {
|
||||||
let root = temp_dir("manifest-required");
|
let root = temp_dir("manifest-required");
|
||||||
@@ -1977,6 +2189,70 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(source_root);
|
let _ = fs::remove_dir_all(source_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_installs_bundled_plugins_into_the_registry() {
|
||||||
|
let config_home = temp_dir("bundled-home");
|
||||||
|
let bundled_root = temp_dir("bundled-root");
|
||||||
|
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
|
||||||
|
|
||||||
|
let mut config = PluginManagerConfig::new(&config_home);
|
||||||
|
config.bundled_root = Some(bundled_root.clone());
|
||||||
|
let manager = PluginManager::new(config);
|
||||||
|
|
||||||
|
let installed = manager
|
||||||
|
.list_installed_plugins()
|
||||||
|
.expect("bundled plugins should auto-install");
|
||||||
|
assert!(installed.iter().any(|plugin| {
|
||||||
|
plugin.metadata.id == "starter@bundled"
|
||||||
|
&& plugin.metadata.kind == PluginKind::Bundled
|
||||||
|
&& !plugin.enabled
|
||||||
|
}));
|
||||||
|
|
||||||
|
let registry = manager.load_registry().expect("registry should exist");
|
||||||
|
let record = registry
|
||||||
|
.plugins
|
||||||
|
.get("starter@bundled")
|
||||||
|
.expect("bundled plugin should be recorded");
|
||||||
|
assert_eq!(record.kind, PluginKind::Bundled);
|
||||||
|
assert!(record.install_path.exists());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(bundled_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persists_bundled_plugin_enable_state_across_reloads() {
|
||||||
|
let config_home = temp_dir("bundled-state-home");
|
||||||
|
let bundled_root = temp_dir("bundled-state-root");
|
||||||
|
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", false);
|
||||||
|
|
||||||
|
let mut config = PluginManagerConfig::new(&config_home);
|
||||||
|
config.bundled_root = Some(bundled_root.clone());
|
||||||
|
let mut manager = PluginManager::new(config.clone());
|
||||||
|
|
||||||
|
manager
|
||||||
|
.enable("starter@bundled")
|
||||||
|
.expect("enable bundled plugin should succeed");
|
||||||
|
assert_eq!(
|
||||||
|
load_enabled_plugins(&manager.settings_path()).get("starter@bundled"),
|
||||||
|
Some(&true)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut reloaded_config = PluginManagerConfig::new(&config_home);
|
||||||
|
reloaded_config.bundled_root = Some(bundled_root.clone());
|
||||||
|
reloaded_config.enabled_plugins = load_enabled_plugins(&manager.settings_path());
|
||||||
|
let reloaded_manager = PluginManager::new(reloaded_config);
|
||||||
|
let reloaded = reloaded_manager
|
||||||
|
.list_installed_plugins()
|
||||||
|
.expect("bundled plugins should still be listed");
|
||||||
|
assert!(reloaded
|
||||||
|
.iter()
|
||||||
|
.any(|plugin| { plugin.metadata.id == "starter@bundled" && plugin.enabled }));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(bundled_root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validates_plugin_source_before_install() {
|
fn validates_plugin_source_before_install() {
|
||||||
let config_home = temp_dir("validate-home");
|
let config_home = temp_dir("validate-home");
|
||||||
@@ -2062,4 +2338,32 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(config_home);
|
let _ = fs::remove_dir_all(config_home);
|
||||||
let _ = fs::remove_dir_all(source_root);
|
let _ = fs::remove_dir_all(source_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aggregates_and_executes_plugin_tools() {
|
||||||
|
let config_home = temp_dir("tool-home");
|
||||||
|
let source_root = temp_dir("tool-source");
|
||||||
|
write_tool_plugin(&source_root, "tool-demo", "1.0.0");
|
||||||
|
|
||||||
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
|
manager
|
||||||
|
.install(source_root.to_str().expect("utf8 path"))
|
||||||
|
.expect("install should succeed");
|
||||||
|
|
||||||
|
let tools = manager.aggregated_tools().expect("tools should aggregate");
|
||||||
|
assert_eq!(tools.len(), 1);
|
||||||
|
assert_eq!(tools[0].definition().name, "plugin_echo");
|
||||||
|
assert_eq!(tools[0].required_permission(), "workspace-write");
|
||||||
|
|
||||||
|
let output = tools[0]
|
||||||
|
.execute(&serde_json::json!({ "message": "hello" }))
|
||||||
|
.expect("plugin tool should execute");
|
||||||
|
let payload: Value = serde_json::from_str(&output).expect("valid json");
|
||||||
|
assert_eq!(payload["plugin"], "tool-demo@external");
|
||||||
|
assert_eq!(payload["tool"], "plugin_echo");
|
||||||
|
assert_eq!(payload["input"]["message"], "hello");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(source_root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,8 +178,10 @@ where
|
|||||||
feature_config: RuntimeFeatureConfig,
|
feature_config: RuntimeFeatureConfig,
|
||||||
plugin_registry: PluginRegistry,
|
plugin_registry: PluginRegistry,
|
||||||
) -> Result<Self, RuntimeError> {
|
) -> Result<Self, RuntimeError> {
|
||||||
let plugin_hook_runner = PluginHookRunner::from_registry(&plugin_registry)
|
let plugin_hook_runner =
|
||||||
.map_err(|error| RuntimeError::new(format!("plugin hook registration failed: {error}")))?;
|
PluginHookRunner::from_registry(&plugin_registry).map_err(|error| {
|
||||||
|
RuntimeError::new(format!("plugin hook registration failed: {error}"))
|
||||||
|
})?;
|
||||||
plugin_registry
|
plugin_registry
|
||||||
.initialize()
|
.initialize()
|
||||||
.map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?;
|
.map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?;
|
||||||
@@ -202,6 +204,7 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn run_turn(
|
pub fn run_turn(
|
||||||
&mut self,
|
&mut self,
|
||||||
user_input: impl Into<String>,
|
user_input: impl Into<String>,
|
||||||
@@ -275,13 +278,12 @@ where
|
|||||||
if plugin_pre_hook_result.is_denied() {
|
if plugin_pre_hook_result.is_denied() {
|
||||||
let deny_message =
|
let deny_message =
|
||||||
format!("PreToolUse hook denied tool `{tool_name}`");
|
format!("PreToolUse hook denied tool `{tool_name}`");
|
||||||
|
let mut messages = pre_hook_result.messages().to_vec();
|
||||||
|
messages.extend(plugin_pre_hook_result.messages().iter().cloned());
|
||||||
ConversationMessage::tool_result(
|
ConversationMessage::tool_result(
|
||||||
tool_use_id,
|
tool_use_id,
|
||||||
tool_name,
|
tool_name,
|
||||||
format_hook_message(
|
format_hook_message(&messages, &deny_message),
|
||||||
plugin_pre_hook_result.messages(),
|
|
||||||
&deny_message,
|
|
||||||
),
|
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -290,29 +292,38 @@ where
|
|||||||
Ok(output) => (output, false),
|
Ok(output) => (output, false),
|
||||||
Err(error) => (error.to_string(), true),
|
Err(error) => (error.to_string(), true),
|
||||||
};
|
};
|
||||||
output = merge_hook_feedback(pre_hook_result.messages(), output, false);
|
output =
|
||||||
|
merge_hook_feedback(pre_hook_result.messages(), output, false);
|
||||||
output = merge_hook_feedback(
|
output = merge_hook_feedback(
|
||||||
plugin_pre_hook_result.messages(),
|
plugin_pre_hook_result.messages(),
|
||||||
output,
|
output,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
let post_hook_result = self
|
let hook_output = output.clone();
|
||||||
.hook_runner
|
let post_hook_result = self.hook_runner.run_post_tool_use(
|
||||||
.run_post_tool_use(&tool_name, &input, &output, is_error);
|
&tool_name,
|
||||||
|
&input,
|
||||||
|
&hook_output,
|
||||||
|
is_error,
|
||||||
|
);
|
||||||
|
let plugin_post_hook_result = self.run_plugin_post_tool_use(
|
||||||
|
&tool_name,
|
||||||
|
&input,
|
||||||
|
&hook_output,
|
||||||
|
is_error,
|
||||||
|
);
|
||||||
if post_hook_result.is_denied() {
|
if post_hook_result.is_denied() {
|
||||||
is_error = true;
|
is_error = true;
|
||||||
}
|
}
|
||||||
|
if plugin_post_hook_result.is_denied() {
|
||||||
|
is_error = true;
|
||||||
|
}
|
||||||
output = merge_hook_feedback(
|
output = merge_hook_feedback(
|
||||||
post_hook_result.messages(),
|
post_hook_result.messages(),
|
||||||
output,
|
output,
|
||||||
post_hook_result.is_denied(),
|
post_hook_result.is_denied(),
|
||||||
);
|
);
|
||||||
let plugin_post_hook_result =
|
|
||||||
self.run_plugin_post_tool_use(&tool_name, &input, &output, is_error);
|
|
||||||
if plugin_post_hook_result.is_denied() {
|
|
||||||
is_error = true;
|
|
||||||
}
|
|
||||||
output = merge_hook_feedback(
|
output = merge_hook_feedback(
|
||||||
plugin_post_hook_result.messages(),
|
plugin_post_hook_result.messages(),
|
||||||
output,
|
output,
|
||||||
@@ -449,11 +460,11 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_hook_message(result: &HookRunResult, fallback: &str) -> String {
|
fn format_hook_message(messages: &[String], fallback: &str) -> String {
|
||||||
if result.messages().is_empty() {
|
if messages.is_empty() {
|
||||||
fallback.to_string()
|
fallback.to_string()
|
||||||
} else {
|
} else {
|
||||||
result.messages().join("\n")
|
messages.join("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ mod init;
|
|||||||
mod input;
|
mod input;
|
||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::BTreeSet;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -1912,8 +1912,7 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
|||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_runtime_plugin_state(
|
fn build_runtime_plugin_state() -> Result<
|
||||||
) -> Result<
|
|
||||||
(
|
(
|
||||||
runtime::RuntimeFeatureConfig,
|
runtime::RuntimeFeatureConfig,
|
||||||
PluginRegistry,
|
PluginRegistry,
|
||||||
@@ -1927,7 +1926,11 @@ fn build_runtime_plugin_state(
|
|||||||
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
||||||
let plugin_registry = plugin_manager.plugin_registry()?;
|
let plugin_registry = plugin_manager.plugin_registry()?;
|
||||||
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?;
|
let tool_registry = GlobalToolRegistry::with_plugin_tools(plugin_registry.aggregated_tools()?)?;
|
||||||
Ok((runtime_config.feature_config().clone(), plugin_registry, tool_registry))
|
Ok((
|
||||||
|
runtime_config.feature_config().clone(),
|
||||||
|
plugin_registry,
|
||||||
|
tool_registry,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_plugin_manager(
|
fn build_plugin_manager(
|
||||||
@@ -2737,12 +2740,12 @@ impl ToolExecutor for CliToolExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn permission_policy(mode: PermissionMode, tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
|
fn permission_policy(mode: PermissionMode, tool_registry: &GlobalToolRegistry) -> PermissionPolicy {
|
||||||
tool_registry
|
tool_registry.permission_specs(None).into_iter().fold(
|
||||||
.permission_specs(None)
|
PermissionPolicy::new(mode),
|
||||||
.into_iter()
|
|policy, (name, required_permission)| {
|
||||||
.fold(PermissionPolicy::new(mode), |policy, (name, required_permission)| {
|
|
||||||
policy.with_tool_requirement(name, required_permission)
|
policy.with_tool_requirement(name, required_permission)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
fn convert_messages(messages: &[ConversationMessage]) -> Vec<InputMessage> {
|
||||||
@@ -2893,6 +2896,7 @@ mod tests {
|
|||||||
use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode};
|
use runtime::{AssistantEvent, ContentBlock, ConversationMessage, MessageRole, PermissionMode};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use tools::GlobalToolRegistry;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_to_repl_when_no_args() {
|
fn defaults_to_repl_when_no_args() {
|
||||||
@@ -3106,7 +3110,7 @@ mod tests {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.collect();
|
.collect();
|
||||||
let filtered = filter_tool_specs(Some(&allowed));
|
let filtered = filter_tool_specs(&GlobalToolRegistry::builtin(), Some(&allowed));
|
||||||
let names = filtered
|
let names = filtered
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|spec| spec.name)
|
.map(|spec| spec.name)
|
||||||
@@ -3140,7 +3144,7 @@ mod tests {
|
|||||||
assert!(help.contains("/export [file]"));
|
assert!(help.contains("/export [file]"));
|
||||||
assert!(help.contains("/session [list|switch <session-id>]"));
|
assert!(help.contains("/session [list|switch <session-id>]"));
|
||||||
assert!(help.contains(
|
assert!(help.contains(
|
||||||
"/plugins [list|install <source>|enable <id>|disable <id>|uninstall <id>|update <id>]"
|
"/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
|
||||||
));
|
));
|
||||||
assert!(help.contains("/exit"));
|
assert!(help.contains("/exit"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,7 +218,9 @@ impl GlobalToolRegistry {
|
|||||||
.ok_or_else(|| format!("unsupported tool: {name}"))?;
|
.ok_or_else(|| format!("unsupported tool: {name}"))?;
|
||||||
match &entry.handler {
|
match &entry.handler {
|
||||||
RegisteredToolHandler::Builtin => execute_tool(name, input),
|
RegisteredToolHandler::Builtin => execute_tool(name, input),
|
||||||
RegisteredToolHandler::Plugin(tool) => tool.execute(input).map_err(|error| error.to_string()),
|
RegisteredToolHandler::Plugin(tool) => {
|
||||||
|
tool.execute(input).map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3094,8 +3096,9 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
|
agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn,
|
||||||
execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
|
execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state,
|
||||||
AgentInput, AgentJob, SubagentToolExecutor,
|
AgentInput, AgentJob, GlobalToolRegistry, SubagentToolExecutor,
|
||||||
};
|
};
|
||||||
|
use plugins::{PluginTool, PluginToolDefinition};
|
||||||
use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
|
use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -3112,6 +3115,17 @@ mod tests {
|
|||||||
std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
|
std::env::temp_dir().join(format!("clawd-tools-{unique}-{name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_executable(path: &PathBuf) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
|
||||||
|
permissions.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(path, permissions).expect("chmod");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exposes_mvp_tools() {
|
fn exposes_mvp_tools() {
|
||||||
let names = mvp_tool_specs()
|
let names = mvp_tool_specs()
|
||||||
@@ -3141,6 +3155,75 @@ mod tests {
|
|||||||
assert!(error.contains("unsupported tool"));
|
assert!(error.contains("unsupported tool"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_registry_registers_and_executes_plugin_tools() {
|
||||||
|
let script = temp_path("plugin-tool.sh");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
|
||||||
|
)
|
||||||
|
.expect("write script");
|
||||||
|
make_executable(&script);
|
||||||
|
|
||||||
|
let registry = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
|
||||||
|
"demo@external",
|
||||||
|
"demo",
|
||||||
|
PluginToolDefinition {
|
||||||
|
name: "plugin_echo".to_string(),
|
||||||
|
description: Some("Echo plugin input".to_string()),
|
||||||
|
input_schema: json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "message": { "type": "string" } },
|
||||||
|
"required": ["message"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
script.display().to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
"workspace-write",
|
||||||
|
script.parent().map(PathBuf::from),
|
||||||
|
)])
|
||||||
|
.expect("registry should build");
|
||||||
|
|
||||||
|
let names = registry
|
||||||
|
.definitions(None)
|
||||||
|
.into_iter()
|
||||||
|
.map(|definition| definition.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(names.contains(&"bash".to_string()));
|
||||||
|
assert!(names.contains(&"plugin_echo".to_string()));
|
||||||
|
|
||||||
|
let output = registry
|
||||||
|
.execute("plugin_echo", &json!({ "message": "hello" }))
|
||||||
|
.expect("plugin tool should execute");
|
||||||
|
let payload: serde_json::Value = serde_json::from_str(&output).expect("valid json");
|
||||||
|
assert_eq!(payload["plugin"], "demo@external");
|
||||||
|
assert_eq!(payload["tool"], "plugin_echo");
|
||||||
|
assert_eq!(payload["input"]["message"], "hello");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn global_registry_rejects_conflicting_plugin_tool_names() {
|
||||||
|
let error = GlobalToolRegistry::with_plugin_tools(vec![PluginTool::new(
|
||||||
|
"demo@external",
|
||||||
|
"demo",
|
||||||
|
PluginToolDefinition {
|
||||||
|
name: "read-file".to_string(),
|
||||||
|
description: Some("Conflicts with builtin".to_string()),
|
||||||
|
input_schema: json!({ "type": "object" }),
|
||||||
|
},
|
||||||
|
"echo".to_string(),
|
||||||
|
Vec::new(),
|
||||||
|
"read-only",
|
||||||
|
None,
|
||||||
|
)])
|
||||||
|
.expect_err("conflicting plugin tool should fail");
|
||||||
|
|
||||||
|
assert!(error.contains("conflicts with already-registered tool `read_file`"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn web_fetch_returns_prompt_aware_summary() {
|
fn web_fetch_returns_prompt_aware_summary() {
|
||||||
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||||
|
|||||||
Reference in New Issue
Block a user