mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { basename, dirname, extname, join, relative, resolve } 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 { createAppSettingsManager, isManagedPath, isManagedRuntime } from "@/lib/app-runtime";
|
||||
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<string, boolean> {
|
||||
const disabled = new Map<string, boolean>();
|
||||
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<string, PluginResourceCounts>,
|
||||
resourcesByPackage: Map<string, PluginResourceInfo[]>,
|
||||
totals: PluginResourceCounts,
|
||||
): void {
|
||||
if (!isManagedPath(resource.path)) return;
|
||||
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<string, PluginResourceCounts>;
|
||||
resourcesByPackage: Map<string, PluginResourceInfo[]>;
|
||||
totals: PluginResourceCounts;
|
||||
} {
|
||||
const countsByPackage = new Map<string, PluginResourceCounts>();
|
||||
const resourcesByPackage = new Map<string, PluginResourceInfo[]>();
|
||||
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<PluginsResponse> {
|
||||
const agentDir = getAgentDir();
|
||||
const projectTrust = getProjectTrustStatus(cwd, agentDir);
|
||||
const settingsManager = createAppSettingsManager(cwd, agentDir, projectTrust.trusted);
|
||||
const packageManager = new DefaultPackageManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
});
|
||||
|
||||
const diagnostics: PluginDiagnostic[] = [];
|
||||
let countsByPackage = new Map<string, PluginResourceCounts>();
|
||||
let resourcesByPackage = new Map<string, PluginResourceInfo[]>();
|
||||
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 {
|
||||
if (isManagedRuntime()) return "global";
|
||||
return scope === "project" ? "project" : "global";
|
||||
}
|
||||
|
||||
function isRemotePackageSource(source: string): boolean {
|
||||
return source.startsWith("npm:") || source.startsWith("git:") || /^[a-z]+:\/\//i.test(source);
|
||||
}
|
||||
|
||||
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 = createAppSettingsManager(body.cwd, agentDir, 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 (
|
||||
source &&
|
||||
isManagedRuntime() &&
|
||||
!isRemotePackageSource(source) &&
|
||||
!isManagedPath(resolve(body.cwd, source))
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Local plugins must be stored inside the integrated application directory" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user