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,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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user