import * as fs from "node:fs/promises"; import * as os from "node:os"; import { getProjectDir } from "@f5-sales-demo/pi-utils"; import { skillCapability } from "../capability/skill"; import type { SourceMeta } from "../capability/types"; import type { SkillsSettings } from "../config/settings"; import { getDefault } from "../config/settings-schema"; import { type Skill as CapabilitySkill, loadCapability } from "../discovery"; import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers"; import { expandTilde } from "../tools/path-utils"; export interface Skill { name: string; description: string; filePath: string; baseDir: string; source: string; contexts?: string[]; /** Source metadata for display */ _source?: SourceMeta; } /** A skill as a CLIENT sees it: enough to render a menu entry, nothing more. */ export interface SkillSummary { name: string; description: string; } /** * Project loaded skills onto the client-facing summary. * * `Skill` also carries `filePath`, `baseDir`, `source` and `_source` — the * operator's on-disk layout. A UI surface (Office pane, Chrome side panel, VS Code * webview) needs only the name and description to populate a menu, so this is the * one place that decides what crosses the boundary. Shared by every transport * (the `list_skills` bridge frame and the `list_skills` RPC command) so they can't * drift into leaking different amounts. */ export function toSkillSummaries(skills: readonly Skill[]): SkillSummary[] { return skills.map(s => ({ name: s.name, description: s.description })); } export interface SkillWarning { skillPath: string; message: string; } export interface LoadSkillsResult { skills: Skill[]; warnings: SkillWarning[]; } export interface LoadSkillsFromDirOptions { /** Directory to scan for skills */ dir: string; /** Source identifier for these skills */ source: string; } export async function loadSkillsFromDir(options: LoadSkillsFromDirOptions): Promise { const [rawProviderId, rawLevel] = options.source.split(":", 2); const providerId = rawProviderId || "custom"; const level: "user" | "project" = rawLevel === "project" ? "project" : "user"; const result = await scanSkillsFromDir( { cwd: getProjectDir(), home: os.homedir(), repoRoot: null }, { dir: options.dir, providerId, level, requireDescription: true, }, ); return { skills: result.items.map(capSkill => ({ name: capSkill.name, description: typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "", filePath: capSkill.path, baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""), source: options.source, contexts: Array.isArray(capSkill.frontmatter?.contexts) ? (capSkill.frontmatter.contexts as string[]) : undefined, _source: capSkill._source, })), warnings: (result.warnings ?? []).map(message => ({ skillPath: options.dir, message })), }; } export interface LoadSkillsOptions extends SkillsSettings { /** Working directory for project-local skills. Default: getProjectDir() */ cwd?: string; } /** * Which skill sources are on when the caller passes no settings. * * Read from the settings schema rather than written out again here: `createAgentSession` * reaches `loadSkills` through `Settings.getGroup("skills")`, but embedders and tests call * it directly, and the two used to disagree (the schema said `enableXcshUser: false`, the * destructuring default said `true`). Deriving both from one place makes that drift * unrepresentable. Pinned by `test/skill-source-defaults.test.ts`. */ export const SKILL_SOURCE_DEFAULTS = { enabled: getDefault("skills.enabled"), enableCodexUser: getDefault("skills.enableCodexUser"), enableXcshUser: getDefault("skills.enableXcshUser"), enableXcshProject: getDefault("skills.enableXcshProject"), enableXcshPlugins: getDefault("skills.enableXcshPlugins"), enablePiUser: getDefault("skills.enablePiUser"), enablePiProject: getDefault("skills.enablePiProject"), } as const; /** * Load skills from all configured locations. * Returns skills and any validation warnings. */ export async function loadSkills(options: LoadSkillsOptions = {}): Promise { const { cwd = getProjectDir(), enabled = SKILL_SOURCE_DEFAULTS.enabled, enableCodexUser = SKILL_SOURCE_DEFAULTS.enableCodexUser, enableXcshUser = SKILL_SOURCE_DEFAULTS.enableXcshUser, enableXcshProject = SKILL_SOURCE_DEFAULTS.enableXcshProject, enableXcshPlugins = SKILL_SOURCE_DEFAULTS.enableXcshPlugins, enablePiUser = SKILL_SOURCE_DEFAULTS.enablePiUser, enablePiProject = SKILL_SOURCE_DEFAULTS.enablePiProject, customDirectories = [], ignoredSkills = [], includeSkills = [], disabledExtensions = [], } = options; // Early return if skills are disabled if (!enabled) { return { skills: [], warnings: [] }; } const anyBuiltInSkillSourceEnabled = enableCodexUser || enableXcshUser || enableXcshProject || enablePiUser || enablePiProject; // Helper to check if a source is enabled function isSourceEnabled(source: SourceMeta): boolean { const { provider, level } = source; if (provider === "codex" && level === "user") return enableCodexUser; if (provider === "xcsh" && level === "user") return enableXcshUser; if (provider === "xcsh" && level === "project") return enableXcshProject; if (provider === "native" && level === "user") return enablePiUser; if (provider === "native" && level === "project") return enablePiProject; if (provider === "xcsh-plugins") return enableXcshPlugins; return anyBuiltInSkillSourceEnabled; } // Use capability API to load all skills const result = await loadCapability(skillCapability.id, { cwd, disabledExtensions }); const skillMap = new Map(); const realPathSet = new Set(); const collisionWarnings: SkillWarning[] = []; // Check if skill name matches any of the include patterns function matchesIncludePatterns(name: string): boolean { if (includeSkills.length === 0) return true; return includeSkills.some(pattern => new Bun.Glob(pattern).match(name)); } // Check if skill name matches any of the ignore patterns function matchesIgnorePatterns(name: string): boolean { if (ignoredSkills.length === 0) return false; return ignoredSkills.some(pattern => new Bun.Glob(pattern).match(name)); } const disabledSkillNames = new Set( (disabledExtensions ?? []).filter(id => id.startsWith("skill:")).map(id => id.slice(6)), ); // Filter skills by source and patterns first const filteredSkills = result.items.filter(capSkill => { if (disabledSkillNames.has(capSkill.name)) return false; if (!isSourceEnabled(capSkill._source)) return false; if (matchesIgnorePatterns(capSkill.name)) return false; if (!matchesIncludePatterns(capSkill.name)) return false; return true; }); // Batch resolve all real paths in parallel const realPaths = await Promise.all( filteredSkills.map(async capSkill => { try { return await fs.realpath(capSkill.path); } catch { return capSkill.path; } }), ); // Process skills with resolved paths for (let i = 0; i < filteredSkills.length; i++) { const capSkill = filteredSkills[i]; const resolvedPath = realPaths[i]; // Skip silently if we've already loaded this exact file (via symlink) if (realPathSet.has(resolvedPath)) { continue; } const existing = skillMap.get(capSkill.name); if (existing) { collisionWarnings.push({ skillPath: capSkill.path, message: `name collision: "${capSkill.name}" already loaded from ${existing.filePath}, skipping this one`, }); } else { skillMap.set(capSkill.name, { name: capSkill.name, description: typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "", filePath: capSkill.path, baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""), source: `${capSkill._source.provider}:${capSkill.level}`, contexts: Array.isArray(capSkill.frontmatter?.contexts) ? (capSkill.frontmatter.contexts as string[]) : undefined, _source: capSkill._source, }); realPathSet.add(resolvedPath); } } const customDirectoryResults = await Promise.all( customDirectories.map(async dir => { const expandedDir = expandTilde(dir); const scanResult = await scanSkillsFromDir( { cwd, home: os.homedir(), repoRoot: null }, { dir: expandedDir, providerId: "custom", level: "user", requireDescription: true, }, ); return { expandedDir, scanResult }; }), ); const allCustomSkills: Array<{ skill: Skill; path: string }> = []; for (const { expandedDir, scanResult } of customDirectoryResults) { for (const capSkill of scanResult.items) { if (disabledSkillNames.has(capSkill.name)) continue; if (matchesIgnorePatterns(capSkill.name)) continue; if (!matchesIncludePatterns(capSkill.name)) continue; allCustomSkills.push({ skill: { name: capSkill.name, description: typeof capSkill.frontmatter?.description === "string" ? capSkill.frontmatter.description : "", filePath: capSkill.path, baseDir: capSkill.path.replace(/[\\/]SKILL\.md$/, ""), source: "custom:user", contexts: Array.isArray(capSkill.frontmatter?.contexts) ? (capSkill.frontmatter.contexts as string[]) : undefined, _source: { ...capSkill._source, providerName: "Custom" }, }, path: capSkill.path, }); } collisionWarnings.push(...(scanResult.warnings ?? []).map(message => ({ skillPath: expandedDir, message }))); } const customRealPaths = await Promise.all( allCustomSkills.map(async ({ path }) => { try { return await fs.realpath(path); } catch { return path; } }), ); for (let i = 0; i < allCustomSkills.length; i++) { const { skill } = allCustomSkills[i]; const resolvedPath = customRealPaths[i]; if (realPathSet.has(resolvedPath)) continue; const existing = skillMap.get(skill.name); if (existing) { collisionWarnings.push({ skillPath: skill.filePath, message: `name collision: "${skill.name}" already loaded from ${existing.filePath}, skipping this one`, }); } else { skillMap.set(skill.name, skill); realPathSet.add(resolvedPath); } } const skills = Array.from(skillMap.values()); // Deterministic ordering for prompt stability (case-insensitive, then exact name, then path). skills.sort((a, b) => compareSkillOrder(a.name, a.filePath, b.name, b.filePath)); return { skills, warnings: [...(result.warnings ?? []).map(w => ({ skillPath: "", message: w })), ...collisionWarnings], }; } export function isApplicableToContext(skill: Skill, contextName?: string): boolean { if (!skill.contexts || skill.contexts.length === 0) return true; if (!contextName) return true; return skill.contexts.includes(contextName); }