mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 22:01:51 +08:00
feat: plugin registry + validation + hooks
This commit is contained in:
@@ -207,12 +207,100 @@ impl Plugin for PluginDefinition {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
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 is_enabled(&self) -> bool {
|
||||||
|
self.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), PluginError> {
|
||||||
|
self.definition.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn summary(&self) -> PluginSummary {
|
||||||
|
PluginSummary {
|
||||||
|
metadata: self.metadata().clone(),
|
||||||
|
enabled: self.enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PluginSummary {
|
pub struct PluginSummary {
|
||||||
pub metadata: PluginMetadata,
|
pub metadata: PluginMetadata,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
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()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PluginManagerConfig {
|
pub struct PluginManagerConfig {
|
||||||
pub config_home: PathBuf,
|
pub config_home: PathBuf,
|
||||||
@@ -326,17 +414,20 @@ impl PluginManager {
|
|||||||
self.config.config_home.join(SETTINGS_FILE_NAME)
|
self.config.config_home.join(SETTINGS_FILE_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
|
pub fn plugin_registry(&self) -> Result<PluginRegistry, PluginError> {
|
||||||
let mut plugins = self
|
Ok(PluginRegistry::new(
|
||||||
.discover_plugins()?
|
self.discover_plugins()?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|plugin| PluginSummary {
|
.map(|plugin| {
|
||||||
enabled: self.is_enabled(plugin.metadata()),
|
let enabled = self.is_enabled(plugin.metadata());
|
||||||
metadata: plugin.metadata().clone(),
|
RegisteredPlugin::new(plugin, enabled)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect(),
|
||||||
plugins.sort_by(|left, right| left.metadata.id.cmp(&right.metadata.id));
|
))
|
||||||
Ok(plugins)
|
}
|
||||||
|
|
||||||
|
pub fn list_plugins(&self) -> Result<Vec<PluginSummary>, PluginError> {
|
||||||
|
Ok(self.plugin_registry()?.summaries())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
|
pub fn discover_plugins(&self) -> Result<Vec<PluginDefinition>, PluginError> {
|
||||||
@@ -347,18 +438,12 @@ impl PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
|
pub fn aggregated_hooks(&self) -> Result<PluginHooks, PluginError> {
|
||||||
self.discover_plugins()?
|
self.plugin_registry()?.aggregated_hooks()
|
||||||
.into_iter()
|
|
||||||
.filter(|plugin| self.is_enabled(plugin.metadata()))
|
|
||||||
.try_fold(PluginHooks::default(), |acc, plugin| {
|
|
||||||
plugin.validate()?;
|
|
||||||
Ok(acc.merged_with(plugin.hooks()))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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_manifest_from_root(&path)
|
load_validated_manifest_from_root(&path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> {
|
pub fn install(&mut self, source: &str) -> Result<InstallOutcome, PluginError> {
|
||||||
@@ -366,8 +451,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_manifest_from_root(&staged_source)?;
|
let manifest = load_validated_manifest_from_root(&staged_source)?;
|
||||||
validate_manifest(&manifest)?;
|
|
||||||
|
|
||||||
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));
|
||||||
@@ -445,8 +529,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_manifest_from_root(&staged_source)?;
|
let manifest = load_validated_manifest_from_root(&staged_source)?;
|
||||||
validate_manifest(&manifest)?;
|
|
||||||
|
|
||||||
if record.install_path.exists() {
|
if record.install_path.exists() {
|
||||||
fs::remove_dir_all(&record.install_path)?;
|
fs::remove_dir_all(&record.install_path)?;
|
||||||
@@ -542,11 +625,7 @@ impl PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_known_plugin(&self, plugin_id: &str) -> Result<(), PluginError> {
|
fn ensure_known_plugin(&self, plugin_id: &str) -> Result<(), PluginError> {
|
||||||
if self
|
if self.plugin_registry()?.contains(plugin_id) {
|
||||||
.list_plugins()?
|
|
||||||
.iter()
|
|
||||||
.any(|plugin| plugin.metadata.id == plugin_id)
|
|
||||||
{
|
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(PluginError::NotFound(format!(
|
Err(PluginError::NotFound(format!(
|
||||||
@@ -617,8 +696,7 @@ fn load_plugin_definition(
|
|||||||
source: String,
|
source: String,
|
||||||
marketplace: &str,
|
marketplace: &str,
|
||||||
) -> Result<PluginDefinition, PluginError> {
|
) -> Result<PluginDefinition, PluginError> {
|
||||||
let manifest = load_manifest_from_root(root)?;
|
let manifest = load_validated_manifest_from_root(root)?;
|
||||||
validate_manifest(&manifest)?;
|
|
||||||
let metadata = PluginMetadata {
|
let metadata = PluginMetadata {
|
||||||
id: plugin_id(&manifest.name, marketplace),
|
id: plugin_id(&manifest.name, marketplace),
|
||||||
name: manifest.name,
|
name: manifest.name,
|
||||||
@@ -637,6 +715,13 @@ fn load_plugin_definition(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_validated_manifest_from_root(root: &Path) -> Result<PluginManifest, PluginError> {
|
||||||
|
let manifest = load_manifest_from_root(root)?;
|
||||||
|
validate_manifest(&manifest)?;
|
||||||
|
validate_hook_paths(Some(root), &manifest.hooks)?;
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_manifest(manifest: &PluginManifest) -> Result<(), PluginError> {
|
fn validate_manifest(manifest: &PluginManifest) -> Result<(), PluginError> {
|
||||||
if manifest.name.trim().is_empty() {
|
if manifest.name.trim().is_empty() {
|
||||||
return Err(PluginError::InvalidManifest(
|
return Err(PluginError::InvalidManifest(
|
||||||
@@ -896,6 +981,17 @@ mod tests {
|
|||||||
.expect("write manifest");
|
.expect("write manifest");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_broken_plugin(root: &Path, name: &str) {
|
||||||
|
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||||
|
fs::write(
|
||||||
|
root.join(MANIFEST_RELATIVE_PATH),
|
||||||
|
format!(
|
||||||
|
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"broken plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/missing.sh\"]\n }}\n}}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("write broken manifest");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validates_manifest_shape() {
|
fn validates_manifest_shape() {
|
||||||
let error = validate_manifest(&PluginManifest {
|
let error = validate_manifest(&PluginManifest {
|
||||||
@@ -982,4 +1078,53 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(config_home);
|
let _ = fs::remove_dir_all(config_home);
|
||||||
let _ = fs::remove_dir_all(source_root);
|
let _ = fs::remove_dir_all(source_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user