Files
sproutclaw/.pi/agent/extensions/webui/backend/settings/skills-settings.ts
root cf5edd6394
Some checks failed
CI / build-check-test (push) Has been cancelled
feat(sproutclaw): modularize webui, extensions, and agent config layout
Restructure local extensions into per-feature directories, split WebUI
into backend modules with slash commands and systemd support, and track
prompts/skills under .pi/agent for portable Gitea deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 16:57:08 +08:00

453 lines
15 KiB
TypeScript

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<string>();
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<typeof readdirSync>;
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<string>): 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<string> {
const patterns = new Set<string>();
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<string>();
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<SkillSettingsEntry[]> {
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<SkillSettingsEntry> {
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,
};
}