mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 07:41:52 +08:00
wip: plugins progress
This commit is contained in:
@@ -221,7 +221,10 @@ impl SlashCommand {
|
|||||||
},
|
},
|
||||||
"plugins" => Self::Plugins {
|
"plugins" => Self::Plugins {
|
||||||
action: parts.next().map(ToOwned::to_owned),
|
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()),
|
other => Self::Unknown(other.to_string()),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -732,7 +732,9 @@ fn parse_install_source(source: &str) -> Result<PluginInstallSource, PluginError
|
|||||||
if source.starts_with("http://")
|
if source.starts_with("http://")
|
||||||
|| source.starts_with("https://")
|
|| source.starts_with("https://")
|
||||||
|| source.starts_with("git@")
|
|| 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 {
|
Ok(PluginInstallSource::GitUrl {
|
||||||
url: source.to_string(),
|
url: source.to_string(),
|
||||||
@@ -963,8 +965,8 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|plugin| plugin.metadata.id == "demo@external"));
|
.any(|plugin| plugin.metadata.id == "demo@external"));
|
||||||
|
|
||||||
fs::remove_dir_all(config_home).expect("cleanup home");
|
let _ = fs::remove_dir_all(config_home);
|
||||||
fs::remove_dir_all(source_root).expect("cleanup source");
|
let _ = fs::remove_dir_all(source_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -977,7 +979,7 @@ mod tests {
|
|||||||
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
|
.validate_plugin_source(source_root.to_str().expect("utf8 path"))
|
||||||
.expect("manifest should validate");
|
.expect("manifest should validate");
|
||||||
assert_eq!(manifest.name, "validator");
|
assert_eq!(manifest.name, "validator");
|
||||||
fs::remove_dir_all(config_home).expect("cleanup home");
|
let _ = fs::remove_dir_all(config_home);
|
||||||
fs::remove_dir_all(source_root).expect("cleanup source");
|
let _ = fs::remove_dir_all(source_root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(®istry)?;
|
|
||||||
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(®istry)?;
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -123,6 +123,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
pub fn new_with_features(
|
pub fn new_with_features(
|
||||||
session: Session,
|
session: Session,
|
||||||
api_client: C,
|
api_client: C,
|
||||||
@@ -697,7 +698,7 @@ mod tests {
|
|||||||
"post hook should preserve non-error result: {output:?}"
|
"post hook should preserve non-error result: {output:?}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("4"),
|
output.contains('4'),
|
||||||
"tool output missing value: {output:?}"
|
"tool output missing value: {output:?}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ impl HookRunner {
|
|||||||
HookRunResult::allow(messages)
|
HookRunResult::allow(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::unused_self)]
|
||||||
fn run_command(
|
fn run_command(
|
||||||
&self,
|
&self,
|
||||||
command: &str,
|
command: &str,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod render;
|
|||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::fmt::Write as _;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{self, Read, Write};
|
use std::io::{self, Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
@@ -22,7 +23,7 @@ use commands::{
|
|||||||
};
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use init::initialize_repo;
|
use init::initialize_repo;
|
||||||
use plugins::{PluginListEntry, PluginManager};
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginSummary};
|
||||||
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,
|
||||||
@@ -30,7 +31,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,
|
||||||
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
RuntimeHookConfig, 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};
|
||||||
@@ -1441,21 +1442,30 @@ impl LiveCli {
|
|||||||
target: Option<&str>,
|
target: Option<&str>,
|
||||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let runtime_config = ConfigLoader::default_for(&cwd).load()?;
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
let manager = PluginManager::default_for(&cwd);
|
let runtime_config = loader.load()?;
|
||||||
|
let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
||||||
|
|
||||||
match action {
|
match action {
|
||||||
None | Some("list") => {
|
None | Some("list") => {
|
||||||
let plugins = manager.list_plugins(&runtime_config)?;
|
let plugins = manager.list_plugins()?;
|
||||||
println!("{}", render_plugins_report(&plugins));
|
println!("{}", render_plugins_report(&plugins));
|
||||||
}
|
}
|
||||||
Some("install") => {
|
Some("install") => {
|
||||||
let Some(target) = target else {
|
let Some(target) = target else {
|
||||||
println!("Usage: /plugins install <path>");
|
println!("Usage: /plugins install <path-or-git-url>");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let result = manager.install_plugin(PathBuf::from(target))?;
|
let result = manager.install(target)?;
|
||||||
println!("Plugins\n Result {}", result.message);
|
println!(
|
||||||
|
"Plugins
|
||||||
|
Result installed {}
|
||||||
|
Version {}
|
||||||
|
Path {}",
|
||||||
|
result.plugin_id,
|
||||||
|
result.version,
|
||||||
|
result.install_path.display(),
|
||||||
|
);
|
||||||
self.reload_runtime_features()?;
|
self.reload_runtime_features()?;
|
||||||
}
|
}
|
||||||
Some("enable") => {
|
Some("enable") => {
|
||||||
@@ -1463,8 +1473,11 @@ impl LiveCli {
|
|||||||
println!("Usage: /plugins enable <plugin-id>");
|
println!("Usage: /plugins enable <plugin-id>");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let result = manager.enable_plugin(target)?;
|
manager.enable(target)?;
|
||||||
println!("Plugins\n Result {}", result.message);
|
println!(
|
||||||
|
"Plugins
|
||||||
|
Result enabled {target}"
|
||||||
|
);
|
||||||
self.reload_runtime_features()?;
|
self.reload_runtime_features()?;
|
||||||
}
|
}
|
||||||
Some("disable") => {
|
Some("disable") => {
|
||||||
@@ -1472,8 +1485,11 @@ impl LiveCli {
|
|||||||
println!("Usage: /plugins disable <plugin-id>");
|
println!("Usage: /plugins disable <plugin-id>");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let result = manager.disable_plugin(target)?;
|
manager.disable(target)?;
|
||||||
println!("Plugins\n Result {}", result.message);
|
println!(
|
||||||
|
"Plugins
|
||||||
|
Result disabled {target}"
|
||||||
|
);
|
||||||
self.reload_runtime_features()?;
|
self.reload_runtime_features()?;
|
||||||
}
|
}
|
||||||
Some("uninstall") => {
|
Some("uninstall") => {
|
||||||
@@ -1481,8 +1497,11 @@ impl LiveCli {
|
|||||||
println!("Usage: /plugins uninstall <plugin-id>");
|
println!("Usage: /plugins uninstall <plugin-id>");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let result = manager.uninstall_plugin(target)?;
|
manager.uninstall(target)?;
|
||||||
println!("Plugins\n Result {}", result.message);
|
println!(
|
||||||
|
"Plugins
|
||||||
|
Result uninstalled {target}"
|
||||||
|
);
|
||||||
self.reload_runtime_features()?;
|
self.reload_runtime_features()?;
|
||||||
}
|
}
|
||||||
Some("update") => {
|
Some("update") => {
|
||||||
@@ -1490,8 +1509,18 @@ impl LiveCli {
|
|||||||
println!("Usage: /plugins update <plugin-id>");
|
println!("Usage: /plugins update <plugin-id>");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let result = manager.update_plugin(target)?;
|
let result = manager.update(target)?;
|
||||||
println!("Plugins\n Result {}", result.message);
|
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()?;
|
self.reload_runtime_features()?;
|
||||||
}
|
}
|
||||||
Some(other) => {
|
Some(other) => {
|
||||||
@@ -1654,16 +1683,20 @@ 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()];
|
let mut lines = vec!["Plugins".to_string()];
|
||||||
if plugins.is_empty() {
|
if plugins.is_empty() {
|
||||||
lines.push(" No plugins discovered.".to_string());
|
lines.push(" No plugins discovered.".to_string());
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
let kind = format!("{:?}", plugin.plugin.source_kind).to_lowercase();
|
let kind = match plugin.metadata.kind {
|
||||||
let location = plugin.plugin.root.as_ref().map_or_else(
|
PluginKind::Builtin => "builtin",
|
||||||
|| kind.clone(),
|
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(),
|
|root| root.display().to_string(),
|
||||||
);
|
);
|
||||||
let enabled = if plugin.enabled {
|
let enabled = if plugin.enabled {
|
||||||
@@ -1673,9 +1706,9 @@ fn render_plugins_report(plugins: &[PluginListEntry]) -> String {
|
|||||||
};
|
};
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
" {id:<24} {kind:<8} {enabled:<8} v{version:<8} {location}",
|
" {id:<24} {kind:<8} {enabled:<8} v{version:<8} {location}",
|
||||||
id = plugin.plugin.id,
|
id = plugin.metadata.id,
|
||||||
kind = kind,
|
kind = kind,
|
||||||
version = plugin.plugin.manifest.version,
|
version = plugin.metadata.version,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
@@ -2024,12 +2057,51 @@ fn build_runtime_feature_config(
|
|||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let loader = ConfigLoader::default_for(&cwd);
|
let loader = ConfigLoader::default_for(&cwd);
|
||||||
let runtime_config = loader.load()?;
|
let runtime_config = loader.load()?;
|
||||||
let plugin_manager = PluginManager::default_for(&cwd);
|
let plugin_manager = build_plugin_manager(&cwd, &loader, &runtime_config);
|
||||||
let plugin_hooks = plugin_manager.active_hook_config(&runtime_config)?;
|
let plugin_hooks = plugin_manager.aggregated_hooks()?;
|
||||||
Ok(runtime_config
|
Ok(runtime_config
|
||||||
.feature_config()
|
.feature_config()
|
||||||
.clone()
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_runtime(
|
fn build_runtime(
|
||||||
@@ -2484,13 +2556,13 @@ fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|||||||
.get("backgroundTaskId")
|
.get("backgroundTaskId")
|
||||||
.and_then(|value| value.as_str())
|
.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
|
} else if let Some(status) = parsed
|
||||||
.get("returnCodeInterpretation")
|
.get("returnCodeInterpretation")
|
||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.filter(|status| !status.is_empty())
|
.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()) {
|
if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) {
|
||||||
@@ -2512,15 +2584,15 @@ fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|||||||
let path = extract_tool_path(file);
|
let path = extract_tool_path(file);
|
||||||
let start_line = file
|
let start_line = file
|
||||||
.get("startLine")
|
.get("startLine")
|
||||||
.and_then(|value| value.as_u64())
|
.and_then(serde_json::Value::as_u64)
|
||||||
.unwrap_or(1);
|
.unwrap_or(1);
|
||||||
let num_lines = file
|
let num_lines = file
|
||||||
.get("numLines")
|
.get("numLines")
|
||||||
.and_then(|value| value.as_u64())
|
.and_then(serde_json::Value::as_u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let total_lines = file
|
let total_lines = file
|
||||||
.get("totalLines")
|
.get("totalLines")
|
||||||
.and_then(|value| value.as_u64())
|
.and_then(serde_json::Value::as_u64)
|
||||||
.unwrap_or(num_lines);
|
.unwrap_or(num_lines);
|
||||||
let content = file
|
let content = file
|
||||||
.get("content")
|
.get("content")
|
||||||
@@ -2546,8 +2618,7 @@ fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|||||||
let line_count = parsed
|
let line_count = parsed
|
||||||
.get("content")
|
.get("content")
|
||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.map(|content| content.lines().count())
|
.map_or(0, |content| content.lines().count());
|
||||||
.unwrap_or(0);
|
|
||||||
format!(
|
format!(
|
||||||
"{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m",
|
"{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m",
|
||||||
if kind == "create" { "Wrote" } else { "Updated" },
|
if kind == "create" { "Wrote" } else { "Updated" },
|
||||||
@@ -2578,7 +2649,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|||||||
let path = extract_tool_path(parsed);
|
let path = extract_tool_path(parsed);
|
||||||
let suffix = if parsed
|
let suffix = if parsed
|
||||||
.get("replaceAll")
|
.get("replaceAll")
|
||||||
.and_then(|value| value.as_bool())
|
.and_then(serde_json::Value::as_bool)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
" (replace all)"
|
" (replace all)"
|
||||||
@@ -2606,7 +2677,7 @@ fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|||||||
fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
|
fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||||
let num_files = parsed
|
let num_files = parsed
|
||||||
.get("numFiles")
|
.get("numFiles")
|
||||||
.and_then(|value| value.as_u64())
|
.and_then(serde_json::Value::as_u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let filenames = parsed
|
let filenames = parsed
|
||||||
.get("filenames")
|
.get("filenames")
|
||||||
@@ -2630,11 +2701,11 @@ fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|||||||
fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String {
|
fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String {
|
||||||
let num_matches = parsed
|
let num_matches = parsed
|
||||||
.get("numMatches")
|
.get("numMatches")
|
||||||
.and_then(|value| value.as_u64())
|
.and_then(serde_json::Value::as_u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let num_files = parsed
|
let num_files = parsed
|
||||||
.get("numFiles")
|
.get("numFiles")
|
||||||
.and_then(|value| value.as_u64())
|
.and_then(serde_json::Value::as_u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let content = parsed
|
let content = parsed
|
||||||
.get("content")
|
.get("content")
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ impl TerminalRenderer {
|
|||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
Event::Start(Tag::Heading { level, .. }) => {
|
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::End(TagEnd::Paragraph) => output.push_str("\n\n"),
|
||||||
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
|
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) {
|
fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) {
|
||||||
state.heading_level = Some(level);
|
state.heading_level = Some(level);
|
||||||
if !output.is_empty() {
|
if !output.is_empty() {
|
||||||
|
|||||||
Reference in New Issue
Block a user