Files
Kigi-CLI/crates/codegen/kigi-agent/src/prompt/skills.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

2687 lines
98 KiB
Rust

//! Skill and command discovery for system prompt injection.
//!
//! Orchestrates priority-based discovery across local, repo, optional
//! workspace-user, user, bundled, config-path, and plugin sources. Parsing primitives
//! live in `kigi_tools::implementations::skills::discovery`.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::plugins::discovery::PluginScope;
use kigi_tools::implementations::skills::types::skill_name_from_path;
pub use kigi_tools::implementations::skills::types::{SkillInfo, SkillScope};
/// Re-export so agent-side discovery (and the shell) can name the resolved
/// vendor-compat config without reaching into `kigi_tools` directly.
pub use kigi_tools::types::compat::CompatConfig;
use kigi_tools::implementations::skills::discovery::{
find_command_paths, find_skill_md_paths, find_skill_paths, is_valid_skill_name,
normalize_skill_name, parse_skill_files, scan_md_files, walk_for_skill_md,
};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct SkillsConfig {
/// Additional skill locations to load. Each entry is a `SKILL.md` file or a
/// directory walked recursively. Supports `~` expansion.
#[serde(default)]
pub paths: Vec<String>,
/// Path prefixes to exclude. Any skill whose resolved path starts with one of
/// these entries is filtered out. Supports `~` expansion.
#[serde(default)]
pub ignore: Vec<String>,
/// Skill names that are disabled. Disabled skills remain in the list
/// (unlike `ignore` which hides them entirely) but are excluded from
/// the system prompt and skill tool invocation.
#[serde(default)]
pub disabled: Vec<String>,
/// Launcher-injected server-synced skill dirs (tagged `Server` scope).
#[serde(default)]
pub server_skill_dirs: Vec<String>,
/// Launcher-injected platform bundled skill dirs (tagged `Bundled` scope).
#[serde(default)]
pub bundled_skill_dirs: Vec<String>,
}
/// List all discovered skills with their metadata.
///
/// Priority order: Local (cwd/.kigi/skills, cwd/.agents/skills, cwd/.claude/skills) → Intermediate dirs →
/// Repo (repo_root/.kigi/skills, repo_root/.agents/skills, repo_root/.claude/skills) → User (~/.kigi/skills, ~/.agents/skills, ~/.claude/skills)
/// → additional paths from `config.paths`
/// → Server (injected `config.server_skill_dirs`)
/// → Bundled (injected `config.bundled_skill_dirs` + `~/.kigi/bundled`; lowest precedence).
///
/// `config.ignore` globs are applied across all sources after collection.
/// Skills with the same name from higher-priority sources override lower-priority ones.
///
/// When `working_directory` is `None`, only User-scoped skills are returned.
///
/// `compat` gates which vendor (`.claude`/`.cursor`) dirs are scanned; pass
/// `CompatConfig::default()` to preserve the historical all-vendors behavior.
pub async fn list_skills(
working_directory: Option<&str>,
config: &SkillsConfig,
compat: CompatConfig,
) -> Vec<SkillInfo> {
list_skills_with_plugins(working_directory, config, None, compat).await
}
/// List all discovered skills including plugin-provided skills.
///
/// When `plugins` is `Some`, skills from enabled plugins are appended with
/// `plugin_name: Some(...)` and `scope` set to the plugin's origin
/// (e.g. `Repo` for `.kigi/plugins/`). Native skills always win bare-name
/// resolution, but qualified plugin entries (`my-plugin:hello`) are
/// preserved even on collision.
pub async fn list_skills_with_plugins(
working_directory: Option<&str>,
config: &SkillsConfig,
plugins: Option<&crate::plugins::PluginRegistry>,
compat: CompatConfig,
) -> Vec<SkillInfo> {
let _skill_discovery_timer = crate::timing::timer("skill_discovery");
let workspace_user_dir = crate::prompt::workspace_user::optional_workspace_user_dir();
let mut skills = list_skills_with_options(
working_directory,
workspace_user_dir.as_deref(),
&kigi_tools::util::kigi_home::kigi_home(),
compat,
)
.await;
let git_root = working_directory.and_then(|wd| {
git2::Repository::discover(wd)
.ok()
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()))
});
skills.extend(collect_config_skills(&config.paths, git_root.as_deref()));
skills.extend(collect_injected_skills(
&config.server_skill_dirs,
SkillScope::Server,
));
skills.extend(collect_injected_skills(
&config.bundled_skill_dirs,
SkillScope::Bundled,
));
let mut skills = filter_skills(skills, &config.ignore);
skills.sort_by_key(|s| s.scope);
let plugin_skills = if let Some(registry) = plugins {
collect_plugin_skills(registry)
} else {
vec![]
};
let mut merged = merge_skills_with_plugins(skills, plugin_skills);
// Mark disabled skills. Disabled skills remain in the list (unlike
// `ignore` which hides them) but are excluded from the system prompt
// and skill tool invocation.
if !config.disabled.is_empty() {
let disabled_set: HashSet<&str> = config.disabled.iter().map(|s| s.as_str()).collect();
for skill in &mut merged {
if disabled_set.contains(skill.name.as_str()) {
skill.enabled = false;
}
}
}
merged
}
/// Canonical source of all config directories that may contain skills.
///
/// Both skill discovery and the file watcher call this function so they
/// agree on which directories matter.
pub fn collect_skill_config_dirs(
cwd: Option<&Path>,
workspace_user_dir: Option<&Path>,
global_dir: &Path,
config_paths: &[String],
compat: CompatConfig,
) -> Vec<PathBuf> {
let kigi_home = global_dir.to_path_buf();
let git_root = cwd.and_then(|c| {
git2::Repository::discover(c)
.ok()
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()))
});
let mut dirs = Vec::new();
let mut seen = HashSet::new();
// Helper: add if the directory exists and hasn't been seen yet.
let mut try_add = |dir: PathBuf| {
if !dir.is_dir() {
return;
}
let canonical = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
if seen.insert(canonical) {
dirs.push(dir);
}
};
// Vendor dirs (`.claude`/`.cursor`) are gated by the resolved compat
// config; `.kigi` and `.agents` are always present. When all cells are on
// this list equals the historical `[".kigi", ".agents", ".claude", ".cursor"]`.
let config_dir_names = compat.skill_config_dirs();
// Priority 1 & 2: Walk from cwd up to the git root.
if let Some(cwd) = cwd {
if let Some(ref root) = git_root {
let mut current = Some(cwd.to_path_buf());
while let Some(dir) = current {
for name in &config_dir_names {
try_add(dir.join(name));
}
if dir == *root {
break;
}
current = dir.parent().map(|p| p.to_path_buf());
}
} else {
for name in &config_dir_names {
try_add(cwd.join(name));
}
}
}
// Priority 2.5: Optional workspace user dir.
if let Some(user_dir) = workspace_user_dir {
for name in &config_dir_names {
try_add(user_dir.join(name));
}
}
// Priority 3: Global user dirs. `.kigi` comes from `kigi_home` (which may
// be overridden), so it's handled separately; `.agents` is always added,
// while `.claude`/`.cursor` are gated by the skills compat cells.
try_add(kigi_home);
#[allow(deprecated)]
if let Some(home) = std::env::home_dir() {
try_add(home.join(".agents"));
if compat.claude.skills {
try_add(home.join(".claude"));
}
if compat.cursor.skills {
try_add(home.join(".cursor"));
}
}
// Priority 4: Config paths (skills.paths entries).
for raw in config_paths {
let expanded = expand_tilde(raw);
if expanded.is_dir() {
try_add(expanded);
} else if expanded.is_file()
&& let Some(parent) = expanded.parent()
{
try_add(parent.to_path_buf());
}
}
dirs
}
/// Determine the skill scope for a config directory based on its location
/// relative to `cwd`, `git_root`, and the user's home directory.
fn scope_for_config_dir(dir: &Path, cwd: Option<&Path>, git_root: Option<&Path>) -> SkillScope {
// Home-level dirs (e.g. ~/.kigi/, ~/.agents/, ~/.claude/) are User scope.
#[allow(deprecated)]
if let Some(home) = std::env::home_dir()
&& dir.parent() == Some(home.as_path())
{
return SkillScope::User;
}
// Dir whose parent is cwd is Local scope.
if let Some(cwd) = cwd
&& dir.parent() == Some(cwd)
{
return SkillScope::Local;
}
// Dir under git root is Repo scope.
if let Some(root) = git_root
&& dir.starts_with(root)
{
return SkillScope::Repo;
}
SkillScope::User
}
/// Collect paths into `out`, deduplicating by canonical path.
///
/// Skill/command discovery does **not** consult `.gitignore`. Auto-discovery
/// only visits known config roots (`.kigi`, `.agents`, `.claude`, `.cursor`),
/// which teams often gitignore as local-only config while still expecting them
/// to load. Hiding a skill uses `[skills] ignore` in config, not repo ignore
/// rules. AGENTS.md discovery still honors gitignore — that is content, not
/// skill roots.
fn collect_discovered_paths(
paths: impl IntoIterator<Item = PathBuf>,
scope: SkillScope,
seen: &mut HashSet<PathBuf>,
out: &mut Vec<(PathBuf, SkillScope)>,
) {
for path in paths {
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
if seen.insert(canonical) {
out.push((path, scope));
}
}
}
/// Discover skills and commands from config dirs, workspace, and bundled paths.
/// Skills are collected before commands so they win name collisions via
/// first-seen-wins dedup. Returns only global skills when `working_directory`
/// is `None`.
async fn list_skills_with_options(
working_directory: Option<&str>,
workspace_user_dir: Option<&Path>,
global_dir: &Path,
compat: CompatConfig,
) -> Vec<SkillInfo> {
let cwd = working_directory.map(PathBuf::from);
let git_root = cwd.as_ref().and_then(|c| {
git2::Repository::discover(c)
.ok()
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()))
});
let config_dirs =
collect_skill_config_dirs(cwd.as_deref(), workspace_user_dir, global_dir, &[], compat);
let mut skill_files: Vec<(PathBuf, SkillScope)> = Vec::new();
let mut seen_canonical_paths = HashSet::new();
for config_dir in &config_dirs {
let scope = scope_for_config_dir(config_dir, cwd.as_deref(), git_root.as_deref());
// Skills before commands: skills win name collisions.
collect_discovered_paths(
find_skill_paths(config_dir),
scope,
&mut seen_canonical_paths,
&mut skill_files,
);
collect_discovered_paths(
find_command_paths(config_dir),
scope,
&mut seen_canonical_paths,
&mut skill_files,
);
}
let bundled_dir = global_dir.join("bundled");
collect_discovered_paths(
find_skill_paths(&bundled_dir),
SkillScope::Bundled,
&mut seen_canonical_paths,
&mut skill_files,
);
parse_skill_files(skill_files)
}
/// Expand a `~`-prefixed path string to an absolute `PathBuf`.
fn expand_tilde(raw: &str) -> PathBuf {
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))
{
return PathBuf::from(home).join(rest);
}
PathBuf::from(raw)
}
/// Collect and parse skills from `SkillsConfig.paths` entries.
///
/// Each entry is either a direct SKILL.md file or a directory to walk recursively.
/// `~` is expanded. Scope is `Repo` if the resolved path falls inside `git_root`,
/// otherwise `User`.
fn collect_config_skills(config_paths: &[String], git_root: Option<&Path>) -> Vec<SkillInfo> {
let mut skill_files: Vec<(PathBuf, SkillScope)> = Vec::new();
let mut seen = HashSet::new();
for raw in config_paths {
let expanded = expand_tilde(raw);
let scope = match git_root {
Some(root) if expanded.starts_with(root) => SkillScope::Repo,
_ => SkillScope::User,
};
if expanded.is_file() && expanded.file_name().is_some_and(|n| n == "SKILL.md") {
collect_discovered_paths(
std::iter::once(expanded),
scope,
&mut seen,
&mut skill_files,
);
} else if expanded.is_dir() {
let dir_paths = find_skill_md_paths(&expanded);
collect_discovered_paths(dir_paths, scope, &mut seen, &mut skill_files);
} else {
tracing::warn!(
path = %expanded.display(),
"config path does not exist or is not a SKILL.md file/directory"
);
}
}
let mut skills = parse_skill_files(skill_files);
// Provenance metadata only (scope still drives precedence): lets inspect
// and UIs distinguish `[skills].paths` entries from plain user/repo skills.
for skill in &mut skills {
skill.config_source = Some(kigi_tools::types::config_source::ConfigSource::ConfigToml {
path: PathBuf::from(&skill.path),
});
}
skills
}
fn collect_injected_skills(dirs: &[String], scope: SkillScope) -> Vec<SkillInfo> {
let mut skill_files: Vec<(PathBuf, SkillScope)> = Vec::new();
let mut seen = HashSet::new();
for raw in dirs {
let expanded = expand_tilde(raw);
if !expanded.is_dir() {
continue;
}
let mut dir_paths = Vec::new();
walk_for_skill_md(&expanded, &mut dir_paths, 0);
collect_discovered_paths(dir_paths, scope, &mut seen, &mut skill_files);
}
parse_skill_files(skill_files)
}
/// Deduplicate skills while preserving first-seen priority order.
///
/// Dedupes in two passes at once:
/// - By canonical path (same file discovered via multiple sources; the kept
/// entry inherits a dropped duplicate's `config_source` stamp)
/// - By skill name (a higher-priority source wins)
///
/// A same-scope name loser whose directory basename differs from the
/// contested name is re-identified under that basename when that identity
/// is free (see [`rekey_to_dir_basename`]) — the same recovery
/// `stamp_plugin_fields` applies to plugin siblings. A loser whose basename
/// equals the contested name or is already claimed stays shadowed, and a
/// frontmatter owner evicts a claimant that only holds its name via an
/// earlier re-key. Cross-scope shadowing (a higher-priority source claiming
/// a name) is an intentional override and is preserved as-is.
fn dedupe_skills(skills: Vec<SkillInfo>) -> Vec<SkillInfo> {
let mut seen_paths: HashMap<PathBuf, usize> = HashMap::new();
// Contested name → (claiming scope, index of the claimant in `deduped`).
let mut seen_names: HashMap<String, (SkillScope, usize)> = HashMap::new();
let mut deduped: Vec<SkillInfo> = Vec::with_capacity(skills.len());
for mut skill in skills {
let canonical_path =
dunce::canonicalize(&skill.path).unwrap_or_else(|_| PathBuf::from(&skill.path));
if let Some(&kept_idx) = seen_paths.get(&canonical_path) {
// A file reached via both auto-discovery and `[skills].paths` is
// genuinely both; carry the provenance stamp onto the kept entry
// so the label isn't source-order-dependent. Scope is untouched.
let kept = &mut deduped[kept_idx];
if kept.config_source.is_none() && skill.config_source.is_some() {
kept.config_source = skill.config_source;
}
continue;
}
if let Some(&(winner_scope, winner_idx)) = seen_names.get(&skill.name) {
if winner_scope == skill.scope
&& !matches!(skill.scope, SkillScope::Server | SkillScope::Bundled)
{
// Same-scope siblings sharing a frontmatter name keep both:
// re-key the challenger to its dir basename; when the
// challenger IS the basename owner, re-key the earlier
// claimant and hand the name back. A challenger whose rekey
// failed for other reasons (dir taken/invalid) has no claim
// and falls through to the shadow-drop.
if rekey_to_dir_basename(&mut skill, &mut seen_names, deduped.len()) {
seen_paths.insert(canonical_path, deduped.len());
deduped.push(skill);
continue;
}
let challenger_owns_basename = skill_name_from_path(&skill.path)
.map(normalize_skill_name)
.is_some_and(|dir| dir == skill.name);
if challenger_owns_basename {
if rekey_to_dir_basename(&mut deduped[winner_idx], &mut seen_names, winner_idx)
{
seen_names.insert(skill.name.clone(), (skill.scope, deduped.len()));
seen_paths.insert(canonical_path, deduped.len());
deduped.push(skill);
continue;
}
if deduped[winner_idx].display_name.is_some() {
// The incumbent holds this name only via an earlier
// re-key and cannot move again; the frontmatter owner
// evicts it (a stale copy must not shadow the skill
// genuinely named after its own directory).
let evicted = &deduped[winner_idx];
let evicted_path = dunce::canonicalize(&evicted.path)
.unwrap_or_else(|_| PathBuf::from(&evicted.path));
seen_paths.remove(&evicted_path);
seen_paths.insert(canonical_path, winner_idx);
deduped[winner_idx] = skill;
continue;
}
}
}
// Server/Bundled are shadowed by design.
if !matches!(skill.scope, SkillScope::Server | SkillScope::Bundled) {
tracing::debug!(
skill = %skill.name,
path = %skill.path,
"skill name shadowed by an earlier skill with the same name; rename to avoid the collision"
);
}
continue;
}
seen_names.insert(skill.name.clone(), (skill.scope, deduped.len()));
seen_paths.insert(canonical_path, deduped.len());
deduped.push(skill);
}
deduped
}
/// Re-identify a name-collision party under its directory basename, keeping
/// the frontmatter name as the display label — the copied-skill-dir case
/// (`cp -r japandi japandi2` with `name: japandi` left in both files).
///
/// Returns `false` — leaving the collision to the caller's shadowing path —
/// when the basename is missing/invalid, equals the skill's current name
/// (a true duplicate), or is itself already claimed.
fn rekey_to_dir_basename(
skill: &mut SkillInfo,
seen_names: &mut HashMap<String, (SkillScope, usize)>,
idx: usize,
) -> bool {
let Some(dir) = skill_name_from_path(&skill.path) else {
return false;
};
let dir = normalize_skill_name(dir);
if !is_valid_skill_name(&dir) || dir == skill.name || seen_names.contains_key(&dir) {
return false;
}
tracing::debug!(
skill = %skill.name,
rekeyed = %dir,
path = %skill.path,
"skill name collides with a same-scope skill; re-identified by directory name"
);
seen_names.insert(dir.clone(), (skill.scope, idx));
skill.display_name = Some(std::mem::replace(&mut skill.name, dir));
true
}
/// Stamp plugin metadata onto skills parsed by `parse_skill_files`.
fn stamp_plugin_fields(skills: &mut [SkillInfo], plugin: &crate::plugins::LoadedPlugin) {
let scope = match plugin.scope {
PluginScope::CliOverride => SkillScope::Local,
PluginScope::Project => SkillScope::Repo,
PluginScope::User => SkillScope::User,
PluginScope::ConfigPath => SkillScope::Plugin,
};
for skill in skills.iter_mut() {
skill.scope = scope;
skill.plugin_name = Some(plugin.name.clone());
skill.plugin_version = plugin.version.clone();
skill.plugin_root = Some(plugin.root_str());
skill.plugin_data = Some(plugin.data_dir_str());
// Identity is the directory basename (`plugin:<dir>`), keeping sibling
// skills collision-free; frontmatter `name` becomes the display label.
// Normalize the basename so the slash name is a valid slug, matching how
// frontmatter/fallback names are slugged at parse time.
if let Some(dir) = skill_name_from_path(&skill.path) {
let dir = normalize_skill_name(dir);
if !dir.is_empty() && dir != skill.name {
skill.display_name = Some(std::mem::replace(&mut skill.name, dir));
}
}
skill.config_source = Some(kigi_tools::types::config_source::ConfigSource::Plugin {
plugin_name: plugin.name.clone(),
path: PathBuf::from(&skill.path),
});
}
}
fn collect_plugin_skills(registry: &crate::plugins::PluginRegistry) -> Vec<SkillInfo> {
let mut skills = Vec::new();
for plugin in registry.enabled_plugins() {
let mut paths: Vec<(PathBuf, SkillScope)> = Vec::new();
// Skills: shared discovery primitive (see `find_skill_md_paths`).
for skill_dir in &plugin.skill_dirs {
if !skill_dir.is_dir() {
continue;
}
paths.extend(
find_skill_md_paths(skill_dir)
.into_iter()
.map(|p| (p, SkillScope::Repo)),
);
}
// Commands (.md files in command directories)
for cmd_dir in &plugin.command_dirs {
paths.extend(
scan_md_files(cmd_dir)
.into_iter()
.map(|p| (p, SkillScope::Repo)),
);
}
let mut parsed = parse_skill_files(paths);
stamp_plugin_fields(&mut parsed, plugin);
skills.extend(parsed);
}
skills
}
/// Plugin-aware skill merge (native first, then plugin skills appended with qualified-name dedup).
fn merge_skills_with_plugins(
native_skills: Vec<SkillInfo>,
plugin_skills: Vec<SkillInfo>,
) -> Vec<SkillInfo> {
let mut deduped = dedupe_skills(native_skills);
let native_names: HashSet<String> = deduped.iter().map(|s| s.name.clone()).collect();
let mut seen_plugin_qualified: HashSet<String> = HashSet::new();
for skill in plugin_skills {
if !seen_plugin_qualified.insert(skill.dedup_key()) {
continue;
}
if native_names.contains(&skill.name) {
tracing::debug!(
skill_name = %skill.name,
plugin = ?skill.plugin_name,
"plugin skill bare name collides with native; qualified form still available"
);
}
deduped.push(skill);
}
deduped
}
/// Filter a list of skills, removing any whose canonical path matches or is within the ignore paths
pub fn filter_skills(skills: Vec<SkillInfo>, ignore_paths: &[String]) -> Vec<SkillInfo> {
if ignore_paths.is_empty() {
return skills;
}
let expanded: Vec<PathBuf> = ignore_paths
.iter()
.map(|p| {
let path = expand_tilde(p);
dunce::canonicalize(&path).unwrap_or(path)
})
.collect();
skills
.into_iter()
.filter(|skill| {
let canonical =
dunce::canonicalize(&skill.path).unwrap_or_else(|_| PathBuf::from(&skill.path));
// >MAX_PATH caveat (see workspace clippy.toml) — fail-open here: over-long ignored skills stay included.
!expanded.iter().any(|ignore| canonical.starts_with(ignore))
})
.collect()
}
/// Format a skill for prompt injection (if body is populated).
/// Injects plain markdown body — no XML envelope.
pub(crate) fn format_skill_for_injection(skill: &SkillInfo) -> Option<String> {
skill
.body
.as_ref()
.filter(|b| !b.is_empty())
.map(|body| kigi_tools::implementations::skills::skill::build_skill_message(skill, body))
}
/// Format multiple skills for prompt injection.
/// Returns plain markdown skill bodies, no XML wrapper.
pub(crate) fn format_skills_for_injection(skills: &[SkillInfo]) -> String {
let parts: Vec<String> = skills
.iter()
.filter_map(format_skill_for_injection)
.collect();
if parts.is_empty() {
return String::new();
}
// Trailing blank line separates the last skill envelope from the
// agent's own prompt body, so `</skill>` doesn't run into the body.
format!("\n\n{}\n\n", parts.join("\n\n"))
}
/// Resolve agent definition `skills:` names to SkillInfo with body populated.
pub(crate) async fn resolve_preloaded_skills(
names: &[String],
discovered: &[SkillInfo],
) -> Vec<SkillInfo> {
let mut result = Vec::new();
for name in names {
// Find matching skill (case-insensitive name match, also try qualified name)
let skill = discovered.iter().find(|s| {
s.name.eq_ignore_ascii_case(name)
|| kigi_tools::implementations::skills::skill::format_skill_name(s)
.eq_ignore_ascii_case(name)
});
let Some(skill) = skill else {
tracing::warn!(
skill_name = %name,
"Skill declared in agent definition not found in discovered skills"
);
continue;
};
// Load the skill with body content
match kigi_tools::implementations::skills::skill::load_skill_with_body(skill).await {
Ok(loaded) => result.push(loaded),
Err(e) => {
tracing::warn!(
skill_name = %name,
path = %skill.path,
error = %e,
"Failed to load skill body for preloading"
);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_tools::implementations::skills::discovery::{
MAX_BODY_PEEK_BYTES, MAX_SKILL_WALK_DEPTH, SkillParseError, extract_first_paragraph,
is_valid_skill_name, normalize_skill_name, parse_skill_frontmatter,
};
use std::fs;
/// Helper: create a minimal valid SKILL.md with the given name.
fn write_skill_md(dir: &Path, name: &str) {
fs::create_dir_all(dir).unwrap();
let content = format!(
"---\nname: {name}\ndescription: A test skill called {name}\n---\n\nSkill body here.\n"
);
fs::write(dir.join("SKILL.md"), content).unwrap();
}
// ── Server-synced skills (injected server_skill_dirs) ────────────────
#[tokio::test]
async fn server_skills_discovered_and_shadowed_by_local() {
let server = tempfile::tempdir().unwrap();
write_skill_md(&server.path().join("server-only"), "server-only");
write_skill_md(&server.path().join("dup"), "dup");
let cwd = tempfile::tempdir().unwrap();
write_skill_md(&cwd.path().join(".kigi").join("skills").join("dup"), "dup");
let config = SkillsConfig {
server_skill_dirs: vec![server.path().to_string_lossy().into_owned()],
..Default::default()
};
let skills = list_skills_with_plugins(
Some(&cwd.path().to_string_lossy()),
&config,
None,
CompatConfig::default(),
)
.await;
let server_only = skills
.iter()
.find(|s| s.name == "server-only")
.expect("server-only skill should be discovered");
assert_eq!(server_only.scope, SkillScope::Server);
let dups: Vec<_> = skills.iter().filter(|s| s.name == "dup").collect();
assert_eq!(dups.len(), 1, "dup should appear once");
assert_eq!(
dups[0].scope,
SkillScope::Local,
"local skill must shadow the server-synced one"
);
}
#[tokio::test]
async fn bundled_skills_injected_and_shadowed_by_local() {
let bundled = tempfile::tempdir().unwrap();
write_skill_md(&bundled.path().join("bundled__helper"), "helper");
write_skill_md(&bundled.path().join("dup"), "dup");
let cwd = tempfile::tempdir().unwrap();
write_skill_md(&cwd.path().join(".kigi").join("skills").join("dup"), "dup");
let config = SkillsConfig {
bundled_skill_dirs: vec![bundled.path().to_string_lossy().into_owned()],
..Default::default()
};
let skills = list_skills_with_plugins(
Some(&cwd.path().to_string_lossy()),
&config,
None,
CompatConfig::default(),
)
.await;
let helper = skills
.iter()
.find(|s| s.name == "helper")
.expect("bundled helper skill should be discovered");
assert_eq!(helper.scope, SkillScope::Bundled);
let dups: Vec<_> = skills.iter().filter(|s| s.name == "dup").collect();
assert_eq!(dups.len(), 1, "dup should appear once");
assert_eq!(dups[0].scope, SkillScope::Local);
}
#[tokio::test]
async fn server_skill_beats_bundled() {
let server = tempfile::tempdir().unwrap();
write_skill_md(&server.path().join("shared"), "shared");
let bundled = tempfile::tempdir().unwrap();
write_skill_md(&bundled.path().join("shared"), "shared");
let cwd = tempfile::tempdir().unwrap();
let config = SkillsConfig {
server_skill_dirs: vec![server.path().to_string_lossy().into_owned()],
bundled_skill_dirs: vec![bundled.path().to_string_lossy().into_owned()],
..Default::default()
};
let skills = list_skills_with_plugins(
Some(&cwd.path().to_string_lossy()),
&config,
None,
CompatConfig::default(),
)
.await;
let shared: Vec<_> = skills.iter().filter(|s| s.name == "shared").collect();
assert_eq!(shared.len(), 1, "shared should appear once");
assert_eq!(
shared[0].scope,
SkillScope::Server,
"server skill must shadow the bundled one"
);
}
// ── Feature 3: Recursive skill reading ──────────────────────────────
#[test]
fn find_skill_paths_flat_layout() {
// Traditional flat layout: skills/<name>/SKILL.md
let tmp = tempfile::tempdir().unwrap();
let kigi_dir = tmp.path().join(".kigi");
write_skill_md(&kigi_dir.join("skills").join("alpha"), "alpha");
write_skill_md(&kigi_dir.join("skills").join("beta"), "beta");
let paths = find_skill_paths(&kigi_dir);
assert_eq!(paths.len(), 2);
assert!(paths.iter().all(|p| p.file_name().unwrap() == "SKILL.md"));
}
#[test]
fn find_skill_paths_nested_layout() {
// Nested: skills/team/infra/SKILL.md, skills/team/training/SKILL.md
let tmp = tempfile::tempdir().unwrap();
let kigi_dir = tmp.path().join(".kigi");
let skills = kigi_dir.join("skills");
write_skill_md(&skills.join("team").join("infra"), "infra");
write_skill_md(&skills.join("team").join("training"), "training");
let paths = find_skill_paths(&kigi_dir);
assert_eq!(paths.len(), 2);
let path_strs: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
assert!(path_strs.iter().any(|p| p.contains("infra")));
assert!(path_strs.iter().any(|p| p.contains("training")));
}
#[test]
fn find_skill_paths_mixed_flat_and_nested() {
// Mix of flat and nested skills
let tmp = tempfile::tempdir().unwrap();
let kigi_dir = tmp.path().join(".kigi");
let skills = kigi_dir.join("skills");
// Flat
write_skill_md(&skills.join("top-level"), "top-level");
// Nested 1 level
write_skill_md(&skills.join("team").join("nested-one"), "nested-one");
// Nested 2 levels
write_skill_md(&skills.join("org").join("team").join("deep"), "deep");
let paths = find_skill_paths(&kigi_dir);
assert_eq!(paths.len(), 3);
}
#[test]
fn find_skill_paths_dir_without_skill_md_is_skipped() {
// A subdirectory exists but has no SKILL.md — should not appear
let tmp = tempfile::tempdir().unwrap();
let kigi_dir = tmp.path().join(".kigi");
let skills = kigi_dir.join("skills");
write_skill_md(&skills.join("valid"), "valid");
// Create a dir with no SKILL.md
fs::create_dir_all(skills.join("empty-dir")).unwrap();
// Create a dir with a random file but no SKILL.md
let other = skills.join("other");
fs::create_dir_all(&other).unwrap();
fs::write(other.join("README.md"), "not a skill").unwrap();
let paths = find_skill_paths(&kigi_dir);
assert_eq!(paths.len(), 1);
assert!(paths[0].display().to_string().contains("valid"));
}
#[test]
fn find_skill_paths_no_skills_dir() {
// .kigi exists but no skills/ subdirectory
let tmp = tempfile::tempdir().unwrap();
let kigi_dir = tmp.path().join(".kigi");
fs::create_dir_all(&kigi_dir).unwrap();
let paths = find_skill_paths(&kigi_dir);
assert!(paths.is_empty());
}
#[test]
fn find_skill_paths_nonexistent_dir() {
let paths = find_skill_paths(Path::new("/nonexistent/path"));
assert!(paths.is_empty());
}
#[test]
fn walk_for_skill_md_respects_depth_limit() {
// Create a directory tree deeper than MAX_SKILL_WALK_DEPTH
let tmp = tempfile::tempdir().unwrap();
let skills_dir = tmp.path().join("skills");
// Build a chain: skills/d0/d1/d2/d3/d4/d5/d6/deep-skill/SKILL.md
// depth 0=d0, 1=d1, ..., 5=d5 (at limit), 6=d6 (beyond limit)
let mut current = skills_dir.clone();
for i in 0..=MAX_SKILL_WALK_DEPTH + 1 {
current = current.join(format!("d{i}"));
}
write_skill_md(&current.join("deep-skill"), "deep-skill");
// Also put one at an accessible depth
write_skill_md(&skills_dir.join("shallow"), "shallow");
let mut paths = Vec::new();
walk_for_skill_md(&skills_dir, &mut paths, 0);
// shallow should be found, deep-skill should not (too deep)
assert_eq!(paths.len(), 1);
assert!(paths[0].display().to_string().contains("shallow"));
}
#[test]
fn find_skill_paths_parent_and_child_both_have_skill_md() {
// A directory has SKILL.md and also has subdirectories with SKILL.md
let tmp = tempfile::tempdir().unwrap();
let kigi_dir = tmp.path().join(".kigi");
let skills = kigi_dir.join("skills");
// Parent skill
write_skill_md(&skills.join("parent"), "parent-skill");
// Child skill inside parent
write_skill_md(&skills.join("parent").join("child"), "child-skill");
let paths = find_skill_paths(&kigi_dir);
assert_eq!(paths.len(), 2);
let path_strs: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
assert!(path_strs.iter().any(|p| p.contains("parent/SKILL.md")));
assert!(path_strs.iter().any(|p| p.contains("child/SKILL.md")));
}
// ── extract_first_paragraph ──────────────────────────────────────
#[test]
fn first_paragraph_simple() {
let body = "This is the first paragraph.\n\nThis is the second.";
assert_eq!(
extract_first_paragraph(body).unwrap(),
"This is the first paragraph."
);
}
#[test]
fn first_paragraph_skips_headings() {
let body = "# Git Commit Skill\n\nReview staged changes and create a commit.\n\n## Steps";
assert_eq!(
extract_first_paragraph(body).unwrap(),
"Review staged changes and create a commit."
);
}
#[test]
fn first_paragraph_multiline() {
let body =
"# Skill\n\nFirst line of paragraph.\nSecond line of paragraph.\n\nAnother paragraph.";
assert_eq!(
extract_first_paragraph(body).unwrap(),
"First line of paragraph. Second line of paragraph."
);
}
#[test]
fn first_paragraph_empty_body() {
assert!(extract_first_paragraph("").is_none());
}
#[test]
fn first_paragraph_headings_only() {
let body = "# Title\n\n## Section\n\n### Subsection";
assert!(extract_first_paragraph(body).is_none());
}
// ── UTF-8 safe body truncation ──────────────────────────────────
#[test]
fn description_fallback_does_not_panic_on_multibyte_boundary() {
// Build a body longer than MAX_BODY_PEEK_BYTES (2048) with multibyte
// characters near the cutoff so the old &body[..2048] would land in
// the middle of a multi-byte char and panic.
//
// Strategy: fill with ASCII up to near the limit, then pack 4-byte
// emoji right at the boundary.
let prefix = "# Heading\n\n";
let filler_len = MAX_BODY_PEEK_BYTES - prefix.len() - 4; // leave room for emoji at boundary
let filler = "a".repeat(filler_len);
// Each emoji is 4 bytes. Place several so one straddles the 2048 mark.
let emoji_run = "\u{1F600}".repeat(10); // 40 bytes of emoji
let body = format!("{prefix}{filler}{emoji_run}");
assert!(body.len() > MAX_BODY_PEEK_BYTES, "body must exceed limit");
// This must not panic when truncated to MAX_BODY_PEEK_BYTES.
let mut end = MAX_BODY_PEEK_BYTES;
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
let peek = &body[..end];
let result = extract_first_paragraph(peek);
// Should produce a paragraph (the long filler + some emoji).
assert!(result.is_some());
}
#[test]
fn description_fallback_end_to_end_with_multibyte_skill_file() {
// End-to-end: a SKILL.md with no description in frontmatter falls
// back to body parsing. The body contains multibyte text that would
// cross the MAX_BODY_PEEK_BYTES boundary.
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("skills").join("emoji-skill");
fs::create_dir_all(&skill_dir).unwrap();
// Body (after frontmatter): heading + paragraph with multibyte chars
// exceeding 2048 bytes.
let long_paragraph = "\u{00E9}".repeat(MAX_BODY_PEEK_BYTES); // 2-byte chars
let content = format!("---\nname: emoji-skill\n---\n# Test\n\n{long_paragraph}\n");
fs::write(skill_dir.join("SKILL.md"), &content).unwrap();
let skills = parse_skill_files(vec![(skill_dir.join("SKILL.md"), SkillScope::Local)]);
// Must not panic and should produce a description.
assert_eq!(skills.len(), 1);
assert!(
!skills[0].description.is_empty(),
"description should be filled from body"
);
}
// ── Frontmatter parsing (existing coverage + regression) ─────────
#[test]
fn parse_valid_frontmatter() {
let content = "---\nname: my-skill\ndescription: A skill\n---\n\nBody.\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert_eq!(parsed.name, "my-skill");
assert_eq!(parsed.description, "A skill");
}
#[test]
fn parse_allowed_tools_comma_string() {
let content = "---\nname: my-skill\ndescription: test\nallowed-tools: \"bash, read_file, grep\"\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert_eq!(
parsed.allowed_tools.as_deref(),
Some(
[
"bash".to_string(),
"read_file".to_string(),
"grep".to_string()
]
.as_slice()
)
);
}
#[test]
fn parse_allowed_tools_yaml_list() {
let content = "---\nname: my-skill\ndescription: test\nallowed-tools:\n - bash\n - read_file\n - grep\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert_eq!(
parsed.allowed_tools.as_deref(),
Some(
[
"bash".to_string(),
"read_file".to_string(),
"grep".to_string()
]
.as_slice()
)
);
}
#[test]
fn parse_allowed_tools_omitted() {
let content = "---\nname: my-skill\ndescription: test\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert!(parsed.allowed_tools.is_none());
}
#[test]
fn parse_model_and_effort() {
let content = "---\nname: my-skill\ndescription: test\nmodel: kigi-3\neffort: high\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert_eq!(parsed.model.as_deref(), Some("kigi-3"));
assert_eq!(parsed.effort.as_deref(), Some("high"));
}
#[test]
fn parse_model_and_effort_omitted() {
let content = "---\nname: my-skill\ndescription: test\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert!(parsed.model.is_none());
assert!(parsed.effort.is_none());
}
// ── agentskills.io spec parity ────────────────────────────────
#[test]
fn parse_license_and_compatibility() {
let content = "---\nname: my-skill\ndescription: test\nlicense: Apache-2.0\ncompatibility: Requires git and docker\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert_eq!(parsed.license.as_deref(), Some("Apache-2.0"));
assert_eq!(
parsed.compatibility.as_deref(),
Some("Requires git and docker")
);
}
#[test]
fn parse_license_and_compatibility_omitted() {
let content = "---\nname: my-skill\ndescription: test\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert!(parsed.license.is_none());
assert!(parsed.compatibility.is_none());
}
#[test]
fn parse_metadata_arbitrary_keys() {
let content = "---\nname: my-skill\ndescription: test\nmetadata:\n author: example-org\n version: \"1.0\"\n short-description: Short desc\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert_eq!(parsed.short_description.as_deref(), Some("Short desc"));
assert_eq!(parsed.author.as_deref(), Some("example-org"));
let meta = parsed.metadata.unwrap();
assert_eq!(meta.get("version").unwrap(), "1.0");
// short-description and author are extracted to top-level fields and not in the generic map
assert!(!meta.contains_key("short-description"));
assert!(!meta.contains_key("author"));
}
#[test]
fn parse_metadata_omitted() {
let content = "---\nname: my-skill\ndescription: test\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert!(parsed.short_description.is_none());
assert!(parsed.metadata.is_none());
}
#[test]
fn name_validation_rejects_leading_hyphen() {
assert!(!is_valid_skill_name("-pdf"));
}
#[test]
fn name_validation_rejects_trailing_hyphen() {
assert!(!is_valid_skill_name("pdf-"));
}
#[test]
fn name_validation_rejects_consecutive_hyphens() {
assert!(!is_valid_skill_name("pdf--tool"));
}
#[test]
fn name_validation_accepts_valid_names() {
assert!(is_valid_skill_name("pdf-processing"));
assert!(is_valid_skill_name("data-analysis"));
assert!(is_valid_skill_name("a"));
assert!(is_valid_skill_name("a-b-c"));
assert!(is_valid_skill_name("tool123"));
}
#[test]
fn normalize_replaces_spaces_with_hyphens() {
assert_eq!(normalize_skill_name("my cool skill"), "my-cool-skill");
}
#[test]
fn normalize_lowercases_and_collapses_hyphens() {
assert_eq!(normalize_skill_name("My Cool Skill"), "my-cool-skill");
}
#[test]
fn normalize_trims_leading_trailing() {
assert_eq!(normalize_skill_name(" -my-skill- "), "my-skill");
}
#[test]
fn parse_frontmatter_normalizes_spaced_name() {
let content = "---\nname: my cool skill\ndescription: A skill\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert_eq!(parsed.name, "my-cool-skill");
}
#[test]
fn parse_skill_files_no_frontmatter_uses_dir_name() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("skills").join("my-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"Just body content, no frontmatter.",
)
.unwrap();
let skills = parse_skill_files(vec![(skill_dir.join("SKILL.md"), SkillScope::Local)]);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-skill");
assert!(
skills[0].user_invocable,
"no-frontmatter skills must be user-invocable"
);
}
#[test]
fn parse_allowed_tools_space_delimited() {
// agentskills.io format: space-delimited with tool patterns
let content = "---\nname: my-skill\ndescription: test\nallowed-tools: \"Bash(git:*) Read Write\"\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert_eq!(
parsed.allowed_tools.as_deref(),
Some(
[
"Bash(git:*)".to_string(),
"Read".to_string(),
"Write".to_string()
]
.as_slice()
)
);
}
#[test]
fn parse_full_spec_plus_extensions() {
// Mixed agentskills.io spec fields + our extensions — all must parse.
let content = "---\nname: my-skill\ndescription: A full skill\nlicense: MIT\ncompatibility: Python 3.12+\nmetadata:\n author: test-org\n version: \"2.0\"\nallowed-tools:\n - bash\n - read_file\nargument-hint: file path\nmodel: kigi-3\neffort: high\nuser-invocable: true\ndisable-model-invocation: false\n---\nBody content.\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert_eq!(parsed.name, "my-skill");
assert_eq!(parsed.description, "A full skill");
assert_eq!(parsed.license.as_deref(), Some("MIT"));
assert_eq!(parsed.compatibility.as_deref(), Some("Python 3.12+"));
assert_eq!(parsed.author.as_deref(), Some("test-org"));
assert_eq!(
parsed.allowed_tools.as_deref(),
Some(["bash".to_string(), "read_file".to_string()].as_slice())
);
assert_eq!(parsed.argument_hint.as_deref(), Some("file path"));
assert_eq!(parsed.model.as_deref(), Some("kigi-3"));
assert_eq!(parsed.effort.as_deref(), Some("high"));
assert!(parsed.user_invocable);
assert!(!parsed.disable_model_invocation);
}
#[test]
fn parse_frontmatter_recovers_colon_in_value() {
let content = "---\nname: my-skill\ndescription: lorem ipsum: dolor sit amet\n---\n";
let parsed = parse_skill_frontmatter(content, None).unwrap();
assert_eq!(parsed.name, "my-skill");
assert_eq!(parsed.description, "lorem ipsum: dolor sit amet");
}
#[test]
fn parse_frontmatter_special_chars_normalized() {
// Non-slug chars (e.g. `@`, `!`, `.`) normalize to hyphens so the skill
// is kept and slash-usable, rather than dropped.
let parsed =
parse_skill_frontmatter("---\nname: inv@lid!name\ndescription: A\n---\n", None)
.unwrap();
assert_eq!(parsed.name, "inv-lid-name");
}
#[test]
fn parse_frontmatter_all_symbol_name_rejected() {
// A name that normalizes to empty has nothing usable → still rejected.
assert!(matches!(
parse_skill_frontmatter("---\nname: \"@!#\"\ndescription: A\n---\n", None),
Err(SkillParseError::InvalidName(_))
));
}
// ── Feature 1: Workspace user skills via list_skills ─────────────
/// Helper: initialize a bare git repo at `path` so git2::Repository::discover works.
fn init_git_repo(path: &Path) {
git2::Repository::init(path).unwrap();
}
#[tokio::test]
async fn list_skills_includes_workspace_user_skills() {
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 workspace user dir with a skill
let user_dir = repo_root.join("x").join("testuser");
write_skill_md(
&user_dir.join(".kigi").join("skills").join("my-tool"),
"my-tool",
);
// cwd = repo root (not inside user dir, so the walk won't find it)
let skills = list_skills_with_options(
Some(repo_root.to_str().unwrap()),
Some(&user_dir),
tmp.path(),
CompatConfig::default(),
)
.await;
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"my-tool"),
"Expected 'my-tool' in skills, got: {names:?}"
);
}
#[tokio::test]
async fn list_skills_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 a skill
let user_dir = repo_root.join("x").join("testuser");
write_skill_md(
&user_dir.join(".kigi").join("skills").join("dedup-skill"),
"dedup-skill",
);
// cwd is inside the user dir — the upward walk will already find it
let skills = list_skills_with_options(
Some(user_dir.to_str().unwrap()),
Some(&user_dir),
tmp.path(),
CompatConfig::default(),
)
.await;
// Should appear exactly once (deduped by canonical path)
let count = skills.iter().filter(|s| s.name == "dedup-skill").count();
assert_eq!(count, 1, "Skill should appear exactly once, got {count}");
}
#[tokio::test]
async fn list_skills_no_workspace_user_dir_no_extra_skills() {
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 a skill that would only be found via workspace user path
let user_dir = repo_root.join("x").join("ghost");
write_skill_md(
&user_dir.join(".kigi").join("skills").join("ghost-skill"),
"ghost-skill",
);
// Pass None — simulates env vars not set
let skills = list_skills_with_options(
Some(repo_root.to_str().unwrap()),
None,
tmp.path(),
CompatConfig::default(),
)
.await;
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(
!names.contains(&"ghost-skill"),
"Without workspace user dir, ghost-skill should not be found"
);
}
#[tokio::test]
async fn list_skills_workspace_user_dir_with_recursive_skills() {
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 nested skills
let user_dir = repo_root.join("x").join("nested-user");
let skills_base = user_dir.join(".kigi").join("skills");
write_skill_md(&skills_base.join("flat-skill"), "flat-skill");
write_skill_md(&skills_base.join("team").join("deep-skill"), "deep-skill");
let skills = list_skills_with_options(
Some(repo_root.to_str().unwrap()),
Some(&user_dir),
tmp.path(),
CompatConfig::default(),
)
.await;
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"flat-skill"),
"flat-skill not found: {names:?}"
);
assert!(
names.contains(&"deep-skill"),
"deep-skill not found: {names:?}"
);
}
// ── collect_config_skills ────────────────────────────────────────
#[test]
fn collect_config_skills_from_directory() {
let tmp = tempfile::tempdir().unwrap();
write_skill_md(&tmp.path().join("alpha"), "alpha");
write_skill_md(&tmp.path().join("beta"), "beta");
let paths = vec![tmp.path().to_str().unwrap().to_string()];
let skills = collect_config_skills(&paths, None);
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"alpha"), "alpha not found: {names:?}");
assert!(names.contains(&"beta"), "beta not found: {names:?}");
}
#[test]
fn collect_config_skills_direct_skill_md_file() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("my-skill");
write_skill_md(&skill_dir, "my-skill");
let paths = vec![skill_dir.join("SKILL.md").to_str().unwrap().to_string()];
let skills = collect_config_skills(&paths, None);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "my-skill");
}
#[test]
fn collect_config_skills_scope_user_outside_repo() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let outside = tmp.path().join("outside");
fs::create_dir_all(&repo).unwrap();
write_skill_md(&outside.join("ext-skill"), "ext-skill");
let paths = vec![outside.to_str().unwrap().to_string()];
let skills = collect_config_skills(&paths, Some(&repo));
assert_eq!(skills[0].scope, SkillScope::User);
}
#[test]
fn collect_config_skills_scope_repo_inside_repo() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path().join("repo");
let inside = repo.join("tools");
write_skill_md(&inside.join("repo-skill"), "repo-skill");
let paths = vec![inside.to_str().unwrap().to_string()];
let skills = collect_config_skills(&paths, Some(&repo));
assert_eq!(skills[0].scope, SkillScope::Repo);
}
#[test]
fn collect_config_skills_skill_md_at_root_of_config_path() {
// When the config path itself is a skill directory (contains SKILL.md),
// it should be discovered even though walk_for_skill_md only walks children.
let tmp = tempfile::tempdir().unwrap();
write_skill_md(tmp.path(), "root-skill");
let paths = vec![tmp.path().to_str().unwrap().to_string()];
let skills = collect_config_skills(&paths, None);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "root-skill");
}
#[test]
fn collect_config_skills_nonexistent_path_is_skipped() {
let paths = vec!["/nonexistent/path/to/skills".to_string()];
let skills = collect_config_skills(&paths, None);
assert!(skills.is_empty());
}
#[test]
fn collect_config_skills_deduplicates_same_file() {
let tmp = tempfile::tempdir().unwrap();
write_skill_md(&tmp.path().join("dup-skill"), "dup-skill");
// Same directory listed twice
let dir = tmp.path().to_str().unwrap().to_string();
let paths = vec![dir.clone(), dir];
let skills = collect_config_skills(&paths, None);
assert_eq!(skills.len(), 1, "Same file should not appear twice");
}
#[test]
fn collect_config_skills_stamps_config_toml_source() {
let tmp = tempfile::tempdir().unwrap();
write_skill_md(&tmp.path().join("cfg-skill"), "cfg-skill");
let paths = vec![tmp.path().to_str().unwrap().to_string()];
let skills = collect_config_skills(&paths, None);
assert_eq!(skills.len(), 1);
match &skills[0].config_source {
Some(kigi_tools::types::config_source::ConfigSource::ConfigToml { path }) => {
assert_eq!(path, Path::new(&skills[0].path));
}
other => panic!("expected ConfigToml source, got {other:?}"),
}
}
// ── filter_skills ────────────────────────────────────────────────
fn make_skill(name: &str, path: &str) -> SkillInfo {
SkillInfo {
name: name.to_string(),
display_name: None,
description: format!("desc for {name}"),
when_to_use: None,
short_description: None,
author: None,
argument_hint: None,
path: path.to_string(),
scope: SkillScope::User,
config_source: None,
plugin_name: None,
plugin_version: None,
plugin_root: None,
plugin_data: None,
allowed_tools: None,
license: None,
compatibility: None,
metadata: None,
model: None,
effort: None,
user_invocable: true,
disable_model_invocation: false,
has_user_specified_description: false,
paths: None,
enabled: true,
body: None,
}
}
#[test]
fn stamp_plugin_fields_sets_root_and_data() {
use crate::plugins::discovery::PluginId;
use crate::plugins::registry::LoadedPlugin;
let root = PathBuf::from("/tmp/plugin-dev");
let plugin = LoadedPlugin {
name: "plugin-dev".to_string(),
id: PluginId::new(PluginScope::User, &root, "plugin-dev"),
root: root.clone(),
canonical_root: root.clone(),
scope: PluginScope::User,
origin: crate::plugins::PluginOrigin::UserKigi,
trusted: true,
enabled: true,
version: Some("1.0.0".to_string()),
description: None,
skill_dirs: vec![],
command_dirs: vec![],
agent_dirs: vec![],
hooks_path: None,
mcp_config_path: None,
lsp_config_path: None,
skill_count: 0,
agent_count: 0,
skill_names: vec![],
agent_names: vec![],
has_hooks: false,
hook_count: 0,
has_inline_hooks_only: false,
mcp_server_count: 0,
has_inline_mcp_only: false,
lsp_server_count: 0,
has_inline_lsp_only: false,
inline_hooks: None,
inline_mcp_servers: None,
inline_lsp_servers: None,
conflict: None,
};
let mut skills = vec![make_skill(
"indexer",
"/tmp/plugin-dev/skills/indexer/SKILL.md",
)];
stamp_plugin_fields(&mut skills, &plugin);
let expected_root = plugin.root_str();
let expected_data = plugin.data_dir_str();
assert_eq!(
skills[0].plugin_root.as_deref(),
Some(expected_root.as_str())
);
assert_eq!(
skills[0].plugin_data.as_deref(),
Some(expected_data.as_str())
);
assert_eq!(skills[0].plugin_name.as_deref(), Some("plugin-dev"));
}
// ── Manifest `skills` entries pointing directly at skill dirs ──
fn make_registry_with_skill_dirs(
name: &str,
root: &Path,
skill_dirs: Vec<PathBuf>,
) -> crate::plugins::PluginRegistry {
use crate::plugins::discovery::{DiscoveredPlugin, PluginId};
use crate::plugins::manifest::PluginManifest;
let dp = DiscoveredPlugin {
manifest: PluginManifest {
name: name.to_string(),
version: Some("0.1.0".to_string()),
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,
},
id: PluginId::new(PluginScope::User, root, name),
root: root.to_path_buf(),
canonical_root: root.to_path_buf(),
scope: PluginScope::User,
origin: crate::plugins::PluginOrigin::UserKigi,
trusted: true,
skill_dirs,
command_dirs: vec![],
agent_dirs: vec![],
hooks_path: None,
mcp_config_path: None,
lsp_config_path: None,
conflict: None,
};
crate::plugins::PluginRegistry::from_discovered(vec![dp], &[], &[name.to_string()])
}
#[test]
fn collect_plugin_skills_finds_root_level_skill_md() {
// Manifest style: "skills": ["skills/one", "skills/two"] — each entry
// IS a skill directory with SKILL.md at its root.
let tmp = tempfile::tempdir().unwrap();
let one = tmp.path().join("skills").join("one");
let two = tmp.path().join("skills").join("two");
write_skill_md(&one, "one");
write_skill_md(&two, "two");
let registry =
make_registry_with_skill_dirs("listed", tmp.path(), vec![one.clone(), two.clone()]);
let skills = collect_plugin_skills(&registry);
assert_eq!(skills.len(), 2, "root-level SKILL.md dirs must load");
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"one") && names.contains(&"two"),
"{names:?}"
);
assert!(
skills
.iter()
.all(|s| s.plugin_name.as_deref() == Some("listed"))
);
}
#[test]
fn collect_plugin_skills_parent_dir_unchanged_and_no_double_count() {
// Convention style (parent dir) still works, and listing both the
// parent and a child dir does not yield duplicates after merge.
let tmp = tempfile::tempdir().unwrap();
let parent = tmp.path().join("skills");
let child = parent.join("one");
write_skill_md(&child, "one");
let registry =
make_registry_with_skill_dirs("mixed", tmp.path(), vec![parent.clone(), child.clone()]);
let merged = merge_skills_with_plugins(vec![], collect_plugin_skills(&registry));
let ones: Vec<_> = merged.iter().filter(|s| s.name == "one").collect();
assert_eq!(ones.len(), 1, "skill must appear exactly once: {merged:?}");
}
#[test]
fn filter_skills_empty_ignore_returns_all() {
let skills = vec![
make_skill("a", "/some/path/a/SKILL.md"),
make_skill("b", "/some/path/b/SKILL.md"),
];
let result = filter_skills(skills.clone(), &[]);
assert_eq!(result.len(), 2);
}
#[test]
fn filter_skills_removes_exact_path_match() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("bad-skill");
write_skill_md(&skill_dir, "bad-skill");
let skill_path = skill_dir.join("SKILL.md");
let skills = vec![
make_skill("good-skill", "/other/SKILL.md"),
make_skill("bad-skill", skill_path.to_str().unwrap()),
];
let ignore = vec![skill_path.to_str().unwrap().to_string()];
let result = filter_skills(skills, &ignore);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "good-skill");
}
#[test]
fn filter_skills_removes_directory_prefix_match() {
let tmp = tempfile::tempdir().unwrap();
let ignored_dir = tmp.path().join("ignored");
write_skill_md(&ignored_dir.join("skill-a"), "skill-a");
write_skill_md(&ignored_dir.join("skill-b"), "skill-b");
let skills = vec![
make_skill(
"skill-a",
ignored_dir
.join("skill-a")
.join("SKILL.md")
.to_str()
.unwrap(),
),
make_skill(
"skill-b",
ignored_dir
.join("skill-b")
.join("SKILL.md")
.to_str()
.unwrap(),
),
make_skill("keeper", "/other/keeper/SKILL.md"),
];
let ignore = vec![ignored_dir.to_str().unwrap().to_string()];
let result = filter_skills(skills, &ignore);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "keeper");
}
#[tokio::test]
async fn list_skills_loads_custom_path_from_config() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// A skill in a custom directory outside the repo
let custom_dir = tmp.path().join("custom-skills");
write_skill_md(&custom_dir.join("custom-skill"), "custom-skill");
let config = SkillsConfig {
paths: vec![custom_dir.to_str().unwrap().to_string()],
ignore: vec![],
disabled: vec![],
server_skill_dirs: vec![],
bundled_skill_dirs: vec![],
};
let skills = list_skills(
Some(repo_root.to_str().unwrap()),
&config,
CompatConfig::default(),
)
.await;
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"custom-skill"),
"custom-skill not found: {names:?}"
);
}
#[tokio::test]
async fn list_skills_ignore_filters_custom_path_skill() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
let custom_dir = tmp.path().join("custom-skills");
write_skill_md(&custom_dir.join("wanted"), "wanted");
write_skill_md(&custom_dir.join("unwanted"), "unwanted");
let unwanted_path = custom_dir.join("unwanted");
let config = SkillsConfig {
paths: vec![custom_dir.to_str().unwrap().to_string()],
ignore: vec![unwanted_path.to_str().unwrap().to_string()],
disabled: vec![],
server_skill_dirs: vec![],
bundled_skill_dirs: vec![],
};
let skills = list_skills(
Some(repo_root.to_str().unwrap()),
&config,
CompatConfig::default(),
)
.await;
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"wanted"), "wanted not found: {names:?}");
assert!(
!names.contains(&"unwanted"),
"unwanted should be filtered: {names:?}"
);
}
#[tokio::test]
async fn list_skills_deduplicates_auto_and_config_overlap() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
let auto_dir = repo_root.join(".kigi").join("skills").join("dup-skill");
write_skill_md(&auto_dir, "dup-skill");
// Add the same auto-discovered skills root as a config path.
let config = SkillsConfig {
paths: vec![
repo_root
.join(".kigi")
.join("skills")
.to_str()
.unwrap()
.to_string(),
],
ignore: vec![],
disabled: vec![],
server_skill_dirs: vec![],
bundled_skill_dirs: vec![],
};
let skills = list_skills(
Some(repo_root.to_str().unwrap()),
&config,
CompatConfig::default(),
)
.await;
let count = skills.iter().filter(|s| s.name == "dup-skill").count();
assert_eq!(count, 1, "dup-skill should only be loaded once");
}
/// A skill reachable via auto-discovery AND `[skills].paths` is genuinely
/// both: the auto-discovered copy wins (scope unchanged) but inherits the
/// ConfigToml stamp, so the label doesn't depend on source order.
#[tokio::test]
async fn list_skills_auto_and_config_overlap_keeps_config_toml_source() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
let auto_dir = repo_root.join(".kigi").join("skills").join("overlap-skill");
write_skill_md(&auto_dir, "overlap-skill");
let config = SkillsConfig {
paths: vec![
repo_root
.join(".kigi")
.join("skills")
.to_str()
.unwrap()
.to_string(),
],
..Default::default()
};
let skills = list_skills(
Some(repo_root.to_str().unwrap()),
&config,
CompatConfig::default(),
)
.await;
let overlaps: Vec<&SkillInfo> = skills
.iter()
.filter(|s| s.name == "overlap-skill")
.collect();
assert_eq!(
overlaps.len(),
1,
"overlap-skill should only be loaded once"
);
assert_eq!(
overlaps[0].scope,
SkillScope::Local,
"auto-discovered scope must win"
);
assert!(
matches!(
overlaps[0].config_source,
Some(kigi_tools::types::config_source::ConfigSource::ConfigToml { .. })
),
"ConfigToml stamp should survive path-dedupe: {:?}",
overlaps[0].config_source
);
}
/// Name-dedupe drops a *different* file that shares a name; its stamp must
/// not leak onto the winner (they are genuinely different skills).
#[test]
fn dedupe_skills_name_collision_does_not_propagate_config_source() {
let winner = make_skill("same-name", "/some/path/a/SKILL.md");
let mut loser = make_skill("same-name", "/some/path/b/SKILL.md");
loser.config_source = Some(kigi_tools::types::config_source::ConfigSource::ConfigToml {
path: PathBuf::from("/some/path/b/SKILL.md"),
});
let deduped = dedupe_skills(vec![winner, loser]);
// Same-scope siblings both survive (the collision loser is re-keyed
// to its dir basename); provenance must stay with its own file.
assert_eq!(deduped.len(), 2);
assert!(
deduped[0].config_source.is_none(),
"name-dedupe must not propagate provenance across different files"
);
assert_eq!(deduped[1].name, "b");
assert!(
deduped[1].config_source.is_some(),
"re-keyed sibling keeps its own provenance"
);
}
#[tokio::test]
async fn list_skills_ignore_allows_lower_priority_same_name_fallback() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let cwd = repo_root.join("work").join("nested");
fs::create_dir_all(&cwd).unwrap();
init_git_repo(&repo_root);
// Same skill name in local (higher-priority) and repo (lower-priority) sources.
write_skill_md(&cwd.join(".kigi").join("skills").join("same"), "same");
let repo_skill_dir = repo_root.join(".kigi").join("skills").join("same");
write_skill_md(&repo_skill_dir, "same");
// Ignore the local skill path. Repo fallback should remain visible.
let config = SkillsConfig {
paths: vec![],
ignore: vec![
cwd.join(".kigi")
.join("skills")
.to_str()
.unwrap()
.to_string(),
],
disabled: vec![],
server_skill_dirs: vec![],
bundled_skill_dirs: vec![],
};
let skills = list_skills(
Some(cwd.to_str().unwrap()),
&config,
CompatConfig::default(),
)
.await;
let same_skills: Vec<&SkillInfo> = skills.iter().filter(|s| s.name == "same").collect();
assert_eq!(
same_skills.len(),
1,
"Expected repo fallback skill after local ignore"
);
assert!(
same_skills[0]
.path
.starts_with(repo_skill_dir.to_str().unwrap()),
"Expected fallback from repo path, got: {}",
same_skills[0].path
);
}
// discover_skills_for_paths and dedup_by_canonical_path tests removed --
// these functions now live in kigi-tools::implementations::skills::discovery
// and kigi-tools::types::skill_discovery_tracker, tested there.
// ── Disabled skills marking ─────────────────────────────────────
#[tokio::test]
async fn disabled_config_marks_skill_enabled_false() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
write_skill_md(
&repo_root.join(".kigi").join("skills").join("commit"),
"commit",
);
write_skill_md(
&repo_root.join(".kigi").join("skills").join("review"),
"review",
);
let config = SkillsConfig {
paths: vec![],
ignore: vec![],
disabled: vec!["commit".to_string()],
server_skill_dirs: vec![],
bundled_skill_dirs: vec![],
};
let skills = list_skills(
Some(repo_root.to_str().unwrap()),
&config,
CompatConfig::default(),
)
.await;
let commit = skills.iter().find(|s| s.name == "commit");
let review = skills.iter().find(|s| s.name == "review");
assert!(
commit.is_some(),
"disabled skill should still appear in list"
);
assert!(
!commit.unwrap().enabled,
"disabled skill should have enabled=false"
);
assert!(review.is_some());
assert!(
review.unwrap().enabled,
"non-disabled skill should have enabled=true"
);
}
#[tokio::test]
async fn disabled_config_empty_leaves_all_enabled() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
write_skill_md(
&repo_root.join(".kigi").join("skills").join("deploy"),
"deploy",
);
let config = SkillsConfig {
paths: vec![],
ignore: vec![],
disabled: vec![],
server_skill_dirs: vec![],
bundled_skill_dirs: vec![],
};
let skills = list_skills(
Some(repo_root.to_str().unwrap()),
&config,
CompatConfig::default(),
)
.await;
assert!(
skills.iter().all(|s| s.enabled),
"all skills should be enabled when disabled list is empty"
);
}
// ── Bundled skills discovery ─────────────────────────────────────
#[tokio::test]
async fn bundled_skills_are_discovered() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// Place a skill under <home>/bundled/skills/commit/SKILL.md
write_skill_md(
&home.join("bundled").join("skills").join("commit"),
"commit",
);
let skills = list_skills_with_options(
Some(repo_root.to_str().unwrap()),
None,
&home,
CompatConfig::default(),
)
.await;
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"commit"),
"Expected bundled 'commit' skill, got: {names:?}"
);
}
#[tokio::test]
async fn user_skills_shadow_bundled_skills() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// User skill at <home>/skills/commit/SKILL.md
write_skill_md(&home.join("skills").join("commit"), "commit");
// Bundled skill at <home>/bundled/skills/commit/SKILL.md (different body)
let bundled_skill_dir = home.join("bundled").join("skills").join("commit");
fs::create_dir_all(&bundled_skill_dir).unwrap();
fs::write(
bundled_skill_dir.join("SKILL.md"),
"---\nname: commit\ndescription: bundled version\n---\nBundled body.\n",
)
.unwrap();
let raw = list_skills_with_options(
Some(repo_root.to_str().unwrap()),
None,
&home,
CompatConfig::default(),
)
.await;
// Both are discovered at the list_skills_with_options level (different canonical paths)
assert_eq!(
raw.iter().filter(|s| s.name == "commit").count(),
2,
"Expected exactly 2 'commit' skills before dedup (user + bundled)"
);
// User skill appears before bundled (first-seen-wins ordering)
let first_commit = raw.iter().find(|s| s.name == "commit").unwrap();
assert!(
!first_commit.path.contains("/bundled/"),
"User skill should appear before bundled: {}",
first_commit.path
);
// After name-based dedup (as list_skills_with_plugins does), only user version survives
let deduped = dedupe_skills(raw);
let commit_skills: Vec<&SkillInfo> =
deduped.iter().filter(|s| s.name == "commit").collect();
assert_eq!(
commit_skills.len(),
1,
"Expected exactly one 'commit' after dedup, got {}",
commit_skills.len()
);
assert!(
!commit_skills[0].path.contains("/bundled/"),
"User skill should win over bundled: {}",
commit_skills[0].path
);
}
// ── Command file discovery ────────────────────────────────────────
/// Regression: project `.claude/commands` often sits under a full `.claude/**`
/// gitignore with only `!.claude/skills/**` re-included (local-only vendor
/// config). User-scoped `~/.claude/commands` still loaded; project commands
/// did not — so `/frontend` never appeared for the large multi-package repo.
#[tokio::test]
async fn project_claude_commands_load_even_when_gitignored() {
let tmp = tempfile::tempdir().expect("create tempdir");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).expect("create repo dir");
init_git_repo(&repo_root);
// Mirror the multi-package-repo-style ignore: ignore all of .claude, re-include skills only.
fs::write(
repo_root.join(".gitignore"),
"**/.claude\n**/.claude/**\n!.claude/\n!.claude/skills/\n!.claude/skills/**\n",
)
.expect("write gitignore");
let commands = repo_root.join(".claude").join("commands");
fs::create_dir_all(&commands).expect("create commands dir");
fs::write(
commands.join("frontend.md"),
"---\nname: frontend\ndescription: Acme Design System Frontend Skill\n---\nUse the design system.\n",
)
.expect("write frontend.md");
// Skill under the force-included path must still load too.
write_skill_md(
&repo_root.join(".claude").join("skills").join("bp-deltas"),
"bp-deltas",
);
let repo_str = repo_root.to_str().unwrap_or_default();
let skills =
list_skills_with_options(Some(repo_str), None, tmp.path(), CompatConfig::default())
.await;
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"frontend"),
"gitignored project .claude/commands/frontend.md must load as a slash skill, got: {names:?}"
);
assert!(
names.contains(&"bp-deltas"),
"project .claude/skills still loads, got: {names:?}"
);
let frontend = skills
.iter()
.find(|s| s.name == "frontend")
.expect("frontend skill");
assert!(
frontend.path.contains("commands"),
"frontend should come from commands/, path={}",
frontend.path
);
}
#[test]
fn command_file_name_derivation_with_and_without_frontmatter() {
let tmp = tempfile::tempdir().expect("create tempdir");
let commands = tmp.path().join("commands");
fs::create_dir_all(&commands).expect("create commands dir");
fs::write(
commands.join("deploy.md"),
"---\nname: deploy\ndescription: Ship it\n---\nBody.\n",
)
.expect("write deploy.md");
fs::write(commands.join("rollback.md"), "Just rollback instructions.")
.expect("write rollback.md");
let files = vec![
(commands.join("deploy.md"), SkillScope::Repo),
(commands.join("rollback.md"), SkillScope::Repo),
];
let skills = parse_skill_files(files);
assert_eq!(skills.len(), 2);
let deploy = skills
.iter()
.find(|s| s.name == "deploy")
.expect("deploy skill not found");
assert_eq!(deploy.description, "Ship it");
let rollback = skills
.iter()
.find(|s| s.name == "rollback")
.expect("rollback skill not found");
assert_eq!(rollback.description, "Just rollback instructions.");
}
#[tokio::test]
async fn skills_shadow_commands_with_same_name() {
let tmp = tempfile::tempdir().expect("create tempdir");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).expect("create repo dir");
init_git_repo(&repo_root);
let claude_dir = repo_root.join(".claude");
write_skill_md(&claude_dir.join("skills").join("deploy"), "deploy");
let commands = claude_dir.join("commands");
fs::create_dir_all(&commands).expect("create commands dir");
fs::write(
commands.join("deploy.md"),
"---\nname: deploy\ndescription: command version\n---\n",
)
.expect("write deploy.md command");
let repo_str = repo_root.to_str().unwrap_or_default();
let raw =
list_skills_with_options(Some(repo_str), None, tmp.path(), CompatConfig::default())
.await;
let deploy_entries: Vec<_> = raw.iter().filter(|s| s.name == "deploy").collect();
assert_eq!(deploy_entries.len(), 2);
assert!(
deploy_entries[0].path.contains("SKILL.md"),
"skill should appear before command"
);
let deduped = dedupe_skills(raw);
let deploy = deduped
.iter()
.filter(|s| s.name == "deploy")
.collect::<Vec<_>>();
assert_eq!(deploy.len(), 1);
assert!(deploy[0].path.contains("SKILL.md"));
}
// ── Plugin skill identity ─────────────────────────────
fn min_plugin(name: &str) -> crate::plugins::LoadedPlugin {
use crate::plugins::discovery::PluginId;
let root = PathBuf::from(format!("/tmp/{name}"));
crate::plugins::LoadedPlugin {
name: name.to_string(),
id: PluginId::new(PluginScope::Project, &root, name),
root: root.clone(),
canonical_root: root,
scope: PluginScope::Project,
origin: crate::plugins::PluginOrigin::ProjectKigi,
trusted: true,
enabled: true,
version: Some("1.0.0".to_string()),
description: None,
skill_dirs: vec![],
command_dirs: vec![],
agent_dirs: vec![],
hooks_path: None,
mcp_config_path: None,
lsp_config_path: None,
skill_count: 0,
agent_count: 0,
skill_names: vec![],
agent_names: vec![],
has_hooks: false,
hook_count: 0,
has_inline_hooks_only: false,
mcp_server_count: 0,
has_inline_mcp_only: false,
lsp_server_count: 0,
has_inline_lsp_only: false,
inline_hooks: None,
inline_mcp_servers: None,
inline_lsp_servers: None,
conflict: None,
}
}
#[test]
fn stamp_plugin_fields_uses_dir_basename_as_identity() {
// Siblings sharing the frontmatter name `deploy` must not collide.
let mut skills = vec![
SkillInfo {
name: "deploy".to_owned(),
path: "/p/skills/deploy-prod/SKILL.md".to_owned(),
..SkillInfo::default()
},
SkillInfo {
name: "deploy".to_owned(),
path: "/p/skills/deploy-staging/SKILL.md".to_owned(),
..SkillInfo::default()
},
];
stamp_plugin_fields(&mut skills, &min_plugin("infra"));
assert_eq!(skills[0].name, "deploy-prod");
assert_eq!(skills[0].display_name.as_deref(), Some("deploy"));
assert_eq!(skills[0].dedup_key(), "infra:deploy-prod");
assert_ne!(skills[0].dedup_key(), skills[1].dedup_key());
}
#[test]
fn stamp_plugin_fields_normalizes_dir_basename() {
let mut skills = vec![SkillInfo {
name: "deploy".to_owned(),
path: "/p/skills/Deploy_Prod/SKILL.md".to_owned(),
..SkillInfo::default()
}];
stamp_plugin_fields(&mut skills, &min_plugin("infra"));
assert_eq!(skills[0].name, "deploy-prod");
assert_eq!(skills[0].display_name.as_deref(), Some("deploy"));
}
#[test]
fn stamp_plugin_fields_keeps_name_when_dir_matches() {
let mut skills = vec![SkillInfo {
name: "deploy".to_owned(),
path: "/p/skills/deploy/SKILL.md".to_owned(),
..SkillInfo::default()
}];
stamp_plugin_fields(&mut skills, &min_plugin("infra"));
assert_eq!(skills[0].name, "deploy");
assert_eq!(skills[0].display_name, None);
assert_eq!(skills[0].label(), "deploy");
}
#[tokio::test]
async fn empty_bundled_dir_produces_no_bundled_skills() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// Create <home>/bundled/ but no skills/ subdirectory
fs::create_dir_all(home.join("bundled")).unwrap();
let skills = list_skills_with_options(
Some(repo_root.to_str().unwrap()),
None,
&home,
CompatConfig::default(),
)
.await;
let bundled: Vec<_> = skills
.iter()
.filter(|s| s.path.contains("/bundled/"))
.collect();
assert!(
bundled.is_empty(),
"Expected no bundled skills from empty bundled dir, got: {:?}",
bundled.iter().map(|s| &s.name).collect::<Vec<_>>()
);
}
// ── collect_skill_config_dirs vendor gating ────────────
#[test]
fn collect_skill_config_dirs_gates_vendor_dirs() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
// Not a git repo → falls to the cwd-only branch (no upward walk).
for name in [".kigi", ".agents", ".claude", ".cursor"] {
fs::create_dir_all(cwd.join(name)).unwrap();
}
let ends_with = |dirs: &[PathBuf], suffix: &str| dirs.iter().any(|d| d.ends_with(suffix));
// All on → both vendor dirs present (byte-for-byte legacy behavior).
let all =
collect_skill_config_dirs(Some(cwd), None, tmp.path(), &[], CompatConfig::default());
assert!(ends_with(&all, ".claude"), "claude missing: {all:?}");
assert!(ends_with(&all, ".cursor"), "cursor missing: {all:?}");
// cursor.skills off → .cursor dropped, .claude kept.
let mut compat = CompatConfig::default();
compat.cursor.skills = false;
let dirs = collect_skill_config_dirs(Some(cwd), None, tmp.path(), &[], compat);
assert!(
!ends_with(&dirs, ".cursor"),
"cursor must be gated off: {dirs:?}"
);
assert!(ends_with(&dirs, ".claude"), "claude must remain: {dirs:?}");
assert!(ends_with(&dirs, ".kigi"), "kigi must remain: {dirs:?}");
}
// ── Same-scope frontmatter-name collisions (copied skill dirs) ──────
fn named_skill(name: &str, path: &str, scope: SkillScope) -> SkillInfo {
SkillInfo {
name: name.to_owned(),
path: path.to_owned(),
scope,
..SkillInfo::default()
}
}
#[test]
fn dedupe_rekeys_same_scope_name_collision_to_dir_basename() {
// `cp -r japandi japandi2` with `name: japandi` left in both files.
let out = dedupe_skills(vec![
named_skill("japandi", "/u/skills/japandi/SKILL.md", SkillScope::User),
named_skill("japandi", "/u/skills/japandi2/SKILL.md", SkillScope::User),
]);
let names: Vec<&str> = out.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["japandi", "japandi2"], "both siblings must survive");
assert_eq!(out[0].display_name, None);
assert_eq!(
out[1].display_name.as_deref(),
Some("japandi"),
"frontmatter name becomes the display label"
);
}
#[test]
fn dedupe_hands_name_back_to_basename_owner() {
// The copy sorts before the original (`backup-japandi/`): the original
// keeps the bare name; the earlier claimant is re-keyed instead.
let out = dedupe_skills(vec![
named_skill(
"japandi",
"/u/skills/backup-japandi/SKILL.md",
SkillScope::User,
),
named_skill("japandi", "/u/skills/japandi/SKILL.md", SkillScope::User),
]);
let names: Vec<&str> = out.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["backup-japandi", "japandi"]);
assert_eq!(out[0].display_name.as_deref(), Some("japandi"));
assert_eq!(out[1].display_name, None);
}
#[test]
fn dedupe_rekeys_every_same_scope_claimant() {
let out = dedupe_skills(vec![
named_skill("japandi", "/u/skills/japandi-a/SKILL.md", SkillScope::User),
named_skill("japandi", "/u/skills/japandi-b/SKILL.md", SkillScope::User),
named_skill("japandi", "/u/skills/japandi/SKILL.md", SkillScope::User),
]);
let names: Vec<&str> = out.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["japandi-a", "japandi-b", "japandi"]);
}
#[test]
fn dedupe_challenger_without_basename_claim_is_still_shadowed() {
// The contested basename is already claimed cross-scope: the
// challenger has no dir identity to fall back to and no claim to
// steal the bare name — first-seen keeps it.
let out = dedupe_skills(vec![
named_skill("japandi2", "/l/skills/japandi2/SKILL.md", SkillScope::Local),
named_skill(
"japandi",
"/u/skills/original-japandi/SKILL.md",
SkillScope::User,
),
named_skill("japandi", "/u/skills/japandi2/SKILL.md", SkillScope::User),
]);
let names: Vec<&str> = out.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["japandi2", "japandi"]);
assert_eq!(
out[1].path, "/u/skills/original-japandi/SKILL.md",
"first-seen claimant keeps the bare name"
);
}
#[test]
fn dedupe_rekeyed_name_shadows_lower_scope_claimant() {
// The re-keyed user copy owns `japandi2` before the server skill
// is seen: scope priority applies to re-keyed names too.
let out = dedupe_skills(vec![
named_skill("japandi", "/u/skills/japandi/SKILL.md", SkillScope::User),
named_skill("japandi", "/u/skills/japandi2/SKILL.md", SkillScope::User),
named_skill(
"japandi2",
"/srv/skills/japandi2/SKILL.md",
SkillScope::Server,
),
]);
let names: Vec<&str> = out.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["japandi", "japandi2"]);
assert_eq!(out[1].scope, SkillScope::User);
}
#[test]
fn dedupe_same_scope_cross_harness_loser_resurfaces() {
// A `.claude` skill claiming a `.kigi`-owned name (both User scope)
// was silently hidden before; it now re-keys to its dir basename.
let out = dedupe_skills(vec![
named_skill(
"review",
"/u/.kigi/skills/review/SKILL.md",
SkillScope::User,
),
named_skill(
"review",
"/u/.claude/skills/my-review/SKILL.md",
SkillScope::User,
),
]);
let names: Vec<&str> = out.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["review", "my-review"]);
}
#[test]
fn dedupe_frontmatter_owner_evicts_rekeyed_squatter() {
// A stale copy re-keyed to `japandi2` must not shadow the skill whose
// frontmatter genuinely says `japandi2` — the owner evicts it.
let out = dedupe_skills(vec![
named_skill(
"japandi",
"/u/.kigi/skills/japandi/SKILL.md",
SkillScope::User,
),
named_skill(
"japandi",
"/u/.kigi/skills/japandi2/SKILL.md",
SkillScope::User,
),
named_skill(
"japandi2",
"/u/.claude/skills/japandi2/SKILL.md",
SkillScope::User,
),
]);
let names: Vec<&str> = out.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["japandi", "japandi2"]);
let owner = &out[1];
assert_eq!(owner.path, "/u/.claude/skills/japandi2/SKILL.md");
assert_eq!(owner.display_name, None, "genuine owner, not a re-key");
}
#[test]
fn dedupe_cross_scope_shadowing_unchanged() {
// Cross-scope same-name is the documented override mechanism: the
// lower-priority skill stays hidden even when its dir basename differs.
let out = dedupe_skills(vec![
named_skill(
"japandi",
"/repo/.kigi/skills/japandi/SKILL.md",
SkillScope::Repo,
),
named_skill("japandi", "/u/skills/japandi2/SKILL.md", SkillScope::User),
]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].scope, SkillScope::Repo);
}
#[test]
fn dedupe_same_scope_same_basename_still_drops() {
// Same name AND same dir basename across two same-scope roots
// (e.g. ~/.kigi/skills and ~/.agents/skills): first-seen wins.
let out = dedupe_skills(vec![
named_skill(
"japandi",
"/u/.kigi/skills/japandi/SKILL.md",
SkillScope::User,
),
named_skill(
"japandi",
"/u/.agents/skills/japandi/SKILL.md",
SkillScope::User,
),
]);
assert_eq!(out.len(), 1);
assert!(out[0].path.contains(".kigi"));
}
#[tokio::test]
async fn copied_skill_dir_with_stale_frontmatter_name_surfaces_both() {
// Name-dedup runs in `list_skills` (via `merge_skills_with_plugins`),
// not in `list_skills_with_options`. Names are prefixed to be
// collision-proof against real user-scope skills (`list_skills`
// scans kigi_home).
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
let skills_dir = repo_root.join(".kigi").join("skills");
write_skill_md(&skills_dir.join("zz-copyfix-japandi"), "zz-copyfix-japandi");
// The copy keeps the original's frontmatter name.
write_skill_md(
&skills_dir.join("zz-copyfix-japandi2"),
"zz-copyfix-japandi",
);
let skills = list_skills(
Some(repo_root.to_str().unwrap()),
&SkillsConfig::default(),
CompatConfig::default(),
)
.await;
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"zz-copyfix-japandi"),
"missing original in {names:?}"
);
assert!(
names.contains(&"zz-copyfix-japandi2"),
"missing rekeyed copy in {names:?}"
);
let rekeyed = skills
.iter()
.find(|s| s.name == "zz-copyfix-japandi2")
.unwrap();
assert_eq!(rekeyed.display_name.as_deref(), Some("zz-copyfix-japandi"));
assert!(rekeyed.path.ends_with("zz-copyfix-japandi2/SKILL.md"));
}
}