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 { return readConfiguredNpmPackageNames(paths.agentSettingsFile); } function getExtensionPath(extension: any): string { return String(extension.path || extension.resolvedPath || ""); } function classifyExtension( paths: WebUiPaths, extension: any, configuredPackages: Set, ): "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, ): Record { 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> { const configuredPackages = getConfiguredNpmPackages(paths); const response = await sendCmd({ type: "get_extensions" }); if (!response.success) throw new Error(response.error || "读取扩展失败"); const loadedByPath = new Map(); 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[]> { const configuredPackages = getConfiguredNpmPackages(paths); const resolved = await listExtensionSettings(paths.repoRoot, paths.agentDir); let loadedByPath = new Map(); 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, ): Record { 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); }