Some checks failed
CI / build-check-test (push) Has been cancelled
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>
453 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|