feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
+51
View File
@@ -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 },
);
}
}
+61
View File
@@ -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 });
}
}
+77
View File
@@ -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 });
}
}
+116
View File
@@ -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 });
}
}
+65
View File
@@ -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 },
);
}
}