From 5c4c765134f2bfe48f71620c939c27a0107ed05d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 18:29:25 -0700 Subject: [PATCH] FN-5904: feat(FN-5904): add browse-and-install flow for skills.sh catalog in Skills view Add end-to-end browse-and-install flow for skills.sh catalog entries in the dashboard Skills view, enabling users to install skills directly from the catalog UI. - Add POST /api/skills/install route with source validation and scoped project cwd - Add installSkill method to SkillsAdapter using supervised npx skills add spawn - Add installSkill client API function and Install button on catalog cards - Refresh discovered skills list automatically after successful install - Handle install errors with structured error codes (invalid_source, install_failed, install_timeout, spawn_error) - Add card header layout with install button, disabled state, and loading spinner - Add comprehensive tests: adapter unit tests, route integration tests, component tests - Add changeset for @runfusion/fusion minor bump - Update dashboard-guide.md with install endpoint documentation Files changed: .changeset/four-oranges-film.md | 5 + docs/dashboard-guide.md | 45 +++++++ packages/dashboard/app/__tests__/api-skills.test.ts | 46 +++++++ packages/dashboard/app/api/legacy.ts | 12 ++ packages/dashboard/app/components/SkillsView.css | 16 +++ packages/dashboard/app/components/SkillsView.tsx | 83 +++++++++--- packages/dashboard/app/components/__tests__/SkillsView.test.tsx | 72 +++++++++++ packages/dashboard/src/__tests__/routes-skills.test.ts | 67 ++++++++++ packages/dashboard/src/__tests__/skills-adapter.test.ts | 113 ++++++++++++++++ packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts | 144 +++++++++++++++++++++ packages/dashboard/src/routes/register-agent-skills-routes.ts | 51 ++++++++ packages/dashboard/src/skills-adapter.ts | 103 +++++++++++++++ 12 files changed, 736 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-5904 Fusion-Task-Lineage: 338d53e3-baba-4c6c-9173-8e214242403c --- .changeset/four-oranges-film.md | 5 + docs/dashboard-guide.md | 45 ++++++ .../app/__tests__/api-skills.test.ts | 46 ++++++ packages/dashboard/app/api/legacy.ts | 12 ++ .../dashboard/app/components/SkillsView.css | 16 ++ .../dashboard/app/components/SkillsView.tsx | 83 +++++++--- .../components/__tests__/SkillsView.test.tsx | 72 +++++++++ .../src/__tests__/routes-skills.test.ts | 67 ++++++++ .../src/__tests__/skills-adapter.test.ts | 113 ++++++++++++++ .../register-agent-skills-routes.test.ts | 144 ++++++++++++++++++ .../routes/register-agent-skills-routes.ts | 51 +++++++ packages/dashboard/src/skills-adapter.ts | 103 +++++++++++++ 12 files changed, 736 insertions(+), 21 deletions(-) create mode 100644 .changeset/four-oranges-film.md create mode 100644 packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts diff --git a/.changeset/four-oranges-film.md b/.changeset/four-oranges-film.md new file mode 100644 index 000000000..4f38d4075 --- /dev/null +++ b/.changeset/four-oranges-film.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add a dashboard browse-and-install flow for skills.sh catalog entries, including the new `POST /api/skills/install` API route and Skills view install actions that refresh discovered skills after a successful install. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d594cdee0..99aedc9ce 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -756,6 +756,8 @@ For setup prerequisites, security caveats for tokenized URLs/QR links, and troub ## Skills API +The Skills view now supports the full browse-and-install loop for skills.sh entries: use **Skills Catalog** to search the catalog, click **Install** on any card with a source repository, and the dashboard will run the same installer as the CLI (`npx skills add -y -a pi`, with `--skill ` when applicable). On success, the view refreshes **Discovered Skills** immediately so the newly installed skill appears without a page reload. + The Skills API provides endpoints for managing execution skills. Skills are toggled via project-scoped settings in `.fusion/settings.json`. ![Skills view](./screenshots/skills-view.png) @@ -871,6 +873,49 @@ Toggle a skill's enabled/disabled state. { "error": "Skills adapter not configured", "code": "adapter_not_configured" } ``` +### POST /api/skills/install + +Install a catalog skill into the current project. + +**Request Body:** +```json +{ + "source": "owner/repo", + "skill": "example-skill" +} +``` + +**Behavior:** +- Validates `source` in `owner/repo` format before spawning anything +- Runs `npx skills add -y -a pi` +- Appends `--skill ` when `skill` is provided +- Uses the scoped project root as `cwd`, so installed files land in the current project's skill directories + +**Response:** `200 OK` +```json +{ + "success": true +} +``` + +**Error Responses:** +- `400 Bad Request` — missing source + ```json + { "error": "source is required", "code": "invalid_body" } + ``` +- `400 Bad Request` — malformed source + ```json + { "error": "Invalid source format. Use owner/repo.", "code": "invalid_source" } + ``` +- `404 Not Found` — adapter not configured + ```json + { "error": "Skills adapter not configured", "code": "adapter_not_configured" } + ``` +- `502 Bad Gateway` — installer failed/timed out/could not start + ```json + { "error": "installer failed", "code": "install_failed" } + ``` + ### GET /api/skills/catalog Fetch the skills.sh catalog with optional authentication. diff --git a/packages/dashboard/app/__tests__/api-skills.test.ts b/packages/dashboard/app/__tests__/api-skills.test.ts index 8b1ff7b00..b5e8c26de 100644 --- a/packages/dashboard/app/__tests__/api-skills.test.ts +++ b/packages/dashboard/app/__tests__/api-skills.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { fetchDiscoveredSkills, toggleExecutionSkill, + installSkill, fetchSkillsCatalog, type DiscoveredSkill, type CatalogFetchResult, @@ -156,6 +157,51 @@ describe("toggleExecutionSkill", () => { }); }); +describe("installSkill", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("posts install requests without projectId", async () => { + globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { success: true })); + + const output = await installSkill("owner/repo", "skill-name"); + + expect(output).toEqual({ success: true }); + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/skills/install", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ source: "owner/repo", skill: "skill-name" }), + }), + ); + }); + + it("includes projectId in install requests", async () => { + globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { success: true })); + + await installSkill("owner/repo", undefined, "proj_123"); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "/api/skills/install?projectId=proj_123", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ source: "owner/repo", skill: undefined }), + }), + ); + }); + + it("propagates install errors", async () => { + globalThis.fetch = vi.fn().mockReturnValue( + mockFetchResponse(false, { error: "installer failed", code: "install_failed" }, 502), + ); + + await expect(installSkill("owner/repo", undefined)).rejects.toThrow("installer failed"); + }); +}); + describe("fetchSkillsCatalog", () => { const originalFetch = globalThis.fetch; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 6ac1f4c61..aa40bd052 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -8779,6 +8779,18 @@ export async function toggleExecutionSkill( }); } +/** Install a catalog skill from skills.sh */ +export async function installSkill( + source: string, + skill: string | undefined, + projectId?: string, +): Promise<{ success: true }> { + return api<{ success: true }>(withProjectId("/skills/install", projectId), { + method: "POST", + body: JSON.stringify({ source, skill }), + }); +} + /** Fetch the skills.sh catalog */ export async function fetchSkillsCatalog( query?: string, diff --git a/packages/dashboard/app/components/SkillsView.css b/packages/dashboard/app/components/SkillsView.css index e5b3f3229..7fd30eed1 100644 --- a/packages/dashboard/app/components/SkillsView.css +++ b/packages/dashboard/app/components/SkillsView.css @@ -199,6 +199,18 @@ border-color: var(--text-muted); } +.skills-view-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-sm); +} + +.skills-view-card-install { + align-self: flex-start; + flex-shrink: 0; +} + .skills-view-card-title { font-weight: 600; color: var(--text); @@ -410,6 +422,10 @@ grid-template-columns: 1fr; } + .skills-view-card-header { + flex-wrap: wrap; + } + .skills-view-item { padding: var(--space-md); min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs)); diff --git a/packages/dashboard/app/components/SkillsView.tsx b/packages/dashboard/app/components/SkillsView.tsx index 3d4b90082..066886ed7 100644 --- a/packages/dashboard/app/components/SkillsView.tsx +++ b/packages/dashboard/app/components/SkillsView.tsx @@ -4,6 +4,7 @@ import { Wrench, RefreshCw, X, ChevronRight, ChevronDown, AlertCircle, Loader2 } import { fetchDiscoveredSkills, toggleExecutionSkill, + installSkill, fetchSkillsCatalog, fetchSkillContent, } from "../api"; @@ -27,6 +28,7 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) { const [catalogError, setCatalogError] = useState(null); const [catalogEntries, setCatalogEntries] = useState([]); const [searchQuery, setSearchQuery] = useState(""); + const [installingCatalogEntryId, setInstallingCatalogEntryId] = useState(null); // Skill content viewing state const [selectedSkillId, setSelectedSkillId] = useState(null); @@ -164,6 +166,25 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) { } }, [projectId, addToast]); + const handleInstallCatalogSkill = useCallback(async (entry: CatalogEntry) => { + const source = entry.repo?.trim(); + if (!source || installingCatalogEntryId === entry.id) { + return; + } + + setInstallingCatalogEntryId(entry.id); + try { + await installSkill(source, entry.slug || entry.name, projectId); + addToast(`Installed ${entry.name}`, "success"); + await loadDiscoveredSkills(); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to install skill"; + addToast(`Failed to install ${entry.name}: ${message}`, "error"); + } finally { + setInstallingCatalogEntryId((current) => (current === entry.id ? null : current)); + } + }, [addToast, installingCatalogEntryId, loadDiscoveredSkills, projectId]); + const loadSkillContent = useCallback(async (skillId: string) => { setIsLoadingContent(true); setContentError(null); @@ -409,28 +430,48 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) { ) : (
- {catalogEntries.map((entry) => ( -
-

{entry.name}

- {entry.description && ( -

{entry.description}

- )} - {entry.tags && entry.tags.length > 0 && ( -
- {entry.tags.map((tag) => ( - - {tag} - - ))} + {catalogEntries.map((entry) => { + const source = entry.repo?.trim(); + const canInstall = Boolean(source); + const isInstalling = installingCatalogEntryId === entry.id; + + return ( +
+
+

{entry.name}

+ {canInstall ? ( + + ) : null}
- )} - {entry.installs !== undefined && ( - - {entry.installs.toLocaleString()} installs - - )} -
- ))} + {entry.description && ( +

{entry.description}

+ )} + {entry.tags && entry.tags.length > 0 && ( +
+ {entry.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + {entry.installs !== undefined && ( + + {entry.installs.toLocaleString()} installs + + )} +
+ ); + })}
)} diff --git a/packages/dashboard/app/components/__tests__/SkillsView.test.tsx b/packages/dashboard/app/components/__tests__/SkillsView.test.tsx index d4fe89853..151160561 100644 --- a/packages/dashboard/app/components/__tests__/SkillsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/SkillsView.test.tsx @@ -8,12 +8,14 @@ import type { DiscoveredSkill, CatalogEntry, SkillContent } from "@fusion/dashbo vi.mock("../../api", () => ({ fetchDiscoveredSkills: vi.fn(), toggleExecutionSkill: vi.fn(), + installSkill: vi.fn(), fetchSkillsCatalog: vi.fn(), fetchSkillContent: vi.fn(), })); const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills); const mockToggleExecutionSkill = vi.mocked(apiModule.toggleExecutionSkill); +const mockInstallSkill = vi.mocked(apiModule.installSkill); const mockFetchSkillsCatalog = vi.mocked(apiModule.fetchSkillsCatalog); const mockFetchSkillContent = vi.mocked(apiModule.fetchSkillContent); @@ -55,6 +57,7 @@ describe("SkillsView", () => { slug: "test-skill", name: "Test Skill", description: "A test skill for testing", + repo: "owner/test-repo", tags: ["testing", "example"], installs: 1234, installation: { @@ -68,6 +71,7 @@ describe("SkillsView", () => { slug: "another-skill", name: "Another Skill", description: "Another example skill", + repo: "owner/another-repo", tags: ["utility"], installs: 5678, installation: { @@ -76,6 +80,19 @@ describe("SkillsView", () => { matchingPaths: ["skills/another-skill"], }, }, + { + id: "cat-003", + slug: "missing-source", + name: "Missing Source", + description: "Cannot be installed from the catalog card", + tags: ["docs"], + installs: 10, + installation: { + installed: false, + matchingSkillIds: [], + matchingPaths: [], + }, + }, ]; beforeEach(() => { @@ -86,6 +103,7 @@ describe("SkillsView", () => { pattern: "+test-skill", targetFile: "/project/.fusion/settings.json", }); + mockInstallSkill.mockResolvedValue({ success: true }); mockFetchSkillsCatalog.mockResolvedValue({ entries: mockCatalogEntries, auth: { @@ -176,6 +194,17 @@ describe("SkillsView", () => { }); }); + it("renders install buttons only for catalog entries with a source repo", async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Install Test Skill" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Install Another Skill" })).toBeTruthy(); + }); + + expect(screen.queryByRole("button", { name: "Install Missing Source" })).toBeNull(); + }); + it("shows loading state while fetching discovered skills", async () => { let resolveSkills: ((value: DiscoveredSkill[]) => void) | undefined; mockFetchDiscoveredSkills.mockImplementation( @@ -357,6 +386,49 @@ describe("SkillsView", () => { }); }); + describe("catalog install", () => { + it("installs a catalog skill and refreshes discovered skills", async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Install Test Skill" })).toBeTruthy(); + }); + + mockFetchDiscoveredSkills.mockClear(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Install Test Skill" })); + }); + + await waitFor(() => { + expect(mockInstallSkill).toHaveBeenCalledWith("owner/test-repo", "test-skill", undefined); + expect(mockFetchDiscoveredSkills).toHaveBeenCalledTimes(1); + expect(mockAddToast).toHaveBeenCalledWith("Installed Test Skill", "success"); + }); + }); + + it("shows an error toast when install fails", async () => { + mockInstallSkill.mockRejectedValue(new Error("install failed")); + + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Install Test Skill" })).toBeTruthy(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Install Test Skill" })); + }); + + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith( + expect.stringContaining("Failed to install Test Skill: install failed"), + "error", + ); + }); + }); + }); + describe("catalog search", () => { it("calls fetchSkillsCatalog with projectId when provided", async () => { render(); diff --git a/packages/dashboard/src/__tests__/routes-skills.test.ts b/packages/dashboard/src/__tests__/routes-skills.test.ts index 20276b0ed..2b97758ac 100644 --- a/packages/dashboard/src/__tests__/routes-skills.test.ts +++ b/packages/dashboard/src/__tests__/routes-skills.test.ts @@ -96,6 +96,7 @@ function createMockSkillsAdapter(overrides?: Partial): SkillsAdap targetFile: `${rootDir}/.fusion/settings.json`, }; }), + installSkill: vi.fn().mockResolvedValue({ success: true }), fetchCatalog: vi.fn().mockResolvedValue({ entries: [ { @@ -411,6 +412,72 @@ describe("Skills routes", () => { }); }); + describe("POST /api/skills/install", () => { + it("installs a skill using the scoped store root dir", async () => { + const mockAdapter = createMockSkillsAdapter({ + installSkill: vi.fn().mockResolvedValue({ success: true }), + }); + const store = new MockStore("/tmp/install-project"); + const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter }); + + const res = await request( + app, + "POST", + "/api/skills/install", + JSON.stringify({ source: "owner/repo", skill: "test-skill" }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + expect(mockAdapter.installSkill).toHaveBeenCalledWith({ + source: "owner/repo", + skill: "test-skill", + cwd: "/tmp/install-project", + }); + }); + + it("returns 400 for invalid source", async () => { + const mockAdapter = createMockSkillsAdapter(); + const store = new MockStore(); + const app = createServer(store as any, { skillsAdapter: mockAdapter as SkillsAdapter }); + + const res = await request( + app, + "POST", + "/api/skills/install", + JSON.stringify({ source: "invalid-source" }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Invalid source format. Use owner/repo.", + code: "invalid_source", + }); + expect(mockAdapter.installSkill).not.toHaveBeenCalled(); + }); + + it("returns 404 when skills adapter is not configured", async () => { + const store = new MockStore(); + const app = createServer(store as any, {}); + + const res = await request( + app, + "POST", + "/api/skills/install", + JSON.stringify({ source: "owner/repo" }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ + error: "Skills adapter not configured", + code: "adapter_not_configured", + }); + }); + }); + describe("GET /api/skills/catalog", () => { it("returns catalog entries with installation info", async () => { const mockAdapter = createMockSkillsAdapter(); diff --git a/packages/dashboard/src/__tests__/skills-adapter.test.ts b/packages/dashboard/src/__tests__/skills-adapter.test.ts index 0f920fccd..7363a7e22 100644 --- a/packages/dashboard/src/__tests__/skills-adapter.test.ts +++ b/packages/dashboard/src/__tests__/skills-adapter.test.ts @@ -3,6 +3,8 @@ import { createSkillsAdapter, extractSkillName, computeSkillId } from "../skills import { writeFile, mkdir, access, readFile, rm } from "node:fs/promises"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; describe("createSkillsAdapter - fetchCatalog fallback behavior", () => { const originalFetch = globalThis.fetch; @@ -642,6 +644,117 @@ describe("createSkillsAdapter - toggleExecutionSkill persistence", () => { }); }); +describe("createSkillsAdapter - installSkill", () => { + it("short-circuits invalid source without spawning", async () => { + const superviseSpawnMock = vi.fn(); + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + superviseSpawn: superviseSpawnMock as never, + }); + + const result = await adapter.installSkill({ source: "invalid", cwd: "/tmp/project" }); + + expect(result).toEqual({ + error: "Invalid source format. Use owner/repo.", + code: "invalid_source", + }); + expect(superviseSpawnMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "without a specific skill", + input: { source: "owner/repo", cwd: "/tmp/project" }, + expectedArgs: ["skills", "add", "owner/repo", "-y", "-a", "pi"], + }, + { + name: "with a specific skill", + input: { source: "owner/repo", skill: "my-skill", cwd: "/tmp/project" }, + expectedArgs: ["skills", "add", "owner/repo", "--skill", "my-skill", "-y", "-a", "pi"], + }, + ])("spawns npx skills add $name", async ({ input, expectedArgs }) => { + const superviseSpawnMock = vi.fn((_command: string, _args: string[]) => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + pid: number; + }; + child.stdout = stdout; + child.stderr = stderr; + child.pid = 4242; + process.nextTick(() => { + stdout.end(); + stderr.end(); + }); + return { + pid: 4242, + pgid: null, + child, + kill: vi.fn(), + waitExit: () => Promise.resolve({ code: 0, signal: null }), + }; + }); + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + superviseSpawn: superviseSpawnMock as never, + }); + + const result = await adapter.installSkill(input); + + expect(result).toEqual({ success: true }); + expect(superviseSpawnMock).toHaveBeenCalledWith( + "npx", + expectedArgs, + expect.objectContaining({ + cwd: "/tmp/project", + shell: true, + stdio: ["ignore", "pipe", "pipe"], + maxLifetimeMs: 60_000, + }), + ); + }); + + it("returns install_failed when the installer exits non-zero", async () => { + const superviseSpawnMock = vi.fn(() => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + pid: number; + }; + child.stdout = stdout; + child.stderr = stderr; + child.pid = 4242; + process.nextTick(() => { + stderr.write("install failed\n"); + stdout.end(); + stderr.end(); + }); + return { + pid: 4242, + pgid: null, + child, + kill: vi.fn(), + waitExit: () => Promise.resolve({ code: 1, signal: null }), + }; + }); + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"), + superviseSpawn: superviseSpawnMock as never, + }); + + const result = await adapter.installSkill({ source: "owner/repo", cwd: "/tmp/project" }); + + expect(result).toEqual({ error: "install failed", code: "install_failed" }); + }); +}); + describe("extractSkillName", () => { it("normalizes Windows separators before deriving the display name", () => { expect(extractSkillName("skills\\tooling\\windows-fix", "npm")).toBe("tooling/windows-fix"); diff --git a/packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts new file mode 100644 index 000000000..5f882398f --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment node + +import express from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createApiRoutes } from "../../routes.js"; +import { request } from "../../test-request.js"; +import type { SkillsAdapter } from "../../skills-adapter.js"; + +function createStore(rootDir = "/tmp/skills-project") { + return { + getTask: vi.fn(), + listTasks: vi.fn().mockResolvedValue([]), + getSettings: vi.fn().mockResolvedValue({}), + getSettingsFast: vi.fn().mockResolvedValue({}), + getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }), + getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }), + getGlobalSettingsStore: vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })), + getRootDir: vi.fn().mockReturnValue(rootDir), + getFusionDir: vi.fn().mockReturnValue(`${rootDir}/.fusion`), + listWorkflowSteps: vi.fn().mockResolvedValue([]), + getMissionStore: vi.fn(), + on: vi.fn(), + off: vi.fn(), + } as any; +} + +function createSkillsAdapter(overrides?: Partial): SkillsAdapter { + return { + discoverSkills: vi.fn().mockResolvedValue([]), + toggleExecutionSkill: vi.fn(), + installSkill: vi.fn().mockResolvedValue({ success: true }), + fetchCatalog: vi.fn().mockResolvedValue({ + entries: [], + auth: { mode: "unauthenticated", tokenPresent: false, fallbackUsed: false }, + }), + readSkillContent: vi.fn(), + ...overrides, + } as SkillsAdapter; +} + +function app(skillsAdapter?: SkillsAdapter, rootDir?: string) { + const server = express(); + server.use(express.json()); + server.use("/api", createApiRoutes(createStore(rootDir), { skillsAdapter })); + return server; +} + +describe("register-agent-skills-routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("POST /api/skills/install installs a skill", async () => { + const skillsAdapter = createSkillsAdapter({ + installSkill: vi.fn().mockResolvedValue({ success: true }), + }); + + const res = await request( + app(skillsAdapter, "/tmp/install-root"), + "POST", + "/api/skills/install", + JSON.stringify({ source: "owner/repo", skill: "skill-name" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + expect(skillsAdapter.installSkill).toHaveBeenCalledWith({ + source: "owner/repo", + skill: "skill-name", + cwd: "/tmp/install-root", + }); + }); + + it("POST /api/skills/install returns 400 for missing source", async () => { + const skillsAdapter = createSkillsAdapter(); + + const res = await request( + app(skillsAdapter), + "POST", + "/api/skills/install", + JSON.stringify({}), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "source is required", code: "invalid_body" }); + }); + + it("POST /api/skills/install returns 400 for malformed source", async () => { + const skillsAdapter = createSkillsAdapter(); + + const res = await request( + app(skillsAdapter), + "POST", + "/api/skills/install", + JSON.stringify({ source: "bad" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: "Invalid source format. Use owner/repo.", + code: "invalid_source", + }); + expect(skillsAdapter.installSkill).not.toHaveBeenCalled(); + }); + + it("POST /api/skills/install returns 404 without a skills adapter", async () => { + const res = await request( + app(undefined), + "POST", + "/api/skills/install", + JSON.stringify({ source: "owner/repo" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ + error: "Skills adapter not configured", + code: "adapter_not_configured", + }); + }); + + it("POST /api/skills/install returns 502 for structured adapter errors", async () => { + const skillsAdapter = createSkillsAdapter({ + installSkill: vi.fn().mockResolvedValue({ + error: "installer failed", + code: "install_failed", + }), + }); + + const res = await request( + app(skillsAdapter), + "POST", + "/api/skills/install", + JSON.stringify({ source: "owner/repo" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(502); + expect(res.body).toEqual({ error: "installer failed", code: "install_failed" }); + }); +}); diff --git a/packages/dashboard/src/routes/register-agent-skills-routes.ts b/packages/dashboard/src/routes/register-agent-skills-routes.ts index 76ec46c7f..58d142350 100644 --- a/packages/dashboard/src/routes/register-agent-skills-routes.ts +++ b/packages/dashboard/src/routes/register-agent-skills-routes.ts @@ -138,6 +138,57 @@ export function registerAgentSkillsRoutes(ctx: ApiRoutesContext): void { } }); + /** + * POST /api/skills/install + * Install a catalog skill via the shared skills.sh installer. + * Body: { source: string; skill?: string } + * Query: projectId (optional) for multi-project context + * Response: { success: true } + * Error: 400 { error: string; code: "invalid_body"|"invalid_source" } + * Error: 502 { error: string; code: "spawn_error"|"install_failed"|"install_timeout" } + */ + router.post("/skills/install", async (req, res) => { + try { + const scopedStore = await getScopedStore(req); + const skillsAdapter = options?.skillsAdapter; + + if (!skillsAdapter) { + res.status(404).json({ error: "Skills adapter not configured", code: "adapter_not_configured" }); + return; + } + + const { source, skill } = req.body as { source?: string; skill?: string }; + if (typeof source !== "string" || !source.trim()) { + res.status(400).json({ error: "source is required", code: "invalid_body" }); + return; + } + + const normalizedSource = source.trim(); + if (!/^[^/]+\/[^/]+$/.test(normalizedSource)) { + res.status(400).json({ error: "Invalid source format. Use owner/repo.", code: "invalid_source" }); + return; + } + + const result = await skillsAdapter.installSkill({ + source: normalizedSource, + skill: typeof skill === "string" ? skill : undefined, + cwd: scopedStore.getRootDir(), + }); + + if ("code" in result) { + res.status(502).json(result); + return; + } + + res.json(result); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err, "Failed to install skill"); + } + }); + /** * GET /api/skills/catalog * Fetch the skills.sh catalog with optional authentication. diff --git a/packages/dashboard/src/skills-adapter.ts b/packages/dashboard/src/skills-adapter.ts index 91cf4ea14..654e801d4 100644 --- a/packages/dashboard/src/skills-adapter.ts +++ b/packages/dashboard/src/skills-adapter.ts @@ -7,6 +7,8 @@ import { access, readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises"; import { join, relative, dirname } from "node:path"; +import { superviseSpawn } from "@fusion/core"; +import type { ChildProcess } from "node:child_process"; /** * Check if a path exists asynchronously using access(). @@ -133,6 +135,17 @@ export interface UpstreamError { /** * Skills adapter interface exposed via ServerOptions. */ +export interface InstallSkillResultSuccess { + success: true; +} + +export interface InstallSkillResultError { + error: string; + code: "invalid_source" | "spawn_error" | "install_failed" | "install_timeout"; +} + +export type InstallSkillResult = InstallSkillResultSuccess | InstallSkillResultError; + export interface SkillsAdapter { /** * Discover all skills available in the project. @@ -149,6 +162,11 @@ export interface SkillsAdapter { input: { skillId: string; enabled: boolean }, ): Promise; + /** + * Install a skill from skills.sh into the current project. + */ + installSkill(input: { source: string; skill?: string; cwd: string }): Promise; + /** * Fetch the skills.sh catalog with optional authentication. */ @@ -193,6 +211,35 @@ function normalizeStoredSkillPath(path: string): string { return path.replaceAll("\\", "/").replace(/^skills\//, ""); } +function isValidInstallSource(source: string): boolean { + return /^[^/]+\/[^/]+$/.test(source); +} + +function captureStream(stream: NodeJS.ReadableStream | null | undefined): Promise { + if (!stream) { + return Promise.resolve(""); + } + + return new Promise((resolve) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + }); + stream.once("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + stream.once("close", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + }); +} + +async function waitForSupervisedExit( + child: ChildProcess, + exitPromise: Promise<{ code: number | null; signal: NodeJS.Signals | null }>, +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + const spawnError = new Promise((_, reject) => { + child.once("error", reject); + }); + return Promise.race([exitPromise, spawnError]); +} + /** * Check if a skill path is enabled in the settings. * Checks both top-level skills and package-scoped skills. @@ -255,6 +302,8 @@ export function createSkillsAdapter(options: { }; /** Project settings path helper */ getSettingsPath: (rootDir: string) => string; + /** Optional superviseSpawn seam for tests */ + superviseSpawn?: typeof superviseSpawn; }): SkillsAdapter { return { async discoverSkills(rootDir: string): Promise { @@ -430,6 +479,60 @@ export function createSkillsAdapter(options: { } }, + async installSkill(input: { source: string; skill?: string; cwd: string }): Promise { + const source = input.source.trim(); + if (!isValidInstallSource(source)) { + return { + error: "Invalid source format. Use owner/repo.", + code: "invalid_source", + }; + } + + const npxArgs = ["skills", "add", source]; + const skill = input.skill?.trim(); + if (skill) { + npxArgs.push("--skill", skill); + } + npxArgs.push("-y", "-a", "pi"); + + const runSpawn = options.superviseSpawn ?? superviseSpawn; + const supervised = runSpawn("npx", npxArgs, { + cwd: input.cwd, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + maxLifetimeMs: 60_000, + }); + + try { + const stderrPromise = captureStream(supervised.child.stderr); + const stdoutPromise = captureStream(supervised.child.stdout); + const exit = await waitForSupervisedExit(supervised.child, supervised.waitExit()); + const [stderr, stdout] = await Promise.all([stderrPromise, stdoutPromise]); + + if (exit.signal === "SIGKILL") { + return { + error: "Skill installation timed out.", + code: "install_timeout", + }; + } + + if ((exit.code ?? 1) !== 0) { + const detail = stderr.trim() || stdout.trim() || "Skill installation failed."; + return { + error: detail, + code: "install_failed", + }; + } + + return { success: true }; + } catch (error) { + return { + error: error instanceof Error ? error.message : "Failed to start skill installer.", + code: "spawn_error", + }; + } + }, + async fetchCatalog(input: { limit: number; query?: string }): Promise { const { limit, query } = input; const boundedLimit = Math.min(Math.max(1, limit), 100);