Parcourir la source

wip: plugins progress

Yeachan-Heo il y a 2 mois
Parent
commit
131660ff4c

+ 4 - 1
rust/crates/commands/src/lib.rs

@@ -297,7 +297,10 @@ impl SlashCommand {
             },
             "plugins" => Self::Plugins {
                 action: parts.next().map(ToOwned::to_owned),
-                target: parts.next().map(ToOwned::to_owned),
+                target: {
+                    let remainder = parts.collect::<Vec<_>>().join(" ");
+                    (!remainder.is_empty()).then_some(remainder)
+                },
             },
             other => Self::Unknown(other.to_string()),
         })

+ 7 - 5
rust/crates/plugins/src/lib.rs

@@ -732,7 +732,9 @@ fn parse_install_source(source: &str) -> Result<PluginInstallSource, PluginError
     if source.starts_with("http://")
         || source.starts_with("https://")
         || source.starts_with("git@")
-        || source.ends_with(".git")
+        || Path::new(source)
+            .extension()
+            .is_some_and(|extension| extension.eq_ignore_ascii_case("git"))
     {
         Ok(PluginInstallSource::GitUrl {
             url: source.to_string(),
@@ -963,8 +965,8 @@ mod tests {
             .iter()
             .any(|plugin| plugin.metadata.id == "demo@external"));
 
-        fs::remove_dir_all(config_home).expect("cleanup home");
-        fs::remove_dir_all(source_root).expect("cleanup source");
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
     }
 
     #[test]
@@ -977,7 +979,7 @@ mod tests {
             .validate_plugin_source(source_root.to_str().expect("utf8 path"))
             .expect("manifest should validate");
         assert_eq!(manifest.name, "validator");
-        fs::remove_dir_all(config_home).expect("cleanup home");
-        fs::remove_dir_all(source_root).expect("cleanup source");
+        let _ = fs::remove_dir_all(config_home);
+        let _ = fs::remove_dir_all(source_root);
     }
 }

+ 0 - 642
rust/crates/plugins/src/manager.rs

@@ -1,642 +0,0 @@
-use std::fmt::{Display, Formatter};
-use std::fs;
-use std::path::{Path, PathBuf};
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use runtime::{RuntimeConfig, RuntimeHookConfig};
-use serde::{Deserialize, Serialize};
-
-use crate::manifest::{LoadedPlugin, Plugin, PluginHooks, PluginManifest};
-use crate::registry::PluginRegistry;
-use crate::settings::{read_settings_file, write_plugin_state, write_settings_file};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "lowercase")]
-pub enum PluginSourceKind {
-    Builtin,
-    Bundled,
-    External,
-}
-
-impl PluginSourceKind {
-    fn suffix(self) -> &'static str {
-        match self {
-            Self::Builtin => "builtin",
-            Self::Bundled => "bundled",
-            Self::External => "external",
-        }
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct InstalledPluginRecord {
-    pub id: String,
-    pub name: String,
-    pub version: String,
-    pub description: String,
-    pub source_kind: PluginSourceKind,
-    pub source_path: String,
-    pub install_path: String,
-    pub installed_at: String,
-    pub updated_at: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct PluginListEntry {
-    pub plugin: LoadedPlugin,
-    pub enabled: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct PluginOperationResult {
-    pub plugin_id: String,
-    pub message: String,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct PluginError {
-    message: String,
-}
-
-impl PluginError {
-    #[must_use]
-    pub fn new(message: impl Into<String>) -> Self {
-        Self {
-            message: message.into(),
-        }
-    }
-}
-
-impl Display for PluginError {
-    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{}", self.message)
-    }
-}
-
-impl std::error::Error for PluginError {}
-
-impl From<String> for PluginError {
-    fn from(value: String) -> Self {
-        Self::new(value)
-    }
-}
-
-impl From<std::io::Error> for PluginError {
-    fn from(value: std::io::Error) -> Self {
-        Self::new(value.to_string())
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct PluginLoader {
-    registry_path: PathBuf,
-}
-
-impl PluginLoader {
-    #[must_use]
-    pub fn new(config_home: impl Into<PathBuf>) -> Self {
-        let config_home = config_home.into();
-        Self {
-            registry_path: config_home.join("plugins").join("installed.json"),
-        }
-    }
-
-    pub fn discover(&self) -> Result<Vec<LoadedPlugin>, PluginError> {
-        let mut plugins = builtin_plugins();
-        plugins.extend(bundled_plugins());
-        plugins.extend(self.load_external_plugins()?);
-        plugins.sort_by(|left, right| left.id.cmp(&right.id));
-        Ok(plugins)
-    }
-
-    fn load_external_plugins(&self) -> Result<Vec<LoadedPlugin>, PluginError> {
-        let registry = PluginRegistry::load(&self.registry_path)?;
-        registry
-            .plugins
-            .into_iter()
-            .map(|record| {
-                let install_path = PathBuf::from(&record.install_path);
-                let (manifest, root) = load_manifest_from_source(&install_path)?;
-                Ok(LoadedPlugin::new(
-                    record.id,
-                    PluginSourceKind::External,
-                    manifest,
-                    Some(root),
-                    Some(PathBuf::from(record.source_path)),
-                ))
-            })
-            .collect()
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct PluginManager {
-    cwd: PathBuf,
-    config_home: PathBuf,
-}
-
-impl PluginManager {
-    #[must_use]
-    pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
-        Self {
-            cwd: cwd.into(),
-            config_home: config_home.into(),
-        }
-    }
-
-    #[must_use]
-    pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
-        let cwd = cwd.into();
-        let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
-            .map(PathBuf::from)
-            .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
-            .unwrap_or_else(|| PathBuf::from(".claude"));
-        Self { cwd, config_home }
-    }
-
-    #[must_use]
-    pub fn loader(&self) -> PluginLoader {
-        PluginLoader::new(&self.config_home)
-    }
-
-    pub fn discover_plugins(&self) -> Result<Vec<LoadedPlugin>, PluginError> {
-        self.loader().discover()
-    }
-
-    pub fn list_plugins(
-        &self,
-        runtime_config: &RuntimeConfig,
-    ) -> Result<Vec<PluginListEntry>, PluginError> {
-        self.discover_plugins().map(|plugins| {
-            plugins
-                .into_iter()
-                .map(|plugin| {
-                    let enabled = is_plugin_enabled(&plugin, runtime_config);
-                    PluginListEntry { plugin, enabled }
-                })
-                .collect()
-        })
-    }
-
-    pub fn active_hook_config(
-        &self,
-        runtime_config: &RuntimeConfig,
-    ) -> Result<RuntimeHookConfig, PluginError> {
-        let mut hooks = PluginHooks::default();
-        for plugin in self.list_plugins(runtime_config)? {
-            if plugin.enabled {
-                let resolved = plugin.plugin.resolved_hooks();
-                hooks.pre_tool_use.extend(resolved.pre_tool_use);
-                hooks.post_tool_use.extend(resolved.post_tool_use);
-            }
-        }
-        Ok(RuntimeHookConfig::new(hooks.pre_tool_use, hooks.post_tool_use))
-    }
-
-    pub fn validate_plugin(&self, source: impl AsRef<Path>) -> Result<PluginManifest, PluginError> {
-        let (manifest, _) = load_manifest_from_source(source.as_ref())?;
-        Ok(manifest)
-    }
-
-    pub fn install_plugin(
-        &self,
-        source: impl AsRef<Path>,
-    ) -> Result<PluginOperationResult, PluginError> {
-        let (manifest, root) = load_manifest_from_source(source.as_ref())?;
-        let plugin_id = external_plugin_id(&manifest.name);
-        let install_path = self.installs_root().join(sanitize_plugin_id(&plugin_id));
-        let canonical_source = fs::canonicalize(root)?;
-
-        copy_dir_recursive(&canonical_source, &install_path)?;
-
-        let now = iso8601_now();
-        let mut registry = self.load_registry()?;
-        let installed_at = registry
-            .find(&plugin_id)
-            .map(|record| record.installed_at.clone())
-            .unwrap_or_else(|| now.clone());
-        registry.upsert(InstalledPluginRecord {
-            id: plugin_id.clone(),
-            name: manifest.name.clone(),
-            version: manifest.version.clone(),
-            description: manifest.description.clone(),
-            source_kind: PluginSourceKind::External,
-            source_path: canonical_source.display().to_string(),
-            install_path: install_path.display().to_string(),
-            installed_at,
-            updated_at: now,
-        });
-        self.save_registry(&registry)?;
-        self.write_enabled_state(&plugin_id, Some(true))?;
-
-        Ok(PluginOperationResult {
-            plugin_id: plugin_id.clone(),
-            message: format!(
-                "Installed plugin {} from {}",
-                plugin_id,
-                canonical_source.display()
-            ),
-        })
-    }
-
-    pub fn enable_plugin(&self, plugin_ref: &str) -> Result<PluginOperationResult, PluginError> {
-        let plugin = self.resolve_plugin(plugin_ref)?;
-        self.write_enabled_state(plugin.id(), Some(true))?;
-        Ok(PluginOperationResult {
-            plugin_id: plugin.id().to_string(),
-            message: format!("Enabled plugin {}", plugin.id()),
-        })
-    }
-
-    pub fn disable_plugin(&self, plugin_ref: &str) -> Result<PluginOperationResult, PluginError> {
-        let plugin = self.resolve_plugin(plugin_ref)?;
-        self.write_enabled_state(plugin.id(), Some(false))?;
-        Ok(PluginOperationResult {
-            plugin_id: plugin.id().to_string(),
-            message: format!("Disabled plugin {}", plugin.id()),
-        })
-    }
-
-    pub fn uninstall_plugin(
-        &self,
-        plugin_ref: &str,
-    ) -> Result<PluginOperationResult, PluginError> {
-        let plugin = self.resolve_plugin(plugin_ref)?;
-        if plugin.source_kind != PluginSourceKind::External {
-            return Err(PluginError::new(format!(
-                "plugin {} is {} and cannot be uninstalled",
-                plugin.id(),
-                plugin.source_kind.suffix()
-            )));
-        }
-
-        let mut registry = self.load_registry()?;
-        let Some(record) = registry.remove(plugin.id()) else {
-            return Err(PluginError::new(format!(
-                "plugin {} is not installed",
-                plugin.id()
-            )));
-        };
-        self.save_registry(&registry)?;
-        self.write_enabled_state(plugin.id(), None)?;
-
-        let install_path = PathBuf::from(record.install_path);
-        if install_path.exists() {
-            fs::remove_dir_all(install_path)?;
-        }
-
-        Ok(PluginOperationResult {
-            plugin_id: plugin.id().to_string(),
-            message: format!("Uninstalled plugin {}", plugin.id()),
-        })
-    }
-
-    pub fn update_plugin(&self, plugin_ref: &str) -> Result<PluginOperationResult, PluginError> {
-        let plugin = self.resolve_plugin(plugin_ref)?;
-        match plugin.source_kind {
-            PluginSourceKind::Builtin | PluginSourceKind::Bundled => Ok(PluginOperationResult {
-                plugin_id: plugin.id().to_string(),
-                message: format!(
-                    "Plugin {} is {} and already managed by the CLI",
-                    plugin.id(),
-                    plugin.source_kind.suffix()
-                ),
-            }),
-            PluginSourceKind::External => {
-                let registry = self.load_registry()?;
-                let record = registry.find(plugin.id()).ok_or_else(|| {
-                    PluginError::new(format!("plugin {} is not installed", plugin.id()))
-                })?;
-                self.install_plugin(PathBuf::from(&record.source_path)).map(|_| PluginOperationResult {
-                    plugin_id: plugin.id().to_string(),
-                    message: format!("Updated plugin {}", plugin.id()),
-                })
-            }
-        }
-    }
-
-    fn resolve_plugin(&self, plugin_ref: &str) -> Result<LoadedPlugin, PluginError> {
-        let plugins = self.discover_plugins()?;
-        if let Some(plugin) = plugins.iter().find(|plugin| plugin.id == plugin_ref) {
-            return Ok(plugin.clone());
-        }
-        let mut matches = plugins
-            .into_iter()
-            .filter(|plugin| plugin.name() == plugin_ref)
-            .collect::<Vec<_>>();
-        match matches.len() {
-            0 => Err(PluginError::new(format!("plugin {plugin_ref} was not found"))),
-            1 => Ok(matches.remove(0)),
-            _ => Err(PluginError::new(format!(
-                "plugin name {plugin_ref} is ambiguous; use a full plugin id"
-            ))),
-        }
-    }
-
-    fn settings_path(&self) -> PathBuf {
-        let _ = &self.cwd;
-        self.config_home.join("settings.json")
-    }
-
-    fn installs_root(&self) -> PathBuf {
-        self.config_home.join("plugins").join("installs")
-    }
-
-    fn registry_path(&self) -> PathBuf {
-        self.config_home.join("plugins").join("installed.json")
-    }
-
-    fn load_registry(&self) -> Result<PluginRegistry, PluginError> {
-        PluginRegistry::load(&self.registry_path()).map_err(PluginError::from)
-    }
-
-    fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginError> {
-        registry.save(&self.registry_path()).map_err(PluginError::from)
-    }
-
-    fn write_enabled_state(
-        &self,
-        plugin_id: &str,
-        enabled: Option<bool>,
-    ) -> Result<(), PluginError> {
-        let settings_path = self.settings_path();
-        let mut settings = read_settings_file(&settings_path)?;
-        write_plugin_state(&mut settings, plugin_id, enabled);
-        write_settings_file(&settings_path, &settings)?;
-        Ok(())
-    }
-}
-
-fn builtin_plugins() -> Vec<LoadedPlugin> {
-    let manifest = PluginManifest {
-        name: "tool-guard".to_string(),
-        description: "Example built-in plugin with optional tool hook messages".to_string(),
-        version: env!("CARGO_PKG_VERSION").to_string(),
-        default_enabled: false,
-        hooks: PluginHooks {
-            pre_tool_use: vec!["printf 'builtin tool-guard saw %s' \"$HOOK_TOOL_NAME\"".to_string()],
-            post_tool_use: Vec::new(),
-        },
-    };
-    vec![LoadedPlugin::new(
-        format!("{}@builtin", manifest.name),
-        PluginSourceKind::Builtin,
-        manifest,
-        None,
-        None,
-    )]
-}
-
-fn bundled_plugins() -> Vec<LoadedPlugin> {
-    let manifest = PluginManifest {
-        name: "tool-audit".to_string(),
-        description: "Example bundled plugin with optional post-tool hooks".to_string(),
-        version: env!("CARGO_PKG_VERSION").to_string(),
-        default_enabled: false,
-        hooks: PluginHooks {
-            pre_tool_use: Vec::new(),
-            post_tool_use: vec!["printf 'bundled tool-audit saw %s' \"$HOOK_TOOL_NAME\"".to_string()],
-        },
-    };
-    vec![LoadedPlugin::new(
-        format!("{}@bundled", manifest.name),
-        PluginSourceKind::Bundled,
-        manifest,
-        None,
-        None,
-    )]
-}
-
-fn is_plugin_enabled(plugin: &LoadedPlugin, runtime_config: &RuntimeConfig) -> bool {
-    runtime_config.plugins().state_for(&plugin.id, plugin.manifest.default_enabled)
-}
-
-fn external_plugin_id(name: &str) -> String {
-    format!("{}@external", name.trim())
-}
-
-fn sanitize_plugin_id(plugin_id: &str) -> String {
-    plugin_id
-        .chars()
-        .map(|character| {
-            if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
-                character
-            } else {
-                '-'
-            }
-        })
-        .collect()
-}
-
-fn load_manifest_from_source(source: &Path) -> Result<(PluginManifest, PathBuf), PluginError> {
-    let (manifest_path, root) = resolve_manifest_path(source)?;
-    let contents = fs::read_to_string(&manifest_path).map_err(|error| {
-        PluginError::new(format!(
-            "failed to read plugin manifest {}: {error}",
-            manifest_path.display()
-        ))
-    })?;
-    let manifest: PluginManifest = serde_json::from_str(&contents).map_err(|error| {
-        PluginError::new(format!(
-            "failed to parse plugin manifest {}: {error}",
-            manifest_path.display()
-        ))
-    })?;
-    manifest.validate().map_err(PluginError::new)?;
-    Ok((manifest, root))
-}
-
-fn resolve_manifest_path(source: &Path) -> Result<(PathBuf, PathBuf), PluginError> {
-    if source.is_file() {
-        let file_name = source.file_name().and_then(|name| name.to_str()).unwrap_or_default();
-        if file_name != "plugin.json" {
-            return Err(PluginError::new(format!(
-                "plugin manifest file must be named plugin.json: {}",
-                source.display()
-            )));
-        }
-        let root = source
-            .parent()
-            .and_then(|parent| parent.parent().filter(|candidate| parent.file_name() == Some(std::ffi::OsStr::new(".claude-plugin"))))
-            .map_or_else(
-                || source.parent().unwrap_or_else(|| Path::new(".")).to_path_buf(),
-                Path::to_path_buf,
-            );
-        return Ok((source.to_path_buf(), root));
-    }
-
-    let nested = source.join(".claude-plugin").join("plugin.json");
-    if nested.exists() {
-        return Ok((nested, source.to_path_buf()));
-    }
-
-    let direct = source.join("plugin.json");
-    if direct.exists() {
-        return Ok((direct, source.to_path_buf()));
-    }
-
-    Err(PluginError::new(format!(
-        "plugin manifest not found in {}",
-        source.display()
-    )))
-}
-
-fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<(), PluginError> {
-    if destination.exists() {
-        fs::remove_dir_all(destination)?;
-    }
-    fs::create_dir_all(destination)?;
-    for entry in fs::read_dir(source)? {
-        let entry = entry?;
-        let path = entry.path();
-        let target = destination.join(entry.file_name());
-        if entry.file_type()?.is_dir() {
-            copy_dir_recursive(&path, &target)?;
-        } else {
-            fs::copy(&path, &target)?;
-        }
-    }
-    Ok(())
-}
-
-fn iso8601_now() -> String {
-    let seconds = SystemTime::now()
-        .duration_since(UNIX_EPOCH)
-        .unwrap_or_default()
-        .as_secs();
-    format!("{seconds}")
-}
-
-#[cfg(test)]
-mod tests {
-    use super::{PluginLoader, PluginManager, PluginSourceKind};
-    use runtime::ConfigLoader;
-    use std::fs;
-    use std::path::Path;
-    use std::time::{SystemTime, UNIX_EPOCH};
-
-    fn temp_dir() -> std::path::PathBuf {
-        let nanos = SystemTime::now()
-            .duration_since(UNIX_EPOCH)
-            .expect("time should be after epoch")
-            .as_nanos();
-        std::env::temp_dir().join(format!("plugins-manager-{nanos}"))
-    }
-
-    fn write_external_plugin(root: &Path, version: &str, hook_body: &str) {
-        fs::create_dir_all(root.join(".claude-plugin")).expect("plugin dir should exist");
-        fs::write(
-            root.join(".claude-plugin").join("plugin.json"),
-            format!(
-                r#"{{
-  "name": "sample-plugin",
-  "description": "sample external plugin",
-  "version": "{version}",
-  "hooks": {{
-    "PreToolUse": ["printf 'pre from ${PLUGIN_DIR} {hook_body}'"]
-  }}
-}}"#
-            ),
-        )
-        .expect("plugin manifest should write");
-        fs::write(root.join("README.md"), "sample").expect("payload should write");
-    }
-
-    #[test]
-    fn discovers_builtin_and_bundled_plugins() {
-        let root = temp_dir();
-        let home = root.join("home").join(".claude");
-        let loader = PluginLoader::new(&home);
-        let plugins = loader.discover().expect("plugins should load");
-        assert!(plugins.iter().any(|plugin| plugin.source_kind == PluginSourceKind::Builtin));
-        assert!(plugins.iter().any(|plugin| plugin.source_kind == PluginSourceKind::Bundled));
-        fs::remove_dir_all(root).expect("cleanup");
-    }
-
-    #[test]
-    fn installs_and_lists_external_plugins() {
-        let root = temp_dir();
-        let cwd = root.join("project");
-        let home = root.join("home").join(".claude");
-        let source = root.join("source-plugin");
-        fs::create_dir_all(&cwd).expect("cwd should exist");
-        write_external_plugin(&source, "1.0.0", "v1");
-
-        let manager = PluginManager::new(&cwd, &home);
-        let result = manager.install_plugin(&source).expect("install should succeed");
-        assert_eq!(result.plugin_id, "sample-plugin@external");
-
-        let runtime_config = ConfigLoader::new(&cwd, &home)
-            .load()
-            .expect("config should load");
-        let plugins = manager
-            .list_plugins(&runtime_config)
-            .expect("plugins should list");
-        let external = plugins
-            .iter()
-            .find(|plugin| plugin.plugin.id == "sample-plugin@external")
-            .expect("external plugin should exist");
-        assert!(external.enabled);
-
-        let hook_config = manager
-            .active_hook_config(&runtime_config)
-            .expect("hook config should build");
-        assert_eq!(hook_config.pre_tool_use().len(), 1);
-        assert!(hook_config.pre_tool_use()[0].contains("sample-plugin-external"));
-
-        fs::remove_dir_all(root).expect("cleanup");
-    }
-
-    #[test]
-    fn disables_enables_updates_and_uninstalls_external_plugins() {
-        let root = temp_dir();
-        let cwd = root.join("project");
-        let home = root.join("home").join(".claude");
-        let source = root.join("source-plugin");
-        fs::create_dir_all(&cwd).expect("cwd should exist");
-        write_external_plugin(&source, "1.0.0", "v1");
-
-        let manager = PluginManager::new(&cwd, &home);
-        manager.install_plugin(&source).expect("install should succeed");
-        manager
-            .disable_plugin("sample-plugin")
-            .expect("disable should succeed");
-        let runtime_config = ConfigLoader::new(&cwd, &home)
-            .load()
-            .expect("config should load");
-        let plugins = manager
-            .list_plugins(&runtime_config)
-            .expect("plugins should list");
-        assert!(!plugins
-            .iter()
-            .find(|plugin| plugin.plugin.id == "sample-plugin@external")
-            .expect("external plugin should exist")
-            .enabled);
-
-        manager
-            .enable_plugin("sample-plugin@external")
-            .expect("enable should succeed");
-        write_external_plugin(&source, "2.0.0", "v2");
-        manager
-            .update_plugin("sample-plugin@external")
-            .expect("update should succeed");
-
-        let loader = PluginLoader::new(&home);
-        let plugins = loader.discover().expect("plugins should load");
-        let external = plugins
-            .iter()
-            .find(|plugin| plugin.id == "sample-plugin@external")
-            .expect("external plugin should exist");
-        assert_eq!(external.manifest.version, "2.0.0");
-
-        manager
-            .uninstall_plugin("sample-plugin@external")
-            .expect("uninstall should succeed");
-        let plugins = loader.discover().expect("plugins should reload");
-        assert!(!plugins
-            .iter()
-            .any(|plugin| plugin.id == "sample-plugin@external"));
-
-        fs::remove_dir_all(root).expect("cleanup");
-    }
-}

+ 0 - 175
rust/crates/plugins/src/manifest.rs

@@ -1,175 +0,0 @@
-use std::path::{Path, PathBuf};
-
-use serde::{Deserialize, Serialize};
-
-use crate::PluginSourceKind;
-
-pub trait Plugin {
-    fn id(&self) -> &str;
-    fn manifest(&self) -> &PluginManifest;
-    fn source_kind(&self) -> PluginSourceKind;
-    fn root(&self) -> Option<&Path>;
-
-    fn resolved_hooks(&self) -> PluginHooks {
-        self.manifest().hooks.resolve(self.root())
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
-pub struct PluginHooks {
-    #[serde(rename = "PreToolUse", alias = "preToolUse", default)]
-    pub pre_tool_use: Vec<String>,
-    #[serde(rename = "PostToolUse", alias = "postToolUse", default)]
-    pub post_tool_use: Vec<String>,
-}
-
-impl PluginHooks {
-    #[must_use]
-    pub fn resolve(&self, root: Option<&Path>) -> Self {
-        let Some(root) = root else {
-            return self.clone();
-        };
-        let replacement = root.display().to_string();
-        Self {
-            pre_tool_use: self
-                .pre_tool_use
-                .iter()
-                .map(|value| value.replace("${PLUGIN_DIR}", &replacement))
-                .collect(),
-            post_tool_use: self
-                .post_tool_use
-                .iter()
-                .map(|value| value.replace("${PLUGIN_DIR}", &replacement))
-                .collect(),
-        }
-    }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PluginManifest {
-    pub name: String,
-    pub description: String,
-    #[serde(default = "default_version")]
-    pub version: String,
-    #[serde(default)]
-    pub default_enabled: bool,
-    #[serde(default)]
-    pub hooks: PluginHooks,
-}
-
-impl PluginManifest {
-    pub fn validate(&self) -> Result<(), String> {
-        if self.name.trim().is_empty() {
-            return Err("plugin manifest name must not be empty".to_string());
-        }
-        if self.description.trim().is_empty() {
-            return Err(format!(
-                "plugin manifest description must not be empty for {}",
-                self.name
-            ));
-        }
-        if self.version.trim().is_empty() {
-            return Err(format!(
-                "plugin manifest version must not be empty for {}",
-                self.name
-            ));
-        }
-        if self
-            .hooks
-            .pre_tool_use
-            .iter()
-            .chain(self.hooks.post_tool_use.iter())
-            .any(|hook| hook.trim().is_empty())
-        {
-            return Err(format!(
-                "plugin manifest hook entries must not be empty for {}",
-                self.name
-            ));
-        }
-        Ok(())
-    }
-}
-
-fn default_version() -> String {
-    "0.1.0".to_string()
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct LoadedPlugin {
-    pub id: String,
-    pub source_kind: PluginSourceKind,
-    pub manifest: PluginManifest,
-    pub root: Option<PathBuf>,
-    pub origin: Option<PathBuf>,
-}
-
-impl LoadedPlugin {
-    #[must_use]
-    pub fn new(
-        id: String,
-        source_kind: PluginSourceKind,
-        manifest: PluginManifest,
-        root: Option<PathBuf>,
-        origin: Option<PathBuf>,
-    ) -> Self {
-        Self {
-            id,
-            source_kind,
-            manifest,
-            root,
-            origin,
-        }
-    }
-
-    #[must_use]
-    pub fn name(&self) -> &str {
-        &self.manifest.name
-    }
-}
-
-impl Plugin for LoadedPlugin {
-    fn id(&self) -> &str {
-        &self.id
-    }
-
-    fn manifest(&self) -> &PluginManifest {
-        &self.manifest
-    }
-
-    fn source_kind(&self) -> PluginSourceKind {
-        self.source_kind
-    }
-
-    fn root(&self) -> Option<&Path> {
-        self.root.as_deref()
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::{PluginHooks, PluginManifest};
-    use std::path::Path;
-
-    #[test]
-    fn validates_manifest_fields() {
-        let manifest = PluginManifest {
-            name: "demo".to_string(),
-            description: "demo plugin".to_string(),
-            version: "1.2.3".to_string(),
-            default_enabled: false,
-            hooks: PluginHooks::default(),
-        };
-        assert!(manifest.validate().is_ok());
-    }
-
-    #[test]
-    fn resolves_plugin_dir_placeholders() {
-        let hooks = PluginHooks {
-            pre_tool_use: vec!["echo ${PLUGIN_DIR}/pre".to_string()],
-            post_tool_use: vec!["echo ${PLUGIN_DIR}/post".to_string()],
-        };
-        let resolved = hooks.resolve(Some(Path::new("/tmp/plugin")));
-        assert_eq!(resolved.pre_tool_use, vec!["echo /tmp/plugin/pre"]);
-        assert_eq!(resolved.post_tool_use, vec!["echo /tmp/plugin/post"]);
-    }
-}

+ 0 - 91
rust/crates/plugins/src/registry.rs

@@ -1,91 +0,0 @@
-use std::path::Path;
-
-use serde::{Deserialize, Serialize};
-
-use crate::InstalledPluginRecord;
-
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
-pub struct PluginRegistry {
-    #[serde(default)]
-    pub plugins: Vec<InstalledPluginRecord>,
-}
-
-impl PluginRegistry {
-    pub fn load(path: &Path) -> Result<Self, String> {
-        match std::fs::read_to_string(path) {
-            Ok(contents) => {
-                if contents.trim().is_empty() {
-                    return Ok(Self::default());
-                }
-                serde_json::from_str(&contents).map_err(|error| error.to_string())
-            }
-            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
-            Err(error) => Err(error.to_string()),
-        }
-    }
-
-    pub fn save(&self, path: &Path) -> Result<(), String> {
-        if let Some(parent) = path.parent() {
-            std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
-        }
-        std::fs::write(
-            path,
-            serde_json::to_string_pretty(self).map_err(|error| error.to_string())?,
-        )
-        .map_err(|error| error.to_string())
-    }
-
-    #[must_use]
-    pub fn find(&self, plugin_id: &str) -> Option<&InstalledPluginRecord> {
-        self.plugins.iter().find(|plugin| plugin.id == plugin_id)
-    }
-
-    pub fn upsert(&mut self, record: InstalledPluginRecord) {
-        if let Some(existing) = self.plugins.iter_mut().find(|plugin| plugin.id == record.id) {
-            *existing = record;
-        } else {
-            self.plugins.push(record);
-        }
-        self.plugins.sort_by(|left, right| left.id.cmp(&right.id));
-    }
-
-    pub fn remove(&mut self, plugin_id: &str) -> Option<InstalledPluginRecord> {
-        let index = self.plugins.iter().position(|plugin| plugin.id == plugin_id)?;
-        Some(self.plugins.remove(index))
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::PluginRegistry;
-    use crate::{InstalledPluginRecord, PluginSourceKind};
-
-    #[test]
-    fn upsert_replaces_existing_entries() {
-        let mut registry = PluginRegistry::default();
-        registry.upsert(InstalledPluginRecord {
-            id: "demo@external".to_string(),
-            name: "demo".to_string(),
-            version: "1.0.0".to_string(),
-            description: "demo".to_string(),
-            source_kind: PluginSourceKind::External,
-            source_path: "/src".to_string(),
-            install_path: "/install".to_string(),
-            installed_at: "t1".to_string(),
-            updated_at: "t1".to_string(),
-        });
-        registry.upsert(InstalledPluginRecord {
-            id: "demo@external".to_string(),
-            name: "demo".to_string(),
-            version: "1.0.1".to_string(),
-            description: "updated".to_string(),
-            source_kind: PluginSourceKind::External,
-            source_path: "/src".to_string(),
-            install_path: "/install".to_string(),
-            installed_at: "t1".to_string(),
-            updated_at: "t2".to_string(),
-        });
-        assert_eq!(registry.plugins.len(), 1);
-        assert_eq!(registry.plugins[0].version, "1.0.1");
-    }
-}

+ 0 - 106
rust/crates/plugins/src/settings.rs

@@ -1,106 +0,0 @@
-use std::path::Path;
-
-use runtime::RuntimePluginConfig;
-use serde_json::{Map, Value};
-
-pub fn read_settings_file(path: &Path) -> Result<Map<String, Value>, String> {
-    match std::fs::read_to_string(path) {
-        Ok(contents) => {
-            if contents.trim().is_empty() {
-                return Ok(Map::new());
-            }
-            serde_json::from_str::<Value>(&contents)
-                .map_err(|error| error.to_string())?
-                .as_object()
-                .cloned()
-                .ok_or_else(|| "settings file must contain a JSON object".to_string())
-        }
-        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()),
-        Err(error) => Err(error.to_string()),
-    }
-}
-
-pub fn write_settings_file(path: &Path, root: &Map<String, Value>) -> Result<(), String> {
-    if let Some(parent) = path.parent() {
-        std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
-    }
-    std::fs::write(
-        path,
-        serde_json::to_string_pretty(root).map_err(|error| error.to_string())?,
-    )
-    .map_err(|error| error.to_string())
-}
-
-pub fn read_enabled_plugin_map(root: &Map<String, Value>) -> Map<String, Value> {
-    root.get("enabledPlugins")
-        .and_then(Value::as_object)
-        .cloned()
-        .unwrap_or_default()
-}
-
-pub fn write_plugin_state(
-    root: &mut Map<String, Value>,
-    plugin_id: &str,
-    enabled: Option<bool>,
-) {
-    let mut enabled_plugins = read_enabled_plugin_map(root);
-    match enabled {
-        Some(value) => {
-            enabled_plugins.insert(plugin_id.to_string(), Value::Bool(value));
-        }
-        None => {
-            enabled_plugins.remove(plugin_id);
-        }
-    }
-    if enabled_plugins.is_empty() {
-        root.remove("enabledPlugins");
-    } else {
-        root.insert("enabledPlugins".to_string(), Value::Object(enabled_plugins));
-    }
-}
-
-pub fn config_from_settings(root: &Map<String, Value>) -> RuntimePluginConfig {
-    let mut config = RuntimePluginConfig::default();
-    if let Some(enabled_plugins) = root.get("enabledPlugins").and_then(Value::as_object) {
-        for (plugin_id, enabled) in enabled_plugins {
-            match enabled.as_bool() {
-                Some(value) => config.set_plugin_state(plugin_id.clone(), value),
-                None => {}
-            }
-        }
-    }
-    config
-}
-
-#[cfg(test)]
-mod tests {
-    use super::{config_from_settings, write_plugin_state};
-    use serde_json::{json, Map, Value};
-
-    #[test]
-    fn writes_and_removes_enabled_plugin_state() {
-        let mut root = Map::new();
-        write_plugin_state(&mut root, "demo@external", Some(true));
-        assert_eq!(
-            root.get("enabledPlugins"),
-            Some(&json!({"demo@external": true}))
-        );
-        write_plugin_state(&mut root, "demo@external", None);
-        assert_eq!(root.get("enabledPlugins"), None);
-    }
-
-    #[test]
-    fn converts_settings_to_runtime_plugin_config() {
-        let mut root = Map::<String, Value>::new();
-        root.insert(
-            "enabledPlugins".to_string(),
-            json!({"demo@external": true, "off@bundled": false}),
-        );
-        let config = config_from_settings(&root);
-        assert_eq!(
-            config.enabled_plugins().get("demo@external"),
-            Some(&true)
-        );
-        assert_eq!(config.enabled_plugins().get("off@bundled"), Some(&false));
-    }
-}

+ 2 - 1
rust/crates/runtime/src/conversation.rs

@@ -133,6 +133,7 @@ where
     }
 
     #[must_use]
+    #[allow(clippy::needless_pass_by_value)]
     pub fn new_with_features(
         session: Session,
         api_client: C,
@@ -761,7 +762,7 @@ mod tests {
             "post hook should preserve non-error result: {output:?}"
         );
         assert!(
-            output.contains("4"),
+            output.contains('4'),
             "tool output missing value: {output:?}"
         );
         assert!(

+ 1 - 0
rust/crates/runtime/src/hooks.rs

@@ -149,6 +149,7 @@ impl HookRunner {
         HookRunResult::allow(messages)
     }
 
+    #[allow(clippy::too_many_arguments, clippy::unused_self)]
     fn run_command(
         &self,
         command: &str,

+ 109 - 39
rust/crates/rusty-claude-cli/src/main.rs

@@ -4,6 +4,7 @@ mod render;
 
 use std::collections::BTreeSet;
 use std::env;
+use std::fmt::Write as _;
 use std::fs;
 use std::io::{self, Read, Write};
 use std::net::TcpListener;
@@ -22,7 +23,7 @@ use commands::{
 };
 use compat_harness::{extract_manifest, UpstreamPaths};
 use init::initialize_repo;
-use plugins::{PluginListEntry, PluginManager};
+use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginSummary};
 use render::{MarkdownStreamState, Spinner, TerminalRenderer};
 use runtime::{
     clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
@@ -30,7 +31,7 @@ use runtime::{
     AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
     ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig,
     OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
-    Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
+    RuntimeHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
 };
 use serde_json::json;
 use tools::{execute_tool, mvp_tool_specs, ToolSpec};
@@ -1490,21 +1491,30 @@ impl LiveCli {
         target: Option<&str>,
     ) -> Result<bool, Box<dyn std::error::Error>> {
         let cwd = env::current_dir()?;
-        let runtime_config = ConfigLoader::default_for(&cwd).load()?;
-        let manager = PluginManager::default_for(&cwd);
+        let loader = ConfigLoader::default_for(&cwd);
+        let runtime_config = loader.load()?;
+        let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config);
 
         match action {
             None | Some("list") => {
-                let plugins = manager.list_plugins(&runtime_config)?;
+                let plugins = manager.list_plugins()?;
                 println!("{}", render_plugins_report(&plugins));
             }
             Some("install") => {
                 let Some(target) = target else {
-                    println!("Usage: /plugins install <path>");
+                    println!("Usage: /plugins install <path-or-git-url>");
                     return Ok(false);
                 };
-                let result = manager.install_plugin(PathBuf::from(target))?;
-                println!("Plugins\n  Result           {}", result.message);
+                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") => {
@@ -1512,8 +1522,11 @@ impl LiveCli {
                     println!("Usage: /plugins enable <plugin-id>");
                     return Ok(false);
                 };
-                let result = manager.enable_plugin(target)?;
-                println!("Plugins\n  Result           {}", result.message);
+                manager.enable(target)?;
+                println!(
+                    "Plugins
+  Result           enabled {target}"
+                );
                 self.reload_runtime_features()?;
             }
             Some("disable") => {
@@ -1521,8 +1534,11 @@ impl LiveCli {
                     println!("Usage: /plugins disable <plugin-id>");
                     return Ok(false);
                 };
-                let result = manager.disable_plugin(target)?;
-                println!("Plugins\n  Result           {}", result.message);
+                manager.disable(target)?;
+                println!(
+                    "Plugins
+  Result           disabled {target}"
+                );
                 self.reload_runtime_features()?;
             }
             Some("uninstall") => {
@@ -1530,8 +1546,11 @@ impl LiveCli {
                     println!("Usage: /plugins uninstall <plugin-id>");
                     return Ok(false);
                 };
-                let result = manager.uninstall_plugin(target)?;
-                println!("Plugins\n  Result           {}", result.message);
+                manager.uninstall(target)?;
+                println!(
+                    "Plugins
+  Result           uninstalled {target}"
+                );
                 self.reload_runtime_features()?;
             }
             Some("update") => {
@@ -1539,8 +1558,18 @@ impl LiveCli {
                     println!("Usage: /plugins update <plugin-id>");
                     return Ok(false);
                 };
-                let result = manager.update_plugin(target)?;
-                println!("Plugins\n  Result           {}", result.message);
+                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) => {
@@ -1858,19 +1887,22 @@ fn render_repl_help() -> String {
     )
 }
 
-fn render_plugins_report(plugins: &[PluginListEntry]) -> 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 = format!("{:?}", plugin.plugin.source_kind).to_lowercase();
-        let location = plugin
-            .plugin
-            .root
-            .as_ref()
-            .map_or_else(|| kind.clone(), |root| root.display().to_string());
+        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 {
@@ -1878,9 +1910,9 @@ fn render_plugins_report(plugins: &[PluginListEntry]) -> String {
         };
         lines.push(format!(
             "  {id:<24} {kind:<8} {enabled:<8} v{version:<8} {location}",
-            id = plugin.plugin.id,
+            id = plugin.metadata.id,
             kind = kind,
-            version = plugin.plugin.manifest.version,
+            version = plugin.metadata.version,
         ));
     }
     lines.join("\n")
@@ -2429,12 +2461,51 @@ fn build_runtime_feature_config(
     let cwd = env::current_dir()?;
     let loader = ConfigLoader::default_for(&cwd);
     let runtime_config = loader.load()?;
-    let plugin_manager = PluginManager::default_for(&cwd);
-    let plugin_hooks = plugin_manager.active_hook_config(&runtime_config)?;
+    let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
+    let plugin_hooks = plugin_manager.aggregated_hooks()?;
     Ok(runtime_config
         .feature_config()
         .clone()
-        .with_hooks(runtime_config.hooks().merged(&plugin_hooks)))
+        .with_hooks(runtime_config.hooks().merged(&RuntimeHookConfig::new(
+            plugin_hooks.pre_tool_use,
+            plugin_hooks.post_tool_use,
+        ))))
+}
+
+fn build_plugin_manager(
+    cwd: &Path,
+    loader: &ConfigLoader,
+    runtime_config: &runtime::RuntimeConfig,
+) -> PluginManager {
+    let plugin_settings = runtime_config.plugins();
+    let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf());
+    plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone();
+    plugin_config.external_dirs = plugin_settings
+        .external_directories()
+        .iter()
+        .map(|path| resolve_plugin_path(cwd, loader.config_home(), path))
+        .collect();
+    plugin_config.install_root = plugin_settings
+        .install_root()
+        .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
+    plugin_config.registry_path = plugin_settings
+        .registry_path()
+        .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
+    plugin_config.bundled_root = plugin_settings
+        .bundled_root()
+        .map(|path| resolve_plugin_path(cwd, loader.config_home(), path));
+    PluginManager::new(plugin_config)
+}
+
+fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf {
+    let path = PathBuf::from(value);
+    if path.is_absolute() {
+        path
+    } else if value.starts_with('.') {
+        cwd.join(path)
+    } else {
+        config_home.join(path)
+    }
 }
 
 #[allow(clippy::needless_pass_by_value)]
@@ -2890,13 +2961,13 @@ fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String {
         .get("backgroundTaskId")
         .and_then(|value| value.as_str())
     {
-        lines[0].push_str(&format!(" backgrounded ({task_id})"));
+        write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string");
     } else if let Some(status) = parsed
         .get("returnCodeInterpretation")
         .and_then(|value| value.as_str())
         .filter(|status| !status.is_empty())
     {
-        lines[0].push_str(&format!(" {status}"));
+        write!(&mut lines[0], " {status}").expect("write to string");
     }
 
     if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) {
@@ -2918,15 +2989,15 @@ fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String {
     let path = extract_tool_path(file);
     let start_line = file
         .get("startLine")
-        .and_then(|value| value.as_u64())
+        .and_then(serde_json::Value::as_u64)
         .unwrap_or(1);
     let num_lines = file
         .get("numLines")
-        .and_then(|value| value.as_u64())
+        .and_then(serde_json::Value::as_u64)
         .unwrap_or(0);
     let total_lines = file
         .get("totalLines")
-        .and_then(|value| value.as_u64())
+        .and_then(serde_json::Value::as_u64)
         .unwrap_or(num_lines);
     let content = file
         .get("content")
@@ -2952,8 +3023,7 @@ fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String {
     let line_count = parsed
         .get("content")
         .and_then(|value| value.as_str())
-        .map(|content| content.lines().count())
-        .unwrap_or(0);
+        .map_or(0, |content| content.lines().count());
     format!(
         "{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m",
         if kind == "create" { "Wrote" } else { "Updated" },
@@ -2984,7 +3054,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
     let path = extract_tool_path(parsed);
     let suffix = if parsed
         .get("replaceAll")
-        .and_then(|value| value.as_bool())
+        .and_then(serde_json::Value::as_bool)
         .unwrap_or(false)
     {
         " (replace all)"
@@ -3012,7 +3082,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
 fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
     let num_files = parsed
         .get("numFiles")
-        .and_then(|value| value.as_u64())
+        .and_then(serde_json::Value::as_u64)
         .unwrap_or(0);
     let filenames = parsed
         .get("filenames")
@@ -3036,11 +3106,11 @@ fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
 fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String {
     let num_matches = parsed
         .get("numMatches")
-        .and_then(|value| value.as_u64())
+        .and_then(serde_json::Value::as_u64)
         .unwrap_or(0);
     let num_files = parsed
         .get("numFiles")
-        .and_then(|value| value.as_u64())
+        .and_then(serde_json::Value::as_u64)
         .unwrap_or(0);
     let content = parsed
         .get("content")

+ 2 - 1
rust/crates/rusty-claude-cli/src/render.rs

@@ -286,7 +286,7 @@ impl TerminalRenderer {
     ) {
         match event {
             Event::Start(Tag::Heading { level, .. }) => {
-                self.start_heading(state, level as u8, output)
+                self.start_heading(state, level as u8, output);
             }
             Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
             Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
@@ -426,6 +426,7 @@ impl TerminalRenderer {
         }
     }
 
+    #[allow(clippy::unused_self)]
     fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) {
         state.heading_level = Some(level);
         if !output.is_empty() {