feat(FN-1790): add dashboard skills API client wrappers
- Add fetchDiscoveredSkills() to fetch all discovered skills with enabled state - Add toggleExecutionSkill() to toggle skill enabled/disabled state - Add fetchSkillsCatalog() to search the skills.sh catalog with query and limit - Export skills types (DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult) for use by hooks/components - Add comprehensive test coverage for all three API functions
This commit is contained in:
272
packages/dashboard/app/api-skills.test.ts
Normal file
272
packages/dashboard/app/api-skills.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
fetchDiscoveredSkills,
|
||||
toggleExecutionSkill,
|
||||
fetchSkillsCatalog,
|
||||
type DiscoveredSkill,
|
||||
type CatalogFetchResult,
|
||||
type ToggleSkillResult,
|
||||
} from "./api";
|
||||
|
||||
function mockFetchResponse(
|
||||
ok: boolean,
|
||||
body: unknown,
|
||||
status = ok ? 200 : 500,
|
||||
contentType = "application/json",
|
||||
) {
|
||||
const bodyText = JSON.stringify(body);
|
||||
return Promise.resolve({
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? "OK" : "Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? contentType : null,
|
||||
},
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(bodyText),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
describe("fetchDiscoveredSkills", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("fetches discovered skills without projectId", async () => {
|
||||
const skills: DiscoveredSkill[] = [
|
||||
{
|
||||
id: "test-package::skills/test-skill/SKILL.md",
|
||||
name: "test-skill",
|
||||
path: "/path/to/skills/test-skill/SKILL.md",
|
||||
relativePath: "skills/test-skill/SKILL.md",
|
||||
enabled: true,
|
||||
metadata: {
|
||||
source: "test-package",
|
||||
scope: "project",
|
||||
origin: "package",
|
||||
},
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { skills }));
|
||||
|
||||
const result = await fetchDiscoveredSkills();
|
||||
|
||||
expect(result).toEqual(skills);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/skills/discovered",
|
||||
expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches discovered skills with projectId", async () => {
|
||||
const skills: DiscoveredSkill[] = [];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { skills }));
|
||||
|
||||
await fetchDiscoveredSkills("proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/skills/discovered?projectId=proj_123",
|
||||
expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Failed to discover skills" }, 500),
|
||||
);
|
||||
|
||||
await expect(fetchDiscoveredSkills()).rejects.toThrow("Failed to discover skills");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleExecutionSkill", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("toggles skill without projectId", async () => {
|
||||
const result: ToggleSkillResult = {
|
||||
settingsPath: "skills",
|
||||
pattern: "+test-skill",
|
||||
targetFile: "/path/to/settings.json",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const output = await toggleExecutionSkill(
|
||||
"test-package::skills/test-skill/SKILL.md",
|
||||
true,
|
||||
);
|
||||
|
||||
expect(output).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/skills/execution",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
skillId: "test-package::skills/test-skill/SKILL.md",
|
||||
enabled: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles skill with projectId", async () => {
|
||||
const result: ToggleSkillResult = {
|
||||
settingsPath: "packages[].skills",
|
||||
pattern: "-test-skill",
|
||||
targetFile: "/path/to/settings.json",
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
await toggleExecutionSkill(
|
||||
"test-package::skills/test-skill/SKILL.md",
|
||||
false,
|
||||
"proj_456",
|
||||
);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/skills/execution?projectId=proj_456",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
skillId: "test-package::skills/test-skill/SKILL.md",
|
||||
enabled: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Skill not found" }, 404),
|
||||
);
|
||||
|
||||
await expect(
|
||||
toggleExecutionSkill("invalid-skill", true),
|
||||
).rejects.toThrow("Skill not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchSkillsCatalog", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("fetches catalog with default parameters", async () => {
|
||||
const result: CatalogFetchResult = {
|
||||
entries: [
|
||||
{
|
||||
id: "skill-1",
|
||||
slug: "skill-one",
|
||||
name: "Skill One",
|
||||
description: "A test skill",
|
||||
installation: {
|
||||
installed: false,
|
||||
matchingSkillIds: [],
|
||||
matchingPaths: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
auth: {
|
||||
mode: "unauthenticated",
|
||||
tokenPresent: false,
|
||||
fallbackUsed: false,
|
||||
},
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const output = await fetchSkillsCatalog();
|
||||
|
||||
expect(output).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/skills/catalog",
|
||||
expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches catalog with query and limit", async () => {
|
||||
const result: CatalogFetchResult = {
|
||||
entries: [],
|
||||
auth: {
|
||||
mode: "authenticated",
|
||||
tokenPresent: true,
|
||||
fallbackUsed: false,
|
||||
},
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
await fetchSkillsCatalog("react", 10);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/skills/catalog?q=react&limit=10",
|
||||
expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches catalog with projectId", async () => {
|
||||
const result: CatalogFetchResult = {
|
||||
entries: [],
|
||||
auth: {
|
||||
mode: "unauthenticated",
|
||||
tokenPresent: false,
|
||||
fallbackUsed: false,
|
||||
},
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
await fetchSkillsCatalog(undefined, undefined, "proj_789");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/skills/catalog?projectId=proj_789",
|
||||
expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("combines query, limit, and projectId", async () => {
|
||||
const result: CatalogFetchResult = {
|
||||
entries: [],
|
||||
auth: {
|
||||
mode: "unauthenticated",
|
||||
tokenPresent: false,
|
||||
fallbackUsed: false,
|
||||
},
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
await fetchSkillsCatalog("typescript", 5, "proj_abc");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/skills/catalog?q=typescript&limit=5&projectId=proj_abc",
|
||||
expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Upstream request timed out" }, 500),
|
||||
);
|
||||
|
||||
await expect(fetchSkillsCatalog()).rejects.toThrow(
|
||||
"Upstream request timed out",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,10 @@ import type {
|
||||
} from "@fusion/core";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
||||
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult } from "@fusion/dashboard";
|
||||
|
||||
// Re-export skills types for use by hooks and components
|
||||
export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult };
|
||||
|
||||
function looksLikeHtml(body: string): boolean {
|
||||
const trimmed = body.trim();
|
||||
@@ -4905,6 +4909,39 @@ export async function reloadPlugin(id: string, projectId?: string): Promise<Plug
|
||||
});
|
||||
}
|
||||
|
||||
// ── Skills Management ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch all discovered skills with their enabled state */
|
||||
export async function fetchDiscoveredSkills(projectId?: string): Promise<DiscoveredSkill[]> {
|
||||
const response = await api<{ skills: DiscoveredSkill[] }>(withProjectId("/skills/discovered", projectId));
|
||||
return response.skills;
|
||||
}
|
||||
|
||||
/** Toggle a skill's enabled/disabled state */
|
||||
export async function toggleExecutionSkill(
|
||||
skillId: string,
|
||||
enabled: boolean,
|
||||
projectId?: string,
|
||||
): Promise<ToggleSkillResult> {
|
||||
return api<ToggleSkillResult>(withProjectId("/skills/execution", projectId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ skillId, enabled }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch the skills.sh catalog */
|
||||
export async function fetchSkillsCatalog(
|
||||
query?: string,
|
||||
limit?: number,
|
||||
projectId?: string,
|
||||
): Promise<CatalogFetchResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set("q", query);
|
||||
if (limit !== undefined) params.set("limit", String(limit));
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<CatalogFetchResult>(withProjectId(`/skills/catalog${suffix}`, projectId));
|
||||
}
|
||||
|
||||
// ── Chat API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChatSessionListResponse {
|
||||
|
||||
Reference in New Issue
Block a user