import { NextResponse } from "next/server"; import { existsSync, readFileSync, statSync } from "fs"; import { basename, dirname, extname, join, relative } from "path"; import { DefaultPackageManager, getAgentDir, SettingsManager, type PackageSource, type ResolvedPaths, type ResolvedResource, } from "@earendil-works/pi-coding-agent"; import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security"; import { getProjectTrustStatus } from "@/lib/project-trust"; import type { PluginDiagnostic, PluginPackageInfo, PluginResourceCounts, PluginResourceInfo, PluginResourceKind, PluginScope, PluginsResponse, } from "@/lib/api-types"; export const dynamic = "force-dynamic"; type PluginAction = "install" | "remove" | "update" | "disable" | "enable"; function emptyCounts(): PluginResourceCounts { return { extensions: 0, skills: 0, prompts: 0, themes: 0 }; } function toPluginScope(scope: string): PluginScope { return scope === "project" ? "project" : "global"; } function keyFor(source: string, scope: PluginScope): string { return `${scope}\0${source}`; } function getPackageSource(entry: PackageSource): string { return typeof entry === "string" ? entry : entry.source; } function isDisabledPackage(entry: PackageSource): boolean { if (typeof entry === "string") return false; return ( Array.isArray(entry.extensions) && entry.extensions.length === 0 && Array.isArray(entry.skills) && entry.skills.length === 0 && Array.isArray(entry.prompts) && entry.prompts.length === 0 && Array.isArray(entry.themes) && entry.themes.length === 0 ); } function getDisabledPackages(settingsManager: SettingsManager): Map { const disabled = new Map(); for (const entry of settingsManager.getGlobalSettings().packages ?? []) { disabled.set(keyFor(getPackageSource(entry), "global"), isDisabledPackage(entry)); } for (const entry of settingsManager.getProjectSettings().packages ?? []) { disabled.set(keyFor(getPackageSource(entry), "project"), isDisabledPackage(entry)); } return disabled; } function setPackageDisabled( settingsManager: SettingsManager, source: string, scope: PluginScope, disabled: boolean, ): boolean { const current = scope === "project" ? settingsManager.getProjectSettings().packages ?? [] : settingsManager.getGlobalSettings().packages ?? []; let changed = false; const next = current.map((entry): PackageSource => { if (getPackageSource(entry) !== source) return entry; changed = true; if (disabled) { return { ...(typeof entry === "string" ? { source: entry } : entry), extensions: [], skills: [], prompts: [], themes: [], }; } return getPackageSource(entry); }); if (!changed) return false; if (scope === "project") settingsManager.setProjectPackages(next); else settingsManager.setPackages(next); return true; } function addCount(counts: PluginResourceCounts, kind: keyof PluginResourceCounts): void { counts[kind] += 1; } function getResourceName(path: string, kind: PluginResourceKind): string { const file = basename(path); const ext = extname(file); if (kind === "skill" && file.toLowerCase() === "skill.md") return basename(dirname(path)); if ((kind === "extension" || kind === "theme" || kind === "prompt") && ext) { if (kind === "extension" && /^index\.(ts|js)$/.test(file)) return basename(dirname(path)); return file.slice(0, -ext.length); } return file; } function getRelativePath(resource: ResolvedResource): string { const baseDir = resource.metadata.baseDir; if (!baseDir) return resource.path; const rel = relative(baseDir, resource.path); return rel && !rel.startsWith("..") ? rel : resource.path; } function getConfiguredVersion(source: string): string | undefined { const npmSpec = source.startsWith("npm:") ? source.slice(4) : undefined; if (npmSpec) { const lastAt = npmSpec.lastIndexOf("@"); const packageNameEnd = npmSpec.startsWith("@") ? npmSpec.indexOf("/", 1) : 0; if (lastAt > packageNameEnd) return npmSpec.slice(lastAt + 1) || undefined; return undefined; } if (source.startsWith("git:") || /^[a-z]+:\/\//.test(source)) { const lastAt = source.lastIndexOf("@"); const lastSlash = source.lastIndexOf("/"); const lastColon = source.lastIndexOf(":"); if (lastAt > Math.max(lastSlash, lastColon)) return source.slice(lastAt + 1) || undefined; } return undefined; } function readPackageMetadata(installedPath?: string): { packageName?: string; version?: string } { if (!installedPath) return {}; try { const stats = statSync(installedPath); const packageJsonPath = stats.isDirectory() ? join(installedPath, "package.json") : join(dirname(installedPath), "package.json"); if (!existsSync(packageJsonPath)) return {}; const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown; version?: unknown; }; return { packageName: typeof parsed.name === "string" ? parsed.name : undefined, version: typeof parsed.version === "string" ? parsed.version : undefined, }; } catch { return {}; } } function collectResource( resource: ResolvedResource, kind: keyof PluginResourceCounts, countsByPackage: Map, resourcesByPackage: Map, totals: PluginResourceCounts, ): void { if (!resource.enabled || resource.metadata.origin !== "package") return; const source = resource.metadata.source; const scope = toPluginScope(resource.metadata.scope); const key = keyFor(source, scope); const counts = countsByPackage.get(key) ?? emptyCounts(); addCount(counts, kind); addCount(totals, kind); countsByPackage.set(key, counts); const resources = resourcesByPackage.get(key) ?? []; const resourceKind = kind === "extensions" ? "extension" : kind === "skills" ? "skill" : kind === "prompts" ? "prompt" : "theme"; resources.push({ kind: resourceKind, name: getResourceName(resource.path, resourceKind), path: resource.path, relativePath: getRelativePath(resource), }); resourcesByPackage.set(key, resources); } function collectResources(paths: ResolvedPaths): { countsByPackage: Map; resourcesByPackage: Map; totals: PluginResourceCounts; } { const countsByPackage = new Map(); const resourcesByPackage = new Map(); const totals = emptyCounts(); for (const resource of paths.extensions) collectResource(resource, "extensions", countsByPackage, resourcesByPackage, totals); for (const resource of paths.skills) collectResource(resource, "skills", countsByPackage, resourcesByPackage, totals); for (const resource of paths.prompts) collectResource(resource, "prompts", countsByPackage, resourcesByPackage, totals); for (const resource of paths.themes) collectResource(resource, "themes", countsByPackage, resourcesByPackage, totals); return { countsByPackage, resourcesByPackage, totals }; } async function readPlugins(cwd: string): Promise { const agentDir = getAgentDir(); const projectTrust = getProjectTrustStatus(cwd, agentDir); const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: projectTrust.trusted, }); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager, }); const diagnostics: PluginDiagnostic[] = []; let countsByPackage = new Map(); let resourcesByPackage = new Map(); let totals = emptyCounts(); const disabledByPackage = getDisabledPackages(settingsManager); try { const resolved = await packageManager.resolve(async (source) => { diagnostics.push({ type: "warning", source, message: "Package is configured but not installed yet.", }); return "skip"; }); ({ countsByPackage, resourcesByPackage, totals } = collectResources(resolved)); } catch (error) { diagnostics.push({ type: "error", message: error instanceof Error ? error.message : String(error), }); } const packages = packageManager.listConfiguredPackages().map((pkg) => { const scope = toPluginScope(pkg.scope); const key = keyFor(pkg.source, scope); const disabled = disabledByPackage.get(key) ?? false; const counts = countsByPackage.get(key) ?? emptyCounts(); const resources = resourcesByPackage.get(key) ?? []; const resourceCount = counts.extensions + counts.skills + counts.prompts + counts.themes; const packageMetadata = readPackageMetadata(pkg.installedPath); if (!pkg.installedPath) { diagnostics.push({ type: "warning", source: pkg.source, message: "Configured package path was not found.", }); } return { source: pkg.source, scope, filtered: pkg.filtered, disabled, installedPath: pkg.installedPath, packageName: packageMetadata.packageName, version: packageMetadata.version, configuredVersion: getConfiguredVersion(pkg.source), counts, resources, status: disabled ? "disabled" : resourceCount > 0 ? "loaded" : pkg.installedPath ? "installed" : "missing", } satisfies PluginPackageInfo; }); return { packages, totals, diagnostics, projectResourcesLoaded: projectTrust.trusted, }; } function readScope(scope: unknown): PluginScope { return scope === "project" ? "project" : "global"; } export async function GET(req: Request) { const { searchParams } = new URL(req.url); const cwd = searchParams.get("cwd"); if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 }); try { const allowedRoots = await getAllowedFileRoots(); if (!isExistingFilePathAllowed(cwd, allowedRoots)) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } return NextResponse.json(await readPlugins(cwd)); } catch (error) { return NextResponse.json({ error: String(error) }, { status: 500 }); } } // POST /api/plugins body: { action, source?, scope?, cwd } export async function POST(req: Request) { if (!isApiRequestAllowed(req)) { return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); } if (!hasJsonContentType(req)) { return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 }); } try { const body = await req.json() as { action?: PluginAction; source?: string; scope?: PluginScope; cwd?: string; }; if (!body.cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 }); if (!body.action) return NextResponse.json({ error: "action required" }, { status: 400 }); const allowedRoots = await getAllowedFileRoots(); if (!isExistingFilePathAllowed(body.cwd, allowedRoots)) { return NextResponse.json({ error: "Access denied" }, { status: 403 }); } const agentDir = getAgentDir(); const projectTrust = getProjectTrustStatus(body.cwd, agentDir); const settingsManager = SettingsManager.create(body.cwd, agentDir, { projectTrusted: projectTrust.trusted, }); const scope = readScope(body.scope); if (scope === "project" && !projectTrust.trusted) { return NextResponse.json( { error: "Project resources must be trusted before modifying project plugins" }, { status: 403 }, ); } const packageManager = new DefaultPackageManager({ cwd: body.cwd, agentDir, settingsManager, }); const source = body.source?.trim(); const local = scope === "project"; if (body.action === "install") { if (!source) return NextResponse.json({ error: "source required" }, { status: 400 }); await packageManager.installAndPersist(source, { local }); } else if (body.action === "remove") { if (!source) return NextResponse.json({ error: "source required" }, { status: 400 }); await packageManager.removeAndPersist(source, { local }); } else if (body.action === "update") { await packageManager.update(source); } else if (body.action === "disable") { if (!source) return NextResponse.json({ error: "source required" }, { status: 400 }); setPackageDisabled(settingsManager, source, scope, true); await settingsManager.flush(); } else if (body.action === "enable") { if (!source) return NextResponse.json({ error: "source required" }, { status: 400 }); setPackageDisabled(settingsManager, source, scope, false); await settingsManager.flush(); } else { return NextResponse.json({ error: `Unsupported action: ${body.action}` }, { status: 400 }); } return NextResponse.json(await readPlugins(body.cwd)); } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 }); } }