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:
@@ -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
@@ -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),
|
||||
}
|
||||
@@ -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(®istry_path) {
|
||||
Ok(content) => match serde_json::from_str::<InstallRegistry>(&content) {
|
||||
Ok(mut reg) => {
|
||||
reg.install_dir = install_dir;
|
||||
reg
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %registry_path.display(),
|
||||
error = %e,
|
||||
"failed to parse install registry; starting fresh"
|
||||
);
|
||||
Self::empty(install_dir)
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::empty(install_dir),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %registry_path.display(),
|
||||
error = %e,
|
||||
"failed to read install registry; starting fresh"
|
||||
);
|
||||
Self::empty(install_dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an empty registry for the given install directory.
|
||||
pub fn empty(install_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
version: 1,
|
||||
repos: HashMap::new(),
|
||||
install_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the registry to disk.
|
||||
pub fn save(&self) -> Result<(), InstallError> {
|
||||
self.save_atomic()
|
||||
}
|
||||
|
||||
pub fn save_atomic(&self) -> Result<(), InstallError> {
|
||||
std::fs::create_dir_all(&self.install_dir).map_err(|e| InstallError::Io {
|
||||
path: self.install_dir.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let registry_path = self.install_dir.join("registry.json");
|
||||
let content = serde_json::to_string_pretty(self).map_err(|e| InstallError::Json {
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
if std::env::var_os("KIGI_TEST_FAIL_REGISTRY_SAVE_AFTER_SERIALIZE").is_some() {
|
||||
return Err(InstallError::InstallFailed {
|
||||
detail: "test-injected registry save failure".into(),
|
||||
});
|
||||
}
|
||||
let temp_path = self.install_dir.join(format!(
|
||||
".registry.json.tmp-{}-{}",
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
));
|
||||
std::fs::write(&temp_path, content).map_err(|e| InstallError::Io {
|
||||
path: temp_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if let Err(e) = std::fs::rename(&temp_path, ®istry_path) {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(InstallError::Io {
|
||||
path: registry_path,
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a repo by its repo key.
|
||||
pub fn get_repo(&self, repo_key: &str) -> Option<&InstalledRepo> {
|
||||
self.repos.get(repo_key)
|
||||
}
|
||||
|
||||
/// Get a mutable reference to a repo by its repo key.
|
||||
pub fn get_repo_mut(&mut self, repo_key: &str) -> Option<&mut InstalledRepo> {
|
||||
self.repos.get_mut(repo_key)
|
||||
}
|
||||
|
||||
/// Find which repo a plugin belongs to.
|
||||
///
|
||||
/// Returns `(repo_key, repo, plugin)` if found.
|
||||
pub fn find_plugin(&self, plugin_name: &str) -> Option<(&str, &InstalledRepo, &RepoPlugin)> {
|
||||
for (repo_key, repo) in &self.repos {
|
||||
if let Some(plugin) = repo.plugins.get(plugin_name) {
|
||||
return Some((repo_key, repo, plugin));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn find_repo_key_by_plugin_root(
|
||||
&self,
|
||||
plugin_root: &Path,
|
||||
plugin_canonical_root: &Path,
|
||||
) -> Option<&str> {
|
||||
self.list().into_iter().find_map(|(repo_key, repo)| {
|
||||
repo.plugins.values().find_map(|plugin| {
|
||||
let installed_plugin_root = match plugin.subdir.as_deref() {
|
||||
Some(subdir) => repo.path.join(subdir),
|
||||
None => repo.path.clone(),
|
||||
};
|
||||
paths_match_plugin_root(&installed_plugin_root, plugin_root, plugin_canonical_root)
|
||||
.then_some(repo_key)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert a repo into the registry.
|
||||
pub fn insert(&mut self, repo_key: String, repo: InstalledRepo) {
|
||||
self.repos.insert(repo_key, repo);
|
||||
}
|
||||
|
||||
/// Remove a repo from the registry.
|
||||
pub fn remove(&mut self, repo_key: &str) -> Option<InstalledRepo> {
|
||||
self.repos.remove(repo_key)
|
||||
}
|
||||
|
||||
/// List all installed repos.
|
||||
pub fn list(&self) -> Vec<(&str, &InstalledRepo)> {
|
||||
let mut entries: Vec<_> = self.repos.iter().map(|(k, v)| (k.as_str(), v)).collect();
|
||||
entries.sort_by_key(|(k, _)| *k);
|
||||
entries
|
||||
}
|
||||
|
||||
/// Get the install directory path.
|
||||
pub fn install_dir(&self) -> &Path {
|
||||
&self.install_dir
|
||||
}
|
||||
|
||||
/// Resolve the install directory from config or default.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `[plugins].install_dir` from effective config (requirements > config > managed)
|
||||
/// 2. Default: `~/.kigi/installed-plugins/`
|
||||
pub fn resolve_install_dir() -> PathBuf {
|
||||
if let Some(dir) = Self::read_install_dir_from_config() {
|
||||
return dir;
|
||||
}
|
||||
|
||||
kigi_config::kigi_home().join(DEFAULT_INSTALL_DIR_NAME)
|
||||
}
|
||||
|
||||
/// Read `[plugins].install_dir` from the effective config
|
||||
/// (managed_config.toml merged under config.toml — user wins).
|
||||
fn read_install_dir_from_config() -> Option<PathBuf> {
|
||||
let root = kigi_config::load_effective_config_disk_only().ok()?;
|
||||
let value = root.get("plugins")?.get("install_dir")?.as_str()?;
|
||||
let expanded = if let Some(stripped) = value.strip_prefix("~/") {
|
||||
dirs::home_dir()?.join(stripped)
|
||||
} else {
|
||||
PathBuf::from(value)
|
||||
};
|
||||
Some(expanded)
|
||||
}
|
||||
|
||||
/// Generate a unique repo key from a source identifier.
|
||||
///
|
||||
/// Format: `<basename>-<hash8>` where hash8 = first 8 hex chars of
|
||||
/// SHA-256(normalized source).
|
||||
///
|
||||
/// Examples:
|
||||
/// - `https://github.com/org-a/tools` → `tools-a1b2c3d4`
|
||||
/// - `/Users/me/projects/my-plugin` → `my-plugin-e5f6g7h8`
|
||||
pub fn repo_key(source: &str) -> String {
|
||||
let basename = source
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches(".git")
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("plugin");
|
||||
|
||||
// Sanitize basename to kebab-case
|
||||
let sanitized: String = basename
|
||||
.to_ascii_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = sanitized.trim_matches('-');
|
||||
|
||||
// Hash the full source for uniqueness
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
source.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
let hash8 = format!("{:08x}", hash & 0xFFFFFFFF);
|
||||
|
||||
format!("{trimmed}-{hash8}")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum InstallError {
|
||||
#[error("I/O error on {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("JSON error: {detail}")]
|
||||
Json { detail: String },
|
||||
|
||||
#[error("plugin '{name}' not found in install registry")]
|
||||
PluginNotFound { name: String },
|
||||
|
||||
#[error("repo '{key}' already installed")]
|
||||
AlreadyInstalled { key: String },
|
||||
|
||||
#[error("SHA verification failed: expected {expected}, got {actual}")]
|
||||
ShaMismatch { expected: String, actual: String },
|
||||
|
||||
#[error("install failed: {detail}")]
|
||||
InstallFailed { detail: String },
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn repo_key_from_https_url() {
|
||||
let key = InstallRegistry::repo_key("https://github.com/user/my-linter");
|
||||
assert!(key.starts_with("my-linter-"));
|
||||
assert_eq!(key.len(), "my-linter-".len() + 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_key_from_ssh_url() {
|
||||
let key = InstallRegistry::repo_key("git@github.com:user/my-plugin.git");
|
||||
assert!(key.starts_with("my-plugin-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_key_from_local_path() {
|
||||
let key = InstallRegistry::repo_key("/Users/me/projects/my-tools");
|
||||
assert!(key.starts_with("my-tools-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_key_collision_safety() {
|
||||
let key_a = InstallRegistry::repo_key("https://github.com/org-a/tools");
|
||||
let key_b = InstallRegistry::repo_key("https://github.com/org-b/tools");
|
||||
assert_ne!(
|
||||
key_a, key_b,
|
||||
"different sources should produce different keys"
|
||||
);
|
||||
assert!(key_a.starts_with("tools-"));
|
||||
assert!(key_b.starts_with("tools-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_crud() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
assert!(reg.repos.is_empty());
|
||||
assert!(reg.list().is_empty());
|
||||
|
||||
// Insert
|
||||
reg.insert(
|
||||
"test-repo-12345678".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Git {
|
||||
url: "https://github.com/user/test".to_string(),
|
||||
git_ref: Some("main".to_string()),
|
||||
commit: "abc123".to_string(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
path: tmp.path().join("test-repo-12345678"),
|
||||
plugins: HashMap::from([(
|
||||
"my-plugin".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: None,
|
||||
version: Some("1.0.0".to_string()),
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(reg.repos.len(), 1);
|
||||
assert!(reg.get_repo("test-repo-12345678").is_some());
|
||||
assert!(reg.find_plugin("my-plugin").is_some());
|
||||
assert!(reg.find_plugin("nonexistent").is_none());
|
||||
|
||||
// Save and reload
|
||||
reg.save().unwrap();
|
||||
let registry_path = tmp.path().join("registry.json");
|
||||
assert!(registry_path.exists());
|
||||
|
||||
// Remove
|
||||
let removed = reg.remove("test-repo-12345678");
|
||||
assert!(removed.is_some());
|
||||
assert!(reg.repos.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
|
||||
reg.insert(
|
||||
"my-linter-aabbccdd".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Local {
|
||||
source_path: PathBuf::from("/home/user/plugins/linter"),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: "2026-03-26T12:00:00Z".to_string(),
|
||||
updated_at: "2026-03-26T12:00:00Z".to_string(),
|
||||
path: tmp.path().join("my-linter-aabbccdd"),
|
||||
plugins: HashMap::from([
|
||||
(
|
||||
"lint-check".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("lint-check".to_string()),
|
||||
version: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"lint-fix".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("lint-fix".to_string()),
|
||||
version: Some("2.0.0".to_string()),
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
reg.save().unwrap();
|
||||
|
||||
// Read the JSON back and parse
|
||||
let content = std::fs::read_to_string(tmp.path().join("registry.json")).unwrap();
|
||||
let loaded: InstallRegistry = serde_json::from_str(&content).unwrap();
|
||||
|
||||
assert_eq!(loaded.version, 1);
|
||||
assert_eq!(loaded.repos.len(), 1);
|
||||
let repo = loaded.get_repo("my-linter-aabbccdd").unwrap();
|
||||
assert_eq!(repo.plugins.len(), 2);
|
||||
assert!(repo.plugins.contains_key("lint-check"));
|
||||
assert!(repo.plugins.contains_key("lint-fix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_plugin_across_repos() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
|
||||
reg.insert(
|
||||
"repo-a-11111111".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Git {
|
||||
url: "https://example.com/a".to_string(),
|
||||
git_ref: None,
|
||||
commit: "aaa".to_string(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
path: tmp.path().join("repo-a-11111111"),
|
||||
plugins: HashMap::from([(
|
||||
"alpha".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: None,
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
reg.insert(
|
||||
"repo-b-22222222".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Git {
|
||||
url: "https://example.com/b".to_string(),
|
||||
git_ref: None,
|
||||
commit: "bbb".to_string(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
path: tmp.path().join("repo-b-22222222"),
|
||||
plugins: HashMap::from([(
|
||||
"beta".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("beta".to_string()),
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
let (key, _, _) = reg.find_plugin("alpha").unwrap();
|
||||
assert_eq!(key, "repo-a-11111111");
|
||||
|
||||
let (key, _, plugin) = reg.find_plugin("beta").unwrap();
|
||||
assert_eq!(key, "repo-b-22222222");
|
||||
assert_eq!(plugin.subdir.as_deref(), Some("beta"));
|
||||
|
||||
assert!(reg.find_plugin("gamma").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_repo_key_by_plugin_root_handles_subdir_plugins() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo-a-11111111");
|
||||
let plugin_root = repo_root.join("plugins").join("nested");
|
||||
std::fs::create_dir_all(&plugin_root).unwrap();
|
||||
let canonical_plugin_root = dunce::canonicalize(&plugin_root).unwrap();
|
||||
let mut reg = InstallRegistry::empty(tmp.path().to_path_buf());
|
||||
reg.insert(
|
||||
"repo-a-11111111".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Local {
|
||||
source_path: repo_root.clone(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
path: repo_root,
|
||||
plugins: HashMap::from([(
|
||||
"nested".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("plugins/nested".to_string()),
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
reg.find_repo_key_by_plugin_root(&plugin_root, &canonical_plugin_root),
|
||||
Some("repo-a-11111111")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_kind_without_subdir_field_deserializes_to_none() {
|
||||
let json = r#"{"type":"Git","url":"https://example.com/r","commit":"abc"}"#;
|
||||
let kind: InstallKind = serde_json::from_str(json).unwrap();
|
||||
match kind {
|
||||
InstallKind::Git { url, subdir, .. } => {
|
||||
assert_eq!(url, "https://example.com/r");
|
||||
assert!(subdir.is_none());
|
||||
}
|
||||
_ => panic!("expected Git"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_kind_without_subdir_field_deserializes_to_none() {
|
||||
let json = r#"{"type":"Local","source_path":"/home/user/plugin"}"#;
|
||||
let kind: InstallKind = serde_json::from_str(json).unwrap();
|
||||
match kind {
|
||||
InstallKind::Local {
|
||||
source_path,
|
||||
subdir,
|
||||
} => {
|
||||
assert_eq!(source_path, PathBuf::from("/home/user/plugin"));
|
||||
assert!(subdir.is_none());
|
||||
}
|
||||
_ => panic!("expected Local"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_kind_with_subdir_round_trips() {
|
||||
let kind = InstallKind::Local {
|
||||
source_path: PathBuf::from("/home/user/workspace"),
|
||||
subdir: Some("plugins/foo".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
let back: InstallKind = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
InstallKind::Local { subdir, .. } => {
|
||||
assert_eq!(subdir.as_deref(), Some("plugins/foo"));
|
||||
}
|
||||
_ => panic!("expected Local"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
//! Refresh of copied local plugin installs from their live source.
|
||||
//!
|
||||
//! A local install is a full directory copy under `installed-plugins/` (not a
|
||||
//! live symlink), so agents/skills added to the live source after install do not
|
||||
//! surface until the snapshot is re-copied. This module re-copies refreshable
|
||||
//! local installs (under-home or trusted) at session spawn and `/plugins reload`.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::git_install::{
|
||||
copy_dir_recursive, discover_plugins_in_dir, remove_repo_path, repo_plugin_map,
|
||||
};
|
||||
use super::install_registry::{InstallError, InstallKind, InstallRegistry, RepoPlugin};
|
||||
use super::trust::TrustStore;
|
||||
|
||||
/// Orphaned tmp/backup siblings younger than this may belong to a concurrent live
|
||||
/// refresh, so [`sweep_stale`] only reclaims entries older than this.
|
||||
const STALE_SWEEP_AGE: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Counts from a [`refresh_local_installs`] pass, for logging and tests.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub(crate) struct RefreshSummary {
|
||||
pub refreshed: usize,
|
||||
pub skipped: usize,
|
||||
pub errors: usize,
|
||||
}
|
||||
|
||||
/// Load the install registry, [`refresh_local_installs`], and persist it if a
|
||||
/// snapshot changed.
|
||||
///
|
||||
/// Runs only at genuine session spawn (`force=false`, cheap skip-unchanged) and
|
||||
/// explicit `/plugins reload` (`force=true`, always re-copies — the guaranteed
|
||||
/// manual remedy). Refresh implies continuous re-consent for under-home / trusted
|
||||
/// sources (install-time trust re-applies every spawn). Non-fatal on failure.
|
||||
pub(crate) fn refresh_local_installs_from_disk(trust: &TrustStore, force: bool) -> RefreshSummary {
|
||||
let mut registry = InstallRegistry::load();
|
||||
let summary = refresh_local_installs(&mut registry, trust, force);
|
||||
if summary.refreshed > 0
|
||||
&& let Err(e) = registry.save()
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to save install registry after local plugin refresh");
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// A local install snapshotted out of the registry so the refresh loop can mutate
|
||||
/// the registry while iterating. `expected` is the recorded plugin set used to
|
||||
/// guard against scope-changing rediscovery.
|
||||
struct RefreshTarget {
|
||||
key: String,
|
||||
source_path: PathBuf,
|
||||
subdir: Option<String>,
|
||||
dest: PathBuf,
|
||||
expected: HashMap<String, RepoPlugin>,
|
||||
}
|
||||
|
||||
/// Re-copy refreshable local installs from their live `source_path` into the
|
||||
/// managed snapshot, rediscovering plugins so new components surface.
|
||||
///
|
||||
/// A source is refreshable when it is under the user's home (auto-trusted, same
|
||||
/// rule as config-path plugins) or in the trust store; remote git installs are
|
||||
/// handled by `update_repo`, not here. Unless `force`, snapshots already matching
|
||||
/// the live source are skipped (a stat-walk, not a byte copy).
|
||||
fn refresh_local_installs(
|
||||
registry: &mut InstallRegistry,
|
||||
trust: &TrustStore,
|
||||
force: bool,
|
||||
) -> RefreshSummary {
|
||||
let mut summary = RefreshSummary::default();
|
||||
let targets: Vec<RefreshTarget> = registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.filter_map(|(key, repo)| match &repo.kind {
|
||||
InstallKind::Local {
|
||||
source_path,
|
||||
subdir,
|
||||
} => Some(RefreshTarget {
|
||||
key: key.to_string(),
|
||||
source_path: source_path.clone(),
|
||||
subdir: subdir.clone(),
|
||||
dest: repo.path.clone(),
|
||||
expected: repo.plugins.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
for RefreshTarget {
|
||||
key,
|
||||
source_path,
|
||||
subdir,
|
||||
dest,
|
||||
expected,
|
||||
} in targets
|
||||
{
|
||||
let refreshable =
|
||||
TrustStore::is_config_path_auto_trusted(&source_path) || trust.is_trusted(&source_path);
|
||||
if !source_path.is_dir() || !refreshable {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// Skip if the snapshot already matches the source (cheap stat-walk).
|
||||
// `force` (/plugins reload) bypasses the skip.
|
||||
if !force && snapshot_matches_source(&source_path, &dest) {
|
||||
summary.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
match recopy_local_install(&source_path, subdir.as_deref(), &dest, &expected) {
|
||||
Ok(Some(plugins)) => {
|
||||
if let Some(repo) = registry.get_repo_mut(&key) {
|
||||
repo.plugins = plugins;
|
||||
repo.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
}
|
||||
summary.refreshed += 1;
|
||||
}
|
||||
Ok(None) => {
|
||||
// Kept the snapshot: rediscovered plugin set/scope differs from
|
||||
// recorded (e.g. legacy install without a persisted `subdir`).
|
||||
tracing::debug!(
|
||||
repo_key = %key,
|
||||
"kept stale local plugin snapshot: rediscovered plugin set/scope differs from recorded"
|
||||
);
|
||||
summary.skipped += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo_key = %key, error = %e, "local plugin refresh failed");
|
||||
summary.errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
/// The set of `(relative_path, file_len)` for every non-symlink file under a
|
||||
/// tree. Symlinks are skipped, matching [`copy_dir_recursive`]. Comparing two of
|
||||
/// these detects add / remove / rename / size-change with no stored fingerprint
|
||||
/// and no brittle src-vs-dst mtime compare (a copy does not preserve mtimes).
|
||||
fn tree_file_set(root: &Path) -> Option<BTreeMap<PathBuf, u64>> {
|
||||
fn walk(base: &Path, dir: &Path, out: &mut BTreeMap<PathBuf, u64>) -> std::io::Result<()> {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
let meta = std::fs::symlink_metadata(&path)?;
|
||||
if meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if meta.is_file() {
|
||||
if let Ok(rel) = path.strip_prefix(base) {
|
||||
out.insert(rel.to_path_buf(), meta.len());
|
||||
}
|
||||
} else if meta.is_dir() {
|
||||
walk(base, &path, out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
walk(root, root, &mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Whether the snapshot at `dest` matches the live `source` by `(relpath, len)`
|
||||
/// set. Catches add/remove/rename/resize; misses only a same-path/same-len edit.
|
||||
fn snapshot_matches_source(source: &Path, dest: &Path) -> bool {
|
||||
match (tree_file_set(source), tree_file_set(dest)) {
|
||||
(Some(src), Some(dst)) => src == dst,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-copy `source_path` into `dest`, returning the rediscovered plugins, or
|
||||
/// `Ok(None)` to keep the existing snapshot unchanged.
|
||||
///
|
||||
/// Invariant: refresh only syncs file contents within the existing plugin set;
|
||||
/// if rediscovery is empty or changes the `(name, subdir)` set, the snapshot is
|
||||
/// kept as-is (protects legacy entries whose `subdir` wasn't persisted).
|
||||
///
|
||||
/// `subdir` scopes discovery as it did at install time; symlinks in the source
|
||||
/// are skipped (see [`copy_dir_recursive`]). The swap is rename-aside (move live
|
||||
/// snapshot to backup, promote tmp, drop backup) so `dest` is never absent during
|
||||
/// a slow delete and a failed promote rolls back to the previous snapshot.
|
||||
fn recopy_local_install(
|
||||
source_path: &Path,
|
||||
subdir: Option<&str>,
|
||||
dest: &Path,
|
||||
expected: &HashMap<String, RepoPlugin>,
|
||||
) -> Result<Option<HashMap<String, RepoPlugin>>, InstallError> {
|
||||
let parent = dest.parent().ok_or_else(|| InstallError::InstallFailed {
|
||||
detail: format!("install path has no parent: {}", dest.display()),
|
||||
})?;
|
||||
let file_name = dest
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("plugin");
|
||||
// Reclaim orphaned tmp/backup dirs left by a crash in a prior run.
|
||||
sweep_stale(parent, file_name);
|
||||
let tmp = parent.join(format!(".{file_name}.refresh-{}", std::process::id()));
|
||||
let backup = parent.join(format!(".{file_name}.backup-{}", std::process::id()));
|
||||
|
||||
let _ = remove_repo_path(&tmp);
|
||||
copy_dir_recursive(source_path, &tmp).map_err(|e| {
|
||||
let _ = remove_repo_path(&tmp);
|
||||
InstallError::Io {
|
||||
path: tmp.clone(),
|
||||
source: e,
|
||||
}
|
||||
})?;
|
||||
|
||||
let discovered = match discover_plugins_in_dir(&tmp, subdir) {
|
||||
Ok(plugins) => plugins,
|
||||
Err(e) => {
|
||||
let _ = remove_repo_path(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the snapshot unless the rediscovered (name, subdir) set is unchanged.
|
||||
let discovered_ids: BTreeSet<(&str, Option<&str>)> = discovered
|
||||
.iter()
|
||||
.map(|p| (p.name.as_str(), p.subdir.as_deref()))
|
||||
.collect();
|
||||
let expected_ids: BTreeSet<(&str, Option<&str>)> = expected
|
||||
.iter()
|
||||
.map(|(name, rp)| (name.as_str(), rp.subdir.as_deref()))
|
||||
.collect();
|
||||
if discovered.is_empty() || discovered_ids != expected_ids {
|
||||
let _ = remove_repo_path(&tmp);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let _ = remove_repo_path(&backup);
|
||||
if dest.exists()
|
||||
&& let Err(e) = std::fs::rename(dest, &backup)
|
||||
{
|
||||
let _ = remove_repo_path(&tmp);
|
||||
return Err(InstallError::Io {
|
||||
path: dest.to_path_buf(),
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
if let Err(e) = promote_tmp_to_dest(&tmp, dest) {
|
||||
let _ = remove_repo_path(&tmp);
|
||||
// Promote failed: restore the prior tree so `dest` is never left missing
|
||||
// (rename, then copy fallback), unless a peer already repopulated `dest`.
|
||||
if dest.exists() {
|
||||
let _ = remove_repo_path(&backup);
|
||||
} else if std::fs::rename(&backup, dest).is_err() {
|
||||
match copy_dir_recursive(&backup, dest) {
|
||||
Ok(()) => {
|
||||
let _ = remove_repo_path(&backup);
|
||||
}
|
||||
Err(restore) => tracing::error!(
|
||||
dest = %dest.display(),
|
||||
backup = %backup.display(),
|
||||
error = %restore,
|
||||
"failed to restore snapshot after refresh promote failure; prior tree kept at backup"
|
||||
),
|
||||
}
|
||||
}
|
||||
return Err(InstallError::Io {
|
||||
path: dest.to_path_buf(),
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
let _ = remove_repo_path(&backup);
|
||||
|
||||
Ok(Some(repo_plugin_map(&discovered)))
|
||||
}
|
||||
|
||||
/// Promote the freshly-copied `tmp` tree onto `dest`. A test hook can force this
|
||||
/// to fail to exercise the rename-aside rollback path.
|
||||
fn promote_tmp_to_dest(tmp: &Path, dest: &Path) -> std::io::Result<()> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if std::env::var_os("KIGI_TEST_FAIL_REFRESH_PROMOTE").is_some() {
|
||||
return Err(std::io::Error::other(
|
||||
"test-injected refresh promote failure",
|
||||
));
|
||||
}
|
||||
}
|
||||
std::fs::rename(tmp, dest)
|
||||
}
|
||||
|
||||
/// Best-effort removal of orphaned `.<name>.refresh-*` / `.<name>.backup-*`
|
||||
/// siblings left by a crash between copy and promote. Only entries older than
|
||||
/// [`STALE_SWEEP_AGE`] are reaped, so a concurrent live refresh's in-flight
|
||||
/// working dir (pid-named, freshly created) is never deleted out from under it.
|
||||
fn sweep_stale(parent: &Path, file_name: &str) {
|
||||
let refresh_prefix = format!(".{file_name}.refresh-");
|
||||
let backup_prefix = format!(".{file_name}.backup-");
|
||||
let Ok(entries) = std::fs::read_dir(parent) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
|
||||
continue;
|
||||
};
|
||||
if !name.starts_with(&refresh_prefix) && !name.starts_with(&backup_prefix) {
|
||||
continue;
|
||||
}
|
||||
let stale = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|m| m.elapsed().ok())
|
||||
.is_some_and(|age| age >= STALE_SWEEP_AGE);
|
||||
if stale {
|
||||
let _ = remove_repo_path(&entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::git_install::{InstallResult, InstallSource, install_from_source};
|
||||
use super::super::install_registry::InstalledRepo;
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
|
||||
/// RAII guard: sets an env var, restores the prior value (or unsets) on drop,
|
||||
/// so a test never leaves process-global env pointing at a dropped tempdir.
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
prev: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
|
||||
let prev = std::env::var_os(key);
|
||||
unsafe { std::env::set_var(key, value) };
|
||||
Self { key, prev }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.prev.take() {
|
||||
Some(v) => unsafe { std::env::set_var(self.key, v) },
|
||||
None => unsafe { std::env::remove_var(self.key) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical home: under-home auto-trust canonicalizes the candidate but not
|
||||
// `$HOME` (macOS `/var` -> `/private/var`). The guard restores `$HOME` on drop.
|
||||
fn home_tempdir() -> (tempfile::TempDir, PathBuf, EnvVarGuard) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let guard = EnvVarGuard::set("HOME", &home);
|
||||
(tmp, home, guard)
|
||||
}
|
||||
|
||||
fn write_plugin_json(dir: &Path, name: &str) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("plugin.json"), format!(r#"{{"name":"{name}"}}"#)).unwrap();
|
||||
}
|
||||
|
||||
fn write_agent_md(plugin_dir: &Path, name: &str) {
|
||||
std::fs::create_dir_all(plugin_dir.join("agents")).unwrap();
|
||||
std::fs::write(
|
||||
plugin_dir.join("agents").join(format!("{name}.md")),
|
||||
format!("---\nname: {name}\ndescription: d\n---\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Install `source` (optionally scoped to `subdir`) and record it in
|
||||
// `registry`, mirroring what the install command persists.
|
||||
fn register_local_install(
|
||||
registry: &mut InstallRegistry,
|
||||
source: &Path,
|
||||
subdir: Option<&str>,
|
||||
) -> InstallResult {
|
||||
let installed = install_from_source(
|
||||
&InstallSource::Local {
|
||||
path: source.to_path_buf(),
|
||||
subdir: subdir.map(str::to_string),
|
||||
},
|
||||
registry,
|
||||
)
|
||||
.unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
registry.insert(
|
||||
installed.repo_key.clone(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Local {
|
||||
source_path: source.to_path_buf(),
|
||||
subdir: subdir.map(str::to_string),
|
||||
},
|
||||
installed_at: now.clone(),
|
||||
updated_at: now,
|
||||
path: installed.repo_path.clone(),
|
||||
plugins: repo_plugin_map(&installed.plugins),
|
||||
},
|
||||
);
|
||||
installed
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_local_install_picks_up_new_agent() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let source = home.join(".claude").join("demo-plugin");
|
||||
write_plugin_json(&source, "demo-plugin");
|
||||
write_agent_md(&source, "old");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join(".kigi").join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
write_agent_md(&source, "new");
|
||||
assert!(!installed.repo_path.join("agents/new.md").exists());
|
||||
|
||||
let trust = TrustStore::load_from(home.join(".kigi").join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.refreshed, 1, "{summary:?}");
|
||||
assert!(installed.repo_path.join("agents/new.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_skips_unchanged_source() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let source = home.join(".claude").join("demo-plugin");
|
||||
write_plugin_json(&source, "demo-plugin");
|
||||
write_agent_md(&source, "old");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join(".kigi").join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
// No edit to the source: snapshot matches, so refresh is a stat-walk skip
|
||||
// with no re-copy.
|
||||
let snapshot = installed.repo_path.join("agents/old.md");
|
||||
let before = std::fs::metadata(&snapshot).unwrap().modified().unwrap();
|
||||
let trust = TrustStore::load_from(home.join(".kigi").join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.refreshed, 0, "{summary:?}");
|
||||
assert_eq!(summary.skipped, 1, "{summary:?}");
|
||||
let after = std::fs::metadata(&snapshot).unwrap().modified().unwrap();
|
||||
assert_eq!(before, after, "unchanged snapshot must not be re-copied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_picks_up_content_preserving_rename() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let source = home.join(".claude").join("demo-plugin");
|
||||
write_plugin_json(&source, "demo-plugin");
|
||||
write_agent_md(&source, "old");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join(".kigi").join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
// Rename keeps file count, total size, and the file's (old) mtime — the
|
||||
// old aggregate fingerprint skipped this; the structural file-set catches it.
|
||||
std::fs::rename(
|
||||
source.join("agents/old.md"),
|
||||
source.join("agents/renamed.md"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let trust = TrustStore::load_from(home.join(".kigi").join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(
|
||||
summary.refreshed, 1,
|
||||
"rename must trigger refresh: {summary:?}"
|
||||
);
|
||||
assert!(installed.repo_path.join("agents/renamed.md").exists());
|
||||
assert!(!installed.repo_path.join("agents/old.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_promote_failure_rolls_back_to_prior_snapshot() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let source = home.join(".claude").join("demo-plugin");
|
||||
write_plugin_json(&source, "demo-plugin");
|
||||
write_agent_md(&source, "old");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join(".kigi").join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
// Change the source so a refresh attempts a re-copy, then force the
|
||||
// promote rename to fail and assert the prior snapshot is restored.
|
||||
write_agent_md(&source, "new");
|
||||
let trust = TrustStore::load_from(home.join(".kigi").join("trusted-plugins"));
|
||||
let summary = {
|
||||
let _fail = EnvVarGuard::set("KIGI_TEST_FAIL_REFRESH_PROMOTE", "1");
|
||||
refresh_local_installs(&mut registry, &trust, false)
|
||||
};
|
||||
|
||||
assert_eq!(summary.errors, 1, "{summary:?}");
|
||||
assert_eq!(summary.refreshed, 0, "{summary:?}");
|
||||
// dest is never left missing and still holds the prior snapshot.
|
||||
assert!(installed.repo_path.join("agents/old.md").exists());
|
||||
assert!(!installed.repo_path.join("agents/new.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_skips_untrusted_source_outside_home() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let source = outside.path().join("untrusted-plugin");
|
||||
write_plugin_json(&source, "untrusted-plugin");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
std::fs::write(source.join("extra.txt"), "x").unwrap();
|
||||
let trust = TrustStore::load_from(home.join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.skipped, 1, "{summary:?}");
|
||||
assert_eq!(summary.refreshed, 0, "{summary:?}");
|
||||
assert!(!installed.repo_path.join("extra.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_trusted_source_outside_home() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let source = outside.path().join("trusted-plugin");
|
||||
write_plugin_json(&source, "trusted-plugin");
|
||||
write_agent_md(&source, "old");
|
||||
|
||||
let mut trust = TrustStore::load_from(home.join("trusted-plugins"));
|
||||
trust.grant_trust(&source).unwrap();
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
|
||||
write_agent_md(&source, "new");
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.refreshed, 1, "{summary:?}");
|
||||
assert!(installed.repo_path.join("agents/new.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_preserves_install_subdir_scope() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let workspace = home.join("workspace");
|
||||
write_plugin_json(&workspace.join("plugins/a"), "plugin-a");
|
||||
write_plugin_json(&workspace.join("plugins/b"), "plugin-b");
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join(".kigi/installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &workspace, Some("plugins/a"));
|
||||
|
||||
write_agent_md(&workspace.join("plugins/a"), "x");
|
||||
|
||||
let trust = TrustStore::load_from(home.join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.refreshed, 1, "{summary:?}");
|
||||
assert!(installed.repo_path.join("plugins/a/agents/x.md").exists());
|
||||
|
||||
let repo = registry.get_repo(&installed.repo_key).unwrap();
|
||||
match &repo.kind {
|
||||
InstallKind::Local { subdir, .. } => assert_eq!(subdir.as_deref(), Some("plugins/a")),
|
||||
_ => panic!("expected Local"),
|
||||
}
|
||||
assert!(repo.plugins.contains_key("plugin-a"));
|
||||
assert!(!repo.plugins.contains_key("plugin-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_does_not_follow_directory_symlinks() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
let secret = home.join("secret-dir");
|
||||
std::fs::create_dir_all(&secret).unwrap();
|
||||
std::fs::write(secret.join("secret.txt"), "leak").unwrap();
|
||||
|
||||
let source = home.join("plugin");
|
||||
write_plugin_json(&source, "plugin");
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&secret, source.join("link-out")).unwrap();
|
||||
|
||||
let mut registry = InstallRegistry::empty(home.join("installed-plugins"));
|
||||
let installed = register_local_install(&mut registry, &source, None);
|
||||
assert!(!installed.repo_path.join("link-out/secret.txt").exists());
|
||||
|
||||
std::fs::write(source.join("extra.txt"), "x").unwrap();
|
||||
let trust = TrustStore::load_from(home.join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, false);
|
||||
assert_eq!(summary.refreshed, 1, "{summary:?}");
|
||||
assert!(!installed.repo_path.join("link-out/secret.txt").exists());
|
||||
assert!(installed.repo_path.join("extra.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial(home_env)]
|
||||
fn refresh_keeps_stale_when_legacy_subdir_scope_lost() {
|
||||
let (_home_tmp, home, _home_guard) = home_tempdir();
|
||||
// Legacy multi-package source: the real plugin is at plugins/foo;
|
||||
// other-dir is unrelated root-level content that root-scope discovery
|
||||
// would pick up.
|
||||
let workspace = home.join("workspace");
|
||||
write_plugin_json(&workspace.join("plugins/foo"), "foo");
|
||||
write_agent_md(&workspace.join("other-dir"), "noise");
|
||||
|
||||
// Snapshot the full source (mirrors the install-time copy).
|
||||
let install_dir = home.join(".kigi").join("installed-plugins");
|
||||
std::fs::create_dir_all(&install_dir).unwrap();
|
||||
let dest = install_dir.join("foo-legacy");
|
||||
copy_dir_recursive(&workspace, &dest).unwrap();
|
||||
|
||||
// Legacy entry: install-level `subdir` was never persisted (None), but the
|
||||
// per-plugin RepoPlugin recorded the correct scope.
|
||||
let mut registry = InstallRegistry::empty(install_dir);
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
registry.insert(
|
||||
"foo-legacy".to_string(),
|
||||
InstalledRepo {
|
||||
kind: InstallKind::Local {
|
||||
source_path: workspace.clone(),
|
||||
subdir: None,
|
||||
},
|
||||
installed_at: now.clone(),
|
||||
updated_at: now,
|
||||
path: dest.clone(),
|
||||
plugins: HashMap::from([(
|
||||
"foo".to_string(),
|
||||
RepoPlugin {
|
||||
subdir: Some("plugins/foo".to_string()),
|
||||
version: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
);
|
||||
|
||||
// Edit the source under plugins/foo so a content refresh would trigger.
|
||||
write_agent_md(&workspace.join("plugins/foo"), "added");
|
||||
|
||||
// force=true so the unchanged-skip can't mask the scope-identity guard.
|
||||
let trust = TrustStore::load_from(home.join(".kigi").join("trusted-plugins"));
|
||||
let summary = refresh_local_installs(&mut registry, &trust, true);
|
||||
|
||||
// Root-scope rediscovery would change the plugin set/scope, so keep stale:
|
||||
// no refresh, and repo.plugins / repo.kind must be untouched (no corruption).
|
||||
assert_eq!(
|
||||
summary.refreshed, 0,
|
||||
"scope change must keep stale: {summary:?}"
|
||||
);
|
||||
let repo = registry.get_repo("foo-legacy").unwrap();
|
||||
assert_eq!(repo.plugins.len(), 1);
|
||||
assert_eq!(
|
||||
repo.plugins.get("foo").and_then(|p| p.subdir.as_deref()),
|
||||
Some("plugins/foo")
|
||||
);
|
||||
match &repo.kind {
|
||||
InstallKind::Local {
|
||||
source_path,
|
||||
subdir,
|
||||
} => {
|
||||
assert_eq!(source_path, &workspace);
|
||||
assert!(subdir.is_none());
|
||||
}
|
||||
_ => panic!("expected Local"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
//! Plugin manifest parsing and validation.
|
||||
//!
|
||||
//! The canonical manifest location is `plugin.json` at the plugin root.
|
||||
//! Fallback locations (checked in order when the root manifest is absent):
|
||||
//! 1. `.kigi-plugin/plugin.json`
|
||||
//! 2. `.claude-plugin/plugin.json`
|
||||
//!
|
||||
//! If no manifest is found at all, the plugin can still function via
|
||||
//! convention-based discovery (skills/, agents/, .mcp.json, hooks/hooks.json),
|
||||
//! with the plugin name derived from the directory name.
|
||||
//!
|
||||
//! The parser is forward-compatible: unknown fields are silently ignored
|
||||
//! so that manifests authored for newer upstream versions still load.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Maximum length of a plugin name (kebab-case identifier).
|
||||
const MAX_PLUGIN_NAME_LEN: usize = 64;
|
||||
|
||||
/// Regex pattern for valid plugin names: lowercase alphanumeric + hyphens.
|
||||
fn is_valid_plugin_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_PLUGIN_NAME_LEN
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
&& !name.starts_with('-')
|
||||
&& !name.ends_with('-')
|
||||
}
|
||||
|
||||
/// Author metadata from a plugin manifest.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Author {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
/// A path reference that can be either a single path or multiple paths.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PathOrPaths {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl PathOrPaths {
|
||||
/// Resolve all contained paths relative to a plugin root.
|
||||
///
|
||||
/// Paths that escape the plugin root (via `..` components) are rejected
|
||||
/// with a warning and excluded from the result.
|
||||
pub fn resolve(&self, plugin_root: &Path) -> Vec<PathBuf> {
|
||||
let paths = match self {
|
||||
PathOrPaths::Single(p) => vec![plugin_root.join(p)],
|
||||
PathOrPaths::Multiple(ps) => ps.iter().map(|p| plugin_root.join(p)).collect(),
|
||||
};
|
||||
paths
|
||||
.into_iter()
|
||||
.filter(|resolved| {
|
||||
if is_path_contained(resolved, plugin_root) {
|
||||
true
|
||||
} else {
|
||||
tracing::warn!(
|
||||
path = %resolved.display(),
|
||||
plugin_root = %plugin_root.display(),
|
||||
"manifest path escapes plugin root; skipping"
|
||||
);
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a resolved path stays within the plugin root.
|
||||
///
|
||||
/// Canonicalizes both sides (resolving symlinks and `..`) before the prefix check.
|
||||
fn is_path_contained(resolved: &Path, plugin_root: &Path) -> bool {
|
||||
let canonical_root =
|
||||
dunce::canonicalize(plugin_root).unwrap_or_else(|_| plugin_root.to_path_buf());
|
||||
let canonical_resolved =
|
||||
dunce::canonicalize(resolved).unwrap_or_else(|_| resolved.to_path_buf());
|
||||
// Fail-closed >MAX_PATH caveat: see workspace clippy.toml.
|
||||
canonical_resolved.starts_with(&canonical_root)
|
||||
}
|
||||
|
||||
/// Resolve a plugin component path (hooks, MCP, LSP) from a manifest field.
|
||||
///
|
||||
/// If the field is `Path(p)`, resolves relative to plugin root with containment check.
|
||||
/// If `Inline(_)`, returns `None` (caller reads inline value directly).
|
||||
/// If `None`, checks for `default_file` at the plugin root.
|
||||
fn resolve_component_path(
|
||||
field: &Option<PathOrInline>,
|
||||
plugin_root: &Path,
|
||||
default_file: &str,
|
||||
label: &str,
|
||||
) -> Option<PathBuf> {
|
||||
match field {
|
||||
Some(PathOrInline::Path(p)) => {
|
||||
let resolved = plugin_root.join(p);
|
||||
if !is_path_contained(&resolved, plugin_root) {
|
||||
tracing::warn!(
|
||||
path = %resolved.display(),
|
||||
plugin_root = %plugin_root.display(),
|
||||
"{label} path escapes plugin root; skipping"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
resolved.is_file().then_some(resolved)
|
||||
}
|
||||
Some(PathOrInline::Inline(_)) => None,
|
||||
None => {
|
||||
let default = plugin_root.join(default_file);
|
||||
default.is_file().then_some(default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value that can be either a file path (string) or an inline JSON object.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PathOrInline {
|
||||
Path(String),
|
||||
Inline(serde_json::Value),
|
||||
}
|
||||
|
||||
/// Parsed plugin manifest from `plugin.json`.
|
||||
///
|
||||
/// Forward-compatible: unknown fields are silently ignored via
|
||||
/// `#[serde(deny_unknown_fields)]` NOT being set.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginManifest {
|
||||
/// User-facing plugin namespace (kebab-case). Required.
|
||||
pub name: String,
|
||||
/// Semver version string.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author: Option<Author>,
|
||||
#[serde(default)]
|
||||
pub homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
pub repository: Option<String>,
|
||||
#[serde(default)]
|
||||
pub license: Option<String>,
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
|
||||
// ── Component path overrides (supplement convention dirs) ──────
|
||||
#[serde(default)]
|
||||
pub skills: Option<PathOrPaths>,
|
||||
#[serde(default)]
|
||||
pub commands: Option<PathOrPaths>,
|
||||
#[serde(default)]
|
||||
pub agents: Option<PathOrPaths>,
|
||||
#[serde(default)]
|
||||
pub hooks: Option<PathOrInline>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Option<PathOrInline>,
|
||||
#[serde(default)]
|
||||
pub lsp_servers: Option<PathOrInline>,
|
||||
}
|
||||
|
||||
impl PluginManifest {
|
||||
/// Validate the parsed manifest.
|
||||
pub fn validate(&self) -> Result<(), ManifestError> {
|
||||
if !is_valid_plugin_name(&self.name) {
|
||||
return Err(ManifestError::InvalidName {
|
||||
name: self.name.clone(),
|
||||
reason: format!(
|
||||
"must be 1-{MAX_PLUGIN_NAME_LEN} chars, lowercase alphanumeric + hyphens, \
|
||||
no leading/trailing hyphens"
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn skill_dirs(&self, plugin_root: &Path) -> Vec<PathBuf> {
|
||||
resolve_dirs(&self.skills, plugin_root, "skills")
|
||||
}
|
||||
|
||||
pub fn command_dirs(&self, plugin_root: &Path) -> Vec<PathBuf> {
|
||||
resolve_dirs(&self.commands, plugin_root, "commands")
|
||||
}
|
||||
|
||||
pub fn agent_dirs(&self, plugin_root: &Path) -> Vec<PathBuf> {
|
||||
resolve_dirs(&self.agents, plugin_root, "agents")
|
||||
}
|
||||
|
||||
/// Resolve the hooks path from the manifest.
|
||||
/// Returns the manifest-specified path or the default `hooks/hooks.json`.
|
||||
pub fn hooks_path(&self, plugin_root: &Path) -> Option<PathBuf> {
|
||||
resolve_component_path(&self.hooks, plugin_root, "hooks/hooks.json", "hooks")
|
||||
}
|
||||
|
||||
pub fn mcp_config_path(&self, plugin_root: &Path) -> Option<PathBuf> {
|
||||
if matches!(self.mcp_servers, Some(PathOrInline::Inline(_))) {
|
||||
let default = plugin_root.join(".mcp.json");
|
||||
return default.is_file().then_some(default);
|
||||
}
|
||||
resolve_component_path(&self.mcp_servers, plugin_root, ".mcp.json", "MCP config")
|
||||
}
|
||||
|
||||
/// Get inline hooks JSON value, if the manifest uses inline hooks.
|
||||
///
|
||||
/// Inline hooks are fully supported — the runtime parses and executes them
|
||||
/// via `parse_plugin_hooks_from_value()`. This accessor is used during
|
||||
/// `LoadedPlugin` construction and by the hooks adapter.
|
||||
pub fn inline_hooks(&self) -> Option<&serde_json::Value> {
|
||||
match &self.hooks {
|
||||
Some(PathOrInline::Inline(v)) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get inline MCP servers JSON value, if the manifest uses inline MCP.
|
||||
///
|
||||
/// Inline MCP servers are fully supported — the runtime parses and starts
|
||||
/// them via `load_plugin_mcp_servers_from_value()`. This accessor is used
|
||||
/// during `LoadedPlugin` construction and by the MCP merger.
|
||||
pub fn inline_mcp_servers(&self) -> Option<&serde_json::Value> {
|
||||
match &self.mcp_servers {
|
||||
Some(PathOrInline::Inline(v)) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lsp_config_path(&self, plugin_root: &Path) -> Option<PathBuf> {
|
||||
resolve_component_path(&self.lsp_servers, plugin_root, ".lsp.json", "LSP config")
|
||||
}
|
||||
|
||||
pub fn inline_lsp_servers(&self) -> Option<&serde_json::Value> {
|
||||
match &self.lsp_servers {
|
||||
Some(PathOrInline::Inline(v)) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Log informational messages about manifest features.
|
||||
///
|
||||
/// Called during discovery. Inline hooks and MCP servers are now
|
||||
/// fully supported; this method logs when they are detected.
|
||||
pub fn warn_unsupported_features(&self, plugin_name: &str) {
|
||||
if self.inline_hooks().is_some() {
|
||||
tracing::info!(plugin = plugin_name, "plugin uses inline hooks in manifest");
|
||||
}
|
||||
if self.inline_mcp_servers().is_some() {
|
||||
tracing::info!(
|
||||
plugin = plugin_name,
|
||||
"plugin uses inline mcpServers in manifest"
|
||||
);
|
||||
}
|
||||
if self.inline_lsp_servers().is_some() {
|
||||
tracing::info!(
|
||||
plugin = plugin_name,
|
||||
"plugin uses inline lspServers in manifest"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve directories from a manifest field or fall back to a default subdirectory.
|
||||
fn resolve_dirs(
|
||||
field: &Option<PathOrPaths>,
|
||||
plugin_root: &Path,
|
||||
default_name: &str,
|
||||
) -> Vec<PathBuf> {
|
||||
match field {
|
||||
Some(paths) => paths.resolve(plugin_root),
|
||||
None => {
|
||||
let default = plugin_root.join(default_name);
|
||||
if default.is_dir() {
|
||||
vec![default]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manifest loading ──────────────────────────────────────────────────
|
||||
|
||||
/// Manifest search order within a plugin directory.
|
||||
const MANIFEST_PATHS: &[&str] = &[
|
||||
"plugin.json",
|
||||
".kigi-plugin/plugin.json",
|
||||
".claude-plugin/plugin.json",
|
||||
];
|
||||
|
||||
/// Result of attempting to load a manifest from a plugin directory.
|
||||
#[derive(Debug)]
|
||||
pub enum ManifestLoadResult {
|
||||
/// Manifest found and parsed successfully.
|
||||
Found(Box<PluginManifest>),
|
||||
/// No manifest file found — plugin uses convention-based discovery.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Load a plugin manifest from the given plugin root directory.
|
||||
///
|
||||
/// Tries manifest files in priority order (see [`MANIFEST_PATHS`]).
|
||||
/// If no manifest is found, returns `ManifestLoadResult::NotFound`.
|
||||
/// The caller can still create a convention-based plugin from the directory.
|
||||
pub fn load_manifest(plugin_root: &Path) -> Result<ManifestLoadResult, ManifestError> {
|
||||
for rel_path in MANIFEST_PATHS {
|
||||
let manifest_path = plugin_root.join(rel_path);
|
||||
if manifest_path.is_file() {
|
||||
let content =
|
||||
std::fs::read_to_string(&manifest_path).map_err(|e| ManifestError::IoError {
|
||||
path: manifest_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
let manifest: PluginManifest =
|
||||
serde_json::from_str(&content).map_err(|e| ManifestError::ParseError {
|
||||
path: manifest_path.clone(),
|
||||
message: e.to_string(),
|
||||
})?;
|
||||
manifest.validate()?;
|
||||
manifest.warn_unsupported_features(&manifest.name);
|
||||
return Ok(ManifestLoadResult::Found(Box::new(manifest)));
|
||||
}
|
||||
}
|
||||
Ok(ManifestLoadResult::NotFound)
|
||||
}
|
||||
|
||||
/// Derive a plugin name from a directory name.
|
||||
///
|
||||
/// Sanitizes the directory name to match the kebab-case constraint:
|
||||
/// lowercase, alphanumeric + hyphens, no leading/trailing hyphens.
|
||||
pub fn name_from_dirname(dir: &Path) -> Option<String> {
|
||||
let dirname = dir.file_name()?.to_str()?;
|
||||
let sanitized: String = dirname
|
||||
.to_ascii_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = sanitized.trim_matches('-').to_string();
|
||||
if trimmed.is_empty() || trimmed.len() > MAX_PLUGIN_NAME_LEN {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed)
|
||||
}
|
||||
|
||||
/// Perform plugin-token substitution in a string.
|
||||
///
|
||||
/// Replaces `${KIGI_PLUGIN_ROOT}`, `${CLAUDE_PLUGIN_ROOT}`,
|
||||
/// `${KIGI_PLUGIN_DATA}`, and `${CLAUDE_PLUGIN_DATA}` with the provided values.
|
||||
///
|
||||
/// Delegates to [`kigi_tools::util::substitute_plugin_tokens`], the single
|
||||
/// source of truth shared with plugin skill/command body substitution.
|
||||
pub fn substitute_env_vars(s: &str, plugin_root: &str, plugin_data: &str) -> String {
|
||||
kigi_tools::util::substitute_plugin_tokens(s, Some(plugin_root), Some(plugin_data))
|
||||
}
|
||||
|
||||
pub fn normalize_inline_mcp_servers(value: &serde_json::Value) -> serde_json::Value {
|
||||
let inner = match value.get("mcpServers") {
|
||||
Some(servers) if servers.is_object() => servers.clone(),
|
||||
_ => value.clone(),
|
||||
};
|
||||
serde_json::json!({ "mcpServers": inner })
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("invalid plugin name {name:?}: {reason}")]
|
||||
InvalidName { name: String, reason: String },
|
||||
|
||||
#[error("failed to read {path}: {source}")]
|
||||
IoError {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("failed to parse {path}: {message}")]
|
||||
ParseError { path: PathBuf, message: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_plugin_names() {
|
||||
assert!(is_valid_plugin_name("my-plugin"));
|
||||
assert!(is_valid_plugin_name("a"));
|
||||
assert!(is_valid_plugin_name("deployment-tools"));
|
||||
assert!(is_valid_plugin_name("plugin123"));
|
||||
assert!(is_valid_plugin_name("a-b-c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_plugin_names() {
|
||||
assert!(!is_valid_plugin_name(""));
|
||||
assert!(!is_valid_plugin_name("-start"));
|
||||
assert!(!is_valid_plugin_name("end-"));
|
||||
assert!(!is_valid_plugin_name("UPPER"));
|
||||
assert!(!is_valid_plugin_name("has space"));
|
||||
assert!(!is_valid_plugin_name("has_underscore"));
|
||||
assert!(!is_valid_plugin_name("has.dot"));
|
||||
assert!(!is_valid_plugin_name(&"a".repeat(65)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_manifest() {
|
||||
let json = r#"{"name": "my-plugin"}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(manifest.name, "my-plugin");
|
||||
assert!(manifest.version.is_none());
|
||||
assert!(manifest.description.is_none());
|
||||
assert!(manifest.skills.is_none());
|
||||
manifest.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_manifest() {
|
||||
let json = r#"{
|
||||
"name": "deployment-tools",
|
||||
"version": "1.2.0",
|
||||
"description": "Tools for deployment",
|
||||
"author": {"name": "Test", "email": "test@example.com"},
|
||||
"homepage": "https://example.com",
|
||||
"repository": "https://github.com/example/plugin",
|
||||
"license": "MIT",
|
||||
"keywords": ["ci-cd", "deploy"],
|
||||
"skills": "./custom/skills/",
|
||||
"agents": "./custom-agents/",
|
||||
"hooks": "./config/hooks.json",
|
||||
"mcpServers": "./mcp-config.json"
|
||||
}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(manifest.name, "deployment-tools");
|
||||
assert_eq!(manifest.version.as_deref(), Some("1.2.0"));
|
||||
assert_eq!(manifest.keywords, vec!["ci-cd", "deploy"]);
|
||||
assert!(matches!(manifest.skills, Some(PathOrPaths::Single(_))));
|
||||
manifest.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
"name": "my-plugin",
|
||||
"marketplace": true,
|
||||
"installState": "active",
|
||||
"futureField": {"nested": "value"},
|
||||
"outputStyles": "./styles/"
|
||||
}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(manifest.name, "my-plugin");
|
||||
manifest.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_inline_hooks() {
|
||||
let json = r#"{
|
||||
"name": "my-plugin",
|
||||
"hooks": {
|
||||
"hooks": {
|
||||
"PostToolUse": [{"hooks": [{"type": "command", "command": "lint"}]}]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert!(manifest.inline_hooks().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_inline_mcp() {
|
||||
let json = r#"{
|
||||
"name": "my-plugin",
|
||||
"mcpServers": {
|
||||
"mcpServers": {
|
||||
"database": {
|
||||
"command": "./servers/db-server",
|
||||
"args": ["--config", "./config.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert!(manifest.inline_mcp_servers().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_multiple_skill_paths() {
|
||||
let json = r#"{
|
||||
"name": "my-plugin",
|
||||
"skills": ["./skills-a/", "./skills-b/"]
|
||||
}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
match manifest.skills.unwrap() {
|
||||
PathOrPaths::Multiple(paths) => {
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_eq!(paths[0], "./skills-a/");
|
||||
assert_eq!(paths[1], "./skills-b/");
|
||||
}
|
||||
_ => panic!("expected Multiple"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_from_dirname_basic() {
|
||||
assert_eq!(
|
||||
name_from_dirname(Path::new("/home/user/my-plugin")),
|
||||
Some("my-plugin".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
name_from_dirname(Path::new("/path/to/MyPlugin")),
|
||||
Some("myplugin".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
name_from_dirname(Path::new("/path/to/my_plugin")),
|
||||
Some("my-plugin".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
name_from_dirname(Path::new("/path/to/---")),
|
||||
None // all hyphens after trim
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_manifest_from_tempdir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugin_root = tmp.path().join("my-plugin");
|
||||
std::fs::create_dir_all(&plugin_root).unwrap();
|
||||
|
||||
// No manifest file
|
||||
match load_manifest(&plugin_root).unwrap() {
|
||||
ManifestLoadResult::NotFound => {}
|
||||
_ => panic!("expected NotFound"),
|
||||
}
|
||||
|
||||
// Write root plugin.json
|
||||
let manifest_path = plugin_root.join("plugin.json");
|
||||
std::fs::write(
|
||||
&manifest_path,
|
||||
r#"{"name": "my-plugin", "version": "0.1.0"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match load_manifest(&plugin_root).unwrap() {
|
||||
ManifestLoadResult::Found(m) => {
|
||||
assert_eq!(m.name, "my-plugin");
|
||||
assert_eq!(m.version.as_deref(), Some("0.1.0"));
|
||||
}
|
||||
_ => panic!("expected Found"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_manifest_fallback_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugin_root = tmp.path().join("fallback-plugin");
|
||||
std::fs::create_dir_all(plugin_root.join(".kigi-plugin")).unwrap();
|
||||
|
||||
// Write manifest in .kigi-plugin/ fallback location
|
||||
std::fs::write(
|
||||
plugin_root.join(".kigi-plugin/plugin.json"),
|
||||
r#"{"name": "fallback-plugin"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match load_manifest(&plugin_root).unwrap() {
|
||||
ManifestLoadResult::Found(m) => assert_eq!(m.name, "fallback-plugin"),
|
||||
_ => panic!("expected Found"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_manifest_root_wins_over_fallback() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugin_root = tmp.path().join("priority-test");
|
||||
std::fs::create_dir_all(plugin_root.join(".kigi-plugin")).unwrap();
|
||||
|
||||
// Write both root and fallback
|
||||
std::fs::write(plugin_root.join("plugin.json"), r#"{"name": "root-wins"}"#).unwrap();
|
||||
std::fs::write(
|
||||
plugin_root.join(".kigi-plugin/plugin.json"),
|
||||
r#"{"name": "fallback-loses"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match load_manifest(&plugin_root).unwrap() {
|
||||
ManifestLoadResult::Found(m) => assert_eq!(m.name, "root-wins"),
|
||||
_ => panic!("expected Found"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_invalid_name() {
|
||||
let json = r#"{"name": "INVALID_NAME"}"#;
|
||||
let manifest: PluginManifest = serde_json::from_str(json).unwrap();
|
||||
assert!(manifest.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitute_env_vars_replaces_all() {
|
||||
let input = "${KIGI_PLUGIN_ROOT}/bin:${CLAUDE_PLUGIN_ROOT}/lib:${KIGI_PLUGIN_DATA}/cache";
|
||||
let result = substitute_env_vars(input, "/home/user/plugin", "/home/user/.data/plugin");
|
||||
assert_eq!(
|
||||
result,
|
||||
"/home/user/plugin/bin:/home/user/plugin/lib:/home/user/.data/plugin/cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_dirs_default_convention() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("test-plugin");
|
||||
std::fs::create_dir_all(root.join("skills")).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "test-plugin".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: None,
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
lsp_servers: None,
|
||||
};
|
||||
let dirs = manifest.skill_dirs(&root);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert!(dirs[0].ends_with("skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_dirs_no_default_when_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("no-skills");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "no-skills".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: None,
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
lsp_servers: None,
|
||||
};
|
||||
let dirs = manifest.skill_dirs(&root);
|
||||
assert!(dirs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_escape_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("contained");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// Create an outside directory
|
||||
let outside = tmp.path().join("outside-skills");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "escape-test".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: Some(PathOrPaths::Single("../outside-skills".to_string())),
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
lsp_servers: None,
|
||||
};
|
||||
let dirs = manifest.skill_dirs(&root);
|
||||
assert!(
|
||||
dirs.is_empty(),
|
||||
"path escaping plugin root should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_within_root_accepted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("plugin");
|
||||
std::fs::create_dir_all(root.join("custom-skills")).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "within-test".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: Some(PathOrPaths::Single("custom-skills".to_string())),
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
lsp_servers: None,
|
||||
};
|
||||
let dirs = manifest.skill_dirs(&root);
|
||||
assert_eq!(dirs.len(), 1, "path within plugin root should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_path_escape_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("plugin");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
// Create a hooks file outside the plugin root
|
||||
let outside = tmp.path().join("outside-hooks.json");
|
||||
std::fs::write(&outside, r#"{"hooks":{}}"#).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "escape-hooks".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: None,
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: Some(PathOrInline::Path("../outside-hooks.json".to_string())),
|
||||
mcp_servers: None,
|
||||
lsp_servers: None,
|
||||
};
|
||||
assert!(
|
||||
manifest.hooks_path(&root).is_none(),
|
||||
"hooks path escaping plugin root should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_path_escape_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("plugin");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let outside = tmp.path().join("outside-mcp.json");
|
||||
std::fs::write(&outside, r#"{"mcpServers":{}}"#).unwrap();
|
||||
|
||||
let manifest = PluginManifest {
|
||||
name: "escape-mcp".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: None,
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: None,
|
||||
mcp_servers: Some(PathOrInline::Path("../outside-mcp.json".to_string())),
|
||||
lsp_servers: None,
|
||||
};
|
||||
assert!(
|
||||
manifest.mcp_config_path(&root).is_none(),
|
||||
"MCP path escaping plugin root should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
fn manifest_with_inline_mcp(servers: serde_json::Value) -> PluginManifest {
|
||||
PluginManifest {
|
||||
name: "sentry".into(),
|
||||
version: None,
|
||||
description: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: vec![],
|
||||
skills: None,
|
||||
commands: None,
|
||||
agents: None,
|
||||
hooks: None,
|
||||
mcp_servers: Some(PathOrInline::Inline(servers)),
|
||||
lsp_servers: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_inline_mcp_servers_wraps_direct_map() {
|
||||
let direct = serde_json::json!({
|
||||
"sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" }
|
||||
});
|
||||
let normalized = normalize_inline_mcp_servers(&direct);
|
||||
let servers = normalized
|
||||
.get("mcpServers")
|
||||
.and_then(|v| v.as_object())
|
||||
.unwrap();
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert!(servers.contains_key("sentry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_inline_mcp_servers_idempotent_for_wrapped() {
|
||||
let wrapped = serde_json::json!({
|
||||
"mcpServers": { "sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" } }
|
||||
});
|
||||
assert_eq!(normalize_inline_mcp_servers(&wrapped), wrapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_config_path_inline_does_not_suppress_sibling_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("sentry");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".mcp.json"),
|
||||
r#"{"mcpServers":{"sentry":{"type":"http","url":"https://mcp.sentry.dev/mcp"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let manifest = manifest_with_inline_mcp(serde_json::json!({
|
||||
"sentry": { "type": "http", "url": "https://mcp.sentry.dev/mcp" }
|
||||
}));
|
||||
|
||||
let resolved = manifest.mcp_config_path(&root);
|
||||
assert!(
|
||||
resolved.as_ref().is_some_and(|p| p.ends_with(".mcp.json")),
|
||||
"inline mcpServers must not hide a sibling .mcp.json"
|
||||
);
|
||||
assert!(manifest.inline_mcp_servers().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_config_path_inline_without_file_is_none() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("inline-only");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
|
||||
let manifest = manifest_with_inline_mcp(serde_json::json!({
|
||||
"foo": { "command": "./server" }
|
||||
}));
|
||||
assert!(manifest.mcp_config_path(&root).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
//! Marketplace plugin discovery.
|
||||
//!
|
||||
//! Sources:
|
||||
//! - `extraKnownMarketplaces` in `.claude/settings.json` (project-level)
|
||||
//! - `~/.claude/plugins/known_marketplaces.json` (user-level registry)
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedMarketplace {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
pub plugin_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Resolve marketplaces and their enabled plugins from `extraKnownMarketplaces`
|
||||
/// and `enabledPlugins` in `.claude/settings.json`. Local directory sources only.
|
||||
pub fn resolve(git_root: &Path) -> Vec<ResolvedMarketplace> {
|
||||
let settings_path = git_root.join(".claude").join("settings.json");
|
||||
let json: serde_json::Value = match std::fs::read_to_string(&settings_path) {
|
||||
Ok(c) => match serde_json::from_str(&c) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "malformed .claude/settings.json");
|
||||
return vec![];
|
||||
}
|
||||
},
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
|
||||
let enabled = enabled_plugin_names(&json);
|
||||
if enabled.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Some(marketplaces) = json
|
||||
.get("extraKnownMarketplaces")
|
||||
.and_then(|v| v.as_object())
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (name, config) in marketplaces {
|
||||
let Some(rel_path) = config
|
||||
.get("source")
|
||||
.and_then(|s| s.get("path"))
|
||||
.and_then(|p| p.as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let marketplace_path = git_root.join(rel_path);
|
||||
let plugins_dir = marketplace_path.join("plugins");
|
||||
let Ok(entries) = std::fs::read_dir(&plugins_dir) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut plugin_dirs = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let plugin_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if enabled.contains(plugin_name) {
|
||||
tracing::info!(marketplace = %name, plugin = plugin_name, "marketplace plugin");
|
||||
plugin_dirs.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
result.push(ResolvedMarketplace {
|
||||
name: name.clone(),
|
||||
path: marketplace_path,
|
||||
plugin_dirs,
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Enabled plugin names from `enabledPlugins` (`"name@marketplace"` keys).
|
||||
fn enabled_plugin_names(json: &serde_json::Value) -> HashSet<String> {
|
||||
json.get("enabledPlugins")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter(|(_, v)| v.as_bool().unwrap_or(false))
|
||||
.filter_map(|(k, _)| k.split('@').next().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parse `enabledPlugins` from a settings JSON value into enabled/disabled lists.
|
||||
///
|
||||
/// The `enabledPlugins` object has keys like `"name@marketplace"` with boolean values.
|
||||
/// Keys with `true` are returned in the first vec (enabled), `false` in the second (disabled).
|
||||
/// The `@marketplace` suffix is stripped — only the plugin name is returned.
|
||||
pub fn parse_enabled_disabled_plugins(json: &serde_json::Value) -> (Vec<String>, Vec<String>) {
|
||||
let Some(obj) = json.get("enabledPlugins").and_then(|v| v.as_object()) else {
|
||||
return (vec![], vec![]);
|
||||
};
|
||||
// Deduplicate by plugin name: the same name may appear under different
|
||||
// marketplace keys (e.g. "foo@market1": true, "foo@market2": false).
|
||||
// If any entry for a name is `false`, the plugin is disabled (safe default).
|
||||
let mut state: HashMap<String, bool> = HashMap::new();
|
||||
for (key, val) in obj {
|
||||
let name = key.split('@').next().unwrap_or(key).to_string();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = val.as_bool() else {
|
||||
continue;
|
||||
};
|
||||
let entry = state.entry(name).or_insert(value);
|
||||
// disabled (false) wins on conflict
|
||||
if !value {
|
||||
*entry = false;
|
||||
}
|
||||
}
|
||||
let mut enabled = Vec::new();
|
||||
let mut disabled = Vec::new();
|
||||
for (name, is_enabled) in state {
|
||||
if is_enabled {
|
||||
enabled.push(name);
|
||||
} else {
|
||||
disabled.push(name);
|
||||
}
|
||||
}
|
||||
(enabled, disabled)
|
||||
}
|
||||
|
||||
/// Load and parse `enabledPlugins` from a `.claude/settings.json` file path.
|
||||
///
|
||||
/// Returns `(enabled, disabled)` plugin name lists.
|
||||
/// Returns empty vecs if the file is missing or malformed.
|
||||
pub fn load_enabled_disabled_plugins(path: &Path) -> (Vec<String>, Vec<String>) {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
let json: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
parse_enabled_disabled_plugins(&json)
|
||||
}
|
||||
|
||||
// ── Compat known_marketplaces.json ────────────────────────────────────
|
||||
|
||||
/// Entry in `~/.claude/plugins/known_marketplaces.json`.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KnownMarketplaceEntry {
|
||||
install_location: PathBuf,
|
||||
}
|
||||
|
||||
/// Resolve user-level marketplaces from `known_marketplaces.json`.
|
||||
///
|
||||
/// Returns marketplace entries with their local `installLocation` paths.
|
||||
/// Plugin dirs are filtered to names present in user-level
|
||||
/// `~/.claude/settings{.local}.json` `enabledPlugins` with any value (a
|
||||
/// `false` entry is an installed-but-disabled plugin whose state we
|
||||
/// mirror), so never-installed catalog plugins are not discovered.
|
||||
pub fn resolve_known_marketplaces() -> Vec<ResolvedMarketplace> {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return vec![];
|
||||
};
|
||||
resolve_known_marketplaces_in(&home.join(".claude"))
|
||||
}
|
||||
|
||||
/// Like [`resolve_known_marketplaces`] but reads from an explicit `~/.claude`
|
||||
/// root, so tests stay isolated from the developer's real home dir.
|
||||
pub fn resolve_known_marketplaces_in(claude_dir: &Path) -> Vec<ResolvedMarketplace> {
|
||||
let json_path = claude_dir.join("plugins").join("known_marketplaces.json");
|
||||
let content = match std::fs::read_to_string(&json_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let registry: HashMap<String, KnownMarketplaceEntry> = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to parse known_marketplaces.json");
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let installed = installed_plugin_keys(claude_dir);
|
||||
|
||||
registry
|
||||
.into_iter()
|
||||
.filter_map(|(name, entry)| {
|
||||
let path = entry.install_location;
|
||||
if !path.is_dir() {
|
||||
return None;
|
||||
}
|
||||
// Collect plugin subdirectories from plugins/ and external_plugins/
|
||||
let mut plugin_dirs = Vec::new();
|
||||
for subdir in &["plugins", "external_plugins"] {
|
||||
let dir = path.join(subdir);
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let plugin_name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let is_installed = match installed.get(plugin_name) {
|
||||
Some(None) => true,
|
||||
Some(Some(marketplaces)) => marketplaces.contains(name.as_str()),
|
||||
None => false,
|
||||
};
|
||||
if is_installed {
|
||||
plugin_dirs.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ResolvedMarketplace {
|
||||
name,
|
||||
path,
|
||||
plugin_dirs,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `enabledPlugins` keys from `<claude_dir>/settings.local.json` and
|
||||
/// `<claude_dir>/settings.json`, keyed by plugin name. `None` = a bare key
|
||||
/// (matches any marketplace); `Some(set)` = only those marketplaces.
|
||||
/// Entries with any boolean value count; non-boolean values are skipped.
|
||||
fn installed_plugin_keys(claude_dir: &Path) -> HashMap<String, Option<HashSet<String>>> {
|
||||
let mut keys: HashMap<String, Option<HashSet<String>>> = HashMap::new();
|
||||
for settings_name in ["settings.local.json", "settings.json"] {
|
||||
let path = claude_dir.join(settings_name);
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let json: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "malformed settings.json");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(obj) = json.get("enabledPlugins").and_then(|v| v.as_object()) else {
|
||||
continue;
|
||||
};
|
||||
for (key, value) in obj {
|
||||
if !value.is_boolean() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = key.splitn(2, '@');
|
||||
let Some(plugin_name) = parts.next().filter(|n| !n.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
match parts.next() {
|
||||
Some(marketplace) => {
|
||||
if let Some(marketplaces) = keys
|
||||
.entry(plugin_name.to_string())
|
||||
.or_insert_with(|| Some(HashSet::new()))
|
||||
{
|
||||
marketplaces.insert(marketplace.to_string());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
keys.insert(plugin_name.to_string(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_enabled_disabled_both() {
|
||||
let json = serde_json::json!({
|
||||
"enabledPlugins": {
|
||||
"alpha@marketplace": true,
|
||||
"beta@marketplace": false,
|
||||
"gamma@other": true
|
||||
}
|
||||
});
|
||||
let (enabled, disabled) = parse_enabled_disabled_plugins(&json);
|
||||
assert!(enabled.contains(&"alpha".to_string()));
|
||||
assert!(enabled.contains(&"gamma".to_string()));
|
||||
assert_eq!(enabled.len(), 2);
|
||||
assert_eq!(disabled.len(), 1);
|
||||
assert!(disabled.contains(&"beta".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enabled_disabled_empty() {
|
||||
let json = serde_json::json!({});
|
||||
let (enabled, disabled) = parse_enabled_disabled_plugins(&json);
|
||||
assert!(enabled.is_empty());
|
||||
assert!(disabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enabled_disabled_no_at_sign() {
|
||||
let json = serde_json::json!({
|
||||
"enabledPlugins": {
|
||||
"plain-name": true,
|
||||
"other-name": false
|
||||
}
|
||||
});
|
||||
let (enabled, disabled) = parse_enabled_disabled_plugins(&json);
|
||||
assert_eq!(enabled.len(), 1);
|
||||
assert!(enabled.contains(&"plain-name".to_string()));
|
||||
assert_eq!(disabled.len(), 1);
|
||||
assert!(disabled.contains(&"other-name".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enabled_disabled_skips_non_bool() {
|
||||
let json = serde_json::json!({
|
||||
"enabledPlugins": {
|
||||
"good@m": true,
|
||||
"bad@m": "yes",
|
||||
"ugly@m": 42
|
||||
}
|
||||
});
|
||||
let (enabled, disabled) = parse_enabled_disabled_plugins(&json);
|
||||
assert_eq!(enabled.len(), 1);
|
||||
assert!(enabled.contains(&"good".to_string()));
|
||||
assert!(disabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_enabled_disabled_missing_file() {
|
||||
let (enabled, disabled) =
|
||||
load_enabled_disabled_plugins(Path::new("/nonexistent/settings.json"));
|
||||
assert!(enabled.is_empty());
|
||||
assert!(disabled.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_enabled_disabled_from_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("settings.json");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"enabledPlugins": {"foo@m": true, "bar@m": false}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (enabled, disabled) = load_enabled_disabled_plugins(&path);
|
||||
assert_eq!(enabled.len(), 1);
|
||||
assert!(enabled.contains(&"foo".to_string()));
|
||||
assert_eq!(disabled.len(), 1);
|
||||
assert!(disabled.contains(&"bar".to_string()));
|
||||
}
|
||||
|
||||
/// Build a `~/.claude`-style dir with one known marketplace named `mp`
|
||||
/// containing `plugins/{alpha,beta}` and `external_plugins/gamma`, plus a
|
||||
/// `settings.json` with the given content (skipped when `None`).
|
||||
fn make_known_marketplace(
|
||||
tmp: &Path,
|
||||
settings_json: Option<&str>,
|
||||
) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let claude_dir = tmp.join(".claude");
|
||||
let mp_dir = tmp.join("mp-repo");
|
||||
for plugin in ["plugins/alpha", "plugins/beta", "external_plugins/gamma"] {
|
||||
std::fs::create_dir_all(mp_dir.join(plugin)).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(claude_dir.join("plugins")).unwrap();
|
||||
let known = serde_json::json!({
|
||||
"mp": { "installLocation": mp_dir.to_string_lossy() }
|
||||
});
|
||||
std::fs::write(
|
||||
claude_dir.join("plugins").join("known_marketplaces.json"),
|
||||
serde_json::to_string(&known).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(settings) = settings_json {
|
||||
std::fs::write(claude_dir.join("settings.json"), settings).unwrap();
|
||||
}
|
||||
(claude_dir, mp_dir)
|
||||
}
|
||||
|
||||
fn plugin_dir_names(marketplaces: &[ResolvedMarketplace]) -> Vec<String> {
|
||||
let mut names: Vec<String> = marketplaces
|
||||
.iter()
|
||||
.flat_map(|m| &m.plugin_dirs)
|
||||
.filter_map(|d| d.file_name().and_then(|n| n.to_str()).map(String::from))
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_filtered_to_enabled_plugins_including_false() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// `gamma@mp: false` = installed-but-disabled: still discovered.
|
||||
// `beta` is not listed at all: a never-installed catalog entry.
|
||||
let (claude_dir, mp_dir) = make_known_marketplace(
|
||||
tmp.path(),
|
||||
Some(r#"{"enabledPlugins": {"alpha@mp": true, "gamma@mp": false}}"#),
|
||||
);
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].name, "mp");
|
||||
assert_eq!(resolved[0].path, mp_dir);
|
||||
assert_eq!(
|
||||
plugin_dir_names(&resolved),
|
||||
vec!["alpha".to_string(), "gamma".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_key_with_other_marketplace_does_not_match() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (claude_dir, _) = make_known_marketplace(
|
||||
tmp.path(),
|
||||
Some(r#"{"enabledPlugins": {"alpha@other": true}}"#),
|
||||
);
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert!(plugin_dir_names(&resolved).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_unqualified_key_matches_any_marketplace() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (claude_dir, _) =
|
||||
make_known_marketplace(tmp.path(), Some(r#"{"enabledPlugins": {"alpha": true}}"#));
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(plugin_dir_names(&resolved), vec!["alpha".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_no_settings_yields_no_plugin_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (claude_dir, _) = make_known_marketplace(tmp.path(), None);
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(resolved.len(), 1, "marketplace entry itself is kept");
|
||||
assert!(plugin_dir_names(&resolved).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_reads_settings_local_json_too() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (claude_dir, _) = make_known_marketplace(
|
||||
tmp.path(),
|
||||
Some(r#"{"enabledPlugins": {"alpha@mp": true}}"#),
|
||||
);
|
||||
std::fs::write(
|
||||
claude_dir.join("settings.local.json"),
|
||||
r#"{"enabledPlugins": {"gamma@mp": false}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(
|
||||
plugin_dir_names(&resolved),
|
||||
vec!["alpha".to_string(), "gamma".to_string()],
|
||||
"keys from settings.local.json and settings.json must both count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marketplaces_non_bool_enabled_value_skipped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (claude_dir, _) = make_known_marketplace(
|
||||
tmp.path(),
|
||||
Some(r#"{"enabledPlugins": {"alpha@mp": "yes", "beta@mp": true}}"#),
|
||||
);
|
||||
|
||||
let resolved = resolve_known_marketplaces_in(&claude_dir);
|
||||
assert_eq!(plugin_dir_names(&resolved), vec!["beta".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enabled_disabled_conflict_disabled_wins() {
|
||||
// Same plugin name from different marketplaces with conflicting values:
|
||||
// disabled (false) should win.
|
||||
let json = serde_json::json!({
|
||||
"enabledPlugins": {
|
||||
"conflict@market1": true,
|
||||
"conflict@market2": false
|
||||
}
|
||||
});
|
||||
let (enabled, disabled) = parse_enabled_disabled_plugins(&json);
|
||||
assert!(enabled.is_empty());
|
||||
assert_eq!(disabled.len(), 1);
|
||||
assert!(disabled.contains(&"conflict".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Plugin system — discover, load, and manage plugins (including compat layouts).
|
||||
//!
|
||||
//! A plugin is a self-contained directory that bundles skills, agents,
|
||||
//! MCP server configs, and hooks into a namespaced unit. Plugins can
|
||||
//! live under `~/.kigi/plugins/`, `.kigi/plugins/` (project-level),
|
||||
//! or be passed via `--plugin-dir` on the CLI.
|
||||
//!
|
||||
//! This module handles:
|
||||
//! - `manifest` — parsing `plugin.json` manifests
|
||||
//! - `discovery` — scanning the filesystem for plugin directories
|
||||
//! - `trust` — project-plugin trust management
|
||||
//! - `registry` — in-memory registry of active plugins
|
||||
|
||||
pub mod discovery;
|
||||
pub mod git_install;
|
||||
pub mod hooks_adapter;
|
||||
pub mod install_registry;
|
||||
pub mod local_refresh;
|
||||
pub mod manifest;
|
||||
pub mod marketplace;
|
||||
pub mod registry;
|
||||
pub mod trust;
|
||||
|
||||
pub use discovery::{
|
||||
DiscoveredPlugin, PluginOrigin, PluginScope, discover_plugins, project_plugin_dirs,
|
||||
project_plugin_dirs_in,
|
||||
};
|
||||
pub use hooks_adapter::parse_plugin_hooks;
|
||||
pub use install_registry::InstallRegistry;
|
||||
pub use manifest::PluginManifest;
|
||||
pub use registry::{LoadedPlugin, PluginRegistry, SharedPluginRegistryHandle};
|
||||
pub use trust::TrustStore;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,365 @@
|
||||
//! Project plugin trust management.
|
||||
//!
|
||||
//! Plugins from project directories (`.kigi/plugins/`, `.claude/plugins/`)
|
||||
//! are an execution surface. A cloned repository could contain plugins with
|
||||
//! hook scripts or MCP server commands that run arbitrary code.
|
||||
//!
|
||||
//! **Trust granularity**: per-plugin-root (not per-worktree). Trusting one
|
||||
//! plugin in a repo does not automatically trust other plugins in the same repo.
|
||||
//!
|
||||
//! **Trust key**: canonical absolute path of the plugin root directory,
|
||||
//! resolved via `dunce::canonicalize()`.
|
||||
//!
|
||||
//! **Trust storage**: `~/.kigi/trusted-plugins` (one canonical path per line).
|
||||
//!
|
||||
//! **Behavior for untrusted plugins**:
|
||||
//! - Skills and agents are **discovered and listed** (metadata-only).
|
||||
//! - Hooks, MCP servers, and scripts are **blocked**.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io::{BufRead, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Name of the trust-store file under `~/.kigi/`.
|
||||
const TRUST_FILE_NAME: &str = "trusted-plugins";
|
||||
|
||||
/// Manages the set of trusted plugin root directories.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrustStore {
|
||||
/// Canonical paths of trusted plugin roots.
|
||||
trusted: HashSet<PathBuf>,
|
||||
/// Path to the trust-store file on disk.
|
||||
file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl TrustStore {
|
||||
/// Load the trust store from disk.
|
||||
///
|
||||
/// If `~/.kigi/trusted-plugins` does not exist, returns an empty store.
|
||||
/// If the file cannot be read, logs a warning and returns an empty store.
|
||||
pub fn load() -> Self {
|
||||
// Gate on user_kigi_home() so a project's `.kigi/trusted-plugins` is never
|
||||
// read as the user trust store when neither KIGI_SHARE_DIR nor a home dir resolves.
|
||||
let Some(grok) = kigi_config::user_kigi_home() else {
|
||||
return Self {
|
||||
trusted: HashSet::new(),
|
||||
file_path: PathBuf::new(),
|
||||
};
|
||||
};
|
||||
let file_path = grok.join(TRUST_FILE_NAME);
|
||||
let trusted = Self::read_trust_file(&file_path);
|
||||
Self { trusted, file_path }
|
||||
}
|
||||
|
||||
/// Load from a custom file path (for testing).
|
||||
pub fn load_from(file_path: PathBuf) -> Self {
|
||||
let trusted = Self::read_trust_file(&file_path);
|
||||
Self { trusted, file_path }
|
||||
}
|
||||
|
||||
/// Check whether a plugin root directory is trusted.
|
||||
///
|
||||
/// Canonicalizes the path before lookup. Returns `false` if
|
||||
/// canonicalization fails (broken symlink, permission error).
|
||||
pub fn is_trusted(&self, plugin_root: &Path) -> bool {
|
||||
match dunce::canonicalize(plugin_root) {
|
||||
Ok(canonical) => self.trusted.contains(&canonical),
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
path = %plugin_root.display(),
|
||||
"failed to canonicalize plugin root for trust check; treating as untrusted"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Grant trust to a plugin root directory.
|
||||
///
|
||||
/// Canonicalizes the path and appends it to `~/.kigi/trusted-plugins`.
|
||||
/// If the path is already trusted, this is a no-op and returns `Ok(())`.
|
||||
pub fn grant_trust(&mut self, plugin_root: &Path) -> Result<(), TrustError> {
|
||||
let canonical =
|
||||
dunce::canonicalize(plugin_root).map_err(|e| TrustError::CanonicalizeFailed {
|
||||
path: plugin_root.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if self.trusted.contains(&canonical) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = self.file_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| TrustError::IoError {
|
||||
path: parent.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
|
||||
// Append to file
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.file_path)
|
||||
.map_err(|e| TrustError::IoError {
|
||||
path: self.file_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
writeln!(file, "{}", canonical.display()).map_err(|e| TrustError::IoError {
|
||||
path: self.file_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
self.trusted.insert(canonical);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke trust for a plugin root directory.
|
||||
///
|
||||
/// Canonicalizes the path, removes it from the in-memory set, and
|
||||
/// rewrites `~/.kigi/trusted-plugins` without the revoked entry.
|
||||
/// If the path is not currently trusted, this is a no-op.
|
||||
pub fn revoke_trust(&mut self, plugin_root: &Path) -> Result<(), TrustError> {
|
||||
let canonical =
|
||||
dunce::canonicalize(plugin_root).map_err(|e| TrustError::CanonicalizeFailed {
|
||||
path: plugin_root.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
if !self.trusted.remove(&canonical) {
|
||||
return Ok(()); // wasn't trusted
|
||||
}
|
||||
|
||||
// Rewrite the entire file without the revoked path
|
||||
self.rewrite_trust_file()
|
||||
}
|
||||
|
||||
/// Rewrite the trust file from the current in-memory set.
|
||||
fn rewrite_trust_file(&self) -> Result<(), TrustError> {
|
||||
if let Some(parent) = self.file_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| TrustError::IoError {
|
||||
path: parent.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut file = std::fs::File::create(&self.file_path).map_err(|e| TrustError::IoError {
|
||||
path: self.file_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
use std::io::Write;
|
||||
for path in &self.trusted {
|
||||
writeln!(file, "{}", path.display()).map_err(|e| TrustError::IoError {
|
||||
path: self.file_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether a config-path plugin should be auto-trusted.
|
||||
///
|
||||
/// A `[plugins].paths` entry is auto-trusted if its canonicalized path
|
||||
/// is under the user's home directory. Otherwise it requires explicit
|
||||
/// trust via `~/.kigi/trusted-plugins`.
|
||||
pub fn is_config_path_auto_trusted(plugin_root: &Path) -> bool {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return false;
|
||||
};
|
||||
match dunce::canonicalize(plugin_root) {
|
||||
Ok(canonical) => canonical.starts_with(&home),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────
|
||||
|
||||
fn read_trust_file(path: &Path) -> HashSet<PathBuf> {
|
||||
let file = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashSet::new(),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"failed to read trust store; no plugins will be trusted"
|
||||
);
|
||||
return HashSet::new();
|
||||
}
|
||||
};
|
||||
|
||||
let reader = std::io::BufReader::new(file);
|
||||
reader
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.ok()?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
// Entries may predate dunce (Windows \\?\ verbatim form); simplify so lookups match.
|
||||
Some(dunce::simplified(Path::new(trimmed)).to_path_buf())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Errors ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TrustError {
|
||||
#[error("failed to canonicalize path {path}: {source}")]
|
||||
CanonicalizeFailed {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("I/O error on {path}: {source}")]
|
||||
IoError {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_trust_store() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
let store = TrustStore::load_from(trust_file);
|
||||
// Nothing is trusted
|
||||
assert!(!store.is_trusted(tmp.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grant_and_check_trust() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
|
||||
let plugin_dir = tmp.path().join("my-plugin");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
let mut store = TrustStore::load_from(trust_file.clone());
|
||||
assert!(!store.is_trusted(&plugin_dir));
|
||||
|
||||
store.grant_trust(&plugin_dir).unwrap();
|
||||
assert!(store.is_trusted(&plugin_dir));
|
||||
|
||||
// Granting again is a no-op
|
||||
store.grant_trust(&plugin_dir).unwrap();
|
||||
|
||||
// Reload from disk and verify persistence
|
||||
let reloaded = TrustStore::load_from(trust_file);
|
||||
assert!(reloaded.is_trusted(&plugin_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_file_skips_comments_and_blanks() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
|
||||
let plugin_dir = tmp.path().join("real-plugin");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
let canonical = dunce::canonicalize(&plugin_dir).unwrap();
|
||||
|
||||
// Write file with comments and blank lines
|
||||
std::fs::write(
|
||||
&trust_file,
|
||||
format!(
|
||||
"# This is a comment\n\n{}\n \n# Another comment\n",
|
||||
canonical.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = TrustStore::load_from(trust_file);
|
||||
assert!(store.is_trusted(&plugin_dir));
|
||||
}
|
||||
|
||||
/// Legacy entries written under std canonicalize use the verbatim `\\?\`
|
||||
/// form; `read_trust_file` must normalize them so lookups keep matching.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn legacy_verbatim_entry_is_trusted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
|
||||
let plugin_dir = tmp.path().join("legacy-plugin");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
let canonical = dunce::canonicalize(&plugin_dir).unwrap();
|
||||
std::fs::write(&trust_file, format!("\\\\?\\{}\n", canonical.display())).unwrap();
|
||||
|
||||
let mut store = TrustStore::load_from(trust_file.clone());
|
||||
assert!(store.is_trusted(&plugin_dir));
|
||||
|
||||
// Revoke rewrites the file in simplified form, dropping the legacy line.
|
||||
store.revoke_trust(&plugin_dir).unwrap();
|
||||
assert!(!TrustStore::load_from(trust_file).is_trusted(&plugin_dir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonexistent_path_is_not_trusted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
let store = TrustStore::load_from(trust_file);
|
||||
|
||||
// Path that doesn't exist on disk
|
||||
let fake = tmp.path().join("does-not-exist");
|
||||
assert!(!store.is_trusted(&fake));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_path_auto_trust_under_home() {
|
||||
// This test checks the logic but can't easily mock $HOME.
|
||||
// We verify the function exists and returns a boolean.
|
||||
let result = TrustStore::is_config_path_auto_trusted(Path::new("/nonexistent/path"));
|
||||
assert!(!result); // nonexistent path can't be canonicalized
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_trust_removes_from_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
|
||||
let plugin_a = tmp.path().join("plugin-a");
|
||||
let plugin_b = tmp.path().join("plugin-b");
|
||||
std::fs::create_dir_all(&plugin_a).unwrap();
|
||||
std::fs::create_dir_all(&plugin_b).unwrap();
|
||||
|
||||
let mut store = TrustStore::load_from(trust_file.clone());
|
||||
store.grant_trust(&plugin_a).unwrap();
|
||||
store.grant_trust(&plugin_b).unwrap();
|
||||
assert!(store.is_trusted(&plugin_a));
|
||||
assert!(store.is_trusted(&plugin_b));
|
||||
|
||||
// Revoke plugin_a
|
||||
store.revoke_trust(&plugin_a).unwrap();
|
||||
assert!(!store.is_trusted(&plugin_a));
|
||||
assert!(store.is_trusted(&plugin_b));
|
||||
|
||||
// Verify persistence
|
||||
let reloaded = TrustStore::load_from(trust_file);
|
||||
assert!(!reloaded.is_trusted(&plugin_a));
|
||||
assert!(reloaded.is_trusted(&plugin_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_trust_noop_if_not_trusted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let trust_file = tmp.path().join("trusted-plugins");
|
||||
let plugin = tmp.path().join("some-plugin");
|
||||
std::fs::create_dir_all(&plugin).unwrap();
|
||||
|
||||
let mut store = TrustStore::load_from(trust_file);
|
||||
// Not trusted — revoke should be a no-op
|
||||
store.revoke_trust(&plugin).unwrap();
|
||||
assert!(!store.is_trusted(&plugin));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user