feat(sproutclaw): modularize webui, extensions, and agent config layout
Some checks failed
CI / build-check-test (push) Has been cancelled
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>
This commit is contained in:
452
.pi/agent/extensions/webui/backend/settings/skills-settings.ts
Normal file
452
.pi/agent/extensions/webui/backend/settings/skills-settings.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user