Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
1810 lines
64 KiB
Rust
1810 lines
64 KiB
Rust
//! Filesystem scanning for plugin directories.
|
|
//!
|
|
//! Discovers plugins from multiple sources in priority order:
|
|
//! 1. CLI `--plugin-dir` paths (scope: `CliOverride`)
|
|
//! 2. `.kigi/plugins/*/` (scope: `Project`, walked from cwd to worktree root)
|
|
//! 3. `.claude/plugins/*/` (scope: `Project`, compat)
|
|
//! 4. `~/.kigi/plugins/*/` (scope: `User`)
|
|
//! 5. `~/.claude/plugins/*/` (scope: `User`, compat)
|
|
//! `~/.kigi/installed-plugins/*/` (scope: `User`, marketplace installs)
|
|
//! Installed plugins from `~/.claude/plugins/installed_plugins.json` (scope: `User`)
|
|
//! 6. Paths from `[plugins].paths` in config (scope: `ConfigPath`)
|
|
//!
|
|
//! Deduplicates by canonical path and resolves name conflicts via
|
|
//! the canonical source precedence.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use super::manifest::{ManifestLoadResult, PluginManifest, load_manifest, name_from_dirname};
|
|
use super::trust::TrustStore;
|
|
|
|
// Public types
|
|
|
|
/// Where a plugin was discovered from.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub enum PluginScope {
|
|
/// `--plugin-dir` (highest priority, always trusted)
|
|
CliOverride = 0,
|
|
/// `.kigi/plugins/` or `.claude/plugins/` in project (requires trust)
|
|
Project = 1,
|
|
/// `~/.kigi/plugins/` or `~/.claude/plugins/` (always trusted)
|
|
User = 2,
|
|
/// `[plugins].paths` in config (trust depends on location)
|
|
ConfigPath = 3,
|
|
}
|
|
|
|
impl PluginScope {
|
|
/// Label used in `PluginId` format.
|
|
pub fn id_label(&self) -> &'static str {
|
|
match self {
|
|
Self::CliOverride => "cli",
|
|
Self::Project => "project",
|
|
Self::User => "user",
|
|
Self::ConfigPath => "config",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for PluginScope {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::CliOverride => write!(f, "cli"),
|
|
Self::Project => write!(f, "project"),
|
|
Self::User => write!(f, "user"),
|
|
Self::ConfigPath => write!(f, "config"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The concrete discovery source a plugin came from.
|
|
///
|
|
/// Finer-grained than [`PluginScope`]: recorded at scan time so consumers
|
|
/// (e.g. the pager's plugins list) don't have to re-derive provenance from
|
|
/// paths. Not part of [`PluginId`], which stays scope-based.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PluginOrigin {
|
|
/// CLI `--plugin-dir`.
|
|
CliOverride,
|
|
/// Project `.kigi/plugins/`.
|
|
ProjectKigi,
|
|
/// Project `.claude/plugins/`.
|
|
ProjectClaude,
|
|
/// `$KIGI_SHARE_DIR/plugins/`.
|
|
UserKigi,
|
|
/// `~/.claude/plugins/`.
|
|
UserClaude,
|
|
/// A compat marketplace clone (project `extraKnownMarketplaces`
|
|
/// or user `known_marketplaces.json`).
|
|
ClaudeMarketplace {
|
|
/// Marketplace name from the settings/registry entry.
|
|
marketplace: String,
|
|
},
|
|
/// Install recorded in `~/.claude/plugins/installed_plugins.json`.
|
|
ClaudeInstalled {
|
|
/// Marketplace name from the `name@marketplace` JSON key, when present.
|
|
marketplace: Option<String>,
|
|
},
|
|
/// Kigi's install registry (`~/.kigi/installed-plugins`).
|
|
MarketplaceInstall {
|
|
/// Git URL of the installed repo (None for local installs).
|
|
git_url: Option<String>,
|
|
},
|
|
/// `[plugins].paths` in config.
|
|
ConfigPath,
|
|
}
|
|
|
|
/// Stable internal identity for a plugin.
|
|
///
|
|
/// Format: `<scope>/<hex8>/<name>`
|
|
/// - `<scope>`: lowercase scope string (cli, project, user, config)
|
|
/// - `<hex8>`: first 8 hex chars of SHA-256 of the canonical plugin root path
|
|
/// - `<name>`: the plugin_name
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct PluginId(pub String);
|
|
|
|
impl PluginId {
|
|
/// Construct a plugin ID from its components.
|
|
pub fn new(scope: PluginScope, canonical_root: &Path, name: &str) -> Self {
|
|
let path_str = canonical_root.to_string_lossy();
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(path_str.as_bytes());
|
|
let hash = hasher.finalize();
|
|
let hex8 = format!(
|
|
"{:02x}{:02x}{:02x}{:02x}",
|
|
hash[0], hash[1], hash[2], hash[3]
|
|
);
|
|
Self(format!("{}/{}/{}", scope.id_label(), hex8, name))
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for PluginId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
/// A plugin candidate discovered on the filesystem.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DiscoveredPlugin {
|
|
/// Parsed manifest (or synthetic for convention-based plugins).
|
|
pub manifest: PluginManifest,
|
|
/// Stable internal identity.
|
|
pub id: PluginId,
|
|
/// Absolute path to the plugin root directory.
|
|
pub root: PathBuf,
|
|
/// Canonical (symlink-resolved) root path.
|
|
pub canonical_root: PathBuf,
|
|
/// Where this plugin was discovered.
|
|
pub scope: PluginScope,
|
|
/// The concrete discovery source this plugin came from.
|
|
pub origin: PluginOrigin,
|
|
/// Whether the plugin is trusted for executable operations.
|
|
pub trusted: bool,
|
|
/// Resolved skill directories.
|
|
pub skill_dirs: Vec<PathBuf>,
|
|
pub command_dirs: Vec<PathBuf>,
|
|
/// Resolved agent directories.
|
|
pub agent_dirs: Vec<PathBuf>,
|
|
/// Resolved hooks file path.
|
|
pub hooks_path: Option<PathBuf>,
|
|
/// Resolved MCP config file path.
|
|
pub mcp_config_path: Option<PathBuf>,
|
|
/// Resolved LSP config file path.
|
|
pub lsp_config_path: Option<PathBuf>,
|
|
/// Warning message when this plugin won a name collision.
|
|
pub conflict: Option<String>,
|
|
}
|
|
|
|
impl DiscoveredPlugin {
|
|
/// User-facing plugin name (from manifest or directory name).
|
|
pub fn plugin_name(&self) -> &str {
|
|
&self.manifest.name
|
|
}
|
|
}
|
|
|
|
/// Configuration for plugin discovery.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct DiscoveryConfig {
|
|
/// CLI `--plugin-dir` paths.
|
|
pub cli_plugin_dirs: Vec<PathBuf>,
|
|
/// `[plugins].paths` from config.
|
|
pub config_paths: Vec<PathBuf>,
|
|
/// `[plugins].disabled` plugin IDs or names.
|
|
pub disabled: Vec<String>,
|
|
/// `[plugins].enabled` plugin IDs or names (overrides default-disabled for project plugins).
|
|
pub enabled: Vec<String>,
|
|
}
|
|
|
|
impl DiscoveryConfig {
|
|
/// Ensure every discovered plugin appears in either `enabled` or `disabled`.
|
|
///
|
|
/// Plugins from auto-enabled scopes (`CliOverride`, `ConfigPath`) are added
|
|
/// to `enabled`. All others (`User`, `Project`) are added to `disabled`.
|
|
/// Plugins already present in either list are left untouched.
|
|
pub fn populate_plugin_lists(&mut self, discovered: &[DiscoveredPlugin]) {
|
|
for dp in discovered {
|
|
let name = &dp.manifest.name;
|
|
let already_listed = self.enabled.iter().any(|e| e == name || e == &dp.id.0)
|
|
|| self.disabled.iter().any(|d| d == name || d == &dp.id.0);
|
|
if already_listed {
|
|
tracing::debug!(plugin = %name, id = %dp.id.0, "plugin already in enabled/disabled list");
|
|
continue;
|
|
}
|
|
if matches!(dp.scope, PluginScope::CliOverride | PluginScope::ConfigPath) {
|
|
tracing::debug!(plugin = %name, scope = ?dp.scope, "auto-adding to enabled list");
|
|
self.enabled.push(name.clone());
|
|
} else {
|
|
tracing::debug!(plugin = %name, scope = ?dp.scope, "auto-adding to disabled list");
|
|
self.disabled.push(name.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Discovery entry point
|
|
|
|
/// User plugin directories in priority order: `$KIGI_SHARE_DIR/plugins` then
|
|
/// `~/.claude/plugins`.
|
|
///
|
|
/// Unlike agent discovery, plugins are deliberately NOT discovered from a
|
|
/// legacy `~/.kigi/plugins`: plugin trust, persisted plugin-data, and install
|
|
/// paths all resolve under `kigi_home()`, so a plugin scanned from the legacy
|
|
/// tree would appear untrusted and lose its persisted state. Keeping plugins on
|
|
/// `kigi_home()` only avoids that half-initialized state.
|
|
fn user_plugin_dirs(home: Option<&Path>, kigi: Option<&Path>) -> Vec<(PathBuf, PluginOrigin)> {
|
|
let mut dirs = Vec::new();
|
|
if let Some(g) = kigi {
|
|
dirs.push((g.join("plugins"), PluginOrigin::UserKigi));
|
|
}
|
|
if let Some(h) = home {
|
|
dirs.push((h.join(".claude").join("plugins"), PluginOrigin::UserClaude));
|
|
}
|
|
dirs
|
|
}
|
|
|
|
/// Origin for a project plugins parent dir: `.claude/plugins` vs `.kigi/plugins`.
|
|
fn project_plugins_dir_origin(plugins_dir: &Path) -> PluginOrigin {
|
|
let is_claude = plugins_dir
|
|
.parent()
|
|
.and_then(|p| p.file_name())
|
|
.is_some_and(|n| n == ".claude");
|
|
if is_claude {
|
|
PluginOrigin::ProjectClaude
|
|
} else {
|
|
PluginOrigin::ProjectKigi
|
|
}
|
|
}
|
|
|
|
/// Project-scoped plugin parent dirs (`.kigi/plugins`, `.claude/plugins`) that
|
|
/// exist along the `cwd`→git-worktree-root walk (inclusive), or just `cwd`'s own
|
|
/// when `cwd` is not inside a git repo, paired with the resolved git worktree
|
|
/// root (when any). This is the exact set [`discover_plugins`] scans for
|
|
/// `PluginScope::Project`; the folder-trust gate reuses the same chain via
|
|
/// [`project_plugin_dirs_in`] so detection and discovery can never drift. The
|
|
/// returned root lets `discover_plugins` reuse it for the marketplace
|
|
/// `resolve(root)` branch instead of resolving the repo a second time.
|
|
pub fn project_plugin_dirs(cwd: Option<&Path>) -> (Vec<PathBuf>, Option<PathBuf>) {
|
|
let Some(cwd) = cwd else {
|
|
return (Vec::new(), None);
|
|
};
|
|
let chain = crate::repo::RepoDirChain::resolve(cwd);
|
|
(project_plugin_dirs_in(&chain.dirs), chain.git_root)
|
|
}
|
|
|
|
/// Existing project plugin parent dirs (`.kigi/plugins`, `.claude/plugins`)
|
|
/// under each dir of a precomputed cwd→git-root chain
|
|
/// ([`crate::repo::RepoDirChain`]). The folder-trust gate reuses its one shared
|
|
/// chain here so detection and discovery can never drift.
|
|
pub fn project_plugin_dirs_in(chain_dirs: &[PathBuf]) -> Vec<PathBuf> {
|
|
crate::repo::existing_subdirs_along(chain_dirs, &[".kigi/plugins", ".claude/plugins"])
|
|
}
|
|
|
|
/// Discover all plugins from the filesystem.
|
|
///
|
|
/// `cwd` is used to find the git worktree root for project-scope plugins.
|
|
/// `project_trusted` is the folder-trust verdict for `cwd`; it gates
|
|
/// `Project`-scope plugins (CLI/User/ConfigPath scopes are unaffected).
|
|
/// Returns plugins deduplicated by canonical path, with name conflicts
|
|
/// resolved by scope precedence.
|
|
pub fn discover_plugins(
|
|
cwd: Option<&Path>,
|
|
config: &DiscoveryConfig,
|
|
trust_store: &TrustStore,
|
|
project_trusted: bool,
|
|
) -> Vec<DiscoveredPlugin> {
|
|
let _plugin_discovery_timer = crate::timing::timer("plugin_discovery");
|
|
let mut seen_paths: HashSet<PathBuf> = HashSet::new();
|
|
let mut candidates: Vec<DiscoveredPlugin> = Vec::new();
|
|
|
|
// 1. CLI --plugin-dir paths
|
|
for dir in &config.cli_plugin_dirs {
|
|
if dir.is_dir() {
|
|
collect_plugin(
|
|
dir,
|
|
PluginScope::CliOverride,
|
|
PluginOrigin::CliOverride,
|
|
trust_store,
|
|
project_trusted,
|
|
&mut seen_paths,
|
|
&mut candidates,
|
|
);
|
|
} else {
|
|
tracing::warn!(path = %dir.display(), "CLI --plugin-dir path is not a directory; skipping");
|
|
}
|
|
}
|
|
|
|
// 2-3. Project plugins (.kigi/plugins/, .claude/plugins/) — scan the SAME
|
|
// dirs the folder-trust gate detects, via the shared `project_plugin_dirs`
|
|
// walk (cwd→git root), so discovery and gating can never drift.
|
|
if let Some(cwd) = cwd {
|
|
let (project_dirs, git_root) = project_plugin_dirs(Some(cwd));
|
|
for plugins_dir in project_dirs {
|
|
let origin = project_plugins_dir_origin(&plugins_dir);
|
|
scan_plugin_dir(
|
|
&plugins_dir,
|
|
PluginScope::Project,
|
|
origin,
|
|
trust_store,
|
|
project_trusted,
|
|
&mut seen_paths,
|
|
&mut candidates,
|
|
);
|
|
}
|
|
|
|
// 3b. Marketplace plugins (extraKnownMarketplaces in .claude/settings.json).
|
|
// Reuse the git root resolved above (one walk, no second discover).
|
|
if let Some(ref root) = git_root {
|
|
for marketplace in &super::marketplace::resolve(root) {
|
|
for dir in &marketplace.plugin_dirs {
|
|
collect_plugin(
|
|
dir,
|
|
PluginScope::Project,
|
|
PluginOrigin::ClaudeMarketplace {
|
|
marketplace: marketplace.name.clone(),
|
|
},
|
|
trust_store,
|
|
project_trusted,
|
|
&mut seen_paths,
|
|
&mut candidates,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4-5. User plugins: $KIGI_SHARE_DIR/plugins, legacy ~/.kigi/plugins, ~/.claude/plugins.
|
|
// Gate the kigi plugins dir on user_kigi_home() so a project's .kigi/plugins
|
|
// is never scanned as user-global when no home resolves.
|
|
let kigi = kigi_config::user_kigi_home();
|
|
let plugin_dirs = user_plugin_dirs(dirs::home_dir().as_deref(), kigi.as_deref());
|
|
for (plugins_dir, origin) in plugin_dirs {
|
|
if plugins_dir.is_dir() {
|
|
scan_plugin_dir(
|
|
&plugins_dir,
|
|
PluginScope::User,
|
|
origin,
|
|
trust_store,
|
|
project_trusted,
|
|
&mut seen_paths,
|
|
&mut candidates,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 5a. Known marketplaces (~/.claude/plugins/known_marketplaces.json).
|
|
// Marketplace repos are cloned locally and registered here.
|
|
// Tracks marketplace installs in installed_plugins.json with explicit
|
|
// Each marketplace has a plugins/ (and optionally external_plugins/) subdirectory.
|
|
for marketplace in &super::marketplace::resolve_known_marketplaces() {
|
|
for dir in &marketplace.plugin_dirs {
|
|
collect_plugin(
|
|
dir,
|
|
PluginScope::User,
|
|
PluginOrigin::ClaudeMarketplace {
|
|
marketplace: marketplace.name.clone(),
|
|
},
|
|
trust_store,
|
|
project_trusted,
|
|
&mut seen_paths,
|
|
&mut candidates,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 5b. Installed plugins (from install registry's managed directory)
|
|
{
|
|
// Installed plugins are always User scope (auto-trusted).
|
|
// The user explicitly installed them via marketplace or CLI,
|
|
// so they should be trusted regardless of install_dir location.
|
|
let registry = super::install_registry::InstallRegistry::load();
|
|
collect_installed_plugins(
|
|
®istry,
|
|
PluginScope::User,
|
|
trust_store,
|
|
project_trusted,
|
|
&mut seen_paths,
|
|
&mut candidates,
|
|
);
|
|
}
|
|
|
|
// 5c. Installed plugins (~/.claude/plugins/installed_plugins.json).
|
|
// installPath entries (nested under cache/<marketplace>/<plugin>/<version>/).
|
|
// scope wins. Within same scope, first-found (alphabetical by canonical
|
|
// with explicit installPath entries (nested under cache/<marketplace>/<plugin>/<version>/).
|
|
// The plugin name is extracted from the JSON key ("name@marketplace").
|
|
if let Some(home) = dirs::home_dir() {
|
|
let installed_json = home
|
|
.join(".claude")
|
|
.join("plugins")
|
|
.join("installed_plugins.json");
|
|
for (name, marketplace, path) in read_claude_installed_plugins(&installed_json, cwd) {
|
|
if path.is_dir() {
|
|
let before = candidates.len();
|
|
collect_plugin(
|
|
&path,
|
|
PluginScope::User,
|
|
PluginOrigin::ClaudeInstalled { marketplace },
|
|
trust_store,
|
|
project_trusted,
|
|
&mut seen_paths,
|
|
&mut candidates,
|
|
);
|
|
// Override dirname-derived name with the real name from the JSON key.
|
|
if candidates.len() > before {
|
|
let plugin = candidates.last_mut().unwrap();
|
|
if plugin.manifest.name != name {
|
|
plugin.id = PluginId::new(plugin.scope, &plugin.canonical_root, &name);
|
|
plugin.manifest.name = name;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. Config-path plugins
|
|
for dir in &config.config_paths {
|
|
if dir.is_dir() {
|
|
collect_plugin(
|
|
dir,
|
|
PluginScope::ConfigPath,
|
|
PluginOrigin::ConfigPath,
|
|
trust_store,
|
|
project_trusted,
|
|
&mut seen_paths,
|
|
&mut candidates,
|
|
);
|
|
} else {
|
|
tracing::warn!(path = %dir.display(), "[plugins].paths entry is not a directory; skipping");
|
|
}
|
|
}
|
|
|
|
// Resolve name conflicts: within the same plugin_name, highest-priority
|
|
// scope wins. Within same scope, first-found (alphabetical by canonical
|
|
// path) wins.
|
|
resolve_name_conflicts(&mut candidates);
|
|
|
|
for p in &candidates {
|
|
tracing::info!(
|
|
name = %p.manifest.name,
|
|
scope = %p.scope,
|
|
root = %p.root.display(),
|
|
skills = p.manifest.skill_dirs(&p.root).len(),
|
|
agents = p.manifest.agent_dirs(&p.root).len(),
|
|
has_hooks = p.hooks_path.is_some(),
|
|
has_mcp = p.mcp_config_path.is_some(),
|
|
has_lsp = p.lsp_config_path.is_some(),
|
|
"plugin discovered"
|
|
);
|
|
}
|
|
tracing::info!(
|
|
discovered = candidates.len(),
|
|
cli = candidates
|
|
.iter()
|
|
.filter(|p| p.scope == PluginScope::CliOverride)
|
|
.count(),
|
|
project = candidates
|
|
.iter()
|
|
.filter(|p| p.scope == PluginScope::Project)
|
|
.count(),
|
|
user = candidates
|
|
.iter()
|
|
.filter(|p| p.scope == PluginScope::User)
|
|
.count(),
|
|
config = candidates
|
|
.iter()
|
|
.filter(|p| p.scope == PluginScope::ConfigPath)
|
|
.count(),
|
|
"plugins: discovery complete"
|
|
);
|
|
|
|
candidates
|
|
}
|
|
|
|
// Internal helpers
|
|
|
|
/// Scan a plugins parent directory (e.g. `~/.kigi/plugins/`) and collect
|
|
/// each subdirectory as a plugin candidate.
|
|
fn scan_plugin_dir(
|
|
plugins_dir: &Path,
|
|
scope: PluginScope,
|
|
origin: PluginOrigin,
|
|
trust_store: &TrustStore,
|
|
project_trusted: bool,
|
|
seen_paths: &mut HashSet<PathBuf>,
|
|
candidates: &mut Vec<DiscoveredPlugin>,
|
|
) {
|
|
let entries = match std::fs::read_dir(plugins_dir) {
|
|
Ok(entries) => entries,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
path = %plugins_dir.display(),
|
|
error = %e,
|
|
"failed to read plugins directory"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let mut subdirs: Vec<PathBuf> = entries
|
|
.filter_map(|e| e.ok())
|
|
// follows symlinks
|
|
.filter(|e| e.path().is_dir())
|
|
.map(|e| e.path())
|
|
.collect();
|
|
|
|
// Sort for deterministic ordering within same scope
|
|
subdirs.sort();
|
|
|
|
for subdir in subdirs {
|
|
collect_plugin(
|
|
&subdir,
|
|
scope,
|
|
origin.clone(),
|
|
trust_store,
|
|
project_trusted,
|
|
seen_paths,
|
|
candidates,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn collect_installed_plugins(
|
|
registry: &super::install_registry::InstallRegistry,
|
|
scope: PluginScope,
|
|
trust_store: &TrustStore,
|
|
project_trusted: bool,
|
|
seen_paths: &mut HashSet<PathBuf>,
|
|
candidates: &mut Vec<DiscoveredPlugin>,
|
|
) {
|
|
for (_key, repo) in registry.list() {
|
|
let origin = PluginOrigin::MarketplaceInstall {
|
|
git_url: match &repo.kind {
|
|
super::install_registry::InstallKind::Git { url, .. } => Some(url.clone()),
|
|
super::install_registry::InstallKind::Local { .. } => None,
|
|
},
|
|
};
|
|
for plugin in repo.plugins.values() {
|
|
let plugin_root = match plugin.subdir.as_deref() {
|
|
Some(sub) => {
|
|
if subdir_escapes(sub) {
|
|
tracing::warn!(
|
|
repo = %repo.path.display(),
|
|
subdir = sub,
|
|
"skipping installed plugin: registry subdir escapes repo root"
|
|
);
|
|
continue;
|
|
}
|
|
repo.path.join(sub)
|
|
}
|
|
None => repo.path.clone(),
|
|
};
|
|
if plugin_root.is_dir() {
|
|
collect_plugin(
|
|
&plugin_root,
|
|
scope,
|
|
origin.clone(),
|
|
trust_store,
|
|
project_trusted,
|
|
seen_paths,
|
|
candidates,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn subdir_escapes(subdir: &str) -> bool {
|
|
let p = Path::new(subdir);
|
|
p.is_absolute()
|
|
|| p.components().any(|c| {
|
|
matches!(
|
|
c,
|
|
std::path::Component::ParentDir
|
|
| std::path::Component::RootDir
|
|
| std::path::Component::Prefix(_)
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Attempt to load a single plugin directory and add it to candidates.
|
|
fn collect_plugin(
|
|
plugin_root: &Path,
|
|
scope: PluginScope,
|
|
origin: PluginOrigin,
|
|
trust_store: &TrustStore,
|
|
project_trusted: bool,
|
|
seen_paths: &mut HashSet<PathBuf>,
|
|
candidates: &mut Vec<DiscoveredPlugin>,
|
|
) {
|
|
// Canonicalize for dedup
|
|
let canonical = match dunce::canonicalize(plugin_root) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
path = %plugin_root.display(),
|
|
error = %e,
|
|
"failed to canonicalize plugin root; skipping"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Deduplicate by canonical path
|
|
if !seen_paths.insert(canonical.clone()) {
|
|
tracing::debug!(
|
|
path = %plugin_root.display(),
|
|
canonical = %canonical.display(),
|
|
"plugin directory already discovered via another path; skipping"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Load manifest or derive from directory name
|
|
let manifest = match load_manifest(plugin_root) {
|
|
Ok(ManifestLoadResult::Found(m)) => *m,
|
|
Ok(ManifestLoadResult::NotFound) => {
|
|
// Convention-based: derive name from directory, check for
|
|
// skills/ or agents/ or .mcp.json or hooks/hooks.json
|
|
let Some(name) = name_from_dirname(plugin_root) else {
|
|
tracing::debug!(
|
|
path = %plugin_root.display(),
|
|
"cannot derive plugin name from directory; skipping"
|
|
);
|
|
return;
|
|
};
|
|
|
|
// Only treat as a plugin if it has at least one component
|
|
let has_skills =
|
|
plugin_root.join("skills").is_dir() || plugin_root.join("commands").is_dir();
|
|
let has_agents = plugin_root.join("agents").is_dir();
|
|
let has_mcp = plugin_root.join(".mcp.json").is_file();
|
|
let has_lsp = plugin_root.join(".lsp.json").is_file();
|
|
let has_hooks = plugin_root.join("hooks").join("hooks.json").is_file();
|
|
|
|
if !has_skills && !has_agents && !has_mcp && !has_lsp && !has_hooks {
|
|
tracing::debug!(
|
|
path = %plugin_root.display(),
|
|
"directory has no manifest and no recognized plugin components; skipping"
|
|
);
|
|
return;
|
|
}
|
|
|
|
PluginManifest {
|
|
name,
|
|
version: None,
|
|
description: None,
|
|
author: None,
|
|
homepage: None,
|
|
repository: None,
|
|
license: None,
|
|
keywords: vec![],
|
|
skills: None,
|
|
commands: None,
|
|
agents: None,
|
|
hooks: None,
|
|
mcp_servers: None,
|
|
lsp_servers: None,
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
path = %plugin_root.display(),
|
|
error = %e,
|
|
"failed to load plugin manifest; skipping"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Determine trust status. Exhaustive match so a new PluginScope variant is a
|
|
// compile error rather than a silent default.
|
|
let trusted = match scope {
|
|
PluginScope::CliOverride | PluginScope::User => true,
|
|
PluginScope::ConfigPath => {
|
|
TrustStore::is_config_path_auto_trusted(plugin_root)
|
|
|| trust_store.is_trusted(plugin_root)
|
|
}
|
|
// Project trust now comes from folder-trust (passed by the caller).
|
|
PluginScope::Project => project_trusted,
|
|
};
|
|
|
|
// Build PluginId
|
|
let id = PluginId::new(scope, &canonical, &manifest.name);
|
|
|
|
// Resolve component paths
|
|
let skill_dirs = manifest.skill_dirs(plugin_root);
|
|
let command_dirs = manifest.command_dirs(plugin_root);
|
|
let agent_dirs = manifest.agent_dirs(plugin_root);
|
|
let hooks_path = manifest.hooks_path(plugin_root);
|
|
let mcp_config_path = manifest.mcp_config_path(plugin_root);
|
|
let lsp_config_path = manifest.lsp_config_path(plugin_root);
|
|
|
|
candidates.push(DiscoveredPlugin {
|
|
manifest,
|
|
id,
|
|
root: plugin_root.to_path_buf(),
|
|
canonical_root: canonical,
|
|
scope,
|
|
origin,
|
|
trusted,
|
|
skill_dirs,
|
|
command_dirs,
|
|
agent_dirs,
|
|
hooks_path,
|
|
mcp_config_path,
|
|
lsp_config_path,
|
|
conflict: None,
|
|
});
|
|
}
|
|
|
|
/// Resolve plugin_name conflicts across scopes.
|
|
///
|
|
/// Within each name group, keep only the highest-priority candidate
|
|
/// (lowest scope ordinal). Log warnings for dropped duplicates.
|
|
fn resolve_name_conflicts(candidates: &mut Vec<DiscoveredPlugin>) {
|
|
let mut name_map: HashMap<String, usize> = HashMap::new();
|
|
let mut to_remove: Vec<usize> = Vec::new();
|
|
// (winner_idx, conflict message) pairs to apply after the scan.
|
|
let mut conflict_msgs: Vec<(usize, String)> = Vec::new();
|
|
|
|
for (idx, candidate) in candidates.iter().enumerate() {
|
|
let name = candidate.manifest.name.clone();
|
|
match name_map.get(&name) {
|
|
Some(&existing_idx) => {
|
|
let existing = &candidates[existing_idx];
|
|
// Lower scope ordinal = higher priority
|
|
if (candidate.scope as u8) < (existing.scope as u8) {
|
|
// New candidate wins
|
|
tracing::warn!(
|
|
plugin_name = %name,
|
|
winner = %candidate.root.display(),
|
|
loser = %existing.root.display(),
|
|
"plugin name collision resolved by scope precedence"
|
|
);
|
|
let msg = format!(
|
|
"Name collision: shadowing \"{}\" from {}",
|
|
name,
|
|
existing.root.display()
|
|
);
|
|
conflict_msgs.push((idx, msg));
|
|
to_remove.push(existing_idx);
|
|
name_map.insert(name, idx);
|
|
} else {
|
|
// Existing wins
|
|
tracing::warn!(
|
|
plugin_name = %name,
|
|
winner = %existing.root.display(),
|
|
loser = %candidate.root.display(),
|
|
"plugin name collision resolved by scope precedence"
|
|
);
|
|
let msg = format!(
|
|
"Name collision: shadowing \"{}\" from {}",
|
|
name,
|
|
candidate.root.display()
|
|
);
|
|
conflict_msgs.push((existing_idx, msg));
|
|
to_remove.push(idx);
|
|
}
|
|
}
|
|
None => {
|
|
name_map.insert(name, idx);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply conflict messages to winners.
|
|
for (idx, msg) in conflict_msgs {
|
|
candidates[idx].conflict = Some(msg);
|
|
}
|
|
|
|
// Remove losers (reverse order to preserve indices)
|
|
to_remove.sort_unstable();
|
|
to_remove.dedup();
|
|
for idx in to_remove.into_iter().rev() {
|
|
candidates.remove(idx);
|
|
}
|
|
}
|
|
|
|
// Compat installed_plugins.json types
|
|
|
|
/// Compat `installed_plugins.json` format.
|
|
#[derive(serde::Deserialize)]
|
|
struct ClaudeInstalledPlugins {
|
|
#[serde(default)]
|
|
plugins: HashMap<String, Vec<ClaudeInstalledEntry>>,
|
|
}
|
|
|
|
/// A single install entry within `installed_plugins.json`.
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ClaudeInstalledEntry {
|
|
install_path: PathBuf,
|
|
scope: Option<String>,
|
|
project_path: Option<PathBuf>,
|
|
}
|
|
|
|
/// Whether a compat install entry should surface for this session `cwd`.
|
|
///
|
|
/// The `local` and `project` scopes are project-tied (as is any entry with
|
|
/// a non-empty `projectPath`): only visible when `cwd` is under `project_path`
|
|
/// (path-component prefix). Missing/empty project path or cwd cannot prove
|
|
/// in-project, so they stay hidden. User/unscoped with no path always surface.
|
|
fn claude_install_visible(
|
|
scope: Option<&str>,
|
|
project_path: Option<&Path>,
|
|
cwd: Option<&Path>,
|
|
) -> bool {
|
|
let has_project_path = project_path.is_some_and(|p| !p.as_os_str().is_empty());
|
|
let project_gated = matches!(scope, Some("local") | Some("project")) || has_project_path;
|
|
if !project_gated {
|
|
return true;
|
|
}
|
|
let Some(project) = project_path.filter(|p| !p.as_os_str().is_empty()) else {
|
|
return false;
|
|
};
|
|
let Some(cwd) = cwd else {
|
|
return false;
|
|
};
|
|
let cwd = dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
|
|
let project = dunce::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
|
|
cwd.starts_with(&project)
|
|
}
|
|
|
|
/// Read plugin names and install paths from compat `installed_plugins.json`.
|
|
///
|
|
/// Keys are `"plugin-name@marketplace"` — the plugin name is extracted from
|
|
/// before `@`, the marketplace name from after it (when present).
|
|
/// Returns `(name, marketplace, path)` tuples. Empty vec on any error.
|
|
///
|
|
/// Project-tied entries are filtered by `cwd` vs `projectPath` (see
|
|
/// [`claude_install_visible`]).
|
|
fn read_claude_installed_plugins(
|
|
json_path: &Path,
|
|
cwd: Option<&Path>,
|
|
) -> Vec<(String, Option<String>, PathBuf)> {
|
|
let content = match std::fs::read_to_string(json_path) {
|
|
Ok(c) => c,
|
|
Err(_) => return vec![],
|
|
};
|
|
let registry: ClaudeInstalledPlugins = match serde_json::from_str(&content) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
path = %json_path.display(),
|
|
error = %e,
|
|
"failed to parse installed_plugins.json"
|
|
);
|
|
return vec![];
|
|
}
|
|
};
|
|
|
|
registry
|
|
.plugins
|
|
.into_iter()
|
|
.flat_map(|(key, entries)| {
|
|
let mut parts = key.splitn(2, '@');
|
|
let name = parts.next().unwrap_or(&key).to_string();
|
|
let marketplace = parts.next().map(String::from);
|
|
entries
|
|
.into_iter()
|
|
.filter(|entry| {
|
|
claude_install_visible(
|
|
entry.scope.as_deref(),
|
|
entry.project_path.as_deref(),
|
|
cwd,
|
|
)
|
|
})
|
|
.map(move |entry| (name.clone(), marketplace.clone(), entry.install_path))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_manifest_plugin(tmp: &Path, name: &str) -> PathBuf {
|
|
let plugin_dir = tmp.join(name);
|
|
std::fs::create_dir_all(&plugin_dir).unwrap();
|
|
std::fs::write(
|
|
plugin_dir.join("plugin.json"),
|
|
format!(r#"{{"name": "{}"}}"#, name),
|
|
)
|
|
.unwrap();
|
|
plugin_dir
|
|
}
|
|
|
|
fn make_convention_plugin(tmp: &Path, name: &str) -> PathBuf {
|
|
let plugin_dir = tmp.join(name);
|
|
std::fs::create_dir_all(plugin_dir.join("skills")).unwrap();
|
|
plugin_dir
|
|
}
|
|
|
|
#[test]
|
|
fn user_plugin_dirs_are_kigi_and_claude_only_no_legacy() {
|
|
let home = Path::new("/home/u");
|
|
let kigi = Path::new("/custom/kigihome");
|
|
let dirs = user_plugin_dirs(Some(home), Some(kigi));
|
|
assert!(dirs.contains(&(kigi.join("plugins"), PluginOrigin::UserKigi)));
|
|
assert!(dirs.contains(&(
|
|
home.join(".claude").join("plugins"),
|
|
PluginOrigin::UserClaude
|
|
)));
|
|
// Plugins are not discovered from the legacy ~/.kigi tree.
|
|
assert!(
|
|
!dirs
|
|
.iter()
|
|
.any(|(p, _)| p == &home.join(".kigi").join("plugins"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn user_plugin_dirs_empty_without_home_or_kigi() {
|
|
assert!(user_plugin_dirs(None, None).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn discover_cli_plugin() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let plugin_dir = make_manifest_plugin(tmp.path(), "cli-tool");
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
collect_plugin(
|
|
&plugin_dir,
|
|
PluginScope::CliOverride,
|
|
PluginOrigin::CliOverride,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
assert_eq!(candidates.len(), 1);
|
|
assert_eq!(candidates[0].plugin_name(), "cli-tool");
|
|
assert_eq!(candidates[0].scope, PluginScope::CliOverride);
|
|
assert_eq!(candidates[0].origin, PluginOrigin::CliOverride);
|
|
assert!(candidates[0].trusted);
|
|
}
|
|
|
|
#[test]
|
|
fn project_plugins_dir_origin_distinguishes_kigi_and_claude() {
|
|
assert_eq!(
|
|
project_plugins_dir_origin(Path::new("/repo/.kigi/plugins")),
|
|
PluginOrigin::ProjectKigi
|
|
);
|
|
assert_eq!(
|
|
project_plugins_dir_origin(Path::new("/repo/.claude/plugins")),
|
|
PluginOrigin::ProjectClaude
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn discover_user_plugins() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
// Create ~/.kigi/plugins/ structure
|
|
let kigi_plugins = tmp.path().join(".kigi").join("plugins");
|
|
std::fs::create_dir_all(&kigi_plugins).unwrap();
|
|
make_manifest_plugin(&kigi_plugins, "user-tool");
|
|
|
|
// Override home dir by directly scanning
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
scan_plugin_dir(
|
|
&kigi_plugins,
|
|
PluginScope::User,
|
|
PluginOrigin::UserKigi,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
assert_eq!(candidates.len(), 1);
|
|
assert_eq!(candidates[0].plugin_name(), "user-tool");
|
|
assert!(candidates[0].trusted);
|
|
}
|
|
|
|
#[test]
|
|
fn discover_convention_plugin_no_manifest() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let plugins_dir = tmp.path().join("plugins");
|
|
make_convention_plugin(&plugins_dir, "my-tool");
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
scan_plugin_dir(
|
|
&plugins_dir,
|
|
PluginScope::User,
|
|
PluginOrigin::UserKigi,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
assert_eq!(candidates.len(), 1);
|
|
assert_eq!(candidates[0].plugin_name(), "my-tool");
|
|
}
|
|
|
|
#[test]
|
|
fn installed_plugins_load_at_registered_subdir_and_root() {
|
|
use crate::plugins::install_registry::{
|
|
InstallKind, InstallRegistry, InstalledRepo, RepoPlugin,
|
|
};
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let install_dir = tmp.path().join("installed-plugins");
|
|
|
|
let sub_key = "acme-deadbeef";
|
|
let sub_repo_path = install_dir.join(sub_key);
|
|
let sub_plugin_dir = sub_repo_path.join("plugins").join("acme");
|
|
std::fs::create_dir_all(&sub_plugin_dir).unwrap();
|
|
std::fs::write(sub_plugin_dir.join("plugin.json"), r#"{"name": "acme"}"#).unwrap();
|
|
|
|
let root_key = "cloud-cafef00d";
|
|
let root_repo_path = install_dir.join(root_key);
|
|
std::fs::create_dir_all(&root_repo_path).unwrap();
|
|
std::fs::write(root_repo_path.join("plugin.json"), r#"{"name": "cloud"}"#).unwrap();
|
|
|
|
let mut registry = InstallRegistry::empty(install_dir);
|
|
registry.insert(
|
|
sub_key.to_string(),
|
|
InstalledRepo {
|
|
kind: InstallKind::Local {
|
|
source_path: sub_repo_path.clone(),
|
|
subdir: None,
|
|
},
|
|
installed_at: String::new(),
|
|
updated_at: String::new(),
|
|
path: sub_repo_path,
|
|
plugins: HashMap::from([(
|
|
"acme".to_string(),
|
|
RepoPlugin {
|
|
subdir: Some("plugins/acme".to_string()),
|
|
version: None,
|
|
},
|
|
)]),
|
|
},
|
|
);
|
|
registry.insert(
|
|
root_key.to_string(),
|
|
InstalledRepo {
|
|
kind: InstallKind::Local {
|
|
source_path: root_repo_path.clone(),
|
|
subdir: None,
|
|
},
|
|
installed_at: String::new(),
|
|
updated_at: String::new(),
|
|
path: root_repo_path,
|
|
plugins: HashMap::from([(
|
|
"cloud".to_string(),
|
|
RepoPlugin {
|
|
subdir: None,
|
|
version: None,
|
|
},
|
|
)]),
|
|
},
|
|
);
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
collect_installed_plugins(
|
|
®istry,
|
|
PluginScope::User,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
let names: Vec<&str> = candidates.iter().map(|p| p.plugin_name()).collect();
|
|
assert_eq!(candidates.len(), 2, "got {names:?}");
|
|
assert!(
|
|
names.contains(&"acme"),
|
|
"subdir plugin should load, got {names:?}"
|
|
);
|
|
assert!(
|
|
names.contains(&"cloud"),
|
|
"root plugin should load, got {names:?}"
|
|
);
|
|
for p in &candidates {
|
|
assert_eq!(
|
|
p.origin,
|
|
PluginOrigin::MarketplaceInstall { git_url: None },
|
|
"direct local installs carry no git URL"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn installed_plugins_record_git_origin() {
|
|
use crate::plugins::install_registry::{
|
|
InstallKind, InstallRegistry, InstalledRepo, RepoPlugin,
|
|
};
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let install_dir = tmp.path().join("installed-plugins");
|
|
|
|
let git_repo_path = install_dir.join("git-plugin-22222222");
|
|
std::fs::create_dir_all(&git_repo_path).unwrap();
|
|
std::fs::write(
|
|
git_repo_path.join("plugin.json"),
|
|
r#"{"name": "git-plugin"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let mut registry = InstallRegistry::empty(install_dir);
|
|
registry.insert(
|
|
"git-plugin-22222222".to_string(),
|
|
InstalledRepo {
|
|
kind: InstallKind::Git {
|
|
url: "https://github.com/owner/repo.git".to_string(),
|
|
git_ref: None,
|
|
commit: "def".to_string(),
|
|
subdir: None,
|
|
},
|
|
installed_at: String::new(),
|
|
updated_at: String::new(),
|
|
path: git_repo_path,
|
|
plugins: HashMap::from([(
|
|
"git-plugin".to_string(),
|
|
RepoPlugin {
|
|
subdir: None,
|
|
version: None,
|
|
},
|
|
)]),
|
|
},
|
|
);
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
collect_installed_plugins(
|
|
®istry,
|
|
PluginScope::User,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
let git = candidates
|
|
.iter()
|
|
.find(|p| p.plugin_name() == "git-plugin")
|
|
.unwrap();
|
|
assert_eq!(
|
|
git.origin,
|
|
PluginOrigin::MarketplaceInstall {
|
|
git_url: Some("https://github.com/owner/repo.git".to_string()),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn installed_plugins_skip_subdir_that_escapes_repo() {
|
|
use crate::plugins::install_registry::{
|
|
InstallKind, InstallRegistry, InstalledRepo, RepoPlugin,
|
|
};
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let install_dir = tmp.path().join("installed-plugins");
|
|
|
|
let escaped_dir = install_dir.join("escaped");
|
|
std::fs::create_dir_all(&escaped_dir).unwrap();
|
|
std::fs::write(
|
|
escaped_dir.join("plugin.json"),
|
|
r#"{"name": "escaped-evil"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let evil_key = "evil-deadbeef";
|
|
let evil_repo_path = install_dir.join(evil_key);
|
|
std::fs::create_dir_all(&evil_repo_path).unwrap();
|
|
|
|
let good_key = "good-cafef00d";
|
|
let good_repo_path = install_dir.join(good_key);
|
|
let good_plugin_dir = good_repo_path.join("plugins").join("good");
|
|
std::fs::create_dir_all(&good_plugin_dir).unwrap();
|
|
std::fs::write(good_plugin_dir.join("plugin.json"), r#"{"name": "good"}"#).unwrap();
|
|
|
|
let mut registry = InstallRegistry::empty(install_dir);
|
|
registry.insert(
|
|
evil_key.to_string(),
|
|
InstalledRepo {
|
|
kind: InstallKind::Local {
|
|
source_path: evil_repo_path.clone(),
|
|
subdir: None,
|
|
},
|
|
installed_at: String::new(),
|
|
updated_at: String::new(),
|
|
path: evil_repo_path,
|
|
plugins: HashMap::from([(
|
|
"escaped-evil".to_string(),
|
|
RepoPlugin {
|
|
subdir: Some("../escaped".to_string()),
|
|
version: None,
|
|
},
|
|
)]),
|
|
},
|
|
);
|
|
registry.insert(
|
|
good_key.to_string(),
|
|
InstalledRepo {
|
|
kind: InstallKind::Local {
|
|
source_path: good_repo_path.clone(),
|
|
subdir: None,
|
|
},
|
|
installed_at: String::new(),
|
|
updated_at: String::new(),
|
|
path: good_repo_path,
|
|
plugins: HashMap::from([(
|
|
"good".to_string(),
|
|
RepoPlugin {
|
|
subdir: Some("plugins/good".to_string()),
|
|
version: None,
|
|
},
|
|
)]),
|
|
},
|
|
);
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
collect_installed_plugins(
|
|
®istry,
|
|
PluginScope::User,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
let names: Vec<&str> = candidates.iter().map(|p| p.plugin_name()).collect();
|
|
assert!(
|
|
names.contains(&"good"),
|
|
"legit subdir plugin should load, got {names:?}"
|
|
);
|
|
assert!(
|
|
!names.contains(&"escaped-evil"),
|
|
"escaping subdir must be skipped, got {names:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dedup_by_canonical_path() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let plugin_dir = make_manifest_plugin(tmp.path(), "dup-test");
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
// Add same plugin twice
|
|
collect_plugin(
|
|
&plugin_dir,
|
|
PluginScope::CliOverride,
|
|
PluginOrigin::CliOverride,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
collect_plugin(
|
|
&plugin_dir,
|
|
PluginScope::CliOverride,
|
|
PluginOrigin::CliOverride,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
assert_eq!(candidates.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn name_conflict_higher_priority_wins() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
// Create two plugins with the same name but different scopes
|
|
let cli_dir = tmp.path().join("cli");
|
|
std::fs::create_dir_all(&cli_dir).unwrap();
|
|
std::fs::write(cli_dir.join("plugin.json"), r#"{"name": "my-plugin"}"#).unwrap();
|
|
|
|
let user_dir = tmp.path().join("user-plugins");
|
|
std::fs::create_dir_all(&user_dir).unwrap();
|
|
let user_plugin = user_dir.join("my-plugin");
|
|
std::fs::create_dir_all(&user_plugin).unwrap();
|
|
std::fs::write(user_plugin.join("plugin.json"), r#"{"name": "my-plugin"}"#).unwrap();
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
|
|
// Add CLI plugin first (higher priority)
|
|
collect_plugin(
|
|
&cli_dir,
|
|
PluginScope::CliOverride,
|
|
PluginOrigin::CliOverride,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
// Add user plugin (lower priority)
|
|
collect_plugin(
|
|
&user_plugin,
|
|
PluginScope::User,
|
|
PluginOrigin::UserKigi,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
resolve_name_conflicts(&mut candidates);
|
|
|
|
assert_eq!(candidates.len(), 1);
|
|
assert_eq!(candidates[0].scope, PluginScope::CliOverride);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_id_format() {
|
|
let id = PluginId::new(
|
|
PluginScope::User,
|
|
Path::new("/home/user/.kigi/plugins/my-plugin"),
|
|
"my-plugin",
|
|
);
|
|
assert!(id.0.starts_with("user/"));
|
|
assert!(id.0.ends_with("/my-plugin"));
|
|
// Format: user/<hex8>/my-plugin
|
|
let parts: Vec<&str> = id.0.split('/').collect();
|
|
assert_eq!(parts.len(), 3);
|
|
assert_eq!(parts[0], "user");
|
|
assert_eq!(parts[1].len(), 8);
|
|
assert_eq!(parts[2], "my-plugin");
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_id_deterministic() {
|
|
let id1 = PluginId::new(PluginScope::User, Path::new("/same/path"), "test");
|
|
let id2 = PluginId::new(PluginScope::User, Path::new("/same/path"), "test");
|
|
assert_eq!(id1, id2);
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_id_different_paths() {
|
|
let id1 = PluginId::new(PluginScope::User, Path::new("/path/a"), "test");
|
|
let id2 = PluginId::new(PluginScope::User, Path::new("/path/b"), "test");
|
|
assert_ne!(id1, id2);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_dir_skipped() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let empty_dir = tmp.path().join("empty");
|
|
std::fs::create_dir_all(&empty_dir).unwrap();
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
collect_plugin(
|
|
&empty_dir,
|
|
PluginScope::User,
|
|
PluginOrigin::UserKigi,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
// No manifest + no skills/agents/mcp/hooks = skipped
|
|
assert!(candidates.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn project_plugin_untrusted_by_default() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let plugin_dir = make_manifest_plugin(tmp.path(), "project-tool");
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
// Untrusted folder (project_trusted = false) blocks the Project plugin.
|
|
collect_plugin(
|
|
&plugin_dir,
|
|
PluginScope::Project,
|
|
PluginOrigin::ProjectKigi,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
assert_eq!(candidates.len(), 1);
|
|
assert!(!candidates[0].trusted);
|
|
}
|
|
|
|
#[test]
|
|
fn project_plugin_trusted_when_folder_trusted() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let plugin_dir = make_manifest_plugin(tmp.path(), "trusted-tool");
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
// Trusted folder (project_trusted = true) allows the Project plugin.
|
|
collect_plugin(
|
|
&plugin_dir,
|
|
PluginScope::Project,
|
|
PluginOrigin::ProjectKigi,
|
|
&trust,
|
|
true,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
|
|
assert_eq!(candidates.len(), 1);
|
|
assert!(candidates[0].trusted);
|
|
}
|
|
|
|
#[test]
|
|
fn non_project_scopes_unaffected_by_project_trusted() {
|
|
// project_trusted = false gates Project scope only: CLI/User stay
|
|
// auto-trusted and ConfigPath keeps using its own trust store.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let cli_dir = make_manifest_plugin(tmp.path(), "cli-tool");
|
|
let user_dir = make_manifest_plugin(tmp.path(), "user-tool");
|
|
let config_dir = make_manifest_plugin(tmp.path(), "config-tool");
|
|
|
|
let mut trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
trust.grant_trust(&config_dir).unwrap();
|
|
|
|
let mut seen = HashSet::new();
|
|
let mut candidates = Vec::new();
|
|
for (dir, scope, origin) in [
|
|
(
|
|
&cli_dir,
|
|
PluginScope::CliOverride,
|
|
PluginOrigin::CliOverride,
|
|
),
|
|
(&user_dir, PluginScope::User, PluginOrigin::UserKigi),
|
|
(
|
|
&config_dir,
|
|
PluginScope::ConfigPath,
|
|
PluginOrigin::ConfigPath,
|
|
),
|
|
] {
|
|
collect_plugin(
|
|
dir,
|
|
scope,
|
|
origin,
|
|
&trust,
|
|
false,
|
|
&mut seen,
|
|
&mut candidates,
|
|
);
|
|
}
|
|
|
|
assert_eq!(candidates.len(), 3);
|
|
assert!(
|
|
candidates.iter().all(|c| c.trusted),
|
|
"CLI/User/ConfigPath plugins must stay trusted under project_trusted=false"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn discover_real_project_plugin_gated_on_project_trusted() {
|
|
// End-to-end through discover_plugins: a repo-local `.kigi/plugins/<x>/`
|
|
// plugin with an MCP component is trusted iff the folder-trust verdict
|
|
// (project_trusted) allows it. Found by name so any user-scoped plugins
|
|
// on the test host are irrelevant.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let plugin_dir = tmp.path().join(".kigi").join("plugins").join("proj-mcp");
|
|
std::fs::create_dir_all(&plugin_dir).unwrap();
|
|
std::fs::write(plugin_dir.join("plugin.json"), r#"{"name": "proj-mcp"}"#).unwrap();
|
|
std::fs::write(plugin_dir.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let config = DiscoveryConfig::default();
|
|
|
|
// Untrusted folder: the project plugin comes back blocked.
|
|
let untrusted = discover_plugins(Some(tmp.path()), &config, &trust, false);
|
|
let p = untrusted
|
|
.iter()
|
|
.find(|p| p.manifest.name == "proj-mcp")
|
|
.expect("project plugin discovered");
|
|
assert_eq!(p.scope, PluginScope::Project);
|
|
assert_eq!(p.origin, PluginOrigin::ProjectKigi);
|
|
assert!(!p.trusted, "untrusted folder must block the project plugin");
|
|
|
|
// Trusted folder: the same plugin is allowed.
|
|
let trusted = discover_plugins(Some(tmp.path()), &config, &trust, true);
|
|
let p = trusted
|
|
.iter()
|
|
.find(|p| p.manifest.name == "proj-mcp")
|
|
.expect("project plugin discovered");
|
|
assert!(p.trusted, "trusted folder must allow the project plugin");
|
|
}
|
|
|
|
#[test]
|
|
fn discover_project_claude_plugin_records_claude_origin() {
|
|
// Unique name: discover_plugins also scans the dev machine's real
|
|
// user dirs, and this test finds its plugin by name.
|
|
let name = format!("proj-claude-tool-{}", std::process::id());
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let plugin_dir = tmp.path().join(".claude").join("plugins").join(&name);
|
|
std::fs::create_dir_all(plugin_dir.join("skills")).unwrap();
|
|
std::fs::write(
|
|
plugin_dir.join("plugin.json"),
|
|
format!(r#"{{"name": "{name}"}}"#),
|
|
)
|
|
.unwrap();
|
|
|
|
let trust = TrustStore::load_from(tmp.path().join("trust"));
|
|
let config = DiscoveryConfig::default();
|
|
let discovered = discover_plugins(Some(tmp.path()), &config, &trust, true);
|
|
let p = discovered
|
|
.iter()
|
|
.find(|p| p.manifest.name == name)
|
|
.expect("project claude plugin discovered");
|
|
assert_eq!(p.scope, PluginScope::Project);
|
|
assert_eq!(p.origin, PluginOrigin::ProjectClaude);
|
|
}
|
|
|
|
#[test]
|
|
fn read_claude_installed_plugins_json() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
// Create fake plugin directories matching the compat cache layout
|
|
let cache = tmp.path().join("cache");
|
|
let plugin_a = cache.join("marketplace-a").join("my-lsp").join("1.0.0");
|
|
let plugin_b = cache.join("marketplace-b").join("my-tool").join("2.0.0");
|
|
std::fs::create_dir_all(plugin_a.join("skills")).unwrap();
|
|
std::fs::create_dir_all(&plugin_b).unwrap();
|
|
std::fs::write(plugin_b.join("plugin.json"), r#"{"name": "my-tool"}"#).unwrap();
|
|
|
|
// Write installed_plugins.json
|
|
let json_path = tmp.path().join("installed_plugins.json");
|
|
let json = serde_json::json!({
|
|
"version": 2,
|
|
"plugins": {
|
|
"my-lsp@marketplace-a": [{
|
|
"scope": "user",
|
|
"installPath": plugin_a.to_string_lossy(),
|
|
"version": "1.0.0"
|
|
}],
|
|
"my-tool@marketplace-b": [{
|
|
"scope": "user",
|
|
"installPath": plugin_b.to_string_lossy(),
|
|
"version": "2.0.0"
|
|
}]
|
|
}
|
|
});
|
|
std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();
|
|
|
|
let results = read_claude_installed_plugins(&json_path, None);
|
|
assert_eq!(results.len(), 2);
|
|
|
|
let names: Vec<&str> = results.iter().map(|(n, _, _)| n.as_str()).collect();
|
|
assert!(names.contains(&"my-lsp"));
|
|
assert!(names.contains(&"my-tool"));
|
|
|
|
let paths: Vec<&PathBuf> = results.iter().map(|(_, _, p)| p).collect();
|
|
assert!(paths.contains(&&plugin_a));
|
|
assert!(paths.contains(&&plugin_b));
|
|
|
|
let lsp = results.iter().find(|(n, _, _)| n == "my-lsp").unwrap();
|
|
assert_eq!(lsp.1.as_deref(), Some("marketplace-a"));
|
|
let tool = results.iter().find(|(n, _, _)| n == "my-tool").unwrap();
|
|
assert_eq!(tool.1.as_deref(), Some("marketplace-b"));
|
|
}
|
|
|
|
#[test]
|
|
fn read_claude_installed_plugins_key_without_marketplace() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let plugin = tmp.path().join("bare");
|
|
std::fs::create_dir_all(&plugin).unwrap();
|
|
let json_path = tmp.path().join("installed_plugins.json");
|
|
let json = serde_json::json!({
|
|
"plugins": { "bare": [{ "installPath": plugin.to_string_lossy() }] }
|
|
});
|
|
std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();
|
|
|
|
let results = read_claude_installed_plugins(&json_path, None);
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].0, "bare");
|
|
assert_eq!(results[0].1, None);
|
|
}
|
|
|
|
#[test]
|
|
fn claude_local_install_included_when_cwd_under_project() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let project = tmp.path().join("proj");
|
|
let nested = project.join("src");
|
|
let plugin = tmp.path().join("cache").join("local-plug");
|
|
std::fs::create_dir_all(&nested).unwrap();
|
|
std::fs::create_dir_all(&plugin).unwrap();
|
|
let json_path = tmp.path().join("installed_plugins.json");
|
|
let json = serde_json::json!({
|
|
"plugins": {
|
|
"local-plug@mp": [{
|
|
"scope": "local",
|
|
"projectPath": project.to_string_lossy(),
|
|
"installPath": plugin.to_string_lossy()
|
|
}]
|
|
}
|
|
});
|
|
std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();
|
|
|
|
let results = read_claude_installed_plugins(&json_path, Some(&nested));
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].0, "local-plug");
|
|
assert_eq!(results[0].2, plugin);
|
|
}
|
|
|
|
#[test]
|
|
fn claude_local_install_excluded_when_cwd_outside_project() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let project = tmp.path().join("proj");
|
|
let other = tmp.path().join("other");
|
|
let plugin = tmp.path().join("cache").join("local-plug");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
std::fs::create_dir_all(&other).unwrap();
|
|
std::fs::create_dir_all(&plugin).unwrap();
|
|
let json_path = tmp.path().join("installed_plugins.json");
|
|
let json = serde_json::json!({
|
|
"plugins": {
|
|
"local-plug@mp": [{
|
|
"scope": "local",
|
|
"projectPath": project.to_string_lossy(),
|
|
"installPath": plugin.to_string_lossy()
|
|
}]
|
|
}
|
|
});
|
|
std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();
|
|
|
|
let results = read_claude_installed_plugins(&json_path, Some(&other));
|
|
assert!(results.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn claude_user_and_unscoped_installs_included_regardless_of_cwd() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let project = tmp.path().join("proj");
|
|
let other = tmp.path().join("other");
|
|
let user_plugin = tmp.path().join("cache").join("user-plug");
|
|
let bare_plugin = tmp.path().join("cache").join("bare-plug");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
std::fs::create_dir_all(&other).unwrap();
|
|
std::fs::create_dir_all(&user_plugin).unwrap();
|
|
std::fs::create_dir_all(&bare_plugin).unwrap();
|
|
let json_path = tmp.path().join("installed_plugins.json");
|
|
let json = serde_json::json!({
|
|
"plugins": {
|
|
"user-plug@mp": [{
|
|
"scope": "user",
|
|
"installPath": user_plugin.to_string_lossy()
|
|
}],
|
|
"bare-plug@mp": [{
|
|
"installPath": bare_plugin.to_string_lossy()
|
|
}],
|
|
"local-plug@mp": [{
|
|
"scope": "local",
|
|
"projectPath": project.to_string_lossy(),
|
|
"installPath": tmp.path().join("cache").join("local-plug").to_string_lossy()
|
|
}]
|
|
}
|
|
});
|
|
std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();
|
|
|
|
let results = read_claude_installed_plugins(&json_path, Some(&other));
|
|
let names: Vec<&str> = results.iter().map(|(n, _, _)| n.as_str()).collect();
|
|
assert!(names.contains(&"user-plug"));
|
|
assert!(names.contains(&"bare-plug"));
|
|
assert!(!names.contains(&"local-plug"));
|
|
}
|
|
|
|
#[test]
|
|
fn claude_local_install_excluded_without_project_path_or_cwd() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let plugin = tmp.path().join("cache").join("local-plug");
|
|
std::fs::create_dir_all(&plugin).unwrap();
|
|
let json_path = tmp.path().join("installed_plugins.json");
|
|
let json = serde_json::json!({
|
|
"plugins": {
|
|
"no-path@mp": [{
|
|
"scope": "local",
|
|
"installPath": plugin.to_string_lossy()
|
|
}],
|
|
"empty-path@mp": [{
|
|
"scope": "local",
|
|
"projectPath": "",
|
|
"installPath": plugin.to_string_lossy()
|
|
}]
|
|
}
|
|
});
|
|
std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();
|
|
|
|
assert!(read_claude_installed_plugins(&json_path, Some(tmp.path())).is_empty());
|
|
|
|
let project = tmp.path().join("proj");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
let json = serde_json::json!({
|
|
"plugins": {
|
|
"has-path@mp": [{
|
|
"scope": "local",
|
|
"projectPath": project.to_string_lossy(),
|
|
"installPath": plugin.to_string_lossy()
|
|
}]
|
|
}
|
|
});
|
|
std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();
|
|
assert!(read_claude_installed_plugins(&json_path, None).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn claude_install_visible_path_boundary_not_string_prefix() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let project = tmp.path().join("proj");
|
|
let sibling = tmp.path().join("proj-other");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
std::fs::create_dir_all(&sibling).unwrap();
|
|
assert!(claude_install_visible(
|
|
Some("local"),
|
|
Some(project.as_path()),
|
|
Some(project.as_path())
|
|
));
|
|
assert!(!claude_install_visible(
|
|
Some("local"),
|
|
Some(project.as_path()),
|
|
Some(sibling.as_path())
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn claude_project_install_excluded_when_cwd_outside_project() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let project = tmp.path().join("proj");
|
|
let other = tmp.path().join("other");
|
|
let plugin = tmp.path().join("cache").join("team-plug");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
std::fs::create_dir_all(&other).unwrap();
|
|
std::fs::create_dir_all(&plugin).unwrap();
|
|
let json_path = tmp.path().join("installed_plugins.json");
|
|
let json = serde_json::json!({
|
|
"plugins": {
|
|
"team-plug@mp": [{
|
|
"scope": "project",
|
|
"projectPath": project.to_string_lossy(),
|
|
"installPath": plugin.to_string_lossy()
|
|
}]
|
|
}
|
|
});
|
|
std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();
|
|
|
|
assert!(read_claude_installed_plugins(&json_path, Some(&other)).is_empty());
|
|
let results = read_claude_installed_plugins(&json_path, Some(&project));
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].0, "team-plug");
|
|
}
|
|
|
|
#[test]
|
|
fn claude_entry_with_project_path_gated_even_without_local_scope() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let project = tmp.path().join("proj");
|
|
let other = tmp.path().join("other");
|
|
let plugin = tmp.path().join("cache").join("path-only");
|
|
std::fs::create_dir_all(&project).unwrap();
|
|
std::fs::create_dir_all(&other).unwrap();
|
|
std::fs::create_dir_all(&plugin).unwrap();
|
|
let json_path = tmp.path().join("installed_plugins.json");
|
|
let json = serde_json::json!({
|
|
"plugins": {
|
|
"path-only@mp": [{
|
|
"projectPath": project.to_string_lossy(),
|
|
"installPath": plugin.to_string_lossy()
|
|
}]
|
|
}
|
|
});
|
|
std::fs::write(&json_path, serde_json::to_string(&json).unwrap()).unwrap();
|
|
|
|
assert!(read_claude_installed_plugins(&json_path, Some(&other)).is_empty());
|
|
assert_eq!(
|
|
read_claude_installed_plugins(&json_path, Some(&project)).len(),
|
|
1
|
|
);
|
|
}
|
|
}
|