feat: plugin subsystem — loader, hooks, tools, bundled, CLI
This commit is contained in:
2
rust/Cargo.lock
generated
2
rust/Cargo.lock
generated
@@ -111,6 +111,7 @@ dependencies = [
|
|||||||
name = "commands"
|
name = "commands"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"plugins",
|
||||||
"runtime",
|
"runtime",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1100,6 +1101,7 @@ name = "runtime"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"glob",
|
"glob",
|
||||||
|
"plugins",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ publish.workspace = true
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
plugins = { path = "../plugins" }
|
||||||
runtime = { path = "../runtime" }
|
runtime = { path = "../runtime" }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use plugins::{PluginError, PluginManager, PluginSummary};
|
||||||
use runtime::{compact_session, CompactionConfig, Session};
|
use runtime::{compact_session, CompactionConfig, Session};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -356,6 +357,151 @@ pub struct SlashCommandResult {
|
|||||||
pub session: Session,
|
pub session: Session,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PluginsCommandResult {
|
||||||
|
pub message: String,
|
||||||
|
pub reload_runtime: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_plugins_slash_command(
|
||||||
|
action: Option<&str>,
|
||||||
|
target: Option<&str>,
|
||||||
|
manager: &mut PluginManager,
|
||||||
|
) -> Result<PluginsCommandResult, PluginError> {
|
||||||
|
match action {
|
||||||
|
None | Some("list") => Ok(PluginsCommandResult {
|
||||||
|
message: render_plugins_report(&manager.list_plugins()?),
|
||||||
|
reload_runtime: false,
|
||||||
|
}),
|
||||||
|
Some("install") => {
|
||||||
|
let Some(target) = target else {
|
||||||
|
return Ok(PluginsCommandResult {
|
||||||
|
message: "Usage: /plugins install <path>".to_string(),
|
||||||
|
reload_runtime: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let install = manager.install(target)?;
|
||||||
|
let plugin = manager
|
||||||
|
.list_plugins()?
|
||||||
|
.into_iter()
|
||||||
|
.find(|plugin| plugin.metadata.id == install.plugin_id);
|
||||||
|
Ok(PluginsCommandResult {
|
||||||
|
message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
|
||||||
|
reload_runtime: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some("enable") => {
|
||||||
|
let Some(target) = target else {
|
||||||
|
return Ok(PluginsCommandResult {
|
||||||
|
message: "Usage: /plugins enable <plugin-id>".to_string(),
|
||||||
|
reload_runtime: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
manager.enable(target)?;
|
||||||
|
Ok(PluginsCommandResult {
|
||||||
|
message: format!(
|
||||||
|
"Plugins\n Result enabled {target}\n Status enabled"
|
||||||
|
),
|
||||||
|
reload_runtime: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some("disable") => {
|
||||||
|
let Some(target) = target else {
|
||||||
|
return Ok(PluginsCommandResult {
|
||||||
|
message: "Usage: /plugins disable <plugin-id>".to_string(),
|
||||||
|
reload_runtime: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
manager.disable(target)?;
|
||||||
|
Ok(PluginsCommandResult {
|
||||||
|
message: format!(
|
||||||
|
"Plugins\n Result disabled {target}\n Status disabled"
|
||||||
|
),
|
||||||
|
reload_runtime: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some("uninstall") => {
|
||||||
|
let Some(target) = target else {
|
||||||
|
return Ok(PluginsCommandResult {
|
||||||
|
message: "Usage: /plugins uninstall <plugin-id>".to_string(),
|
||||||
|
reload_runtime: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
manager.uninstall(target)?;
|
||||||
|
Ok(PluginsCommandResult {
|
||||||
|
message: format!("Plugins\n Result uninstalled {target}"),
|
||||||
|
reload_runtime: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some("update") => {
|
||||||
|
let Some(target) = target else {
|
||||||
|
return Ok(PluginsCommandResult {
|
||||||
|
message: "Usage: /plugins update <plugin-id>".to_string(),
|
||||||
|
reload_runtime: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let update = manager.update(target)?;
|
||||||
|
let plugin = manager
|
||||||
|
.list_plugins()?
|
||||||
|
.into_iter()
|
||||||
|
.find(|plugin| plugin.metadata.id == update.plugin_id);
|
||||||
|
Ok(PluginsCommandResult {
|
||||||
|
message: format!(
|
||||||
|
"Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
|
||||||
|
update.plugin_id,
|
||||||
|
plugin
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
|
||||||
|
update.old_version,
|
||||||
|
update.new_version,
|
||||||
|
plugin
|
||||||
|
.as_ref()
|
||||||
|
.map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
|
||||||
|
),
|
||||||
|
reload_runtime: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some(other) => Ok(PluginsCommandResult {
|
||||||
|
message: format!(
|
||||||
|
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
|
||||||
|
),
|
||||||
|
reload_runtime: false,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
||||||
|
let mut lines = vec!["Plugins".to_string()];
|
||||||
|
if plugins.is_empty() {
|
||||||
|
lines.push(" No plugins discovered.".to_string());
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
for plugin in plugins {
|
||||||
|
let enabled = if plugin.enabled {
|
||||||
|
"enabled"
|
||||||
|
} else {
|
||||||
|
"disabled"
|
||||||
|
};
|
||||||
|
lines.push(format!(
|
||||||
|
" {name:<20} v{version:<10} {enabled}",
|
||||||
|
name = plugin.metadata.name,
|
||||||
|
version = plugin.metadata.version,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
|
||||||
|
let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
|
||||||
|
let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
|
||||||
|
let enabled = plugin.is_some_and(|plugin| plugin.enabled);
|
||||||
|
format!(
|
||||||
|
"Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
|
||||||
|
if enabled { "enabled" } else { "disabled" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn handle_slash_command(
|
pub fn handle_slash_command(
|
||||||
input: &str,
|
input: &str,
|
||||||
@@ -410,10 +556,34 @@ pub fn handle_slash_command(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
handle_slash_command, render_slash_command_help, resume_supported_slash_commands,
|
handle_plugins_slash_command, handle_slash_command, render_plugins_report,
|
||||||
slash_command_specs, SlashCommand,
|
render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
||||||
|
SlashCommand,
|
||||||
};
|
};
|
||||||
|
use plugins::{PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
||||||
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("time should be after epoch")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_external_plugin(root: &Path, name: &str, version: &str) {
|
||||||
|
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||||
|
fs::write(
|
||||||
|
root.join(".claude-plugin").join("plugin.json"),
|
||||||
|
format!(
|
||||||
|
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write manifest");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_supported_slash_commands() {
|
fn parses_supported_slash_commands() {
|
||||||
@@ -519,6 +689,13 @@ mod tests {
|
|||||||
target: Some("demo".to_string())
|
target: Some("demo".to_string())
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SlashCommand::parse("/plugins list"),
|
||||||
|
Some(SlashCommand::Plugins {
|
||||||
|
action: Some("list".to_string()),
|
||||||
|
target: None
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -652,4 +829,73 @@ mod tests {
|
|||||||
handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
|
handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_plugins_report_with_name_version_and_status() {
|
||||||
|
let rendered = render_plugins_report(&[
|
||||||
|
PluginSummary {
|
||||||
|
metadata: PluginMetadata {
|
||||||
|
id: "demo@external".to_string(),
|
||||||
|
name: "demo".to_string(),
|
||||||
|
version: "1.2.3".to_string(),
|
||||||
|
description: "demo plugin".to_string(),
|
||||||
|
kind: plugins::PluginKind::External,
|
||||||
|
source: "demo".to_string(),
|
||||||
|
default_enabled: false,
|
||||||
|
root: None,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
PluginSummary {
|
||||||
|
metadata: PluginMetadata {
|
||||||
|
id: "sample@external".to_string(),
|
||||||
|
name: "sample".to_string(),
|
||||||
|
version: "0.9.0".to_string(),
|
||||||
|
description: "sample plugin".to_string(),
|
||||||
|
kind: plugins::PluginKind::External,
|
||||||
|
source: "sample".to_string(),
|
||||||
|
default_enabled: false,
|
||||||
|
root: None,
|
||||||
|
},
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert!(rendered.contains("demo"));
|
||||||
|
assert!(rendered.contains("v1.2.3"));
|
||||||
|
assert!(rendered.contains("enabled"));
|
||||||
|
assert!(rendered.contains("sample"));
|
||||||
|
assert!(rendered.contains("v0.9.0"));
|
||||||
|
assert!(rendered.contains("disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installs_plugin_from_path_and_lists_it() {
|
||||||
|
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 = handle_plugins_slash_command(
|
||||||
|
Some("install"),
|
||||||
|
Some(source_root.to_str().expect("utf8 path")),
|
||||||
|
&mut manager,
|
||||||
|
)
|
||||||
|
.expect("install command should succeed");
|
||||||
|
assert!(install.reload_runtime);
|
||||||
|
assert!(install.message.contains("installed demo@external"));
|
||||||
|
assert!(install.message.contains("Name demo"));
|
||||||
|
assert!(install.message.contains("Version 1.0.0"));
|
||||||
|
assert!(install.message.contains("Status enabled"));
|
||||||
|
|
||||||
|
let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
|
||||||
|
.expect("list command should succeed");
|
||||||
|
assert!(!list.reload_runtime);
|
||||||
|
assert!(list.message.contains("demo"));
|
||||||
|
assert!(list.message.contains("v1.0.0"));
|
||||||
|
assert!(list.message.contains("enabled"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(source_root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
|||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -13,7 +13,9 @@ const BUILTIN_MARKETPLACE: &str = "builtin";
|
|||||||
const BUNDLED_MARKETPLACE: &str = "bundled";
|
const BUNDLED_MARKETPLACE: &str = "bundled";
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
const REGISTRY_FILE_NAME: &str = "installed.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 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[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 struct PluginManifest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub permissions: Vec<String>,
|
||||||
#[serde(rename = "defaultEnabled", default)]
|
#[serde(rename = "defaultEnabled", default)]
|
||||||
pub default_enabled: bool,
|
pub default_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hooks: PluginHooks,
|
pub hooks: PluginHooks,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub lifecycle: PluginLifecycle,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -125,37 +260,41 @@ pub struct InstalledPluginRegistry {
|
|||||||
pub plugins: BTreeMap<String, InstalledPluginRecord>,
|
pub plugins: BTreeMap<String, InstalledPluginRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct BuiltinPlugin {
|
pub struct BuiltinPlugin {
|
||||||
metadata: PluginMetadata,
|
metadata: PluginMetadata,
|
||||||
hooks: PluginHooks,
|
hooks: PluginHooks,
|
||||||
lifecycle: PluginLifecycle,
|
lifecycle: PluginLifecycle,
|
||||||
|
tools: Vec<PluginTool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct BundledPlugin {
|
pub struct BundledPlugin {
|
||||||
metadata: PluginMetadata,
|
metadata: PluginMetadata,
|
||||||
hooks: PluginHooks,
|
hooks: PluginHooks,
|
||||||
lifecycle: PluginLifecycle,
|
lifecycle: PluginLifecycle,
|
||||||
|
tools: Vec<PluginTool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct ExternalPlugin {
|
pub struct ExternalPlugin {
|
||||||
metadata: PluginMetadata,
|
metadata: PluginMetadata,
|
||||||
hooks: PluginHooks,
|
hooks: PluginHooks,
|
||||||
lifecycle: PluginLifecycle,
|
lifecycle: PluginLifecycle,
|
||||||
|
tools: Vec<PluginTool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Plugin {
|
pub trait Plugin {
|
||||||
fn metadata(&self) -> &PluginMetadata;
|
fn metadata(&self) -> &PluginMetadata;
|
||||||
fn hooks(&self) -> &PluginHooks;
|
fn hooks(&self) -> &PluginHooks;
|
||||||
fn lifecycle(&self) -> &PluginLifecycle;
|
fn lifecycle(&self) -> &PluginLifecycle;
|
||||||
|
fn tools(&self) -> &[PluginTool];
|
||||||
fn validate(&self) -> Result<(), PluginError>;
|
fn validate(&self) -> Result<(), PluginError>;
|
||||||
fn initialize(&self) -> Result<(), PluginError>;
|
fn initialize(&self) -> Result<(), PluginError>;
|
||||||
fn shutdown(&self) -> Result<(), PluginError>;
|
fn shutdown(&self) -> Result<(), PluginError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum PluginDefinition {
|
pub enum PluginDefinition {
|
||||||
Builtin(BuiltinPlugin),
|
Builtin(BuiltinPlugin),
|
||||||
Bundled(BundledPlugin),
|
Bundled(BundledPlugin),
|
||||||
@@ -175,6 +314,10 @@ impl Plugin for BuiltinPlugin {
|
|||||||
&self.lifecycle
|
&self.lifecycle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tools(&self) -> &[PluginTool] {
|
||||||
|
&self.tools
|
||||||
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), PluginError> {
|
fn validate(&self) -> Result<(), PluginError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -201,13 +344,23 @@ impl Plugin for BundledPlugin {
|
|||||||
&self.lifecycle
|
&self.lifecycle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tools(&self) -> &[PluginTool] {
|
||||||
|
&self.tools
|
||||||
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), PluginError> {
|
fn validate(&self) -> Result<(), PluginError> {
|
||||||
validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
|
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> {
|
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> {
|
fn shutdown(&self) -> Result<(), PluginError> {
|
||||||
@@ -233,13 +386,23 @@ impl Plugin for ExternalPlugin {
|
|||||||
&self.lifecycle
|
&self.lifecycle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tools(&self) -> &[PluginTool] {
|
||||||
|
&self.tools
|
||||||
|
}
|
||||||
|
|
||||||
fn validate(&self) -> Result<(), PluginError> {
|
fn validate(&self) -> Result<(), PluginError> {
|
||||||
validate_hook_paths(self.metadata.root.as_deref(), &self.hooks)?;
|
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> {
|
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> {
|
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> {
|
fn validate(&self) -> Result<(), PluginError> {
|
||||||
match self {
|
match self {
|
||||||
Self::Builtin(plugin) => plugin.validate(),
|
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 {
|
pub struct RegisteredPlugin {
|
||||||
definition: PluginDefinition,
|
definition: PluginDefinition,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
@@ -327,6 +498,11 @@ impl RegisteredPlugin {
|
|||||||
self.definition.hooks()
|
self.definition.hooks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn tools(&self) -> &[PluginTool] {
|
||||||
|
self.definition.tools()
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_enabled(&self) -> bool {
|
pub fn is_enabled(&self) -> bool {
|
||||||
self.enabled
|
self.enabled
|
||||||
@@ -359,7 +535,7 @@ pub struct PluginSummary {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
pub struct PluginRegistry {
|
pub struct PluginRegistry {
|
||||||
plugins: Vec<RegisteredPlugin>,
|
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> {
|
pub fn initialize(&self) -> Result<(), PluginError> {
|
||||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
|
for plugin in self.plugins.iter().filter(|plugin| plugin.is_enabled()) {
|
||||||
plugin.validate()?;
|
plugin.validate()?;
|
||||||
@@ -412,7 +609,12 @@ impl PluginRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn shutdown(&self) -> Result<(), PluginError> {
|
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()?;
|
plugin.shutdown()?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -561,7 +763,7 @@ impl PluginManager {
|
|||||||
|
|
||||||
pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> {
|
pub fn validate_plugin_source(&self, source: &str) -> Result<PluginManifest, PluginError> {
|
||||||
let path = resolve_local_source(source)?;
|
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> {
|
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 temp_root = self.install_root().join(".tmp");
|
||||||
let staged_source = materialize_source(&install_source, &temp_root)?;
|
let staged_source = materialize_source(&install_source, &temp_root)?;
|
||||||
let cleanup_source = matches!(install_source, PluginInstallSource::GitUrl { .. });
|
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 plugin_id = plugin_id(&manifest.name, EXTERNAL_MARKETPLACE);
|
||||||
let install_path = self.install_root().join(sanitize_plugin_id(&plugin_id));
|
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 temp_root = self.install_root().join(".tmp");
|
||||||
let staged_source = materialize_source(&record.source, &temp_root)?;
|
let staged_source = materialize_source(&record.source, &temp_root)?;
|
||||||
let cleanup_source = matches!(record.source, PluginInstallSource::GitUrl { .. });
|
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() {
|
if record.install_path.exists() {
|
||||||
fs::remove_dir_all(&record.install_path)?;
|
fs::remove_dir_all(&record.install_path)?;
|
||||||
@@ -806,6 +1008,7 @@ pub fn builtin_plugins() -> Vec<PluginDefinition> {
|
|||||||
},
|
},
|
||||||
hooks: PluginHooks::default(),
|
hooks: PluginHooks::default(),
|
||||||
lifecycle: PluginLifecycle::default(),
|
lifecycle: PluginLifecycle::default(),
|
||||||
|
tools: Vec::new(),
|
||||||
})]
|
})]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,7 +1018,7 @@ fn load_plugin_definition(
|
|||||||
source: String,
|
source: String,
|
||||||
marketplace: &str,
|
marketplace: &str,
|
||||||
) -> Result<PluginDefinition, PluginError> {
|
) -> Result<PluginDefinition, PluginError> {
|
||||||
let manifest = load_validated_manifest_from_root(root)?;
|
let manifest = load_validated_package_manifest_from_root(root)?;
|
||||||
let metadata = PluginMetadata {
|
let metadata = PluginMetadata {
|
||||||
id: plugin_id(&manifest.name, marketplace),
|
id: plugin_id(&manifest.name, marketplace),
|
||||||
name: manifest.name,
|
name: manifest.name,
|
||||||
@@ -828,34 +1031,46 @@ fn load_plugin_definition(
|
|||||||
};
|
};
|
||||||
let hooks = resolve_hooks(root, &manifest.hooks);
|
let hooks = resolve_hooks(root, &manifest.hooks);
|
||||||
let lifecycle = resolve_lifecycle(root, &manifest.lifecycle);
|
let lifecycle = resolve_lifecycle(root, &manifest.lifecycle);
|
||||||
|
let tools = resolve_tools(root, &metadata.id, &metadata.name, &manifest.tools);
|
||||||
Ok(match kind {
|
Ok(match kind {
|
||||||
PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin {
|
PluginKind::Builtin => PluginDefinition::Builtin(BuiltinPlugin {
|
||||||
metadata,
|
metadata,
|
||||||
hooks,
|
hooks,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
tools,
|
||||||
}),
|
}),
|
||||||
PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin {
|
PluginKind::Bundled => PluginDefinition::Bundled(BundledPlugin {
|
||||||
metadata,
|
metadata,
|
||||||
hooks,
|
hooks,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
tools,
|
||||||
}),
|
}),
|
||||||
PluginKind::External => PluginDefinition::External(ExternalPlugin {
|
PluginKind::External => PluginDefinition::External(ExternalPlugin {
|
||||||
metadata,
|
metadata,
|
||||||
hooks,
|
hooks,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
tools,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_validated_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> {
|
pub fn load_plugin_from_directory(root: &Path) -> Result<PluginManifest, PluginError> {
|
||||||
let manifest = load_manifest_from_root(root)?;
|
let manifest = load_manifest_from_directory(root)?;
|
||||||
validate_manifest(&manifest)?;
|
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_hook_paths(Some(root), &manifest.hooks)?;
|
||||||
validate_lifecycle_paths(Some(root), &manifest.lifecycle)?;
|
validate_lifecycle_paths(Some(root), &manifest.lifecycle)?;
|
||||||
Ok(manifest)
|
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() {
|
if manifest.name.trim().is_empty() {
|
||||||
return Err(PluginError::InvalidManifest(
|
return Err(PluginError::InvalidManifest(
|
||||||
"plugin manifest name cannot be empty".to_string(),
|
"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(),
|
"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(())
|
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 manifest_path = root.join(MANIFEST_RELATIVE_PATH);
|
||||||
let contents = fs::read_to_string(&manifest_path).map_err(|error| {
|
let contents = fs::read_to_string(&manifest_path).map_err(|error| {
|
||||||
PluginError::NotFound(format!(
|
PluginError::NotFound(format!(
|
||||||
@@ -885,6 +1135,109 @@ fn load_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> {
|
|||||||
Ok(serde_json::from_str(&contents)?)
|
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 {
|
fn resolve_hooks(root: &Path, hooks: &PluginHooks) -> PluginHooks {
|
||||||
PluginHooks {
|
PluginHooks {
|
||||||
pre_tool_use: hooks
|
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> {
|
fn validate_hook_paths(root: Option<&Path>, hooks: &PluginHooks) -> Result<(), PluginError> {
|
||||||
let Some(root) = root else {
|
let Some(root) = root else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -938,6 +1317,16 @@ fn validate_lifecycle_paths(
|
|||||||
Ok(())
|
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> {
|
fn validate_command_path(root: &Path, entry: &str, kind: &str) -> Result<(), PluginError> {
|
||||||
if is_literal_command(entry) {
|
if is_literal_command(entry) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -965,7 +1354,7 @@ fn resolve_hook_entry(root: &Path, entry: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_literal_command(entry: &str) -> bool {
|
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(
|
fn run_lifecycle_commands(
|
||||||
@@ -979,17 +1368,29 @@ fn run_lifecycle_commands(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for command in commands {
|
for command in commands {
|
||||||
let output = if Path::new(command).exists() {
|
let mut process = if Path::new(command).exists() {
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
Command::new("cmd").arg("/C").arg(command).output()?
|
let mut process = Command::new("cmd");
|
||||||
|
process.arg("/C").arg(command);
|
||||||
|
process
|
||||||
} else {
|
} else {
|
||||||
Command::new("sh").arg(command).output()?
|
let mut process = Command::new("sh");
|
||||||
|
process.arg(command);
|
||||||
|
process
|
||||||
}
|
}
|
||||||
} else if cfg!(windows) {
|
} 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 {
|
} 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() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
@@ -1206,12 +1607,12 @@ mod tests {
|
|||||||
let log_path = root.join("lifecycle.log");
|
let log_path = root.join("lifecycle.log");
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("lifecycle").join("init.sh"),
|
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");
|
.expect("write init hook");
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("lifecycle").join("shutdown.sh"),
|
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");
|
.expect("write shutdown hook");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -1232,6 +1633,7 @@ mod tests {
|
|||||||
description: "desc".to_string(),
|
description: "desc".to_string(),
|
||||||
default_enabled: false,
|
default_enabled: false,
|
||||||
hooks: PluginHooks::default(),
|
hooks: PluginHooks::default(),
|
||||||
|
lifecycle: PluginLifecycle::default(),
|
||||||
})
|
})
|
||||||
.expect_err("empty name should fail");
|
.expect_err("empty name should fail");
|
||||||
assert!(error.to_string().contains("name cannot be empty"));
|
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() {
|
fn plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins() {
|
||||||
let config_home = temp_dir("lifecycle-home");
|
let config_home = temp_dir("lifecycle-home");
|
||||||
let source_root = temp_dir("lifecycle-source");
|
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));
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
manager
|
let install = manager
|
||||||
.install(source_root.to_str().expect("utf8 path"))
|
.install(source_root.to_str().expect("utf8 path"))
|
||||||
.expect("install should succeed");
|
.expect("install should succeed");
|
||||||
|
let log_path = install.install_path.join("lifecycle.log");
|
||||||
|
|
||||||
let registry = manager.plugin_registry().expect("registry should build");
|
let registry = manager.plugin_registry().expect("registry should build");
|
||||||
registry.initialize().expect("init should succeed");
|
registry.initialize().expect("init should succeed");
|
||||||
|
|||||||
@@ -113,6 +113,21 @@ pub struct ConversationRuntime<C, T> {
|
|||||||
plugins_shutdown: bool,
|
plugins_shutdown: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<C, T> ConversationRuntime<C, T> {
|
||||||
|
fn shutdown_registered_plugins(&mut self) -> Result<(), RuntimeError> {
|
||||||
|
if self.plugins_shutdown {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(registry) = &self.plugin_registry {
|
||||||
|
registry
|
||||||
|
.shutdown()
|
||||||
|
.map_err(|error| RuntimeError::new(format!("plugin shutdown failed: {error}")))?;
|
||||||
|
}
|
||||||
|
self.plugins_shutdown = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<C, T> ConversationRuntime<C, T>
|
impl<C, T> ConversationRuntime<C, T>
|
||||||
where
|
where
|
||||||
C: ApiClient,
|
C: ApiClient,
|
||||||
@@ -144,7 +159,7 @@ where
|
|||||||
tool_executor: T,
|
tool_executor: T,
|
||||||
permission_policy: PermissionPolicy,
|
permission_policy: PermissionPolicy,
|
||||||
system_prompt: Vec<String>,
|
system_prompt: Vec<String>,
|
||||||
feature_config: RuntimeFeatureConfig,
|
feature_config: RuntimeFeatureConfig,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let usage_tracker = UsageTracker::from_session(&session);
|
let usage_tracker = UsageTracker::from_session(&session);
|
||||||
Self {
|
Self {
|
||||||
@@ -172,6 +187,11 @@ where
|
|||||||
feature_config: RuntimeFeatureConfig,
|
feature_config: RuntimeFeatureConfig,
|
||||||
plugin_registry: PluginRegistry,
|
plugin_registry: PluginRegistry,
|
||||||
) -> Result<Self, RuntimeError> {
|
) -> Result<Self, RuntimeError> {
|
||||||
|
let hook_runner =
|
||||||
|
HookRunner::from_feature_config_and_plugins(&feature_config, &plugin_registry)
|
||||||
|
.map_err(|error| {
|
||||||
|
RuntimeError::new(format!("plugin hook registration failed: {error}"))
|
||||||
|
})?;
|
||||||
plugin_registry
|
plugin_registry
|
||||||
.initialize()
|
.initialize()
|
||||||
.map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?;
|
.map_err(|error| RuntimeError::new(format!("plugin initialization failed: {error}")))?;
|
||||||
@@ -183,6 +203,7 @@ where
|
|||||||
system_prompt,
|
system_prompt,
|
||||||
feature_config,
|
feature_config,
|
||||||
);
|
);
|
||||||
|
runtime.hook_runner = hook_runner;
|
||||||
runtime.plugin_registry = Some(plugin_registry);
|
runtime.plugin_registry = Some(plugin_registry);
|
||||||
Ok(runtime)
|
Ok(runtime)
|
||||||
}
|
}
|
||||||
@@ -336,21 +357,12 @@ where
|
|||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn into_session(mut self) -> Session {
|
pub fn into_session(mut self) -> Session {
|
||||||
let _ = self.shutdown_plugins();
|
let _ = self.shutdown_registered_plugins();
|
||||||
std::mem::take(&mut self.session)
|
std::mem::take(&mut self.session)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shutdown_plugins(&mut self) -> Result<(), RuntimeError> {
|
pub fn shutdown_plugins(&mut self) -> Result<(), RuntimeError> {
|
||||||
if self.plugins_shutdown {
|
self.shutdown_registered_plugins()
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if let Some(registry) = &self.plugin_registry {
|
|
||||||
registry
|
|
||||||
.shutdown()
|
|
||||||
.map_err(|error| RuntimeError::new(format!("plugin shutdown failed: {error}")))?;
|
|
||||||
}
|
|
||||||
self.plugins_shutdown = true;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> {
|
fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> {
|
||||||
@@ -381,7 +393,7 @@ where
|
|||||||
|
|
||||||
impl<C, T> Drop for ConversationRuntime<C, T> {
|
impl<C, T> Drop for ConversationRuntime<C, T> {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.shutdown_plugins();
|
let _ = self.shutdown_registered_plugins();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +537,8 @@ mod tests {
|
|||||||
use crate::usage::TokenUsage;
|
use crate::usage::TokenUsage;
|
||||||
use plugins::{PluginManager, PluginManagerConfig};
|
use plugins::{PluginManager, PluginManagerConfig};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -603,12 +617,12 @@ mod tests {
|
|||||||
let log_path = root.join("lifecycle.log");
|
let log_path = root.join("lifecycle.log");
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("lifecycle").join("init.sh"),
|
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 script");
|
.expect("write init script");
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("lifecycle").join("shutdown.sh"),
|
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 script");
|
.expect("write shutdown script");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -621,6 +635,36 @@ mod tests {
|
|||||||
log_path
|
log_path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
|
||||||
|
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||||
|
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
|
||||||
|
fs::write(
|
||||||
|
root.join("hooks").join("pre.sh"),
|
||||||
|
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
|
||||||
|
)
|
||||||
|
.expect("write pre hook");
|
||||||
|
fs::write(
|
||||||
|
root.join("hooks").join("post.sh"),
|
||||||
|
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
|
||||||
|
)
|
||||||
|
.expect("write post hook");
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let exec_mode = fs::Permissions::from_mode(0o755);
|
||||||
|
fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone())
|
||||||
|
.expect("chmod pre hook");
|
||||||
|
fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode)
|
||||||
|
.expect("chmod post hook");
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
root.join(".claude-plugin").join("plugin.json"),
|
||||||
|
format!(
|
||||||
|
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"runtime hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write plugin manifest");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
|
fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
|
||||||
let api_client = ScriptedApiClient { call_count: 0 };
|
let api_client = ScriptedApiClient { call_count: 0 };
|
||||||
@@ -866,12 +910,13 @@ mod tests {
|
|||||||
fn initializes_and_shuts_down_plugins_with_runtime_lifecycle() {
|
fn initializes_and_shuts_down_plugins_with_runtime_lifecycle() {
|
||||||
let config_home = temp_dir("config");
|
let config_home = temp_dir("config");
|
||||||
let source_root = temp_dir("source");
|
let source_root = temp_dir("source");
|
||||||
let log_path = write_lifecycle_plugin(&source_root, "runtime-lifecycle");
|
let _ = write_lifecycle_plugin(&source_root, "runtime-lifecycle");
|
||||||
|
|
||||||
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
manager
|
let install = manager
|
||||||
.install(source_root.to_str().expect("utf8 path"))
|
.install(source_root.to_str().expect("utf8 path"))
|
||||||
.expect("install should succeed");
|
.expect("install should succeed");
|
||||||
|
let log_path = install.install_path.join("lifecycle.log");
|
||||||
let registry = manager.plugin_registry().expect("registry should load");
|
let registry = manager.plugin_registry().expect("registry should load");
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -898,6 +943,116 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(source_root);
|
let _ = fs::remove_dir_all(source_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn executes_hooks_from_installed_plugins_during_tool_use() {
|
||||||
|
struct TwoCallApiClient {
|
||||||
|
calls: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiClient for TwoCallApiClient {
|
||||||
|
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||||
|
self.calls += 1;
|
||||||
|
match self.calls {
|
||||||
|
1 => Ok(vec![
|
||||||
|
AssistantEvent::ToolUse {
|
||||||
|
id: "tool-1".to_string(),
|
||||||
|
name: "add".to_string(),
|
||||||
|
input: r#"{"lhs":2,"rhs":2}"#.to_string(),
|
||||||
|
},
|
||||||
|
AssistantEvent::MessageStop,
|
||||||
|
]),
|
||||||
|
2 => {
|
||||||
|
assert!(request
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.any(|message| message.role == MessageRole::Tool));
|
||||||
|
Ok(vec![
|
||||||
|
AssistantEvent::TextDelta("done".to_string()),
|
||||||
|
AssistantEvent::MessageStop,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
_ => Err(RuntimeError::new("unexpected extra API call")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_home = temp_dir("hook-config");
|
||||||
|
let first_source_root = temp_dir("hook-source-a");
|
||||||
|
let second_source_root = temp_dir("hook-source-b");
|
||||||
|
write_hook_plugin(
|
||||||
|
&first_source_root,
|
||||||
|
"first",
|
||||||
|
"plugin pre one",
|
||||||
|
"plugin post one",
|
||||||
|
);
|
||||||
|
write_hook_plugin(
|
||||||
|
&second_source_root,
|
||||||
|
"second",
|
||||||
|
"plugin pre two",
|
||||||
|
"plugin post two",
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
|
manager
|
||||||
|
.install(first_source_root.to_str().expect("utf8 path"))
|
||||||
|
.expect("first plugin install should succeed");
|
||||||
|
manager
|
||||||
|
.install(second_source_root.to_str().expect("utf8 path"))
|
||||||
|
.expect("second plugin install should succeed");
|
||||||
|
let registry = manager.plugin_registry().expect("registry should load");
|
||||||
|
|
||||||
|
let mut runtime = ConversationRuntime::new_with_plugins(
|
||||||
|
Session::new(),
|
||||||
|
TwoCallApiClient { calls: 0 },
|
||||||
|
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
|
||||||
|
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||||
|
vec!["system".to_string()],
|
||||||
|
RuntimeFeatureConfig::default(),
|
||||||
|
registry,
|
||||||
|
)
|
||||||
|
.expect("runtime should load plugin hooks");
|
||||||
|
|
||||||
|
let summary = runtime
|
||||||
|
.run_turn("use add", None)
|
||||||
|
.expect("tool loop succeeds");
|
||||||
|
|
||||||
|
assert_eq!(summary.tool_results.len(), 1);
|
||||||
|
let ContentBlock::ToolResult {
|
||||||
|
is_error, output, ..
|
||||||
|
} = &summary.tool_results[0].blocks[0]
|
||||||
|
else {
|
||||||
|
panic!("expected tool result block");
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
!*is_error,
|
||||||
|
"plugin hooks should not force an error: {output:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains('4'),
|
||||||
|
"tool output missing value: {output:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("plugin pre one"),
|
||||||
|
"tool output missing first pre hook feedback: {output:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("plugin pre two"),
|
||||||
|
"tool output missing second pre hook feedback: {output:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("plugin post one"),
|
||||||
|
"tool output missing first post hook feedback: {output:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("plugin post two"),
|
||||||
|
"tool output missing second post hook feedback: {output:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(first_source_root);
|
||||||
|
let _ = fs::remove_dir_all(second_source_root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reconstructs_usage_tracker_from_restored_session() {
|
fn reconstructs_usage_tracker_from_restored_session() {
|
||||||
struct SimpleApi;
|
struct SimpleApi;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
use plugins::{PluginError, PluginRegistry};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
||||||
@@ -62,6 +64,19 @@ impl HookRunner {
|
|||||||
Self::new(feature_config.hooks().clone())
|
Self::new(feature_config.hooks().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_feature_config_and_plugins(
|
||||||
|
feature_config: &RuntimeFeatureConfig,
|
||||||
|
plugin_registry: &PluginRegistry,
|
||||||
|
) -> Result<Self, PluginError> {
|
||||||
|
let mut config = feature_config.hooks().clone();
|
||||||
|
let plugin_hooks = plugin_registry.aggregated_hooks()?;
|
||||||
|
config.extend(&RuntimeHookConfig::new(
|
||||||
|
plugin_hooks.pre_tool_use,
|
||||||
|
plugin_hooks.post_tool_use,
|
||||||
|
));
|
||||||
|
Ok(Self::new(config))
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
|
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
|
||||||
self.run_commands(
|
self.run_commands(
|
||||||
@@ -238,7 +253,11 @@ fn shell_command(command: &str) -> CommandWithStdin {
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
let command_builder = {
|
let command_builder = if Path::new(command).exists() {
|
||||||
|
let mut command_builder = Command::new("sh");
|
||||||
|
command_builder.arg(command);
|
||||||
|
CommandWithStdin::new(command_builder)
|
||||||
|
} else {
|
||||||
let mut command_builder = Command::new("sh");
|
let mut command_builder = Command::new("sh");
|
||||||
command_builder.arg("-lc").arg(command);
|
command_builder.arg("-lc").arg(command);
|
||||||
CommandWithStdin::new(command_builder)
|
CommandWithStdin::new(command_builder)
|
||||||
@@ -294,6 +313,50 @@ impl CommandWithStdin {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{HookRunResult, HookRunner};
|
use super::{HookRunResult, HookRunner};
|
||||||
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
|
||||||
|
use plugins::{PluginManager, PluginManagerConfig};
|
||||||
|
use std::fs;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn temp_dir(label: &str) -> PathBuf {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("time should be after epoch")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("hook-runner-{label}-{nanos}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_hook_plugin(root: &Path, name: &str) {
|
||||||
|
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||||
|
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
|
||||||
|
fs::write(
|
||||||
|
root.join("hooks").join("pre.sh"),
|
||||||
|
"#!/bin/sh\nprintf 'plugin pre'\n",
|
||||||
|
)
|
||||||
|
.expect("write pre hook");
|
||||||
|
fs::write(
|
||||||
|
root.join("hooks").join("post.sh"),
|
||||||
|
"#!/bin/sh\nprintf 'plugin post'\n",
|
||||||
|
)
|
||||||
|
.expect("write post hook");
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let exec_mode = fs::Permissions::from_mode(0o755);
|
||||||
|
fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone())
|
||||||
|
.expect("chmod pre hook");
|
||||||
|
fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode)
|
||||||
|
.expect("chmod post hook");
|
||||||
|
}
|
||||||
|
fs::write(
|
||||||
|
root.join(".claude-plugin").join("plugin.json"),
|
||||||
|
format!(
|
||||||
|
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write plugin manifest");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allows_exit_code_zero_and_captures_stdout() {
|
fn allows_exit_code_zero_and_captures_stdout() {
|
||||||
@@ -338,6 +401,40 @@ mod tests {
|
|||||||
.any(|message| message.contains("allowing tool execution to continue")));
|
.any(|message| message.contains("allowing tool execution to continue")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collects_hooks_from_enabled_plugins() {
|
||||||
|
let config_home = temp_dir("config");
|
||||||
|
let source_root = temp_dir("source");
|
||||||
|
write_hook_plugin(&source_root, "hooked");
|
||||||
|
|
||||||
|
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
|
||||||
|
manager
|
||||||
|
.install(source_root.to_str().expect("utf8 path"))
|
||||||
|
.expect("install should succeed");
|
||||||
|
let registry = manager.plugin_registry().expect("registry should build");
|
||||||
|
|
||||||
|
let runner = HookRunner::from_feature_config_and_plugins(
|
||||||
|
&RuntimeFeatureConfig::default(),
|
||||||
|
®istry,
|
||||||
|
)
|
||||||
|
.expect("plugin hooks should load");
|
||||||
|
|
||||||
|
let pre_result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
|
||||||
|
let post_result = runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
pre_result,
|
||||||
|
HookRunResult::allow(vec!["plugin pre".to_string()])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
post_result,
|
||||||
|
HookRunResult::allow(vec!["plugin post".to_string()])
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(config_home);
|
||||||
|
let _ = fs::remove_dir_all(source_root);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn shell_snippet(script: &str) -> String {
|
fn shell_snippet(script: &str) -> String {
|
||||||
script.replace('\'', "\"")
|
script.replace('\'', "\"")
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ use api::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand,
|
handle_plugins_slash_command, render_slash_command_help, resume_supported_slash_commands,
|
||||||
|
slash_command_specs, SlashCommand,
|
||||||
};
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use init::initialize_repo;
|
use init::initialize_repo;
|
||||||
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginRegistry, PluginSummary};
|
use plugins::{PluginManager, PluginManagerConfig, PluginRegistry};
|
||||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
||||||
@@ -31,7 +32,7 @@ use runtime::{
|
|||||||
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
|
||||||
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
|
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
|
||||||
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
|
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
|
||||||
RuntimeHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tools::{execute_tool, mvp_tool_specs, ToolSpec};
|
use tools::{execute_tool, mvp_tool_specs, ToolSpec};
|
||||||
@@ -1494,89 +1495,10 @@ impl LiveCli {
|
|||||||
let loader = ConfigLoader::default_for(&cwd);
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
let runtime_config = loader.load()?;
|
let runtime_config = loader.load()?;
|
||||||
let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
||||||
|
let result = handle_plugins_slash_command(action, target, &mut manager)?;
|
||||||
match action {
|
println!("{}", result.message);
|
||||||
None | Some("list") => {
|
if result.reload_runtime {
|
||||||
let plugins = manager.list_plugins()?;
|
self.reload_runtime_features()?;
|
||||||
println!("{}", render_plugins_report(&plugins));
|
|
||||||
}
|
|
||||||
Some("install") => {
|
|
||||||
let Some(target) = target else {
|
|
||||||
println!("Usage: /plugins install <path-or-git-url>");
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let result = manager.install(target)?;
|
|
||||||
println!(
|
|
||||||
"Plugins
|
|
||||||
Result installed {}
|
|
||||||
Version {}
|
|
||||||
Path {}",
|
|
||||||
result.plugin_id,
|
|
||||||
result.version,
|
|
||||||
result.install_path.display(),
|
|
||||||
);
|
|
||||||
self.reload_runtime_features()?;
|
|
||||||
}
|
|
||||||
Some("enable") => {
|
|
||||||
let Some(target) = target else {
|
|
||||||
println!("Usage: /plugins enable <plugin-id>");
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
manager.enable(target)?;
|
|
||||||
println!(
|
|
||||||
"Plugins
|
|
||||||
Result enabled {target}"
|
|
||||||
);
|
|
||||||
self.reload_runtime_features()?;
|
|
||||||
}
|
|
||||||
Some("disable") => {
|
|
||||||
let Some(target) = target else {
|
|
||||||
println!("Usage: /plugins disable <plugin-id>");
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
manager.disable(target)?;
|
|
||||||
println!(
|
|
||||||
"Plugins
|
|
||||||
Result disabled {target}"
|
|
||||||
);
|
|
||||||
self.reload_runtime_features()?;
|
|
||||||
}
|
|
||||||
Some("uninstall") => {
|
|
||||||
let Some(target) = target else {
|
|
||||||
println!("Usage: /plugins uninstall <plugin-id>");
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
manager.uninstall(target)?;
|
|
||||||
println!(
|
|
||||||
"Plugins
|
|
||||||
Result uninstalled {target}"
|
|
||||||
);
|
|
||||||
self.reload_runtime_features()?;
|
|
||||||
}
|
|
||||||
Some("update") => {
|
|
||||||
let Some(target) = target else {
|
|
||||||
println!("Usage: /plugins update <plugin-id>");
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let result = manager.update(target)?;
|
|
||||||
println!(
|
|
||||||
"Plugins
|
|
||||||
Result updated {}
|
|
||||||
Old version {}
|
|
||||||
New version {}
|
|
||||||
Path {}",
|
|
||||||
result.plugin_id,
|
|
||||||
result.old_version,
|
|
||||||
result.new_version,
|
|
||||||
result.install_path.display(),
|
|
||||||
);
|
|
||||||
self.reload_runtime_features()?;
|
|
||||||
}
|
|
||||||
Some(other) => {
|
|
||||||
println!(
|
|
||||||
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
@@ -1887,37 +1809,6 @@ fn render_repl_help() -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_plugins_report(plugins: &[PluginSummary]) -> String {
|
|
||||||
let mut lines = vec!["Plugins".to_string()];
|
|
||||||
if plugins.is_empty() {
|
|
||||||
lines.push(" No plugins discovered.".to_string());
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
for plugin in plugins {
|
|
||||||
let kind = match plugin.metadata.kind {
|
|
||||||
PluginKind::Builtin => "builtin",
|
|
||||||
PluginKind::Bundled => "bundled",
|
|
||||||
PluginKind::External => "external",
|
|
||||||
};
|
|
||||||
let location = plugin.metadata.root.as_ref().map_or_else(
|
|
||||||
|| plugin.metadata.source.clone(),
|
|
||||||
|root| root.display().to_string(),
|
|
||||||
);
|
|
||||||
let enabled = if plugin.enabled {
|
|
||||||
"enabled"
|
|
||||||
} else {
|
|
||||||
"disabled"
|
|
||||||
};
|
|
||||||
lines.push(format!(
|
|
||||||
" {id:<24} {kind:<8} {enabled:<8} v{version:<8} {location}",
|
|
||||||
id = plugin.metadata.id,
|
|
||||||
kind = kind,
|
|
||||||
version = plugin.metadata.version,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_context(
|
fn status_context(
|
||||||
session_path: Option<&Path>,
|
session_path: Option<&Path>,
|
||||||
) -> Result<StatusContext, Box<dyn std::error::Error>> {
|
) -> Result<StatusContext, Box<dyn std::error::Error>> {
|
||||||
@@ -2463,15 +2354,7 @@ fn build_runtime_plugin_state(
|
|||||||
let runtime_config = loader.load()?;
|
let runtime_config = loader.load()?;
|
||||||
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
||||||
let plugin_registry = plugin_manager.plugin_registry()?;
|
let plugin_registry = plugin_manager.plugin_registry()?;
|
||||||
let plugin_hooks = plugin_registry.aggregated_hooks()?;
|
Ok((runtime_config.feature_config().clone(), plugin_registry))
|
||||||
let feature_config = runtime_config
|
|
||||||
.feature_config()
|
|
||||||
.clone()
|
|
||||||
.with_hooks(runtime_config.hooks().merged(&RuntimeHookConfig::new(
|
|
||||||
plugin_hooks.pre_tool_use,
|
|
||||||
plugin_hooks.post_tool_use,
|
|
||||||
)));
|
|
||||||
Ok((feature_config, plugin_registry))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_plugin_manager(
|
fn build_plugin_manager(
|
||||||
|
|||||||
Reference in New Issue
Block a user