Files
claw-code-parity/rust/crates/plugins/src/lib.rs
T
Yeachan-Heo a7b77d0ec8 Clear stale enabled state during plugin loader pruning
The plugin loader already pruned stale registry entries, but stale enabled state
could linger in settings.json after bundled or installed plugin discovery
cleaned up missing installs. This change removes those orphaned enabled flags
when stale registry entries are dropped so loader-managed state stays coherent.

Constraint: Commit only plugin loader/registry code in this pass
Rejected: Leave stale enabled flags in settings.json | state drift would survive loader self-healing
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Any future loader-side pruning should remove matching enabled state in the same code path
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: Interactive CLI /plugins flows against manually edited settings.json
2026-04-01 08:10:36 +00:00

2936 lines
94 KiB
Rust

mod hooks;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{Display, Formatter};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub use hooks::{HookEvent, HookRunResult, HookRunner};
const EXTERNAL_MARKETPLACE: &str = "external";
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";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PluginKind {
Builtin,
Bundled,
External,
}
impl Display for PluginKind {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Builtin => write!(f, "builtin"),
Self::Bundled => write!(f, "bundled"),
Self::External => write!(f, "external"),
}
}
}
impl PluginKind {
#[must_use]
fn marketplace(self) -> &'static str {
match self {
Self::Builtin => BUILTIN_MARKETPLACE,
Self::Bundled => BUNDLED_MARKETPLACE,
Self::External => EXTERNAL_MARKETPLACE,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginMetadata {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub kind: PluginKind,
pub source: String,
pub default_enabled: bool,
pub root: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginHooks {
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<String>,
#[serde(rename = "PostToolUse", default)]
pub post_tool_use: Vec<String>,
}
impl PluginHooks {
#[must_use]
pub fn is_empty(&self) -> bool {
self.pre_tool_use.is_empty() && self.post_tool_use.is_empty()
}
#[must_use]
pub fn merged_with(&self, other: &Self) -> Self {
let mut merged = self.clone();
merged
.pre_tool_use
.extend(other.pre_tool_use.iter().cloned());
merged
.post_tool_use
.extend(other.post_tool_use.iter().cloned());
merged
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginLifecycle {
#[serde(rename = "Init", default)]
pub init: Vec<String>,
#[serde(rename = "Shutdown", default)]
pub shutdown: Vec<String>,
}
impl PluginLifecycle {
#[must_use]
pub fn is_empty(&self) -> bool {
self.init.is_empty() && self.shutdown.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PluginManifest {
pub name: String,
pub version: String,
pub description: String,
pub permissions: Vec<PluginPermission>,
#[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, 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,
}
}
}
impl AsRef<str> for PluginPermission {
fn as_ref(&self) -> &str {
self.as_str()
}
}
#[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>,
pub required_permission: PluginToolPermission,
}
#[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)]
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,
}
#[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, Eq, 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_tool_permission_label"
)]
pub required_permission: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PluginTool {
plugin_id: String,
plugin_name: String,
definition: PluginToolDefinition,
command: String,
args: Vec<String>,
required_permission: PluginToolPermission,
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: PluginToolPermission,
root: Option<PathBuf>,
) -> Self {
Self {
plugin_id: plugin_id.into(),
plugin_name: plugin_name.into(),
definition,
command: command.into(),
args,
required_permission,
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.as_str()
}
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_label() -> String {
"danger-full-access".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginInstallSource {
LocalPath { path: PathBuf },
GitUrl { url: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstalledPluginRecord {
#[serde(default = "default_plugin_kind")]
pub kind: PluginKind,
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub install_path: PathBuf,
pub source: PluginInstallSource,
pub installed_at_unix_ms: u128,
pub updated_at_unix_ms: u128,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstalledPluginRegistry {
#[serde(default)]
pub plugins: BTreeMap<String, InstalledPluginRecord>,
}
fn default_plugin_kind() -> PluginKind {
PluginKind::External
}
#[derive(Debug, Clone, PartialEq)]
pub struct BuiltinPlugin {
metadata: PluginMetadata,
hooks: PluginHooks,
lifecycle: PluginLifecycle,
tools: Vec<PluginTool>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BundledPlugin {
metadata: PluginMetadata,
hooks: PluginHooks,
lifecycle: PluginLifecycle,
tools: Vec<PluginTool>,
}
#[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)]
pub enum PluginDefinition {
Builtin(BuiltinPlugin),
Bundled(BundledPlugin),
External(ExternalPlugin),
}
impl Plugin for BuiltinPlugin {
fn metadata(&self) -> &PluginMetadata {
&self.metadata
}
fn hooks(&self) -> &PluginHooks {
&self.hooks
}
fn lifecycle(&self) -> &PluginLifecycle {
&self.lifecycle
}
fn tools(&self) -> &[PluginTool] {
&self.tools
}
fn validate(&self) -> Result<(), PluginError> {
Ok(())
}
fn initialize(&self) -> Result<(), PluginError> {
Ok(())
}
fn shutdown(&self) -> Result<(), PluginError> {
Ok(())
}
}
impl Plugin for BundledPlugin {
fn metadata(&self) -> &PluginMetadata {
&self.metadata
}
fn hooks(&self) -> &PluginHooks {
&self.hooks
}
fn lifecycle(&self) -> &PluginLifecycle {
&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_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,
)
}
fn shutdown(&self) -> Result<(), PluginError> {
run_lifecycle_commands(
self.metadata(),
self.lifecycle(),
"shutdown",
&self.lifecycle.shutdown,
)
}
}
impl Plugin for ExternalPlugin {
fn metadata(&self) -> &PluginMetadata {
&self.metadata
}
fn hooks(&self) -> &PluginHooks {
&self.hooks
}
fn lifecycle(&self) -> &PluginLifecycle {
&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_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,
)
}
fn shutdown(&self) -> Result<(), PluginError> {
run_lifecycle_commands(
self.metadata(),
self.lifecycle(),
"shutdown",
&self.lifecycle.shutdown,
)
}
}
impl Plugin for PluginDefinition {
fn metadata(&self) -> &PluginMetadata {
match self {
Self::Builtin(plugin) => plugin.metadata(),
Self::Bundled(plugin) => plugin.metadata(),
Self::External(plugin) => plugin.metadata(),
}
}
fn hooks(&self) -> &PluginHooks {
match self {
Self::Builtin(plugin) => plugin.hooks(),
Self::Bundled(plugin) => plugin.hooks(),
Self::External(plugin) => plugin.hooks(),
}
}
fn lifecycle(&self) -> &PluginLifecycle {
match self {
Self::Builtin(plugin) => plugin.lifecycle(),
Self::Bundled(plugin) => plugin.lifecycle(),
Self::External(plugin) => plugin.lifecycle(),
}
}
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(),
Self::Bundled(plugin) => plugin.validate(),
Self::External(plugin) => plugin.validate(),
}
}
fn initialize(&self) -> Result<(), PluginError> {
match self {
Self::Builtin(plugin) => plugin.initialize(),
Self::Bundled(plugin) => plugin.initialize(),
Self::External(plugin) => plugin.initialize(),
}
}
fn shutdown(&self) -> Result<(), PluginError> {
match self {
Self::Builtin(plugin) => plugin.shutdown(),
Self::Bundled(plugin) => plugin.shutdown(),
Self::External(plugin) => plugin.shutdown(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RegisteredPlugin {
definition: PluginDefinition,
enabled: bool,
}
impl RegisteredPlugin {
#[must_use]
pub fn new(definition: PluginDefinition, enabled: bool) -> Self {
Self {
definition,
enabled,
}
}
#[must_use]
pub fn metadata(&self) -> &PluginMetadata {
self.definition.metadata()
}
#[must_use]
pub fn hooks(&self) -> &PluginHooks {
self.definition.hooks()
}
#[must_use]
pub fn tools(&self) -> &[PluginTool] {
self.definition.tools()
}
#[must_use]
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn validate(&self) -> Result<(), PluginError> {
self.definition.validate()
}
pub fn initialize(&self) -> Result<(), PluginError> {
self.definition.initialize()
}
pub fn shutdown(&self) -> Result<(), PluginError> {
self.definition.shutdown()
}
#[must_use]
pub fn summary(&self) -> PluginSummary {
PluginSummary {
metadata: self.metadata().clone(),
enabled: self.enabled,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginSummary {
pub metadata: PluginMetadata,
pub enabled: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PluginRegistry {
plugins: Vec<RegisteredPlugin>,
}
impl PluginRegistry {
#[must_use]
pub fn new(mut plugins: Vec<RegisteredPlugin>) -> Self {
plugins.sort_by(|left, right| left.metadata().id.cmp(&right.metadata().id));
Self { plugins }
}
#[must_use]
pub fn plugins(&self) -> &[RegisteredPlugin] {
&self.plugins
}
#[must_use]
pub fn get(&self, plugin_id: &str) -> Option<&RegisteredPlugin> {
self.plugins
.iter()
.find(|plugin| plugin.metadata().id == plugin_id)
}
#[must_use]
pub fn contains(&self, plugin_id: &str) -> bool {
self.get(plugin_id).is_some()
}
#[must_use]
pub fn summaries(&self) -> Vec<PluginSummary> {
self.plugins.iter().map(RegisteredPlugin::summary).collect()
}
pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
self.plugins
.iter()
.filter(|plugin| plugin.is_enabled())
.try_fold(PluginHooks::default(), |acc, plugin| {
plugin.validate()?;
Ok(acc.merged_with(plugin.hooks()))
})
}
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()?;
plugin.initialize()?;
}
Ok(())
}
pub fn shutdown(&self) -> Result<(), PluginError> {
for plugin in self
.plugins
.iter()
.rev()
.filter(|plugin| plugin.is_enabled())
{
plugin.shutdown()?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManagerConfig {
pub config_home: PathBuf,
pub enabled_plugins: BTreeMap<String, bool>,
pub external_dirs: Vec<PathBuf>,
pub install_root: Option<PathBuf>,
pub registry_path: Option<PathBuf>,
pub bundled_root: Option<PathBuf>,
}
impl PluginManagerConfig {
#[must_use]
pub fn new(config_home: impl Into<PathBuf>) -> Self {
Self {
config_home: config_home.into(),
enabled_plugins: BTreeMap::new(),
external_dirs: Vec::new(),
install_root: None,
registry_path: None,
bundled_root: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManager {
config: PluginManagerConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallOutcome {
pub plugin_id: String,
pub version: String,
pub install_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateOutcome {
pub plugin_id: String,
pub old_version: String,
pub new_version: String,
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)]
pub enum PluginError {
Io(std::io::Error),
Json(serde_json::Error),
ManifestValidation(Vec<PluginManifestValidationError>),
InvalidManifest(String),
NotFound(String),
CommandFailed(String),
}
impl Display for PluginError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(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::NotFound(message)
| Self::CommandFailed(message) => write!(f, "{message}"),
}
}
}
impl std::error::Error for PluginError {}
impl From<std::io::Error> for PluginError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<serde_json::Error> for PluginError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
}
}
impl PluginManager {
#[must_use]
pub fn new(config: PluginManagerConfig) -> Self {
Self { config }
}
#[must_use]
pub fn bundled_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bundled")
}
#[must_use]
pub fn install_root(&self) -> PathBuf {
self.config
.install_root
.clone()
.unwrap_or_else(|| self.config.config_home.join("plugins").join("installed"))
}
#[must_use]
pub fn registry_path(&self) -> PathBuf {
self.config.registry_path.clone().unwrap_or_else(|| {
self.config
.config_home
.join("plugins")
.join(REGISTRY_FILE_NAME)
})
}
#[must_use]
pub fn settings_path(&self) -> PathBuf {
self.config.config_home.join(SETTINGS_FILE_NAME)
}
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(),
))
}
pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
Ok(self.plugin_registry()?.summaries())
}
pub fn list_installed_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
Ok(self.installed_plugin_registry()?.summaries())
}
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)
}
pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
self.plugin_registry()?.aggregated_hooks()
}
pub fn aggregated_tools(&self) -> Result<Vec<PluginTool>, PluginError> {
self.plugin_registry()?.aggregated_tools()
}
pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> {
let path = resolve_local_source(source)?;
load_plugin_from_directory(&path)
}
pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> {
let install_source = parse_install_source(source)?;
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_plugin_from_directory(&staged_source)?;
let plugin_id = plugin_id(&manifest.name, EXTERNAL_MARKETPLACE);
let install_path = self.install_root().join(sanitize_plugin_id(&plugin_id));
if install_path.exists() {
fs::remove_dir_all(&install_path)?;
}
copy_dir_all(&staged_source, &install_path)?;
if cleanup_source {
let _ = fs::remove_dir_all(&staged_source);
}
let now = unix_time_ms();
let record = InstalledPluginRecord {
kind: PluginKind::External,
id: plugin_id.clone(),
name: manifest.name,
version: manifest.version.clone(),
description: manifest.description,
install_path: install_path.clone(),
source: install_source,
installed_at_unix_ms: now,
updated_at_unix_ms: now,
};
let mut registry = self.load_registry()?;
registry.plugins.insert(plugin_id.clone(), record);
self.store_registry(&registry)?;
self.write_enabled_state(&plugin_id, Some(true))?;
self.config.enabled_plugins.insert(plugin_id.clone(), true);
Ok(InstallOutcome {
plugin_id,
version: manifest.version,
install_path,
})
}
pub fn enable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
self.ensure_known_plugin(plugin_id)?;
self.write_enabled_state(plugin_id, Some(true))?;
self.config
.enabled_plugins
.insert(plugin_id.to_string(), true);
Ok(())
}
pub fn disable(&mut self, plugin_id: &str) -> Result<(), PluginError> {
self.ensure_known_plugin(plugin_id)?;
self.write_enabled_state(plugin_id, Some(false))?;
self.config
.enabled_plugins
.insert(plugin_id.to_string(), false);
Ok(())
}
pub fn uninstall(&mut self, plugin_id: &str) -> Result<(), PluginError> {
let mut registry = self.load_registry()?;
let record = registry.plugins.remove(plugin_id).ok_or_else(|| {
PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
})?;
if record.kind == PluginKind::Bundled {
registry.plugins.insert(plugin_id.to_string(), record);
return Err(PluginError::CommandFailed(format!(
"plugin `{plugin_id}` is bundled and managed automatically; disable it instead"
)));
}
if record.install_path.exists() {
fs::remove_dir_all(&record.install_path)?;
}
self.store_registry(&registry)?;
self.write_enabled_state(plugin_id, None)?;
self.config.enabled_plugins.remove(plugin_id);
Ok(())
}
pub fn update(&mut self, plugin_id: &str) -> Result<UpdateOutcome, PluginError> {
let mut registry = self.load_registry()?;
let record = registry.plugins.get(plugin_id).cloned().ok_or_else(|| {
PluginError::NotFound(format!("plugin `{plugin_id}` is not installed"))
})?;
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_plugin_from_directory(&staged_source)?;
if record.install_path.exists() {
fs::remove_dir_all(&record.install_path)?;
}
copy_dir_all(&staged_source, &record.install_path)?;
if cleanup_source {
let _ = fs::remove_dir_all(&staged_source);
}
let updated_record = InstalledPluginRecord {
version: manifest.version.clone(),
description: manifest.description,
updated_at_unix_ms: unix_time_ms(),
..record.clone()
};
registry
.plugins
.insert(plugin_id.to_string(), updated_record);
self.store_registry(&registry)?;
Ok(UpdateOutcome {
plugin_id: plugin_id.to_string(),
old_version: record.version,
new_version: manifest.version,
install_path: record.install_path,
})
}
fn discover_installed_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
let mut registry = self.load_registry()?;
let mut plugins = Vec::new();
let mut seen_ids = BTreeSet::<String>::new();
let mut seen_paths = BTreeSet::<PathBuf>::new();
let mut stale_registry_ids = Vec::new();
for install_path in discover_plugin_dirs(&self.install_root())? {
let matched_record = registry
.plugins
.values()
.find(|record| record.install_path == install_path);
let kind = matched_record.map_or(PluginKind::External, |record| record.kind);
let source = matched_record.map_or_else(
|| 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);
}
}
for record in registry.plugins.values() {
if seen_paths.contains(&record.install_path) {
continue;
}
if !record.install_path.exists() || plugin_manifest_path(&record.install_path).is_err()
{
stale_registry_ids.push(record.id.clone());
continue;
}
let plugin = load_plugin_definition(
&record.install_path,
record.kind,
describe_install_source(&record.source),
record.kind.marketplace(),
)?;
if seen_ids.insert(plugin.metadata().id.clone()) {
seen_paths.insert(record.install_path.clone());
plugins.push(plugin);
}
}
if !stale_registry_ids.is_empty() {
for plugin_id in stale_registry_ids {
registry.plugins.remove(&plugin_id);
}
self.store_registry(&registry)?;
}
Ok(plugins)
}
fn discover_external_directory_plugins(
&self,
existing_plugins: &[PluginDefinition],
) -> Result<Vec<PluginDefinition>, PluginError> {
let mut plugins = Vec::new();
for directory in &self.config.external_dirs {
for root in discover_plugin_dirs(directory)? {
let plugin = load_plugin_definition(
&root,
PluginKind::External,
root.display().to_string(),
EXTERNAL_MARKETPLACE,
)?;
if existing_plugins
.iter()
.chain(plugins.iter())
.all(|existing| existing.metadata().id != plugin.metadata().id)
{
plugins.push(plugin);
}
}
}
Ok(plugins)
}
fn installed_plugin_registry(&self) -> Result<PluginRegistry, 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(),
))
}
fn sync_bundled_plugins(&self) -> Result<(), PluginError> {
let bundled_root = self
.config
.bundled_root
.clone()
.unwrap_or_else(Self::bundled_root);
let bundled_plugins = discover_plugin_dirs(&bundled_root)?;
let mut registry = self.load_registry()?;
let mut changed = false;
let install_root = self.install_root();
let mut active_bundled_ids = BTreeSet::new();
for source_root in bundled_plugins {
let manifest = load_plugin_from_directory(&source_root)?;
let plugin_id = plugin_id(&manifest.name, BUNDLED_MARKETPLACE);
active_bundled_ids.insert(plugin_id.clone());
let install_path = install_root.join(sanitize_plugin_id(&plugin_id));
let now = unix_time_ms();
let existing_record = registry.plugins.get(&plugin_id);
let needs_sync = existing_record.is_none_or(|record| {
record.kind != PluginKind::Bundled
|| record.version != manifest.version
|| record.name != manifest.name
|| record.description != manifest.description
|| record.install_path != install_path
|| !record.install_path.exists()
});
if !needs_sync {
continue;
}
if install_path.exists() {
fs::remove_dir_all(&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);
registry.plugins.insert(
plugin_id.clone(),
InstalledPluginRecord {
kind: PluginKind::Bundled,
id: plugin_id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
install_path,
source: PluginInstallSource::LocalPath { path: source_root },
installed_at_unix_ms,
updated_at_unix_ms: now,
},
);
changed = true;
}
let stale_bundled_ids = registry
.plugins
.iter()
.filter_map(|(plugin_id, record)| {
(record.kind == PluginKind::Bundled && !active_bundled_ids.contains(plugin_id))
.then_some(plugin_id.clone())
})
.collect::<Vec<_>>();
for plugin_id in stale_bundled_ids {
if let Some(record) = registry.plugins.remove(&plugin_id) {
if record.install_path.exists() {
fs::remove_dir_all(&record.install_path)?;
}
changed = true;
}
}
if changed {
self.store_registry(&registry)?;
}
Ok(())
}
fn is_enabled(&self, metadata: &PluginMetadata) -> bool {
self.config
.enabled_plugins
.get(&metadata.id)
.copied()
.unwrap_or(match metadata.kind {
PluginKind::External => false,
PluginKind::Builtin | PluginKind::Bundled => metadata.default_enabled,
})
}
fn ensure_known_plugin(&self, plugin_id: &str) -> Result<(), PluginError> {
if self.plugin_registry()?.contains(plugin_id) {
Ok(())
} else {
Err(PluginError::NotFound(format!(
"plugin `{plugin_id}` is not installed or discoverable"
)))
}
}
fn load_registry(&self) -> Result<InstalledPluginRegistry, PluginError> {
let path = self.registry_path();
match fs::read_to_string(&path) {
Ok(contents) => Ok(serde_json::from_str(&contents)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(InstalledPluginRegistry::default())
}
Err(error) => Err(PluginError::Io(error)),
}
}
fn store_registry(&self, registry: &InstalledPluginRegistry) -> Result<(), PluginError> {
let path = self.registry_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_string_pretty(registry)?)?;
Ok(())
}
fn write_enabled_state(
&self,
plugin_id: &str,
enabled: Option<bool>,
) -> Result<(), PluginError> {
update_settings_json(&self.settings_path(), |root| {
let enabled_plugins = ensure_object(root, "enabledPlugins");
match enabled {
Some(value) => {
enabled_plugins.insert(plugin_id.to_string(), Value::Bool(value));
}
None => {
enabled_plugins.remove(plugin_id);
}
}
})
}
}
#[must_use]
pub fn builtin_plugins() -> Vec<PluginDefinition> {
vec![PluginDefinition::Builtin(BuiltinPlugin {
metadata: PluginMetadata {
id: plugin_id("example-builtin", BUILTIN_MARKETPLACE),
name: "example-builtin".to_string(),
version: "0.1.0".to_string(),
description: "Example built-in plugin scaffold for the Rust plugin system".to_string(),
kind: PluginKind::Builtin,
source: BUILTIN_MARKETPLACE.to_string(),
default_enabled: false,
root: None,
},
hooks: PluginHooks::default(),
lifecycle: PluginLifecycle::default(),
tools: Vec::new(),
})]
}
fn load_plugin_definition(
root: &Path,
kind: PluginKind,
source: String,
marketplace: &str,
) -> Result<PluginDefinition, PluginError> {
let manifest = load_plugin_from_directory(root)?;
let metadata = PluginMetadata {
id: plugin_id(&manifest.name, marketplace),
name: manifest.name,
version: manifest.version,
description: manifest.description,
kind,
source,
default_enabled: manifest.default_enabled,
root: Some(root.to_path_buf()),
};
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,
}),
})
}
pub fn load_plugin_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
load_manifest_from_directory(root)
}
fn load_manifest_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
let manifest_path = plugin_manifest_path(root)?;
load_manifest_from_path(root, &manifest_path)
}
fn load_manifest_from_path(
root: &Path,
manifest_path: &Path,
) -> Result<PluginManifest, PluginError> {
let contents = fs::read_to_string(manifest_path).map_err(|error| {
PluginError::NotFound(format!(
"plugin manifest not found at {}: {error}",
manifest_path.display()
))
})?;
let raw_manifest: RawPluginManifest = serde_json::from_str(&contents)?;
build_plugin_manifest(root, raw_manifest)
}
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 build_plugin_manifest(
root: &Path,
raw: RawPluginManifest,
) -> Result<PluginManifest, PluginError> {
let mut errors = Vec::new();
validate_required_manifest_field("name", &raw.name, &mut errors);
validate_required_manifest_field("version", &raw.version, &mut errors);
validate_required_manifest_field("description", &raw.description, &mut errors);
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.lifecycle.init.iter(),
"lifecycle command",
&mut errors,
);
validate_command_entries(
root,
raw.lifecycle.shutdown.iter(),
"lifecycle command",
&mut errors,
);
let tools = build_manifest_tools(root, raw.tools, &mut errors);
let commands = build_manifest_commands(root, raw.commands, &mut errors);
if !errors.is_empty() {
return Err(PluginError::ManifestValidation(errors));
}
Ok(PluginManifest {
name: raw.name,
version: raw.version,
description: raw.description,
permissions,
default_enabled: raw.default_enabled,
hooks: raw.hooks,
lifecycle: raw.lifecycle,
tools,
commands,
})
}
fn validate_required_manifest_field(
field: &'static str,
value: &str,
errors: &mut Vec<PluginManifestValidationError>,
) {
if value.trim().is_empty() {
errors.push(PluginManifestValidationError::EmptyField { field });
}
}
fn build_manifest_permissions(
permissions: &[String],
errors: &mut Vec<PluginManifestValidationError>,
) -> Vec<PluginPermission> {
let mut seen = BTreeSet::new();
let mut validated = Vec::new();
for permission in permissions {
let permission = permission.trim();
if permission.is_empty() {
errors.push(PluginManifestValidationError::EmptyEntryField {
kind: "permission",
field: "value",
name: None,
});
continue;
}
if !seen.insert(permission.to_string()) {
errors.push(PluginManifestValidationError::DuplicatePermission {
permission: permission.to_string(),
});
continue;
}
match PluginPermission::parse(permission) {
Some(permission) => validated.push(permission),
None => errors.push(PluginManifestValidationError::InvalidPermission {
permission: permission.to_string(),
}),
}
}
validated
}
fn build_manifest_tools(
root: &Path,
tools: Vec<RawPluginToolManifest>,
errors: &mut Vec<PluginManifestValidationError>,
) -> Vec<PluginToolManifest> {
let mut seen = BTreeSet::new();
let mut validated = Vec::new();
for tool in tools {
let name = tool.name.trim().to_string();
if name.is_empty() {
errors.push(PluginManifestValidationError::EmptyEntryField {
kind: "tool",
field: "name",
name: None,
});
continue;
}
if !seen.insert(name.clone()) {
errors.push(PluginManifestValidationError::DuplicateEntry { kind: "tool", name });
continue;
}
if tool.description.trim().is_empty() {
errors.push(PluginManifestValidationError::EmptyEntryField {
kind: "tool",
field: "description",
name: Some(name.clone()),
});
}
if tool.command.trim().is_empty() {
errors.push(PluginManifestValidationError::EmptyEntryField {
kind: "tool",
field: "command",
name: Some(name.clone()),
});
} else {
validate_command_entry(root, &tool.command, "tool", errors);
}
if !tool.input_schema.is_object() {
errors.push(PluginManifestValidationError::InvalidToolInputSchema {
tool_name: name.clone(),
});
}
let Some(required_permission) =
PluginToolPermission::parse(tool.required_permission.trim())
else {
errors.push(
PluginManifestValidationError::InvalidToolRequiredPermission {
tool_name: name.clone(),
permission: tool.required_permission.trim().to_string(),
},
);
continue;
};
validated.push(PluginToolManifest {
name,
description: tool.description,
input_schema: tool.input_schema,
command: tool.command,
args: tool.args,
required_permission,
});
}
validated
}
fn build_manifest_commands(
root: &Path,
commands: Vec<PluginCommandManifest>,
errors: &mut Vec<PluginManifestValidationError>,
) -> Vec<PluginCommandManifest> {
let mut seen = BTreeSet::new();
let mut validated = Vec::new();
for command in commands {
let name = command.name.trim().to_string();
if name.is_empty() {
errors.push(PluginManifestValidationError::EmptyEntryField {
kind: "command",
field: "name",
name: None,
});
continue;
}
if !seen.insert(name.clone()) {
errors.push(PluginManifestValidationError::DuplicateEntry {
kind: "command",
name,
});
continue;
}
if command.description.trim().is_empty() {
errors.push(PluginManifestValidationError::EmptyEntryField {
kind: "command",
field: "description",
name: Some(name.clone()),
});
}
if command.command.trim().is_empty() {
errors.push(PluginManifestValidationError::EmptyEntryField {
kind: "command",
field: "command",
name: Some(name.clone()),
});
} else {
validate_command_entry(root, &command.command, "command", errors);
}
validated.push(command);
}
validated
}
fn validate_command_entries<'a>(
root: &Path,
entries: impl Iterator<Item = &'a String>,
kind: &'static str,
errors: &mut Vec<PluginManifestValidationError>,
) {
for entry in entries {
validate_command_entry(root, entry, kind, errors);
}
}
fn validate_command_entry(
root: &Path,
entry: &str,
kind: &'static str,
errors: &mut Vec<PluginManifestValidationError>,
) {
if entry.trim().is_empty() {
errors.push(PluginManifestValidationError::EmptyEntryField {
kind,
field: "command",
name: None,
});
return;
}
if is_literal_command(entry) {
return;
}
let path = if Path::new(entry).is_absolute() {
PathBuf::from(entry)
} else {
root.join(entry)
};
if !path.exists() {
errors.push(PluginManifestValidationError::MissingPath { kind, path });
}
}
fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
PluginHooks {
pre_tool_use: hooks
.pre_tool_use
.iter()
.map(|entry| resolve_hook_entry(root, entry))
.collect(),
post_tool_use: hooks
.post_tool_use
.iter()
.map(|entry| resolve_hook_entry(root, entry))
.collect(),
}
}
fn resolve_lifecycle(root: &Path, lifecycle: &PluginLifecycle) -> PluginLifecycle {
PluginLifecycle {
init: lifecycle
.init
.iter()
.map(|entry| resolve_hook_entry(root, entry))
.collect(),
shutdown: lifecycle
.shutdown
.iter()
.map(|entry| resolve_hook_entry(root, entry))
.collect(),
}
}
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,
Some(root.to_path_buf()),
)
})
.collect()
}
fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> {
let Some(root) = root else {
return Ok(());
};
for entry in hooks.pre_tool_use.iter().chain(hooks.post_tool_use.iter()) {
validate_command_path(root, entry, "hook")?;
}
Ok(())
}
fn validate_lifecycle_paths(
root: Option<&Path>,
lifecycle: &PluginLifecycle,
) -> Result<(), PluginError> {
let Some(root) = root else {
return Ok(());
};
for entry in lifecycle.init.iter().chain(lifecycle.shutdown.iter()) {
validate_command_path(root, entry, "lifecycle command")?;
}
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(());
}
let path = if Path::new(entry).is_absolute() {
PathBuf::from(entry)
} else {
root.join(entry)
};
if !path.exists() {
return Err(PluginError::InvalidManifest(format!(
"{kind} path `{}` does not exist",
path.display()
)));
}
Ok(())
}
fn resolve_hook_entry(root: &Path, entry: &str) -> String {
if is_literal_command(entry) {
entry.to_string()
} else {
root.join(entry).display().to_string()
}
}
fn is_literal_command(entry: &str) -> bool {
!entry.starts_with("./") && !entry.starts_with("../") && !Path::new(entry).is_absolute()
}
fn run_lifecycle_commands(
metadata: &PluginMetadata,
lifecycle: &PluginLifecycle,
phase: &str,
commands: &[String],
) -> Result<(), PluginError> {
if lifecycle.is_empty() || commands.is_empty() {
return Ok(());
}
for command in commands {
let mut process = if Path::new(command).exists() {
if cfg!(windows) {
let mut process = Command::new("cmd");
process.arg("/C").arg(command);
process
} else {
let mut process = Command::new("sh");
process.arg(command);
process
}
} else if cfg!(windows) {
let mut process = Command::new("cmd");
process.arg("/C").arg(command);
process
} else {
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();
return Err(PluginError::CommandFailed(format!(
"plugin `{}` {} failed for `{}`: {}",
metadata.id,
phase,
command,
if stderr.is_empty() {
format!("exit status {}", output.status)
} else {
stderr
}
)));
}
}
Ok(())
}
fn resolve_local_source(source: &str) -> Result<PathBuf, PluginError> {
let path = PathBuf::from(source);
if path.exists() {
Ok(path)
} else {
Err(PluginError::NotFound(format!(
"plugin source `{source}` was not found"
)))
}
}
fn parse_install_source(source: &str) -> Result<PluginInstallSource, PluginError> {
if source.starts_with("http://")
|| source.starts_with("https://")
|| source.starts_with("git@")
|| Path::new(source)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("git"))
{
Ok(PluginInstallSource::GitUrl {
url: source.to_string(),
})
} else {
Ok(PluginInstallSource::LocalPath {
path: resolve_local_source(source)?,
})
}
}
fn materialize_source(
source: &PluginInstallSource,
temp_root: &Path,
) -> Result<PathBuf, PluginError> {
fs::create_dir_all(temp_root)?;
match source {
PluginInstallSource::LocalPath { path } => Ok(path.clone()),
PluginInstallSource::GitUrl { url } => {
let destination = temp_root.join(format!("plugin-{}", unix_time_ms()));
let output = Command::new("git")
.arg("clone")
.arg("--depth")
.arg("1")
.arg(url)
.arg(&destination)
.output()?;
if !output.status.success() {
return Err(PluginError::CommandFailed(format!(
"git clone failed for `{url}`: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(destination)
}
}
}
fn discover_plugin_dirs(root: &Path) -> Result<Vec<PathBuf>, PluginError> {
match fs::read_dir(root) {
Ok(entries) => {
let mut paths = Vec::new();
for entry in entries {
let path = entry?.path();
if path.is_dir() && plugin_manifest_path(&path).is_ok() {
paths.push(path);
}
}
paths.sort();
Ok(paths)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(PluginError::Io(error)),
}
}
fn plugin_id(name: &str, marketplace: &str) -> String {
format!("{name}@{marketplace}")
}
fn sanitize_plugin_id(plugin_id: &str) -> String {
plugin_id
.chars()
.map(|ch| match ch {
'/' | '\\' | '@' | ':' => '-',
other => other,
})
.collect()
}
fn describe_install_source(source: &PluginInstallSource) -> String {
match source {
PluginInstallSource::LocalPath { path } => path.display().to_string(),
PluginInstallSource::GitUrl { url } => url.clone(),
}
}
fn unix_time_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_millis()
}
fn copy_dir_all(source: &Path, destination: &Path) -> Result<(), PluginError> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir_all(&entry.path(), &target)?;
} else {
fs::copy(entry.path(), target)?;
}
}
Ok(())
}
fn update_settings_json(
path: &Path,
mut update: impl FnMut(&mut Map<String, Value>),
) -> Result<(), PluginError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut root = match fs::read_to_string(path) {
Ok(contents) if !contents.trim().is_empty() => serde_json::from_str::<Value>(&contents)?,
Ok(_) => Value::Object(Map::new()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Object(Map::new()),
Err(error) => return Err(PluginError::Io(error)),
};
let object = root.as_object_mut().ok_or_else(|| {
PluginError::InvalidManifest(format!(
"settings file {} must contain a JSON object",
path.display()
))
})?;
update(object);
fs::write(path, serde_json::to_string_pretty(&root)?)?;
Ok(())
}
fn ensure_object<'a>(root: &'a mut Map<String, Value>, key: &str) -> &'a mut Map<String, Value> {
if !root.get(key).is_some_and(Value::is_object) {
root.insert(key.to_string(), Value::Object(Map::new()));
}
root.get_mut(key)
.and_then(Value::as_object_mut)
.expect("object should exist")
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(label: &str) -> PathBuf {
std::env::temp_dir().join(format!("plugins-{label}-{}", unix_time_ms()))
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("parent dir");
}
fs::write(path, contents).expect("write file");
}
fn write_loader_plugin(root: &Path) {
write_file(
root.join("hooks").join("pre.sh").as_path(),
"#!/bin/sh\nprintf 'pre'\n",
);
write_file(
root.join("tools").join("echo-tool.sh").as_path(),
"#!/bin/sh\ncat\n",
);
write_file(
root.join("commands").join("sync.sh").as_path(),
"#!/bin/sh\nprintf 'sync'\n",
);
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "loader-demo",
"version": "1.2.3",
"description": "Manifest loader test plugin",
"permissions": ["read", "write"],
"hooks": {
"PreToolUse": ["./hooks/pre.sh"]
},
"tools": [
{
"name": "echo_tool",
"description": "Echoes JSON input",
"inputSchema": {
"type": "object"
},
"command": "./tools/echo-tool.sh",
"requiredPermission": "workspace-write"
}
],
"commands": [
{
"name": "sync",
"description": "Sync command",
"command": "./commands/sync.sh"
}
]
}"#,
);
}
fn write_external_plugin(root: &Path, name: &str, version: &str) {
write_file(
root.join("hooks").join("pre.sh").as_path(),
"#!/bin/sh\nprintf 'pre'\n",
);
write_file(
root.join("hooks").join("post.sh").as_path(),
"#!/bin/sh\nprintf 'post'\n",
);
write_file(
root.join(MANIFEST_RELATIVE_PATH).as_path(),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"test plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
)
.as_str(),
);
}
fn write_broken_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 \"PreToolUse\": [\"./hooks/missing.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(
root.join("lifecycle").join("init.sh").as_path(),
"#!/bin/sh\nprintf 'init\\n' >> lifecycle.log\n",
);
write_file(
root.join("lifecycle").join("shutdown.sh").as_path(),
"#!/bin/sh\nprintf 'shutdown\\n' >> lifecycle.log\n",
);
write_file(
root.join(MANIFEST_RELATIVE_PATH).as_path(),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"lifecycle plugin\",\n \"lifecycle\": {{\n \"Init\": [\"./lifecycle/init.sh\"],\n \"Shutdown\": [\"./lifecycle/shutdown.sh\"]\n }}\n}}"
)
.as_str(),
);
log_path
}
fn write_tool_plugin(root: &Path, name: &str, version: &str) {
write_tool_plugin_with_name(root, name, version, "plugin_echo");
}
fn write_tool_plugin_with_name(root: &Path, name: &str, version: &str, tool_name: &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\": \"{tool_name}\",\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]
fn load_plugin_from_directory_validates_required_fields() {
let root = temp_dir("manifest-required");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{"name":"","version":"1.0.0","description":"desc"}"#,
);
let error = load_plugin_from_directory(&root).expect_err("empty name should fail");
assert!(error.to_string().contains("name cannot be empty"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_reads_root_manifest_and_validates_entries() {
let root = temp_dir("manifest-root");
write_loader_plugin(&root);
let manifest = load_plugin_from_directory(&root).expect("manifest should load");
assert_eq!(manifest.name, "loader-demo");
assert_eq!(manifest.version, "1.2.3");
assert_eq!(
manifest
.permissions
.iter()
.map(|permission| permission.as_str())
.collect::<Vec<_>>(),
vec!["read", "write"]
);
assert_eq!(manifest.hooks.pre_tool_use, vec!["./hooks/pre.sh"]);
assert_eq!(manifest.tools.len(), 1);
assert_eq!(manifest.tools[0].name, "echo_tool");
assert_eq!(
manifest.tools[0].required_permission,
PluginToolPermission::WorkspaceWrite
);
assert_eq!(manifest.commands.len(), 1);
assert_eq!(manifest.commands[0].name, "sync");
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_supports_packaged_manifest_path() {
let root = temp_dir("manifest-packaged");
write_external_plugin(&root, "packaged-demo", "1.0.0");
let manifest = load_plugin_from_directory(&root).expect("packaged manifest should load");
assert_eq!(manifest.name, "packaged-demo");
assert!(manifest.tools.is_empty());
assert!(manifest.commands.is_empty());
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_defaults_optional_fields() {
let root = temp_dir("manifest-defaults");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "minimal",
"version": "0.1.0",
"description": "Minimal manifest"
}"#,
);
let manifest = load_plugin_from_directory(&root).expect("minimal manifest should load");
assert!(manifest.permissions.is_empty());
assert!(manifest.hooks.is_empty());
assert!(manifest.tools.is_empty());
assert!(manifest.commands.is_empty());
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_duplicate_permissions_and_commands() {
let root = temp_dir("manifest-duplicates");
write_file(
root.join("commands").join("sync.sh").as_path(),
"#!/bin/sh\nprintf 'sync'\n",
);
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "duplicate-manifest",
"version": "1.0.0",
"description": "Duplicate validation",
"permissions": ["read", "read"],
"commands": [
{"name": "sync", "description": "Sync one", "command": "./commands/sync.sh"},
{"name": "sync", "description": "Sync two", "command": "./commands/sync.sh"}
]
}"#,
);
let error = load_plugin_from_directory(&root).expect_err("duplicates should fail");
match error {
PluginError::ManifestValidation(errors) => {
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::DuplicatePermission { permission }
if permission == "read"
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::DuplicateEntry { kind, name }
if *kind == "command" && name == "sync"
)));
}
other => panic!("expected manifest validation errors, got {other}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_missing_tool_or_command_paths() {
let root = temp_dir("manifest-paths");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "missing-paths",
"version": "1.0.0",
"description": "Missing path validation",
"tools": [
{
"name": "tool_one",
"description": "Missing tool script",
"inputSchema": {"type": "object"},
"command": "./tools/missing.sh"
}
]
}"#,
);
let error = load_plugin_from_directory(&root).expect_err("missing paths should fail");
assert!(error.to_string().contains("does not exist"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_invalid_permissions() {
let root = temp_dir("manifest-invalid-permissions");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "invalid-permissions",
"version": "1.0.0",
"description": "Invalid permission validation",
"permissions": ["admin"]
}"#,
);
let error = load_plugin_from_directory(&root).expect_err("invalid permissions should fail");
match error {
PluginError::ManifestValidation(errors) => {
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::InvalidPermission { permission }
if permission == "admin"
)));
}
other => panic!("expected manifest validation errors, got {other}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_rejects_invalid_tool_required_permission() {
let root = temp_dir("manifest-invalid-tool-permission");
write_file(
root.join("tools").join("echo.sh").as_path(),
"#!/bin/sh\ncat\n",
);
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "invalid-tool-permission",
"version": "1.0.0",
"description": "Invalid tool permission validation",
"tools": [
{
"name": "echo_tool",
"description": "Echo tool",
"inputSchema": {"type": "object"},
"command": "./tools/echo.sh",
"requiredPermission": "admin"
}
]
}"#,
);
let error =
load_plugin_from_directory(&root).expect_err("invalid tool permission should fail");
match error {
PluginError::ManifestValidation(errors) => {
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::InvalidToolRequiredPermission {
tool_name,
permission
} if tool_name == "echo_tool" && permission == "admin"
)));
}
other => panic!("expected manifest validation errors, got {other}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn load_plugin_from_directory_accumulates_multiple_validation_errors() {
let root = temp_dir("manifest-multi-error");
write_file(
root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "",
"version": "1.0.0",
"description": "",
"permissions": ["admin"],
"commands": [
{"name": "", "description": "", "command": "./commands/missing.sh"}
]
}"#,
);
let error =
load_plugin_from_directory(&root).expect_err("multiple manifest errors should fail");
match error {
PluginError::ManifestValidation(errors) => {
assert!(errors.len() >= 4);
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::EmptyField { field } if *field == "name"
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::EmptyField { field }
if *field == "description"
)));
assert!(errors.iter().any(|error| matches!(
error,
PluginManifestValidationError::InvalidPermission { permission }
if permission == "admin"
)));
}
other => panic!("expected manifest validation errors, got {other}"),
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn discovers_builtin_and_bundled_plugins() {
let manager = PluginManager::new(PluginManagerConfig::new(temp_dir("discover")));
let plugins = manager.list_plugins().expect("plugins should list");
assert!(plugins
.iter()
.any(|plugin| plugin.metadata.kind == PluginKind::Builtin));
assert!(plugins
.iter()
.any(|plugin| plugin.metadata.kind == PluginKind::Bundled));
}
#[test]
fn installs_enables_updates_and_uninstalls_external_plugins() {
let config_home = temp_dir("home");
let source_root = temp_dir("source");
write_external_plugin(&source_root, "demo", "1.0.0");
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let install = manager
.install(source_root.to_str().expect("utf8 path"))
.expect("install should succeed");
assert_eq!(install.plugin_id, "demo@external");
assert!(manager
.list_plugins()
.expect("list plugins")
.iter()
.any(|plugin| plugin.metadata.id == "demo@external" && plugin.enabled));
let hooks = manager.aggregated_hooks().expect("hooks should aggregate");
assert_eq!(hooks.pre_tool_use.len(), 1);
assert!(hooks.pre_tool_use[0].contains("pre.sh"));
manager
.disable("demo@external")
.expect("disable should work");
assert!(manager
.aggregated_hooks()
.expect("hooks after disable")
.is_empty());
manager.enable("demo@external").expect("enable should work");
write_external_plugin(&source_root, "demo", "2.0.0");
let update = manager.update("demo@external").expect("update should work");
assert_eq!(update.old_version, "1.0.0");
assert_eq!(update.new_version, "2.0.0");
manager
.uninstall("demo@external")
.expect("uninstall should work");
assert!(!manager
.list_plugins()
.expect("list plugins")
.iter()
.any(|plugin| plugin.metadata.id == "demo@external"));
let _ = fs::remove_dir_all(config_home);
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 default_bundled_root_loads_repo_bundles_as_installed_plugins() {
let config_home = temp_dir("default-bundled-home");
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let installed = manager
.list_installed_plugins()
.expect("default bundled plugins should auto-install");
assert!(installed
.iter()
.any(|plugin| plugin.metadata.id == "example-bundled@bundled"));
assert!(installed
.iter()
.any(|plugin| plugin.metadata.id == "sample-hooks@bundled"));
let _ = fs::remove_dir_all(config_home);
}
#[test]
fn bundled_sync_prunes_removed_bundled_registry_entries() {
let config_home = temp_dir("bundled-prune-home");
let bundled_root = temp_dir("bundled-prune-root");
let stale_install_path = config_home
.join("plugins")
.join("installed")
.join("stale-bundled-external");
write_bundled_plugin(&bundled_root.join("active"), "active", "0.1.0", false);
write_file(
stale_install_path.join(MANIFEST_RELATIVE_PATH).as_path(),
r#"{
"name": "stale",
"version": "0.1.0",
"description": "stale bundled plugin"
}"#,
);
let mut config = PluginManagerConfig::new(&config_home);
config.bundled_root = Some(bundled_root.clone());
config.install_root = Some(config_home.join("plugins").join("installed"));
let manager = PluginManager::new(config);
let mut registry = InstalledPluginRegistry::default();
registry.plugins.insert(
"stale@bundled".to_string(),
InstalledPluginRecord {
kind: PluginKind::Bundled,
id: "stale@bundled".to_string(),
name: "stale".to_string(),
version: "0.1.0".to_string(),
description: "stale bundled plugin".to_string(),
install_path: stale_install_path.clone(),
source: PluginInstallSource::LocalPath {
path: bundled_root.join("stale"),
},
installed_at_unix_ms: 1,
updated_at_unix_ms: 1,
},
);
manager.store_registry(&registry).expect("store registry");
manager
.write_enabled_state("stale@bundled", Some(true))
.expect("seed bundled enabled state");
let installed = manager
.list_installed_plugins()
.expect("bundled sync should succeed");
assert!(installed
.iter()
.any(|plugin| plugin.metadata.id == "active@bundled"));
assert!(!installed
.iter()
.any(|plugin| plugin.metadata.id == "stale@bundled"));
let registry = manager.load_registry().expect("load registry");
assert!(!registry.plugins.contains_key("stale@bundled"));
assert!(!stale_install_path.exists());
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(bundled_root);
}
#[test]
fn installed_plugin_discovery_keeps_registry_entries_outside_install_root() {
let config_home = temp_dir("registry-fallback-home");
let bundled_root = temp_dir("registry-fallback-bundled");
let install_root = config_home.join("plugins").join("installed");
let external_install_path = temp_dir("registry-fallback-external");
write_file(
external_install_path.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "registry-fallback",
"version": "1.0.0",
"description": "Registry fallback plugin"
}"#,
);
let mut config = PluginManagerConfig::new(&config_home);
config.bundled_root = Some(bundled_root.clone());
config.install_root = Some(install_root.clone());
let manager = PluginManager::new(config);
let mut registry = InstalledPluginRegistry::default();
registry.plugins.insert(
"registry-fallback@external".to_string(),
InstalledPluginRecord {
kind: PluginKind::External,
id: "registry-fallback@external".to_string(),
name: "registry-fallback".to_string(),
version: "1.0.0".to_string(),
description: "Registry fallback plugin".to_string(),
install_path: external_install_path.clone(),
source: PluginInstallSource::LocalPath {
path: external_install_path.clone(),
},
installed_at_unix_ms: 1,
updated_at_unix_ms: 1,
},
);
manager.store_registry(&registry).expect("store registry");
manager
.write_enabled_state("stale-external@external", Some(true))
.expect("seed stale external enabled state");
let installed = manager
.list_installed_plugins()
.expect("registry fallback plugin should load");
assert!(installed
.iter()
.any(|plugin| plugin.metadata.id == "registry-fallback@external"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(bundled_root);
let _ = fs::remove_dir_all(external_install_path);
}
#[test]
fn installed_plugin_discovery_prunes_stale_registry_entries() {
let config_home = temp_dir("registry-prune-home");
let bundled_root = temp_dir("registry-prune-bundled");
let install_root = config_home.join("plugins").join("installed");
let missing_install_path = temp_dir("registry-prune-missing");
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);
let mut registry = InstalledPluginRegistry::default();
registry.plugins.insert(
"stale-external@external".to_string(),
InstalledPluginRecord {
kind: PluginKind::External,
id: "stale-external@external".to_string(),
name: "stale-external".to_string(),
version: "1.0.0".to_string(),
description: "stale external plugin".to_string(),
install_path: missing_install_path.clone(),
source: PluginInstallSource::LocalPath {
path: missing_install_path.clone(),
},
installed_at_unix_ms: 1,
updated_at_unix_ms: 1,
},
);
manager.store_registry(&registry).expect("store registry");
let installed = manager
.list_installed_plugins()
.expect("stale registry entries should be pruned");
assert!(!installed
.iter()
.any(|plugin| plugin.metadata.id == "stale-external@external"));
let registry = manager.load_registry().expect("load registry");
assert!(!registry.plugins.contains_key("stale-external@external"));
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]
fn persists_bundled_plugin_disable_state_across_reloads() {
let config_home = temp_dir("bundled-disabled-home");
let bundled_root = temp_dir("bundled-disabled-root");
write_bundled_plugin(&bundled_root.join("starter"), "starter", "0.1.0", true);
let mut config = PluginManagerConfig::new(&config_home);
config.bundled_root = Some(bundled_root.clone());
let mut manager = PluginManager::new(config);
manager
.disable("starter@bundled")
.expect("disable bundled plugin should succeed");
assert_eq!(
load_enabled_plugins(&manager.settings_path()).get("starter@bundled"),
Some(&false)
);
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]
fn validates_plugin_source_before_install() {
let config_home = temp_dir("validate-home");
let source_root = temp_dir("validate-source");
write_external_plugin(&source_root, "validator", "1.0.0");
let manager = PluginManager::new(PluginManagerConfig::new(&config_home));
let manifest = manager
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
.expect("manifest should validate");
assert_eq!(manifest.name, "validator");
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn plugin_registry_tracks_enabled_state_and_lookup() {
let config_home = temp_dir("registry-home");
let source_root = temp_dir("registry-source");
write_external_plugin(&source_root, "registry-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");
manager
.disable("registry-demo@external")
.expect("disable should succeed");
let registry = manager.plugin_registry().expect("registry should build");
let plugin = registry
.get("registry-demo@external")
.expect("installed plugin should be discoverable");
assert_eq!(plugin.metadata().name, "registry-demo");
assert!(!plugin.is_enabled());
assert!(registry.contains("registry-demo@external"));
assert!(!registry.contains("missing@external"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn rejects_plugin_sources_with_missing_hook_paths() {
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));
let error = manager
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
.expect_err("missing hook file should fail validation");
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 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");
let source_root = temp_dir("lifecycle-source");
let _ = write_lifecycle_plugin(&source_root, "lifecycle-demo", "1.0.0");
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
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");
registry.shutdown().expect("shutdown should succeed");
let log = fs::read_to_string(&log_path).expect("lifecycle log should exist");
assert_eq!(log, "init\nshutdown\n");
let _ = fs::remove_dir_all(config_home);
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);
}
#[test]
fn list_installed_plugins_scans_install_root_without_registry_entries() {
let config_home = temp_dir("installed-scan-home");
let bundled_root = temp_dir("installed-scan-bundled");
let install_root = config_home.join("plugins").join("installed");
let installed_plugin_root = install_root.join("scan-demo");
write_file(
installed_plugin_root.join(MANIFEST_FILE_NAME).as_path(),
r#"{
"name": "scan-demo",
"version": "1.0.0",
"description": "Scanned from install root"
}"#,
);
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);
let installed = manager
.list_installed_plugins()
.expect("installed plugins should scan directories");
assert!(installed
.iter()
.any(|plugin| plugin.metadata.id == "scan-demo@external"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(bundled_root);
}
#[test]
fn list_installed_plugins_scans_packaged_manifests_in_install_root() {
let config_home = temp_dir("installed-packaged-scan-home");
let bundled_root = temp_dir("installed-packaged-scan-bundled");
let install_root = config_home.join("plugins").join("installed");
let installed_plugin_root = install_root.join("scan-packaged");
write_file(
installed_plugin_root.join(MANIFEST_RELATIVE_PATH).as_path(),
r#"{
"name": "scan-packaged",
"version": "1.0.0",
"description": "Packaged manifest in install root"
}"#,
);
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);
let installed = manager
.list_installed_plugins()
.expect("installed plugins should scan packaged manifests");
assert!(installed
.iter()
.any(|plugin| plugin.metadata.id == "scan-packaged@external"));
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(bundled_root);
}
}