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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
+296
View File
@@ -0,0 +1,296 @@
//! Agent — a fully built agent: definition + session context.
use std::sync::Arc;
use kigi_sampling_types::HostedTool;
use kigi_tools::bridge::ToolBridge;
use kigi_tools::types::definition::ToolDefinition;
use crate::compaction::CompactionPolicy;
use crate::config::{AgentDefinition, CompletionRequirement, PermissionMode};
use crate::prompt::context::PromptContext;
use crate::system_reminder::ReminderPolicy;
/// A fully built agent: definition + session context.
///
/// NOT portable — tied to a specific session via its ToolBridge,
/// rendered system prompt, and session-level policies.
///
/// Created by AgentBuilder from an AgentDefinition + session context.
///
/// The Agent is effectively immutable after construction. It holds
/// Arc<ToolBridge> — mutations to tool state (MCP registration,
/// completion tracking, retry config) go through ToolBridge's
/// internal locks.
pub struct Agent {
/// The definition this agent was built from.
definition: AgentDefinition,
/// The context that produced the current system prompt.
/// Stored for inspection, re-rendering, and serialization.
prompt_context: PromptContext,
/// The rendered system prompt (cached from prompt_context.render()).
system_prompt: String,
/// The tool bridge — owns ToolRegistry + ToolState + SessionContext.
tool_bridge: Arc<ToolBridge>,
/// Session-level policies.
reminder_policy: ReminderPolicy,
compaction_policy: CompactionPolicy,
/// Backend-hosted tools to include in API requests.
/// These are sent as native Responses API types (e.g., `WebSearch`)
/// and executed server-side by the agentic sampler.
hosted_tools: Vec<HostedTool>,
/// Build-time toggle for server-side search tools. ANDed at request
/// time with the per-model `SessionActor::supports_backend_search`.
backend_search_enabled: bool,
}
impl Agent {
/// Create a new Agent.
///
/// Normally called by `AgentBuilder::build()`. Exposed publicly for
/// test helpers that need to construct an Agent with a pre-built ToolBridge.
pub fn new(
definition: AgentDefinition,
prompt_context: PromptContext,
system_prompt: String,
tool_bridge: Arc<ToolBridge>,
reminder_policy: ReminderPolicy,
compaction_policy: CompactionPolicy,
hosted_tools: Vec<HostedTool>,
backend_search_enabled: bool,
) -> Self {
Self {
definition,
prompt_context,
system_prompt,
tool_bridge,
reminder_policy,
compaction_policy,
hosted_tools,
backend_search_enabled,
}
}
// ── From definition ──────────────────────────────────────────────
/// Agent name (unique identifier).
pub fn name(&self) -> &str {
&self.definition.name
}
/// Agent description.
pub fn description(&self) -> &str {
&self.definition.description
}
/// The full agent definition.
pub fn definition(&self) -> &AgentDefinition {
&self.definition
}
/// Permission mode for this agent.
pub fn permission_mode(&self) -> &PermissionMode {
&self.definition.permission_mode
}
/// Completion requirement, if any.
pub fn completion_requirement(&self) -> Option<&CompletionRequirement> {
self.definition.completion_requirement.as_ref()
}
// ── Session-level ────────────────────────────────────────────────
/// The rendered system prompt.
pub fn system_prompt(&self) -> &str {
&self.system_prompt
}
/// Compact system prompt for post-compaction use.
///
/// Returns a static string — the compact prompt never changes at runtime.
pub fn compact_system_prompt(&self) -> &str {
crate::prompt::template::COMPACT_SYSTEM_PROMPT
}
/// The tool bridge for this agent.
pub fn tool_bridge(&self) -> &Arc<ToolBridge> {
&self.tool_bridge
}
/// Compaction policy.
pub fn compaction_policy(&self) -> &CompactionPolicy {
&self.compaction_policy
}
/// Reminder policy.
pub fn reminder_policy(&self) -> &ReminderPolicy {
&self.reminder_policy
}
/// Cached AGENTS.md section (derived from prompt_context).
pub fn agents_md_section(&self) -> Option<String> {
self.prompt_context.format_agents_md_section()
}
/// AGENTS.md content formatted for user-message injection.
///
/// Returns the `<system-reminder>` block to prepend as a user message,
/// respecting audience (compacted for subagents) and template.
pub fn agents_md_user_reminder(&self) -> Option<String> {
self.prompt_context.agents_md_user_reminder()
}
/// Personas content formatted for user-message injection.
///
/// Returns the `<system-reminder>` block to prepend as a user message,
/// respecting audience (suppressed for subagents) and template.
pub fn personas_user_reminder(&self) -> Option<String> {
self.prompt_context.personas_user_reminder()
}
/// The structured prompt context for inspection and re-rendering.
pub fn prompt_context(&self) -> &PromptContext {
&self.prompt_context
}
/// Audience this agent's prompt was rendered for (Primary or Subagent).
///
/// Used by the runtime turn-end TodoGate together with
/// [`crate::AgentDefinition::carries_task_completion_discipline`] to
/// decide whether the active prompt actually carries the discipline
/// rules the gate's reminder text invokes.
pub fn prompt_audience(&self) -> crate::prompt::context::PromptAudience {
self.prompt_context.audience
}
/// Tool definitions for the sampling API — delegates to ToolBridge.
pub async fn tool_definitions(&self) -> Vec<ToolDefinition> {
self.tool_bridge.tool_definitions().await
}
/// Backend-hosted tools that should be included in API requests.
/// These are sent as native types (e.g., `rs::Tool::WebSearch`) and
/// executed server-side by the agentic sampler.
pub fn hosted_tools(&self) -> &[HostedTool] {
&self.hosted_tools
}
/// Build-time toggle for server-side search tools. Callers should
/// AND this with the per-model `supports_backend_search` flag to
/// decide whether to ship `hosted_tools` on a request. Do not use
/// `hosted_tools().is_empty()` as a proxy — the list also depends
/// on web-search config.
pub fn backend_search_enabled(&self) -> bool {
self.backend_search_enabled
}
/// Built-in tool definitions only (excludes MCP tools).
pub async fn tool_definitions_builtins_only(&self) -> Vec<ToolDefinition> {
self.tool_bridge.tool_definitions_builtins_only().await
}
/// Whether auto-compact should trigger given current token usage.
///
/// `context_window` comes from the session's SamplingConfig (model-provided).
pub fn should_auto_compact(
&self,
total_tokens: u64,
context_window: std::num::NonZeroU64,
) -> bool {
let cw = context_window.get();
kigi_token_estimation::exceeds_threshold(
total_tokens,
cw,
self.compaction_policy.auto_compact_threshold_percent as u8,
)
}
/// Update completion and retry policies from a new definition.
///
/// Does NOT rebuild the tool registry or re-render prompts.
/// Used for mid-session mode switching.
pub async fn update_policies_from_definition(&self, _def: &AgentDefinition) {
// TODO: completion requirements and retry configs are now part of
// ToolServerConfig and handled at registry finalization time.
// Mid-session policy updates are not yet supported in the new architecture.
}
/// Re-render the system prompt from current ToolBridge state
/// (tool name overrides, disabled tools). Called by hosts after
/// mid-session tool-override updates.
pub async fn finalize_prompt(&mut self) {
self.prompt_context.build_timestamp_utc = chrono::Utc::now().to_rfc3339();
self.system_prompt = self
.prompt_context
.render(&self.tool_bridge)
.await
.unwrap_or_default();
}
/// Re-render the system prompt for a different definition, reusing
/// the existing ToolBridge. Used for mid-session mode switching.
pub async fn render_prompt_for_definition(&self, definition: &AgentDefinition) -> String {
let mut ctx = self.prompt_context.clone();
ctx.prompt_mode = definition.prompt_mode.clone();
ctx.prompt_body = definition.prompt_body.clone();
ctx.system_prompt = definition.system_prompt.clone();
ctx.build_timestamp_utc = chrono::Utc::now().to_rfc3339();
// Clear agents_md if the new definition doesn't want it
if !definition.agents_md {
ctx.agents_md_files.clear();
}
ctx.render(&self.tool_bridge).await.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use std::num::NonZeroU64;
/// Standalone function testing the same logic as Agent::should_auto_compact
fn should_auto_compact_check(total_tokens: u64, context_window: u64, threshold: u32) -> bool {
let cw = NonZeroU64::new(context_window).expect("test context_window must be non-zero");
let usage_percent = (total_tokens * 100) / cw.get();
usage_percent >= threshold as u64
}
#[test]
fn test_should_auto_compact_below_threshold() {
// 80% of 100K window with 85% threshold → false
assert!(!should_auto_compact_check(80_000, 100_000, 85));
}
#[test]
fn test_should_auto_compact_above_threshold() {
// 90% of 100K window with 85% threshold → true
assert!(should_auto_compact_check(90_000, 100_000, 85));
}
#[test]
fn test_should_auto_compact_at_threshold() {
// Exactly 85% of 100K window with 85% threshold → true
assert!(should_auto_compact_check(85_000, 100_000, 85));
}
#[test]
fn test_should_auto_compact_empty_usage() {
// 0 tokens used → false
assert!(!should_auto_compact_check(0, 100_000, 85));
}
#[test]
fn test_should_auto_compact_100_percent_threshold() {
// 100% threshold → only triggers when fully used
assert!(!should_auto_compact_check(99_999, 100_000, 100));
assert!(should_auto_compact_check(100_000, 100_000, 100));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
//! Compaction policy — threshold, model, and memory flush configuration.
/// Session-level compaction policy.
///
/// Controls when and how the session's conversation is compacted
/// to free up context window space, and whether a memory flush
/// runs before each compaction.
#[derive(Debug, Clone)]
pub struct CompactionPolicy {
/// Percentage of context window that triggers auto-compaction.
/// E.g., 85 means compact when 85% of the context window is used.
pub auto_compact_threshold_percent: u32,
/// Model to use for generating the compaction summary.
/// None = use the session's current model.
pub compact_model: Option<String>,
/// Whether to run a memory flush turn before each compaction.
/// When enabled, the session actor asks the model to summarize
/// important information from the conversation before it's compacted.
/// Requires the memory system to be enabled.
pub memory_flush_enabled: bool,
/// Per-compaction wall-clock budget (seconds); a generation exceeding it is
/// cut and retried — the backstop for reasoning runaways token limits miss.
pub wall_clock_budget_secs: u64,
/// Prefire two-pass compaction: when usage approaches the threshold,
/// speculatively summarize the history prefix in the background (pass 1);
/// at compaction, summarize NOTE₁ + the recent tail (pass 2). Resolved from
/// config (`two_pass_compaction` flag) at session build; `false` keeps the
/// legacy single-pass path. Default `false` (real sessions set it from config).
pub two_pass_enabled: bool,
}
impl Default for CompactionPolicy {
fn default() -> Self {
Self {
auto_compact_threshold_percent: 85,
compact_model: None,
memory_flush_enabled: false,
wall_clock_budget_secs: 300,
two_pass_enabled: false,
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
//! Error types for agent construction.
/// Errors that can occur during Agent construction.
#[derive(Debug, thiserror::Error)]
pub enum AgentBuildError {
/// Failed to parse the agent definition file (bad YAML frontmatter,
/// missing closing `---`, or invalid Markdown structure).
#[error("failed to parse agent definition: {0}")]
ParseError(String),
/// Required fields are missing from the definition (name, description).
#[error("missing required field in agent definition: {0}")]
MissingField(String),
/// A tool name override references a tool that doesn't exist in the
/// registry (typo in the definition's `toolNameOverrides`).
#[error("tool name override references nonexistent tool '{0}'")]
UnknownToolOverride(String),
/// IO error during AGENTS.md or skills discovery.
#[error("IO error during agent construction: {0}")]
IoError(#[from] std::io::Error),
/// MiniJinja template rendering failed (extend or full mode).
/// Includes line numbers and context from the template.
#[error("template rendering error: {0}")]
MiniJinjaError(#[from] minijinja::Error),
/// Tool registry error (e.g., unsatisfied requirements during finalization).
#[error("tool error: {0}")]
ToolError(String),
/// A configuration value is present but invalid (e.g. `max_turns = 0`).
#[error("invalid configuration: {0}")]
InvalidConfig(String),
}
+29
View File
@@ -0,0 +1,29 @@
//! Agent builder, definition parsing, and system prompt assembly.
//!
//! This crate extracts a first-class `Agent` type from `kigi-shell`.
//! An `Agent` bundles tools, system prompt, system-reminder policy,
//! compaction policy, and model configuration into a single, portable
//! object that any host can consume.
pub mod agent;
pub mod builder;
pub mod compaction;
pub mod config;
pub mod discovery;
pub mod error;
pub mod plugins;
pub mod prompt;
pub mod repo;
pub mod system_reminder;
pub mod timing;
pub use agent::Agent;
pub use builder::AgentBuilder;
pub use compaction::CompactionPolicy;
pub use config::AgentDefinition;
pub use config::preset_names;
pub use config::toolset_for_preset;
pub use config::workspace_grok_build_toolset;
pub use error::AgentBuildError;
pub use prompt::context::{DEFAULT_SYSTEM_PROMPT_LABEL, PromptContext};
pub use system_reminder::ReminderPolicy;
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));
}
}
@@ -0,0 +1,604 @@
//! AGENTS.md / Claude.md / rules directory discovery and loading.
//!
//! Searches from cwd to repo root, plus `~/.kigi/`. Also discovers
//! `*.md` files in `.kigi/rules/` and `.claude/rules/` directories.
use std::path::{Path, PathBuf};
use crate::prompt::ignore::{build_gitignore, is_ignored};
use kigi_tools::types::compat::CompatConfig;
/// Represents an agent config file with its path and content.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AgentConfigFile {
/// The filename (e.g., "AGENTS.md", "Claude.md")
pub file_name: String,
/// The full absolute path to the config file
pub file_path: String,
/// The content of the config file
pub content: String,
}
/// Find matching agent config files in a directory.
///
/// `filenames` is the (compat-gated) recognized list, precomputed once by the
/// caller so the cwd→root walk doesn't re-allocate it per directory. When all
/// cells are on it equals the legacy `AGENT_FILENAMES` list exactly.
fn find_agent_files(dir: &Path, filenames: &[&str]) -> Vec<PathBuf> {
filenames
.iter()
.filter_map(|name| {
let path = dir.join(name);
path.exists().then_some(path)
})
.collect()
}
/// Find `*.md` files in `.kigi/rules/`, `.claude/rules/`, and `.cursor/rules/`,
/// sorted alphabetically. `rules_subdirs` is the (compat-gated) list, precomputed
/// once by the caller so the walk doesn't re-allocate it per directory.
fn find_rules_files(dir: &Path, rules_subdirs: &[&str]) -> Vec<PathBuf> {
let mut results = Vec::new();
for rules_subdir in rules_subdirs {
let rules_dir = dir.join(rules_subdir);
if !rules_dir.is_dir() {
continue;
}
let mut entries: Vec<PathBuf> = match std::fs::read_dir(&rules_dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
.map(|e| e.path())
.filter(|p| {
p.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
})
.collect(),
Err(_) => continue,
};
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
results.extend(entries);
}
results
}
/// Read Agents.md from ~/.kigi/, git repo root, and session cwd.
/// Returns a list of AgentConfigFile with their file names, full paths, and contents.
///
/// `compat` gates which vendor (`.claude`/`.cursor`) surfaces are scanned for
/// rules / project-instruction files; pass `CompatConfig::default()` to
/// preserve the historical all-vendors behavior.
pub async fn read_agents_config_with_paths(
working_directory: &str,
compat: CompatConfig,
) -> Vec<AgentConfigFile> {
let workspace_user_dir = crate::prompt::workspace_user::optional_workspace_user_dir();
read_agents_config_with_options(working_directory, workspace_user_dir.as_deref(), compat).await
}
/// Inner implementation that accepts an optional workspace user dir as a
/// parameter, making it testable without environment variable mutation.
async fn read_agents_config_with_options(
working_directory: &str,
workspace_user_dir: Option<&Path>,
compat: CompatConfig,
) -> Vec<AgentConfigFile> {
let cwd = PathBuf::from(working_directory);
let global_dir = kigi_tools::util::kigi_home::kigi_home();
let git_root = git2::Repository::discover(&cwd)
.ok()
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()));
let gitignore = build_gitignore(git_root.as_deref());
// Always include kigi_home (~/.kigi/) first, then ~/.claude/ and ~/.cursor/
// for compat — each gated by the resolved `agents` compat cell.
let mut dirs = vec![global_dir];
if let Some(home) = dirs::home_dir() {
for compat_dir in compat.agents_home_dirs() {
let dir = home.join(compat_dir);
if dir.is_dir() {
dirs.push(dir);
}
}
}
// Walk from cwd up to git root to pick up agent files in intermediate directories
if let Some(ref root) = git_root {
let mut current = Some(cwd.as_path());
let mut chain: Vec<PathBuf> = Vec::new();
while let Some(dir) = current {
let dir_buf = dir.to_path_buf();
if !chain.contains(&dir_buf) {
chain.push(dir_buf);
}
if dir == root.as_path() {
break;
}
current = dir.parent();
}
// CRITICAL: Reverse to get root → CWD order (deeper files come later)
chain.reverse();
// Inject optional workspace user dir if not already in the chain.
// Insert after repo root (index 0 after reverse) so it's higher priority
// than repo root AGENTS.md but lower priority than intermediate dirs and cwd.
if let Some(user_dir) = workspace_user_dir {
let user_dir_canonical =
dunce::canonicalize(user_dir).unwrap_or_else(|_| user_dir.to_path_buf());
let already_in_chain = chain.iter().any(|d| {
dunce::canonicalize(d).unwrap_or_else(|_| d.clone()) == user_dir_canonical
});
if !already_in_chain {
// chain[0] is repo root after reverse; insert right after it.
let insert_pos = 1.min(chain.len());
chain.insert(insert_pos, user_dir.to_path_buf());
}
}
dirs.extend(chain);
} else if !dirs.contains(&cwd) {
dirs.push(cwd.clone());
}
// Compute the gated lists once (constant across all scanned dirs) so the
// per-directory scan below doesn't re-allocate them.
let agent_filenames = compat.agent_filenames();
let rules_dirs = compat.rules_dirs();
let files: Vec<PathBuf> = dirs
.into_iter()
.flat_map(|dir| {
let mut combined = find_agent_files(&dir, &agent_filenames);
combined.extend(find_rules_files(&dir, &rules_dirs));
combined
})
.filter(|path| !is_ignored(path, gitignore.as_ref(), git_root.as_deref()))
.collect();
// Deduplicate by canonical path to handle case-insensitive filesystems
// and symlink-resolved tmpdir paths.
let mut seen_canonical = std::collections::HashSet::new();
files
.into_iter()
.filter(|path| {
let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.clone());
seen_canonical.insert(canonical)
})
.filter_map(|file_path| {
let content = std::fs::read_to_string(&file_path).ok()?;
let file_name = file_path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("AGENTS.md")
.to_string();
let full_path = file_path.display().to_string();
Some(AgentConfigFile {
file_name,
file_path: full_path,
content,
})
})
.collect()
}
/// Format AGENTS.md configs into a `<system-reminder>` block for user message injection.
pub fn format_agents_md_section(configs: &[AgentConfigFile]) -> Option<String> {
render_agents_md(configs)
}
/// Verbatim leading bytes [`render_agents_md`] emits for every reminder block.
/// Used by `kigi-shell` to structurally detect legacy untagged AGENTS.md
/// copies (pre-`SyntheticReason::ProjectInstructions`) on resumed sessions.
pub const LEGACY_AGENTS_MD_REMINDER_PREFIX: &str =
"\n\n<system-reminder>\nAs you answer the user's questions, you can use the following context";
fn render_agents_md(configs: &[AgentConfigFile]) -> Option<String> {
if configs.is_empty() {
return None;
}
let mut section = String::new();
section.push_str(LEGACY_AGENTS_MD_REMINDER_PREFIX);
section.push_str(
" (ordered from repo root to current directory - deeper files take precedence on conflicts):\n",
);
for config in configs {
section.push_str(&format!("\n## From: {}\n", config.file_path));
// Strip YAML frontmatter from rules files (e.g. .claude/rules/*.md,
// .kigi/rules/*.md) so globs/paths metadata doesn't leak into the
// system prompt as raw YAML.
let is_rules_file = config.file_path.contains("/.kigi/rules/")
|| config.file_path.contains("/.claude/rules/");
let content = if is_rules_file {
kigi_tools::implementations::skills::skill::extract_skill_body(&config.content)
} else {
config.content.clone()
};
section.push_str(&content);
section.push('\n');
}
section.push_str("\nFollow these instructions exactly. When working in subdirectories not listed above, check for additional project instruction files (AGENTS.md, Claude.md, etc.).");
section.push_str("\n</system-reminder>");
Some(section)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// Helper: initialize a git repo at `path` so git2::Repository::discover works.
fn init_git_repo(path: &Path) {
git2::Repository::init(path).unwrap();
}
// ── find_agent_files unit tests ─────────────────────────────────
#[test]
fn find_agent_files_finds_agents_md() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("AGENTS.md"), "# Instructions").unwrap();
let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames());
// On case-insensitive filesystems (macOS), both "Agents.md" and "AGENTS.md"
// resolve to the same file, so we may get more than 1 result.
assert!(!files.is_empty());
assert!(
files
.iter()
.any(|f| f.to_string_lossy().contains("AGENTS.md")
|| f.to_string_lossy().contains("Agents.md"))
);
}
#[test]
fn find_agent_files_finds_all_variants() {
let tmp = tempfile::tempdir().unwrap();
let filenames = CompatConfig::default().agent_filenames();
for name in &filenames {
let path = tmp.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, format!("# {name}")).unwrap();
}
let files = find_agent_files(tmp.path(), &filenames);
assert_eq!(files.len(), filenames.len());
}
#[test]
fn find_agent_files_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames());
assert!(files.is_empty());
}
#[test]
fn find_agent_files_nonexistent_dir() {
let files = find_agent_files(
Path::new("/nonexistent/dir"),
&CompatConfig::default().agent_filenames(),
);
assert!(files.is_empty());
}
#[test]
fn find_agent_files_discovers_claude_subdir() {
let tmp = tempfile::tempdir().unwrap();
let claude_dir = tmp.path().join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("CLAUDE.md"), "# Project instructions").unwrap();
let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames());
assert!(
files
.iter()
.any(|f| f.to_string_lossy().contains(".claude/CLAUDE.md")),
"Should discover .claude/CLAUDE.md, got: {files:?}"
);
}
#[test]
fn find_rules_files_discovers_claude_rules() {
let tmp = tempfile::tempdir().unwrap();
let rules_dir = tmp.path().join(".claude").join("rules");
fs::create_dir_all(&rules_dir).unwrap();
fs::write(rules_dir.join("style.md"), "# Style rules").unwrap();
fs::write(rules_dir.join("safety.md"), "# Safety rules").unwrap();
let files = find_rules_files(tmp.path(), &CompatConfig::default().rules_dirs());
assert_eq!(files.len(), 2);
assert!(files[0].to_string_lossy().contains("safety.md"));
assert!(files[1].to_string_lossy().contains("style.md"));
}
// ── format_agents_md_section tests ──────────────────────────────
#[test]
fn format_agents_md_section_empty_returns_none() {
assert!(format_agents_md_section(&[]).is_none());
}
#[test]
fn format_agents_md_section_includes_all_configs() {
let configs = vec![
AgentConfigFile {
file_name: "AGENTS.md".to_string(),
file_path: "/repo/AGENTS.md".to_string(),
content: "Repo-level instructions".to_string(),
},
AgentConfigFile {
file_name: "AGENTS.md".to_string(),
file_path: "/repo/x/user/AGENTS.md".to_string(),
content: "User-level instructions".to_string(),
},
];
let section = format_agents_md_section(&configs).unwrap();
assert!(section.contains("Repo-level instructions"));
assert!(section.contains("User-level instructions"));
assert!(section.contains("/repo/AGENTS.md"));
assert!(section.contains("/repo/x/user/AGENTS.md"));
assert!(section.contains("<system-reminder>"));
}
#[test]
fn format_agents_md_section_delivers_full_content() {
let long_content = "A".repeat(5000);
let configs = vec![AgentConfigFile {
file_name: "AGENTS.md".to_string(),
file_path: "/repo/AGENTS.md".to_string(),
content: long_content,
}];
let section = format_agents_md_section(&configs).unwrap();
// No cap: the full content is delivered verbatim, with no truncation marker.
assert!(
section.contains(&"A".repeat(5000)),
"full content must be preserved"
);
assert!(
!section.contains("truncated"),
"content must not be truncated"
);
}
// ── Feature 2: Workspace user AGENTS.md via read_agents_config ───
#[tokio::test]
async fn read_agents_config_includes_workspace_user_agents_md() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// Create user AGENTS.md
let user_dir = repo_root.join("x").join("testuser");
fs::create_dir_all(&user_dir).unwrap();
fs::write(
user_dir.join("AGENTS.md"),
"# User-specific instructions\nAlways use tabs.",
)
.unwrap();
// cwd = repo root (user dir is NOT in the walk path)
let configs = read_agents_config_with_options(
repo_root.to_str().unwrap(),
Some(&user_dir),
CompatConfig::default(),
)
.await;
let contents: Vec<&str> = configs.iter().map(|c| c.content.as_str()).collect();
assert!(
contents.iter().any(|c| c.contains("Always use tabs")),
"Workspace user AGENTS.md should be included, got: {contents:?}"
);
}
#[tokio::test]
async fn read_agents_config_workspace_user_dedup_when_cwd_inside_user_dir() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// User dir with AGENTS.md
let user_dir = repo_root.join("x").join("testuser");
fs::create_dir_all(&user_dir).unwrap();
fs::write(user_dir.join("AGENTS.md"), "# Dedup test instructions").unwrap();
// cwd IS the user dir — the walk already includes it
let configs = read_agents_config_with_options(
user_dir.to_str().unwrap(),
Some(&user_dir),
CompatConfig::default(),
)
.await;
// "Dedup test instructions" should appear exactly once
let count = configs
.iter()
.filter(|c| c.content.contains("Dedup test instructions"))
.count();
assert_eq!(
count, 1,
"User AGENTS.md should appear exactly once, got {count}"
);
}
#[tokio::test]
async fn read_agents_config_no_workspace_user_dir_no_user_agents_md() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// User dir with AGENTS.md (should NOT be found)
let user_dir = repo_root.join("x").join("ghost");
fs::create_dir_all(&user_dir).unwrap();
fs::write(user_dir.join("AGENTS.md"), "# Ghost instructions").unwrap();
// Pass None — simulates env vars not set
let configs = read_agents_config_with_options(
repo_root.to_str().unwrap(),
None,
CompatConfig::default(),
)
.await;
let has_ghost = configs
.iter()
.any(|c| c.content.contains("Ghost instructions"));
assert!(
!has_ghost,
"Without optional workspace user dir, ghost AGENTS.md should not be found"
);
}
/// Regression: running outside a git repo must not panic.
#[tokio::test]
async fn regression_no_panic_outside_git_repo() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("not_a_repo");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("AGENTS.md"), "# outside git").unwrap();
let configs =
read_agents_config_with_options(dir.to_str().unwrap(), None, CompatConfig::default())
.await;
assert!(configs.iter().any(|c| c.content.contains("outside git")));
}
#[tokio::test]
async fn read_agents_config_workspace_user_and_repo_root_both_found() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// Repo root AGENTS.md
fs::write(repo_root.join("AGENTS.md"), "# XYZZY_REPO_ROOT_MARKER").unwrap();
// User AGENTS.md
let user_dir = repo_root.join("x").join("testuser");
fs::create_dir_all(&user_dir).unwrap();
fs::write(user_dir.join("AGENTS.md"), "# XYZZY_USER_SPECIFIC_MARKER").unwrap();
let configs = read_agents_config_with_options(
repo_root.to_str().unwrap(),
Some(&user_dir),
CompatConfig::default(),
)
.await;
// Both should be found
let has_repo = configs
.iter()
.any(|c| c.content.contains("XYZZY_REPO_ROOT_MARKER"));
let has_user = configs
.iter()
.any(|c| c.content.contains("XYZZY_USER_SPECIFIC_MARKER"));
assert!(
has_repo,
"Repo root AGENTS.md not found in: {:?}",
configs
.iter()
.map(|c| (&c.file_path, &c.content))
.collect::<Vec<_>>()
);
assert!(
has_user,
"User AGENTS.md not found in: {:?}",
configs
.iter()
.map(|c| (&c.file_path, &c.content))
.collect::<Vec<_>>()
);
}
#[test]
fn render_strips_frontmatter_from_rules_files() {
let configs = vec![AgentConfigFile {
file_name: "style.md".to_string(),
file_path: "/repo/.claude/rules/style.md".to_string(),
content: "---\nglobs: [\"*.rs\"]\n---\n# Use snake_case".to_string(),
}];
let section = format_agents_md_section(&configs).unwrap();
assert!(section.contains("# Use snake_case"));
assert!(!section.contains("globs:"));
}
// ── .claude/CLAUDE.md integration tests ─────────────────────────
#[tokio::test]
async fn read_agents_config_discovers_claude_subdir_claude_md() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// .claude/CLAUDE.md at repo root
let claude_dir = repo_root.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("CLAUDE.md"), "# XYZZY_CLAUDE_SUBDIR_MARKER").unwrap();
let configs = read_agents_config_with_options(
repo_root.to_str().unwrap(),
None,
CompatConfig::default(),
)
.await;
assert!(
configs
.iter()
.any(|c| c.content.contains("XYZZY_CLAUDE_SUBDIR_MARKER")),
".claude/CLAUDE.md should be discovered, got: {:?}",
configs
.iter()
.map(|c| (&c.file_path, &c.content))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn read_agents_config_claude_subdir_and_direct_both_found() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// Direct CLAUDE.md
fs::write(repo_root.join("CLAUDE.md"), "# XYZZY_DIRECT_MARKER").unwrap();
// .claude/CLAUDE.md
let claude_dir = repo_root.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("CLAUDE.md"), "# XYZZY_SUBDIR_MARKER").unwrap();
let configs = read_agents_config_with_options(
repo_root.to_str().unwrap(),
None,
CompatConfig::default(),
)
.await;
let has_direct = configs
.iter()
.any(|c| c.content.contains("XYZZY_DIRECT_MARKER"));
let has_subdir = configs
.iter()
.any(|c| c.content.contains("XYZZY_SUBDIR_MARKER"));
assert!(has_direct, "Direct CLAUDE.md should be found");
assert!(has_subdir, ".claude/CLAUDE.md should be found");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
//! Gitignore integration for AGENTS.md and skills discovery.
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::path::{Path, PathBuf};
pub fn build_gitignore(repo_root: Option<&Path>) -> Option<Gitignore> {
// No repo root → no gitignore rules to apply.
let root = repo_root?;
let mut builder = GitignoreBuilder::new(root);
let repo_gitignore = root.join(".gitignore");
if repo_gitignore.exists() {
let _ = builder.add(&repo_gitignore);
}
if let Some(global_path) = get_global_gitignore_path()
&& global_path.exists()
{
let _ = builder.add(&global_path);
}
builder.build().ok()
}
pub fn is_ignored(path: &Path, gitignore: Option<&Gitignore>, repo_root: Option<&Path>) -> bool {
let Some(gi) = gitignore else {
return false;
};
kigi_tools::gitignore::is_ignored(gi, path, repo_root)
}
fn get_global_gitignore_path() -> Option<PathBuf> {
git2::Config::open_default()
.ok()
.and_then(|cfg| cfg.get_path("core.excludesFile").ok())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".gitignore")))
}
@@ -0,0 +1,9 @@
//! System prompt assembly — template rendering, AGENTS.md, and skills.
pub mod agents_md;
pub mod context;
pub mod ignore;
pub mod skills;
pub mod subagent_prompts;
pub mod template;
pub mod user_message;
pub mod workspace_user;
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
//! System prompts for built-in subagent profiles.
//!
//!
//! ## Tool name resolution
//!
//! All tool names in these prompts use the `${{ tools.by_kind.* }}` template
//! syntax from the `TemplateRenderer`. When the prompt is rendered via
//! `PromptContext::render()` → `ToolBridge::render_prompt()`, MiniJinja
//! resolves each variable to the current session's tool names.
//!
//! This means:
//! - Tool names are NEVER hardcoded — they adapt to name overrides and
//! alternate tool namespaces
//! - If a tool kind is absent from the renderer's context, MiniJinja
//! resolves it to an empty string (templates can also use
//! `${%- if tools.by_kind.X %}` conditionals to hide entire sections)
//!
//! Tool-kind mapping (common names → ToolKind):
//! Read → `${{ tools.by_kind.read }}`
//! Write/Edit → `${{ tools.by_kind.edit }}`
//! Glob → `${{ tools.by_kind.list }}`
//! Grep → `${{ tools.by_kind.search }}`
//! Bash → `${{ tools.by_kind.execute }}`
//! WebSearch → `${{ tools.by_kind.web_search }}`
pub use kigi_tool_types::{EXPLORE_PROMPT, GENERAL_PURPOSE_PROMPT, PLAN_PROMPT};
@@ -0,0 +1,880 @@
//! System prompt template source and constants.
//!
//! Templates are XOR-obfuscated by `scripts/encrypt_templates.py` (obfuscation,
//! not security — seeds live in-repo) so they don't appear as obvious plaintext
//! in `strings` output. They are decrypted on demand and the returned
//! `Zeroizing<String>` wipes the plaintext from memory on drop.
use zeroize::Zeroizing;
// Encrypted template bytes (pre-generated by scripts/encrypt_templates.py).
#[path = "prompt_encrypted.rs"]
mod prompt_encrypted;
use prompt_encrypted::*;
/// Decrypt XOR-obfuscated template data (mirrors `scripts/encrypt_templates.py::xor_encrypt`).
/// Obfuscation only — not a security boundary.
fn decrypt(data: &[u8], seed: u8) -> Zeroizing<String> {
let bytes: Vec<u8> = data
.iter()
.enumerate()
.map(|(i, &b)| b ^ seed.wrapping_add(i as u8))
.collect();
Zeroizing::new(String::from_utf8(bytes).expect(
"prompt template decryption produced invalid UTF-8 — \
prompt_encrypted.rs is likely stale; run: \
python3 scripts/encrypt_templates.py",
))
}
/// The base prompt template (decrypted fresh; zeroed on drop).
pub(crate) fn base_template() -> Zeroizing<String> {
decrypt(BASE_PROMPT_ENC, PROMPT_SEEDS[0])
}
/// The base prompt template source, exposed for `grok prompt --section template`.
pub fn base_template_source() -> Zeroizing<String> {
base_template()
}
pub(crate) fn apply_patch_template() -> Zeroizing<String> {
decrypt(CODEX_PROMPT_ENC, PROMPT_SEEDS[1])
}
/// Apply-patch prompt template source, exposed for `grok prompt --section apply-patch-template`.
pub fn apply_patch_template_source() -> Zeroizing<String> {
apply_patch_template()
}
/// The subagent-specific base template (decrypted fresh; zeroed on drop).
pub(crate) fn subagent_template() -> Zeroizing<String> {
decrypt(SUBAGENT_PROMPT_ENC, PROMPT_SEEDS[2])
}
/// The compact system prompt used after conversation compaction.
pub const COMPACT_SYSTEM_PROMPT: &str = "You are an AI coding agent. You operate in a workspace with a provided codebase.\n\n\
Your main goal is to complete the user's request, denoted within the <user_query> tag.";
#[cfg(test)]
mod tests {
use super::*;
use kigi_tools::types::template_renderer::TemplateRenderer;
use kigi_tools::types::tool::ToolKind;
use std::collections::HashMap;
/// Verify the pre-generated encrypted file matches the current template sources.
/// If this fails, run: `python3 scripts/encrypt_templates.py`
#[test]
fn test_encrypted_templates_not_stale() {
fn xor_encrypt(data: &[u8], seed: u8) -> Vec<u8> {
data.iter()
.enumerate()
.map(|(i, &b)| b ^ seed.wrapping_add(i as u8))
.collect()
}
let base_raw = include_bytes!("../../templates/prompt.md");
let apply_patch_raw = include_bytes!("../../templates/apply_patch_prompt.md");
let subagent_raw = include_bytes!("../../templates/subagent_prompt.md");
assert_eq!(
BASE_PROMPT_ENC,
&xor_encrypt(base_raw, PROMPT_SEEDS[0]),
"prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
);
assert_eq!(
CODEX_PROMPT_ENC,
&xor_encrypt(apply_patch_raw, PROMPT_SEEDS[1]),
"apply_patch_prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
);
assert_eq!(
SUBAGENT_PROMPT_ENC,
&xor_encrypt(subagent_raw, PROMPT_SEEDS[2]),
"subagent_prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
);
}
/// Build a TemplateRenderer with the standard grok-build tool kinds.
fn default_renderer() -> TemplateRenderer {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "read_file"),
(ToolKind::Edit, "search_replace"),
(ToolKind::Execute, "run_terminal_command"),
(ToolKind::Search, "grep"),
(ToolKind::List, "list_dir"),
(ToolKind::Plan, "todo_write"),
(ToolKind::Skill, "skill"),
(
ToolKind::BackgroundTaskAction,
"get_command_or_subagent_output",
),
(ToolKind::KillTaskAction, "kill_command_or_subagent"),
(ToolKind::WebSearch, "web_search"),
]
.into_iter()
.map(|(k, v)| (k, v.to_string()))
.collect();
TemplateRenderer::new(tools, HashMap::new())
}
fn default_placeholders() -> serde_json::Value {
serde_json::json!({
"os_name": "macos",
"shell_path": "/bin/zsh",
"working_directory": "/tmp/test",
"current_date": "2025-01-15",
"memory_enabled": false,
"is_non_interactive": false,
"system_prompt_label": crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL,
})
}
fn render_base(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
let tmpl = base_template();
renderer
.render_with_extra(&tmpl, placeholders)
.expect("base template render failed")
}
fn render_subagent(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
let tmpl = subagent_template();
renderer
.render_with_extra(&tmpl, placeholders)
.expect("subagent template render failed")
}
fn render_apply_patch(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
let tmpl = apply_patch_template();
renderer
.render_with_extra(&tmpl, placeholders)
.expect("codex template render failed")
}
// ── Variable substitution ───────────────────────────────────────
#[test]
fn test_variable_substitution_tool_kind() {
let r = default_renderer();
let p = default_placeholders();
let result = r
.render_with_extra("Use ${{ tools.by_kind.read }} to read files.", &p)
.unwrap();
assert_eq!(result, "Use read_file to read files.");
}
#[test]
fn test_variable_substitution_agent_fields() {
let r = default_renderer();
let p = default_placeholders();
let result = r
.render_with_extra("OS: ${{ os_name }}, Shell: ${{ shell_path }}", &p)
.unwrap();
assert_eq!(result, "OS: macos, Shell: /bin/zsh");
}
// ── Conditionals ────────────────────────────────────────────────
#[test]
fn test_conditional_tool_present() {
let r = default_renderer();
let p = default_placeholders();
let result = r
.render_with_extra("${%- if tools.by_kind.plan %}show${%- endif %}", &p)
.unwrap();
assert_eq!(result, "show");
}
#[test]
fn test_conditional_tool_absent() {
// Renderer without plan tool
let tools: HashMap<ToolKind, String> = [(ToolKind::Read, "read_file".to_string())].into();
let r = TemplateRenderer::new(tools, HashMap::new());
let p = default_placeholders();
let result = r
.render_with_extra("${%- if tools.by_kind.plan %}show${%- endif %}", &p)
.unwrap();
assert_eq!(result, "");
}
#[test]
fn test_literal_braces_pass_through() {
let r = default_renderer();
let p = default_placeholders();
let result = r
.render_with_extra("Use {{ literal_braces }} in prose.", &p)
.unwrap();
assert_eq!(result, "Use {{ literal_braces }} in prose.");
}
// ── Tool name overrides ─────────────────────────────────────────
#[test]
fn test_tool_name_override() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "view_file".to_string()),
(ToolKind::Edit, "Edit".to_string()),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let p = default_placeholders();
let result = r
.render_with_extra(
"Use ${{ tools.by_kind.read }} and ${{ tools.by_kind.edit }}.",
&p,
)
.unwrap();
assert_eq!(result, "Use view_file and Edit.");
}
// ── Base template rendering ─────────────────────────────────────
#[test]
fn test_base_template_renders() {
let prompt = render_base(&default_renderer(), &default_placeholders());
assert!(prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL));
assert!(prompt.contains("user_query"));
}
#[test]
fn test_base_template_contains_resolved_tool_names() {
let prompt = render_base(&default_renderer(), &default_placeholders());
// The minimal prompt only resolves the read/edit tool names, inside
// <tool_calling>. (todo_write / run_terminal_command lived in sections
// that the trimmed prompt no longer renders.)
assert!(prompt.contains("read_file"), "Should contain 'read_file'");
assert!(
prompt.contains("search_replace"),
"Should contain 'search_replace'"
);
assert!(!prompt.contains("${{"), "No unresolved template variables");
assert!(!prompt.contains("${%"), "No unresolved template blocks");
}
#[test]
fn test_base_template_with_overridden_tool_names() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "view_file".to_string()),
(ToolKind::Edit, "edit".to_string()),
(ToolKind::Execute, "run_terminal_cmd".to_string()),
(ToolKind::Search, "grep".to_string()),
(ToolKind::Plan, "todo_write".to_string()),
(
ToolKind::BackgroundTaskAction,
"get_task_output".to_string(),
),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
prompt.contains("`view_file`"),
"Should use overridden 'view_file'"
);
assert!(prompt.contains("`edit`"), "Should use overridden 'edit'");
assert!(
!prompt.contains("`read_file`"),
"Should NOT contain canonical 'read_file'"
);
}
#[test]
fn test_base_template_plan_absent_omits_task_management() {
// Renderer without Plan tool
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "read_file".to_string()),
(ToolKind::Execute, "run_terminal_cmd".to_string()),
(
ToolKind::BackgroundTaskAction,
"get_task_output".to_string(),
),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
!prompt.contains("Task Management"),
"Task Management section should be omitted"
);
}
#[test]
fn test_base_template_execute_absent_omits_background_tasks() {
// Renderer without Execute tool
let tools: HashMap<ToolKind, String> = [(ToolKind::Plan, "todo_write".to_string())].into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
!prompt.contains("background_tasks"),
"background_tasks section should be omitted"
);
}
#[test]
fn test_monitor_tool_renders_watch_section() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Execute, "run_command".to_string()),
(ToolKind::BackgroundTaskAction, "get_output".to_string()),
(ToolKind::KillTaskAction, "kill_task".to_string()),
(ToolKind::Monitor, "monitor".to_string()),
]
.into_iter()
.collect();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
prompt.contains("For watch processes"),
"monitor section should render when Monitor tool is present"
);
assert!(
prompt.contains("streams each stdout line back as a chat notification"),
"monitor section should describe streaming stdout as notifications"
);
assert!(
prompt.contains("Use the `monitor` tool"),
"monitor section should resolve the Monitor tool name"
);
}
#[test]
fn test_no_monitor_tool_omits_watch_section() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Execute, "run_command".to_string()),
(ToolKind::BackgroundTaskAction, "get_output".to_string()),
(ToolKind::KillTaskAction, "kill_task".to_string()),
]
.into_iter()
.collect();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
!prompt.contains("For watch processes"),
"monitor section should NOT render without Monitor tool"
);
assert!(
!prompt.contains("<background_tasks>"),
"background_tasks section is gated on the Monitor tool and is omitted without it"
);
}
// ── Required sections regression ────────────────────────────────
#[test]
fn test_base_template_contains_required_sections() {
let p = default_placeholders();
let prompt = render_base(&default_renderer(), &p);
assert!(
prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL),
"Must contain agent identity"
);
assert!(
prompt.contains("user_query"),
"Must reference user_query tag"
);
}
#[test]
fn test_compact_prompt_matches_expected() {
assert_eq!(
COMPACT_SYSTEM_PROMPT,
"You are an AI coding agent. You operate in a workspace with a provided codebase.\n\n\
Your main goal is to complete the user's request, denoted within the <user_query> tag.",
);
}
// ── Mid-session mode switching ──────────────────────────────────
#[test]
fn test_mid_session_switch_concise_to_full() {
let compact = COMPACT_SYSTEM_PROMPT;
assert!(!compact.contains("read_file"), "Compact has no tool names");
assert!(
!compact.contains("<tool_calling>"),
"Compact has no tool section"
);
let full = render_base(&default_renderer(), &default_placeholders());
assert!(
full.contains("<tool_calling>"),
"Full prompt has tool section"
);
assert!(full.contains("read_file"), "Full prompt has read_file");
assert!(
full.contains("search_replace"),
"Full prompt has search_replace"
);
}
#[test]
fn test_mid_session_switch_preserves_tool_overrides() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "view".to_string()),
(ToolKind::Edit, "edit".to_string()),
(ToolKind::Execute, "run_terminal_cmd".to_string()),
(ToolKind::Plan, "todo_write".to_string()),
(
ToolKind::BackgroundTaskAction,
"get_task_output".to_string(),
),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(prompt.contains("`edit`"), "Should use overridden 'edit'");
assert!(prompt.contains("`view`"), "Should use overridden 'view'");
assert!(
!prompt.contains("`read_file`"),
"Should not contain original 'read_file'"
);
assert!(
!prompt.contains("`search_replace`"),
"Should not contain original 'search_replace'"
);
}
// ── Determinism ─────────────────────────────────────────────────
#[test]
fn test_prompt_deterministic_across_renders() {
let r = default_renderer();
let p = default_placeholders();
let a = render_base(&r, &p);
let b = render_base(&r, &p);
assert_eq!(a, b, "Prompt rendering must be deterministic");
}
#[test]
fn test_full_mode_deterministic() {
let r = default_renderer();
let p = default_placeholders();
let body = "Agent: ${{ tools.by_kind.read }}, OS: ${{ os_name }}";
let a = r.render_with_extra(body, &p).unwrap();
let b = r.render_with_extra(body, &p).unwrap();
assert_eq!(a, b, "Full mode rendering must be deterministic");
}
// ── Disabled tools ──────────────────────────────────────────────
#[test]
fn test_disabled_tools_omit_sections() {
// No plan, no execute
let tools: HashMap<ToolKind, String> = [(ToolKind::Read, "read_file".to_string())].into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
!prompt.contains("Task Management"),
"Task Management must be omitted"
);
assert!(
!prompt.contains("background_tasks"),
"background_tasks must be omitted"
);
}
// ── Memory section ──────────────────────────────────────────────
#[test]
fn test_memory_enabled_does_not_render_memory_section() {
// The <memory> section was removed from the minimal base prompt.
// Even when the memory tools are registered AND memory_enabled=true,
// the trimmed template must not render a memory section. (Complements
// test_memory_disabled_omits_memory_section, which covers the default.)
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "read_file".to_string()),
(ToolKind::MemorySearch, "memory_search".to_string()),
(ToolKind::MemoryGet, "memory_get".to_string()),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let mut p = default_placeholders();
p["memory_enabled"] = serde_json::json!(true);
let prompt = render_base(&r, &p);
assert!(
!prompt.contains("<memory>"),
"Memory section was removed from the minimal prompt"
);
assert!(
!prompt.contains("### Memory Management"),
"Memory Management section was removed from the minimal prompt"
);
assert!(
!prompt.contains("memory_search"),
"memory tool names must not appear once the memory section is gone"
);
assert!(
!prompt.contains("memory_get"),
"memory tool names must not appear once the memory section is gone"
);
}
#[test]
fn test_memory_disabled_omits_memory_section() {
let prompt = render_base(&default_renderer(), &default_placeholders());
assert!(
!prompt.contains("<memory>"),
"Memory section must be omitted"
);
}
// ── Web search disabled ─────────────────────────────────────────
#[test]
fn test_web_search_disabled_renders_without_crash() {
// No Fetch tool
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "read_file".to_string()),
(ToolKind::Plan, "todo_write".to_string()),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let tmpl = base_template();
let result = r.render_with_extra(&tmpl, &default_placeholders());
assert!(
result.is_ok(),
"Must render without crash: {:?}",
result.err()
);
}
// ── Apply-patch template rendering ───────────────────────────────────
#[test]
fn test_apply_patch_template_renders() {
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
assert!(prompt.contains("coding agent"));
}
#[test]
fn test_apply_patch_template_contains_resolved_tool_names() {
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
assert!(prompt.contains("todo_write"), "Should contain 'todo_write'");
// apply_patch is hardcoded, not resolved via ${{ tools.by_kind.edit }}
assert!(
prompt.contains("apply_patch"),
"Should contain hardcoded 'apply_patch'"
);
assert!(!prompt.contains("${{"), "No unresolved template variables");
assert!(!prompt.contains("${%"), "No unresolved template blocks");
}
#[test]
fn test_apply_patch_template_plan_absent_omits_planning() {
// Renderer without Plan tool
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "read_file".to_string()),
(ToolKind::Edit, "search_replace".to_string()),
(ToolKind::Execute, "run_terminal_cmd".to_string()),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_apply_patch(&r, &default_placeholders());
assert!(
!prompt.contains("## Planning"),
"Planning section should be omitted when plan tool absent"
);
assert!(
!prompt.contains("update_plan"),
"update_plan references should be omitted"
);
}
#[test]
fn test_apply_patch_template_plan_present_includes_planning() {
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
assert!(
prompt.contains("## Planning"),
"Planning section should be present when plan tool exists"
);
}
#[test]
fn test_apply_patch_template_with_overridden_tool_names() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "view_file".to_string()),
(ToolKind::Edit, "some_other_edit".to_string()),
(ToolKind::Execute, "run_terminal_cmd".to_string()),
(ToolKind::Plan, "update_plan".to_string()),
(
ToolKind::BackgroundTaskAction,
"get_task_output".to_string(),
),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_apply_patch(&r, &default_placeholders());
// apply_patch is hardcoded — NOT affected by Edit tool override
assert!(
prompt.contains("`apply_patch`"),
"apply_patch must remain hardcoded regardless of edit override"
);
assert!(
!prompt.contains("some_other_edit"),
"Edit override must NOT leak into apply-patch prompt"
);
// Plan tool IS resolved via template
assert!(
prompt.contains("`update_plan`"),
"Should use overridden 'update_plan'"
);
}
#[test]
fn test_apply_patch_template_deterministic_across_renders() {
let r = default_renderer();
let p = default_placeholders();
let a = render_apply_patch(&r, &p);
let b = render_apply_patch(&r, &p);
assert_eq!(a, b, "Apply-patch template rendering must be deterministic");
}
#[test]
fn test_subagent_template_deterministic_across_renders() {
let r = default_renderer();
let p = default_placeholders();
let a = render_subagent(&r, &p);
let b = render_subagent(&r, &p);
assert_eq!(a, b, "Subagent template rendering must be deterministic");
}
// ── Task completion discipline ─────────────────────────────────
//
// The `<task_completion_discipline>` block was removed from both
// base and subagent templates. These tests pin the deletion so the
// block doesn't accidentally come back, and so the runtime TodoGate
// doesn't start firing reminders that reference a non-existent
// block.
#[test]
fn task_completion_discipline_block_is_not_rendered() {
let prompt = render_base(&default_renderer(), &default_placeholders());
assert!(
!prompt.contains("<task_completion_discipline>"),
"discipline block was removed from the base template"
);
let subagent = render_subagent(&default_renderer(), &default_placeholders());
assert!(
!subagent.contains("<task_completion_discipline>"),
"discipline block was removed from the subagent template"
);
}
/// Soft byte ceiling shared by both prompt-size budget tests.
/// Forward-budget guard against runaway growth, not a tight target.
const PROMPT_SIZE_SOFT_CEILING_BYTES: usize = 16384;
fn assert_template_size_under(prompt: &str, label: &str) {
assert!(
prompt.len() < PROMPT_SIZE_SOFT_CEILING_BYTES,
"{label} prompt is {} bytes, exceeding soft ceiling of {} bytes",
prompt.len(),
PROMPT_SIZE_SOFT_CEILING_BYTES,
);
}
#[test]
fn test_base_template_size_budget() {
let prompt = render_base(&default_renderer(), &default_placeholders());
assert_template_size_under(&prompt, "base");
}
#[test]
fn test_subagent_template_size_budget() {
let prompt = render_subagent(&default_renderer(), &default_placeholders());
assert_template_size_under(&prompt, "subagent");
}
// ── Guard invariant ─────────────────────────────────────────────
// Every `${{ tools.by_kind.X }}` must sit inside a `${%- if ... %}`
// whose condition requires X (contains `tools.by_kind.X` at a word
// boundary, with no top-level ` or `). If violated, X could render
// as empty string at runtime.
fn word_bounded(hay: &str, needle: &str) -> bool {
let mut s = 0;
while let Some(i) = hay[s..].find(needle) {
let end = s + i + needle.len();
match hay[end..].chars().next() {
None => return true,
Some(c) if !(c.is_alphanumeric() || c == '_') => return true,
_ => s += i + 1,
}
}
false
}
fn guarantees(cond: &str, kind: &str) -> bool {
if word_bounded(cond, &format!("tools.by_kind.{kind}")) && !cond.contains(" or ") {
return true;
}
false
}
fn assert_guards(template: &str, label: &str) {
let bytes = template.as_bytes();
let mut stack: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let mut i = 0;
while i + 2 < bytes.len() {
let three = &bytes[i..i + 3];
if three == b"${%" {
let end = bytes[i + 3..]
.windows(2)
.position(|w| w == b"%}")
.map(|e| i + 3 + e + 2)
.unwrap_or(bytes.len());
let body = std::str::from_utf8(&bytes[i + 3..end - 2])
.unwrap()
.trim_matches(['-', ' ']);
if let Some(c) = body.strip_prefix("if ") {
stack.push(c.trim().into());
} else if let Some(c) = body.strip_prefix("elif ") {
stack.pop();
stack.push(c.trim().into());
} else if body == "else" {
stack.pop();
stack.push("<else>".into());
} else if body == "endif" {
stack.pop();
}
i = end;
} else if three == b"${{" {
let end = bytes[i + 3..]
.windows(2)
.position(|w| w == b"}}")
.map(|e| i + 3 + e + 2)
.unwrap_or(bytes.len());
let body = std::str::from_utf8(&bytes[i + 3..end - 2]).unwrap().trim();
// search_tool and use_tool are always built-in, so they
// never need a guard.
const ALWAYS_BUILTIN: &[&str] = &["search_tool", "use_tool"];
if let Some(kind) = body.strip_prefix("tools.by_kind.")
&& kind.chars().all(|c| c.is_alphanumeric() || c == '_')
&& !ALWAYS_BUILTIN.contains(&kind)
&& !stack.iter().any(|c| guarantees(c, kind))
{
let line = template[..i].lines().count() + 1;
errors.push(format!(
"{label}:{line}: unguarded `${{{{ tools.by_kind.{kind} }}}}` (stack: {stack:?})"
));
}
i = end;
} else {
i += 1;
}
}
assert!(errors.is_empty(), "\n {}", errors.join("\n "));
}
#[test]
fn test_template_vars_are_always_guarded() {
assert_guards(&base_template(), "prompt.md");
assert_guards(&subagent_template(), "subagent_prompt.md");
assert_guards(&apply_patch_template(), "apply_patch_prompt.md");
}
// ── Combination sweep ───────────────────────────────────────────
// Belt-and-braces: renders the base template across tool-kind subsets
// and asserts no raw template tokens leak. The static guard test above
// is the authoritative check; this one just catches syntax drift.
// ── is_non_interactive gating ──────────────────────────────────
// Headless / SDK / stdio / generic-ACP sessions have no human typing
// into a TUI prompt, so the `! <command>` shell-prefix tip and the
// `<user_guide>` TUI pointer are noise. Those sections must drop out
// when `is_non_interactive=true` and remain when it's false.
#[test]
fn interactive_renders_shell_prefix_tip_and_user_guide() {
// The `! <command>` shell-prefix tip was removed from the minimal
// prompt. The <user_guide> block still renders for interactive
// sessions only, so that's what we assert here.
let mut p = default_placeholders();
p["is_non_interactive"] = serde_json::json!(false);
let prompt = render_base(&default_renderer(), &p);
assert!(
prompt.contains("<user_guide>"),
"interactive prompt must keep the <user_guide> block"
);
assert!(
prompt.contains("interactive CLI tool"),
"interactive prompt must declare interactive mode in the header"
);
assert!(
!prompt.contains("autonomous agent"),
"interactive prompt must NOT advertise non-interactive (autonomous) mode"
);
}
#[test]
fn non_interactive_suppresses_shell_prefix_tip_and_user_guide() {
let mut p = default_placeholders();
p["is_non_interactive"] = serde_json::json!(true);
let prompt = render_base(&default_renderer(), &p);
assert!(
!prompt.contains("`! <command>`"),
"non-interactive prompt must suppress the shell-prefix tip"
);
assert!(
!prompt.contains("<user_guide>"),
"non-interactive prompt must suppress the <user_guide> block"
);
assert!(
prompt.contains("autonomous agent"),
"non-interactive prompt must declare autonomous mode in the header"
);
assert!(
!prompt.contains("interactive CLI tool"),
"non-interactive prompt must NOT claim to be the interactive CLI"
);
// Sanity: rest of the template still renders.
assert!(prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL));
assert!(prompt.contains("user_query"));
}
#[test]
fn test_combination_sweep_no_unresolved_variables() {
let optional = [
ToolKind::Read,
ToolKind::Edit,
ToolKind::Execute,
ToolKind::Search,
ToolKind::List,
ToolKind::Plan,
ToolKind::Skill,
ToolKind::Task,
ToolKind::AskUser,
ToolKind::EnterPlan,
ToolKind::ExitPlan,
ToolKind::BackgroundTaskAction,
ToolKind::Monitor,
ToolKind::MemorySearch,
ToolKind::MemoryGet,
];
let mut subsets: Vec<Vec<ToolKind>> = vec![vec![], optional.to_vec()];
for i in 0..optional.len() {
subsets.push(vec![optional[i]]);
for j in (i + 1)..optional.len() {
subsets.push(vec![optional[i], optional[j]]);
}
}
for memory_enabled in [false, true] {
for subset in &subsets {
let tools: HashMap<ToolKind, String> = subset
.iter()
.map(|k| (*k, format!("{k:?}").to_lowercase()))
.collect();
let r = TemplateRenderer::new(tools, HashMap::new());
let mut p = default_placeholders();
p["memory_enabled"] = serde_json::json!(memory_enabled);
let rendered = r
.render_with_extra(&base_template(), &p)
.unwrap_or_else(|e| {
panic!("render failed: {subset:?} mem={memory_enabled}: {e:?}")
});
assert!(
!rendered.contains("${{") && !rendered.contains("${%"),
"unresolved token in render: {subset:?} mem={memory_enabled}",
);
}
}
}
}
@@ -0,0 +1,376 @@
//! Per-agent first-user-message rendering.
//!
//! Mirrors `prompt::context::PromptContext` but for the first user message
//! (the prefix that contains `<user_info>`, `<git_status>`, optional
//! workspace overview, optional rules / skills / MCP listings).
//!
//! `UserMessageTemplate` selects the rendering strategy:
//! - `Default` -- the legacy Grok Build prefix (built by the shell layer).
//! - `Custom` -- caller-supplied template string (MiniJinja, same delimiters
//! as the system prompt templates).
//!
//! The shell layer gathers session-scoped inputs (cwd, vcs status, rule
//! files, skill registry, MCP servers) and hands them to
//! `UserMessageContext::render`, which dispatches on `template`.
use crate::prompt::agents_md::AgentConfigFile;
use chrono::NaiveDate;
use kigi_tools::bridge::ToolBridge;
use kigi_tools::implementations::skills::types::SkillInfo;
use kigi_tools::types::skill_discovery_tracker::{XmlRenderMode, format_announcement_xml};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::PathBuf;
/// Date format for the `Today's date` field of the user-message preamble
/// (e.g. "Friday Apr 24, 2026"). Any format change is observable to the model.
pub const USER_MESSAGE_DATE_FORMAT: &str = "%A %b %-d, %Y";
/// Per-repo character cap applied to `vcs_status` at render time. The
/// `<git_status>` block has no token budget -- this character cap is the only
/// size control, and it is applied per repo at render, never at gather, so
/// other consumers of the raw status are unaffected.
pub const GIT_STATUS_CHARACTER_LIMIT: usize = 10_000;
/// Trim, drop-if-empty, and cap a VCS status string for the
/// `<git_status>` block.
///
/// Returns `None` when the trimmed status is empty (so the section is dropped
/// and no empty code fence is emitted), otherwise the status capped at
/// [`GIT_STATUS_CHARACTER_LIMIT`] -- snapped back to the last newline -- with
/// the `... (git status truncated)` marker appended.
fn normalize_git_status(status: &str) -> Option<String> {
let status = status.trim();
if status.is_empty() {
return None;
}
if status.len() <= GIT_STATUS_CHARACTER_LIMIT {
return Some(status.to_string());
}
let mut end = GIT_STATUS_CHARACTER_LIMIT;
while !status.is_char_boundary(end) {
end -= 1;
}
let mut truncated = &status[..end];
if let Some(nl) = truncated.rfind('\n')
&& nl > 0
{
truncated = &truncated[..nl];
}
Some(format!("{truncated}\n\n... (git status truncated)"))
}
/// Selects the first-user-message rendering strategy for an agent.
///
/// Built-in variants decrypt the underlying XOR-obfuscated template on demand
/// (obfuscation, not security). Decrypted bytes are zeroed on drop via
/// `Zeroizing`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UserMessageTemplate {
/// Legacy Grok Build prefix: `<user_info>` + optional `<git_status>`.
/// Built directly by the shell layer; this
/// renderer returns `None` for `Default` and the caller falls back to
/// its own legacy path.
#[default]
Default,
/// Caller-supplied MiniJinja template string.
Custom(String),
}
impl UserMessageTemplate {
pub fn is_cursor(&self) -> bool {
false
}
}
/// Backward-compatible deserialization: accepts both the new tagged format
/// (`"default"`, `{"custom": "..."}`) and a bare string (treated
/// as `Custom`).
impl<'de> Deserialize<'de> for UserMessageTemplate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = UserMessageTemplate;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(r#""default", "cursor", {"custom": "..."}, or a template string"#)
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
match v {
"default" => Ok(UserMessageTemplate::Default),
other => Ok(UserMessageTemplate::Custom(other.to_owned())),
}
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> Result<Self::Value, M::Error> {
match map.next_key::<String>()? {
Some(ref k) if k == "custom" => {
let val: String = map.next_value()?;
Ok(UserMessageTemplate::Custom(val))
}
Some(other) => Err(serde::de::Error::unknown_field(&other, &["custom"])),
None => Err(serde::de::Error::custom(r#"expected {"custom": "..."}"#)),
}
}
}
deserializer.deserialize_any(Visitor)
}
}
/// One discovered rule file (AGENTS.md / Claude.md / .kigi/rules/*.md).
///
/// Wire-compatible with `AgentConfigFile` -- this type exists so the
/// `UserMessageContext` does not depend on the AGENTS-discovery internals
/// beyond the path/content pair.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleEntry {
/// Absolute path of the file (used as the rule `name` attribute).
pub path: String,
/// Raw file body.
pub content: String,
}
impl From<AgentConfigFile> for RuleEntry {
fn from(f: AgentConfigFile) -> Self {
Self {
path: f.file_path,
content: f.content,
}
}
}
/// Connected MCP server metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerEntry {
pub name: String,
/// Free-form usage instructions a user provided when configuring the
/// server. Surfaced in the `serverUseInstructions` attribute.
pub server_use_instructions: Option<String>,
/// Absolute path to the per-server descriptor folder. Surfaced in
/// the `folderPath` attribute. Compatible models read tool
/// schemas from `<folder_path>/tools/<tool>.json` and resource
/// descriptors from `<folder_path>/resources/<resource>.json` before
/// calling `CallMcpTool`/`FetchMcpResource`. The session is
/// responsible for materializing the descriptor files at this path.
pub folder_path: Option<String>,
}
/// All inputs the templated first user message needs. The shell gathers
/// these once at session start (and again on compaction) and hands the
/// struct to `render`.
#[derive(Debug, Clone)]
pub struct UserMessageContext {
pub template: UserMessageTemplate,
/// Display path -- the path the model sees as the workspace.
pub workspace_path: PathBuf,
/// OS identifier surfaced as the `<user_info>` `OS Version:` value.
///
/// This is `"<kernel> <release>"` (e.g. `"darwin 24.6.0"`,
/// `"linux 6.5.0-..."`) -- not the OS family (`std::env::consts::OS`, e.g.
/// `"macos"`). Producers that don't have a uname-style string available may
/// pass `std::env::consts::OS` as a fallback; callers that need the full
/// string should use `kigi_shell::util::uname::os_kernel_and_release`
/// (or equivalent).
pub os_family: String,
/// `$SHELL` env, basename only -- e.g. "zsh", "bash".
pub shell: String,
/// Git/jj working-tree root, if any.
pub vcs_root: Option<PathBuf>,
/// Pre-fetched VCS status output (caller handles timeouts).
pub vcs_status: Option<String>,
/// Local date captured at session start (or compaction). Formatted
/// inside the renderer using [`USER_MESSAGE_DATE_FORMAT`] so the producer
/// cannot accidentally drift the model-facing date shape.
pub today_local: Option<NaiveDate>,
/// Per-workspace terminals folder, surfaced as
/// `Terminals folder: <path>` in the `<user_info>` block. The
/// shell tool persists each background command's output to a file
/// here (`<terminals_folder>/<numeric-shell-id>.txt`); the model uses
/// this path to read terminal state via the read tool. Optional --
/// when `None`, the line is omitted from the rendered preamble.
pub terminals_folder: Option<PathBuf>,
/// Workspace-scoped rule files (cwd / repo root / optional workspace user dir).
pub workspace_rules: Vec<RuleEntry>,
/// User-scoped rule files (~/.kigi/, ~/.claude/).
pub user_rules: Vec<RuleEntry>,
/// Skill registry snapshot (already deduped). Rendered through the
/// shared budget-tier renderer.
pub skills: Vec<SkillInfo>,
/// Optional listing budget in characters; defaults to the standard
/// 1%-of-context heuristic when None.
pub skill_listing_budget_chars: Option<usize>,
/// Connected MCP servers (alphabetical).
pub mcp_servers: Vec<McpServerEntry>,
/// Absolute path to the per-workspace MCP descriptor root
/// (`~/.kigi/projects/<encoded-cwd>/mcps`). Surfaced in
/// the `<mcp_file_system>` instructions so the model knows where
/// to discover tool/resource schemas. Required when `mcp_servers` is
/// non-empty; ignored otherwise.
pub mcps_root: Option<String>,
/// Client-facing name of the read tool (resolved from `TemplateRenderer`).
/// Used in the skill section's instructional text. Defaults to `"Read"`.
pub read_tool_name: String,
}
/// Typed placeholder bag handed to MiniJinja.
///
/// Field names here must match `${{ … }}` references in any caller-supplied
/// `Custom` template. Keeping this as a typed
/// struct -- rather than a free-form `serde_json::Value` -- means the set
/// of supported placeholders is greppable from one place, every nested
/// shape is enforced by `Serialize`, and rename refactors flow through
/// the compiler instead of silently producing empty strings at render
/// time.
#[derive(Debug, Clone, Serialize)]
struct UserMessagePlaceholders<'a> {
workspace_path: String,
os_family: &'a str,
shell: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
vcs_root: Option<String>,
/// Owned because the renderer caps/normalizes the raw status via
/// [`normalize_git_status`] before handing it to MiniJinja.
#[serde(skip_serializing_if = "Option::is_none")]
vcs_status: Option<String>,
/// Pre-formatted using [`USER_MESSAGE_DATE_FORMAT`]; `None` is rendered as
/// `null` so the `${% if today_local %}` guard in the template drops
/// the line entirely.
#[serde(skip_serializing_if = "Option::is_none")]
today_local: Option<String>,
/// Pre-rendered as a string so the template can `${% if terminals_folder %}`-guard.
#[serde(skip_serializing_if = "Option::is_none")]
terminals_folder: Option<String>,
has_rules: bool,
workspace_rules: &'a [RuleEntry],
user_rules: &'a [RuleEntry],
/// Pre-rendered budgeted `<agent_skill>` XML rows; the template
/// just substitutes this verbatim. See `render_skill_listing_xml` for
/// why the skill listing is special-cased.
skill_listing: String,
/// Client-facing name of the read tool, used in the skill section's
/// instructional text. Defaults to `"Read"`.
read_tool_name: String,
mcp_servers: &'a [McpServerEntry],
#[serde(skip_serializing_if = "Option::is_none")]
mcps_root: Option<&'a str>,
}
impl UserMessageContext {
/// Build placeholders for MiniJinja rendering.
fn placeholders(&self) -> UserMessagePlaceholders<'_> {
UserMessagePlaceholders {
workspace_path: self.workspace_path.to_string_lossy().into_owned(),
os_family: &self.os_family,
shell: &self.shell,
vcs_root: self
.vcs_root
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
vcs_status: self.vcs_status.as_deref().and_then(normalize_git_status),
today_local: self
.today_local
.map(|d| d.format(USER_MESSAGE_DATE_FORMAT).to_string()),
terminals_folder: self
.terminals_folder
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
has_rules: !self.workspace_rules.is_empty() || !self.user_rules.is_empty(),
workspace_rules: &self.workspace_rules,
user_rules: &self.user_rules,
skill_listing: self.render_skill_listing_xml().unwrap_or_default(),
read_tool_name: self.read_tool_name.clone(),
mcp_servers: &self.mcp_servers,
mcps_root: self.mcps_root.as_deref(),
}
}
/// Render the skill list as `<agent_skill>` XML rows.
pub fn render_skill_listing_xml(&self) -> Option<String> {
if self.skills.is_empty() {
return None;
}
let mode = if self.template.is_cursor() {
XmlRenderMode::Verbatim
} else {
XmlRenderMode::Budgeted {
budget_chars: self.skill_listing_budget_chars,
overflow_indicator: true,
}
};
let mut announced = HashSet::new();
format_announcement_xml(&self.skills, &mut announced, None, None, mode)
}
/// Render the first user message.
///
/// Returns `None` for `UserMessageTemplate::Default` -- the caller is
/// responsible for the legacy prefix path. `Custom` dispatches through
/// `ToolBridge::render_prompt` so MiniJinja
/// `${{ tools.by_kind.* }}` references resolve correctly.
pub async fn render(&self, bridge: &ToolBridge) -> Option<String> {
let placeholders = serde_json::to_value(self.placeholders())
.expect("UserMessagePlaceholders serializes infallibly");
let rendered = match &self.template {
UserMessageTemplate::Default => return None,
UserMessageTemplate::Custom(s) => bridge.render_prompt(s, &placeholders).await,
};
rendered.map(|s| s.trim_end().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn template_override_deserialize_strings() {
let v: UserMessageTemplate = serde_json::from_str(r#""default""#).unwrap();
assert_eq!(v, UserMessageTemplate::Default);
let v: UserMessageTemplate = serde_json::from_str(r#""my custom""#).unwrap();
assert_eq!(v, UserMessageTemplate::Custom("my custom".into()));
}
#[test]
fn template_override_deserialize_custom_map() {
let v: UserMessageTemplate =
serde_json::from_str(r#"{"custom": "my template body"}"#).unwrap();
assert_eq!(v, UserMessageTemplate::Custom("my template body".into()));
}
#[test]
fn template_override_round_trip() {
for original in [
UserMessageTemplate::Default,
UserMessageTemplate::Custom("body".into()),
] {
let json = serde_json::to_string(&original).unwrap();
let loaded: UserMessageTemplate = serde_json::from_str(&json).unwrap();
assert_eq!(original, loaded);
}
}
/// A status under the cap passes through unchanged (trim is a no-op for
/// real `git status --short --branch` output, which starts with `##`).
#[test]
fn normalize_git_status_passthrough_under_limit() {
let status = "## main...origin/main\n M src/app.rs";
assert_eq!(normalize_git_status(status).as_deref(), Some(status));
}
/// Empty / whitespace-only status -> `None` so the section is dropped and
/// no empty fence is emitted.
#[test]
fn normalize_git_status_drops_whitespace_only() {
assert_eq!(normalize_git_status(""), None);
assert_eq!(normalize_git_status(" \n\t "), None);
}
/// A status over the cap is truncated at the last newline before the limit
/// and carries the spec's truncation marker.
#[test]
fn normalize_git_status_truncates_over_limit() {
let mut status = String::from("## main...origin/main\n");
while status.len() <= GIT_STATUS_CHARACTER_LIMIT {
status.push_str(" M src/some/long/path/to/file.rs\n");
}
assert!(status.len() > GIT_STATUS_CHARACTER_LIMIT);
let out = normalize_git_status(&status).expect("non-empty status");
assert!(
out.ends_with("\n\n... (git status truncated)"),
"missing truncation marker: {out}"
);
let body = out
.strip_suffix("\n\n... (git status truncated)")
.expect("marker suffix");
assert!(
body.len() <= GIT_STATUS_CHARACTER_LIMIT,
"body {} exceeds cap {GIT_STATUS_CHARACTER_LIMIT}",
body.len()
);
assert!(status.starts_with(body), "body is not a clean prefix");
assert!(!body.ends_with('\n'), "body should be snapped to last line");
}
}
@@ -0,0 +1,162 @@
//! Optional multi-user workspace helpers for loading per-user agent config.
//!
//! When optional workspace root and user env vars are set and the resolved
//! directory exists, that path can contribute AGENTS.md / rules / skills
//! discovery. Unset env vars are a no-op (typical for standalone installs).
use std::path::PathBuf;
/// If optional workspace env vars are set, returns the user's config directory
/// when the resolved path exists on disk. Unset or missing paths yield `None`.
pub fn optional_workspace_user_dir() -> Option<PathBuf> {
let root = std::env::var("XAI_ROOT").ok()?;
let user = std::env::var("XAI_USER").ok()?;
resolve_workspace_user_dir(&root, &workspace_user_relpath(&user))
}
/// Map `$XAI_USER` to a path relative to the workspace root.
///
/// A bare username is nested one level under `x/` so it cannot collide with an
/// unrelated same-named directory at the workspace root. Values that already
/// contain a path separator are used as-is (explicit relative path).
fn workspace_user_relpath(user: &str) -> String {
if user.contains('/') || user.contains('\\') {
user.to_string()
} else {
format!("x/{user}")
}
}
/// Pure logic: join `root` with a relative `user` path and return it if the
/// directory exists on disk.
///
/// Returns `None` if either argument is empty or the resulting path is not
/// a directory.
///
/// Example: `resolve_workspace_user_dir("/workspace", "users/alice")`
/// → `Some("/workspace/users/alice")` if that directory exists.
pub fn resolve_workspace_user_dir(root: &str, user: &str) -> Option<PathBuf> {
if root.is_empty() || user.is_empty() {
return None;
}
let path = PathBuf::from(root).join(user);
path.is_dir().then_some(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
// ── resolve_workspace_user_dir (pure, no env vars) ───────────────
#[test]
fn resolve_returns_none_for_empty_root() {
assert!(resolve_workspace_user_dir("", "users/someone").is_none());
}
#[test]
fn resolve_returns_none_for_empty_user() {
let tmp = tempfile::tempdir().unwrap();
assert!(resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "").is_none());
}
#[test]
fn resolve_returns_none_for_both_empty() {
assert!(resolve_workspace_user_dir("", "").is_none());
}
#[test]
fn resolve_returns_none_when_dir_does_not_exist() {
let tmp = tempfile::tempdir().unwrap();
assert!(
resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "users/nonexistent").is_none()
);
}
#[test]
fn resolve_returns_path_when_dir_exists() {
let tmp = tempfile::tempdir().unwrap();
let user_dir = tmp.path().join("users").join("testuser");
fs::create_dir_all(&user_dir).unwrap();
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "users/testuser");
assert_eq!(result, Some(user_dir));
}
#[test]
fn resolve_handles_single_component_user() {
let tmp = tempfile::tempdir().unwrap();
let user_dir = tmp.path().join("alice");
fs::create_dir_all(&user_dir).unwrap();
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "alice");
assert_eq!(result, Some(user_dir));
}
#[test]
fn resolve_handles_deeply_nested_user() {
let tmp = tempfile::tempdir().unwrap();
let user_dir = tmp.path().join("org").join("team").join("user");
fs::create_dir_all(&user_dir).unwrap();
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "org/team/user");
assert_eq!(result, Some(user_dir));
}
#[test]
fn resolve_returns_none_when_path_is_file_not_dir() {
let tmp = tempfile::tempdir().unwrap();
let file_path = tmp.path().join("users").join("testuser");
fs::create_dir_all(file_path.parent().unwrap()).unwrap();
fs::write(&file_path, "not a directory").unwrap();
assert!(
resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "users/testuser").is_none()
);
}
#[test]
fn resolve_supports_nested_user_layout_path() {
let tmp = tempfile::tempdir().unwrap();
let user_dir = tmp.path().join("x").join("testuser");
fs::create_dir_all(&user_dir).unwrap();
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "x/testuser");
assert_eq!(result, Some(user_dir));
}
// ── workspace_user_relpath ───────────────────────────────────────
#[test]
fn bare_username_is_nested_under_x() {
assert_eq!(workspace_user_relpath("alice"), "x/alice");
assert_eq!(workspace_user_relpath("bob"), "x/bob");
}
#[test]
fn multi_segment_user_is_explicit_relative_path() {
assert_eq!(workspace_user_relpath("users/alice"), "users/alice");
assert_eq!(workspace_user_relpath(r"users\alice"), r"users\alice");
}
#[test]
fn bare_username_does_not_resolve_to_same_named_root_dir() {
// Prefer the nested layout even when a same-named directory exists at
// the workspace root.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("alice")).unwrap();
let user_dir = root.join("x").join("alice");
fs::create_dir_all(&user_dir).unwrap();
let rel = workspace_user_relpath("alice");
let resolved = resolve_workspace_user_dir(root.to_str().unwrap(), &rel);
assert_eq!(resolved, Some(user_dir));
assert_ne!(
resolved.as_deref(),
Some(root.join("alice").as_path()),
"must not resolve to a same-named directory at the workspace root"
);
}
}
+222
View File
@@ -0,0 +1,222 @@
//! Shared git-repo dir-chain primitive.
//!
//! One `git2` discovery + one cwd→root walk, reused across the many repo-local
//! config marker checks the folder-trust gate runs back-to-back. Lives in its
//! own module (rather than `discovery`) because it is a generic repo-walk
//! primitive consumed cross-crate by `kigi-workspace`, not agent-definition
//! discovery.
use std::path::{Path, PathBuf};
/// The git worktree root for `cwd` (if any) plus the directory chain from `cwd`
/// up to that root (inclusive, cwd-first), resolved with ONE `git2` discovery
/// and ONE upward walk.
///
/// The folder-trust gate's `repo_configs_present` probes a dozen repo-local
/// code-exec markers (`.mcp.json`, `.kigi/config.toml`, `.claude/settings.json`,
/// project plugin/agent dirs, …) back-to-back on the agent startup path. Each
/// marker walker used to run its own `discover` + cwd→root walk; sharing one
/// `RepoDirChain` collapses that to a single traversal (each redundant syscall
/// is taxed 10-100x on Windows, and on a non-git dir each `discover` walks to
/// the filesystem root). Both the gate and the real loaders consume the same
/// chain via `*_in` walker variants, so detection can't drift from loading.
///
/// The public cwd-taking delegators (`find_project_configs`,
/// `project_plugin_dirs`, `project_agent_dirs`, …) now resolve through this
/// chain too, so their non-gate callers (config watcher, reloader, the mcp/
/// config loaders, inspect, upload, mcp_doctor) gain the per-level canonicalize
/// below. That is deliberate: all those callers are cold (startup / file-change /
/// session-setup / manual commands), never per-keystroke, and the canonical stop
/// is strictly more correct.
///
/// Outside a git repo `git_root` is `None` and `dirs` is just `[cwd]`, matching
/// every walker's no-repo branch (probe `cwd` only).
#[derive(Debug, Clone)]
pub struct RepoDirChain {
/// Git worktree root (`workdir`), or `None` when `cwd` is not inside a repo.
pub git_root: Option<PathBuf>,
/// `cwd` up to and including `git_root`, cwd-first (`[cwd]` with no repo).
pub dirs: Vec<PathBuf>,
}
impl RepoDirChain {
/// Resolve the chain for `cwd`: ONE `git2` discovery + ONE upward walk.
pub fn resolve(cwd: &Path) -> Self {
let git_root = git2::Repository::discover(cwd)
.ok()
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()))
// Home-is-a-git-repo (dotfiles in $HOME): a discovery that walks up
// to $HOME must NOT treat the whole home subtree as one repo, or
// home-level `.kigi`/`.mcp.json`/plugins would look repo-local. Drop
// it so cwd is handled as no-repo (probe cwd only). Home is compared
// canonically to match the symlink handling in the walk below.
.filter(|root| !is_home_dir(root));
let mut dirs = Vec::new();
if let Some(ref root) = git_root {
// Canonicalize only for the stop test so a symlinked cwd/ancestor
// still halts AT the worktree root instead of over-walking to the
// filesystem root; pushed dirs keep their original spelling (callers
// `join` markers onto them, which resolve the same either way). The
// per-level canonicalize is required to stop at root through a
// symlinked ancestor while keeping raw spelling — do NOT reduce to a
// 2-call `starts_with` variant (it would mis-handle a mid-chain
// absolute symlink and reintroduce the over-walk).
let root_canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.clone());
let mut current = Some(cwd.to_path_buf());
while let Some(dir) = current {
let dir_canonical = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
let parent = dir.parent().map(|p| p.to_path_buf());
dirs.push(dir);
if dir_canonical == root_canonical {
break;
}
current = parent;
}
} else {
dirs.push(cwd.to_path_buf());
}
Self { git_root, dirs }
}
}
/// Whether `path` canonicalizes to the user's home directory. Local (not reused
/// from `kigi-workspace`, which depends on THIS crate) to keep the dep edge
/// one-way; backs the home-is-dotfiles guard in [`RepoDirChain::resolve`].
fn is_home_dir(path: &Path) -> bool {
let Some(home) = dirs::home_dir() else {
return false;
};
let canon = |p: &Path| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canon(path) == canon(&home)
}
/// Existing `<dir>/<subdir>` directories under each dir of a precomputed
/// cwd→git-root chain ([`RepoDirChain::dirs`]), in chain order (cwd-first, then
/// each `subdirs` entry in order). Shared body for the project plugin/agent dir
/// walkers so the byte-identical double-loop lives in one place.
pub(crate) fn existing_subdirs_along(chain_dirs: &[PathBuf], subdirs: &[&str]) -> Vec<PathBuf> {
let mut found = Vec::new();
for dir in chain_dirs {
for subdir in subdirs {
let candidate = dir.join(subdir);
if candidate.is_dir() {
found.push(candidate);
}
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
/// RAII guard: set an env var, restore the prior value (or unset) 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) },
}
}
}
#[test]
fn resolve_in_repo_yields_cwd_to_root_chain() {
// A git-init'd tmp with a 2-deep subdir: the chain is cwd→root inclusive,
// cwd-first, in the dirs' original spelling, and `git_root` is the root.
let tmp = tempfile::tempdir().unwrap();
git2::Repository::init(tmp.path()).unwrap();
let nested = tmp.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
let chain = RepoDirChain::resolve(&nested);
assert_eq!(
chain.dirs,
vec![
nested.clone(),
tmp.path().join("a"),
tmp.path().to_path_buf(),
]
);
// `git_root` is the canonical worktree root (git2's `workdir`); compare by
// canonical form so a `/tmp`→`/private/tmp` symlink doesn't fail the test.
let root = chain.git_root.expect("inside a repo");
assert_eq!(
dunce::canonicalize(&root).unwrap(),
dunce::canonicalize(tmp.path()).unwrap()
);
}
#[test]
fn resolve_outside_repo_is_cwd_only() {
// A non-git tmp: no discovery hit, so the chain is just `[cwd]` and there
// is no git root. Only assert the no-repo shape when the temp dir is
// genuinely outside any repo (a dev/CI checkout may place $TMPDIR inside
// a larger git worktree).
let tmp = tempfile::tempdir().unwrap();
let plain = tmp.path().join("plain");
std::fs::create_dir_all(&plain).unwrap();
if git2::Repository::discover(&plain).is_err() {
let chain = RepoDirChain::resolve(&plain);
assert_eq!(chain.dirs, vec![plain]);
assert_eq!(chain.git_root, None);
}
}
#[test]
#[serial(home_env)]
fn resolve_treats_home_git_repo_as_no_repo() {
// Home-is-a-git-repo (dotfiles in $HOME): discovery walks up to $HOME,
// but the guard drops that root so a subdir resolves as no-repo (probe
// cwd only) instead of spanning the whole home subtree. $HOME is guarded
// (dirs::home_dir reads it) and canonicalized to match the guard.
let tmp = tempfile::tempdir().unwrap();
let home = dunce::canonicalize(tmp.path()).unwrap();
git2::Repository::init(&home).unwrap();
let _home_guard = EnvVarGuard::set("HOME", &home);
let sub = home.join("proj");
std::fs::create_dir_all(&sub).unwrap();
let chain = RepoDirChain::resolve(&sub);
assert_eq!(chain.git_root, None, "a home-dir git root must be dropped");
assert_eq!(chain.dirs, vec![sub]);
}
#[test]
#[serial(home_env)]
fn resolve_keeps_non_home_git_root() {
// The guard is home-EXACT: a git root that is NOT $HOME still resolves
// normally (no over-trigger), so $HOME points at an unrelated dir here.
let home = tempfile::tempdir().unwrap();
let _home_guard = EnvVarGuard::set("HOME", home.path());
let repo = tempfile::tempdir().unwrap();
git2::Repository::init(repo.path()).unwrap();
let sub = repo.path().join("pkg");
std::fs::create_dir_all(&sub).unwrap();
let chain = RepoDirChain::resolve(&sub);
let root = chain.git_root.expect("a non-home git root must be kept");
assert_eq!(
dunce::canonicalize(&root).unwrap(),
dunce::canonicalize(repo.path()).unwrap()
);
}
}
@@ -0,0 +1,127 @@
//! Reminder policy — wraps kigi-tools reminder config.
/// Default per-prompt fire cap for the runtime turn-end TodoGate. Used
/// only as the default for `TodoGateConfig`; the runtime consumer reads
/// the live value from `ReminderPolicy.todo_gate.max_fires_per_prompt`,
/// so this constant is NOT a hardcoded cap.
pub const DEFAULT_TODO_GATE_MAX_FIRES: u32 = 2;
/// Session-level system reminder policy.
///
/// Controls whether system reminders are enabled and configures
/// the TodoNudge and TodoGate behavior.
#[derive(Debug, Clone)]
pub struct ReminderPolicy {
/// Whether system reminders are enabled at all.
pub enabled: bool,
/// Configuration for the periodic TodoWrite nudge reminder.
pub todo_nudge: TodoNudgeConfig,
/// Configuration for the runtime turn-end TodoGate.
pub todo_gate: TodoGateConfig,
}
impl Default for ReminderPolicy {
fn default() -> Self {
Self {
enabled: true,
todo_nudge: TodoNudgeConfig::default(),
todo_gate: TodoGateConfig::default(),
}
}
}
/// Configuration for the TodoWrite nudge reminder.
///
/// The system will remind the model to use `todo_write` when it
/// hasn't done so within a configurable number of turns.
#[derive(Debug, Clone)]
pub struct TodoNudgeConfig {
/// Whether the TodoNudge reminder is enabled.
pub enabled: bool,
/// Number of turns since last `todo_write` call before nudging.
pub turns_since_todo_write: u32,
/// Minimum turns between nudge reminders.
pub turns_between_reminders: u32,
}
impl Default for TodoNudgeConfig {
fn default() -> Self {
Self {
enabled: true,
turns_since_todo_write: 3,
turns_between_reminders: 5,
}
}
}
/// Configuration for the runtime turn-end TodoGate.
///
/// The gate inspects `TodoState` after every content-only assistant
/// message and forces another turn via `<system-reminder>` injection
/// if pending/unbacked-in-progress todos remain — see
/// `kigi-shell::session::acp_session::evaluate_todo_gate`.
///
/// **Disabled by default.** Operators opt in via the remote
/// `todo_gate_enabled = true` remote settings key, or via the
/// `--todo-gate` CLI flag (session-scoped force-enable, highest
/// precedence).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TodoGateConfig {
/// Whether the gate runs at all.
pub enabled: bool,
/// Hard cap on how many times the gate may fire per user prompt
/// before the next turn is allowed to end with `TurnOutcome::Completed`.
/// Bounds the worst-case extra inference cost.
pub max_fires_per_prompt: u32,
}
impl Default for TodoGateConfig {
fn default() -> Self {
Self {
enabled: false,
max_fires_per_prompt: DEFAULT_TODO_GATE_MAX_FIRES,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_todo_gate_is_disabled_with_const_cap() {
let cfg = TodoGateConfig::default();
assert!(!cfg.enabled, "TodoGate must be opt-in");
assert_eq!(cfg.max_fires_per_prompt, DEFAULT_TODO_GATE_MAX_FIRES);
assert_eq!(DEFAULT_TODO_GATE_MAX_FIRES, 2);
}
#[test]
fn reminder_policy_default_disables_gate_but_keeps_nudge_and_global_enabled() {
let policy = ReminderPolicy::default();
assert!(
policy.enabled,
"global system reminders stay enabled by default"
);
assert!(
!policy.todo_gate.enabled,
"TodoGate ships disabled; remote/local opt-in required"
);
assert_eq!(policy.todo_gate.max_fires_per_prompt, 2);
// The two reminder mechanisms are independent — flipping one
// must not change the other (regression guard).
assert!(policy.todo_nudge.enabled);
}
#[test]
fn todo_gate_enable_does_not_disturb_nudge() {
// Remote opt-in (or `[reminder.todo_gate] enabled = true` local
// config) flips the gate to on without touching the periodic
// TodoNudge as a side-effect.
let mut policy = ReminderPolicy::default();
policy.todo_gate.enabled = true;
assert!(policy.todo_gate.enabled);
assert!(policy.todo_nudge.enabled, "TodoNudge must stay enabled");
assert!(policy.enabled, "global enable must stay true");
}
}
+31
View File
@@ -0,0 +1,31 @@
const TARGET: &str = "xai_grok_instrumentation";
pub struct TimingGuard {
name: &'static str,
start: std::time::Instant,
}
impl TimingGuard {
pub fn new(name: &'static str) -> Self {
Self {
name,
start: std::time::Instant::now(),
}
}
}
impl Drop for TimingGuard {
fn drop(&mut self) {
let elapsed_us = self.start.elapsed().as_micros() as u64;
tracing::info!(
target: TARGET,
event = "timing",
name = self.name,
elapsed_us,
);
}
}
pub fn timer(name: &'static str) -> TimingGuard {
TimingGuard::new(name)
}