pub mod hooks;
pub mod manifest;
pub mod marketplace;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
fn validate_plugin_name(name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("Plugin name cannot be empty");
}
if name.contains('\0') {
anyhow::bail!("Invalid plugin name: {:?}", name);
}
let valid = name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
&& !name.starts_with('.')
&& !name.contains("..");
if !valid {
anyhow::bail!(
"Invalid plugin name {:?}: only ASCII alphanumeric, '-', '_', '.' allowed (no leading dot, no '..')",
name
);
}
let path = std::path::Path::new(name);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(std::path::Component::Normal(_)), None) => {}
_ => anyhow::bail!(
"Invalid plugin name {:?}: must be a single path component",
name
),
}
Ok(())
}
pub fn plugins_dir() -> PathBuf {
crate::config::config_dir().join("plugins")
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Plugin {
pub name: String,
pub version: String,
pub description: String,
pub root_dir: PathBuf,
pub hooks: Option<hooks::HookConfig>,
pub commands: Vec<PluginCommand>,
pub skill_dirs: Vec<PathBuf>,
pub context_file: Option<PathBuf>,
pub agents_dir: Option<PathBuf>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct PluginCommand {
pub name: String,
pub content: String,
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct PluginManager {
plugins: Vec<Plugin>,
}
impl PluginManager {
pub fn new() -> Self {
Self {
plugins: Vec::new(),
}
}
pub fn discover() -> Self {
let dir = plugins_dir();
let mut plugins = Vec::new();
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => {
tracing::debug!("No plugins directory at {}", dir.display());
return Self { plugins };
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
match Plugin::load(&path) {
Ok(plugin) => {
tracing::info!(
name = %plugin.name,
commands = plugin.commands.len(),
skills = plugin.skill_dirs.len(),
has_hooks = plugin.hooks.is_some(),
"Plugin loaded"
);
plugins.push(plugin);
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to load plugin, skipping"
);
}
}
}
tracing::info!(count = plugins.len(), "Plugins discovered");
Self { plugins }
}
pub fn count(&self) -> usize {
self.plugins.len()
}
pub fn all(&self) -> &[Plugin] {
&self.plugins
}
#[allow(dead_code)]
pub fn find(&self, name: &str) -> Option<&Plugin> {
self.plugins.iter().find(|p| p.name == name)
}
pub fn find_command(&self, cmd: &str) -> Option<(&PluginCommand, &Plugin)> {
let cmd = cmd.trim_start_matches('/');
for plugin in &self.plugins {
for command in &plugin.commands {
if command.name == cmd {
return Some((command, plugin));
}
}
}
None
}
pub fn all_hooks(&self) -> hooks::HookRuntime {
let mut runtime = hooks::HookRuntime::new();
for plugin in &self.plugins {
if let Some(ref hook_config) = plugin.hooks {
runtime.merge(hook_config, &plugin.root_dir);
}
}
runtime
}
pub fn install_from_github(owner_repo: &str, alias: Option<&str>) -> Result<PathBuf> {
let dir = plugins_dir();
std::fs::create_dir_all(&dir)?;
let dir_name =
alias.unwrap_or_else(|| owner_repo.split('/').next_back().unwrap_or(owner_repo));
validate_plugin_name(dir_name)?;
let target = dir.join(dir_name);
if !target.starts_with(&dir) {
anyhow::bail!("Plugin path escapes plugin directory");
}
if target.exists() {
tracing::info!(path = %target.display(), "Plugin directory exists, pulling latest");
let status = std::process::Command::new("git")
.args(["pull", "--ff-only"])
.current_dir(&target)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => {
let _ = std::fs::remove_dir_all(&target);
anyhow::bail!(
"git pull failed (exit {}); plugin directory removed",
s.code().unwrap_or(-1)
);
}
Err(e) => {
let _ = std::fs::remove_dir_all(&target);
anyhow::bail!("Failed to run git pull: {e}; plugin directory removed");
}
}
} else {
let url = format!("https://github.com/{owner_repo}.git");
tracing::info!(url = %url, target = %target.display(), "Cloning plugin");
let status = std::process::Command::new("git")
.args(["clone", "--depth", "1", &url])
.arg(&target)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::inherit())
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => {
let _ = std::fs::remove_dir_all(&target);
anyhow::bail!(
"git clone failed (exit {}). Check that `{owner_repo}` is a valid GitHub repo.",
s.code().unwrap_or(-1)
);
}
Err(e) => {
anyhow::bail!("Failed to run git clone: {e}");
}
}
}
let has_plugin_json = target.join(".claude-plugin").join("plugin.json").exists();
let has_hooks = target.join("hooks").join("hooks.json").exists();
let has_commands = target.join("commands").is_dir();
let has_skills = target.join("skills").is_dir();
if !has_plugin_json && !has_hooks && !has_commands && !has_skills {
let _ = std::fs::remove_dir_all(&target);
anyhow::bail!(
"`{owner_repo}` doesn't appear to be a Claude Code / collet plugin. \
Expected at least one of: .claude-plugin/plugin.json, hooks/hooks.json, \
commands/, skills/"
);
}
if let Ok(this_plugin) = Plugin::load(&target)
&& let Ok(entries) = std::fs::read_dir(&dir)
{
for entry in entries.flatten() {
let other_path = entry.path();
if !other_path.is_dir() || other_path == target {
continue;
}
if let Ok(other) = Plugin::load(&other_path)
&& other.name == this_plugin.name
{
tracing::info!(
old_dir = %other_path.display(),
name = %other.name,
"Removing duplicate plugin directory"
);
let _ = std::fs::remove_dir_all(&other_path);
}
}
}
Ok(target)
}
pub fn install(spec: &str, alias: Option<&str>) -> Result<(PathBuf, Plugin)> {
let owner_repo = if marketplace::is_at_spec(spec) {
let resolved = marketplace::resolve(spec)?;
resolved.owner_repo
} else if marketplace::is_owner_repo(spec) {
spec.to_string()
} else {
anyhow::bail!(
"Invalid plugin spec `{spec}`.\n\
Use `<owner/repo>` for direct install or `<name>@<marketplace>` for marketplace install."
)
};
let path = Self::install_from_github(&owner_repo, alias)?;
let plugin = Plugin::load(&path)?;
Ok((path, plugin))
}
pub fn update(name: Option<&str>) -> Result<Vec<(String, bool)>> {
let dir = plugins_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let entries: Vec<_> = std::fs::read_dir(&dir)
.context("Failed to read plugins directory")?
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
let mut results = Vec::new();
for path in &entries {
let dir_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if let Some(target_name) = name {
let plugin_name = Plugin::load(path)
.map(|p| p.name)
.unwrap_or(dir_name.clone());
if plugin_name != target_name && dir_name != target_name {
continue;
}
}
let head_before = git_head(path);
let status = std::process::Command::new("git")
.args(["pull", "--ff-only"])
.current_dir(path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => {
let head_after = git_head(path);
let updated = head_before != head_after;
results.push((dir_name, updated));
}
Ok(s) => {
tracing::warn!(
plugin = %dir_name,
code = ?s.code(),
"git pull failed for plugin"
);
results.push((dir_name, false));
}
Err(e) => {
tracing::warn!(plugin = %dir_name, error = %e, "Failed to run git pull");
results.push((dir_name, false));
}
}
}
if let Some(target_name) = name
&& results.is_empty()
{
anyhow::bail!("Plugin '{target_name}' is not installed");
}
Ok(results)
}
pub fn remove(name: &str) -> Result<()> {
let dir = plugins_dir();
if validate_plugin_name(name).is_ok() {
let target = dir.join(name);
if target.is_dir() {
std::fs::remove_dir_all(&target)
.with_context(|| format!("Failed to remove {}", target.display()))?;
tracing::info!(name = %name, "Plugin removed (directory name match)");
return Ok(());
}
}
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => anyhow::bail!("Plugin '{name}' is not installed"),
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if let Ok(plugin) = Plugin::load(&path)
&& plugin.name == name
{
std::fs::remove_dir_all(&path)
.with_context(|| format!("Failed to remove {}", path.display()))?;
tracing::info!(name = %name, dir = %path.display(), "Plugin removed (manifest name match)");
return Ok(());
}
}
anyhow::bail!("Plugin '{name}' is not installed")
}
pub fn list_text(&self) -> String {
if self.plugins.is_empty() {
return "No plugins installed.\n\n\
Register a marketplace and install:\n \
`/plugin marketplace add epicsagas/plugins`\n \
`/plugin install epic@plugins`\n\n\
Or install directly from GitHub:\n \
`/plugin install owner/repo`"
.to_string();
}
let mut lines = Vec::new();
for plugin in &self.plugins {
lines.push(format!("**{}** v{}", plugin.name, plugin.version));
lines.push(format!(" {}", plugin.description));
lines.push(format!(" Path: {}", plugin.root_dir.display()));
let mut features = Vec::new();
if !plugin.commands.is_empty() {
features.push(format!(
"{} commands: /{}",
plugin.commands.len(),
plugin
.commands
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>()
.join(", /")
));
}
if !plugin.skill_dirs.is_empty() {
features.push(format!("{} skills", plugin.skill_dirs.len()));
}
if plugin.hooks.is_some() {
features.push("hooks".to_string());
}
if plugin.context_file.is_some() {
features.push("context".to_string());
}
if !features.is_empty() {
lines.push(format!(" [{}]", features.join(", ")));
}
lines.push(String::new());
}
lines.join("\n")
}
}
fn git_head(path: &std::path::Path) -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(path)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
impl Default for PluginManager {
fn default() -> Self {
Self::new()
}
}
impl Plugin {
pub fn load(root_dir: &Path) -> Result<Self> {
let manifest_path = root_dir.join(".claude-plugin").join("plugin.json");
let manifest = if manifest_path.exists() {
Some(manifest::parse_plugin_json(&manifest_path)?)
} else {
None
};
let name = manifest
.as_ref()
.map(|m| m.name.clone())
.unwrap_or_else(|| {
root_dir
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string())
});
let version = manifest
.as_ref()
.map(|m| m.version.clone())
.unwrap_or_else(|| "0.0.0".to_string());
let description = manifest
.as_ref()
.map(|m| m.description.clone())
.unwrap_or_else(|| "No description".to_string());
let hooks_path = root_dir.join("hooks").join("hooks.json");
let hooks = if hooks_path.exists() {
Some(hooks::parse_hooks_json(&hooks_path)?)
} else {
None
};
let commands = discover_commands(root_dir);
let skill_dirs = discover_skill_dirs(root_dir);
let context_file = {
let claude_md = root_dir.join("CLAUDE.md");
if claude_md.exists() {
Some(claude_md)
} else {
None
}
};
let agents_dir = {
let d = root_dir.join("agents");
if d.is_dir() { Some(d) } else { None }
};
Ok(Self {
name,
version,
description,
root_dir: root_dir.to_path_buf(),
hooks,
commands,
skill_dirs,
context_file,
agents_dir,
})
}
}
fn discover_commands(root_dir: &Path) -> Vec<PluginCommand> {
let commands_dir = root_dir.join("commands");
let mut commands = Vec::new();
let entries = match std::fs::read_dir(&commands_dir) {
Ok(e) => e,
Err(_) => return commands,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().map(|e| e.to_string_lossy().to_string());
if ext.as_deref() != Some("md") {
continue;
}
let name = path
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if name.is_empty() || name.starts_with('.') {
continue;
}
match std::fs::read_to_string(&path) {
Ok(content) => {
tracing::debug!(name = %name, "Plugin command discovered");
commands.push(PluginCommand {
name,
content,
path,
});
}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "Failed to read command file");
}
}
}
commands
}
fn discover_skill_dirs(root_dir: &Path) -> Vec<PathBuf> {
let skills_dir = root_dir.join("skills");
let mut dirs = Vec::new();
let entries = match std::fs::read_dir(&skills_dir) {
Ok(e) => e,
Err(_) => return dirs,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if path.join("SKILL.md").exists() {
tracing::debug!(dir = %path.display(), "Plugin skill directory discovered");
dirs.push(path);
}
}
dirs
}
pub fn print_plugin_usage() {
eprintln!("collet plugin — manage Claude Code–compatible plugins");
eprintln!();
eprintln!("Usage:");
eprintln!(" collet plugin marketplace add <owner/repo> [name] Register a marketplace");
eprintln!(" collet plugin marketplace list List registered marketplaces");
eprintln!(" collet plugin marketplace remove <name> Unregister a marketplace");
eprintln!(" collet plugin install <name>@<marketplace> Install from marketplace");
eprintln!(" collet plugin install <owner/repo> [alias] Install directly from GitHub");
eprintln!(" collet plugin list List installed plugins");
eprintln!(" collet plugin remove <name> Remove a plugin");
eprintln!(" collet plugin update [name] Update plugin(s)");
eprintln!();
eprintln!("Examples:");
eprintln!(" collet plugin marketplace add epicsagas/plugins");
eprintln!(" collet plugin install epic@plugins");
eprintln!(" collet plugin install epicsagas/epic-harness");
eprintln!(" collet plugin remove epic");
}
pub fn cmd_plugin(args: &[String]) -> Result<()> {
let sub = args.first().map(|s| s.as_str()).unwrap_or("help");
match sub {
"help" | "--help" | "-h" => {
print_plugin_usage();
Ok(())
}
"marketplace" | "mkt" => {
let sub2 = args.get(1).map(|s| s.as_str()).unwrap_or("list");
match sub2 {
"add" | "register" => {
let owner_repo = args.get(2).map(|s| s.as_str()).unwrap_or("");
if owner_repo.is_empty() || !owner_repo.contains('/') {
eprintln!("Usage: collet plugin marketplace add <owner/repo> [name]");
eprintln!("Example: collet plugin marketplace add epicsagas/plugins");
std::process::exit(1);
}
let short_name = args
.get(3)
.map(|s| s.as_str())
.unwrap_or_else(|| marketplace::derive_marketplace_name(owner_repo));
let mut reg = marketplace::MarketplaceRegistry::load()?;
let is_new = reg.add(short_name, owner_repo)?;
if is_new {
eprintln!("✅ Marketplace '{short_name}' registered ({owner_repo}).");
eprintln!(" Install plugins: collet plugin install <name>@{short_name}");
} else {
eprintln!("✅ Marketplace '{short_name}' updated ({owner_repo}).");
}
Ok(())
}
"list" | "ls" => {
let reg = marketplace::MarketplaceRegistry::load()?;
if reg.is_empty() {
eprintln!("No marketplaces registered.");
eprintln!("Add one: collet plugin marketplace add <owner/repo>");
} else {
for (name, repo) in reg.list() {
println!("{name} {repo}");
}
}
Ok(())
}
"remove" | "rm" | "unregister" => {
let name = args.get(2).map(|s| s.as_str()).unwrap_or("");
if name.is_empty() {
eprintln!("Usage: collet plugin marketplace remove <name>");
std::process::exit(1);
}
let mut reg = marketplace::MarketplaceRegistry::load()?;
if reg.remove(name)? {
eprintln!("✅ Marketplace '{name}' removed.");
} else {
eprintln!("Marketplace '{name}' was not registered.");
}
Ok(())
}
_ => {
eprintln!("Unknown marketplace subcommand: {sub2}");
eprintln!("Available: add, list, remove");
std::process::exit(1);
}
}
}
"install" => {
let spec = args.get(1).map(|s| s.as_str()).unwrap_or("");
if spec.is_empty() {
eprintln!("Usage:");
eprintln!(" collet plugin install <name>@<marketplace>");
eprintln!(" collet plugin install <owner/repo> [alias]");
std::process::exit(1);
}
let alias = args.get(2).map(|s| s.as_str()).filter(|s| !s.is_empty());
let (_, plugin) = PluginManager::install(spec, alias)?;
eprintln!("✅ Plugin '{}' v{} installed.", plugin.name, plugin.version);
if !plugin.description.is_empty() {
eprintln!(" {}", plugin.description);
}
Ok(())
}
"list" | "ls" => {
let mgr = PluginManager::discover();
println!("{}", mgr.list_text());
Ok(())
}
"remove" | "rm" | "uninstall" => {
let name = args.get(1).map(|s| s.as_str()).unwrap_or("");
if name.is_empty() {
eprintln!("Usage: collet plugin remove <name>");
std::process::exit(1);
}
PluginManager::remove(name)?;
eprintln!("✅ Plugin '{name}' removed.");
Ok(())
}
"update" | "upgrade" => {
let name = args.get(1).map(|s| s.as_str());
let results = PluginManager::update(name)?;
if results.is_empty() {
eprintln!("No plugins installed.");
} else {
for (n, updated) in &results {
if *updated {
eprintln!("✅ {n} — updated");
} else {
eprintln!(" {n} — already up to date");
}
}
}
Ok(())
}
"reload" => {
let mgr = PluginManager::discover();
eprintln!("✅ {} plugin(s) loaded", mgr.count());
Ok(())
}
other => {
eprintln!("Unknown plugin subcommand: {other}");
print_plugin_usage();
std::process::exit(1);
}
}
}
#[cfg(test)]
mod name_validation_tests {
use super::*;
#[test]
fn test_validate_plugin_name() {
assert!(validate_plugin_name("my-plugin").is_ok());
assert!(validate_plugin_name("plugin_v2").is_ok());
assert!(validate_plugin_name("plugin.v2").is_ok());
assert!(validate_plugin_name("").is_err());
assert!(validate_plugin_name(".hidden").is_err());
assert!(validate_plugin_name("../escape").is_err());
assert!(validate_plugin_name("plugin\0name").is_err());
assert!(validate_plugin_name("has/slash").is_err());
assert!(validate_plugin_name("has\\backslash").is_err());
assert!(validate_plugin_name("has space").is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn create_test_plugin(dir: &Path, name: &str, version: &str, description: &str) {
let plugin_dir = dir.join(name);
let claude_plugin_dir = plugin_dir.join(".claude-plugin");
fs::create_dir_all(&claude_plugin_dir).unwrap();
fs::write(
claude_plugin_dir.join("plugin.json"),
serde_json::json!({
"name": name,
"version": version,
"description": description,
})
.to_string(),
)
.unwrap();
let commands_dir = plugin_dir.join("commands");
fs::create_dir_all(&commands_dir).unwrap();
fs::write(
commands_dir.join("spec.md"),
"# Spec Command\nDefine requirements.",
)
.unwrap();
fs::write(commands_dir.join("go.md"), "# Go Command\nBuild it.").unwrap();
let tdd_dir = plugin_dir.join("skills").join("tdd");
fs::create_dir_all(&tdd_dir).unwrap();
fs::write(
tdd_dir.join("SKILL.md"),
"---\nname: tdd\ndescription: Test-driven development\n---\n\n# TDD\nWrite tests first.",
)
.unwrap();
fs::write(
plugin_dir.join("CLAUDE.md"),
"# Project Context\nThis is a test plugin.",
)
.unwrap();
}
#[test]
fn test_plugin_load() {
let dir = tempfile::tempdir().unwrap();
create_test_plugin(dir.path(), "test-plugin", "1.0.0", "A test plugin");
let plugin = Plugin::load(&dir.path().join("test-plugin")).unwrap();
assert_eq!(plugin.name, "test-plugin");
assert_eq!(plugin.version, "1.0.0");
assert_eq!(plugin.description, "A test plugin");
assert_eq!(plugin.commands.len(), 2);
assert_eq!(plugin.skill_dirs.len(), 1);
assert!(plugin.context_file.is_some());
}
#[test]
fn test_plugin_find_command() {
let dir = tempfile::tempdir().unwrap();
create_test_plugin(dir.path(), "test-plugin", "1.0.0", "A test plugin");
let plugin = Plugin::load(&dir.path().join("test-plugin")).unwrap();
let mgr = PluginManager {
plugins: vec![plugin],
};
let (cmd, _) = mgr.find_command("/spec").unwrap();
assert_eq!(cmd.name, "spec");
assert!(cmd.content.contains("Spec Command"));
assert!(mgr.find_command("/nonexistent").is_none());
}
#[test]
fn test_plugin_no_manifest() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join("bare-plugin");
fs::create_dir_all(plugin_dir.join("commands")).unwrap();
fs::write(
plugin_dir.join("commands").join("hello.md"),
"# Hello\nSay hello.",
)
.unwrap();
let plugin = Plugin::load(&plugin_dir).unwrap();
assert_eq!(plugin.name, "bare-plugin");
assert_eq!(plugin.version, "0.0.0");
assert_eq!(plugin.commands.len(), 1);
}
#[test]
fn test_discover_commands_skips_non_md() {
let dir = tempfile::tempdir().unwrap();
let commands = dir.path().join("commands");
fs::create_dir_all(&commands).unwrap();
fs::write(commands.join("spec.md"), "# Spec").unwrap();
fs::write(commands.join("README.txt"), "Not a command").unwrap();
fs::write(commands.join(".hidden.md"), "Hidden").unwrap();
let cmds = discover_commands(dir.path());
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].name, "spec");
}
#[test]
fn test_discover_skill_dirs() {
let dir = tempfile::tempdir().unwrap();
let skills = dir.path().join("skills");
fs::create_dir_all(skills.join("tdd")).unwrap();
fs::write(skills.join("tdd").join("SKILL.md"), "---\nname: tdd\n---\n").unwrap();
fs::create_dir_all(skills.join("empty")).unwrap();
let dirs = discover_skill_dirs(dir.path());
assert_eq!(dirs.len(), 1);
assert!(dirs[0].ends_with("tdd"));
}
}