M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
commit
d6c20fc13f
2612 files changed
+1353757
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,661 @@
|
||||
//! Plugin hooks adapter — pre-filter and source-entry builder.
|
||||
//!
|
||||
//! This module is a bridge between plugin hook JSON files and the shared
|
||||
//! `kigi-hooks` runtime. It pre-filters unsupported events from plugin
|
||||
//! hook files before passing them to `parse_hook_file()`, and injects
|
||||
//! plugin-specific environment variables into the resulting `HookSpec` entries.
|
||||
//!
|
||||
//! This is NOT a second hooks engine — it feeds into the existing
|
||||
//! `kigi-hooks` crate's parser and runtime.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use kigi_hooks::config::{HookSpec, parse_hook_file};
|
||||
|
||||
use super::manifest::substitute_env_vars;
|
||||
|
||||
/// Supported hook event names.
|
||||
/// Both PascalCase and snake_case forms are accepted.
|
||||
const SUPPORTED_EVENTS: &[&str] = &[
|
||||
// v0 events — PascalCase and snake_case
|
||||
"SessionStart",
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"SessionEnd",
|
||||
"session_start",
|
||||
"pre_tool_use",
|
||||
"post_tool_use",
|
||||
"session_end",
|
||||
// v2 events — PascalCase and snake_case
|
||||
"Notification",
|
||||
"Stop",
|
||||
"UserPromptSubmit",
|
||||
"SubagentStart",
|
||||
"SubagentEnd",
|
||||
"notification",
|
||||
"stop",
|
||||
"user_prompt_submit",
|
||||
"subagent_start",
|
||||
"subagent_end",
|
||||
];
|
||||
|
||||
/// Parse plugin hook files with pre-filtering and env injection.
|
||||
///
|
||||
/// For each trusted plugin with hooks, this function:
|
||||
/// 1. Reads the hooks JSON file
|
||||
/// 2. Pre-filters unsupported event names (avoiding parse failures)
|
||||
/// 3. Parses via `parse_hook_file()`
|
||||
/// 4. Injects plugin-specific env vars into each resulting `HookSpec`
|
||||
///
|
||||
/// Returns `(specs, warnings)` — specs are ready to merge into the
|
||||
/// `HookRegistry`, warnings are unsupported-handler or parse errors.
|
||||
pub fn parse_plugin_hooks(
|
||||
hooks_path: &Path,
|
||||
plugin_name: &str,
|
||||
plugin_root: &str,
|
||||
plugin_data: &str,
|
||||
) -> (Vec<HookSpec>, Vec<String>) {
|
||||
let content = match std::fs::read_to_string(hooks_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return (
|
||||
vec![],
|
||||
vec![format!(
|
||||
"plugin {plugin_name}: failed to read hooks file {}: {e}",
|
||||
hooks_path.display()
|
||||
)],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let (specs, warnings) =
|
||||
process_hooks_content(&content, hooks_path, plugin_name, plugin_root, plugin_data);
|
||||
tracing::debug!(
|
||||
plugin = plugin_name,
|
||||
hooks_count = specs.len(),
|
||||
warnings = warnings.len(),
|
||||
"plugin hooks loaded from file"
|
||||
);
|
||||
(specs, warnings)
|
||||
}
|
||||
|
||||
/// Parse inline hooks from a manifest JSON value.
|
||||
///
|
||||
/// Same pipeline as [`parse_plugin_hooks()`] but skips the file I/O step.
|
||||
/// The `value` is expected to be the manifest's inline hooks object,
|
||||
/// structured as `{ "hooks": { "EventName": [...] } }`.
|
||||
pub fn parse_plugin_hooks_from_value(
|
||||
value: &serde_json::Value,
|
||||
plugin_name: &str,
|
||||
plugin_root: &str,
|
||||
plugin_data: &str,
|
||||
) -> (Vec<HookSpec>, Vec<String>) {
|
||||
let content = serde_json::to_string(value).unwrap_or_default();
|
||||
// Use a synthetic path for parse_hook_file's source_dir (resolves relative commands).
|
||||
let synthetic_path = Path::new(plugin_root).join("plugin.json");
|
||||
let (specs, warnings) = process_hooks_content(
|
||||
&content,
|
||||
&synthetic_path,
|
||||
plugin_name,
|
||||
plugin_root,
|
||||
plugin_data,
|
||||
);
|
||||
tracing::debug!(
|
||||
plugin = plugin_name,
|
||||
hooks_count = specs.len(),
|
||||
warnings = warnings.len(),
|
||||
"plugin hooks loaded from manifest inline"
|
||||
);
|
||||
(specs, warnings)
|
||||
}
|
||||
|
||||
/// Shared processing pipeline for plugin hooks (file-based or inline).
|
||||
///
|
||||
/// Pre-filters unsupported events, parses via `parse_hook_file()`,
|
||||
/// injects plugin env vars, and namespaces hook names.
|
||||
fn process_hooks_content(
|
||||
content: &str,
|
||||
source_path: &Path,
|
||||
plugin_name: &str,
|
||||
plugin_root: &str,
|
||||
plugin_data: &str,
|
||||
) -> (Vec<HookSpec>, Vec<String>) {
|
||||
let (filtered_content, skipped_events) = prefilter_unsupported_events(content);
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
|
||||
for event in &skipped_events {
|
||||
tracing::info!(
|
||||
plugin = plugin_name,
|
||||
event = event,
|
||||
"skipping unsupported hook event from plugin"
|
||||
);
|
||||
warnings.push(format!(
|
||||
"plugin {plugin_name}: skipped unsupported event '{event}'"
|
||||
));
|
||||
}
|
||||
|
||||
let (mut specs, parse_errors) = parse_hook_file(&filtered_content, source_path);
|
||||
|
||||
for err in &parse_errors {
|
||||
let msg = format!("plugin {plugin_name}: {err}");
|
||||
tracing::warn!("{msg}");
|
||||
warnings.push(msg);
|
||||
}
|
||||
|
||||
// Build plugin env vars. `KIGI_PLUGIN_*` is the native contract;
|
||||
// `CLAUDE_PLUGIN_*` aliases the same values for external hooks that read
|
||||
// those names.
|
||||
let plugin_env: HashMap<String, String> = HashMap::from([
|
||||
("KIGI_PLUGIN_ROOT".to_string(), plugin_root.to_string()),
|
||||
("CLAUDE_PLUGIN_ROOT".to_string(), plugin_root.to_string()),
|
||||
("KIGI_PLUGIN_DATA".to_string(), plugin_data.to_string()),
|
||||
("CLAUDE_PLUGIN_DATA".to_string(), plugin_data.to_string()),
|
||||
]);
|
||||
|
||||
// Inject env vars and update source labels.
|
||||
//
|
||||
// The plugin adapter owns the keys in `plugin_env` (CLAUDE_PLUGIN_ROOT
|
||||
// etc.), so plugin-injected values must always win over any
|
||||
// user-declared `env` on the hook JSON for those specific keys --
|
||||
// otherwise a plugin author could (deliberately or by accident) pin
|
||||
// the plugin root to an arbitrary path and break the plugin
|
||||
// contract. User-declared keys not owned by the plugin are
|
||||
// preserved.
|
||||
for spec in &mut specs {
|
||||
for (k, v) in &plugin_env {
|
||||
spec.extra_env.insert(k.clone(), v.clone());
|
||||
}
|
||||
// Prefix name with plugin namespace for identification
|
||||
spec.name = format!("plugin/{}/{}", plugin_name, spec.name);
|
||||
// Substitute plugin env vars in command paths at config-load time so
|
||||
// that hooks like `${CLAUDE_PLUGIN_ROOT}/hooks/foo.sh` resolve to the
|
||||
// real plugin directory regardless of which spawn branch the runner
|
||||
// takes (mirrors what managed_mcp does for MCP server commands).
|
||||
if let Some(cmd) = &spec.command {
|
||||
let cmd_str = cmd.to_string_lossy();
|
||||
// Mirror what `managed_mcp::load_plugin_mcp_servers_from_config`
|
||||
// does for plugin MCP server commands: first substitute the
|
||||
// plugin-specific placeholders (`${CLAUDE_PLUGIN_ROOT}` and
|
||||
// friends), then run the result through the generic
|
||||
// `${VAR}` / `$VAR` env expansion. Doing both passes at
|
||||
// config-load time keeps hook env var resolution consistent
|
||||
// with managed MCP server resolution and avoids relying on
|
||||
// the runtime `sh -c` shell-metachar heuristic in
|
||||
// `kigi-hooks::runner::command` for env vars whose
|
||||
// values are already known at load time.
|
||||
let substituted = substitute_env_vars(&cmd_str, plugin_root, plugin_data);
|
||||
let expanded = kigi_config::expand_env_vars_in_string(&substituted);
|
||||
if expanded != cmd_str {
|
||||
spec.command = Some(PathBuf::from(expanded));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(specs, warnings)
|
||||
}
|
||||
|
||||
/// Pre-filter unsupported event names from a hooks JSON file.
|
||||
///
|
||||
/// Parses the JSON, removes event keys from the `"hooks"` object that are
|
||||
/// not in the supported set, and returns the filtered JSON string plus the
|
||||
/// list of removed event names.
|
||||
///
|
||||
/// This is critical because the hooks crate uses `HashMap<HookEventName, ...>`
|
||||
/// deserialization which causes a full parse failure on unknown event names.
|
||||
fn prefilter_unsupported_events(json_content: &str) -> (String, Vec<String>) {
|
||||
let mut value: serde_json::Value = match serde_json::from_str(json_content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
// If JSON is invalid, return as-is and let parse_hook_file handle the error
|
||||
return (json_content.to_string(), vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let mut skipped = Vec::new();
|
||||
|
||||
if let Some(hooks_obj) = value.get_mut("hooks").and_then(|v| v.as_object_mut()) {
|
||||
let keys_to_remove: Vec<String> = hooks_obj
|
||||
.keys()
|
||||
.filter(|key| !SUPPORTED_EVENTS.contains(&key.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for key in keys_to_remove {
|
||||
hooks_obj.remove(&key);
|
||||
skipped.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| json_content.to_string()),
|
||||
skipped,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefilter_removes_unsupported_events() {
|
||||
let json = r#"{
|
||||
"hooks": {
|
||||
"SessionStart": [{"hooks": [{"type": "command", "command": "echo start"}]}],
|
||||
"CustomEvent": [{"hooks": [{"type": "command", "command": "echo custom"}]}],
|
||||
"UnknownHook": [{"hooks": [{"type": "command", "command": "echo unknown"}]}],
|
||||
"PostToolUse": [{"hooks": [{"type": "command", "command": "echo post"}]}]
|
||||
}
|
||||
}"#;
|
||||
|
||||
let (filtered, skipped) = prefilter_unsupported_events(json);
|
||||
|
||||
assert_eq!(skipped.len(), 2);
|
||||
assert!(skipped.contains(&"CustomEvent".to_string()));
|
||||
assert!(skipped.contains(&"UnknownHook".to_string()));
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&filtered).unwrap();
|
||||
let hooks = parsed["hooks"].as_object().unwrap();
|
||||
assert!(hooks.contains_key("SessionStart"));
|
||||
assert!(hooks.contains_key("PostToolUse"));
|
||||
assert!(!hooks.contains_key("CustomEvent"));
|
||||
assert!(!hooks.contains_key("UnknownHook"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefilter_preserves_all_supported_events() {
|
||||
let json = r#"{
|
||||
"hooks": {
|
||||
"SessionStart": [],
|
||||
"PreToolUse": [],
|
||||
"PostToolUse": [],
|
||||
"SessionEnd": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
let (_, skipped) = prefilter_unsupported_events(json);
|
||||
assert!(skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefilter_handles_snake_case_events() {
|
||||
let json = r#"{
|
||||
"hooks": {
|
||||
"session_start": [],
|
||||
"pre_tool_use": [],
|
||||
"unknown_event": []
|
||||
}
|
||||
}"#;
|
||||
|
||||
let (_, skipped) = prefilter_unsupported_events(json);
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert!(skipped.contains(&"unknown_event".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefilter_handles_invalid_json() {
|
||||
let json = "not valid json{";
|
||||
let (filtered, skipped) = prefilter_unsupported_events(json);
|
||||
assert_eq!(filtered, json); // returned as-is
|
||||
assert!(skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefilter_handles_no_hooks_key() {
|
||||
let json = r#"{"settings": {}}"#;
|
||||
let (_, skipped) = prefilter_unsupported_events(json);
|
||||
assert!(skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_plugin_hooks_from_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let hooks_dir = tmp.path().join("hooks");
|
||||
std::fs::create_dir_all(&hooks_dir).unwrap();
|
||||
|
||||
let hooks_file = hooks_dir.join("hooks.json");
|
||||
std::fs::write(
|
||||
&hooks_file,
|
||||
r#"{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{"type": "command", "command": "echo plugin-hook"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"FutureEvent": [
|
||||
{
|
||||
"hooks": [
|
||||
{"type": "command", "command": "echo unsupported"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (specs, warnings) =
|
||||
parse_plugin_hooks(&hooks_file, "my-plugin", "/path/to/plugin", "/path/to/data");
|
||||
|
||||
// Should have 1 spec from SessionStart, FutureEvent was filtered
|
||||
assert_eq!(specs.len(), 1);
|
||||
assert!(specs[0].name.starts_with("plugin/my-plugin/"));
|
||||
assert_eq!(
|
||||
specs[0].extra_env.get("KIGI_PLUGIN_ROOT").unwrap(),
|
||||
"/path/to/plugin"
|
||||
);
|
||||
assert_eq!(
|
||||
specs[0].extra_env.get("CLAUDE_PLUGIN_ROOT").unwrap(),
|
||||
"/path/to/plugin"
|
||||
);
|
||||
assert_eq!(
|
||||
specs[0].extra_env.get("KIGI_PLUGIN_DATA").unwrap(),
|
||||
"/path/to/data"
|
||||
);
|
||||
|
||||
// Should have a warning about FutureEvent
|
||||
assert!(warnings.iter().any(|w| w.contains("FutureEvent")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_inline_hooks_from_value() {
|
||||
let value = serde_json::json!({
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{"type": "command", "command": "echo inline-hook"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let (specs, warnings) = parse_plugin_hooks_from_value(
|
||||
&value,
|
||||
"inline-plugin",
|
||||
"/path/to/plugin",
|
||||
"/path/to/data",
|
||||
);
|
||||
|
||||
assert_eq!(specs.len(), 1);
|
||||
assert!(specs[0].name.starts_with("plugin/inline-plugin/"));
|
||||
assert_eq!(
|
||||
specs[0].extra_env.get("KIGI_PLUGIN_ROOT").unwrap(),
|
||||
"/path/to/plugin"
|
||||
);
|
||||
assert!(warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_inline_hooks_filters_unsupported_events() {
|
||||
let value = serde_json::json!({
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{"hooks": [{"type": "command", "command": "echo post"}]}
|
||||
],
|
||||
"FutureEvent": [
|
||||
{"hooks": [{"type": "command", "command": "echo future"}]}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let (specs, warnings) =
|
||||
parse_plugin_hooks_from_value(&value, "filter-test", "/root", "/data");
|
||||
|
||||
// PostToolUse is supported, FutureEvent is not
|
||||
assert_eq!(specs.len(), 1);
|
||||
assert!(warnings.iter().any(|w| w.contains("FutureEvent")));
|
||||
}
|
||||
|
||||
/// Regression: hook commands that reference
|
||||
/// `${CLAUDE_PLUGIN_ROOT}` (or its `KIGI_PLUGIN_ROOT` alias) must be
|
||||
/// substituted at config-load time so the runner spawns the real
|
||||
/// plugin path. Without substitution the runner's pre-spawn env-var
|
||||
/// check refuses to run such hooks (the dispatcher fail-opens so the
|
||||
/// tool call itself is not blocked, but the hook never runs).
|
||||
#[test]
|
||||
fn parse_plugin_hooks_substitutes_plugin_root_in_command() {
|
||||
let value = serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{"hooks": [
|
||||
{"type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/pre.sh"},
|
||||
{"type": "command", "command": "${KIGI_PLUGIN_ROOT}/hooks/alias.sh"},
|
||||
{"type": "command", "command": "${CLAUDE_PLUGIN_DATA}/cache/post.sh"}
|
||||
]}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let (specs, warnings) = parse_plugin_hooks_from_value(
|
||||
&value,
|
||||
"gb1183-plugin",
|
||||
"/opt/plugins/gb1183",
|
||||
"/var/plugins/gb1183",
|
||||
);
|
||||
|
||||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||||
assert_eq!(specs.len(), 3);
|
||||
|
||||
let commands: Vec<String> = specs
|
||||
.iter()
|
||||
.map(|s| s.command.as_ref().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(commands.contains(&"/opt/plugins/gb1183/hooks/pre.sh".to_string()));
|
||||
assert!(commands.contains(&"/opt/plugins/gb1183/hooks/alias.sh".to_string()));
|
||||
assert!(commands.contains(&"/var/plugins/gb1183/cache/post.sh".to_string()));
|
||||
|
||||
// None of the resolved commands should still contain the literal
|
||||
// `${...}` placeholder.
|
||||
for cmd in &commands {
|
||||
assert!(
|
||||
!cmd.contains("${"),
|
||||
"command still contains placeholder: {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
// The plugin adapter must NOT mutate
|
||||
// `command_raw`. The pager UI / ACP DTO surface the raw form
|
||||
// for display so users see what they wrote (and so any secrets
|
||||
// resolved from `extra_env` don't leak). A future "tidy" pass
|
||||
// that mistakenly rewrote `command_raw` would silently break
|
||||
// the secrets-leakage protection.
|
||||
let raws: Vec<&str> = specs
|
||||
.iter()
|
||||
.map(|s| s.command_raw.as_deref().unwrap_or(""))
|
||||
.collect();
|
||||
assert!(
|
||||
raws.contains(&"${CLAUDE_PLUGIN_ROOT}/hooks/pre.sh"),
|
||||
"command_raw must preserve the source string verbatim, got {raws:?}"
|
||||
);
|
||||
assert!(
|
||||
raws.contains(&"${KIGI_PLUGIN_ROOT}/hooks/alias.sh"),
|
||||
"command_raw must preserve the source string verbatim, got {raws:?}"
|
||||
);
|
||||
assert!(
|
||||
raws.contains(&"${CLAUDE_PLUGIN_DATA}/cache/post.sh"),
|
||||
"command_raw must preserve the source string verbatim, got {raws:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_inline_hooks_handles_empty_value() {
|
||||
let value = serde_json::json!({});
|
||||
let (specs, warnings) = parse_plugin_hooks_from_value(&value, "empty", "/root", "/data");
|
||||
assert!(specs.is_empty());
|
||||
assert!(warnings.is_empty());
|
||||
}
|
||||
|
||||
/// Regression: plugin hook commands that reference generic env vars
|
||||
/// (e.g. `${HOME}` / `$HOME`) must be expanded at config-load time
|
||||
/// just like managed MCP server commands. Otherwise resolution
|
||||
/// depends on the runtime `sh -c` heuristic in
|
||||
/// `kigi-hooks::runner::command`, which can fail for hooks
|
||||
/// whose handler doesn't otherwise contain shell metacharacters.
|
||||
/// Plugin hooks must not be double-expanded: a `${CLAUDE_PLUGIN_ROOT}`
|
||||
/// reference resolves to the plugin root exactly once, and the result
|
||||
/// contains no leftover `$` placeholders. This is the contract the
|
||||
/// hooks_adapter has long held, and it must continue to hold
|
||||
/// now that `parse_hook_file` itself does an env-expansion pass with
|
||||
/// the per-hook `extra_env`. The first pass (in `parse_hook_file`)
|
||||
/// runs against an EMPTY `extra_env` for plugin hooks (the adapter
|
||||
/// only fills it in afterwards), so the placeholder survives that
|
||||
/// pass and the second pass (here, after `extra_env` is wired in)
|
||||
/// resolves it.
|
||||
#[test]
|
||||
fn parse_plugin_hooks_resolves_plugin_root_exactly_once() {
|
||||
let value = serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{"hooks": [
|
||||
{"type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/x.sh"}
|
||||
]}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let (specs, warnings) = parse_plugin_hooks_from_value(
|
||||
&value,
|
||||
"no-double-expand",
|
||||
"/the/plugin/root",
|
||||
"/the/plugin/data",
|
||||
);
|
||||
|
||||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||||
assert_eq!(specs.len(), 1);
|
||||
let cmd = specs[0]
|
||||
.command
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
assert_eq!(cmd, "/the/plugin/root/x.sh");
|
||||
assert!(
|
||||
!cmd.contains('$'),
|
||||
"command must not contain leftover $: {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Plugin hook JSON may declare its own `env` map. The user-declared
|
||||
/// keys land in `extra_env`, but the plugin adapter MUST override
|
||||
/// any user-declared value for keys the plugin owns
|
||||
/// (CLAUDE_PLUGIN_ROOT, KIGI_PLUGIN_ROOT, CLAUDE_PLUGIN_DATA,
|
||||
/// KIGI_PLUGIN_DATA). This preserves the plugin contract while still
|
||||
/// supporting user-defined env vars on plugin hooks.
|
||||
#[test]
|
||||
fn parse_plugin_hooks_user_env_merged_with_plugin_precedence() {
|
||||
// Exercise ALL FOUR plugin-owned keys, not just
|
||||
// CLAUDE_PLUGIN_ROOT. A regression that only iterates one key
|
||||
// would otherwise pass.
|
||||
let value = serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo hi",
|
||||
"env": {
|
||||
"FOO": "bar",
|
||||
"CLAUDE_PLUGIN_ROOT": "/user/wins?",
|
||||
"KIGI_PLUGIN_ROOT": "/user/wins?",
|
||||
"CLAUDE_PLUGIN_DATA": "/user/wins?",
|
||||
"KIGI_PLUGIN_DATA": "/user/wins?"
|
||||
}
|
||||
}
|
||||
]}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let (specs, warnings) = parse_plugin_hooks_from_value(
|
||||
&value,
|
||||
"user-env-plugin",
|
||||
"/actual/plugin/root",
|
||||
"/actual/plugin/data",
|
||||
);
|
||||
|
||||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||||
assert_eq!(specs.len(), 1);
|
||||
|
||||
// User-declared key the plugin doesn't own: preserved verbatim.
|
||||
assert_eq!(
|
||||
specs[0].extra_env.get("FOO").map(String::as_str),
|
||||
Some("bar"),
|
||||
"user-declared env keys must survive plugin merge"
|
||||
);
|
||||
|
||||
// All four plugin-owned keys: plugin wins, user's attempt is
|
||||
// overridden. CLAUDE_PLUGIN_ROOT and KIGI_PLUGIN_ROOT both map
|
||||
// to plugin_root; CLAUDE_PLUGIN_DATA and KIGI_PLUGIN_DATA both
|
||||
// map to plugin_data.
|
||||
for (key, expected) in [
|
||||
("CLAUDE_PLUGIN_ROOT", "/actual/plugin/root"),
|
||||
("KIGI_PLUGIN_ROOT", "/actual/plugin/root"),
|
||||
("CLAUDE_PLUGIN_DATA", "/actual/plugin/data"),
|
||||
("KIGI_PLUGIN_DATA", "/actual/plugin/data"),
|
||||
] {
|
||||
assert_eq!(
|
||||
specs[0].extra_env.get(key).map(String::as_str),
|
||||
Some(expected),
|
||||
"plugin-injected key {key} must override user-declared value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_plugin_hooks_expands_generic_env_vars_in_command() {
|
||||
// SAFETY: only mutated within this single-threaded test.
|
||||
// SAFETY: this test sets process env vars; tokio test macros
|
||||
// serialize tests within the same module by default but to be
|
||||
// robust use a uniquely-named var.
|
||||
let var = "GB1183_HOOKS_ADAPTER_TEST_HOME";
|
||||
// SAFETY: env writes are not thread-safe; this test is single-threaded.
|
||||
unsafe {
|
||||
std::env::set_var(var, "/expanded/home");
|
||||
}
|
||||
|
||||
let cmd_braces = format!("${{{var}}}/helper.sh");
|
||||
let cmd_bare = format!("${var}/raw.sh");
|
||||
let value = serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{"hooks": [
|
||||
{"type": "command", "command": cmd_braces},
|
||||
{"type": "command", "command": cmd_bare},
|
||||
]}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let (specs, warnings) =
|
||||
parse_plugin_hooks_from_value(&value, "env-expand", "/root", "/data");
|
||||
|
||||
// SAFETY: env writes are not thread-safe; this test is single-threaded.
|
||||
unsafe {
|
||||
std::env::remove_var(var);
|
||||
}
|
||||
|
||||
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
|
||||
assert_eq!(specs.len(), 2);
|
||||
|
||||
let commands: Vec<String> = specs
|
||||
.iter()
|
||||
.map(|s| s.command.as_ref().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
commands.contains(&"/expanded/home/helper.sh".to_string()),
|
||||
"missing brace-form expansion: {commands:?}"
|
||||
);
|
||||
assert!(
|
||||
commands.contains(&"/expanded/home/raw.sh".to_string()),
|
||||
"missing bare-form expansion: {commands:?}"
|
||||
);
|
||||
for cmd in &commands {
|
||||
assert!(!cmd.contains('$'), "command still contains $: {cmd}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
//! Install registry for managing plugins installed from git repos or local directories.
|
||||
//!
|
||||
//! Tracks which repos have been cloned/symlinked into the managed install directory,
|
||||
//! along with the plugins discovered within each repo.
|
||||
//!
|
||||
//! The registry is persisted as `registry.json` in the install directory.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Default install directory name under `~/.kigi/`.
|
||||
const DEFAULT_INSTALL_DIR_NAME: &str = "installed-plugins";
|
||||
|
||||
/// Registry of installed repos and their plugins.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstallRegistry {
|
||||
/// Schema version for forward compatibility.
|
||||
pub version: u32,
|
||||
/// Installed repos, keyed by repo key (`<basename>-<hash8>`).
|
||||
pub repos: HashMap<String, InstalledRepo>,
|
||||
/// Absolute path to the install directory.
|
||||
#[serde(skip)]
|
||||
install_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// How a repo was installed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum InstallKind {
|
||||
/// Cloned from a remote git repo.
|
||||
Git {
|
||||
url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
git_ref: Option<String>,
|
||||
commit: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
subdir: Option<String>,
|
||||
},
|
||||
/// Copied from a local directory (full tree snapshot under installed-plugins).
|
||||
Local {
|
||||
source_path: PathBuf,
|
||||
/// Optional plugin subdirectory selector used at install time (e.g.
|
||||
/// multi-package `path#plugins/foo`). Preserved so refresh rediscovers
|
||||
/// the same scope.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
subdir: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A single installed repo, which may contain one or more plugins.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InstalledRepo {
|
||||
pub kind: InstallKind,
|
||||
pub installed_at: String,
|
||||
pub updated_at: String,
|
||||
/// Absolute path to the repo directory (or symlink) in the install dir.
|
||||
pub path: PathBuf,
|
||||
/// Plugins discovered within this repo.
|
||||
pub plugins: HashMap<String, RepoPlugin>,
|
||||
}
|
||||
|
||||
/// A plugin discovered within an installed repo.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RepoPlugin {
|
||||
/// Subdirectory within the repo (None if plugin is at repo root).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subdir: Option<String>,
|
||||
/// Plugin version from manifest (if available).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
fn paths_match_plugin_root(
|
||||
installed_plugin_root: &Path,
|
||||
plugin_root: &Path,
|
||||
plugin_canonical_root: &Path,
|
||||
) -> bool {
|
||||
installed_plugin_root == plugin_root
|
||||
|| installed_plugin_root == plugin_canonical_root
|
||||
|| dunce::canonicalize(installed_plugin_root)
|
||||
.ok()
|
||||
.is_some_and(|canonical| canonical == plugin_root || canonical == plugin_canonical_root)
|
||||
}
|
||||
|
||||
impl InstallRegistry {
|
||||
/// Load the registry from the resolved install directory.
|
||||
///
|
||||
/// If the registry file doesn't exist, returns an empty registry.
|
||||
pub fn load() -> Self {
|
||||
let install_dir = Self::resolve_install_dir();
|
||||
let registry_path = install_dir.join("registry.json");
|
||||
|
||||
match std::fs::read_to_string(®istry_path) {
|
||||
Ok(content) => match serde_json::from_str::<InstallRegistry>(&content) {
|
||||
Ok(mut reg) => {
|
||||
reg.install_dir = install_dir;
|
||||
reg
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %registry_path.display(),
|
||||
error = %e,
|
||||
"failed to parse install registry; starting fresh"
|
||||
);
|
||||
Self::empty(install_dir)
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::empty(install_dir),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %registry_path.display(),
|
||||
error = %e,
|
||||
"failed to read install registry; starting fresh"
|
||||
);
|
||||
Self::empty(install_dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an empty registry for the given install directory.
|
||||
pub fn empty(install_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
repos: HashMap::new(),
|
||||
install_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the registry to disk.
|
||||
pub fn save(&self) -> Result<(), InstallError> {
|
||||
self.save_atomic()
|
||||
}
|
||||
|
||||
pub fn save_atomic(&self) -> Result<(), InstallError> {
|
||||
std::fs::create_dir_all(&self.install_dir).map_err(|e| InstallError::Io {
|
||||
path: self.install_dir.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let registry_path = self.install_dir.join("registry.json");
|
||||
let content = serde_json::to_string_pretty(self).map_err(|e| InstallError::Json {
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if std::env::var_os("KIGI_TEST_FAIL_REGISTRY_SAVE_AFTER_SERIALIZE").is_some() {
|
||||
return Err(InstallError::InstallFailed {
|
||||
detail: "test-injected registry save failure".into(),
|
||||
});
|
||||
}
|
||||
let temp_path = self.install_dir.join(format!(
|
||||
".registry.json.tmp-{}-{}",
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
));
|
||||
std::fs::write(&temp_path, content).map_err(|e| InstallError::Io {
|
||||
path: temp_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if let Err(e) = std::fs::rename(&temp_path, ®istry_path) {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(InstallError::Io {
|
||||
path: registry_path,
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a repo by its repo key.
|
||||
pub fn get_repo(&self, repo_key: &str) -> Option<&InstalledRepo> {
|
||||
self.repos.get(repo_key)
|
||||
}
|
||||
|
||||
/// Get a mutable reference to a repo by its repo key.
|
||||
pub fn get_repo_mut(&mut self, repo_key: &str) -> Option<&mut InstalledRepo> {
|
||||
self.repos.get_mut(repo_key)
|
||||
}
|
||||
|
||||
/// Find which repo a plugin belongs to.
|
||||
///
|
||||
/// Returns `(repo_key, repo, plugin)` if found.
|
||||
pub fn find_plugin(&self, plugin_name: &str) -> Option<(&str, &InstalledRepo, &RepoPlugin)> {
|
||||
for (repo_key, repo) in &self.repos {
|
||||
if let Some(plugin) = repo.plugins.get(plugin_name) {
|
||||
return Some((repo_key, repo, plugin));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn find_repo_key_by_plugin_root(
|
||||
&self,
|
||||
plugin_root: &Path,
|
||||
plugin_canonical_root: &Path,
|
||||
) -> Option<&str> {
|
||||
self.list().into_iter().find_map(|(repo_key, repo)| {
|
||||
repo.plugins.values().find_map(|plugin| {
|
||||
let installed_plugin_root = match plugin.subdir.as_deref() {
|
||||
Some(subdir) => repo.path.join(subdir),
|
||||
None => repo.path.clone(),
|
||||
};
|
||||
paths_match_plugin_root(&installed_plugin_root, plugin_root, plugin_canonical_root)
|
||||
.then_some(repo_key)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert a repo into the registry.
|
||||
pub fn insert(&mut self, repo_key: String, repo: InstalledRepo) {
|
||||
self.repos.insert(repo_key, repo);
|
||||
}
|
||||
|
||||
/// Remove a repo from the registry.
|
||||
pub fn remove(&mut self, repo_key: &str) -> Option<InstalledRepo> {
|
||||
self.repos.remove(repo_key)
|
||||
}
|
||||
|
||||
/// List all installed repos.
|
||||
pub fn list(&self) -> Vec<(&str, &InstalledRepo)> {
|
||||
let mut entries: Vec<_> = self.repos.iter().map(|(k, v)| (k.as_str(), v)).collect();
|
||||
entries.sort_by_key(|(k, _)| *k);
|
||||
entries
|
||||
}
|
||||
|
||||
/// Get the install directory path.
|
||||
pub fn install_dir(&self) -> &Path {
|
||||
&self.install_dir
|
||||
}
|
||||
|
||||
/// Resolve the install directory from config or default.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `[plugins].install_dir` from effective config (requirements > config > managed)
|
||||
/// 2. Default: `~/.kigi/installed-plugins/`
|
||||
pub fn resolve_install_dir() -> PathBuf {
|
||||
if let Some(dir) = Self::read_install_dir_from_config() {
|
||||
return dir;
|
||||
}
|
||||
|
||||
kigi_config::kigi_home().join(DEFAULT_INSTALL_DIR_NAME)
|
||||
}
|
||||
|
||||
/// Read `[plugins].install_dir` from the effective config
|
||||
/// (managed_config.toml merged under config.toml — user wins).
|
||||
fn read_install_dir_from_config() -> Option<PathBuf> {
|
||||
let root = kigi_config::load_effective_config_disk_only().ok()?;
|
||||
let value = root.get("plugins")?.get("install_dir")?.as_str()?;
|
||||
let expanded = if let Some(stripped) = value.strip_prefix("~/") {
|
||||
dirs::home_dir()?.join(stripped)
|
||||
} else {
|
||||
PathBuf::from(value)
|
||||
};
|
||||
Some(expanded)
|
||||
}
|
||||
|
||||
/// Generate a unique repo key from a source identifier.
|
||||
///
|
||||
/// Format: `<basename>-<hash8>` where hash8 = first 8 hex chars of
|
||||
/// SHA-256(normalized source).
|
||||
///
|
||||
/// Examples:
|
||||
/// - `https://github.com/org-a/tools` → `tools-a1b2c3d4`
|
||||
/// - `/Users/me/projects/my-plugin` → `my-plugin-e5f6g7h8`
|
||||
pub fn repo_key(source: &str) -> String {
|
||||
let basename = source
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches(".git")
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("plugin");
|
||||
|
||||
// Sanitize basename to kebab-case
|
||||
let sanitized: String = basename
|
||||
.to_ascii_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = sanitized.trim_matches('-');
|
||||
|
||||
// Hash the full source for uniqueness
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
source.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
let hash8 = format!("{:08x}", hash & 0xFFFFFFFF);
|
||||
|
||||
format!("{trimmed}-{hash8}")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum InstallError {
|
||||
#[error("I/O error on {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("JSON error: {detail}")]
|
||||
Json { detail: String },
|
||||
|
||||
#[error("plugin '{name}' not found in install registry")]
|
||||
PluginNotFound { name: String },
|
||||
|
||||
#[error("repo '{key}' already installed")]
|
||||
AlreadyInstalled { key: String },
|
||||
|
||||
#[error("SHA verification failed: expected {expected}, got {actual}")]
|
||||
ShaMismatch { expected: String, actual: String },
|
||||
|
||||
#[error("install failed: {detail}")]
|
||||
InstallFailed { detail: String },
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repo_key_from_https_url() {
|
||||
let key = InstallRegistry::repo_key("https://github.com/user/my-linter");
|
||||
assert!(key.starts_with("my-linter-"));
|
||||
assert_eq!(key.len(), "my-linter-".len() + 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_key_from_ssh_url() {
|
||||
let key = InstallRegistry::repo_key("git@github.com:user/my-plugin.git");
|
||||
assert!(key.starts_with("my-plugin-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_key_from_local_path() {
|
||||
let key = InstallRegistry::repo_key("/Users/me/projects/my-tools");
|
||||
assert!(key.starts_with("my-tools-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_key_collision_safety() {
|
||||
let key_a = InstallRegistry::repo_key("https://github.com/org-a/tools");
|
||||
let key_b = InstallRegistry::repo_key("https://github.com/org-b/tools");
|
||||
assert_ne!(
|
||||
key_a, key_b,
|
||||
"different sources should produce different keys"
|
||||
);
|
||||
assert!(key_a.starts_with("tools-"));
|
||||
assert!(key_b.starts_with("tools-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_crud() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
assert!(reg.repos.is_empty());
|
||||
assert!(reg.list().is_empty());
|
||||
|
||||
// Insert
|
||||
reg.insert(
|
||||
"test-repo-12345678".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Git {
|
||||
url: "https://github.com/user/test".to_string(),
|
||||
git_ref: Some("main".to_string()),
|
||||
commit: "abc123".to_string(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
path: tmp.path().join("test-repo-12345678"),
|
||||
plugins: HashMap::from([(
|
||||
"my-plugin".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: None,
|
||||
version: Some("1.0.0".to_string()),
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(reg.repos.len(), 1);
|
||||
assert!(reg.get_repo("test-repo-12345678").is_some());
|
||||
assert!(reg.find_plugin("my-plugin").is_some());
|
||||
assert!(reg.find_plugin("nonexistent").is_none());
|
||||
|
||||
// Save and reload
|
||||
reg.save().unwrap();
|
||||
let registry_path = tmp.path().join("registry.json");
|
||||
assert!(registry_path.exists());
|
||||
|
||||
// Remove
|
||||
let removed = reg.remove("test-repo-12345678");
|
||||
assert!(removed.is_some());
|
||||
assert!(reg.repos.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
|
||||
reg.insert(
|
||||
"my-linter-aabbccdd".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Local {
|
||||
source_path: PathBuf::from("/home/user/plugins/linter"),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: "2026-03-26T12:00:00Z".to_string(),
|
||||
updated_at: "2026-03-26T12:00:00Z".to_string(),
|
||||
path: tmp.path().join("my-linter-aabbccdd"),
|
||||
plugins: HashMap::from([
|
||||
(
|
||||
"lint-check".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("lint-check".to_string()),
|
||||
version: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"lint-fix".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("lint-fix".to_string()),
|
||||
version: Some("2.0.0".to_string()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
reg.save().unwrap();
|
||||
|
||||
// Read the JSON back and parse
|
||||
let content = std::fs::read_to_string(tmp.path().join("registry.json")).unwrap();
|
||||
let loaded: InstallRegistry = serde_json::from_str(&content).unwrap();
|
||||
|
||||
assert_eq!(loaded.version, 1);
|
||||
assert_eq!(loaded.repos.len(), 1);
|
||||
let repo = loaded.get_repo("my-linter-aabbccdd").unwrap();
|
||||
assert_eq!(repo.plugins.len(), 2);
|
||||
assert!(repo.plugins.contains_key("lint-check"));
|
||||
assert!(repo.plugins.contains_key("lint-fix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_plugin_across_repos() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
|
||||
reg.insert(
|
||||
"repo-a-11111111".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Git {
|
||||
url: "https://example.com/a".to_string(),
|
||||
git_ref: None,
|
||||
commit: "aaa".to_string(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
path: tmp.path().join("repo-a-11111111"),
|
||||
plugins: HashMap::from([(
|
||||
"alpha".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: None,
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
reg.insert(
|
||||
"repo-b-22222222".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Git {
|
||||
url: "https://example.com/b".to_string(),
|
||||
git_ref: None,
|
||||
commit: "bbb".to_string(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
path: tmp.path().join("repo-b-22222222"),
|
||||
plugins: HashMap::from([(
|
||||
"beta".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("beta".to_string()),
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
let (key, _, _) = reg.find_plugin("alpha").unwrap();
|
||||
assert_eq!(key, "repo-a-11111111");
|
||||
|
||||
let (key, _, plugin) = reg.find_plugin("beta").unwrap();
|
||||
assert_eq!(key, "repo-b-22222222");
|
||||
assert_eq!(plugin.subdir.as_deref(), Some("beta"));
|
||||
|
||||
assert!(reg.find_plugin("gamma").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_repo_key_by_plugin_root_handles_subdir_plugins() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo-a-11111111");
|
||||
let plugin_root = repo_root.join("plugins").join("nested");
|
||||
std::fs::create_dir_all(&plugin_root).unwrap();
|
||||
let canonical_plugin_root = dunce::canonicalize(&plugin_root).unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
reg.insert(
|
||||
"repo-a-11111111".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Local {
|
||||
source_path: repo_root.clone(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
path: repo_root,
|
||||
plugins: HashMap::from([(
|
||||
"nested".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("plugins/nested".to_string()),
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
reg.find_repo_key_by_plugin_root(&plugin_root, &canonical_plugin_root),
|
||||
Some("repo-a-11111111")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_kind_without_subdir_field_deserializes_to_none() {
|
||||
let json = r#"{"type":"Git","url":"https://example.com/r","commit":"abc"}"#;
|
||||
let kind: InstallKind = serde_json::from_str(json).unwrap();
|
||||
match kind {
|
||||
InstallKind::Git { url, subdir, .. } => {
|
||||
assert_eq!(url, "https://example.com/r");
|
||||
assert!(subdir.is_none());
|
||||
}
|
||||
_ => panic!("expected Git"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_kind_without_subdir_field_deserializes_to_none() {
|
||||
let json = r#"{"type":"Local","source_path":"/home/user/plugin"}"#;
|
||||
let kind: InstallKind = serde_json::from_str(json).unwrap();
|
||||
match kind {
|
||||
InstallKind::Local {
|
||||
source_path,
|
||||
subdir,
|
||||
} => {
|
||||
assert_eq!(source_path, PathBuf::from("/home/user/plugin"));
|
||||
assert!(subdir.is_none());
|
||||
}
|
||||
_ => panic!("expected Local"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_kind_with_subdir_round_trips() {
|
||||
let kind = InstallKind::Local {
|
||||
source_path: PathBuf::from("/home/user/workspace"),
|
||||
subdir: Some("plugins/foo".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
let back: InstallKind = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
InstallKind::Local { subdir, .. } => {
|
||||
assert_eq!(subdir.as_deref(), Some("plugins/foo"));
|
||||
}
|
||||
_ => panic!("expected Local"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
//! Refresh of copied local plugin installs from their live source.
|
||||
//!
|
||||
//! A local install is a full directory copy under `installed-plugins/` (not a
|
||||
//! live symlink), so agents/skills added to the live source after install do not
|
||||
//! surface until the snapshot is re-copied. This module re-copies refreshable
|
||||
//! local installs (under-home or trusted) at session spawn and `/plugins reload`.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::git_install::{
|
||||
copy_dir_recursive, discover_plugins_in_dir, remove_repo_path, repo_plugin_map,
|
||||
};
|
||||
use super::install_registry::{InstallError, InstallKind, InstallRegistry, RepoPlugin};
|
||||
use super::trust::TrustStore;
|
||||
|
||||
/// Orphaned tmp/backup siblings younger than this may belong to a concurrent live
|
||||
/// refresh, so [`sweep_stale`] only reclaims entries older than this.
|
||||
const STALE_SWEEP_AGE: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Counts from a [`refresh_local_installs`] pass, for logging and tests.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub(crate) struct RefreshSummary {
|
||||
pub refreshed: usize,
|
||||
pub skipped: usize,
|
||||
pub errors: usize,
|
||||
}
|
||||
|
||||
/// Load the install registry, [`refresh_local_installs`], and persist it if a
|
||||
/// snapshot changed.
|
||||
///
|
||||
/// Runs only at genuine session spawn (`force=false`, cheap skip-unchanged) and
|
||||
/// explicit `/plugins reload` (`force=true`, always re-copies — the guaranteed
|
||||
/// manual remedy). Refresh implies continuous re-consent for under-home / trusted
|
||||
/// sources (install-time trust re-applies every spawn). Non-fatal on failure.
|
||||
pub(crate) fn refresh_local_installs_from_disk(trust: &TrustStore, force: bool) -> RefreshSummary {
|
||||
let mut registry = InstallRegistry::load();
|
||||
let summary = refresh_local_installs(&mut registry, trust, force);
|
||||
if summary.refreshed > 0
|
||||
&& let Err(e) = registry.save()
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to save install registry after local plugin refresh");
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// A local install snapshotted out of the registry so the refresh loop can mutate
|
||||
/// the registry while iterating. `expected` is the recorded plugin set used to
|
||||
/// guard against scope-changing rediscovery.
|
||||
struct RefreshTarget {
|
||||
key: String,
|
||||
source_path: PathBuf,
|
||||
subdir: Option<String>,
|
||||
dest: PathBuf,
|
||||
expected: HashMap<String, RepoPlugin>,
|
||||
}
|
||||
|
||||
/// Re-copy refreshable local installs from their live `source_path` into the
|
||||
/// managed snapshot, rediscovering plugins so new components surface.
|
||||
///
|
||||
/// A source is refreshable when it is under the user's home (auto-trusted, same
|
||||
/// rule as config-path plugins) or in the trust store; remote git installs are
|
||||
/// handled by `update_repo`, not here. Unless `force`, snapshots already matching
|
||||
/// the live source are skipped (a stat-walk, not a byte copy).
|
||||
fn refresh_local_installs(
|
||||
registry: &mut InstallRegistry,
|
||||
trust: &TrustStore,
|
||||
force: bool,
|
||||
) -> RefreshSummary {
|
||||
let mut summary = RefreshSummary::default();
|
||||
let targets: Vec<RefreshTarget> = registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.filter_map(|(key, repo)| match &repo.kind {
|
||||
InstallKind::Local {
|
||||
source_path,
|
||||
subdir,
|
||||
} => Some(RefreshTarget {
|
||||
key: key.to_string(),
|
||||
source_path: source_path.clone(),
|
||||
subdir: subdir.clone(),
|
||||
dest: repo.path.clone(),
|
||||
expected: repo.plugins.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
for RefreshTarget {
|
||||
key,
|
||||
source_path,
|
||||
subdir,
|
||||
dest,
|
||||
expected,
|
||||
} in targets
|
||||
{
|
||||
let refreshable =
|
||||
TrustStore::is_config_path_auto_trusted(&source_path) || trust.is_trusted(&source_path);
|
||||
if !source_path.is_dir() || !refreshable {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// Skip if the snapshot already matches the source (cheap stat-walk).
|
||||
// `force` (/plugins reload) bypasses the skip.
|
||||
if !force && snapshot_matches_source(&source_path, &dest) {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match recopy_local_install(&source_path, subdir.as_deref(), &dest, &expected) {
|
||||
Ok(Some(plugins)) => {
|
||||
if let Some(repo) = registry.get_repo_mut(&key) {
|
||||
repo.plugins = plugins;
|
||||
repo.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
}
|
||||
summary.refreshed += 1;
|
||||
}
|
||||
Ok(None) => {
|
||||
// Kept the snapshot: rediscovered plugin set/scope differs from
|
||||
// recorded (e.g. legacy install without a persisted `subdir`).
|
||||
tracing::debug!(
|
||||
repo_key = %key,
|
||||
"kept stale local plugin snapshot: rediscovered plugin set/scope differs from recorded"
|
||||
);
|
||||
summary.skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo_key = %key, error = %e, "local plugin refresh failed");
|
||||
summary.errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
/// The set of `(relative_path, file_len)` for every non-symlink file under a
|
||||
/// tree. Symlinks are skipped, matching [`copy_dir_recursive`]. Comparing two of
|
||||
/// these detects add / remove / rename / size-change with no stored fingerprint
|
||||
/// and no brittle src-vs-dst mtime compare (a copy does not preserve mtimes).
|
||||
fn tree_file_set(root: &Path) -> Option<BTreeMap<PathBuf, u64>> {
|
||||
fn walk(base: &Path, dir: &Path, out: &mut BTreeMap<PathBuf, u64>) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
let meta = std::fs::symlink_metadata(&path)?;
|
||||
if meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if meta.is_file() {
|
||||
if let Ok(rel) = path.strip_prefix(base) {
|
||||
out.insert(rel.to_path_buf(), meta.len());
|
||||
}
|
||||
} else if meta.is_dir() {
|
||||
walk(base, &path, out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
walk(root, root, &mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Whether the snapshot at `dest` matches the live `source` by `(relpath, len)`
|
||||
/// set. Catches add/remove/rename/resize; misses only a same-path/same-len edit.
|
||||
fn snapshot_matches_source(source: &Path, dest: &Path) -> bool {
|
||||
match (tree_file_set(source), tree_file_set(dest)) {
|
||||
(Some(src), Some(dst)) => src == dst,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-copy `source_path` into `dest`, returning the rediscovered plugins, or
|
||||
/// `Ok(None)` to keep the existing snapshot unchanged.
|
||||
///
|
||||
/// Invariant: refresh only syncs file contents within the existing plugin set;
|
||||
/// if rediscovery is empty or changes the `(name, subdir)` set, the snapshot is
|
||||
/// kept as-is (protects legacy entries whose `subdir` wasn't persisted).
|
||||
///
|
||||
/// `subdir` scopes discovery as it did at install time; symlinks in the source
|
||||
/// are skipped (see [`copy_dir_recursive`]). The swap is rename-aside (move live
|
||||
/// snapshot to backup, promote tmp, drop backup) so `dest` is never absent during
|
||||
/// a slow delete and a failed promote rolls back to the previous snapshot.
|
||||
fn recopy_local_install(
|
||||
source_path: &Path,
|
||||
subdir: Option<&str>,
|
||||
dest: &Path,
|
||||
expected: &HashMap<String, RepoPlugin>,
|
||||
) -> Result<Option<HashMap<String, RepoPlugin>>, InstallError> {
|
||||
let parent = dest.parent().ok_or_else(|| InstallError::InstallFailed {
|
||||
detail: format!("install path has no parent: {}", dest.display()),
|
||||
})?;
|
||||
let file_name = dest
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("plugin");
|
||||
// Reclaim orphaned tmp/backup dirs left by a crash in a prior run.
|
||||
sweep_stale(parent, file_name);
|
||||
let tmp = parent.join(format!(".{file_name}.refresh-{}", std::process::id()));
|
||||
let backup = parent.join(format!(".{file_name}.backup-{}", std::process::id()));
|
||||
|
||||
let _ = remove_repo_path(&tmp);
|
||||
copy_dir_recursive(source_path, &tmp).map_err(|e| {
|
||||
let _ = remove_repo_path(&tmp);
|
||||
InstallError::Io {
|
||||
path: tmp.clone(),
|
||||
source: e,
|
||||
}
|
||||
})?;
|
||||
|
||||
let discovered = match discover_plugins_in_dir(&tmp, subdir) {
|
||||
Ok(plugins) => plugins,
|
||||
Err(e) => {
|
||||
let _ = remove_repo_path(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the snapshot unless the rediscovered (name, subdir) set is unchanged.
|
||||
let discovered_ids: BTreeSet<(&str, Option<&str>)> = discovered
|
||||
.iter()
|
||||
.map(|p| (p.name.as_str(), p.subdir.as_deref()))
|
||||
.collect();
|
||||
let expected_ids: BTreeSet<(&str, Option<&str>)> = expected
|
||||
.iter()
|
||||
.map(|(name, rp)| (name.as_str(), rp.subdir.as_deref()))
|
||||
.collect();
|
||||
if discovered.is_empty() || discovered_ids != expected_ids {
|
||||
let _ = remove_repo_path(&tmp);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let _ = remove_repo_path(&backup);
|
||||
if dest.exists()
|
||||
&& let Err(e) = std::fs::rename(dest, &backup)
|
||||
{
|
||||
let _ = remove_repo_path(&tmp);
|
||||
return Err(InstallError::Io {
|
||||
path: dest.to_path_buf(),
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
if let Err(e) = promote_tmp_to_dest(&tmp, dest) {
|
||||
let _ = remove_repo_path(&tmp);
|
||||
// Promote failed: restore the prior tree so `dest` is never left missing
|
||||
// (rename, then copy fallback), unless a peer already repopulated `dest`.
|
||||
if dest.exists() {
|
||||
let _ = remove_repo_path(&backup);
|
||||
} else if std::fs::rename(&backup, dest).is_err() {
|
||||
match copy_dir_recursive(&backup, dest) {
|
||||
Ok(()) => {
|
||||
let _ = remove_repo_path(&backup);
|
||||
}
|
||||
Err(restore) => tracing::error!(
|
||||
dest = %dest.display(),
|
||||
backup = %backup.display(),
|
||||
error = %restore,
|
||||
"failed to restore snapshot after refresh promote failure; prior tree kept at backup"
|
||||
),
|
||||
}
|
||||
}
|
||||
return Err(InstallError::Io {
|
||||
path: dest.to_path_buf(),
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
let _ = remove_repo_path(&backup);
|
||||
|
||||
Ok(Some(repo_plugin_map(&discovered)))
|
||||
}
|
||||
|
||||
/// Promote the freshly-copied `tmp` tree onto `dest`. A test hook can force this
|
||||
/// to fail to exercise the rename-aside rollback path.
|
||||
fn promote_tmp_to_dest(tmp: &Path, dest: &Path) -> std::io::Result<()> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if std::env::var_os("KIGI_TEST_FAIL_REFRESH_PROMOTE").is_some() {
|
||||
return Err(std::io::Error::other(
|
||||
"test-injected refresh promote failure",
|
||||
));
|
||||
}
|
||||
}
|
||||
std::fs::rename(tmp, dest)
|
||||
}
|
||||
|
||||
/// Best-effort removal of orphaned `.<name>.refresh-*` / `.<name>.backup-*`
|
||||
/// siblings left by a crash between copy and promote. Only entries older than
|
||||
/// [`STALE_SWEEP_AGE`] are reaped, so a concurrent live refresh's in-flight
|
||||
/// working dir (pid-named, freshly created) is never deleted out from under it.
|
||||
fn sweep_stale(parent: &Path, file_name: &str) {
|
||||
let refresh_prefix = format!(".{file_name}.refresh-");
|
||||
let backup_prefix = format!(".{file_name}.backup-");
|
||||
let Ok(entries) = std::fs::read_dir(parent) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
||||
continue;
|
||||
};
|
||||
if !name.starts_with(&refresh_prefix) && !name.starts_with(&backup_prefix) {
|
||||
continue;
|
||||
}
|
||||
let stale = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|m| m.elapsed().ok())
|
||||
.is_some_and(|age| age >= STALE_SWEEP_AGE);
|
||||
if stale {
|
||||
let _ = remove_repo_path(&entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::git_install::{InstallResult, InstallSource, install_from_source};
|
||||
use super::super::install_registry::InstalledRepo;
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
/// RAII guard: sets an env var, restores the prior value (or unsets) on drop,
|
||||
/// so a test never leaves process-global env pointing at a dropped tempdir.
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
prev: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
|
||||
let prev = std::env::var_os(key);
|
||||
unsafe { std::env::set_var(key, value) };
|
||||
Self { key, prev }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.prev.take() {
|
||||
Some(v) => unsafe { std::env::set_var(self.key, v) },
|
||||
None => unsafe { std::env::remove_var(self.key) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical home: under-home auto-trust canonicalizes the candidate but not
|
||||
// `$HOME` (macOS `/var` -> `/private/var`). The guard restores `$HOME` on drop.
|
||||
fn home_tempdir() -> (tempfile::TempDir, PathBuf, EnvVarGuard) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let guard = EnvVarGuard::set("HOME", &home);
|
||||
(tmp, home, guard)
|
||||
}
|
||||
|
||||
fn write_plugin_json(dir: &Path, name: &str) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("plugin.json"), format!(r#"{{"name":"{name}"}}"#)).unwrap();
|
||||
}
|
||||
|
||||
fn write_agent_md(plugin_dir: &Path, name: &str) {
|
||||
std::fs::create_dir_all(plugin_dir.join("agents")).unwrap();
|
||||
std::fs::write(
|
||||
plugin_dir.join("agents").join(format!("{name}.md")),
|
||||
format!("---\nname: {name}\ndescription: d\n---\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Install `source` (optionally scoped to `subdir`) and record it in
|
||||
// `registry`, mirroring what the install command persists.
|
||||
fn register_local_install(
|
||||
registry: &mut InstallRegistry,
|
||||
source: &Path,
|
||||
subdir: Option<&str>,
|
||||
) -> InstallResult {
|
||||
let installed = install_from_source(
|
||||
&InstallSource::Local {
|
||||
path: source.to_path_buf(),
|
||||
subdir: subdir.map(str::to_string),
|
||||
},
|
||||
registry,
|
||||
)
|
||||
.unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
registry.insert(
|
||||
installed.repo_key.clone(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Local {
|
||||
source_path: source.to_path_buf(),
|
||||
subdir: subdir.map(str::to_string),
|
||||
},
|
||||
installed_at: now.clone(),
|
||||
updated_at: now,
|
||||
path: installed.repo_path.clone(),
|
||||
plugins: repo_plugin_map(&installed.plugins),
|
||||
},
|
||||
);
|
||||
installed
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_local_install_picks_up_new_agent() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let source = home.join(".claude").join("demo-plugin");
|
||||
write_plugin_json(&source, "demo-plugin");
|
||||
write_agent_md(&source, "old");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join(".kigi").join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
write_agent_md(&source, "new");
|
||||
assert!(!installed.repo_path.join("agents/new.md").exists());
|
||||
|
||||
let trust = TrustStore::load_from(home.join(".kigi").join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.refreshed, 1, "{summary:?}");
|
||||
assert!(installed.repo_path.join("agents/new.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_skips_unchanged_source() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let source = home.join(".claude").join("demo-plugin");
|
||||
write_plugin_json(&source, "demo-plugin");
|
||||
write_agent_md(&source, "old");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join(".kigi").join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
// No edit to the source: snapshot matches, so refresh is a stat-walk skip
|
||||
// with no re-copy.
|
||||
let snapshot = installed.repo_path.join("agents/old.md");
|
||||
let before = std::fs::metadata(&snapshot).unwrap().modified().unwrap();
|
||||
let trust = TrustStore::load_from(home.join(".kigi").join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.refreshed, 0, "{summary:?}");
|
||||
assert_eq!(summary.skipped, 1, "{summary:?}");
|
||||
let after = std::fs::metadata(&snapshot).unwrap().modified().unwrap();
|
||||
assert_eq!(before, after, "unchanged snapshot must not be re-copied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_picks_up_content_preserving_rename() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let source = home.join(".claude").join("demo-plugin");
|
||||
write_plugin_json(&source, "demo-plugin");
|
||||
write_agent_md(&source, "old");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join(".kigi").join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
// Rename keeps file count, total size, and the file's (old) mtime — the
|
||||
// old aggregate fingerprint skipped this; the structural file-set catches it.
|
||||
std::fs::rename(
|
||||
source.join("agents/old.md"),
|
||||
source.join("agents/renamed.md"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let trust = TrustStore::load_from(home.join(".kigi").join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(
|
||||
summary.refreshed, 1,
|
||||
"rename must trigger refresh: {summary:?}"
|
||||
);
|
||||
assert!(installed.repo_path.join("agents/renamed.md").exists());
|
||||
assert!(!installed.repo_path.join("agents/old.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_promote_failure_rolls_back_to_prior_snapshot() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let source = home.join(".claude").join("demo-plugin");
|
||||
write_plugin_json(&source, "demo-plugin");
|
||||
write_agent_md(&source, "old");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join(".kigi").join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
// Change the source so a refresh attempts a re-copy, then force the
|
||||
// promote rename to fail and assert the prior snapshot is restored.
|
||||
write_agent_md(&source, "new");
|
||||
let trust = TrustStore::load_from(home.join(".kigi").join("trusted-plugins"));
|
||||
let summary = {
|
||||
let _fail = EnvVarGuard::set("KIGI_TEST_FAIL_REFRESH_PROMOTE", "1");
|
||||
refresh_local_installs(&mut registry, &trust, false)
|
||||
};
|
||||
|
||||
assert_eq!(summary.errors, 1, "{summary:?}");
|
||||
assert_eq!(summary.refreshed, 0, "{summary:?}");
|
||||
// dest is never left missing and still holds the prior snapshot.
|
||||
assert!(installed.repo_path.join("agents/old.md").exists());
|
||||
assert!(!installed.repo_path.join("agents/new.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_skips_untrusted_source_outside_home() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let source = outside.path().join("untrusted-plugin");
|
||||
write_plugin_json(&source, "untrusted-plugin");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
std::fs::write(source.join("extra.txt"), "x").unwrap();
|
||||
let trust = TrustStore::load_from(home.join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.skipped, 1, "{summary:?}");
|
||||
assert_eq!(summary.refreshed, 0, "{summary:?}");
|
||||
assert!(!installed.repo_path.join("extra.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_trusted_source_outside_home() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let source = outside.path().join("trusted-plugin");
|
||||
write_plugin_json(&source, "trusted-plugin");
|
||||
write_agent_md(&source, "old");
|
||||
|
||||
let mut trust = TrustStore::load_from(home.join("trusted-plugins"));
|
||||
trust.grant_trust(&source).unwrap();
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
write_agent_md(&source, "new");
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.refreshed, 1, "{summary:?}");
|
||||
assert!(installed.repo_path.join("agents/new.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_preserves_install_subdir_scope() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let workspace = home.join("workspace");
|
||||
write_plugin_json(&workspace.join("plugins/a"), "plugin-a");
|
||||
write_plugin_json(&workspace.join("plugins/b"), "plugin-b");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join(".kigi/installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &workspace, Some("plugins/a"));
|
||||
|
||||
write_agent_md(&workspace.join("plugins/a"), "x");
|
||||
|
||||
let trust = TrustStore::load_from(home.join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.refreshed, 1, "{summary:?}");
|
||||
assert!(installed.repo_path.join("plugins/a/agents/x.md").exists());
|
||||
|
||||
let repo = registry.get_repo(&installed.repo_key).unwrap();
|
||||
match &repo.kind {
|
||||
InstallKind::Local { subdir, .. } => assert_eq!(subdir.as_deref(), Some("plugins/a")),
|
||||
_ => panic!("expected Local"),
|
||||
}
|
||||
assert!(repo.plugins.contains_key("plugin-a"));
|
||||
assert!(!repo.plugins.contains_key("plugin-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_does_not_follow_directory_symlinks() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let secret = home.join("secret-dir");
|
||||
std::fs::create_dir_all(&secret).unwrap();
|
||||
std::fs::write(secret.join("secret.txt"), "leak").unwrap();
|
||||
|
||||
let source = home.join("plugin");
|
||||
write_plugin_json(&source, "plugin");
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&secret, source.join("link-out")).unwrap();
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
assert!(!installed.repo_path.join("link-out/secret.txt").exists());
|
||||
|
||||
std::fs::write(source.join("extra.txt"), "x").unwrap();
|
||||
let trust = TrustStore::load_from(home.join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.refreshed, 1, "{summary:?}");
|
||||
assert!(!installed.repo_path.join("link-out/secret.txt").exists());
|
||||
assert!(installed.repo_path.join("extra.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_keeps_stale_when_legacy_subdir_scope_lost() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
// Legacy multi-package source: the real plugin is at plugins/foo;
|
||||
// other-dir is unrelated root-level content that root-scope discovery
|
||||
// would pick up.
|
||||
let workspace = home.join("workspace");
|
||||
write_plugin_json(&workspace.join("plugins/foo"), "foo");
|
||||
write_agent_md(&workspace.join("other-dir"), "noise");
|
||||
|
||||
// Snapshot the full source (mirrors the install-time copy).
|
||||
let install_dir = home.join(".kigi").join("installed-plugins");
|
||||
std::fs::create_dir_all(&install_dir).unwrap();
|
||||
let dest = install_dir.join("foo-legacy");
|
||||
copy_dir_recursive(&workspace, &dest).unwrap();
|
||||
|
||||
// Legacy entry: install-level `subdir` was never persisted (None), but the
|
||||
// per-plugin RepoPlugin recorded the correct scope.
|
||||
let mut registry = InstallRegistry::empty(install_dir);
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
registry.insert(
|
||||
"foo-legacy".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Local {
|
||||
source_path: workspace.clone(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: now.clone(),
|
||||
updated_at: now,
|
||||
path: dest.clone(),
|
||||
plugins: HashMap::from([(
|
||||
"foo".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("plugins/foo".to_string()),
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
// Edit the source under plugins/foo so a content refresh would trigger.
|
||||
write_agent_md(&workspace.join("plugins/foo"), "added");
|
||||
|
||||
// force=true so the unchanged-skip can't mask the scope-identity guard.
|
||||
let trust = TrustStore::load_from(home.join(".kigi").join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, true);
|
||||
|
||||
// Root-scope rediscovery would change the plugin set/scope, so keep stale:
|
||||
// no refresh, and repo.plugins / repo.kind must be untouched (no corruption).
|
||||
assert_eq!(
|
||||
summary.refreshed, 0,
|
||||
"scope change must keep stale: {summary:?}"
|
||||
);
|
||||
let repo = registry.get_repo("foo-legacy").unwrap();
|
||||
assert_eq!(repo.plugins.len(), 1);
|
||||
assert_eq!(
|
||||
repo.plugins.get("foo").and_then(|p| p.subdir.as_deref()),
|
||||
Some("plugins/foo")
|
||||
);
|
||||
match &repo.kind {
|
||||
InstallKind::Local {
|
||||
source_path,
|
||||
subdir,
|
||||
} => {
|
||||
assert_eq!(source_path, &workspace);
|
||||
assert!(subdir.is_none());
|
||||
}
|
||||
_ => panic!("expected Local"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
//! Plugin manifest parsing and validation.
|
||||
//!
|
||||
//! The canonical manifest location is `plugin.json` at the plugin root.
|
||||
//! Fallback locations (checked in order when the root manifest is absent):
|
||||
//! 1. `.kigi-plugin/plugin.json`
|
||||
//! 2. `.claude-plugin/plugin.json`
|
||||
//!
|
||||
//! If no manifest is found at all, the plugin can still function via
|
||||
//! convention-based discovery (skills/, agents/, .mcp.json, hooks/hooks.json),
|
||||
//! with the plugin name derived from the directory name.
|
||||
//!
|
||||
//! The parser is forward-compatible: unknown fields are silently ignored
|
||||
//! so that manifests authored for newer upstream versions still load.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Maximum length of a plugin name (kebab-case identifier).
|
||||
const MAX_PLUGIN_NAME_LEN: usize = 64;
|
||||
|
||||
/// Regex pattern for valid plugin names: lowercase alphanumeric + hyphens.
|
||||
fn is_valid_plugin_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_PLUGIN_NAME_LEN
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
&& !name.starts_with('-')
|
||||
&& !name.ends_with('-')
|
||||
}
|
||||
|
||||
/// Author metadata from a plugin manifest.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Author {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
/// A path reference that can be either a single path or multiple paths.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PathOrPaths {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl PathOrPaths {
|
||||
/// Resolve all contained paths relative to a plugin root.
|
||||
///
|
||||
/// Paths that escape the plugin root (via `..` components) are rejected
|
||||
/// with a warning and excluded from the result.
|
||||
pub fn resolve(&self, plugin_root: &Path) -> Vec<PathBuf> {
|
||||
let paths = match self {
|
||||
PathOrPaths::Single(p) => vec![plugin_root.join(p)],
|
||||
PathOrPaths::Multiple(ps) => ps.iter().map(|p| plugin_root.join(p)).collect(),
|
||||
};
|
||||
paths
|
||||
.into_iter()
|
||||
.filter(|resolved| {
|
||||
if is_path_contained(resolved, plugin_root) {
|
||||
true
|
||||
} else {
|
||||
tracing::warn!(
|
||||
path = %resolved.display(),
|
||||
plugin_root = %plugin_root.display(),
|
||||
"manifest path escapes plugin root; skipping"
|
||||
);
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a resolved path stays within the plugin root.
|
||||
///
|
||||
/// Canonicalizes both sides (resolving symlinks and `..`) before the prefix check.
|
||||
fn is_path_contained(resolved: &Path, plugin_root: &Path) -> bool {
|
||||
let canonical_root =
|
||||
dunce::canonicalize(plugin_root).unwrap_or_else(|_| plugin_root.to_path_buf());
|
||||
let canonical_resolved =
|
||||
dunce::canonicalize(resolved).unwrap_or_else(|_| resolved.to_path_buf());
|
||||
// Fail-closed >MAX_PATH caveat: see workspace clippy.toml.
|
||||
canonical_resolved.starts_with(&canonical_root)
|
||||
}
|
||||
|
||||
/// Resolve a plugin component path (hooks, MCP, LSP) from a manifest field.
|
||||
///
|
||||
/// If the field is `Path(p)`, resolves relative to plugin root with containment check.
|
||||
/// If `Inline(_)`, returns `None` (caller reads inline value directly).
|
||||
/// If `None`, checks for `default_file` at the plugin root.
|
||||
fn resolve_component_path(
|
||||
field: &Option<PathOrInline>,
|
||||
plugin_root: &Path,
|
||||
default_file: &str,
|
||||
label: &str,
|
||||
) -> Option<PathBuf> {
|
||||
match field {
|
||||
Some(PathOrInline::Path(p)) => {
|
||||
let resolved = plugin_root.join(p);
|
||||
if !is_path_contained(&resolved, plugin_root) {
|
||||
tracing::warn!(
|
||||
path = %resolved.display(),
|
||||
plugin_root = %plugin_root.display(),
|
||||
"{label} path escapes plugin root; skipping"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
resolved.is_file().then_some(resolved)
|
||||
}
|
||||
Some(PathOrInline::Inline(_)) => None,
|
||||
None => {
|
||||
let default = plugin_root.join(default_file);
|
||||
default.is_file().then_some(default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value that can be either a file path (string) or an inline JSON object.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PathOrInline {
|
||||
Path(String),
|
||||
Inline(serde_json::Value),
|
||||
}
|
||||
|
||||
/// Parsed plugin manifest from `plugin.json`.
|
||||
///
|
||||
/// Forward-compatible: unknown fields are silently ignored via
|
||||
/// `#[serde(deny_unknown_fields)]` NOT being set.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginManifest {
|
||||
/// User-facing plugin namespace (kebab-case). Required.
|
||||
pub name: String,
|
||||
/// Semver version string.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author: Option<Author>,
|
||||
#[serde(default)]
|
||||
pub homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
pub repository: Option<String>,
|
||||
#[serde(default)]
|
||||
pub license: Option<String>,
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
|
||||
// ── Component path overrides (supplement convention dirs) ──────
|
||||
#[serde(default)]
|
||||
pub skills: Option<PathOrPaths>,
|
||||
#[serde(default)]
|
||||
pub commands: Option<PathOrPaths>,
|
||||
#[serde(default)]
|
||||
pub agents: Option<PathOrPaths>,
|
||||
#[serde(default)]
|
||||
pub hooks: Option<PathOrInline>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Option<PathOrInline>,
|
||||
#[serde(default)]
|
||||
pub lsp_servers: Option<PathOrInline>,
|
||||
}
|
||||
|
||||
impl PluginManifest {
|
||||
/// Validate the parsed manifest.
|
||||
pub fn validate(&self) -> Result<(), ManifestError> {
|
||||
if !is_valid_plugin_name(&self.name) {
|
||||
return Err(ManifestError::InvalidName {
|
||||
name: self.name.clone(),
|
||||
reason: format!(
|
||||
"must be 1-{MAX_PLUGIN_NAME_LEN} chars, lowercase alphanumeric + hyphens, \
|
||||
no leading/trailing hyphens"
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn skill_dirs(&self, plugin_root: &Path) -> Vec<PathBuf> {
|
||||
resolve_dirs(&self.skills, plugin_root, "skills")
|
||||
}
|
||||
|
||||
pub fn command_dirs(&self, plugin_root: &Path) -> Vec<PathBuf> {
|
||||
resolve_dirs(&self.commands, plugin_root, "commands")
|
||||
}
|
||||
|
||||
pub fn agent_dirs(&self, plugin_root: &Path) -> Vec<PathBuf> {
|
||||
resolve_dirs(&self.agents, plugin_root, "agents")
|
||||
}
|
||||
|
||||
/// Resolve the hooks path from the manifest.
|
||||
/// Returns the manifest-specified path or the default `hooks/hooks.json`.
|
||||
pub fn hooks_path(&self, plugin_root: &Path) -> Option<PathBuf> {
|
||||
resolve_component_path(&self.hooks, plugin_root, "hooks/hooks.json", "hooks")
|
||||
}
|
||||
|
||||
pub fn mcp_config_path(&self, plugin_root: &Path) -> Option<PathBuf> {
|
||||
if matches!(self.mcp_servers, Some(PathOrInline::Inline(_))) {
|
||||
let default = plugin_root.join(".mcp.json");
|
||||
return default.is_file().then_some(default);
|
||||
}
|
||||
resolve_component_path(&self.mcp_servers, plugin_root, ".mcp.json", "MCP config")
|
||||
}
|
||||
|
||||
/// Get inline hooks JSON value, if the manifest uses inline hooks.
|
||||
///
|
||||
/// Inline hooks are fully supported — the runtime parses and executes them
|
||||
/// via `parse_plugin_hooks_from_value()`. This accessor is used during
|
||||
/// `LoadedPlugin` construction and by the hooks adapter.
|
||||
pub fn inline_hooks(&self) -> Option<&serde_json::Value> {
|
||||
match &self.hooks {
|
||||
Some(PathOrInline::Inline(v)) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get inline MCP servers JSON value, if the manifest uses inline MCP.
|
||||
///
|
||||
/// Inline MCP servers are fully supported — the runtime parses and starts
|
||||
/// them via `load_plugin_mcp_servers_from_value()`. This accessor is used
|
||||
/// during `LoadedPlugin` construction and by the MCP merger.
|
||||
pub fn inline_mcp_servers(&self) -> Option<&serde_json::Value> {
|
||||
match &self.mcp_servers {
|
||||
Some(PathOrInline::Inline(v)) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lsp_config_path(&self, plugin_root: &Path) -> Option<PathBuf> {
|
||||
resolve_component_path(&self.lsp_servers, plugin_root, ".lsp.json", "LSP config")
|
||||
}
|
||||
|
||||
pub fn inline_lsp_servers(&self) -> Option<&serde_json::Value> {
|
||||
match &self.lsp_servers {
|
||||
Some(PathOrInline::Inline(v)) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Log informational messages about manifest features.
|
||||
///
|
||||
/// Called during discovery. Inline hooks and MCP servers are now
|
||||
/// fully supported; this method logs when they are detected.
|
||||
pub fn warn_unsupported_features(&self, plugin_name: &str) {
|
||||
if self.inline_hooks().is_some() {
|
||||
tracing::info!(plugin = plugin_name, "plugin uses inline hooks in manifest");
|
||||
}
|
||||
if self.inline_mcp_servers().is_some() {
|
||||
tracing::info!(
|
||||
plugin = plugin_name,
|
||||
"plugin uses inline mcpServers in manifest"
|
||||
);
|
||||
}
|
||||
if self.inline_lsp_servers().is_some() {
|
||||
tracing::info!(
|
||||
plugin = plugin_name,
|
||||
"plugin uses inline lspServers in manifest"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve directories from a manifest field or fall back to a default subdirectory.
|
||||
fn resolve_dirs(
|
||||
field: &Option<PathOrPaths>,
|
||||
plugin_root: &Path,
|
||||
default_name: &str,
|
||||
) -> Vec<PathBuf> {
|
||||
match field {
|
||||
Some(paths) => paths.resolve(plugin_root),
|
||||
None => {
|
||||
let default = plugin_root.join(default_name);
|
||||
if default.is_dir() {
|
||||
vec![default]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manifest loading ──────────────────────────────────────────────────
|
||||
|
||||
/// Manifest search order within a plugin directory.
|
||||
const MANIFEST_PATHS: &[&str] = &[
|
||||
"plugin.json",
|
||||
".kigi-plugin/plugin.json",
|
||||
".claude-plugin/plugin.json",
|
||||
];
|
||||
|
||||
/// Result of attempting to load a manifest from a plugin directory.
|
||||
#[derive(Debug)]
|
||||
pub enum ManifestLoadResult {
|
||||
/// Manifest found and parsed successfully.
|
||||
Found(Box<PluginManifest>),
|
||||
/// No manifest file found — plugin uses convention-based discovery.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Load a plugin manifest from the given plugin root directory.
|
||||
///
|
||||
/// Tries manifest files in priority order (see [`MANIFEST_PATHS`]).
|
||||
/// If no manifest is found, returns `ManifestLoadResult::NotFound`.
|
||||
/// The caller can still create a convention-based plugin from the directory.
|
||||
pub fn load_manifest(plugin_root: &Path) -> Result<ManifestLoadResult, ManifestError> {
|
||||
for rel_path in MANIFEST_PATHS {
|
||||
let manifest_path = plugin_root.join(rel_path);
|
||||
if manifest_path.is_file() {
|
||||
let content =
|
||||
std::fs::read_to_string(&manifest_path).map_err(|e| ManifestError::IoError {
|
||||
path: manifest_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_str(&content).map_err(|e| ManifestError::ParseError {
|
||||
path: manifest_path.clone(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
manifest.validate()?;
|
||||
manifest.warn_unsupported_features(&manifest.name);
|
||||
return Ok(ManifestLoadResult::Found(Box::new(manifest)));
|
||||
}
|
||||
}
|
||||
Ok(ManifestLoadResult::NotFound)
|
||||
}
|
||||
|
||||
/// Derive a plugin name from a directory name.
|
||||
///
|
||||
/// Sanitizes the directory name to match the kebab-case constraint:
|
||||
/// lowercase, alphanumeric + hyphens, no leading/trailing hyphens.
|
||||
pub fn name_from_dirname(dir: &Path) -> Option<String> {
|
||||
let dirname = dir.file_name()?.to_str()?;
|
||||
let sanitized: String = dirname
|
||||
.to_ascii_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = sanitized.trim_matches('-').to_string();
|
||||
if trimmed.is_empty() || trimmed.len() > MAX_PLUGIN_NAME_LEN {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed)
|
||||
}
|
||||
|
||||
/// Perform plugin-token substitution in a string.
|
||||
///
|
||||
/// Replaces `${KIGI_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`,
|
||||
/// `${KIGI_PLUGIN_DATA}`, and `${CLAUDE_PLUGIN_DATA}` with the provided values.
|
||||
///
|
||||
/// Delegates to [`kigi_tools::util::substitute_plugin_tokens`], the single
|
||||
/// source of truth shared with plugin skill/command body substitution.
|
||||
pub fn substitute_env_vars(s: &str, plugin_root: &str, plugin_data: &str) -> String {
|
||||
kigi_tools::util::substitute_plugin_tokens(s, Some(plugin_root), Some(plugin_data))
|
||||
}
|
||||
|
||||
pub fn normalize_inline_mcp_servers(value: &serde_json::Value) -> serde_json::Value {
|
||||
let inner = match value.get("mcpServers") {
|
||||
Some(servers) if servers.is_object() => servers.clone(),
|
||||
_ => value.clone(),
|
||||
};
|
||||
serde_json::json!({ "mcpServers": inner })
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("invalid plugin name {name:?}: {reason}")]
|
||||
InvalidName { name: String, reason: String },
|
||||
|
||||
#[error("failed to read {path}: {source}")]
|
||||
IoError {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("failed to parse {path}: {message}")]
|
||||
ParseError { path: PathBuf, message: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_plugin_names() {
|
||||
assert!(is_valid_plugin_name("my-plugin"));
|
||||
assert!(is_valid_plugin_name("a"));
|
||||
assert!(is_valid_plugin_name("deployment-tools"));
|
||||
assert!(is_valid_plugin_name("plugin123"));
|
||||
assert!(is_valid_plugin_name("a-b-c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_plugin_names() {
|
||||
assert!(!is_valid_plugin_name(""));
|
||||
assert!(!is_valid_plugin_name("-start"));
|
||||
assert!(!is_valid_plugin_name("end-"));
|
||||
assert!(!is_valid_plugin_name("UPPER"));
|
||||
assert!(!is_valid_plugin_name("has space"));
|
||||
assert!(!is_valid_plugin_name("has_underscore"));
|
||||
assert!(!is_valid_plugin_name("has.dot"));
|
||||
assert!(!is_valid_plugin_name(&"a".repeat(65)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_manifest() {
|
||||
let json = r#"{"name": "my-plugin"}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(manifest.name, "my-plugin");
|
||||
assert!(manifest.version.is_none());
|
||||
assert!(manifest.description.is_none());
|
||||
assert!(manifest.skills.is_none());
|
||||
manifest.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_manifest() {
|
||||
let json = r#"{
|
||||
"name": "deployment-tools",
|
||||
"version": "1.2.0",
|
||||
"description": "Tools for deployment",
|
||||
"author": {"name": "Test", "email": "test@example.com"},
|
||||
"homepage": "https://example.com",
|
||||
"repository": "https://github.com/example/plugin",
|
||||
"license": "MIT",
|
||||
"keywords": ["ci-cd", "deploy"],
|
||||
"skills": "./custom/skills/",
|
||||
"agents": "./custom-agents/",
|
||||
"hooks": "./config/hooks.json",
|
||||
"mcpServers": "./mcp-config.json"
|
||||
}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(manifest.name, "deployment-tools");
|
||||
assert_eq!(manifest.version.as_deref(), Some("1.2.0"));
|
||||
assert_eq!(manifest.keywords, vec!["ci-cd", "deploy"]);
|
||||
assert!(matches!(manifest.skills, Some(PathOrPaths::Single(_))));
|
||||
manifest.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
"name": "my-plugin",
|
||||
"marketplace": true,
|
||||
"installState": "active",
|
||||
"futureField": {"nested": "value"},
|
||||
"outputStyles": "./styles/"
|
||||
}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(manifest.name, "my-plugin");
|
||||
manifest.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_inline_hooks() {
|
||||
let json = r#"{
|
||||
"name": "my-plugin",
|
||||
"hooks": {
|
||||
"hooks": {
|
||||
"PostToolUse": [{"hooks": [{"type": "command", "command": "lint"}]}]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert!(manifest.inline_hooks().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_inline_mcp() {
|
||||
let json = r#"{
|
||||
"name": "my-plugin",
|
||||
"mcpServers": {
|
||||
"mcpServers": {
|
||||
"database": {
|
||||
"command": "./servers/db-server",
|
||||
"args": ["--config", "./config.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert!(manifest.inline_mcp_servers().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_multiple_skill_paths() {
|
||||
let json = r#"{
|
||||
"name": "my-plugin",
|
||||
"skills": ["./skills-a/", "./skills-b/"]
|
||||
}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
match manifest.skills.unwrap() {
|
||||
PathOrPaths::Multiple(paths) => {
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_eq!(paths[0], "./skills-a/");
|
||||
assert_eq!(paths[1], "./skills-b/");
|
||||
}
|
||||
_ => panic!("expected Multiple"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_from_dirname_basic() {
|
||||
assert_eq!(
|
||||
name_from_dirname(Path::new("/home/user/my-plugin")),
|
||||
Some("my-plugin".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
name_from_dirname(Path::new("/path/to/MyPlugin")),
|
||||
Some("myplugin".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
name_from_dirname(Path::new("/path/to/my_plugin")),
|
||||
Some("my-plugin".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
name_from_dirname(Path::new("/path/to/---")),
|
||||
None // all hyphens after trim
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_manifest_from_tempdir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugin_root = tmp.path().join("my-plugin");
|
||||
std::fs::create_dir_all(&plugin_root).unwrap();
|
||||
|
||||
// No manifest file
|
||||
match load_manifest(&plugin_root).unwrap() {
|
||||
ManifestLoadResult::NotFound => {}
|
||||
_ => panic!("expected NotFound"),
|
||||
}
|
||||
|
||||
// Write root plugin.json
|
||||
let manifest_path = plugin_root.join("plugin.json");
|
||||
std::fs::write(
|
||||
&manifest_path,
|
||||
r#"{"name": "my-plugin", "version": "0.1.0"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match load_manifest(&plugin_root).unwrap() {
|
||||
ManifestLoadResult::Found(m) => {
|
||||
assert_eq!(m.name, "my-plugin");
|
||||
assert_eq!(m.version.as_deref(), Some("0.1.0"));
|
||||
}
|
||||
_ => panic!("expected Found"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_manifest_fallback_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugin_root = tmp.path().join("fallback-plugin");
|
||||
std::fs::create_dir_all(plugin_root.join(".kigi-plugin")).unwrap();
|
||||
|
||||
// Write manifest in .kigi-plugin/ fallback location
|
||||
std::fs::write(
|
||||
plugin_root.join(".kigi-plugin/plugin.json"),
|
||||
r#"{"name": "fallback-plugin"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match load_manifest(&plugin_root).unwrap() {
|
||||
ManifestLoadResult::Found(m) => assert_eq!(m.name, "fallback-plugin"),
|
||||
_ => panic!("expected Found"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_manifest_root_wins_over_fallback() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugin_root = tmp.path().join("priority-test");
|
||||
std::fs::create_dir_all(plugin_root.join(".kigi-plugin")).unwrap();
|
||||
|
||||
// Write both root and fallback
|
||||
std::fs::write(plugin_root.join("plugin.json"), r#"{"name": "root-wins"}"#).unwrap();
|
||||
std::fs::write(
|
||||
plugin_root.join(".kigi-plugin/plugin.json"),
|
||||
r#"{"name": "fallback-loses"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match load_manifest(&plugin_root).unwrap() {
|
||||
ManifestLoadResult::Found(m) => assert_eq!(m.name, "root-wins"),
|
||||
_ => panic!("expected Found"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_invalid_name() {
|
||||
let json = r#"{"name": "INVALID_NAME"}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert!(manifest.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitute_env_vars_replaces_all() {
|
||||
let input = "${KIGI_PLUGIN_ROOT}/bin:${CLAUDE_PLUGIN_ROOT}/lib:${KIGI_PLUGIN_DATA}/cache";
|
||||
let result = substitute_env_vars(input, "/home/user/plugin", "/home/user/.data/plugin");
|
||||
assert_eq!(
|
||||
result,
|
||||
"/home/user/plugin/bin:/home/user/plugin/lib:/home/user/.data/plugin/cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_dirs_default_convention() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("test-plugin");
|
||||
std::fs::create_dir_all(root.join("skills")).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "test-plugin".into(),
|
||||
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,
|
||||
};
|
||||
let dirs = manifest.skill_dirs(&root);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert!(dirs[0].ends_with("skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_dirs_no_default_when_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("no-skills");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "no-skills".into(),
|
||||
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,
|
||||
};
|
||||
let dirs = manifest.skill_dirs(&root);
|
||||
assert!(dirs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_escape_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("contained");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// Create an outside directory
|
||||
let outside = tmp.path().join("outside-skills");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "escape-test".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: Some(PathOrPaths::Single("../outside-skills".to_string())),
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
lsp_servers: None,
|
||||
};
|
||||
let dirs = manifest.skill_dirs(&root);
|
||||
assert!(
|
||||
dirs.is_empty(),
|
||||
"path escaping plugin root should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_within_root_accepted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("plugin");
|
||||
std::fs::create_dir_all(root.join("custom-skills")).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "within-test".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: Some(PathOrPaths::Single("custom-skills".to_string())),
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
lsp_servers: None,
|
||||
};
|
||||
let dirs = manifest.skill_dirs(&root);
|
||||
assert_eq!(dirs.len(), 1, "path within plugin root should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_path_escape_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("plugin");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// Create a hooks file outside the plugin root
|
||||
let outside = tmp.path().join("outside-hooks.json");
|
||||
std::fs::write(&outside, r#"{"hooks":{}}"#).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "escape-hooks".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: None,
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: Some(PathOrInline::Path("../outside-hooks.json".to_string())),
|
||||
mcp_servers: None,
|
||||
lsp_servers: None,
|
||||
};
|
||||
assert!(
|
||||
manifest.hooks_path(&root).is_none(),
|
||||
"hooks path escaping plugin root should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_path_escape_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("plugin");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let outside = tmp.path().join("outside-mcp.json");
|
||||
std::fs::write(&outside, r#"{"mcpServers":{}}"#).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "escape-mcp".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: None,
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: None,
|
||||
mcp_servers: Some(PathOrInline::Path("../outside-mcp.json".to_string())),
|
||||
lsp_servers: None,
|
||||
};
|
||||
assert!(
|
||||
manifest.mcp_config_path(&root).is_none(),
|
||||
"MCP path escaping plugin root should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
fn manifest_with_inline_mcp(servers: serde_json::Value) -> PluginManifest {
|
||||
PluginManifest {
|
||||
name: "sentry".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: None,
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: None,
|
||||
mcp_servers: Some(PathOrInline::Inline(servers)),
|
||||
lsp_servers: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_inline_mcp_servers_wraps_direct_map() {
|
||||
let direct = serde_json::json!({
|
||||
"sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" }
|
||||
});
|
||||
let normalized = normalize_inline_mcp_servers(&direct);
|
||||
let servers = normalized
|
||||
.get("mcpServers")
|
||||
.and_then(|v| v.as_object())
|
||||
.unwrap();
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert!(servers.contains_key("sentry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_inline_mcp_servers_idempotent_for_wrapped() {
|
||||
let wrapped = serde_json::json!({
|
||||
"mcpServers": { "sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" } }
|
||||
});
|
||||
assert_eq!(normalize_inline_mcp_servers(&wrapped), wrapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_config_path_inline_does_not_suppress_sibling_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("sentry");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".mcp.json"),
|
||||
r#"{"mcpServers":{"sentry":{"type":"http","url":"https://mcp.sentry.dev/mcp"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let manifest = manifest_with_inline_mcp(serde_json::json!({
|
||||
"sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" }
|
||||
}));
|
||||
|
||||
let resolved = manifest.mcp_config_path(&root);
|
||||
assert!(
|
||||
resolved.as_ref().is_some_and(|p| p.ends_with(".mcp.json")),
|
||||
"inline mcpServers must not hide a sibling .mcp.json"
|
||||
);
|
||||
assert!(manifest.inline_mcp_servers().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_config_path_inline_without_file_is_none() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("inline-only");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
|
||||
let manifest = manifest_with_inline_mcp(serde_json::json!({
|
||||
"foo": { "command": "./server" }
|
||||
}));
|
||||
assert!(manifest.mcp_config_path(&root).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
//! Marketplace plugin discovery.
|
||||
//!
|
||||
//! Sources:
|
||||
//! - `extraKnownMarketplaces` in `.claude/settings.json` (project-level)
|
||||
//! - `~/.claude/plugins/known_marketplaces.json` (user-level registry)
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedMarketplace {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub plugin_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Resolve marketplaces and their enabled plugins from `extraKnownMarketplaces`
|
||||
/// and `enabledPlugins` in `.claude/settings.json`. Local directory sources only.
|
||||
pub fn resolve(git_root: &Path) -> Vec<ResolvedMarketplace> {
|
||||
let settings_path = git_root.join(".claude").join("settings.json");
|
||||
let json: serde_json::Value = match std::fs::read_to_string(&settings_path) {
|
||||
Ok(c) => match serde_json::from_str(&c) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "malformed .claude/settings.json");
|
||||
return vec![];
|
||||
}
|
||||
},
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
let enabled = enabled_plugin_names(&json);
|
||||
if enabled.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Some(marketplaces) = json
|
||||
.get("extraKnownMarketplaces")
|
||||
.and_then(|v| v.as_object())
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (name, config) in marketplaces {
|
||||
let Some(rel_path) = config
|
||||
.get("source")
|
||||
.and_then(|s| s.get("path"))
|
||||
.and_then(|p| p.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let marketplace_path = git_root.join(rel_path);
|
||||
let plugins_dir = marketplace_path.join("plugins");
|
||||
let Ok(entries) = std::fs::read_dir(&plugins_dir) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut plugin_dirs = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let plugin_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if enabled.contains(plugin_name) {
|
||||
tracing::info!(marketplace = %name, plugin = plugin_name, "marketplace plugin");
|
||||
plugin_dirs.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
result.push(ResolvedMarketplace {
|
||||
name: name.clone(),
|
||||
path: marketplace_path,
|
||||
plugin_dirs,
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Enabled plugin names from `enabledPlugins` (`"name@marketplace"` keys).
|
||||
fn enabled_plugin_names(json: &serde_json::Value) -> HashSet<String> {
|
||||
json.get("enabledPlugins")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter(|(_, v)| v.as_bool().unwrap_or(false))
|
||||
.filter_map(|(k, _)| k.split('@').next().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parse `enabledPlugins` from a settings JSON value into enabled/disabled lists.
|
||||
///
|
||||
/// The `enabledPlugins` object has keys like `"name@marketplace"` with boolean values.
|
||||
/// Keys with `true` are returned in the first vec (enabled), `false` in the second (disabled).
|
||||
/// The `@marketplace` suffix is stripped — only the plugin name is returned.
|
||||
pub fn parse_enabled_disabled_plugins(json: &serde_json::Value) -> (Vec<String>, Vec<String>) {
|
||||
let Some(obj) = json.get("enabledPlugins").and_then(|v| v.as_object()) else {
|
||||
return (vec![], vec![]);
|
||||
};
|
||||
// Deduplicate by plugin name: the same name may appear under different
|
||||
// marketplace keys (e.g. "foo@market1": true, "foo@market2": false).
|
||||
// If any entry for a name is `false`, the plugin is disabled (safe default).
|
||||
let mut state: HashMap<String, bool> = HashMap::new();
|
||||
for (key, val) in obj {
|
||||
let name = key.split('@').next().unwrap_or(key).to_string();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = val.as_bool() else {
|
||||
continue;
|
||||
};
|
||||
let entry = state.entry(name).or_insert(value);
|
||||
// disabled (false) wins on conflict
|
||||
if !value {
|
||||
*entry = false;
|
||||
}
|
||||
}
|
||||
let mut enabled = Vec::new();
|
||||
let mut disabled = Vec::new();
|
||||
for (name, is_enabled) in state {
|
||||
if is_enabled {
|
||||
enabled.push(name);
|
||||
} else {
|
||||
disabled.push(name);
|
||||
}
|
||||
}
|
||||
(enabled, disabled)
|
||||
}
|
||||
|
||||
/// Load and parse `enabledPlugins` from a `.claude/settings.json` file path.
|
||||
///
|
||||
/// Returns `(enabled, disabled)` plugin name lists.
|
||||
/// Returns empty vecs if the file is missing or malformed.
|
||||
pub fn load_enabled_disabled_plugins(path: &Path) -> (Vec<String>, Vec<String>) {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
let json: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
parse_enabled_disabled_plugins(&json)
|
||||
}
|
||||
|
||||
// ── Compat known_marketplaces.json ────────────────────────────────────
|
||||
|
||||
/// Entry in `~/.claude/plugins/known_marketplaces.json`.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KnownMarketplaceEntry {
|
||||
install_location: PathBuf,
|
||||
}
|
||||
|
||||
/// Resolve user-level marketplaces from `known_marketplaces.json`.
|
||||
///
|
||||
/// Returns marketplace entries with their local `installLocation` paths.
|
||||
/// Plugin dirs are filtered to names present in user-level
|
||||
/// `~/.claude/settings{.local}.json` `enabledPlugins` with any value (a
|
||||
/// `false` entry is an installed-but-disabled plugin whose state we
|
||||
/// mirror), so never-installed catalog plugins are not discovered.
|
||||
pub fn resolve_known_marketplaces() -> Vec<ResolvedMarketplace> {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return vec![];
|
||||
};
|
||||
resolve_known_marketplaces_in(&home.join(".claude"))
|
||||
}
|
||||
|
||||
/// Like [`resolve_known_marketplaces`] but reads from an explicit `~/.claude`
|
||||
/// root, so tests stay isolated from the developer's real home dir.
|
||||
pub fn resolve_known_marketplaces_in(claude_dir: &Path) -> Vec<ResolvedMarketplace> {
|
||||
let json_path = claude_dir.join("plugins").join("known_marketplaces.json");
|
||||
let content = match std::fs::read_to_string(&json_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let registry: HashMap<String, KnownMarketplaceEntry> = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to parse known_marketplaces.json");
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let installed = installed_plugin_keys(claude_dir);
|
||||
|
||||
registry
|
||||
.into_iter()
|
||||
.filter_map(|(name, entry)| {
|
||||
let path = entry.install_location;
|
||||
if !path.is_dir() {
|
||||
return None;
|
||||
}
|
||||
// Collect plugin subdirectories from plugins/ and external_plugins/
|
||||
let mut plugin_dirs = Vec::new();
|
||||
for subdir in &["plugins", "external_plugins"] {
|
||||
let dir = path.join(subdir);
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let plugin_name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let is_installed = match installed.get(plugin_name) {
|
||||
Some(None) => true,
|
||||
Some(Some(marketplaces)) => marketplaces.contains(name.as_str()),
|
||||
None => false,
|
||||
};
|
||||
if is_installed {
|
||||
plugin_dirs.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ResolvedMarketplace {
|
||||
name,
|
||||
path,
|
||||
plugin_dirs,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `enabledPlugins` keys from `<claude_dir>/settings.local.json` and
|
||||
/// `<claude_dir>/settings.json`, keyed by plugin name. `None` = a bare key
|
||||
/// (matches any marketplace); `Some(set)` = only those marketplaces.
|
||||
/// Entries with any boolean value count; non-boolean values are skipped.
|
||||
fn installed_plugin_keys(claude_dir: &Path) -> HashMap<String, Option<HashSet<String>>> {
|
||||
let mut keys: HashMap<String, Option<HashSet<String>>> = HashMap::new();
|
||||
for settings_name in ["settings.local.json", "settings.json"] {
|
||||
let path = claude_dir.join(settings_name);
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let json: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "malformed settings.json");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(obj) = json.get("enabledPlugins").and_then(|v| v.as_object()) else {
|
||||
continue;
|
||||
};
|
||||
for (key, value) in obj {
|
||||
if !value.is_boolean() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = key.splitn(2, '@');
|
||||
let Some(plugin_name) = parts.next().filter(|n| !n.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
match parts.next() {
|
||||
Some(marketplace) => {
|
||||
if let Some(marketplaces) = keys
|
||||
.entry(plugin_name.to_string())
|
||||
.or_insert_with(|| Some(HashSet::new()))
|
||||
{
|
||||
marketplaces.insert(marketplace.to_string());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
keys.insert(plugin_name.to_string(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_enabled_disabled_both() {
|
||||
let json = serde_json::json!({
|
||||
"enabledPlugins": {
|
||||
"alpha@marketplace": true,
|
||||
"beta@marketplace": false,
|
||||
"gamma@other": true
|
||||
}
|
||||
});
|
||||
let (enabled, disabled) = parse_enabled_disabled_plugins(&json);
|
||||
assert!(enabled.contains(&"alpha".to_string()));
|
||||
assert!(enabled.contains(&"gamma".to_string()));
|
||||
assert_eq!(enabled.len(), 2);
|
||||
assert_eq!(disabled.len(), 1);
|
||||
assert!(disabled.contains(&"beta".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enabled_disabled_empty() {
|
||||
let json = serde_json::json!({});
|
||||
let (enabled, disabled) = parse_enabled_disabled_plugins(&json);
|
||||
assert!(enabled.is_empty());
|
||||
assert!(disabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enabled_disabled_no_at_sign() {
|
||||
let json = serde_json::json!({
|
||||
"enabledPlugins": {
|
||||
"plain-name": true,
|
||||
"other-name": false
|
||||
}
|
||||
});
|
||||
let (enabled, disabled) = parse_enabled_disabled_plugins(&json);
|
||||
assert_eq!(enabled.len(), 1);
|
||||
assert!(enabled.contains(&"plain-name".to_string()));
|
||||
assert_eq!(disabled.len(), 1);
|
||||
assert!(disabled.contains(&"other-name".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enabled_disabled_skips_non_bool() {
|
||||
let json = serde_json::json!({
|
||||
"enabledPlugins": {
|
||||
"good@m": true,
|
||||
"bad@m": "yes",
|
||||
"ugly@m": 42
|
||||
}
|
||||
});
|
||||
let (enabled, disabled) = parse_enabled_disabled_plugins(&json);
|
||||
assert_eq!(enabled.len(), 1);
|
||||
assert!(enabled.contains(&"good".to_string()));
|
||||
assert!(disabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_enabled_disabled_missing_file() {
|
||||
let (enabled, disabled) =
|
||||
load_enabled_disabled_plugins(Path::new("/nonexistent/settings.json"));
|
||||
assert!(enabled.is_empty());
|
||||
assert!(disabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_enabled_disabled_from_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("settings.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"enabledPlugins": {"foo@m": true, "bar@m": false}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (enabled, disabled) = load_enabled_disabled_plugins(&path);
|
||||
assert_eq!(enabled.len(), 1);
|
||||
assert!(enabled.contains(&"foo".to_string()));
|
||||
assert_eq!(disabled.len(), 1);
|
||||
assert!(disabled.contains(&"bar".to_string()));
|
||||
}
|
||||
|
||||
/// Build a `~/.claude`-style dir with one known marketplace named `mp`
|
||||
/// containing `plugins/{alpha,beta}` and `external_plugins/gamma`, plus a
|
||||
/// `settings.json` with the given content (skipped when `None`).
|
||||
fn make_known_marketplace(
|
||||
tmp: &Path,
|
||||
settings_json: Option<&str>,
|
||||
) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let claude_dir = tmp.join(".claude");
|
||||
let mp_dir = tmp.join("mp-repo");
|
||||
for plugin in ["plugins/alpha", "plugins/beta", "external_plugins/gamma"] {
|
||||
std::fs::create_dir_all(mp_dir.join(plugin)).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(claude_dir.join("plugins")).unwrap();
|
||||
let known = serde_json::json!({
|
||||
"mp": { "installLocation": mp_dir.to_string_lossy() }
|
||||
});
|
||||
std::fs::write(
|
||||
claude_dir.join("plugins").join("known_marketplaces.json"),
|
||||
serde_json::to_string(&known).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(settings) = settings_json {
|
||||
std::fs::write(claude_dir.join("settings.json"), settings).unwrap();
|
||||
}
|
||||
(claude_dir, mp_dir)
|
||||
}
|
||||
|
||||
fn plugin_dir_names(marketplaces: &[ResolvedMarketplace]) -> Vec<String> {
|
||||
let mut names: Vec<String> = marketplaces
|
||||
.iter()
|
||||
.flat_map(|m| &m.plugin_dirs)
|
||||
.filter_map(|d| d.file_name().and_then(|n| n.to_str()).map(String::from))
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_filtered_to_enabled_plugins_including_false() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// `gamma@mp: false` = installed-but-disabled: still discovered.
|
||||
// `beta` is not listed at all: a never-installed catalog entry.
|
||||
let (claude_dir, mp_dir) = make_known_marketplace(
|
||||
tmp.path(),
|
||||
Some(r#"{"enabledPlugins": {"alpha@mp": true, "gamma@mp": false}}"#),
|
||||
);
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].name, "mp");
|
||||
assert_eq!(resolved[0].path, mp_dir);
|
||||
assert_eq!(
|
||||
plugin_dir_names(&resolved),
|
||||
vec!["alpha".to_string(), "gamma".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_key_with_other_marketplace_does_not_match() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (claude_dir, _) = make_known_marketplace(
|
||||
tmp.path(),
|
||||
Some(r#"{"enabledPlugins": {"alpha@other": true}}"#),
|
||||
);
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert!(plugin_dir_names(&resolved).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_unqualified_key_matches_any_marketplace() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (claude_dir, _) =
|
||||
make_known_marketplace(tmp.path(), Some(r#"{"enabledPlugins": {"alpha": true}}"#));
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(plugin_dir_names(&resolved), vec!["alpha".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_no_settings_yields_no_plugin_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (claude_dir, _) = make_known_marketplace(tmp.path(), None);
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(resolved.len(), 1, "marketplace entry itself is kept");
|
||||
assert!(plugin_dir_names(&resolved).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_reads_settings_local_json_too() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (claude_dir, _) = make_known_marketplace(
|
||||
tmp.path(),
|
||||
Some(r#"{"enabledPlugins": {"alpha@mp": true}}"#),
|
||||
);
|
||||
std::fs::write(
|
||||
claude_dir.join("settings.local.json"),
|
||||
r#"{"enabledPlugins": {"gamma@mp": false}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(
|
||||
plugin_dir_names(&resolved),
|
||||
vec!["alpha".to_string(), "gamma".to_string()],
|
||||
"keys from settings.local.json and settings.json must both count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_non_bool_enabled_value_skipped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (claude_dir, _) = make_known_marketplace(
|
||||
tmp.path(),
|
||||
Some(r#"{"enabledPlugins": {"alpha@mp": "yes", "beta@mp": true}}"#),
|
||||
);
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(plugin_dir_names(&resolved), vec!["beta".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enabled_disabled_conflict_disabled_wins() {
|
||||
// Same plugin name from different marketplaces with conflicting values:
|
||||
// disabled (false) should win.
|
||||
let json = serde_json::json!({
|
||||
"enabledPlugins": {
|
||||
"conflict@market1": true,
|
||||
"conflict@market2": false
|
||||
}
|
||||
});
|
||||
let (enabled, disabled) = parse_enabled_disabled_plugins(&json);
|
||||
assert!(enabled.is_empty());
|
||||
assert_eq!(disabled.len(), 1);
|
||||
assert!(disabled.contains(&"conflict".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Plugin system — discover, load, and manage plugins (including compat layouts).
|
||||
//!
|
||||
//! A plugin is a self-contained directory that bundles skills, agents,
|
||||
//! MCP server configs, and hooks into a namespaced unit. Plugins can
|
||||
//! live under `~/.kigi/plugins/`, `.kigi/plugins/` (project-level),
|
||||
//! or be passed via `--plugin-dir` on the CLI.
|
||||
//!
|
||||
//! This module handles:
|
||||
//! - `manifest` — parsing `plugin.json` manifests
|
||||
//! - `discovery` — scanning the filesystem for plugin directories
|
||||
//! - `trust` — project-plugin trust management
|
||||
//! - `registry` — in-memory registry of active plugins
|
||||
|
||||
pub mod discovery;
|
||||
pub mod git_install;
|
||||
pub mod hooks_adapter;
|
||||
pub mod install_registry;
|
||||
pub mod local_refresh;
|
||||
pub mod manifest;
|
||||
pub mod marketplace;
|
||||
pub mod registry;
|
||||
pub mod trust;
|
||||
|
||||
pub use discovery::{
|
||||
DiscoveredPlugin, PluginOrigin, PluginScope, discover_plugins, project_plugin_dirs,
|
||||
project_plugin_dirs_in,
|
||||
};
|
||||
pub use hooks_adapter::parse_plugin_hooks;
|
||||
pub use install_registry::InstallRegistry;
|
||||
pub use manifest::PluginManifest;
|
||||
pub use registry::{LoadedPlugin, PluginRegistry, SharedPluginRegistryHandle};
|
||||
pub use trust::TrustStore;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,365 @@
|
||||
//! Project plugin trust management.
|
||||
//!
|
||||
//! Plugins from project directories (`.kigi/plugins/`, `.claude/plugins/`)
|
||||
//! are an execution surface. A cloned repository could contain plugins with
|
||||
//! hook scripts or MCP server commands that run arbitrary code.
|
||||
//!
|
||||
//! **Trust granularity**: per-plugin-root (not per-worktree). Trusting one
|
||||
//! plugin in a repo does not automatically trust other plugins in the same repo.
|
||||
//!
|
||||
//! **Trust key**: canonical absolute path of the plugin root directory,
|
||||
//! resolved via `dunce::canonicalize()`.
|
||||
//!
|
||||
//! **Trust storage**: `~/.kigi/trusted-plugins` (one canonical path per line).
|
||||
//!
|
||||
//! **Behavior for untrusted plugins**:
|
||||
//! - Skills and agents are **discovered and listed** (metadata-only).
|
||||
//! - Hooks, MCP servers, and scripts are **blocked**.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{BufRead, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Name of the trust-store file under `~/.kigi/`.
|
||||
const TRUST_FILE_NAME: &str = "trusted-plugins";
|
||||
|
||||
/// Manages the set of trusted plugin root directories.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrustStore {
|
||||
/// Canonical paths of trusted plugin roots.
|
||||
trusted: HashSet<PathBuf>,
|
||||
/// Path to the trust-store file on disk.
|
||||
file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl TrustStore {
|
||||
/// Load the trust store from disk.
|
||||
///
|
||||
/// If `~/.kigi/trusted-plugins` does not exist, returns an empty store.
|
||||
/// If the file cannot be read, logs a warning and returns an empty store.
|
||||
pub fn load() -> Self {
|
||||
// Gate on user_kigi_home() so a project's `.kigi/trusted-plugins` is never
|
||||
// read as the user trust store when neither KIGI_SHARE_DIR nor a home dir resolves.
|
||||
let Some(grok) = kigi_config::user_kigi_home() else {
|
||||
return Self {
|
||||
trusted: HashSet::new(),
|
||||
file_path: PathBuf::new(),
|
||||
};
|
||||
};
|
||||
let file_path = grok.join(TRUST_FILE_NAME);
|
||||
let trusted = Self::read_trust_file(&file_path);
|
||||
Self { trusted, file_path }
|
||||
}
|
||||
|
||||
/// Load from a custom file path (for testing).
|
||||
pub fn load_from(file_path: PathBuf) -> Self {
|
||||
let trusted = Self::read_trust_file(&file_path);
|
||||
Self { trusted, file_path }
|
||||
}
|
||||
|
||||
/// Check whether a plugin root directory is trusted.
|
||||
///
|
||||
/// Canonicalizes the path before lookup. Returns `false` if
|
||||
/// canonicalization fails (broken symlink, permission error).
|
||||
pub fn is_trusted(&self, plugin_root: &Path) -> bool {
|
||||
match dunce::canonicalize(plugin_root) {
|
||||
Ok(canonical) => self.trusted.contains(&canonical),
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
path = %plugin_root.display(),
|
||||
"failed to canonicalize plugin root for trust check; treating as untrusted"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grant trust to a plugin root directory.
|
||||
///
|
||||
/// Canonicalizes the path and appends it to `~/.kigi/trusted-plugins`.
|
||||
/// If the path is already trusted, this is a no-op and returns `Ok(())`.
|
||||
pub fn grant_trust(&mut self, plugin_root: &Path) -> Result<(), TrustError> {
|
||||
let canonical =
|
||||
dunce::canonicalize(plugin_root).map_err(|e| TrustError::CanonicalizeFailed {
|
||||
path: plugin_root.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if self.trusted.contains(&canonical) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = self.file_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| TrustError::IoError {
|
||||
path: parent.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
|
||||
// Append to file
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.file_path)
|
||||
.map_err(|e| TrustError::IoError {
|
||||
path: self.file_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
writeln!(file, "{}", canonical.display()).map_err(|e| TrustError::IoError {
|
||||
path: self.file_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
self.trusted.insert(canonical);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke trust for a plugin root directory.
|
||||
///
|
||||
/// Canonicalizes the path, removes it from the in-memory set, and
|
||||
/// rewrites `~/.kigi/trusted-plugins` without the revoked entry.
|
||||
/// If the path is not currently trusted, this is a no-op.
|
||||
pub fn revoke_trust(&mut self, plugin_root: &Path) -> Result<(), TrustError> {
|
||||
let canonical =
|
||||
dunce::canonicalize(plugin_root).map_err(|e| TrustError::CanonicalizeFailed {
|
||||
path: plugin_root.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if !self.trusted.remove(&canonical) {
|
||||
return Ok(()); // wasn't trusted
|
||||
}
|
||||
|
||||
// Rewrite the entire file without the revoked path
|
||||
self.rewrite_trust_file()
|
||||
}
|
||||
|
||||
/// Rewrite the trust file from the current in-memory set.
|
||||
fn rewrite_trust_file(&self) -> Result<(), TrustError> {
|
||||
if let Some(parent) = self.file_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| TrustError::IoError {
|
||||
path: parent.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut file = std::fs::File::create(&self.file_path).map_err(|e| TrustError::IoError {
|
||||
path: self.file_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
use std::io::Write;
|
||||
for path in &self.trusted {
|
||||
writeln!(file, "{}", path.display()).map_err(|e| TrustError::IoError {
|
||||
path: self.file_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether a config-path plugin should be auto-trusted.
|
||||
///
|
||||
/// A `[plugins].paths` entry is auto-trusted if its canonicalized path
|
||||
/// is under the user's home directory. Otherwise it requires explicit
|
||||
/// trust via `~/.kigi/trusted-plugins`.
|
||||
pub fn is_config_path_auto_trusted(plugin_root: &Path) -> bool {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return false;
|
||||
};
|
||||
match dunce::canonicalize(plugin_root) {
|
||||
Ok(canonical) => canonical.starts_with(&home),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────
|
||||
|
||||
fn read_trust_file(path: &Path) -> HashSet<PathBuf> {
|
||||
let file = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashSet::new(),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"failed to read trust store; no plugins will be trusted"
|
||||
);
|
||||
return HashSet::new();
|
||||
}
|
||||
};
|
||||
|
||||
let reader = std::io::BufReader::new(file);
|
||||
reader
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.ok()?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
// Entries may predate dunce (Windows \\?\ verbatim form); simplify so lookups match.
|
||||
Some(dunce::simplified(Path::new(trimmed)).to_path_buf())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TrustError {
|
||||
#[error("failed to canonicalize path {path}: {source}")]
|
||||
CanonicalizeFailed {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("I/O error on {path}: {source}")]
|
||||
IoError {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_trust_store() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
let store = TrustStore::load_from(trust_file);
|
||||
// Nothing is trusted
|
||||
assert!(!store.is_trusted(tmp.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_and_check_trust() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
|
||||
let plugin_dir = tmp.path().join("my-plugin");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
let mut store = TrustStore::load_from(trust_file.clone());
|
||||
assert!(!store.is_trusted(&plugin_dir));
|
||||
|
||||
store.grant_trust(&plugin_dir).unwrap();
|
||||
assert!(store.is_trusted(&plugin_dir));
|
||||
|
||||
// Granting again is a no-op
|
||||
store.grant_trust(&plugin_dir).unwrap();
|
||||
|
||||
// Reload from disk and verify persistence
|
||||
let reloaded = TrustStore::load_from(trust_file);
|
||||
assert!(reloaded.is_trusted(&plugin_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_file_skips_comments_and_blanks() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
|
||||
let plugin_dir = tmp.path().join("real-plugin");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
let canonical = dunce::canonicalize(&plugin_dir).unwrap();
|
||||
|
||||
// Write file with comments and blank lines
|
||||
std::fs::write(
|
||||
&trust_file,
|
||||
format!(
|
||||
"# This is a comment\n\n{}\n \n# Another comment\n",
|
||||
canonical.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = TrustStore::load_from(trust_file);
|
||||
assert!(store.is_trusted(&plugin_dir));
|
||||
}
|
||||
|
||||
/// Legacy entries written under std canonicalize use the verbatim `\\?\`
|
||||
/// form; `read_trust_file` must normalize them so lookups keep matching.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn legacy_verbatim_entry_is_trusted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
|
||||
let plugin_dir = tmp.path().join("legacy-plugin");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
let canonical = dunce::canonicalize(&plugin_dir).unwrap();
|
||||
std::fs::write(&trust_file, format!("\\\\?\\{}\n", canonical.display())).unwrap();
|
||||
|
||||
let mut store = TrustStore::load_from(trust_file.clone());
|
||||
assert!(store.is_trusted(&plugin_dir));
|
||||
|
||||
// Revoke rewrites the file in simplified form, dropping the legacy line.
|
||||
store.revoke_trust(&plugin_dir).unwrap();
|
||||
assert!(!TrustStore::load_from(trust_file).is_trusted(&plugin_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonexistent_path_is_not_trusted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
let store = TrustStore::load_from(trust_file);
|
||||
|
||||
// Path that doesn't exist on disk
|
||||
let fake = tmp.path().join("does-not-exist");
|
||||
assert!(!store.is_trusted(&fake));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_auto_trust_under_home() {
|
||||
// This test checks the logic but can't easily mock $HOME.
|
||||
// We verify the function exists and returns a boolean.
|
||||
let result = TrustStore::is_config_path_auto_trusted(Path::new("/nonexistent/path"));
|
||||
assert!(!result); // nonexistent path can't be canonicalized
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_trust_removes_from_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
|
||||
let plugin_a = tmp.path().join("plugin-a");
|
||||
let plugin_b = tmp.path().join("plugin-b");
|
||||
std::fs::create_dir_all(&plugin_a).unwrap();
|
||||
std::fs::create_dir_all(&plugin_b).unwrap();
|
||||
|
||||
let mut store = TrustStore::load_from(trust_file.clone());
|
||||
store.grant_trust(&plugin_a).unwrap();
|
||||
store.grant_trust(&plugin_b).unwrap();
|
||||
assert!(store.is_trusted(&plugin_a));
|
||||
assert!(store.is_trusted(&plugin_b));
|
||||
|
||||
// Revoke plugin_a
|
||||
store.revoke_trust(&plugin_a).unwrap();
|
||||
assert!(!store.is_trusted(&plugin_a));
|
||||
assert!(store.is_trusted(&plugin_b));
|
||||
|
||||
// Verify persistence
|
||||
let reloaded = TrustStore::load_from(trust_file);
|
||||
assert!(!reloaded.is_trusted(&plugin_a));
|
||||
assert!(reloaded.is_trusted(&plugin_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_trust_noop_if_not_trusted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
let plugin = tmp.path().join("some-plugin");
|
||||
std::fs::create_dir_all(&plugin).unwrap();
|
||||
|
||||
let mut store = TrustStore::load_from(trust_file);
|
||||
// Not trusted — revoke should be a no-op
|
||||
store.revoke_trust(&plugin).unwrap();
|
||||
assert!(!store.is_trusted(&plugin));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user