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:
5
.changeset/four-oranges-film.md
Normal file
5
.changeset/four-oranges-film.md
Normal 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.
|
||||
@@ -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`.
|
||||
|
||||

|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user