feat: plugin subsystem — loader, hooks, tools, bundled, CLI

This commit is contained in:
Yeachan-Heo
2026-04-01 06:45:13 +00:00
parent be08a46e48
commit 4769353b30
7 changed files with 965 additions and 178 deletions

View File

@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
@@ -13,7 +13,9 @@ const BUILTIN_MARKETPLACE: &str = "builtin";
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 = ".claude-plugin/plugin.json";
const PACKAGE_MANIFEST_RELATIVE_PATH: &str = MANIFEST_RELATIVE_PATH;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -87,17 +89,150 @@ impl PluginLifecycle {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginManifest {
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<PluginToolManifest>,
#[serde(default)]
pub commands: Vec<PluginCommandManifest>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginToolManifest {
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_tool_permission")]
pub required_permission: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginToolDefinition {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(rename = "inputSchema")]
pub input_schema: Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginCommandManifest {
pub name: String,
pub description: String,
pub command: String,
}
type PluginPackageManifest = PluginManifest;
#[derive(Debug, Clone, PartialEq)]
pub struct PluginTool {
plugin_id: String,
plugin_name: String,
definition: PluginToolDefinition,
command: String,
args: Vec<String>,
required_permission: String,
root: Option<PathBuf>,
}
impl PluginTool {
#[must_use]
pub fn new(
plugin_id: impl Into<String>,
plugin_name: impl Into<String>,
definition: PluginToolDefinition,
command: impl Into<String>,
args: Vec<String>,
required_permission: impl Into<String>,
root: Option<PathBuf>,
) -> Self {
Self {
plugin_id: plugin_id.into(),
plugin_name: plugin_name.into(),
definition,
command: command.into(),
args,
required_permission: required_permission.into(),
root,
}
}
#[must_use]
pub fn plugin_id(&self) -> &str {
&self.plugin_id
}
#[must_use]
pub fn definition(&self) -> &PluginToolDefinition {
&self.definition
}
#[must_use]
pub fn required_permission(&self) -> &str {
&self.required_permission
}
pub fn execute(&self, input: &Value) -> Result<String, PluginError> {
let input_json = input.to_string();
let mut process = Command::new(&self.command);
process
.args(&self.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.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("CLAWD_PLUGIN_ROOT", root.display().to_string());
}
let mut child = process.spawn()?;
if let Some(stdin) = child.stdin.as_mut() {
use std::io::Write as _;
stdin.write_all(input_json.as_bytes())?;
}
let output = child.wait_with_output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(PluginError::CommandFailed(format!(
"plugin tool `{}` from `{}` failed for `{}`: {}",
self.definition.name,
self.plugin_id,
self.command,
if stderr.is_empty() {
format!("exit status {}", output.status)
} else {
stderr
}
)))
}
}
}
fn default_tool_permission() -> String {
"danger-full-access".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -125,37 +260,41 @@ pub struct InstalledPluginRegistry {
pub plugins: BTreeMap<String, InstalledPluginRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct BuiltinPlugin {
metadata: PluginMetadata,
hooks: PluginHooks,
lifecycle: PluginLifecycle,
tools: Vec<PluginTool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct BundledPlugin {
metadata: PluginMetadata,
hooks: PluginHooks,
lifecycle: PluginLifecycle,
tools: Vec<PluginTool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct ExternalPlugin {
metadata: PluginMetadata,
hooks: PluginHooks,
lifecycle: PluginLifecycle,
tools: Vec<PluginTool>,
}
pub trait Plugin {
fn metadata(&self) -> &PluginMetadata;
fn hooks(&self) -> &PluginHooks;
fn lifecycle(&self) -> &PluginLifecycle;
fn tools(&self) -> &[PluginTool];
fn validate(&self) -> Result<(), PluginError>;
fn initialize(&self) -> Result<(), PluginError>;
fn shutdown(&self) -> Result<(), PluginError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub enum PluginDefinition {
Builtin(BuiltinPlugin),
Bundled(BundledPlugin),
@@ -175,6 +314,10 @@ impl Plugin for BuiltinPlugin {
&self.lifecycle
}
fn tools(&self) -> &[PluginTool] {
&self.tools
}
fn validate(&self) -> Result<(), PluginError> {
Ok(())
}
@@ -201,13 +344,23 @@ impl Plugin for BundledPlugin {
&self.lifecycle
}
fn tools(&self) -> &[PluginTool] {
&self.tools
}
fn validate(&self) -> Result<(), PluginError> {
validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)
validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)?;
validate_tool_paths(self.metadata.root.as_deref(), &self.tools)
}
fn initialize(&self) -> Result<(), PluginError> {
run_lifecycle_commands(self.metadata(), self.lifecycle(), "init", &self.lifecycle.init)
run_lifecycle_commands(
self.metadata(),
self.lifecycle(),
"init",
&self.lifecycle.init,
)
}
fn shutdown(&self) -> Result<(), PluginError> {
@@ -233,13 +386,23 @@ impl Plugin for ExternalPlugin {
&self.lifecycle
}
fn tools(&self) -> &[PluginTool] {
&self.tools
}
fn validate(&self) -> Result<(), PluginError> {
validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)
validate_lifecycle_paths(self.metadata.root.as_deref(), &self.lifecycle)?;
validate_tool_paths(self.metadata.root.as_deref(), &self.tools)
}
fn initialize(&self) -> Result<(), PluginError> {
run_lifecycle_commands(self.metadata(), self.lifecycle(), "init", &self.lifecycle.init)
run_lifecycle_commands(
self.metadata(),
self.lifecycle(),
"init",
&self.lifecycle.init,
)
}
fn shutdown(&self) -> Result<(), PluginError> {
@@ -277,6 +440,14 @@ impl Plugin for PluginDefinition {
}
}
fn tools(&self) -> &[PluginTool] {
match self {
Self::Builtin(plugin) => plugin.tools(),
Self::Bundled(plugin) => plugin.tools(),
Self::External(plugin) => plugin.tools(),
}
}
fn validate(&self) -> Result<(), PluginError> {
match self {
Self::Builtin(plugin) => plugin.validate(),
@@ -302,7 +473,7 @@ impl Plugin for PluginDefinition {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct RegisteredPlugin {
definition: PluginDefinition,
enabled: bool,
@@ -327,6 +498,11 @@ impl RegisteredPlugin {
self.definition.hooks()
}
#[must_use]
pub fn tools(&self) -> &[PluginTool] {
self.definition.tools()
}
#[must_use]
pub fn is_enabled(&self) -> bool {
self.enabled
@@ -359,7 +535,7 @@ pub struct PluginSummary {
pub enabled: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PluginRegistry {
plugins: Vec<RegisteredPlugin>,
}
@@ -403,6 +579,27 @@ impl PluginRegistry {
})
}
pub fn aggregated_tools(&self) -> Result<Vec<PluginTool>, PluginError> {
let mut tools = Vec::new();
let mut seen_names = BTreeMap::new();
for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
plugin.validate()?;
for tool in plugin.tools() {
if let Some(existing_plugin) =
seen_names.insert(tool.definition().name.clone(), tool.plugin_id().to_string())
{
return Err(PluginError::InvalidManifest(format!(
"plugin tool `{}` is defined by both `{existing_plugin}` and `{}`",
tool.definition().name,
tool.plugin_id()
)));
}
tools.push(tool.clone());
}
}
Ok(tools)
}
pub fn initialize(&self) -> Result<(), PluginError> {
for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
plugin.validate()?;
@@ -412,7 +609,12 @@ impl PluginRegistry {
}
pub fn shutdown(&self) -> Result<(), PluginError> {
for plugin in self.plugins.iter().rev().filter(|plugin| plugin.is_enabled()) {
for plugin in self
.plugins
.iter()
.rev()
.filter(|plugin| plugin.is_enabled())
{
plugin.shutdown()?;
}
Ok(())
@@ -561,7 +763,7 @@ impl PluginManager {
pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> {
let path = resolve_local_source(source)?;
load_validated_manifest_from_root(&path)
load_plugin_from_directory(&path)
}
pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> {
@@ -569,7 +771,7 @@ impl PluginManager {
let temp_root = self.install_root().join(".tmp");
let staged_source = materialize_source(&install_source, &temp_root)?;
let cleanup_source = matches!(install_source, PluginInstallSource::GitUrl { .. });
let manifest = load_validated_manifest_from_root(&staged_source)?;
let manifest = load_validated_package_manifest_from_root(&staged_source)?;
let plugin_id = plugin_id(&manifest.name, EXTERNAL_MARKETPLACE);
let install_path = self.install_root().join(sanitize_plugin_id(&plugin_id));
@@ -647,7 +849,7 @@ impl PluginManager {
let temp_root = self.install_root().join(".tmp");
let staged_source = materialize_source(&record.source, &temp_root)?;
let cleanup_source = matches!(record.source, PluginInstallSource::GitUrl { .. });
let manifest = load_validated_manifest_from_root(&staged_source)?;
let manifest = load_validated_package_manifest_from_root(&staged_source)?;
if record.install_path.exists() {
fs::remove_dir_all(&record.install_path)?;
@@ -806,6 +1008,7 @@ pub fn builtin_plugins() -> Vec<PluginDefinition> {
},
hooks: PluginHooks::default(),
lifecycle: PluginLifecycle::default(),
tools: Vec::new(),
})]
}
@@ -815,7 +1018,7 @@ fn load_plugin_definition(
source: String,
marketplace: &str,
) -> Result<PluginDefinition, PluginError> {
let manifest = load_validated_manifest_from_root(root)?;
let manifest = load_validated_package_manifest_from_root(root)?;
let metadata = PluginMetadata {
id: plugin_id(&manifest.name, marketplace),
name: manifest.name,
@@ -828,34 +1031,46 @@ fn load_plugin_definition(
};
let hooks = resolve_hooks(root, &manifest.hooks);
let lifecycle = resolve_lifecycle(root, &manifest.lifecycle);
let tools = resolve_tools(root, &metadata.id, &metadata.name, &manifest.tools);
Ok(match kind {
PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin {
metadata,
hooks,
lifecycle,
tools,
}),
PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin {
metadata,
hooks,
lifecycle,
tools,
}),
PluginKind::External => PluginDefinition::External(ExternalPlugin {
metadata,
hooks,
lifecycle,
tools,
}),
})
}
fn load_validated_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> {
let manifest = load_manifest_from_root(root)?;
validate_manifest(&manifest)?;
pub fn load_plugin_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
let manifest = load_manifest_from_directory(root)?;
validate_plugin_manifest(root, &manifest)?;
Ok(manifest)
}
fn load_validated_package_manifest_from_root(
root: &Path,
) -> Result<PluginPackageManifest, PluginError> {
let manifest = load_package_manifest_from_root(root)?;
validate_package_manifest(root, &manifest)?;
validate_hook_paths(Some(root), &manifest.hooks)?;
validate_lifecycle_paths(Some(root), &manifest.lifecycle)?;
Ok(manifest)
}
fn validate_manifest(manifest: &PluginManifest) -> Result<(), PluginError> {
fn validate_plugin_manifest(root: &Path, manifest: &PluginManifest) -> Result<(), PluginError> {
if manifest.name.trim().is_empty() {
return Err(PluginError::InvalidManifest(
"plugin manifest name cannot be empty".to_string(),
@@ -871,10 +1086,45 @@ fn validate_manifest(manifest: &PluginManifest) -> Result<(), PluginError> {
"plugin manifest description cannot be empty".to_string(),
));
}
validate_named_strings(&manifest.permissions, "permission")?;
validate_hook_paths(Some(root), &manifest.hooks)?;
validate_named_commands(root, &manifest.tools, "tool")?;
validate_named_commands(root, &manifest.commands, "command")?;
Ok(())
}
fn load_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> {
fn validate_package_manifest(root: &Path, manifest: &PluginPackageManifest) -> Result<(), PluginError> {
if manifest.name.trim().is_empty() {
return Err(PluginError::InvalidManifest(
"plugin manifest name cannot be empty".to_string(),
));
}
if manifest.version.trim().is_empty() {
return Err(PluginError::InvalidManifest(
"plugin manifest version cannot be empty".to_string(),
));
}
if manifest.description.trim().is_empty() {
return Err(PluginError::InvalidManifest(
"plugin manifest description cannot be empty".to_string(),
));
}
validate_named_commands(root, &manifest.tools, "tool")?;
Ok(())
}
fn load_manifest_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
let manifest_path = plugin_manifest_path(root)?;
let contents = fs::read_to_string(&manifest_path).map_err(|error| {
PluginError::NotFound(format!(
"plugin manifest not found at {}: {error}",
manifest_path.display()
))
})?;
Ok(serde_json::from_str(&contents)?)
}
fn load_package_manifest_from_root(root: &Path) -> Result<PluginPackageManifest, PluginError> {
let manifest_path = root.join(MANIFEST_RELATIVE_PATH);
let contents = fs::read_to_string(&manifest_path).map_err(|error| {
PluginError::NotFound(format!(
@@ -885,6 +1135,109 @@ fn load_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> {
Ok(serde_json::from_str(&contents)?)
}
fn plugin_manifest_path(root: &Path) -> Result<PathBuf, PluginError> {
let direct_path = root.join(MANIFEST_FILE_NAME);
if direct_path.exists() {
return Ok(direct_path);
}
let packaged_path = root.join(MANIFEST_RELATIVE_PATH);
if packaged_path.exists() {
return Ok(packaged_path);
}
Err(PluginError::NotFound(format!(
"plugin manifest not found at {} or {}",
direct_path.display(),
packaged_path.display()
)))
}
fn validate_named_strings(entries: &[String], kind: &str) -> Result<(), PluginError> {
let mut seen = BTreeMap::<&str, ()>::new();
for entry in entries {
let trimmed = entry.trim();
if trimmed.is_empty() {
return Err(PluginError::InvalidManifest(format!(
"plugin manifest {kind} cannot be empty"
)));
}
if seen.insert(trimmed, ()).is_some() {
return Err(PluginError::InvalidManifest(format!(
"plugin manifest {kind} `{trimmed}` is duplicated"
)));
}
}
Ok(())
}
fn validate_named_commands(
root: &Path,
entries: &[impl NamedCommand],
kind: &str,
) -> Result<(), PluginError> {
let mut seen = BTreeMap::<&str, ()>::new();
for entry in entries {
let name = entry.name().trim();
if name.is_empty() {
return Err(PluginError::InvalidManifest(format!(
"plugin {kind} name cannot be empty"
)));
}
if seen.insert(name, ()).is_some() {
return Err(PluginError::InvalidManifest(format!(
"plugin {kind} `{name}` is duplicated"
)));
}
if entry.description().trim().is_empty() {
return Err(PluginError::InvalidManifest(format!(
"plugin {kind} `{name}` description cannot be empty"
)));
}
if entry.command().trim().is_empty() {
return Err(PluginError::InvalidManifest(format!(
"plugin {kind} `{name}` command cannot be empty"
)));
}
validate_command_path(root, entry.command(), kind)?;
}
Ok(())
}
trait NamedCommand {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn command(&self) -> &str;
}
impl NamedCommand for PluginToolManifest {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn command(&self) -> &str {
&self.command
}
}
impl NamedCommand for PluginCommandManifest {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn command(&self) -> &str {
&self.command
}
}
fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
PluginHooks {
pre_tool_use: hooks
@@ -915,6 +1268,32 @@ fn resolve_lifecycle(root: &Path, lifecycle: &PluginLifecycle) -> PluginLifecycl
}
}
fn resolve_tools(
root: &Path,
plugin_id: &str,
plugin_name: &str,
tools: &[PluginToolManifest],
) -> Vec<PluginTool> {
tools
.iter()
.map(|tool| {
PluginTool::new(
plugin_id,
plugin_name,
PluginToolDefinition {
name: tool.name.clone(),
description: Some(tool.description.clone()),
input_schema: tool.input_schema.clone(),
},
resolve_hook_entry(root, &tool.command),
tool.args.clone(),
tool.required_permission.clone(),
Some(root.to_path_buf()),
)
})
.collect()
}
fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> {
let Some(root) = root else {
return Ok(());
@@ -938,6 +1317,16 @@ fn validate_lifecycle_paths(
Ok(())
}
fn validate_tool_paths(root: Option<&Path>, tools: &[PluginTool]) -> Result<(), PluginError> {
let Some(root) = root else {
return Ok(());
};
for tool in tools {
validate_command_path(root, &tool.command, "tool")?;
}
Ok(())
}
fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), PluginError> {
if is_literal_command(entry) {
return Ok(());
@@ -965,7 +1354,7 @@ fn resolve_hook_entry(root: &Path, entry: &str) -> String {
}
fn is_literal_command(entry: &str) -> bool {
!entry.starts_with("./") && !entry.starts_with("../")
!entry.starts_with("./") && !entry.starts_with("../") && !Path::new(entry).is_absolute()
}
fn run_lifecycle_commands(
@@ -979,17 +1368,29 @@ fn run_lifecycle_commands(
}
for command in commands {
let output = if Path::new(command).exists() {
let mut process = if Path::new(command).exists() {
if cfg!(windows) {
Command::new("cmd").arg("/C").arg(command).output()?
let mut process = Command::new("cmd");
process.arg("/C").arg(command);
process
} else {
Command::new("sh").arg(command).output()?
let mut process = Command::new("sh");
process.arg(command);
process
}
} else if cfg!(windows) {
Command::new("cmd").arg("/C").arg(command).output()?
let mut process = Command::new("cmd");
process.arg("/C").arg(command);
process
} else {
Command::new("sh").arg("-lc").arg(command).output()?
let mut process = Command::new("sh");
process.arg("-lc").arg(command);
process
};
if let Some(root) = &metadata.root {
process.current_dir(root);
}
let output = process.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
@@ -1206,12 +1607,12 @@ mod tests {
let log_path = root.join("lifecycle.log");
fs::write(
root.join("lifecycle").join("init.sh"),
"#!/bin/sh\nprintf 'init\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n",
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
)
.expect("write init hook");
fs::write(
root.join("lifecycle").join("shutdown.sh"),
"#!/bin/sh\nprintf 'shutdown\\n' >> \"$(dirname \"$0\")/../lifecycle.log\"\n",
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
)
.expect("write shutdown hook");
fs::write(
@@ -1232,6 +1633,7 @@ mod tests {
description: "desc".to_string(),
default_enabled: false,
hooks: PluginHooks::default(),
lifecycle: PluginLifecycle::default(),
})
.expect_err("empty name should fail");
assert!(error.to_string().contains("name cannot be empty"));
@@ -1364,12 +1766,13 @@ mod tests {
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
let config_home = temp_dir("lifecycle-home");
let source_root = temp_dir("lifecycle-source");
let log_path = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
manager
let install = manager
.install(source_root.to_str().expect("utf8 path"))
.expect("install should succeed");
let log_path = install.install_path.join("lifecycle.log");
let registry = manager.plugin_registry().expect("registry should build");
registry.initialize().expect("init should succeed");