import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { DefaultPackageManager, type PathMetadata, type ResolvedResource, } from "../../../../../../packages/coding-agent/src/core/package-manager.ts"; import { SettingsManager, type PackageSource } from "../../../../../../packages/coding-agent/src/core/settings-manager.ts"; import { parseFrontmatter } from "../../../../../../packages/coding-agent/src/utils/frontmatter.ts"; const SKILLS_DIR = "skills"; const SKILLS_DISABLED_DIR = "skills-disabled"; export interface SkillSettingsEntry { path: string; enabled: boolean; toggleable: boolean; name: string; description: string; scope: string; source: string; } interface MovableSkillLocation { baseDir: string; fromPath: string; isDirectory: boolean; currentlyDisabled: boolean; } interface SkillScanTarget { baseDir: string; scope: string; } function createManagers(repoRoot: string, agentDir: string) { const settingsManager = SettingsManager.create(repoRoot, agentDir); const packageManager = new DefaultPackageManager({ cwd: repoRoot, agentDir, settingsManager, }); return { settingsManager, packageManager }; } function resolveSkillFilePath(pathValue: string): string { const resolved = resolve(pathValue); if (resolved.endsWith("SKILL.md")) return resolved; const skillFile = join(resolved, "SKILL.md"); return existsSync(skillFile) ? skillFile : resolved; } function normalizeSkillPath(pathValue: string): string { return resolveSkillFilePath(pathValue); } function readSkillMeta(pathValue: string): { name: string; description: string } { const skillFile = resolveSkillFilePath(pathValue); const fallbackName = basename(dirname(skillFile)); if (!existsSync(skillFile)) { return { name: fallbackName, description: "" }; } try { const content = readFileSync(skillFile, "utf8"); const { frontmatter } = parseFrontmatter<{ name?: string; description?: string }>(content); return { name: String(frontmatter.name || fallbackName), description: String(frontmatter.description || ""), }; } catch { return { name: fallbackName, description: "" }; } } function isSkillToggleable(metadata: PathMetadata, skillPath: string): boolean { if (metadata.origin === "package") return false; if (metadata.source?.startsWith("npm:")) return false; if (skillPath.includes("/node_modules/") || skillPath.includes("\\node_modules\\")) return false; return metadata.origin === "top-level" && metadata.source === "auto"; } function getSkillScanTargets(repoRoot: string, agentDir: string): SkillScanTarget[] { const targets: SkillScanTarget[] = [ { baseDir: agentDir, scope: "user" }, { baseDir: join(repoRoot, ".pi"), scope: "project" }, { baseDir: join(homedir(), ".agents"), scope: "user" }, ]; const userAgents = join(homedir(), ".agents"); let dir = resolve(repoRoot); const gitRoot = (() => { let current = dir; while (true) { if (existsSync(join(current, ".git"))) return current; const parent = dirname(current); if (parent === current) return null; current = parent; } })(); dir = resolve(repoRoot); while (true) { const agentsBase = join(dir, ".agents"); if (resolve(agentsBase) !== resolve(userAgents)) { targets.push({ baseDir: agentsBase, scope: "project" }); } if (gitRoot && dir === gitRoot) break; const parent = dirname(dir); if (parent === dir) break; dir = parent; } const seen = new Set(); return targets.filter((t) => { const key = resolve(t.baseDir); if (seen.has(key)) return false; seen.add(key); return true; }); } function collectSkillFilesInDir(dir: string, piRootMarkdown = false): string[] { const entries: string[] = []; if (!existsSync(dir)) return entries; const walk = (currentDir: string, isRoot: boolean): void => { let dirEntries: ReturnType; try { dirEntries = readdirSync(currentDir, { withFileTypes: true }); } catch { return; } for (const entry of dirEntries) { if (entry.name === "SKILL.md") { const fullPath = join(currentDir, entry.name); try { if (statSync(fullPath).isFile()) entries.push(fullPath); } catch { /* ignore */ } return; } } for (const entry of dirEntries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; const fullPath = join(currentDir, entry.name); let isDir = entry.isDirectory(); let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { const stats = statSync(fullPath); isDir = stats.isDirectory(); isFile = stats.isFile(); } catch { continue; } } if (piRootMarkdown && isRoot && isFile && entry.name.endsWith(".md")) { entries.push(fullPath); continue; } if (isDir) walk(fullPath, false); } }; walk(dir, true); return entries; } function resolveMovableSkill(skillFilePath: string, baseDir: string): MovableSkillLocation | null { const skillFile = resolve(skillFilePath); const activeRoot = join(baseDir, SKILLS_DIR); const disabledRoot = join(baseDir, SKILLS_DISABLED_DIR); for (const [root, currentlyDisabled] of [ [activeRoot, false], [disabledRoot, true], ] as const) { const rel = relative(root, skillFile); if (rel.startsWith("..") || isAbsolute(rel)) continue; const parentRel = relative(root, dirname(skillFile)); if (parentRel === ".") { return { baseDir, fromPath: skillFile, isDirectory: false, currentlyDisabled }; } const topSegment = rel.split(/[/\\]/)[0]; if (!topSegment) continue; const fromPath = join(root, topSegment); if (!existsSync(fromPath)) continue; return { baseDir, fromPath, isDirectory: statSync(fromPath).isDirectory(), currentlyDisabled, }; } return null; } function findMovableSkill(pathValue: string, repoRoot: string, agentDir: string): MovableSkillLocation | null { const skillFile = resolveSkillFilePath(pathValue); for (const { baseDir } of getSkillScanTargets(repoRoot, agentDir)) { const found = resolveMovableSkill(skillFile, baseDir); if (found) return found; } return null; } function moveSkillBetweenDirs(location: MovableSkillLocation, enabled: boolean): string { const activeRoot = join(location.baseDir, SKILLS_DIR); const disabledRoot = join(location.baseDir, SKILLS_DISABLED_DIR); const destRoot = enabled ? activeRoot : disabledRoot; mkdirSync(destRoot, { recursive: true }); const name = basename(location.fromPath); const destPath = join(destRoot, name); if (resolve(location.fromPath) === resolve(destPath)) { return location.isDirectory ? join(destPath, "SKILL.md") : destPath; } if (existsSync(destPath)) { throw new Error(`目标已存在: ${destPath}`); } movePath(location.fromPath, destPath); return location.isDirectory ? join(destPath, "SKILL.md") : destPath; } function movePath(fromPath: string, destPath: string): void { try { renameSync(fromPath, destPath); } catch (err) { const code = err && typeof err === "object" && "code" in err ? String(err.code) : ""; if (code !== "EXDEV") throw err; cpSync(fromPath, destPath, { recursive: true }); rmSync(fromPath, { recursive: true, force: true }); } } function stripSkillPatternPrefix(entry: string): string { if (entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-")) { return entry.slice(1); } return entry; } function entryMatchesSkillPatterns(entry: string, patterns: Set): boolean { const stripped = stripSkillPatternPrefix(entry); for (const pattern of patterns) { if (stripped === pattern) return true; if (stripped.endsWith(`/${pattern}`)) return true; if (pattern.endsWith(stripped)) return true; } return false; } function patternsForSkillFile(skillFile: string, baseDir: string): Set { const patterns = new Set(); for (const dirName of [SKILLS_DIR, SKILLS_DISABLED_DIR]) { const root = join(baseDir, dirName); const rel = relative(root, skillFile).replace(/\\/g, "/"); if (rel.startsWith("..") || isAbsolute(rel)) continue; patterns.add(`${SKILLS_DIR}/${rel}`); const top = rel.split("/")[0]; if (top && top !== rel) patterns.add(`${SKILLS_DIR}/${top}`); if (rel.endsWith("/SKILL.md")) { patterns.add(`${SKILLS_DIR}/${dirname(rel)}`); } } return patterns; } function removeLegacySkillPatterns( settingsManager: SettingsManager, skillFilePath: string, repoRoot: string, agentDir: string, ): void { const skillFile = normalizeSkillPath(skillFilePath); const patterns = new Set(); for (const { baseDir } of getSkillScanTargets(repoRoot, agentDir)) { for (const p of patternsForSkillFile(skillFile, baseDir)) { patterns.add(p); } } const cleanList = (entries: string[]): string[] => entries.filter((entry) => !entryMatchesSkillPatterns(entry, patterns)); const globalSettings = settingsManager.getGlobalSettings(); const cleanedGlobal = cleanList([...(globalSettings.skills ?? [])]); if (cleanedGlobal.length !== (globalSettings.skills ?? []).length) { settingsManager.setSkillPaths(cleanedGlobal); } const projectSettings = settingsManager.getProjectSettings(); const cleanedProject = cleanList([...(projectSettings.skills ?? [])]); if (cleanedProject.length !== (projectSettings.skills ?? []).length) { settingsManager.setProjectSkillPaths(cleanedProject); } const cleanPackages = (packages: PackageSource[], setter: (pkgs: PackageSource[]) => void): void => { let changed = false; const updated = packages.map((pkg) => { if (typeof pkg === "string") return pkg; if (!pkg.skills?.length) return pkg; const cleaned = cleanList([...pkg.skills]); if (cleaned.length === pkg.skills.length) return pkg; changed = true; const next = { ...pkg, skills: cleaned.length > 0 ? cleaned : undefined }; if (!next.skills && !next.extensions && !next.prompts && !next.themes) { return pkg.source; } return next; }); if (changed) setter(updated); }; cleanPackages([...(globalSettings.packages ?? [])], (pkgs) => settingsManager.setPackages(pkgs)); cleanPackages([...(projectSettings.packages ?? [])], (pkgs) => settingsManager.setProjectPackages(pkgs)); } function toggleSkillByMove( pathValue: string, enabled: boolean, repoRoot: string, agentDir: string, settingsManager: SettingsManager, ): string { const location = findMovableSkill(pathValue, repoRoot, agentDir); if (!location) { throw new Error("仅支持切换 skills 目录下的 skill"); } let newPath = normalizeSkillPath(pathValue); if (enabled && location.currentlyDisabled) { newPath = normalizeSkillPath(moveSkillBetweenDirs(location, true)); } else if (!enabled && !location.currentlyDisabled) { newPath = normalizeSkillPath(moveSkillBetweenDirs(location, false)); } removeLegacySkillPatterns(settingsManager, newPath, repoRoot, agentDir); return newPath; } function mapSkillEntry(resource: ResolvedResource): SkillSettingsEntry { const meta = readSkillMeta(resource.path); const toggleable = isSkillToggleable(resource.metadata, resource.path); return { path: normalizeSkillPath(resource.path), enabled: resource.enabled, toggleable, name: meta.name, description: meta.description, scope: resource.metadata.scope || "", source: resource.metadata.source || "", }; } function mapDisabledSkillEntry(skillPath: string, scope: string): SkillSettingsEntry { const meta = readSkillMeta(skillPath); return { path: normalizeSkillPath(skillPath), enabled: false, toggleable: true, name: meta.name, description: meta.description, scope, source: "auto", }; } function collectDisabledSkillEntries(repoRoot: string, agentDir: string): SkillSettingsEntry[] { const entries: SkillSettingsEntry[] = []; for (const { baseDir, scope } of getSkillScanTargets(repoRoot, agentDir)) { const disabledDir = join(baseDir, SKILLS_DISABLED_DIR); const piRootMarkdown = resolve(baseDir) === resolve(agentDir) || resolve(baseDir) === resolve(join(repoRoot, ".pi")); for (const skillPath of collectSkillFilesInDir(disabledDir, piRootMarkdown)) { entries.push(mapDisabledSkillEntry(skillPath, scope)); } } return entries; } function pathsMatch(a: string, b: string): boolean { const left = normalizeSkillPath(a); const right = normalizeSkillPath(b); if (left === right) return true; return resolve(a) === resolve(b); } function mergeSkillEntries(resolved: SkillSettingsEntry[], disabled: SkillSettingsEntry[]): SkillSettingsEntry[] { const merged = [...resolved]; for (const entry of disabled) { if (!merged.some((item) => pathsMatch(item.path, entry.path))) { merged.push(entry); } } return merged.sort((a, b) => a.name.localeCompare(b.name)); } export async function listSkillSettings(repoRoot: string, agentDir: string): Promise { const { packageManager } = createManagers(repoRoot, agentDir); const resolved = await packageManager.resolve(async () => "skip"); const active = resolved.skills.map(mapSkillEntry); const disabled = collectDisabledSkillEntries(repoRoot, agentDir); return mergeSkillEntries(active, disabled); } export async function setSkillEnabled( repoRoot: string, agentDir: string, pathValue: string, enabled: boolean, ): Promise { const { settingsManager, packageManager } = createManagers(repoRoot, agentDir); const resolved = await packageManager.resolve(async () => "skip"); const match = resolved.skills.find((resource) => pathsMatch(resource.path, pathValue)); const disabledOnly = collectDisabledSkillEntries(repoRoot, agentDir).find((entry) => pathsMatch(entry.path, pathValue), ); if (match && !isSkillToggleable(match.metadata, match.path)) { throw new Error("npm 包内的 skill 由包管理器控制,无法在此禁用"); } if (!match && !disabledOnly) { throw new Error("未找到对应 skill"); } if (!findMovableSkill(pathValue, repoRoot, agentDir)) { throw new Error("仅支持切换 skills 目录下的 skill"); } const newPath = toggleSkillByMove(pathValue, enabled, repoRoot, agentDir, settingsManager); const meta = readSkillMeta(newPath); const scope = match?.metadata.scope || disabledOnly?.scope || ""; const source = match?.metadata.source || disabledOnly?.source || "auto"; return { path: newPath, enabled, toggleable: true, name: meta.name, description: meta.description, scope, source, }; }