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,196 @@
|
||||
import { basename, resolve } from "node:path";
|
||||
import type { WebUiPaths } from "../config/paths.ts";
|
||||
import type { SendCmd } from "../types/context.ts";
|
||||
import { listExtensionSettings, type ExtensionSettingsEntry } from "../settings/extension-settings.ts";
|
||||
import {
|
||||
getExtensionCategoryFromPath,
|
||||
readConfiguredNpmPackageNames,
|
||||
readNpmPackageVersion,
|
||||
resolveNpmSource,
|
||||
} from "../settings/extensions-paths.ts";
|
||||
|
||||
function getConfiguredNpmPackages(paths: WebUiPaths): Set<string> {
|
||||
return readConfiguredNpmPackageNames(paths.agentSettingsFile);
|
||||
}
|
||||
|
||||
function getExtensionPath(extension: any): string {
|
||||
return String(extension.path || extension.resolvedPath || "");
|
||||
}
|
||||
|
||||
function classifyExtension(
|
||||
paths: WebUiPaths,
|
||||
extension: any,
|
||||
configuredPackages: Set<string>,
|
||||
): "local" | "npm" | null {
|
||||
const pathValue = getExtensionPath(extension);
|
||||
return getExtensionCategoryFromPath(
|
||||
pathValue,
|
||||
paths.agentExtensionsDir,
|
||||
paths.agentNpmNodeModules,
|
||||
configuredPackages,
|
||||
);
|
||||
}
|
||||
|
||||
function cleanExtensionName(name: string): string {
|
||||
return basename(name).replace(/\.[cm]?[tj]s$/i, "");
|
||||
}
|
||||
|
||||
function displayExtensionName(extensionPath: string, source: string): string {
|
||||
const sourceMatch = source.match(/^npm:(.+)$/);
|
||||
if (sourceMatch?.[1]) return sourceMatch[1];
|
||||
|
||||
const normalized = extensionPath.replace(/\\/g, "/");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const file = parts[parts.length - 1] || normalized;
|
||||
if (/^index\.[tj]s$/i.test(file) && parts.length >= 2) {
|
||||
return cleanExtensionName(parts[parts.length - 2]);
|
||||
}
|
||||
return cleanExtensionName(file);
|
||||
}
|
||||
|
||||
function displayExtensionKind(
|
||||
scope: string,
|
||||
source: string,
|
||||
category: "local" | "npm",
|
||||
version?: string,
|
||||
): string {
|
||||
if (category === "npm") {
|
||||
const packageName = source.startsWith("npm:") ? source.slice(4) : source;
|
||||
const base = packageName ? `npm · ${packageName}` : "npm";
|
||||
return version ? `${base} · ${version}` : base;
|
||||
}
|
||||
const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "";
|
||||
if (source === "auto" || source === "local") {
|
||||
return scopeText ? `${scopeText}本地` : "本地";
|
||||
}
|
||||
return source ? `${scopeText || "未知"} · ${source}` : scopeText || "本地";
|
||||
}
|
||||
|
||||
function displayExtensionLocation(repoRoot: string, extensionPath: string, resolvedPath: string): string {
|
||||
const pathValue = extensionPath || resolvedPath;
|
||||
if (!pathValue) return "";
|
||||
return pathValue.replace(repoRoot, ".");
|
||||
}
|
||||
|
||||
export function normalizeExtension(
|
||||
paths: WebUiPaths,
|
||||
extension: any,
|
||||
configuredPackages: Set<string>,
|
||||
): Record<string, unknown> {
|
||||
const sourceInfo = extension.sourceInfo || {};
|
||||
const pathValue = getExtensionPath(extension);
|
||||
const category = classifyExtension(paths, extension, configuredPackages);
|
||||
const source =
|
||||
category === "npm"
|
||||
? resolveNpmSource(pathValue, paths.agentNpmNodeModules, String(sourceInfo.source || ""))
|
||||
: String(sourceInfo.source || "");
|
||||
const resolvedPath = String(extension.resolvedPath || extension.path || pathValue);
|
||||
const npmCategory = category === "npm" ? "npm" : "local";
|
||||
const version =
|
||||
category === "npm"
|
||||
? readNpmPackageVersion(paths.agentNpmNodeModules, pathValue, source)
|
||||
: undefined;
|
||||
return {
|
||||
name: displayExtensionName(pathValue, source),
|
||||
rawName: extension.name || "",
|
||||
path: pathValue,
|
||||
resolvedPath,
|
||||
scope: sourceInfo.scope || extension.scope || "",
|
||||
source,
|
||||
sourcePath: sourceInfo.path || "",
|
||||
location: displayExtensionLocation(paths.repoRoot, pathValue, resolvedPath),
|
||||
version,
|
||||
kind: displayExtensionKind(sourceInfo.scope || extension.scope, source, npmCategory, version),
|
||||
category: category || "local",
|
||||
enabled: extension.enabled !== false,
|
||||
commands: extension.commands || [],
|
||||
tools: extension.tools || [],
|
||||
flags: extension.flags || [],
|
||||
shortcuts: extension.shortcuts || [],
|
||||
handlers: extension.handlers || [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLoadedExtensionsByPath(
|
||||
paths: WebUiPaths,
|
||||
sendCmd: SendCmd,
|
||||
): Promise<Map<string, any>> {
|
||||
const configuredPackages = getConfiguredNpmPackages(paths);
|
||||
const response = await sendCmd({ type: "get_extensions" });
|
||||
if (!response.success) throw new Error(response.error || "读取扩展失败");
|
||||
const loadedByPath = new Map<string, any>();
|
||||
for (const extension of response.data?.extensions || []) {
|
||||
if (classifyExtension(paths, extension, configuredPackages) === null) continue;
|
||||
const pathValue = getExtensionPath(extension);
|
||||
loadedByPath.set(resolve(pathValue), extension);
|
||||
if (extension.resolvedPath) {
|
||||
loadedByPath.set(resolve(String(extension.resolvedPath)), extension);
|
||||
}
|
||||
}
|
||||
return loadedByPath;
|
||||
}
|
||||
|
||||
export async function listExtensionsForSettings(
|
||||
paths: WebUiPaths,
|
||||
sendCmd: SendCmd,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const configuredPackages = getConfiguredNpmPackages(paths);
|
||||
const resolved = await listExtensionSettings(paths.repoRoot, paths.agentDir);
|
||||
let loadedByPath = new Map<string, any>();
|
||||
try {
|
||||
loadedByPath = await listLoadedExtensionsByPath(paths, sendCmd);
|
||||
} catch {
|
||||
/* show resolved extensions even if agent RPC is unavailable */
|
||||
}
|
||||
|
||||
return resolved
|
||||
.map((entry) => {
|
||||
const loaded =
|
||||
loadedByPath.get(resolve(entry.path)) ||
|
||||
loadedByPath.get(resolve(entry.resolvedPath || entry.path));
|
||||
const merged = loaded
|
||||
? { ...loaded, enabled: entry.enabled }
|
||||
: {
|
||||
path: entry.path,
|
||||
resolvedPath: entry.resolvedPath,
|
||||
sourceInfo: { scope: entry.scope, source: entry.source },
|
||||
commands: [],
|
||||
tools: [],
|
||||
flags: [],
|
||||
shortcuts: [],
|
||||
handlers: [],
|
||||
enabled: entry.enabled,
|
||||
};
|
||||
return normalizeExtension(paths, merged, configuredPackages);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const categoryOrder = a.category === b.category ? 0 : a.category === "local" ? -1 : 1;
|
||||
if (categoryOrder !== 0) return categoryOrder;
|
||||
return String(a.name).localeCompare(String(b.name));
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeExtensionToggleResponse(
|
||||
paths: WebUiPaths,
|
||||
extension: ExtensionSettingsEntry,
|
||||
loadedByPath: Map<string, any>,
|
||||
): Record<string, unknown> {
|
||||
const configuredPackages = getConfiguredNpmPackages(paths);
|
||||
const loaded =
|
||||
loadedByPath.get(resolve(extension.path)) ||
|
||||
loadedByPath.get(resolve(extension.resolvedPath || extension.path));
|
||||
const merged = loaded
|
||||
? { ...loaded, enabled: extension.enabled }
|
||||
: {
|
||||
path: extension.path,
|
||||
resolvedPath: extension.resolvedPath,
|
||||
sourceInfo: { scope: extension.scope, source: extension.source },
|
||||
commands: [],
|
||||
tools: [],
|
||||
flags: [],
|
||||
shortcuts: [],
|
||||
handlers: [],
|
||||
enabled: extension.enabled,
|
||||
};
|
||||
return normalizeExtension(paths, merged, configuredPackages);
|
||||
}
|
||||
Reference in New Issue
Block a user