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

@@ -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) {

View File

@@ -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);

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/.`,
);
}

View File

@@ -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;