Files
sproutclaw/.pi/agent/extensions/webui/backend/settings/extension-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

204 lines
6.6 KiB
TypeScript

import { basename, dirname, 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";
export interface ExtensionSettingsEntry {
path: string;
resolvedPath: string;
enabled: boolean;
name: string;
scope: string;
source: string;
}
interface ExtensionResourceItem {
path: string;
enabled: boolean;
metadata: PathMetadata;
}
function createManagers(repoRoot: string, agentDir: string) {
const settingsManager = SettingsManager.create(repoRoot, agentDir);
const packageManager = new DefaultPackageManager({
cwd: repoRoot,
agentDir,
settingsManager,
});
return { settingsManager, packageManager };
}
function normalizeExtensionPath(pathValue: string): string {
return resolve(pathValue);
}
function readExtensionDisplayName(pathValue: string): string {
const normalized = pathValue.replace(/\\/g, "/");
const parts = normalized.split("/").filter(Boolean);
const file = parts[parts.length - 1] || normalized;
if (/^index\.[cm]?[tj]s$/i.test(file) && parts.length >= 2) {
return parts[parts.length - 2].replace(/\.[cm]?[tj]s$/i, "");
}
return basename(file).replace(/\.[cm]?[tj]s$/i, "");
}
function toResourceItem(resource: ResolvedResource): ExtensionResourceItem {
return {
path: resource.path,
enabled: resource.enabled,
metadata: resource.metadata,
};
}
function getTopLevelBaseDir(scope: "user" | "project", repoRoot: string, agentDir: string): string {
return scope === "project" ? join(repoRoot, ".pi") : agentDir;
}
function getResourcePattern(item: ExtensionResourceItem, repoRoot: string, agentDir: string): string {
if (item.metadata.origin === "package") {
const baseDir = item.metadata.baseDir ?? dirname(item.path);
return relative(baseDir, item.path);
}
const scope = item.metadata.scope as "user" | "project";
const baseDir = getTopLevelBaseDir(scope, repoRoot, agentDir);
return relative(baseDir, item.path);
}
function applyPatternUpdate(current: string[], pattern: string, enabled: boolean): string[] {
const disablePattern = `-${pattern}`;
const enablePattern = `+${pattern}`;
const updated = current.filter((entry) => {
const stripped = entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
return stripped !== pattern;
});
updated.push(enabled ? enablePattern : disablePattern);
return updated;
}
function toggleTopLevelResource(
item: ExtensionResourceItem,
enabled: boolean,
settingsManager: SettingsManager,
repoRoot: string,
agentDir: string,
): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const current = [...(settings.extensions ?? [])];
const pattern = getResourcePattern(item, repoRoot, agentDir);
const updated = applyPatternUpdate(current, pattern, enabled);
if (scope === "project") {
settingsManager.setProjectExtensionPaths(updated);
} else {
settingsManager.setExtensionPaths(updated);
}
}
function togglePackageResource(
item: ExtensionResourceItem,
enabled: boolean,
settingsManager: SettingsManager,
repoRoot: string,
agentDir: string,
): void {
const scope = item.metadata.scope as "user" | "project";
const settings =
scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
const packages = [...(settings.packages ?? [])] as PackageSource[];
const pkgIndex = packages.findIndex((pkg) => {
const source = typeof pkg === "string" ? pkg : pkg.source;
return source === item.metadata.source;
});
if (pkgIndex === -1) return;
let pkg = packages[pkgIndex];
if (typeof pkg === "string") {
pkg = { source: pkg };
packages[pkgIndex] = pkg;
}
const current = [...((pkg.extensions ?? []) as string[])];
const pattern = getResourcePattern(item, repoRoot, agentDir);
const updated = applyPatternUpdate(current, pattern, enabled);
(pkg as Record<string, unknown>).extensions = updated.length > 0 ? updated : undefined;
if (!pkg.skills && !pkg.extensions && !pkg.prompts && !pkg.themes) {
packages.splice(pkgIndex, 1);
}
if (scope === "project") {
settingsManager.setProjectPackages(packages);
} else {
settingsManager.setPackages(packages);
}
}
function toggleExtensionResource(
item: ExtensionResourceItem,
enabled: boolean,
settingsManager: SettingsManager,
repoRoot: string,
agentDir: string,
): void {
if (item.metadata.origin === "top-level") {
toggleTopLevelResource(item, enabled, settingsManager, repoRoot, agentDir);
} else {
togglePackageResource(item, enabled, settingsManager, repoRoot, agentDir);
}
}
function pathsMatch(a: string, b: string): boolean {
const left = normalizeExtensionPath(a);
const right = normalizeExtensionPath(b);
if (left === right) return true;
return resolve(a) === resolve(b);
}
function mapExtensionEntry(resource: ResolvedResource): ExtensionSettingsEntry {
return {
path: normalizeExtensionPath(resource.path),
resolvedPath: normalizeExtensionPath(resource.path),
enabled: resource.enabled,
name: readExtensionDisplayName(resource.path),
scope: resource.metadata.scope || "",
source: resource.metadata.source || "",
};
}
export async function listExtensionSettings(
repoRoot: string,
agentDir: string,
): Promise<ExtensionSettingsEntry[]> {
const { packageManager } = createManagers(repoRoot, agentDir);
const resolved = await packageManager.resolve(async () => "skip");
return resolved.extensions.map(mapExtensionEntry).sort((a, b) => a.name.localeCompare(b.name));
}
export async function setExtensionEnabled(
repoRoot: string,
agentDir: string,
pathValue: string,
enabled: boolean,
): Promise<ExtensionSettingsEntry> {
const { settingsManager, packageManager } = createManagers(repoRoot, agentDir);
const resolved = await packageManager.resolve(async () => "skip");
const match = resolved.extensions.find((resource) => pathsMatch(resource.path, pathValue));
if (!match) {
throw new Error("未找到对应扩展");
}
toggleExtensionResource(toResourceItem(match), enabled, settingsManager, repoRoot, agentDir);
const refreshed = await packageManager.resolve(async () => "skip");
const updated = refreshed.extensions.find((resource) => pathsMatch(resource.path, pathValue));
if (!updated) {
throw new Error("更新扩展状态后未能重新解析");
}
return mapExtensionEntry(updated);
}