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>
197 lines
6.5 KiB
TypeScript
197 lines
6.5 KiB
TypeScript
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);
|
|
}
|