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
This commit is contained in:
gsxdsm
2026-06-02 18:29:25 -07:00
parent 577ce12a18
commit 5c4c765134
12 changed files with 736 additions and 21 deletions

View File

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

View File

@@ -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 <owner/repo> -y -a pi`, with `--skill <slug>` 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 <source> -y -a pi`
- Appends `--skill <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.

View File

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

View File

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

View File

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

View File

@@ -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<string | null>(null);
const [catalogEntries, setCatalogEntries] = useState<CatalogEntry[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [installingCatalogEntryId, setInstallingCatalogEntryId] = useState<string | null>(null);
// Skill content viewing state
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(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) {
</div>
) : (
<div className="skills-view-grid">
{catalogEntries.map((entry) => (
<div key={entry.id} className="skills-view-card">
<h4 className="skills-view-card-title">{entry.name}</h4>
{entry.description && (
<p className="skills-view-card-description">{entry.description}</p>
)}
{entry.tags && entry.tags.length > 0 && (
<div className="skills-view-card-tags">
{entry.tags.map((tag) => (
<span key={tag} className="badge badge--sm">
{tag}
</span>
))}
{catalogEntries.map((entry) => {
const source = entry.repo?.trim();
const canInstall = Boolean(source);
const isInstalling = installingCatalogEntryId === entry.id;
return (
<div key={entry.id} className="skills-view-card">
<div className="skills-view-card-header">
<h4 className="skills-view-card-title">{entry.name}</h4>
{canInstall ? (
<button
type="button"
className="btn btn-sm skills-view-card-install"
onClick={() => void handleInstallCatalogSkill(entry)}
disabled={isInstalling}
aria-label={`Install ${entry.name}`}
>
{isInstalling ? <Loader2 size={14} className="spin" /> : null}
{isInstalling ? "Installing…" : "Install"}
</button>
) : null}
</div>
)}
{entry.installs !== undefined && (
<span className="skills-view-card-installs">
{entry.installs.toLocaleString()} installs
</span>
)}
</div>
))}
{entry.description && (
<p className="skills-view-card-description">{entry.description}</p>
)}
{entry.tags && entry.tags.length > 0 && (
<div className="skills-view-card-tags">
{entry.tags.map((tag) => (
<span key={tag} className="badge badge--sm">
{tag}
</span>
))}
</div>
)}
{entry.installs !== undefined && (
<span className="skills-view-card-installs">
{entry.installs.toLocaleString()} installs
</span>
)}
</div>
);
})}
</div>
)}
</section>

View File

@@ -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(<SkillsView addToast={mockAddToast} onClose={onClose} />);
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(<SkillsView addToast={mockAddToast} onClose={onClose} />);
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(<SkillsView addToast={mockAddToast} onClose={onClose} />);
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(<SkillsView projectId={projectId} addToast={mockAddToast} onClose={onClose} />);

View File

@@ -96,6 +96,7 @@ function createMockSkillsAdapter(overrides?: Partial<SkillsAdapter>): 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();

View File

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

View File

@@ -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>): 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" });
});
});

View File

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

View File

@@ -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<ToggleSkillResult>;
/**
* Install a skill from skills.sh into the current project.
*/
installSkill(input: { source: string; skill?: string; cwd: string }): Promise<InstallSkillResult>;
/**
* 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<string> {
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<never>((_, 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<DiscoveredSkill[]> {
@@ -430,6 +479,60 @@ export function createSkillsAdapter(options: {
}
},
async installSkill(input: { source: string; skill?: string; cwd: string }): Promise<InstallSkillResult> {
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<CatalogFetchResult | UpstreamError> {
const { limit, query } = input;
const boundedLimit = Math.min(Math.max(1, limit), 100);