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:
@@ -0,0 +1,203 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user