feat: plugin registry + validation + hooks

This commit is contained in:
Yeachan-Heo
2026-04-01 06:00:49 +00:00
parent bba3b0db45
commit 7f7807e48e

View File

@@ -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);
}
} }