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:
ZacharyZhang-NY committed 2026-07-17 05:31:01 -04:00
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(&registry_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, &registry_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));
}
}