mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
MAX_INLINE_BASH_OUTPUT_BYTES,
|
||||
openRegularFileNoFollow,
|
||||
readUtf8FileWithinLimit,
|
||||
resolveBashOutputPath,
|
||||
} from "@/lib/bash-output";
|
||||
import { isBashOutputPathReferencedBySession } from "@/lib/session-file-references";
|
||||
|
||||
// GET /api/agent/[id]/bash-output?path=<absPath>
|
||||
// Reads a bash output temp file referenced by this session. Inline display is
|
||||
// size-limited; download responses stream the file without buffering it.
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
let path: string | null = null;
|
||||
let download = false;
|
||||
try {
|
||||
const url = new URL(_req.url);
|
||||
path = url.searchParams.get("path");
|
||||
download = url.searchParams.get("download") === "1";
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid url" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return NextResponse.json({ error: "path required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolved = resolveBashOutputPath(path, tmpdir());
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: "invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!await isBashOutputPathReferencedBySession(resolved, id)) {
|
||||
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (download) {
|
||||
const { handle } = await openRegularFileNoFollow(resolved);
|
||||
const stream = Readable.toWeb(handle.createReadStream()) as ReadableStream<Uint8Array>;
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": "attachment; filename=\"bash-output.log\"",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await readUtf8FileWithinLimit(resolved);
|
||||
if (result.tooLarge) {
|
||||
return NextResponse.json({
|
||||
error: `Full output is too large to display (limit ${MAX_INLINE_BASH_OUTPUT_BYTES} bytes)`,
|
||||
data: { size: result.size, maxBytes: MAX_INLINE_BASH_OUTPUT_BYTES },
|
||||
}, { status: 413 });
|
||||
}
|
||||
return NextResponse.json({ success: true, data: { output: result.content } });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "full output unavailable" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/agent/[id]/events - SSE stream of agent events
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
// Fast path: already-running session
|
||||
let session = getRpcSession(id);
|
||||
if (!session || !session.isAlive()) {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return new Response("Session not found", { status: 404 });
|
||||
}
|
||||
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
|
||||
try {
|
||||
({ session } = await startRpcSession(id, filePath, cwd));
|
||||
} catch (error) {
|
||||
return new Response(`Failed to start agent: ${error}`, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encode = (data: unknown) => {
|
||||
const text = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
};
|
||||
|
||||
// Send initial connected event
|
||||
encode({ type: "connected", sessionId: id });
|
||||
|
||||
const unsubscribe = session.onEvent((event) => {
|
||||
encode(event);
|
||||
});
|
||||
|
||||
// Heartbeat every 30s to prevent server/proxy timeout (Next.js default ~120-150s)
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(":\n\n"));
|
||||
} catch {
|
||||
// controller already closed
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Cleanup when client disconnects
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
controller.close();
|
||||
};
|
||||
|
||||
// Detect client disconnect via abort signal
|
||||
req.signal?.addEventListener("abort", cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
import { startRpcSession, getRpcSession } from "@/lib/rpc-manager";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
// POST /api/agent/[id] - Send a command to an existing session
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const body = await req.json() as { type: string; [key: string]: unknown };
|
||||
|
||||
// Fast path: already-running session
|
||||
const existing = getRpcSession(id);
|
||||
if (existing?.isAlive()) {
|
||||
const result = await existing.send(body);
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
}
|
||||
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
|
||||
|
||||
const { session } = await startRpcSession(id, filePath, cwd);
|
||||
const result = await session.send(body);
|
||||
|
||||
return NextResponse.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/agent/[id] - Get current agent state
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const session = getRpcSession(id);
|
||||
if (!session || !session.isAlive()) {
|
||||
return NextResponse.json({ running: false });
|
||||
}
|
||||
|
||||
const state = await session.send({ type: "get_state" });
|
||||
return NextResponse.json({ running: true, state });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { allowFileRoot } from "@/lib/file-access";
|
||||
import { invalidateSessionListCache } from "@/lib/session-reader";
|
||||
import { startRpcSession } from "@/lib/rpc-manager";
|
||||
// POST /api/agent/new body: { cwd: string; type: string; message?: string; ... }
|
||||
// Spawns a brand-new pi session. Most calls immediately send the first command;
|
||||
// type:"ensure_session" only creates the runtime so clients can query commands.
|
||||
// Returns { sessionId, data } where sessionId is pi's real session id.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: string; [key: string]: unknown };
|
||||
const { cwd, ...command } = body;
|
||||
|
||||
if (!cwd || typeof cwd !== "string") {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
if (!existsSync(cwd)) {
|
||||
return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use a one-time key so startRpcSession's lock doesn't conflict with real session ids
|
||||
const { provider, modelId, toolNames, thinkingLevel, ...promptCommand } = command as { provider?: string; modelId?: string; toolNames?: string[]; thinkingLevel?: string; [key: string]: unknown };
|
||||
|
||||
// Must be unique per request: startRpcSession coalesces concurrent callers
|
||||
// that share a key onto one session. Date.now() (ms resolution) collides for
|
||||
// requests in the same millisecond, merging two new sessions into one.
|
||||
const tempKey = `__new__${randomUUID()}`;
|
||||
const { session, realSessionId } = await startRpcSession(tempKey, "", cwd, toolNames);
|
||||
|
||||
// Keep the files-route allowed-roots cache (see app/api/files/[...path]/route.ts)
|
||||
// in sync so the new cwd is immediately readable via /api/files. Without this,
|
||||
// a file request under a brand-new cwd would 403 for up to the cache TTL.
|
||||
allowFileRoot(cwd);
|
||||
invalidateSessionListCache();
|
||||
|
||||
// Apply pre-selected model before sending the prompt
|
||||
if (provider && modelId) {
|
||||
await session.send({ type: "set_model", provider, modelId });
|
||||
}
|
||||
|
||||
// Apply pre-selected thinking level before sending the prompt
|
||||
if (thinkingLevel) {
|
||||
await session.send({ type: "set_thinking_level", level: thinkingLevel });
|
||||
}
|
||||
|
||||
if (promptCommand.type === "ensure_session") {
|
||||
return NextResponse.json({ success: true, sessionId: realSessionId, data: null });
|
||||
}
|
||||
|
||||
const result = await session.send(promptCommand);
|
||||
|
||||
return NextResponse.json({ success: true, sessionId: realSessionId, data: result });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getRunningRpcSessionIds, subscribeRunningSessions } from "@/lib/rpc-manager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/agent/running/events - SSE stream of the set of currently-running
|
||||
// session ids. Pushes an update whenever any session starts or stops working,
|
||||
// so the sidebar never has to poll.
|
||||
export async function GET(req: Request) {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encode = (data: unknown) => {
|
||||
const text = `data: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
};
|
||||
|
||||
// Subscribe BEFORE taking the initial snapshot so no state change can slip
|
||||
// through the gap between snapshot and subscription.
|
||||
const unsubscribe = subscribeRunningSessions((ids) => {
|
||||
try {
|
||||
encode({ type: "running", runningSessionIds: ids });
|
||||
} catch {
|
||||
// controller already closed
|
||||
}
|
||||
});
|
||||
|
||||
// Initial snapshot so the client renders the correct state immediately.
|
||||
// (A duplicate frame here is harmless: the client just sets the same set.)
|
||||
encode({ type: "running", runningSessionIds: getRunningRpcSessionIds() });
|
||||
|
||||
// Heartbeat to keep the connection alive through proxies/timeouts.
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(":\n\n"));
|
||||
} catch {
|
||||
// controller already closed
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
try { controller.close(); } catch { /* already closed */ }
|
||||
};
|
||||
|
||||
req.signal?.addEventListener("abort", cleanup);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Providers that use OAuth — handled separately via /api/auth/providers
|
||||
const OAUTH_PROVIDER_IDS = new Set(["anthropic", "github-copilot", "openai-codex"]);
|
||||
|
||||
export async function GET() {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const all = modelRuntime.getModels();
|
||||
|
||||
// Deduplicate by provider, skip OAuth-only providers and custom providers (source=models_json_key)
|
||||
const seen = new Set<string>();
|
||||
const result: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
configured: boolean;
|
||||
source?: string;
|
||||
modelCount: number;
|
||||
}[] = [];
|
||||
|
||||
for (const provider of modelRuntime.getProviders()) {
|
||||
if (seen.has(provider.id)) continue;
|
||||
seen.add(provider.id);
|
||||
if (OAUTH_PROVIDER_IDS.has(provider.id) || !provider.auth.apiKey?.login) continue;
|
||||
const status = modelRuntime.getProviderAuthStatus(provider.id);
|
||||
// Skip providers whose key comes from models.json (those are custom providers)
|
||||
if (status.source === "models_json_key") continue;
|
||||
const modelCount = all.filter((model) => model.provider === provider.id).length;
|
||||
result.push({
|
||||
id: provider.id,
|
||||
displayName: provider.name,
|
||||
configured: status.configured,
|
||||
source: status.source,
|
||||
modelCount,
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ providers: result });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { NextResponse } from "next/server";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ provider: string }> };
|
||||
|
||||
// GET /api/auth/api-key/[provider] — returns auth status (never returns the actual key)
|
||||
export async function GET(_req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const status = modelRuntime.getProviderAuthStatus(provider);
|
||||
const displayName = modelRuntime.getProvider(provider)?.name ?? provider;
|
||||
const models = modelRuntime.getModels(provider).length;
|
||||
return NextResponse.json({ provider, displayName, configured: status.configured, source: status.source, models });
|
||||
}
|
||||
|
||||
// POST /api/auth/api-key/[provider] body: { apiKey: string }
|
||||
export async function POST(req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
try {
|
||||
const { apiKey } = await req.json() as { apiKey?: string };
|
||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||
return NextResponse.json({ error: "apiKey is required" }, { status: 400 });
|
||||
}
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
let keySubmitted = false;
|
||||
await modelRuntime.login(provider, "api_key", {
|
||||
notify: () => {},
|
||||
prompt: async (prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
const keyOption = prompt.options.find((option) => option.id === "api-key" || option.id === "bearer-token");
|
||||
if (keyOption) return keyOption.id;
|
||||
throw new Error(`${provider} requires interactive authentication setup`);
|
||||
}
|
||||
if (!keySubmitted && prompt.type === "secret") {
|
||||
keySubmitted = true;
|
||||
return apiKey.trim();
|
||||
}
|
||||
throw new Error(`${provider} requires additional authentication settings`);
|
||||
},
|
||||
});
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/auth/api-key/[provider] — removes stored API key
|
||||
export async function DELETE(_req: Request, { params }: Params) {
|
||||
const { provider } = await params;
|
||||
try {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
await modelRuntime.logout(provider);
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// In-memory registry: loginToken -> resolve/reject for the manualCodeInput promise
|
||||
declare global {
|
||||
var __piLoginCallbacks: Map<string, { resolve: (v: string) => void; reject: (e: Error) => void }> | undefined;
|
||||
}
|
||||
|
||||
function getCallbackRegistry() {
|
||||
if (!globalThis.__piLoginCallbacks) globalThis.__piLoginCallbacks = new Map();
|
||||
return globalThis.__piLoginCallbacks;
|
||||
}
|
||||
|
||||
// POST /api/auth/login/[provider] — frontend sends redirect URL or auth code
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
const { token, code } = (await req.json()) as { token?: string; code?: string };
|
||||
|
||||
if (!token || !code) {
|
||||
return Response.json({ error: "token and code required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const registry = getCallbackRegistry();
|
||||
const callbacks = registry.get(token);
|
||||
if (!callbacks) {
|
||||
return Response.json({ error: "No pending login for token" }, { status: 404 });
|
||||
}
|
||||
// Verify token belongs to this provider (token format: "<provider>-<ts>-<random>")
|
||||
if (!token.startsWith(`${provider}-`)) {
|
||||
return Response.json({ error: "Token does not match provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
callbacks.resolve(code);
|
||||
registry.delete(token);
|
||||
return Response.json({ ok: true, provider });
|
||||
}
|
||||
|
||||
// GET /api/auth/login/[provider] — SSE stream for OAuth flow
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const send = (controller: ReadableStreamDefaultController, data: unknown) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
};
|
||||
|
||||
// AbortController propagates client disconnect into ModelRuntime.login().
|
||||
const abort = new AbortController();
|
||||
req.signal.addEventListener("abort", () => abort.abort());
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
if (!modelRuntime.getProvider(provider)?.auth.oauth) {
|
||||
send(controller, { type: "error", message: `Unknown provider: ${provider}` });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = getCallbackRegistry();
|
||||
const activeTokens = new Set<string>();
|
||||
let pendingManualRequest: { token: string; promise: Promise<string> } | undefined;
|
||||
|
||||
const createClientInputRequest = () => {
|
||||
const token = `${provider}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
activeTokens.add(token);
|
||||
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
registry.set(token, {
|
||||
resolve: (value) => {
|
||||
activeTokens.delete(token);
|
||||
registry.delete(token);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
activeTokens.delete(token);
|
||||
registry.delete(token);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { token, promise };
|
||||
};
|
||||
|
||||
const getManualInputRequest = () => {
|
||||
if (!pendingManualRequest) {
|
||||
pendingManualRequest = createClientInputRequest();
|
||||
pendingManualRequest.promise
|
||||
.finally(() => {
|
||||
pendingManualRequest = undefined;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return pendingManualRequest;
|
||||
};
|
||||
|
||||
// Cleanup: remove pending token and abort any waiting promise
|
||||
const cleanup = () => {
|
||||
for (const token of activeTokens) {
|
||||
registry.get(token)?.reject(new Error("Login cancelled"));
|
||||
registry.delete(token);
|
||||
}
|
||||
activeTokens.clear();
|
||||
};
|
||||
|
||||
// Also cancel on client disconnect
|
||||
abort.signal.addEventListener("abort", cleanup);
|
||||
|
||||
try {
|
||||
await modelRuntime.login(provider, "oauth", {
|
||||
prompt: async (prompt: AuthPrompt) => {
|
||||
const request = prompt.type === "manual_code"
|
||||
? getManualInputRequest()
|
||||
: createClientInputRequest();
|
||||
if (prompt.type === "select") {
|
||||
send(controller, {
|
||||
type: "select_request",
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
token: request.token,
|
||||
});
|
||||
} else {
|
||||
send(controller, {
|
||||
type: "prompt_request",
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder ?? null,
|
||||
token: request.token,
|
||||
});
|
||||
}
|
||||
return request.promise;
|
||||
},
|
||||
notify: (event: AuthEvent) => {
|
||||
if (event.type === "auth_url") {
|
||||
const request = getManualInputRequest();
|
||||
send(controller, {
|
||||
type: "auth",
|
||||
url: event.url,
|
||||
instructions: event.instructions ?? null,
|
||||
token: request.token,
|
||||
});
|
||||
} else if (event.type === "device_code") {
|
||||
send(controller, {
|
||||
type: "device_code",
|
||||
userCode: event.userCode,
|
||||
verificationUri: event.verificationUri,
|
||||
intervalSeconds: event.intervalSeconds ?? null,
|
||||
expiresInSeconds: event.expiresInSeconds ?? null,
|
||||
});
|
||||
} else {
|
||||
send(controller, { type: "progress", message: event.message });
|
||||
}
|
||||
},
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
invalidateModelsCache();
|
||||
send(controller, { type: "success" });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg !== "Login cancelled") {
|
||||
send(controller, { type: "error", message: msg });
|
||||
} else {
|
||||
send(controller, { type: "cancelled" });
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
abort.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ provider: string }> }
|
||||
) {
|
||||
const { provider } = await params;
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
if (!modelRuntime.getProvider(provider)?.auth.oauth) {
|
||||
return Response.json({ error: `Unknown provider: ${provider}` }, { status: 400 });
|
||||
}
|
||||
await modelRuntime.logout(provider);
|
||||
invalidateModelsCache();
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const credentials = await modelRuntime.listCredentials();
|
||||
const loggedInProviders = new Set(
|
||||
credentials.filter((credential) => credential.type === "oauth").map((credential) => credential.providerId),
|
||||
);
|
||||
const providers = modelRuntime.getProviders().filter((provider) => provider.auth.oauth);
|
||||
|
||||
const EXCLUDED = new Set(["anthropic"]);
|
||||
const DISPLAY_NAMES: Record<string, string> = {
|
||||
"openai-codex": "ChatGPT Plus/Pro",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
};
|
||||
|
||||
const result = await Promise.all(
|
||||
providers
|
||||
.filter((p) => !EXCLUDED.has(p.id))
|
||||
.map(async (p) => {
|
||||
return {
|
||||
id: p.id,
|
||||
name: DISPLAY_NAMES[p.id] ?? p.name,
|
||||
usesCallbackServer: false,
|
||||
loggedIn: loggedInProviders.has(p.id),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return Response.json({ providers: result });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { stat } from "fs/promises";
|
||||
import {
|
||||
getBrowseStartDirectory,
|
||||
getParentDirectory,
|
||||
listDirectories,
|
||||
resolveDirectory,
|
||||
} from "@/lib/directory-browser";
|
||||
|
||||
// GET /api/cwd/browse?path=...:列出文件系统中的可读子目录。
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const requested = request.nextUrl.searchParams.get("path")?.trim();
|
||||
const candidate = getBrowseStartDirectory(requested);
|
||||
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = await resolveDirectory(candidate);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Directory does not exist" }, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
const directoryStat = await stat(resolved);
|
||||
if (!directoryStat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Path is not a directory" }, { status: 400 });
|
||||
}
|
||||
|
||||
const directories = await listDirectories(resolved);
|
||||
|
||||
return NextResponse.json({
|
||||
path: resolved,
|
||||
parentPath: getParentDirectory(resolved),
|
||||
directories,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { statSync, type Stats } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { isAbsolute, resolve } from "path";
|
||||
import { allowFileRoot } from "@/lib/file-access";
|
||||
|
||||
function normalizeCwd(cwd: string): string {
|
||||
if (cwd === "~") return homedir();
|
||||
if (cwd.startsWith("~/")) return resolve(homedir(), cwd.slice(2));
|
||||
return isAbsolute(cwd) ? cwd : resolve(cwd);
|
||||
}
|
||||
|
||||
// POST /api/cwd/validate body: { cwd: string }
|
||||
// Validates a candidate workspace before the UI selects it.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: unknown };
|
||||
const cwd = typeof body.cwd === "string" ? body.cwd.trim() : "";
|
||||
|
||||
if (!cwd) {
|
||||
return NextResponse.json({ error: "Path is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedCwd = normalizeCwd(cwd);
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = statSync(normalizedCwd);
|
||||
} catch {
|
||||
return NextResponse.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: `Path is not a directory: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
allowFileRoot(normalizedCwd);
|
||||
return NextResponse.json({ success: true, cwd: normalizedCwd });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdirSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { allowFileRoot } from "@/lib/file-access";
|
||||
|
||||
// POST /api/default-cwd
|
||||
// Creates ~/pi-cwd-<YYYYMMDD> if it doesn't exist and returns the path.
|
||||
export async function POST() {
|
||||
try {
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const dir = join(homedir(), `pi-cwd-${date}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
allowFileRoot(dir);
|
||||
return NextResponse.json({ cwd: dir });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getAllowedFileRoots,
|
||||
isExistingFilePathAllowed,
|
||||
isFilePathAllowed,
|
||||
isWindowsAbsolutePath,
|
||||
} from "@/lib/file-access";
|
||||
import { buildEntriesFromFiles, filterFileEntries, type FileIndexEntry } from "@/lib/file-fuzzy";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Same skip lists as /api/files — only used for the non-git readdir fallback.
|
||||
// Git-tracked repos rely on .gitignore instead (matches the TUI's fd behavior).
|
||||
const IGNORED_NAMES = new Set([
|
||||
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
|
||||
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
|
||||
"target", "vendor", ".DS_Store",
|
||||
]);
|
||||
|
||||
const IGNORED_SUFFIXES = [".pyc"];
|
||||
|
||||
/** Cap on the plain (no-query) response used as the client-side index */
|
||||
const MAX_FILES = 5000;
|
||||
/** Hard caps on the full in-memory listing that ?q= searches against */
|
||||
const GIT_HARD_CAP = 200_000;
|
||||
const WALK_HARD_CAP = 50_000;
|
||||
const MAX_WALK_DEPTH = 8;
|
||||
const MAX_QUERY_LENGTH = 500;
|
||||
const CACHE_TTL_MS = 10_000;
|
||||
const CACHE_MAX_ENTRIES = 20;
|
||||
|
||||
interface FileListing {
|
||||
/** Full listing up to the hard cap (not the client cap) */
|
||||
files: string[];
|
||||
/** True when even the hard cap was exceeded */
|
||||
hardTruncated: boolean;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
listing: FileListing;
|
||||
/** Derived lazily on the first ?q= search against this listing */
|
||||
entries?: FileIndexEntry[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// Per-cwd cache on globalThis so it survives Next.js hot-reload; the @ menu
|
||||
// re-requests on every open and searches on every keystroke, so listings must
|
||||
// not be recomputed within a short window.
|
||||
declare global {
|
||||
var __piFileIndexCache: Map<string, CacheEntry> | undefined;
|
||||
}
|
||||
|
||||
function getIndexCache(): Map<string, CacheEntry> {
|
||||
if (!globalThis.__piFileIndexCache) globalThis.__piFileIndexCache = new Map();
|
||||
return globalThis.__piFileIndexCache;
|
||||
}
|
||||
|
||||
async function listWithGit(cwd: string): Promise<FileListing | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"git",
|
||||
["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
{ timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: "C" } },
|
||||
);
|
||||
const all = stdout.split("\0").filter(Boolean);
|
||||
if (all.length > GIT_HARD_CAP) {
|
||||
return { files: all.slice(0, GIT_HARD_CAP), hardTruncated: true };
|
||||
}
|
||||
return { files: all, hardTruncated: false };
|
||||
} catch {
|
||||
// Not a git repo (or git unavailable) — caller falls back to readdir walk.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listWithWalk(cwd: string): FileListing {
|
||||
const files: string[] = [];
|
||||
// BFS so shallow files win when the cap truncates the listing.
|
||||
const queue: Array<{ abs: string; rel: string; depth: number }> = [{ abs: cwd, rel: "", depth: 0 }];
|
||||
while (queue.length > 0) {
|
||||
const { abs, rel, depth } = queue.shift()!;
|
||||
let dirents: fs.Dirent[];
|
||||
try {
|
||||
dirents = fs.readdirSync(abs, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const d of dirents) {
|
||||
if (IGNORED_NAMES.has(d.name) || IGNORED_SUFFIXES.some((s) => d.name.endsWith(s))) continue;
|
||||
const childRel = rel ? `${rel}/${d.name}` : d.name;
|
||||
if (d.isDirectory()) {
|
||||
if (depth + 1 <= MAX_WALK_DEPTH) {
|
||||
queue.push({ abs: path.join(abs, d.name), rel: childRel, depth: depth + 1 });
|
||||
}
|
||||
} else if (d.isFile()) {
|
||||
if (files.length >= WALK_HARD_CAP) {
|
||||
return { files, hardTruncated: true };
|
||||
}
|
||||
files.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { files, hardTruncated: false };
|
||||
}
|
||||
|
||||
// GET /api/file-index?cwd=/abs/path[&q=query]
|
||||
// Without q: { files: string[] (relative to cwd, capped at MAX_FILES),
|
||||
// truncated: boolean } — the client-side index for local filtering.
|
||||
// With q: { matches: { path, isDir }[] } — ranked against the FULL listing so
|
||||
// repos larger than MAX_FILES still find deep files (cap applied after
|
||||
// matching, like the TUI passing the query to fd).
|
||||
// Guarded by the same allow-list as /api/files.
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const cwd = req.nextUrl.searchParams.get("cwd")?.trim() ?? "";
|
||||
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
|
||||
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
const query = req.nextUrl.searchParams.get("q")?.slice(0, MAX_QUERY_LENGTH) ?? "";
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(cwd);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Directory not found" }, { status: 404 });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const cache = getIndexCache();
|
||||
const now = Date.now();
|
||||
let cached = cache.get(cwd);
|
||||
if (!cached || cached.expiresAt <= now) {
|
||||
const listing = (await listWithGit(cwd)) ?? listWithWalk(cwd);
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.expiresAt <= now) cache.delete(key);
|
||||
}
|
||||
if (cache.size >= CACHE_MAX_ENTRIES) cache.clear();
|
||||
cached = { listing, expiresAt: now + CACHE_TTL_MS };
|
||||
cache.set(cwd, cached);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
cached.entries ??= buildEntriesFromFiles(cached.listing.files);
|
||||
return NextResponse.json({ matches: filterFileEntries(cached.entries, query) });
|
||||
}
|
||||
|
||||
const { files, hardTruncated } = cached.listing;
|
||||
return NextResponse.json({
|
||||
files: files.slice(0, MAX_FILES),
|
||||
truncated: hardTruncated || files.length > MAX_FILES,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
getAllowedFileRoots,
|
||||
isExistingFilePathAllowed,
|
||||
isFilePathAllowed,
|
||||
isWindowsAbsolutePath,
|
||||
normalizeSlashes,
|
||||
} from "@/lib/file-access";
|
||||
import {
|
||||
DOCX_PREVIEW_MAX_BYTES,
|
||||
IMAGE_PREVIEW_MAX_BYTES,
|
||||
TEXT_PREVIEW_MAX_BYTES,
|
||||
documentPreviewKind,
|
||||
getAudioMime,
|
||||
getDocumentMime,
|
||||
getFileExt,
|
||||
getImageMime,
|
||||
} from "@/lib/file-types";
|
||||
import { resolveDirentIsDirectory } from "@/lib/file-dirent";
|
||||
import { isFilePathReferencedBySession } from "@/lib/session-file-references";
|
||||
import { isApiRequestAllowed } from "@/lib/request-security";
|
||||
import {
|
||||
inspectUploadTargets,
|
||||
parseUploadConflictStrategy,
|
||||
validateUploadFileNames,
|
||||
} from "@/lib/file-upload";
|
||||
import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data";
|
||||
|
||||
const IGNORED_NAMES = new Set([
|
||||
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
|
||||
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
|
||||
"target", "vendor", ".DS_Store", ".git",
|
||||
]);
|
||||
|
||||
const IGNORED_SUFFIXES = [".pyc"];
|
||||
|
||||
const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const;
|
||||
type FileRequestType = typeof FILE_REQUEST_TYPES[number];
|
||||
const FILE_REQUEST_TYPE_SET = new Set<string>(FILE_REQUEST_TYPES);
|
||||
const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024;
|
||||
// Multipart boundaries and headers are not file bytes, but must be bounded too.
|
||||
const MAX_UPLOAD_REQUEST_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
|
||||
|
||||
const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
|
||||
mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby",
|
||||
go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
|
||||
c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
|
||||
html: "html", htm: "html", css: "css", scss: "css", less: "css",
|
||||
json: "json", jsonl: "json", yaml: "yaml", yml: "yaml",
|
||||
toml: "toml", xml: "xml", md: "markdown", mdx: "markdown",
|
||||
sh: "bash", bash: "bash", zsh: "bash", fish: "bash",
|
||||
sql: "sql", graphql: "graphql", gql: "graphql",
|
||||
dockerfile: "dockerfile", tf: "hcl", hcl: "hcl",
|
||||
env: "bash", gitignore: "bash", txt: "text",
|
||||
pdf: "pdf", docx: "word",
|
||||
};
|
||||
|
||||
function getLanguage(filePath: string): string {
|
||||
const base = path.basename(filePath).toLowerCase();
|
||||
// Special full-name matches
|
||||
if (base === "dockerfile" || base.startsWith("dockerfile.")) return "dockerfile";
|
||||
if (base === ".env" || base.startsWith(".env.")) return "bash";
|
||||
if (base === "makefile" || base === "gnumakefile") return "makefile";
|
||||
const ext = base.split(".").pop() ?? "";
|
||||
return EXT_TO_LANGUAGE[ext] ?? "text";
|
||||
}
|
||||
|
||||
function filePathFromSegments(segments: string[]): string {
|
||||
const joined = segments.join("/");
|
||||
const slashJoined = normalizeSlashes(joined);
|
||||
if (isWindowsAbsolutePath(slashJoined)) return slashJoined;
|
||||
return "/" + joined.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
function parseFileRequestType(value: string): FileRequestType | null {
|
||||
return FILE_REQUEST_TYPE_SET.has(value) ? (value as FileRequestType) : null;
|
||||
}
|
||||
|
||||
async function getUploadDirectory(segments: string[]): Promise<
|
||||
{ directory: string } | { response: NextResponse }
|
||||
> {
|
||||
const directory = filePathFromSegments(segments);
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(directory, allowedRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(directory);
|
||||
} catch {
|
||||
return { response: NextResponse.json({ error: "Upload directory not found" }, { status: 404 }) };
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return { response: NextResponse.json({ error: "Upload target is not a directory" }, { status: 400 }) };
|
||||
}
|
||||
|
||||
// A browsable directory can be a symlink. Resolve both sides before writes
|
||||
// so a symlink inside an allowed root cannot redirect uploads outside it.
|
||||
const realDirectory = fs.realpathSync(directory);
|
||||
const realRoots = new Set<string>();
|
||||
for (const root of allowedRoots) {
|
||||
try {
|
||||
realRoots.add(fs.realpathSync(root));
|
||||
} catch {
|
||||
// Ignore stale session roots that no longer exist.
|
||||
}
|
||||
}
|
||||
if (!isFilePathAllowed(realDirectory, realRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
|
||||
return { directory: realDirectory };
|
||||
}
|
||||
|
||||
function parseUploadFileNames(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
if (!isApiRequestAllowed(request)) {
|
||||
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { path: segments } = await params;
|
||||
const uploadDirectory = await getUploadDirectory(segments);
|
||||
if ("response" in uploadDirectory) return uploadDirectory.response;
|
||||
const { directory } = uploadDirectory;
|
||||
const type = request.nextUrl.searchParams.get("type") ?? "upload";
|
||||
|
||||
if (type === "upload-check") {
|
||||
const body = await request.json().catch(() => null) as { fileNames?: unknown } | null;
|
||||
const fileNames = parseUploadFileNames(body?.fileNames);
|
||||
if (!fileNames) {
|
||||
return NextResponse.json({ error: "fileNames must be an array of strings" }, { status: 400 });
|
||||
}
|
||||
const validationError = validateUploadFileNames(fileNames);
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(inspectUploadTargets(directory, fileNames));
|
||||
}
|
||||
|
||||
if (type !== "upload") {
|
||||
return NextResponse.json({ error: "Invalid upload request type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const strategy = parseUploadConflictStrategy(request.nextUrl.searchParams.get("conflict"));
|
||||
if (!strategy) {
|
||||
return NextResponse.json({ error: "Invalid conflict strategy" }, { status: 400 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await parseFormDataWithinLimit(request, MAX_UPLOAD_REQUEST_BYTES);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestBodyTooLargeError) {
|
||||
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const files = formData.getAll("files").filter((entry): entry is File => typeof entry !== "string");
|
||||
if (files.some((file) => file.size > MAX_UPLOAD_FILE_BYTES)) {
|
||||
return NextResponse.json({ error: "Each upload must be 25MB or smaller" }, { status: 413 });
|
||||
}
|
||||
if (files.reduce((total, file) => total + file.size, 0) > MAX_UPLOAD_TOTAL_BYTES) {
|
||||
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
|
||||
}
|
||||
const fileNames = files.map((file) => file.name);
|
||||
const validationError = validateUploadFileNames(fileNames);
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 });
|
||||
}
|
||||
|
||||
const inspection = inspectUploadTargets(directory, fileNames);
|
||||
if (strategy === "error" && inspection.conflicts.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: "One or more files already exist",
|
||||
conflicts: inspection.conflicts,
|
||||
nonReplaceable: inspection.nonReplaceable,
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
const conflictSet = new Set(inspection.conflicts);
|
||||
const nonReplaceableSet = new Set(inspection.nonReplaceable);
|
||||
const uploaded: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const destination = path.join(directory, file.name);
|
||||
if (conflictSet.has(file.name) && strategy === "skip") {
|
||||
skipped.push(file.name);
|
||||
continue;
|
||||
}
|
||||
if (conflictSet.has(file.name) && nonReplaceableSet.has(file.name)) {
|
||||
errors.push({ name: file.name, error: "Cannot replace a directory or symbolic link" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = Buffer.from(await file.arrayBuffer());
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (conflictSet.has(file.name)) {
|
||||
try {
|
||||
fs.unlinkSync(destination);
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(destination, bytes, { flag: "wx" });
|
||||
uploaded.push(file.name);
|
||||
} catch (error) {
|
||||
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ uploaded, skipped, errors },
|
||||
{ status: errors.length > 0 ? 207 : 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function createFileBodyStream(filePath: string, range?: { start: number; end: number }): ReadableStream<Uint8Array> {
|
||||
const fileStream = fs.createReadStream(filePath, range);
|
||||
let closed = false;
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
fileStream.on("data", (chunk: Buffer) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
} catch {
|
||||
closed = true;
|
||||
fileStream.destroy();
|
||||
}
|
||||
});
|
||||
fileStream.once("end", () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// The browser may cancel media probes before the file stream ends.
|
||||
}
|
||||
});
|
||||
fileStream.once("error", (error) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {
|
||||
controller.error(error);
|
||||
} catch {
|
||||
// The response was already abandoned by the client.
|
||||
}
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
fileStream.destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
|
||||
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDisposition(filePath: string, asDownload = false): string {
|
||||
const disposition = asDownload ? "attachment" : "inline";
|
||||
const fileName = path.basename(filePath);
|
||||
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download";
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
|
||||
}
|
||||
|
||||
function streamFile(filePath: string, stat: fs.Stats, contentType: string, rangeHeader: string | null, asDownload = false): Response {
|
||||
const headers = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-cache",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Disposition": getContentDisposition(filePath, asDownload),
|
||||
};
|
||||
|
||||
if (!rangeHeader) {
|
||||
return new Response(createFileBodyStream(filePath), {
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Length": String(stat.size),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
||||
if (!match) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Range": `bytes */${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let start = match[1] ? Number(match[1]) : 0;
|
||||
let end = match[2] ? Number(match[2]) : stat.size - 1;
|
||||
if (!match[1] && match[2]) {
|
||||
const suffixLength = Number(match[2]);
|
||||
start = Math.max(stat.size - suffixLength, 0);
|
||||
end = stat.size - 1;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= stat.size) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Range": `bytes */${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
end = Math.min(end, stat.size - 1);
|
||||
const chunkSize = end - start + 1;
|
||||
return new Response(createFileBodyStream(filePath, { start, end }), {
|
||||
status: 206,
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Length": String(chunkSize),
|
||||
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function wrapDocxPreviewHtml(bodyHtml: string, fileName: string): string {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
html, body { margin: 0; min-height: 100%; background: #eef1f5; color: #171717; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 28px; }
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
max-width: 840px;
|
||||
min-height: calc(100vh - 56px);
|
||||
margin: 0 auto;
|
||||
padding: 56px 64px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
.file-title {
|
||||
margin: 0 0 28px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #6b7280;
|
||||
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
word-break: break-word;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.1em 0 0.45em; color: #111827; }
|
||||
p { margin: 0.65em 0; line-height: 1.7; }
|
||||
table { border-collapse: collapse; max-width: 100%; margin: 1em 0; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 6px 9px; vertical-align: top; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
a { color: #2563eb; }
|
||||
@media (max-width: 720px) {
|
||||
body { padding: 0; background: #fff; }
|
||||
main { min-height: 100vh; padding: 28px 22px; box-shadow: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="file-title">${escapeHtml(fileName)}</div>
|
||||
${bodyHtml}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: segments } = await params;
|
||||
const filePath = filePathFromSegments(segments);
|
||||
const rawType = request.nextUrl.searchParams.get("type") ?? "list";
|
||||
const type = parseFileRequestType(rawType);
|
||||
if (!type) {
|
||||
return NextResponse.json({ error: "Invalid file request type" }, { status: 400 });
|
||||
}
|
||||
const sessionId = request.nextUrl.searchParams.get("sessionId");
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
|
||||
const allowedBySessionReference =
|
||||
!allowedByRoot &&
|
||||
type !== "list" &&
|
||||
await isFilePathReferencedBySession(filePath, sessionId);
|
||||
if (!allowedByRoot && !allowedBySessionReference) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(filePath);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (type === "read") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const imageMime = getImageMime(filePath);
|
||||
if (imageMime) {
|
||||
if (stat.size > IMAGE_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "Image too large (>10MB)" }, { status: 413 });
|
||||
}
|
||||
return streamFile(filePath, stat, imageMime, request.headers.get("range"));
|
||||
}
|
||||
const audioMime = getAudioMime(filePath);
|
||||
if (audioMime) {
|
||||
return streamFile(filePath, stat, audioMime, request.headers.get("range"));
|
||||
}
|
||||
const documentMime = getDocumentMime(filePath);
|
||||
if (documentMime) {
|
||||
return streamFile(filePath, stat, documentMime, request.headers.get("range"));
|
||||
}
|
||||
if (stat.size > TEXT_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "File too large for preview (>256KB)" }, { status: 413 });
|
||||
}
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const language = getLanguage(filePath);
|
||||
return NextResponse.json({ content, language, size: stat.size });
|
||||
}
|
||||
|
||||
if (type === "download") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const mime = getImageMime(filePath) || getAudioMime(filePath) || getDocumentMime(filePath) || "application/octet-stream";
|
||||
return streamFile(filePath, stat, mime, request.headers.get("range"), true);
|
||||
}
|
||||
|
||||
if (type === "meta") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
const imageMime = getImageMime(filePath);
|
||||
const audioMime = getAudioMime(filePath);
|
||||
const documentMime = getDocumentMime(filePath);
|
||||
return NextResponse.json({
|
||||
size: stat.size,
|
||||
language: getLanguage(filePath),
|
||||
mime: imageMime || audioMime || documentMime || "text/plain",
|
||||
previewKind: documentPreviewKind(filePath),
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "preview") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
if (getFileExt(filePath) !== "docx") {
|
||||
return NextResponse.json({ error: "Preview not available for this file type" }, { status: 400 });
|
||||
}
|
||||
if (stat.size > DOCX_PREVIEW_MAX_BYTES) {
|
||||
return NextResponse.json({ error: "DOCX too large for preview (>10MB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
const mammoth = await import("mammoth");
|
||||
const result = await mammoth.convertToHtml(
|
||||
{ path: filePath },
|
||||
{
|
||||
externalFileAccess: false,
|
||||
convertImage: mammoth.images.dataUri,
|
||||
}
|
||||
);
|
||||
const html = wrapDocxPreviewHtml(result.value, path.basename(filePath));
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "watch") {
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
||||
}
|
||||
let watcher: fs.FSWatcher | null = null;
|
||||
let lastMtimeMs = stat.mtimeMs;
|
||||
let lastSize = stat.size;
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const send = (eventName: string, data: Record<string, unknown>) => {
|
||||
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(payload));
|
||||
} catch {
|
||||
// client disconnected
|
||||
}
|
||||
};
|
||||
// Send initial ping so client knows connection is live
|
||||
send("connected", { filePath });
|
||||
try {
|
||||
watcher = fs.watch(filePath, () => {
|
||||
try {
|
||||
const s = fs.statSync(filePath);
|
||||
// Some platforms emit watch events for file reads/attribute
|
||||
// access. Ignore those or the client's refresh read loops.
|
||||
if (s.mtimeMs === lastMtimeMs && s.size === lastSize) return;
|
||||
lastMtimeMs = s.mtimeMs;
|
||||
lastSize = s.size;
|
||||
send("change", { mtime: s.mtime.toISOString(), size: s.size });
|
||||
} catch {
|
||||
send("change", { mtime: new Date().toISOString(), size: 0 });
|
||||
}
|
||||
});
|
||||
watcher.on("error", () => {
|
||||
try { controller.close(); } catch { /* ignore */ }
|
||||
});
|
||||
} catch {
|
||||
send("error", { message: "Failed to watch file" });
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
try { watcher?.close(); } catch { /* ignore */ }
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// type === "list"
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Avoid per-entry stat calls for normal files and directories. Symlinks and
|
||||
// filesystems without directory type information use the stat fallback.
|
||||
const dirents = fs.readdirSync(filePath, { withFileTypes: true });
|
||||
const entries = dirents
|
||||
.filter((d) => !IGNORED_NAMES.has(d.name) && !IGNORED_SUFFIXES.some((s) => d.name.endsWith(s)))
|
||||
.flatMap((d) => {
|
||||
const isDir = resolveDirentIsDirectory(d, path.join(filePath, d.name));
|
||||
return isDir === null
|
||||
? []
|
||||
: [{ name: d.name, isDir, size: 0, modified: "" }];
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Dirs first, then files, both alphabetically
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return NextResponse.json({ entries, path: filePath });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access";
|
||||
import { getGitFileDiff } from "@/lib/git-changes";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? "";
|
||||
const filePath = request.nextUrl.searchParams.get("path")?.trim() ?? "";
|
||||
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
|
||||
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
if (!filePath || (!filePath.startsWith("/") && !isWindowsAbsolutePath(filePath))) {
|
||||
return NextResponse.json({ error: "path must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots) || !isFilePathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
// The cwd must resolve inside an allowed root. The file itself may no
|
||||
// longer exist when Git reports it as deleted; getGitFileDiff verifies
|
||||
// that the requested path belongs to this repository and its status.
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(await getGitFileDiff(cwd, filePath));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import fs from "fs";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed, isWindowsAbsolutePath } from "@/lib/file-access";
|
||||
import { getGitStatus } from "@/lib/git-changes";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const cwd = request.nextUrl.searchParams.get("cwd")?.trim() ?? "";
|
||||
if (!cwd || (!cwd.startsWith("/") && !isWindowsAbsolutePath(cwd))) {
|
||||
return NextResponse.json({ error: "cwd must be an absolute path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(cwd);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Directory not found" }, { status: 404 });
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
||||
}
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(await getGitStatus(cwd));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { homedir } from "os";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ home: homedir() });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
flattenModelsDevCatalog,
|
||||
recommendModelCatalogPreset,
|
||||
searchModelCatalog,
|
||||
type ModelCatalogEntry,
|
||||
} from "@/lib/model-catalog";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MODELS_DEV_URL = "https://models.dev/api.json";
|
||||
const CATALOG_TTL_MS = 60 * 60 * 1000;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
interface CatalogCache {
|
||||
entries: ModelCatalogEntry[];
|
||||
expiresAt: number;
|
||||
inFlight?: Promise<ModelCatalogEntry[]>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __piModelsDevCatalogCache: CatalogCache | undefined;
|
||||
}
|
||||
|
||||
function getCache(): CatalogCache {
|
||||
return globalThis.__piModelsDevCatalogCache ??= { entries: [], expiresAt: 0 };
|
||||
}
|
||||
|
||||
async function fetchCatalog(): Promise<ModelCatalogEntry[]> {
|
||||
const response = await fetch(MODELS_DEV_URL, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) throw new Error(`models.dev returned HTTP ${response.status}`);
|
||||
const entries = flattenModelsDevCatalog(await response.json());
|
||||
if (entries.length === 0) throw new Error("models.dev returned an empty catalog");
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<ModelCatalogEntry[]> {
|
||||
const cache = getCache();
|
||||
if (cache.entries.length > 0 && cache.expiresAt > Date.now()) return cache.entries;
|
||||
if (!cache.inFlight) {
|
||||
cache.inFlight = fetchCatalog().then((entries) => {
|
||||
cache.entries = entries;
|
||||
cache.expiresAt = Date.now() + CATALOG_TTL_MS;
|
||||
return entries;
|
||||
}).finally(() => {
|
||||
cache.inFlight = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await cache.inFlight;
|
||||
} catch (error) {
|
||||
if (cache.entries.length > 0) return cache.entries;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const query = (searchParams.get("q") ?? "").slice(0, 120);
|
||||
const provider = (searchParams.get("provider") ?? "").slice(0, 120);
|
||||
const baseUrl = (searchParams.get("baseUrl") ?? "").slice(0, 500);
|
||||
const parsedLimit = Number.parseInt(searchParams.get("limit") ?? "50", 10);
|
||||
const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;
|
||||
|
||||
try {
|
||||
const entries = await loadCatalog();
|
||||
const models = searchModelCatalog(entries, query, provider, limit);
|
||||
const recommendation = recommendModelCatalogPreset(entries, query, provider, baseUrl);
|
||||
return NextResponse.json({ models, recommendation, source: MODELS_DEV_URL });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveModelDiscoveryAuth } from "@/lib/model-discovery-auth";
|
||||
import { buildModelsListUrl, parseDiscoveredModels } from "@/lib/model-discovery";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DISCOVERY_TIMEOUT_MS = 20_000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasHeader(headers: Headers, name: string): boolean {
|
||||
return headers.has(name);
|
||||
}
|
||||
|
||||
function buildHeaders(api: string, apiKey: string | undefined, configured: Record<string, string>): Headers {
|
||||
const headers = new Headers(configured);
|
||||
if (!hasHeader(headers, "accept")) headers.set("Accept", "application/json");
|
||||
if (!apiKey) return headers;
|
||||
|
||||
if (api === "anthropic-messages") {
|
||||
if (!hasHeader(headers, "x-api-key")) headers.set("x-api-key", apiKey);
|
||||
if (!hasHeader(headers, "anthropic-version")) headers.set("anthropic-version", "2023-06-01");
|
||||
} else if (api === "google-generative-ai") {
|
||||
if (!hasHeader(headers, "x-goog-api-key")) headers.set("x-goog-api-key", apiKey);
|
||||
} else if (!hasHeader(headers, "authorization")) {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { providerName?: unknown; provider?: unknown };
|
||||
const providerName = typeof body.providerName === "string" ? body.providerName.trim() : "";
|
||||
if (!providerName) return NextResponse.json({ error: "providerName is required" }, { status: 400 });
|
||||
if (!isRecord(body.provider)) return NextResponse.json({ error: "provider is required" }, { status: 400 });
|
||||
|
||||
const baseUrl = typeof body.provider.baseUrl === "string" ? body.provider.baseUrl.trim() : "";
|
||||
if (!baseUrl) return NextResponse.json({ error: "Base URL is required" }, { status: 400 });
|
||||
const api = typeof body.provider.api === "string" && body.provider.api
|
||||
? body.provider.api
|
||||
: "openai-completions";
|
||||
|
||||
let endpoint: URL;
|
||||
try {
|
||||
endpoint = buildModelsListUrl(baseUrl, api);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Base URL is invalid" }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = await resolveModelDiscoveryAuth(providerName, body.provider);
|
||||
if (typeof body.provider.apiKey === "string" && body.provider.apiKey.trim() && !auth.apiKey) {
|
||||
return NextResponse.json({ error: `No API key found for "${providerName}"` }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
cache: "no-store",
|
||||
headers: buildHeaders(api, auth.apiKey, auth.headers),
|
||||
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({
|
||||
error: responseText.slice(0, 500) || `Upstream returned HTTP ${response.status}`,
|
||||
status: response.status,
|
||||
}, { status: 502 });
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(responseText);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Upstream model list was not valid JSON" }, { status: 502 });
|
||||
}
|
||||
const models = parseDiscoveredModels(payload);
|
||||
if (models.length === 0) {
|
||||
return NextResponse.json({ error: "No models found in the upstream response" }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ models, endpoint: endpoint.toString() });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const status = error instanceof DOMException && error.name === "TimeoutError" ? 504 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function getModelsPath(): string {
|
||||
return join(getAgentDir(), "models.json");
|
||||
}
|
||||
|
||||
function readModelsJson(): Record<string, unknown> {
|
||||
const path = getModelsPath();
|
||||
if (!existsSync(path)) return { providers: {} };
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { providers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeModelsJson(data: Record<string, unknown>): void {
|
||||
const path = getModelsPath();
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(readModelsJson());
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as Record<string, unknown>;
|
||||
writeModelsJson(body);
|
||||
invalidateModelsCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { completeSimple, type AssistantMessage } from "@earendil-works/pi-ai/compat";
|
||||
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
||||
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function getAssistantText(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!isApiRequestAllowed(req)) {
|
||||
return NextResponse.json({ ok: false, error: "Untrusted API request" }, { status: 403 });
|
||||
}
|
||||
if (!hasJsonContentType(req)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Content-Type must be application/json" },
|
||||
{ status: 415 },
|
||||
);
|
||||
}
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
try {
|
||||
const body = await req.json() as { providerName?: unknown; provider?: unknown; model?: unknown };
|
||||
const providerName = typeof body.providerName === "string" ? body.providerName.trim() : "";
|
||||
if (!providerName) return NextResponse.json({ ok: false, error: "providerName is required" }, { status: 400 });
|
||||
if (!isRecord(body.provider)) return NextResponse.json({ ok: false, error: "provider is required" }, { status: 400 });
|
||||
if (!isRecord(body.model)) return NextResponse.json({ ok: false, error: "model is required" }, { status: 400 });
|
||||
|
||||
const modelId = typeof body.model.id === "string" ? body.model.id.trim() : "";
|
||||
if (!modelId) return NextResponse.json({ ok: false, error: "Model ID is required" }, { status: 400 });
|
||||
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-web-model-test-"));
|
||||
const modelsPath = join(tempDir, "models.json");
|
||||
writeFileSync(modelsPath, JSON.stringify({
|
||||
providers: {
|
||||
[providerName]: {
|
||||
...body.provider,
|
||||
models: [{ ...body.model, id: modelId }],
|
||||
},
|
||||
},
|
||||
}, null, 2), "utf8");
|
||||
|
||||
const modelRuntime = await ModelRuntime.create({ modelsPath });
|
||||
const loadError = modelRuntime.getError();
|
||||
if (loadError) return NextResponse.json({ ok: false, error: loadError });
|
||||
|
||||
const model = modelRuntime.getModel(providerName, modelId);
|
||||
if (!model) return NextResponse.json({ ok: false, error: `Model not found: ${providerName}/${modelId}` });
|
||||
|
||||
const resolved = await modelRuntime.getAuth(model);
|
||||
if (!resolved?.auth.apiKey) {
|
||||
return NextResponse.json({ ok: false, error: `No API key found for "${providerName}"` });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TEST_TIMEOUT_MS);
|
||||
let status: number | undefined;
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const message = await completeSimple(model, {
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: "Reply with OK only.",
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
}, {
|
||||
apiKey: resolved.auth.apiKey,
|
||||
headers: resolved.auth.headers,
|
||||
maxTokens: 16,
|
||||
timeoutMs: TEST_TIMEOUT_MS,
|
||||
maxRetries: 0,
|
||||
cacheRetention: "none",
|
||||
signal: controller.signal,
|
||||
onResponse: (response) => { status = response.status; },
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: message.errorMessage ?? (controller.signal.aborted ? "Test timed out" : "Model returned an error"),
|
||||
latencyMs,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
latencyMs,
|
||||
status,
|
||||
responseText: getAssistantText(message).slice(0, 300),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ ok: false, error: errorMessage(error) }, { status: 500 });
|
||||
} finally {
|
||||
if (tempDir) rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { stat } from "fs/promises";
|
||||
import { resolve } from "path";
|
||||
import { createAgentSessionServices, getAgentDir, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||
import { loadModelsWithCache, withModelRuntimeError, type ModelsData } from "@/lib/models-cache";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { projectTrustReloadOptions } from "@/lib/project-trust";
|
||||
import { createAppSettingsManager, getAppResourceLoaderOptions } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function compareModelEntries(
|
||||
a: { id: string; name: string; provider: string },
|
||||
b: { id: string; name: string; provider: string }
|
||||
): number {
|
||||
return modelNameCollator.compare(a.name || a.id, b.name || b.id)
|
||||
|| modelNameCollator.compare(a.provider, b.provider)
|
||||
|| modelNameCollator.compare(a.id, b.id);
|
||||
}
|
||||
|
||||
const THINKING_SUFFIXES = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
|
||||
function stripThinkingSuffix(modelRef: string): string {
|
||||
const trimmed = modelRef.trim();
|
||||
const colonIndex = trimmed.lastIndexOf(":");
|
||||
if (colonIndex === -1) return trimmed;
|
||||
const suffix = trimmed.substring(colonIndex + 1);
|
||||
return THINKING_SUFFIXES.has(suffix) ? trimmed.substring(0, colonIndex) : trimmed;
|
||||
}
|
||||
|
||||
function filterByExactEnabledModels<T extends { id: string; provider: string }>(
|
||||
available: readonly T[],
|
||||
enabledModels: string[] | undefined,
|
||||
): readonly T[] {
|
||||
if (!enabledModels || enabledModels.length === 0) return available;
|
||||
|
||||
const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean));
|
||||
const visible = available.filter((m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id));
|
||||
return visible.length > 0 ? visible : available;
|
||||
}
|
||||
|
||||
async function loadModels(cwd: string): Promise<ModelsData> {
|
||||
const nameMap = new Map<string, string>();
|
||||
let modelList: { id: string; name: string; provider: string }[] = [];
|
||||
let defaultModel: { provider: string; modelId: string } | null = null;
|
||||
const thinkingLevels: Record<string, string[]> = {};
|
||||
const thinkingLevelMaps: Record<string, Record<string, string | null>> = {};
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
// Gate untrusted project extensions: enumerating models still imports and
|
||||
// runs a repository's .pi/extensions factories, so honor project trust here
|
||||
// too (see lib/project-trust.ts, #236).
|
||||
const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir);
|
||||
const services = await createAgentSessionServices({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager: createAppSettingsManager(cwd, agentDir),
|
||||
resourceLoaderOptions: getAppResourceLoaderOptions(),
|
||||
...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}),
|
||||
});
|
||||
const available = await services.modelRuntime.getAvailable();
|
||||
const modelError = services.modelRuntime.getError();
|
||||
const settings: SettingsManager = services.settingsManager;
|
||||
const enabledModels = settings.getEnabledModels();
|
||||
const visible = filterByExactEnabledModels(available, enabledModels);
|
||||
modelList = visible.map((m: { id: string; name: string; provider: string }) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
provider: m.provider,
|
||||
})).sort(compareModelEntries);
|
||||
for (const m of visible) {
|
||||
const key = `${m.provider}:${m.id}`;
|
||||
nameMap.set(key, m.name);
|
||||
thinkingLevels[key] = getSupportedThinkingLevels(m);
|
||||
if (m.thinkingLevelMap) thinkingLevelMaps[key] = m.thinkingLevelMap;
|
||||
}
|
||||
|
||||
const provider = settings.getDefaultProvider();
|
||||
const modelId = settings.getDefaultModel();
|
||||
if (provider && modelId && visible.some((m) => m.provider === provider && m.id === modelId)) {
|
||||
defaultModel = { provider, modelId };
|
||||
}
|
||||
|
||||
return withModelRuntimeError(
|
||||
{ models: Object.fromEntries(nameMap), modelList, defaultModel, thinkingLevels, thinkingLevelMaps },
|
||||
modelError,
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_MODELS: ModelsData = {
|
||||
models: {},
|
||||
modelList: [],
|
||||
defaultModel: null,
|
||||
thinkingLevels: {},
|
||||
thinkingLevelMaps: {},
|
||||
};
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const requestedCwd = new URL(req.url).searchParams.get("cwd") || process.cwd();
|
||||
const cwd = resolve(requestedCwd);
|
||||
|
||||
let cwdStat;
|
||||
try {
|
||||
cwdStat = await stat(cwd);
|
||||
} catch {
|
||||
return Response.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
if (!cwdStat.isDirectory()) {
|
||||
return Response.json({ error: `Not a directory: ${cwd}` }, { status: 400 });
|
||||
}
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
return Response.json(await loadModelsWithCache(cwd, () => loadModels(cwd)));
|
||||
} catch {
|
||||
return Response.json(EMPTY_MODELS);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { stat } from "fs/promises";
|
||||
import { resolve } from "path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { invalidateModelsCache } from "@/lib/models-cache";
|
||||
import { getProjectTrustStatus, trustProject } from "@/lib/project-trust";
|
||||
import { destroyRpcSessionsForCwd, hasBusyRpcSessionForCwd } from "@/lib/rpc-manager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function validateCwd(value: unknown): Promise<
|
||||
{ cwd: string } | { response: NextResponse }
|
||||
> {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return { response: NextResponse.json({ error: "cwd required" }, { status: 400 }) };
|
||||
}
|
||||
|
||||
const cwd = resolve(value);
|
||||
try {
|
||||
if (!(await stat(cwd)).isDirectory()) {
|
||||
return { response: NextResponse.json({ error: "cwd must be a directory" }, { status: 400 }) };
|
||||
}
|
||||
} catch {
|
||||
return { response: NextResponse.json({ error: "Directory does not exist" }, { status: 400 }) };
|
||||
}
|
||||
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
||||
}
|
||||
return { cwd };
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const result = await validateCwd(new URL(req.url).searchParams.get("cwd"));
|
||||
if ("response" in result) return result.response;
|
||||
return NextResponse.json(getProjectTrustStatus(result.cwd, getAgentDir()));
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: unknown };
|
||||
const result = await validateCwd(body.cwd);
|
||||
if ("response" in result) return result.response;
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const current = getProjectTrustStatus(result.cwd, agentDir);
|
||||
if (!current.requiresTrust) {
|
||||
return NextResponse.json({ error: "This project has no resources that require trust" }, { status: 409 });
|
||||
}
|
||||
if (hasBusyRpcSessionForCwd(result.cwd)) {
|
||||
return NextResponse.json({ error: "Wait for the active session to finish before trusting this project" }, { status: 409 });
|
||||
}
|
||||
|
||||
const status = trustProject(result.cwd, agentDir);
|
||||
invalidateModelsCache();
|
||||
destroyRpcSessionsForCwd(result.cwd);
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
getManagedRuntimePaths,
|
||||
isManagedRuntime,
|
||||
} from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
if (!isManagedRuntime()) {
|
||||
return Response.json({ managed: false, agentDir: getAgentDir() });
|
||||
}
|
||||
|
||||
const paths = getManagedRuntimePaths();
|
||||
return Response.json({
|
||||
managed: true,
|
||||
appRoot: paths.appRoot,
|
||||
dataDir: paths.dataDir,
|
||||
agentDir: paths.agentDir,
|
||||
resourcesDir: paths.resourcesDir,
|
||||
skillRoots: paths.managedSkillRoots,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
|
||||
import { generateSessionTitle } from "@/lib/session-title";
|
||||
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
|
||||
import { invalidateSessionListCache, resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const cwd = SessionManager.open(filePath).getHeader()?.cwd ?? process.cwd();
|
||||
const existing = getRpcSession(id);
|
||||
const { session } = existing?.isAlive()
|
||||
? { session: existing }
|
||||
: await startRpcSession(id, filePath, cwd);
|
||||
|
||||
// globalThis keeps wrappers alive across dev hot reloads; older instances
|
||||
// may predate waitUntilReady(), but those have already completed startup.
|
||||
await session.waitUntilReady?.();
|
||||
const result = await generateSessionTitle(session.inner as unknown as AgentSession);
|
||||
|
||||
if (!session.isAlive()) {
|
||||
return NextResponse.json(
|
||||
{ error: "The session was closed while its title was being generated. Please try again." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
session.inner.setSessionName(result.title);
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ title: result.title, usage: result.usage ?? null });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import { resolveSessionPath, buildSessionContext } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const url = new URL(req.url);
|
||||
const leafId = url.searchParams.get("leafId") ?? undefined;
|
||||
const deferThinking = url.searchParams.has("deferThinking");
|
||||
const deferToolResultImages = url.searchParams.has("deferMedia");
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const sm = SessionManager.open(filePath);
|
||||
const context = buildSessionContext(sm.getEntries() as never, leafId, {
|
||||
deferThinking,
|
||||
deferToolResultImages,
|
||||
});
|
||||
|
||||
return NextResponse.json({ context });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionEntries, resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string; entryId: string }> },
|
||||
) {
|
||||
const { id, entryId } = await params;
|
||||
const blockIndexParam = new URL(req.url).searchParams.get("blockIndex");
|
||||
const blockIndex = blockIndexParam === null ? Number.NaN : Number(blockIndexParam);
|
||||
if (!Number.isSafeInteger(blockIndex) || blockIndex < 0) {
|
||||
return NextResponse.json({ error: "Valid blockIndex is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
|
||||
// SessionManager-backed parsing preserves the SDK's malformed-line tolerance.
|
||||
const entry = getSessionEntries(filePath).find((candidate) => candidate.id === entryId);
|
||||
if (!entry || entry.type !== "message" || entry.message.role !== "assistant") {
|
||||
return NextResponse.json({ error: "Assistant message not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const block = entry.message.content[blockIndex];
|
||||
if (!block || block.type !== "thinking") {
|
||||
return NextResponse.json({ error: "Thinking block not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ thinking: block.thinking });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { execFile } from "child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { promisify } from "util";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
import { NextResponse } from "next/server";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type PiCodingAgentModule = {
|
||||
getPackageDir: () => string;
|
||||
};
|
||||
|
||||
type ExportHtmlModule = {
|
||||
exportFromFile: (inputPath: string, outputPath: string) => Promise<string>;
|
||||
};
|
||||
|
||||
async function getPiPackageDir(): Promise<string | null> {
|
||||
try {
|
||||
const { getPackageDir } = (await import("@earendil-works/pi-coding-agent")) as PiCodingAgentModule;
|
||||
return getPackageDir();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
|
||||
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
function getContentDisposition(fileName: string, inline: boolean): string {
|
||||
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "session.html";
|
||||
const disposition = inline ? "inline" : "attachment";
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
|
||||
}
|
||||
|
||||
async function getPiCliPath(): Promise<string | null> {
|
||||
const candidates = new Set<string>();
|
||||
const packageDir = await getPiPackageDir();
|
||||
|
||||
if (packageDir) {
|
||||
candidates.add(join(packageDir, "dist", "cli.js"));
|
||||
}
|
||||
|
||||
try {
|
||||
const resolver = (import.meta as ImportMeta & {
|
||||
resolve?: (specifier: string) => string | Promise<string>;
|
||||
}).resolve;
|
||||
if (typeof resolver === "function") {
|
||||
const indexUrl = await resolver("@earendil-works/pi-coding-agent");
|
||||
candidates.add(join(dirname(fileURLToPath(indexUrl)), "cli.js"));
|
||||
}
|
||||
} catch {
|
||||
// Next.js production bundles can strip import.meta.resolve.
|
||||
}
|
||||
|
||||
candidates.add(
|
||||
join(
|
||||
process.cwd(),
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
"dist",
|
||||
"cli.js"
|
||||
)
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the exported HTML to fix recursive functions that overflow
|
||||
* the call stack on deep linear session trees (e.g., 5000+ entries).
|
||||
*
|
||||
* ## Root Cause
|
||||
* pi-coding-agent's template.js uses recursive helpers to render and
|
||||
* navigate the session tree in the exported HTML:
|
||||
*
|
||||
* 1. sortChildren(node) — recursively sorts children of every node.
|
||||
* Calls itself via node.children.forEach(sortChildren).
|
||||
* On a 5527-entry linear chain (no branches), this recurses 5527
|
||||
* levels deep → stack overflow.
|
||||
*
|
||||
* 2. mapNodes(node) — recursively indexes tree nodes the first time
|
||||
* a tree item is clicked. Same depth -> same overflow.
|
||||
*
|
||||
* 3. markActive(node) — recursively marks nodes on the active path.
|
||||
* Calls itself via markActive(child) for each child.
|
||||
* Same depth → same overflow.
|
||||
*
|
||||
* Both functions are inlined in the HTML by pi-coding-agent at export
|
||||
* time. We cannot modify template.js directly (it's in node_modules
|
||||
* and would be overwritten on npm install). Instead, we patch the
|
||||
* generated HTML string before returning it to the client.
|
||||
*
|
||||
* ## Fix
|
||||
* Replace each recursive function with an iterative equivalent:
|
||||
*
|
||||
* sortChildren → explicit stack (DFS pre-order, push children in
|
||||
* reverse to maintain order)
|
||||
* mapNodes → explicit stack (DFS pre-order)
|
||||
* markActive → two-stack post-order (stack1 for traversal,
|
||||
* stack2 for processing children before parent)
|
||||
*
|
||||
* ## Line Ending Normalization
|
||||
* This file (route.ts) uses CRLF (Windows), while template.js uses LF
|
||||
* (Unix). The template strings in the backtick literals inherit the
|
||||
* file's CRLF line endings. At runtime, readFileSync() also returns
|
||||
* CRLF on Windows. We normalize everything to LF before matching.
|
||||
*
|
||||
* The helper `n(s)` strips \r\n → \n on both the HTML and the
|
||||
* replacement strings, ensuring cross-platform matching.
|
||||
*/
|
||||
function patchExportHtml(html: string): string {
|
||||
// Normalize line endings: route.ts is CRLF, template.js is LF.
|
||||
// Without this, the replace() below would fail on Windows.
|
||||
const n = (s: string) => s.replace(/\r\n/g, "\n");
|
||||
html = n(html);
|
||||
|
||||
const replaceRequired = (source: string, name: string, search: string, replacement: string) => {
|
||||
const normalizedSearch = n(search);
|
||||
const normalizedReplacement = n(replacement);
|
||||
const matches = source.split(normalizedSearch).length - 1;
|
||||
if (matches !== 1) {
|
||||
throw new Error(`Failed to patch exported HTML: ${name} expected 1 match, found ${matches}`);
|
||||
}
|
||||
return source.replace(normalizedSearch, normalizedReplacement);
|
||||
};
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"sortChildren",
|
||||
` function sortChildren(node) {
|
||||
node.children.sort((a, b) =>
|
||||
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
node.children.forEach(sortChildren);
|
||||
}`,
|
||||
` function sortChildren(root) {
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
node.children.sort((a, b) =>
|
||||
new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()
|
||||
);
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
stack.push(node.children[i]);
|
||||
}
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"mapNodes",
|
||||
` function mapNodes(node) {
|
||||
treeNodeMap.set(node.entry.id, node);
|
||||
node.children.forEach(mapNodes);
|
||||
}
|
||||
tree.forEach(mapNodes);`,
|
||||
` const stack = [...tree].reverse();
|
||||
while (stack.length) {
|
||||
const node = stack.pop();
|
||||
treeNodeMap.set(node.entry.id, node);
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
stack.push(node.children[i]);
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
html = replaceRequired(
|
||||
html,
|
||||
"markActive",
|
||||
` function markActive(node) {
|
||||
let has = activePathIds.has(node.entry.id);
|
||||
for (const child of node.children) {
|
||||
if (markActive(child)) has = true;
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
return has;
|
||||
}`,
|
||||
` function markActive(root) {
|
||||
// Post-order traversal using two stacks
|
||||
const stack1 = [root];
|
||||
const stack2 = [];
|
||||
while (stack1.length) {
|
||||
const node = stack1.pop();
|
||||
stack2.push(node);
|
||||
for (const child of node.children) {
|
||||
stack1.push(child);
|
||||
}
|
||||
}
|
||||
while (stack2.length) {
|
||||
const node = stack2.pop();
|
||||
let has = activePathIds.has(node.entry.id);
|
||||
for (const child of node.children) {
|
||||
if (containsActive.get(child)) has = true;
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
}
|
||||
}`
|
||||
);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
async function exportSession(filePath: string, outputPath: string): Promise<void> {
|
||||
const cliPath = await getPiCliPath();
|
||||
if (cliPath) {
|
||||
await execFileAsync(process.execPath, [cliPath, "--export", filePath, outputPath], {
|
||||
cwd: process.cwd(),
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
...process.env,
|
||||
PI_OFFLINE: "1",
|
||||
PI_SKIP_VERSION_CHECK: "1",
|
||||
},
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const packageDir = await getPiPackageDir();
|
||||
if (!packageDir) throw new Error("pi CLI not found");
|
||||
|
||||
const exporterUrl = pathToFileURL(join(packageDir, "dist", "core", "export-html", "index.js")).href;
|
||||
const { exportFromFile } = (await import(exporterUrl)) as ExportHtmlModule;
|
||||
await exportFromFile(filePath, outputPath);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const inline = new URL(req.url).searchParams.get("inline") === "1";
|
||||
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const tempDir = join(tmpdir(), "pi-web-export");
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const sessionBase = basename(filePath, ".jsonl");
|
||||
const fileName = `pi-session-${sessionBase}.html`;
|
||||
const outputPath = join(tempDir, `${randomUUID()}.html`);
|
||||
|
||||
try {
|
||||
await exportSession(filePath, outputPath);
|
||||
|
||||
const html = readFileSync(outputPath, "utf8");
|
||||
const patchedHtml = patchExportHtml(html);
|
||||
return new Response(patchedHtml, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Disposition": getContentDisposition(fileName, inline),
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(outputPath, { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
resolveSessionPath,
|
||||
resolveSessionIdByPath,
|
||||
invalidateSessionPathCache,
|
||||
invalidateSessionListCache,
|
||||
buildSessionContext,
|
||||
readSessionHeader,
|
||||
} from "@/lib/session-reader";
|
||||
import { sessionPathKey } from "@/lib/session-path";
|
||||
import { getRpcSession } from "@/lib/rpc-manager";
|
||||
|
||||
// BranchNavigator still traverses recursively, so keep the response tree shallow.
|
||||
const MAX_PROJECTED_TREE_DEPTH = 200;
|
||||
|
||||
/**
|
||||
* Project the session tree into the shallow navigation tree sent to the client.
|
||||
* Keeps roots, branch points, and leaves while contracting single-child chains
|
||||
* without recursive traversal. Contracted entry IDs are attached to the next
|
||||
* visible node so the UI can still recognize an active leaf inside the chain.
|
||||
*/
|
||||
function projectTreeForResponse<T extends { entry: { id: string }; children: T[]; compressedEntryIds?: string[] }>(
|
||||
nodes: T[]
|
||||
): T[] {
|
||||
const keep = new Set<T>();
|
||||
const roots = new Set(nodes);
|
||||
const seen = new Set<T>();
|
||||
const stack = [...nodes];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (seen.has(node)) continue;
|
||||
seen.add(node);
|
||||
|
||||
if (
|
||||
roots.has(node) ||
|
||||
node.children.length !== 1
|
||||
) {
|
||||
keep.add(node);
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
const cloneNode = (node: T, compressedEntryIds?: string[]): T => ({
|
||||
...node,
|
||||
children: [],
|
||||
...(compressedEntryIds?.length ? { compressedEntryIds } : {}),
|
||||
});
|
||||
const projectedRoots = nodes.map((node) => cloneNode(node));
|
||||
const tasks = nodes.map((source, index) => ({
|
||||
source,
|
||||
projected: projectedRoots[index],
|
||||
depth: 1,
|
||||
}));
|
||||
|
||||
const appendFlattenedKeptDescendants = (source: T, projectedParent: T) => {
|
||||
const pending = [{ node: source, compressedEntryIds: [] as string[] }];
|
||||
const flattenedSeen = new Set<T>();
|
||||
|
||||
while (pending.length > 0) {
|
||||
const { node, compressedEntryIds } = pending.pop()!;
|
||||
if (flattenedSeen.has(node)) continue;
|
||||
flattenedSeen.add(node);
|
||||
|
||||
if (keep.has(node)) {
|
||||
projectedParent.children.push(cloneNode(node, compressedEntryIds));
|
||||
}
|
||||
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
pending.push({
|
||||
node: node.children[i],
|
||||
compressedEntryIds: keep.has(node)
|
||||
? []
|
||||
: [...compressedEntryIds, node.entry.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while (tasks.length > 0) {
|
||||
const { source, projected, depth } = tasks.pop()!;
|
||||
|
||||
for (const sourceChild of source.children) {
|
||||
let child = sourceChild;
|
||||
|
||||
if (depth >= MAX_PROJECTED_TREE_DEPTH) {
|
||||
appendFlattenedKeptDescendants(child, projected);
|
||||
continue;
|
||||
}
|
||||
|
||||
const compressedEntryIds: string[] = [];
|
||||
while (!keep.has(child) && child.children.length === 1) {
|
||||
compressedEntryIds.push(child.entry.id);
|
||||
child = child.children[0];
|
||||
}
|
||||
|
||||
if (!keep.has(child)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectedChild = cloneNode(child, compressedEntryIds);
|
||||
projected.children.push(projectedChild);
|
||||
tasks.push({ source: child, projected: projectedChild, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return projectedRoots;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const sm = SessionManager.open(filePath);
|
||||
const entries = sm.getEntries() as never;
|
||||
const leafId = sm.getLeafId();
|
||||
const tree = projectTreeForResponse(sm.getTree());
|
||||
const searchParams = new URL(req.url).searchParams;
|
||||
const deferThinking = searchParams.has("deferThinking");
|
||||
const deferToolResultImages = searchParams.has("deferMedia");
|
||||
const context = buildSessionContext(entries, leafId, { deferThinking, deferToolResultImages });
|
||||
|
||||
const header = sm.getHeader();
|
||||
let modified = header?.timestamp ?? new Date().toISOString();
|
||||
try { modified = statSync(filePath).mtime.toISOString(); } catch { /* use header timestamp */ }
|
||||
const parentSessionId = header?.parentSession
|
||||
? await resolveSessionIdByPath(header.parentSession)
|
||||
: undefined;
|
||||
const info = header ? {
|
||||
path: filePath,
|
||||
id: header.id,
|
||||
cwd: header.cwd ?? "",
|
||||
name: sm.getSessionName(),
|
||||
created: header.timestamp,
|
||||
modified,
|
||||
messageCount: context.messages.length,
|
||||
firstMessage: context.messages.find((m) => m.role === "user")
|
||||
? (() => {
|
||||
const msg = context.messages.find((m) => m.role === "user")!;
|
||||
const c = (msg as { content: unknown }).content;
|
||||
return typeof c === "string" ? c : (Array.isArray(c) ? (c.find((b: { type: string }) => b.type === "text") as { text: string } | undefined)?.text ?? "" : "") || "(no messages)";
|
||||
})()
|
||||
: "(no messages)",
|
||||
parentSessionId,
|
||||
} : null;
|
||||
|
||||
return NextResponse.json({
|
||||
sessionId: id,
|
||||
filePath,
|
||||
info,
|
||||
leafId,
|
||||
tree,
|
||||
context,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/sessions/[id] body: { name: string }
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { name } = await req.json() as { name?: string };
|
||||
if (typeof name !== "string") {
|
||||
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
||||
}
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
const sm = SessionManager.open(filePath);
|
||||
sm.appendSessionInfo(name.trim());
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/sessions/[id]
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const filePath = await resolveSessionPath(id);
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Read only the bounded header before deleting.
|
||||
const parentSessionPath = readSessionHeader(filePath)?.parentSession;
|
||||
|
||||
// Re-attach all direct children to this session's parent (cascade re-parent)
|
||||
// Scan sibling files in the same directory
|
||||
const targetPathKey = sessionPathKey(filePath);
|
||||
const dir = dirname(filePath);
|
||||
try {
|
||||
const files = readdirSync(dir).filter(
|
||||
(file) => file.endsWith(".jsonl") && sessionPathKey(join(dir, file)) !== targetPathKey,
|
||||
);
|
||||
for (const file of files) {
|
||||
const childPath = join(dir, file);
|
||||
try {
|
||||
const content = readFileSync(childPath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
const header = JSON.parse(lines[0]) as { type?: string; parentSession?: string };
|
||||
if (
|
||||
header.type === "session" &&
|
||||
header.parentSession &&
|
||||
sessionPathKey(header.parentSession) === targetPathKey
|
||||
) {
|
||||
// Rewrite header with new parentSession
|
||||
header.parentSession = parentSessionPath;
|
||||
lines[0] = JSON.stringify(header);
|
||||
writeFileSync(childPath, lines.join("\n"));
|
||||
}
|
||||
} catch { /* skip malformed */ }
|
||||
}
|
||||
} catch { /* skip if dir unreadable */ }
|
||||
|
||||
getRpcSession(id)?.destroy();
|
||||
unlinkSync(filePath);
|
||||
invalidateSessionPathCache(id);
|
||||
invalidateSessionListCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getRpcSession } from "@/lib/rpc-manager";
|
||||
import { resolveSessionPath } from "@/lib/session-reader";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
if (!await resolveSessionPath(id)) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const rpc = getRpcSession(id);
|
||||
if (!rpc?.isAlive()) return NextResponse.json({ running: false });
|
||||
|
||||
const state = await rpc.send({ type: "get_state" });
|
||||
return NextResponse.json({ running: true, state });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listAllSessions } from "@/lib/session-reader";
|
||||
import { getRunningRpcSessionIds } from "@/lib/rpc-manager";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sessions = await listAllSessions();
|
||||
return NextResponse.json({ sessions, runningSessionIds: getRunningRpcSessionIds() });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { SkillInstallScope } from "@/lib/api-types";
|
||||
import { checkSkillUpdates } from "@/lib/skill-updates";
|
||||
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
cwd?: unknown;
|
||||
package?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
const cwd = typeof body.cwd === "string" ? body.cwd : "";
|
||||
if (!cwd) return NextResponse.json({ error: "cwd required" }, { status: 400 });
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const pkg = typeof body.package === "string" ? body.package : undefined;
|
||||
const scope = body.scope === "global" || body.scope === "project"
|
||||
? body.scope as SkillInstallScope
|
||||
: undefined;
|
||||
if ((pkg && !scope) || (!pkg && scope)) {
|
||||
return NextResponse.json({ error: "package and scope must be provided together" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { skills } = await loadSkillsWithInstallInfo(cwd);
|
||||
const installs = skills
|
||||
.map((skill) => skill.install)
|
||||
.filter((install): install is NonNullable<typeof install> => Boolean(install))
|
||||
.filter((install) => !pkg || (install.package === pkg && install.scope === scope));
|
||||
|
||||
if (pkg && installs.length === 0) {
|
||||
return NextResponse.json({ error: "Installed skill not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updates = await checkSkillUpdates(installs, {
|
||||
githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
|
||||
});
|
||||
return NextResponse.json({ updates });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import { runNpx } from "@/lib/npx";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
|
||||
import { getProjectTrustStatus } from "@/lib/project-trust";
|
||||
import { getSkillsCliEnvironment, isManagedRuntime } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ANSI_RE = /\x1B\[[0-9;]*m/g;
|
||||
|
||||
// POST /api/skills/install body: { package: string; scope: "global" | "project"; cwd?: string }
|
||||
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 { package: pkg, scope, cwd } = await req.json() as { package?: string; scope?: string; cwd?: string };
|
||||
if (!pkg?.trim()) return NextResponse.json({ error: "package required" }, { status: 400 });
|
||||
|
||||
const isGlobal = isManagedRuntime() || scope !== "project";
|
||||
if (!isGlobal) {
|
||||
if (!cwd) return NextResponse.json({ error: "cwd required for project install" }, { status: 400 });
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
if (!getProjectTrustStatus(cwd, getAgentDir()).trusted) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project resources must be trusted before installing project skills" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const args = ["skills", "add", pkg.trim(), "-y", "--agent", "pi"];
|
||||
if (isGlobal) args.push("-g");
|
||||
|
||||
console.log(`[skills/install] running: npx ${args.join(" ")}`);
|
||||
const { stdout, stderr } = await runNpx(args, {
|
||||
timeout: 60000,
|
||||
cwd: !isGlobal && cwd ? cwd : undefined,
|
||||
env: getSkillsCliEnvironment(),
|
||||
});
|
||||
|
||||
const output = (stdout + stderr).replace(ANSI_RE, "");
|
||||
const success = /Installation complete|Installed \d+ skill/.test(output);
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: output.slice(-300) || "Install failed" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ success: true, output });
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = ((err.stdout ?? "") + (err.stderr ?? "")).replace(ANSI_RE, "");
|
||||
return NextResponse.json({ error: output || (err.message ?? String(e)) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import path from "path";
|
||||
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
||||
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { getManagedRuntimePaths, isManagedRuntime } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/skills?cwd=<path>
|
||||
// Uses DefaultResourceLoader (same logic as AgentSession startup) so settings.json
|
||||
// skill paths, package skills, and .agents/skills directories are all included.
|
||||
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 loadSkillsWithInstallInfo(cwd));
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/skills — toggle disable-model-invocation on a SKILL.md file
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { filePath: string; disableModelInvocation: boolean };
|
||||
const { filePath, disableModelInvocation } = body;
|
||||
if (!filePath) return NextResponse.json({ error: "filePath required" }, { status: 400 });
|
||||
if (!existsSync(filePath)) return NextResponse.json({ error: "file not found" }, { status: 404 });
|
||||
const allowedRoots = new Set(await getAllowedFileRoots());
|
||||
allowedRoots.add(getAgentDir());
|
||||
if (isManagedRuntime()) {
|
||||
for (const root of getManagedRuntimePaths().managedSkillRoots) {
|
||||
if (existsSync(root)) allowedRoots.add(root);
|
||||
}
|
||||
} else {
|
||||
// Upstream-compatible mode keeps the CLI's user-wide skill root.
|
||||
const globalSkillsDir = path.join(homedir(), ".agents", "skills");
|
||||
if (existsSync(globalSkillsDir)) allowedRoots.add(globalSkillsDir);
|
||||
}
|
||||
if (!isExistingFilePathAllowed(filePath, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const key = "disable-model-invocation";
|
||||
|
||||
// Use parseFrontmatter to check current value, then do a surgical line edit
|
||||
// to preserve the original YAML formatting of all other fields.
|
||||
const { frontmatter } = parseFrontmatter<Record<string, unknown>>(content);
|
||||
const alreadySet = Boolean(frontmatter[key]);
|
||||
|
||||
let updated = content;
|
||||
if (disableModelInvocation && !alreadySet) {
|
||||
// Add key after the opening --- line
|
||||
updated = content.replace(/^---\r?\n/, `---\n${key}: true\n`);
|
||||
// If no frontmatter exists, create one
|
||||
if (updated === content) updated = `---\n${key}: true\n---\n${content}`;
|
||||
} else if (!disableModelInvocation && alreadySet) {
|
||||
// Remove the key line entirely
|
||||
updated = content.replace(new RegExp(`^${key}\\s*:.*\\r?\\n`, "m"), "");
|
||||
}
|
||||
|
||||
writeFileSync(filePath, updated, "utf8");
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runNpx } from "@/lib/npx";
|
||||
import type { SkillSearchResult } from "@/lib/api-types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ANSI_RE = /\x1B\[[0-9;]*m/g;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MIN_LIMIT = 1;
|
||||
const MAX_LIMIT = 50;
|
||||
const SEARCH_API_BASE = process.env.SKILLS_API_URL || "https://skills.sh";
|
||||
|
||||
interface SkillsApiSkill {
|
||||
id?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
installs?: number;
|
||||
}
|
||||
|
||||
interface SkillsApiResponse {
|
||||
skills?: SkillsApiSkill[];
|
||||
}
|
||||
|
||||
function parseLimit(value: unknown): number {
|
||||
const num = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(num)) return DEFAULT_LIMIT;
|
||||
return Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.floor(num)));
|
||||
}
|
||||
|
||||
function formatInstalls(count?: number): string {
|
||||
if (!count || count <= 0) return "";
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M installs`;
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}K installs`;
|
||||
return `${count} install${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function parseSearchOutput(raw: string): SkillSearchResult[] {
|
||||
const clean = raw.replace(ANSI_RE, "");
|
||||
const results: SkillSearchResult[] = [];
|
||||
const lines = clean.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
// package line: "owner/repo@skill NNK installs"
|
||||
const pkgMatch = line.match(/^([\w.\-]+\/[\w.\-@:]+)\s+([\d.,]+[KMB]?\s+installs)$/);
|
||||
if (pkgMatch) {
|
||||
const urlLine = lines[i + 1]?.trim().replace(/^└\s*/, "");
|
||||
results.push({
|
||||
package: pkgMatch[1],
|
||||
installs: pkgMatch[2],
|
||||
url: urlLine?.startsWith("https://") ? urlLine : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function searchSkillsApi(query: string, limit: number): Promise<SkillSearchResult[]> {
|
||||
const url = `${SEARCH_API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`skills.sh search failed: HTTP ${res.status}`);
|
||||
|
||||
const data = (await res.json()) as SkillsApiResponse;
|
||||
return (data.skills ?? [])
|
||||
.map((skill) => {
|
||||
const name = skill.name?.trim();
|
||||
const source = skill.source?.trim();
|
||||
const slug = skill.id?.trim();
|
||||
if (!name || (!source && !slug)) return null;
|
||||
|
||||
const pkg = `${source || slug}@${name}`;
|
||||
return {
|
||||
package: pkg,
|
||||
installs: formatInstalls(skill.installs),
|
||||
url: slug ? `${SEARCH_API_BASE}/${slug}` : "",
|
||||
};
|
||||
})
|
||||
.filter((skill): skill is SkillSearchResult => skill !== null)
|
||||
.sort((a, b) => parseInstallCount(b.installs) - parseInstallCount(a.installs));
|
||||
}
|
||||
|
||||
function parseInstallCount(installs: string): number {
|
||||
const match = installs.match(/^([\d.]+)([KMB])?\s+installs?$/);
|
||||
if (!match) return 0;
|
||||
const value = Number(match[1]);
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
const multiplier = match[2] === "B" ? 1_000_000_000 : match[2] === "M" ? 1_000_000 : match[2] === "K" ? 1_000 : 1;
|
||||
return value * multiplier;
|
||||
}
|
||||
|
||||
// POST /api/skills/search body: { query: string, limit?: number }
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { query, limit: rawLimit } = await req.json() as { query?: string; limit?: unknown };
|
||||
if (!query?.trim()) return NextResponse.json({ error: "query required" }, { status: 400 });
|
||||
const limit = parseLimit(rawLimit);
|
||||
|
||||
try {
|
||||
const results = await searchSkillsApi(query.trim(), limit);
|
||||
return NextResponse.json({ results });
|
||||
} catch {
|
||||
const { stdout, stderr } = await runNpx(["skills", "find", query.trim()], {
|
||||
timeout: 20000,
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
});
|
||||
|
||||
const results = parseSearchOutput(stdout + stderr).slice(0, limit);
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; message?: string };
|
||||
const raw = (err.stdout ?? "") + (err.stderr ?? "");
|
||||
const results = raw ? parseSearchOutput(raw) : [];
|
||||
if (results.length > 0) return NextResponse.json({ results });
|
||||
return NextResponse.json({ error: err.message ?? String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { runNpx } from "@/lib/npx";
|
||||
import type { SkillInstallScope } from "@/lib/api-types";
|
||||
import { buildSkillUpdateArgs } from "@/lib/skill-updates";
|
||||
import { loadSkillsWithInstallInfo } from "@/lib/skills-service";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { getSkillsCliEnvironment, isManagedRuntime } from "@/lib/app-runtime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as {
|
||||
cwd?: unknown;
|
||||
package?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
const cwd = typeof body.cwd === "string" ? body.cwd : "";
|
||||
const pkg = typeof body.package === "string" ? body.package : "";
|
||||
const scope = body.scope === "global" || body.scope === "project"
|
||||
? body.scope as SkillInstallScope
|
||||
: undefined;
|
||||
if (!cwd || !pkg || !scope) {
|
||||
return NextResponse.json({ error: "cwd, package, and scope are required" }, { status: 400 });
|
||||
}
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { skills } = await loadSkillsWithInstallInfo(cwd);
|
||||
const skill = skills.find(
|
||||
(item) => item.install?.package === pkg && item.install.scope === scope,
|
||||
);
|
||||
if (!skill?.install) {
|
||||
return NextResponse.json({ error: "Installed skill not found" }, { status: 404 });
|
||||
}
|
||||
if (!skill.install.canCheckForUpdates) {
|
||||
return NextResponse.json({ error: "This skill cannot be updated automatically" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await runNpx(buildSkillUpdateArgs(skill.install), {
|
||||
timeout: 60_000,
|
||||
cwd: !isManagedRuntime() && scope === "project" ? cwd : undefined,
|
||||
env: getSkillsCliEnvironment(),
|
||||
});
|
||||
|
||||
const refreshed = await loadSkillsWithInstallInfo(cwd);
|
||||
const updatedSkill = refreshed.skills.find(
|
||||
(item) => item.install?.package === pkg && item.install.scope === scope,
|
||||
);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
skill: updatedSkill,
|
||||
output: `${stdout}${stderr}`.slice(-500),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const detail = error as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = `${detail.stdout ?? ""}${detail.stderr ?? ""}`;
|
||||
return NextResponse.json(
|
||||
{ error: output || detail.message || String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { existsSync } from "fs";
|
||||
import { addWorktree, listWorktrees, removeWorktree, resolveProject } from "@/lib/worktree";
|
||||
import { allowFileRoot, getAllowedFileRoots, isExistingFilePathAllowed, isFilePathAllowed } from "@/lib/file-access";
|
||||
|
||||
/** Same gate as /api/files: only session cwds / project roots / explicitly
|
||||
* allowed dirs may be inspected or mutated through this endpoint. */
|
||||
async function checkCwdAllowed(cwd: string): Promise<NextResponse | null> {
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isFilePathAllowed(cwd, allowedRoots) || !isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/worktrees?cwd= → { projectRoot, isGit, isTopLevel, worktrees }
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const cwd = new URL(req.url).searchParams.get("cwd");
|
||||
if (!cwd) {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
const denied = await checkCwdAllowed(cwd);
|
||||
if (denied) return denied;
|
||||
|
||||
const project = await resolveProject(cwd);
|
||||
let worktrees: Awaited<ReturnType<typeof listWorktrees>> = [];
|
||||
let isGit = true;
|
||||
try {
|
||||
// For a removed-worktree cwd (session of a deleted worktree), fall back
|
||||
// to the inferred project root so the switcher still shows the project.
|
||||
worktrees = await listWorktrees(existsSync(cwd) ? cwd : project.projectRoot);
|
||||
} catch {
|
||||
isGit = false;
|
||||
}
|
||||
// Every listed path is a git-verified worktree of this project; allow the
|
||||
// file explorer to browse them even before they have any session (the
|
||||
// in-memory allowlist from addWorktree does not survive server restarts).
|
||||
for (const w of worktrees) allowFileRoot(w.path);
|
||||
return NextResponse.json({
|
||||
projectRoot: project.projectRoot,
|
||||
isGit,
|
||||
isTopLevel: project.isTopLevel,
|
||||
worktrees,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/worktrees body: { cwd, branch } → { path, branch }
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: string; branch?: string };
|
||||
if (!body.cwd || typeof body.cwd !== "string") {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
if (!body.branch || typeof body.branch !== "string") {
|
||||
return NextResponse.json({ error: "branch is required" }, { status: 400 });
|
||||
}
|
||||
const denied = await checkCwdAllowed(body.cwd);
|
||||
if (denied) return denied;
|
||||
if (!existsSync(body.cwd)) {
|
||||
return NextResponse.json({ error: `Directory does not exist: ${body.cwd}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await addWorktree(body.cwd, body.branch);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/worktrees body: { cwd, path, force? }
|
||||
export async function DELETE(req: Request) {
|
||||
try {
|
||||
const body = await req.json() as { cwd?: string; path?: string; force?: boolean };
|
||||
if (!body.cwd || typeof body.cwd !== "string") {
|
||||
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
|
||||
}
|
||||
if (!body.path || typeof body.path !== "string") {
|
||||
return NextResponse.json({ error: "path is required" }, { status: 400 });
|
||||
}
|
||||
const denied = await checkCwdAllowed(body.cwd);
|
||||
if (denied) return denied;
|
||||
|
||||
await removeWorktree(body.cwd, body.path, body.force === true);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// git refuses to remove dirty worktrees without --force; surface that so
|
||||
// the UI can offer a force-remove confirmation.
|
||||
const dirty = /contains modified or untracked files|is dirty/i.test(message);
|
||||
return NextResponse.json({ error: message, dirty }, { status: dirty ? 409 : 400 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user