feat(webui): add skills marketplace
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
completeProviderOAuth,
|
||||
createModelConfiguration,
|
||||
createProviderSettings,
|
||||
deleteSkill,
|
||||
deleteModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
fetchCliApps,
|
||||
fetchInstalledCliApps,
|
||||
fetchMcpPresets,
|
||||
fetchMarketplaceSkillTrends,
|
||||
fetchNanobotFeatures,
|
||||
fetchProviderModels,
|
||||
fetchSessionAutomations,
|
||||
@@ -21,9 +23,11 @@ import {
|
||||
fetchSidebarState,
|
||||
fetchSkillDetail,
|
||||
fetchSkills,
|
||||
fetchTrendingMarketplaceSkills,
|
||||
fetchWebuiThread,
|
||||
fetchWorkspaces,
|
||||
importMcpConfig,
|
||||
installMarketplaceSkill,
|
||||
listSessions,
|
||||
listSlashCommands,
|
||||
loginProviderOAuth,
|
||||
@@ -35,6 +39,7 @@ import {
|
||||
runCliAppAction,
|
||||
runMcpPresetAction,
|
||||
saveCustomMcpServer,
|
||||
searchMarketplaceSkills,
|
||||
startApiService,
|
||||
stopApiService,
|
||||
cancelChannelConnect,
|
||||
@@ -49,6 +54,7 @@ import {
|
||||
updateNetworkSafetySettings,
|
||||
updateProviderSettings,
|
||||
updateSettings,
|
||||
updateSkillEnabled,
|
||||
updateWebSearchSettings,
|
||||
validateChannel,
|
||||
} from "@/lib/api";
|
||||
@@ -287,6 +293,72 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes skills.sh search queries", async () => {
|
||||
await searchMarketplaceSkills("tok", "React & testing");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/search?q=React+%26+testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches the skills.sh 24-hour leaderboard", async () => {
|
||||
await fetchTrendingMarketplaceSkills("tok");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/trending",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches skills.sh trend history independently", async () => {
|
||||
await fetchMarketplaceSkillTrends("tok", [
|
||||
"vercel-labs/skills/find-skills",
|
||||
"acme/skills/react",
|
||||
]);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills&id=acme%2Fskills%2Freact",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes skills.sh install coordinates", async () => {
|
||||
await installMarketplaceSkill("tok", "vercel-labs/agent-skills", "react-testing");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/install?source=vercel-labs%2Fagent-skills&skill=react-testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates and deletes installed skills with encoded names", async () => {
|
||||
await updateSkillEnabled("tok", "custom skill", false);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/update?name=custom+skill&enabled=false",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
|
||||
await deleteSkill("tok", "custom skill");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/delete?name=custom+skill",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes websocket keys when deleting a session", async () => {
|
||||
await deleteSession("tok", "websocket:chat-1");
|
||||
|
||||
|
||||
@@ -384,20 +384,39 @@ describe("App layout", () => {
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||
{
|
||||
name: "cron",
|
||||
description: "Schedule reminders.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
},
|
||||
{
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/github": {
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
requirements: {
|
||||
@@ -406,8 +425,48 @@ describe("App layout", () => {
|
||||
missing_bins: ["gh"],
|
||||
missing_env: [],
|
||||
},
|
||||
install_options: [{
|
||||
id: "brew",
|
||||
kind: "brew",
|
||||
label: "Install GitHub CLI (brew)",
|
||||
command: "brew install gh",
|
||||
}],
|
||||
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
|
||||
},
|
||||
"/api/webui/skills/update?name=github&enabled=false": {
|
||||
skills: [
|
||||
{
|
||||
name: "cron",
|
||||
description: "Schedule reminders.",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
deletable: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "github",
|
||||
description: "Work with GitHub.",
|
||||
source: "builtin",
|
||||
enabled: false,
|
||||
deletable: false,
|
||||
available: false,
|
||||
unavailable_reason: "CLI: gh",
|
||||
},
|
||||
{
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
last_action: {
|
||||
name: "github",
|
||||
enabled: false,
|
||||
deleted: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
@@ -419,9 +478,12 @@ describe("App layout", () => {
|
||||
fireEvent.click(skillsButton);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Skills" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Search installed skills" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Custom" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Built-in" })).toBeInTheDocument();
|
||||
expect(screen.getByText("cron")).toBeInTheDocument();
|
||||
expect(screen.getByText("github")).toBeInTheDocument();
|
||||
expect(screen.getByText("Missing: CLI: gh")).toBeInTheDocument();
|
||||
expect(screen.getByText("Needs setup")).toBeInTheDocument();
|
||||
expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", { name: "Skills" })).toHaveAttribute(
|
||||
@@ -439,11 +501,186 @@ describe("App layout", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open details for github" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Unavailable reason")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CLI: gh").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Missing CLI")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Raw SKILL.md"));
|
||||
expect(screen.getByText("Setup required")).toBeInTheDocument();
|
||||
expect(screen.getByText("brew install gh")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Missing CLI")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Check again" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Skill instructions"));
|
||||
expect(screen.getByText(/Use GitHub CLI/)).toBeInTheDocument();
|
||||
const enabledSwitch = screen.getByRole("switch", { name: "Disable github" });
|
||||
expect(enabledSwitch).toHaveAttribute("aria-checked", "true");
|
||||
fireEvent.click(enabledSwitch);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("switch", { name: "Enable github" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a custom skill from its detail sheet", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/custom-skill": {
|
||||
name: "custom-skill",
|
||||
description: "A workspace skill.",
|
||||
source: "workspace",
|
||||
enabled: true,
|
||||
deletable: true,
|
||||
available: true,
|
||||
requirements: {
|
||||
bins: [],
|
||||
env: [],
|
||||
missing_bins: [],
|
||||
missing_env: [],
|
||||
},
|
||||
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
|
||||
},
|
||||
"/api/webui/skills/delete?name=custom-skill": {
|
||||
skills: [],
|
||||
last_action: {
|
||||
name: "custom-skill",
|
||||
enabled: false,
|
||||
deleted: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Open details for custom-skill" }),
|
||||
);
|
||||
expect(await screen.findByRole("heading", { name: "custom-skill" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(screen.getByRole("heading", { name: "Delete custom-skill?" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete skill" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Open details for custom-skill" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("No matching skills.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("discovers and installs a skill from skills.sh", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
|
||||
"/api/settings/mcp-presets": { presets: [], installed_count: 0 },
|
||||
"/api/webui/skills": {
|
||||
skills: [
|
||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||
],
|
||||
},
|
||||
"/api/webui/skills/trending": {
|
||||
period: "24h",
|
||||
install_supported: true,
|
||||
skills: [{
|
||||
id: "vercel-labs/skills/find-skills",
|
||||
skill_id: "find-skills",
|
||||
name: "find-skills",
|
||||
source: "vercel-labs/skills",
|
||||
installs: 14_481,
|
||||
url: "https://skills.sh/vercel-labs/skills/find-skills",
|
||||
installed: false,
|
||||
rank: 18,
|
||||
}],
|
||||
},
|
||||
"/api/webui/skills/trends?id=vercel-labs%2Fskills%2Ffind-skills": {
|
||||
trends: {
|
||||
"vercel-labs/skills/find-skills": [20, 32, 28, 45, 41, 50, 62, 58],
|
||||
},
|
||||
},
|
||||
"/api/webui/skills/search?q=React": {
|
||||
query: "React",
|
||||
install_supported: true,
|
||||
skills: [{
|
||||
id: "acme/agent-skills/react-testing",
|
||||
skill_id: "react-testing",
|
||||
name: "React Testing",
|
||||
source: "acme/agent-skills",
|
||||
installs: 42,
|
||||
url: "https://skills.sh/acme/agent-skills/react-testing",
|
||||
installed: false,
|
||||
}],
|
||||
},
|
||||
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
|
||||
trends: { "acme/agent-skills/react-testing": [] },
|
||||
},
|
||||
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing": {
|
||||
skills: [
|
||||
{
|
||||
name: "react-testing",
|
||||
description: "Test React apps.",
|
||||
source: "workspace",
|
||||
available: true,
|
||||
},
|
||||
{ name: "cron", description: "Schedule reminders.", source: "builtin", available: true },
|
||||
],
|
||||
last_action: {
|
||||
installed: true,
|
||||
already_installed: false,
|
||||
name: "react-testing",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Skills" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "Discover" }));
|
||||
expect(await screen.findByRole("heading", { name: "Trending today" })).toBeInTheDocument();
|
||||
expect(screen.getByText("find-skills")).toBeInTheDocument();
|
||||
expect(screen.getByText(/14,481 installs \/ 24h/)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole("img", { name: "8-week install trend" }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search skills.sh" }), {
|
||||
target: { value: "React" },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("React Testing")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Install React Testing?" }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/webui/skills/install?source=acme%2Fagent-skills&skill=react-testing",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: expect.any(String) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: "Installed" })).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
|
||||
expect(screen.getByText("react-testing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens Automations from the main sidebar", async () => {
|
||||
|
||||
@@ -1478,12 +1478,29 @@ describe("ThreadComposer", () => {
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
skills={[{
|
||||
name: skillName,
|
||||
description: "Fetch and summarize the latest AI research papers every day",
|
||||
source: "builtin",
|
||||
available: true,
|
||||
}]}
|
||||
skills={[
|
||||
{
|
||||
name: skillName,
|
||||
description: "Fetch and summarize the latest AI research papers every day",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "arxiv-disabled",
|
||||
description: "Disabled research workflow",
|
||||
source: "builtin",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: "arxiv-unavailable",
|
||||
description: "Unavailable research workflow",
|
||||
source: "builtin",
|
||||
enabled: true,
|
||||
available: false,
|
||||
},
|
||||
]}
|
||||
slashCommands={COMMANDS}
|
||||
/>,
|
||||
);
|
||||
@@ -1499,6 +1516,8 @@ describe("ThreadComposer", () => {
|
||||
const name = within(option).getByText(skillName);
|
||||
expect(name).not.toHaveClass("truncate");
|
||||
expect(within(option).queryByText(`$${skillName}`)).not.toBeInTheDocument();
|
||||
expect(within(palette).queryByText("arxiv-disabled")).not.toBeInTheDocument();
|
||||
expect(within(palette).queryByText("arxiv-unavailable")).not.toBeInTheDocument();
|
||||
expect(within(palette).queryByText("/model")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
|
||||
Reference in New Issue
Block a user