feat(FN-1908): merge fusion/fn-1908

This commit is contained in:
gsxdsm
2026-04-16 08:38:41 -07:00
parent 70da5b21e6
commit 3a0350da60
9 changed files with 941 additions and 0 deletions

View File

@@ -0,0 +1,451 @@
/**
* Tests for skills CLI commands
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { spawn, type ChildProcess } from "node:child_process";
// ─── Mock setup ────────────────────────────────────────────────────────────────
type Listener = (...args: any[]) => void;
interface MockChild extends MockEmitter {
kill: ReturnType<typeof vi.fn>;
killed: boolean;
stdout?: MockEmitter;
stderr?: MockEmitter;
}
interface MockEmitter {
on(event: string, listener: Listener): MockEmitter;
once(event: string, listener: Listener): MockEmitter;
off(event: string, listener: Listener): MockEmitter;
emit(event: string, ...args: any[]): boolean;
}
const mocks = vi.hoisted(() => {
function createEmitter(): MockEmitter {
const listeners = new Map<string, Set<Listener>>();
const add = (event: string, listener: Listener) => {
const eventListeners = listeners.get(event) ?? new Set<Listener>();
eventListeners.add(listener);
listeners.set(event, eventListeners);
};
const remove = (event: string, listener: Listener) => {
const eventListeners = listeners.get(event);
if (!eventListeners) return;
eventListeners.delete(listener);
if (eventListeners.size === 0) {
listeners.delete(event);
}
};
return {
on(event: string, listener: Listener) {
add(event, listener);
return this;
},
once(event: string, listener: Listener) {
const wrapped: Listener = (...args: any[]) => {
remove(event, wrapped);
listener(...args);
};
add(event, wrapped);
return this;
},
off(event: string, listener: Listener) {
remove(event, listener);
return this;
},
emit(event: string, ...args: any[]) {
const eventListeners = listeners.get(event);
if (!eventListeners || eventListeners.size === 0) {
return false;
}
for (const listener of [...eventListeners]) {
listener(...args);
}
return true;
},
};
}
function createMockChild(): MockChild {
const emitter = createEmitter();
const child = emitter as MockChild;
child.killed = false;
child.kill = vi.fn(() => {
child.killed = true;
return true;
});
// Mock stdout/stderr streams
child.stdout = createEmitter();
child.stderr = createEmitter();
return child;
}
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
const spawnMock = vi.fn((command: string) => {
const child = createMockChild();
// Auto-resolve with success by default for npx
if (command === "npx") {
// Use setTimeout instead of queueMicrotask for more predictable timing
setTimeout(() => {
child.emit("exit", 0);
}, 10);
}
return child;
});
return {
mockFetch,
spawn: spawnMock,
createMockChild,
};
});
vi.mock("node:child_process", () => ({
spawn: mocks.spawn,
}));
// ─── Spies setup ──────────────────────────────────────────────────────────────
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// ─── Import after mocks and spies ────────────────────────────────────────────
import { searchSkills, formatInstalls, runSkillsSearch, runSkillsInstall } from "../skills.js";
// ─── formatInstalls tests ──────────────────────────────────────────────────────
describe("formatInstalls", () => {
it("formats millions correctly", () => {
expect(formatInstalls(1_500_000)).toBe("1.5M installs");
expect(formatInstalls(2_000_000)).toBe("2M installs");
expect(formatInstalls(1_000_000)).toBe("1M installs");
});
it("formats thousands correctly", () => {
expect(formatInstalls(32_000)).toBe("32K installs");
expect(formatInstalls(10_000)).toBe("10K installs");
expect(formatInstalls(1_500)).toBe("1.5K installs");
});
it("formats hundreds correctly", () => {
expect(formatInstalls(500)).toBe("500 installs");
expect(formatInstalls(100)).toBe("100 installs");
expect(formatInstalls(1)).toBe("1 installs");
});
it("returns empty string for zero", () => {
expect(formatInstalls(0)).toBe("");
});
});
// ─── searchSkills tests ───────────────────────────────────────────────────────
describe("searchSkills", () => {
beforeEach(() => {
mocks.mockFetch.mockReset();
});
it("returns skills sorted by installs descending", async () => {
mocks.mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
query: "firebase",
searchType: "skills",
skills: [
{ id: "firebase/agent-skills/firebase-basics", skillId: "firebase-basics", name: "firebase-basics", installs: 24095, source: "firebase/agent-skills" },
{ id: "firebase/agent-skills/firebase-auth", skillId: "firebase-auth", name: "firebase-auth", installs: 18000, source: "firebase/agent-skills" },
{ id: "firebase/agent-skills/firebase-firestore", skillId: "firebase-firestore", name: "firebase-firestore", installs: 35000, source: "firebase/agent-skills" },
],
}),
} as unknown as Response);
const results = await searchSkills("firebase");
expect(results).toHaveLength(3);
expect(results[0]!.name).toBe("firebase-firestore"); // Most installs
expect(results[1]!.name).toBe("firebase-basics");
expect(results[2]!.name).toBe("firebase-auth"); // Least installs
expect(mocks.mockFetch).toHaveBeenCalledWith(
expect.stringContaining("q=firebase"),
expect.any(Object),
);
});
it("returns empty array when no skills found", async () => {
mocks.mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
query: "nonexistent",
searchType: "skills",
skills: [],
}),
} as unknown as Response);
const results = await searchSkills("nonexistent");
expect(results).toHaveLength(0);
});
it("returns empty array on network error", async () => {
mocks.mockFetch.mockRejectedValueOnce(new TypeError("fetch failed"));
const results = await searchSkills("firebase");
expect(results).toHaveLength(0);
});
it("returns empty array on non-200 status", async () => {
mocks.mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
} as unknown as Response);
const results = await searchSkills("firebase");
expect(results).toHaveLength(0);
});
it("returns empty array on invalid JSON response", async () => {
mocks.mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => { throw new Error("Parse error"); },
} as unknown as Response);
const results = await searchSkills("firebase");
expect(results).toHaveLength(0);
});
});
// ─── runSkillsSearch tests ────────────────────────────────────────────────────
describe("runSkillsSearch", () => {
beforeEach(() => {
consoleLogSpy.mockClear();
consoleErrorSpy.mockClear();
mocks.mockFetch.mockReset();
});
afterEach(() => {
consoleLogSpy.mockClear();
consoleErrorSpy.mockClear();
});
it("prints usage when no query provided", async () => {
await runSkillsSearch([]);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Usage: fn skills search"),
);
});
it("prints usage when query is only whitespace", async () => {
await runSkillsSearch([" "]);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Usage: fn skills search"),
);
});
it("prints skills with correct format", async () => {
mocks.mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
query: "firebase",
searchType: "skills",
skills: [
{ id: "firebase/agent-skills/firebase-basics", skillId: "firebase-basics", name: "firebase-basics", installs: 24095, source: "firebase/agent-skills" },
{ id: "firebase/agent-skills/firebase-auth", skillId: "firebase-auth", name: "firebase-auth", installs: 18000, source: "firebase/agent-skills" },
{ id: "firebase/agent-skills/firebase-firestore", skillId: "firebase-firestore", name: "firebase-firestore", installs: 35000, source: "firebase/agent-skills" },
],
}),
} as unknown as Response);
await runSkillsSearch(["firebase"]);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Skills matching 'firebase' (3 results)"),
);
// Check install count formatting
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("24.1K installs"),
);
// Check install hint
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("fn skills install <source> --skill <name>"),
);
});
it("prints no skills found message", async () => {
mocks.mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
query: "nonexistent",
searchType: "skills",
skills: [],
}),
} as unknown as Response);
await runSkillsSearch(["nonexistent"]);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("No skills found for 'nonexistent'"),
);
});
it("passes limit option through to searchSkills", async () => {
mocks.mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
query: "react",
searchType: "skills",
skills: [],
}),
} as unknown as Response);
await runSkillsSearch(["react"], { limit: 5 });
expect(mocks.mockFetch.mock.calls[0]![0]).toContain("limit=5");
});
it("uses default limit of 10", async () => {
mocks.mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
query: "react",
searchType: "skills",
skills: [],
}),
} as unknown as Response);
await runSkillsSearch(["react"]);
expect(mocks.mockFetch.mock.calls[0]![0]).toContain("limit=10");
});
});
// ─── runSkillsInstall tests ────────────────────────────────────────────────────
describe("runSkillsInstall", () => {
beforeEach(() => {
consoleLogSpy.mockClear();
consoleErrorSpy.mockClear();
mocks.spawn.mockReset();
// Create a new child for each test with default success exit
const child = mocks.createMockChild();
// Override the on method to emit exit after a short delay
const originalOn = child.on.bind(child);
child.on = vi.fn((event: string, handler: Listener) => {
if (event === "exit") {
// Emit exit after a short delay to simulate async spawn
setTimeout(() => {
handler(0);
}, 10);
return child;
}
return originalOn(event, handler);
});
mocks.spawn.mockReturnValue(child);
});
afterEach(() => {
consoleLogSpy.mockClear();
consoleErrorSpy.mockClear();
});
it("prints usage when no source provided", async () => {
await runSkillsInstall([]);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Usage: fn skills install"),
);
});
it("prints error for invalid source format", async () => {
await runSkillsInstall(["not-a-repo"]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Invalid source format"),
);
});
it("prints usage for empty string source (treated as no source)", async () => {
// Empty string is falsy, so it's treated as "no source" and prints usage
await runSkillsInstall([""]);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Usage: fn skills install"),
);
});
it("spawns npx with correct args for install all skills", async () => {
await runSkillsInstall(["firebase/agent-skills"]);
// Wait for async spawn to complete
await new Promise((r) => setTimeout(r, 100));
expect(mocks.spawn).toHaveBeenCalledWith(
"npx",
["skills", "add", "firebase/agent-skills", "-y", "-a", "pi"],
expect.any(Object),
);
});
it("spawns npx with correct args for specific skill", async () => {
await runSkillsInstall(["firebase/agent-skills"], { skill: "firebase-basics" });
// Wait for async spawn to complete
await new Promise((r) => setTimeout(r, 100));
expect(mocks.spawn).toHaveBeenCalledWith(
"npx",
["skills", "add", "firebase/agent-skills", "--skill", "firebase-basics", "-y", "-a", "pi"],
expect.any(Object),
);
});
it("prints success message on successful install", async () => {
await runSkillsInstall(["firebase/agent-skills"]);
// Wait for async spawn to complete
await new Promise((r) => setTimeout(r, 100));
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Installed skill from firebase/agent-skills"),
);
});
it("prints error on non-zero exit code", async () => {
// Create a child that exits with error
const errorChild = mocks.createMockChild();
errorChild.on = vi.fn((event: string, handler: Listener) => {
if (event === "exit") {
setTimeout(() => handler(1), 10);
return errorChild;
}
return errorChild;
});
mocks.spawn.mockReturnValueOnce(errorChild);
await runSkillsInstall(["firebase/agent-skills"]);
// Wait for async spawn to complete
await new Promise((r) => setTimeout(r, 100));
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to install skill"),
);
});
});

View File

@@ -0,0 +1,209 @@
/**
* Skills CLI Commands
*
* Provides CLI commands for browsing and importing skills from skills.sh:
* - fn skills search <query> - Search skills.sh for agent skills
* - fn skills install <owner/repo> - Install skills from a source
*/
import { spawn, type ChildProcess } from "node:child_process";
/**
* Skill entry from the skills.sh /api/search endpoint.
*/
export interface SkillsShSearchResult {
/** Full skill ID, e.g. "vercel-labs/agent-skills/vercel-react-best-practices" */
id: string;
/** Skill name, e.g. "vercel-react-best-practices" */
skillId: string;
/** Skill name, e.g. "vercel-react-best-practices" */
name: string;
/** Install count */
installs: number;
/** GitHub source owner/repo, e.g. "vercel-labs/agent-skills" */
source: string;
}
/**
* API base URL for skills.sh.
* Override via SKILLS_API_URL environment variable for testing.
*/
export const SKILLS_API_BASE = process.env.SKILLS_API_URL ?? "https://skills.sh";
/**
* Response from the skills.sh /api/search endpoint.
*/
interface SkillsSearchResponse {
query: string;
searchType: string;
skills: Array<{
id: string;
skillId: string;
name: string;
installs: number;
source: string;
}>;
}
/**
* Search skills.sh for skills matching the given query.
*
* Uses the public /api/search endpoint (no authentication required).
*
* @param query - Search query (framework, technology, or capability)
* @param limit - Maximum number of results (default: 10)
* @returns Array of matching skills sorted by install count descending
*/
export async function searchSkills(query: string, limit = 10): Promise<SkillsShSearchResult[]> {
const url = `${SKILLS_API_BASE}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`;
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(10_000),
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
console.error(`[skills] Search failed: HTTP ${response.status} ${response.statusText}`);
return [];
}
const data = (await response.json()) as SkillsSearchResponse;
if (!data.skills || !Array.isArray(data.skills)) {
console.error("[skills] Invalid response format from skills.sh API");
return [];
}
// Return skills sorted by installs descending
return data.skills.sort((a, b) => b.installs - a.installs);
} catch (err) {
const error = err as Error;
console.error(`[skills] Search failed: ${error.message}`);
return [];
}
}
/**
* Format install count for display.
*
* @param count - Number of installs
* @returns Formatted string like "1.5M installs", "32K installs", or "" for 0
*/
export function formatInstalls(count: number): string {
if (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} installs`;
}
/**
* Run the skills search command.
*
* @param args - Command arguments (query words)
* @param options - Search options
* @param options.limit - Maximum results to show (default: 10)
*/
export async function runSkillsSearch(
args: string[],
options?: { limit?: number },
): Promise<void> {
const query = args.join(" ").trim();
if (!query) {
console.log("Usage: fn skills search <query> [--limit <n>]");
console.log("Example: fn skills search react");
console.log(" fn skills search firebase --limit 5");
return;
}
const skills = await searchSkills(query, options?.limit ?? 10);
if (skills.length === 0) {
console.log(`No skills found for '${query}'`);
return;
}
console.log(`Skills matching '${query}' (${skills.length} results):\n`);
for (let i = 0; i < skills.length; i++) {
const skill = skills[i]!;
const installs = formatInstalls(skill.installs);
console.log(`${i + 1}. ${skill.name} (${skill.source})${installs ? `${installs}` : ""}`);
}
console.log("\nInstall with: fn skills install <source> --skill <name>");
}
/**
* Validate that a source string is in owner/repo format.
*/
function isValidSourceFormat(source: string): boolean {
return /^[^/]+\/[^/]+$/.test(source);
}
/**
* Run the skills install command.
*
* @param args - Command arguments (source owner/repo)
* @param options - Install options
* @param options.skill - Specific skill name to install
*/
export async function runSkillsInstall(
args: string[],
options?: { skill?: string },
): Promise<void> {
const source = args[0]?.trim();
if (!source) {
console.log("Usage: fn skills install <owner/repo> [--skill <name>]");
console.log("Example: fn skills install firebase/agent-skills");
console.log(" fn skills install firebase/agent-skills --skill firebase-basics");
return;
}
if (!isValidSourceFormat(source)) {
console.error("Invalid source format. Use owner/repo (e.g., firebase/agent-skills)");
return;
}
// Build npx skills add arguments
const npxArgs = ["skills", "add", source];
if (options?.skill) {
npxArgs.push("--skill", options.skill);
}
// Non-interactive mode (-y) targeting pi agent (-a pi)
npxArgs.push("-y", "-a", "pi");
// Execute via spawn (async, non-blocking)
const child = spawn("npx", npxArgs, {
cwd: process.cwd(),
stdio: "inherit",
});
const exitCode = await new Promise<number>((resolve, reject) => {
child.on("exit", (code) => {
resolve(code ?? 1);
});
child.on("error", (err) => {
reject(err);
});
});
if (exitCode !== 0) {
console.error("Failed to install skill. Make sure 'npx' is available.");
return;
}
console.log(
`Installed skill from ${source}. Skills are discovered from .pi/skills/ and .agents/skills/.`,
);
}