feat(FN-1908): merge fusion/fn-1908
This commit is contained in:
@@ -116,6 +116,9 @@ describe("fn pi extension", () => {
|
||||
// Agent tools
|
||||
"fn_agent_stop",
|
||||
"fn_agent_start",
|
||||
// Skills tools
|
||||
"fn_skills_search",
|
||||
"fn_skills_install",
|
||||
];
|
||||
|
||||
for (const name of expected) {
|
||||
|
||||
@@ -58,6 +58,7 @@ const { runAgentExport } = await import("./commands/agent-export.js");
|
||||
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js");
|
||||
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable } = await import("./commands/plugin.js");
|
||||
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
|
||||
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
|
||||
|
||||
const HELP = `
|
||||
fn — AI-orchestrated task board
|
||||
@@ -154,6 +155,11 @@ Usage:
|
||||
fn plugin enable <id> Enable a plugin
|
||||
fn plugin disable <id> Disable a plugin
|
||||
fn plugin create <name> Scaffold a new plugin project
|
||||
fn skills search <query> Search skills.sh for agent skills
|
||||
fn skills search <query> --limit 5 Limit results
|
||||
fn skills install <owner/repo> Install skills from a source
|
||||
fn skills install <owner/repo> --skill <name>
|
||||
Install a specific skill
|
||||
|
||||
Options:
|
||||
--project, -P <name> Target a specific project (bypasses CWD detection)
|
||||
@@ -1056,6 +1062,74 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "skills": {
|
||||
const subcommand = args[1];
|
||||
|
||||
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
||||
console.log("fn skills — Browse and install skills from skills.sh\n");
|
||||
console.log("Usage:");
|
||||
console.log(" fn skills search <query> Search skills.sh for agent skills");
|
||||
console.log(" fn skills search <query> --limit 5 Limit results (default: 10, max: 50)");
|
||||
console.log(" fn skills install <owner/repo> Install skills from a source");
|
||||
console.log(" fn skills install <owner/repo> --skill <name>");
|
||||
console.log(" Install a specific skill");
|
||||
console.log("\nExamples:");
|
||||
console.log(" fn skills search react");
|
||||
console.log(" fn skills search firebase --limit 5");
|
||||
console.log(" fn skills install firebase/agent-skills");
|
||||
console.log(" fn skills install firebase/agent-skills --skill firebase-basics");
|
||||
break;
|
||||
}
|
||||
|
||||
if (subcommand === "search") {
|
||||
// Collect all remaining args as the query
|
||||
const queryArgs = args.slice(2);
|
||||
|
||||
// Parse --limit option
|
||||
let limit = 10;
|
||||
const filteredArgs: string[] = [];
|
||||
for (let i = 0; i < queryArgs.length; i++) {
|
||||
if (queryArgs[i] === "--limit" && i + 1 < queryArgs.length) {
|
||||
const parsed = parseInt(queryArgs[i + 1], 10);
|
||||
if (!isNaN(parsed)) {
|
||||
limit = Math.min(Math.max(parsed, 1), 50);
|
||||
}
|
||||
i++; // skip the value
|
||||
} else {
|
||||
filteredArgs.push(queryArgs[i]!);
|
||||
}
|
||||
}
|
||||
|
||||
await runSkillsSearch(filteredArgs, { limit });
|
||||
break;
|
||||
}
|
||||
|
||||
if (subcommand === "install") {
|
||||
// Collect all remaining args as the source and options
|
||||
const installArgs = args.slice(2);
|
||||
|
||||
// Parse --skill option
|
||||
let skill: string | undefined;
|
||||
const filteredArgs: string[] = [];
|
||||
for (let i = 0; i < installArgs.length; i++) {
|
||||
if (installArgs[i] === "--skill" && i + 1 < installArgs.length) {
|
||||
skill = installArgs[i + 1];
|
||||
i++; // skip the value
|
||||
} else {
|
||||
filteredArgs.push(installArgs[i]!);
|
||||
}
|
||||
}
|
||||
|
||||
await runSkillsInstall(filteredArgs, { skill });
|
||||
break;
|
||||
}
|
||||
|
||||
console.error(`Unknown subcommand: skills ${subcommand}`);
|
||||
console.log("Try: fn skills search | install");
|
||||
process.exit(1);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
console.log(HELP);
|
||||
|
||||
451
packages/cli/src/commands/__tests__/skills.test.ts
Normal file
451
packages/cli/src/commands/__tests__/skills.test.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
209
packages/cli/src/commands/skills.ts
Normal file
209
packages/cli/src/commands/skills.ts
Normal 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/.`,
|
||||
);
|
||||
}
|
||||
@@ -1639,6 +1639,182 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// ── fn_skills_search ─────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_skills_search",
|
||||
label: "FN: Search Skills",
|
||||
description:
|
||||
"Search the skills.sh directory for agent skills. Returns matching skills with names, " +
|
||||
"sources (owner/repo), install counts, and install commands. " +
|
||||
"Use fn_skills_install to install a selected skill.",
|
||||
promptSnippet: "Search skills.sh for agent skills",
|
||||
promptGuidelines: [
|
||||
"Use fn_skills_search to discover skills before installing",
|
||||
"Returns skills sorted by popularity (install count)",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
query: Type.String({
|
||||
description:
|
||||
"Search query — framework name, technology, or capability (e.g., 'react', 'firebase', 'testing', 'docker')",
|
||||
}),
|
||||
limit: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Max results to return (default: 10, max: 50)",
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
// Dynamic import to match existing extension patterns
|
||||
const { searchSkills, formatInstalls } = await import("./commands/skills.js");
|
||||
|
||||
const skills = await searchSkills(params.query, params.limit ?? 10);
|
||||
|
||||
if (skills.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: `No skills found for '${params.query}'` }],
|
||||
details: { count: 0, skills: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Found ${skills.length} skills matching '${params.query}':\n`);
|
||||
|
||||
for (let i = 0; i < skills.length; i++) {
|
||||
const skill = skills[i]!;
|
||||
const installs = formatInstalls(skill.installs);
|
||||
lines.push(`${i + 1}. ${skill.name} (${skill.source})${installs ? ` — ${installs}` : ""}`);
|
||||
}
|
||||
|
||||
lines.push("\nInstall a skill with: fn_skills_install({ source: \"<owner/repo>\", skill: \"<name>\" })");
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: {
|
||||
count: skills.length,
|
||||
skills: skills.map((s) => ({
|
||||
name: s.name,
|
||||
source: s.source,
|
||||
installs: s.installs,
|
||||
installCommand: `fn skills install ${s.source} --skill ${s.name}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── fn_skills_install ─────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_skills_install",
|
||||
label: "FN: Install Skill",
|
||||
description:
|
||||
"Install an agent skill from skills.sh into the current project. " +
|
||||
"Downloads skill files into the project's skill directories (.pi/skills/, .agents/skills/). " +
|
||||
"The skill becomes available to AI agents in subsequent sessions.",
|
||||
promptSnippet: "Install a skill from skills.sh into the current project",
|
||||
promptGuidelines: [
|
||||
"Use fn_skills_install after fn_skills_search to install a discovered skill",
|
||||
"The source is in owner/repo format (e.g., 'firebase/agent-skills')",
|
||||
"Specify the skill name to install a specific skill, or omit to install all from the source",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
source: Type.String({
|
||||
description: "GitHub source in owner/repo format (e.g., 'firebase/agent-skills')",
|
||||
}),
|
||||
skill: Type.Optional(
|
||||
Type.String({
|
||||
description: "Specific skill name to install (e.g., 'firebase-basics'). Omit to install all skills from the source.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
// Validate source format
|
||||
if (!/^[^/]+\/[^/]+$/.test(params.source)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Invalid source format: '${params.source}'. Use owner/repo format (e.g., 'firebase/agent-skills').`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: { error: "Invalid source format" },
|
||||
};
|
||||
}
|
||||
|
||||
// Build npx skills add arguments
|
||||
const npxArgs = ["skills", "add", params.source];
|
||||
|
||||
if (params.skill) {
|
||||
npxArgs.push("--skill", params.skill);
|
||||
}
|
||||
|
||||
// Non-interactive mode (-y) targeting pi agent (-a pi)
|
||||
npxArgs.push("-y", "-a", "pi");
|
||||
|
||||
// Execute via spawn
|
||||
const child = spawn("npx", npxArgs, {
|
||||
cwd: ctx.cwd,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
child.on("exit", (code) => {
|
||||
resolve(code ?? 1);
|
||||
});
|
||||
child.on("error", () => {
|
||||
resolve(1);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
// Always dispose the child process
|
||||
child.kill();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to install skill: ${stderr || "npx skills add exited with code " + exitCode}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
details: { exitCode, stderr },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Installed skill from ${params.source}. Skills are discovered from .pi/skills/ and .agents/skills/. The skill will be available in future agent sessions.`,
|
||||
},
|
||||
],
|
||||
details: { source: params.source, skill: params.skill ?? "all" },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── /fn command — start the dashboard + engine ───────────────────
|
||||
|
||||
let dashboardProcess: ChildProcess | null = null;
|
||||
|
||||
Reference in New Issue
Block a user