feat(FN-1908): merge fusion/fn-1908
This commit is contained in:
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/.`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user