mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
117 lines
4.1 KiB
TypeScript
117 lines
4.1 KiB
TypeScript
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 });
|
|
}
|
|
}
|