From c47374ecdf27102ffbaa28f44fb84e8ed9dd8d38 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Thu, 27 Aug 2026 16:43:38 +0800 Subject: [PATCH 1/2] feat(skills): switch marketplace to skills.sh --- .../core/src/domain/skill-management.test.ts | 2 +- packages/core/src/domain/skill-management.ts | 1 + .../src/__tests__/skills/commands.test.ts | 39 +- .../__tests__/skills/install-manager.test.ts | 17 +- .../__tests__/skills/search-parser.test.ts | 89 ---- .../skills/skills-hub-client.test.ts | 238 +++++---- .../storage/skill-library-repo.test.ts | 9 +- .../server/src/commands/skills/install.ts | 7 +- packages/server/src/commands/skills/query.ts | 12 +- packages/server/src/server.ts | 2 +- packages/server/src/skills/install-manager.ts | 30 +- packages/server/src/skills/recommendation.ts | 9 +- packages/server/src/skills/search-parser.ts | 119 ----- .../server/src/skills/skills-hub-client.ts | 467 +++++++++++++++--- .../src/domain/skill-management.ts | 8 +- packages/skill-manager/src/ports/hosts.ts | 7 +- .../repositories/skill-library-repository.ts | 7 +- .../src/services/skill-manager.test.ts | 18 +- .../src/services/skill-manager.ts | 42 +- .../workspace/actions/use-skills-panel.ts | 24 +- .../views/shared/skills-panel.test.tsx | 39 +- .../workspace/views/shared/skills-panel.tsx | 119 ++++- packages/web/src/locales/en.json | 7 +- packages/web/src/locales/zh.json | 7 +- packages/web/src/styles/components.css | 19 + .../web/src/styles/components.theme.test.ts | 7 + packages/web/src/theme/icon-theme.test.ts | 13 +- packages/web/src/theme/icon-theme.ts | 5 + 28 files changed, 894 insertions(+), 469 deletions(-) delete mode 100644 packages/server/src/__tests__/skills/search-parser.test.ts delete mode 100644 packages/server/src/skills/search-parser.ts diff --git a/packages/core/src/domain/skill-management.test.ts b/packages/core/src/domain/skill-management.test.ts index d7cac645..e756609c 100644 --- a/packages/core/src/domain/skill-management.test.ts +++ b/packages/core/src/domain/skill-management.test.ts @@ -33,7 +33,7 @@ describe("skill management domain", () => { }); it("exports stable skill library origins", () => { - expect(SKILL_LIBRARY_ORIGINS).toEqual(["builtin", "skillhub", "filesystem"]); + expect(SKILL_LIBRARY_ORIGINS).toEqual(["builtin", "skillhub", "skills-sh", "filesystem"]); }); it("exports stable mount statuses", () => { diff --git a/packages/core/src/domain/skill-management.ts b/packages/core/src/domain/skill-management.ts index c03dc3e4..4afe32ae 100644 --- a/packages/core/src/domain/skill-management.ts +++ b/packages/core/src/domain/skill-management.ts @@ -3,6 +3,7 @@ export * from "@coder-studio/skill-manager"; /** Coder Studio workspace-intelligence extension; not part of the reusable Skill Manager. */ export interface SkillRecommendationEntry { slug: string; + registryRef?: string; displayName: string; description?: string; reason: string; diff --git a/packages/server/src/__tests__/skills/commands.test.ts b/packages/server/src/__tests__/skills/commands.test.ts index 7f00ad4b..25634f46 100644 --- a/packages/server/src/__tests__/skills/commands.test.ts +++ b/packages/server/src/__tests__/skills/commands.test.ts @@ -107,6 +107,8 @@ describe("skills commands", () => { displayName: "Code Review", description: "Review code changes before merge", version: "1.3.0", + installCount: 8_674, + githubStars: 518, }, ]), } as never, @@ -129,6 +131,8 @@ describe("skills commands", () => { displayName: "Code Review", description: "Review code changes before merge", version: "1.3.0", + installCount: 8_674, + githubStars: 518, installed: true, installedVersion: "1.2.3", mountedProviderIds: ["codex"], @@ -363,11 +367,12 @@ describe("skills commands", () => { list: vi.fn(() => [ { slug: "code-review", + registryRef: "mattpocock/skills@code-review", displayName: "Code Review", description: "Review code changes before merge", version: "1.2.3", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/library/code-review", installState: "installed", installedAt: 1, @@ -439,11 +444,12 @@ describe("skills commands", () => { list: vi.fn(() => [ { slug: "code-review", + registryRef: "mattpocock/skills@code-review", displayName: "Code Review", description: "Review code changes before merge", version: "1.2.3", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/library/code-review", installState: "installed", installedAt: 1, @@ -451,11 +457,12 @@ describe("skills commands", () => { }, { slug: "security-review", + registryRef: "acme/skills@security-review", displayName: "Security Review", description: "Review security issues", version: "1.2.3", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/library/security-review", installState: "installed", installedAt: 1, @@ -517,8 +524,8 @@ describe("skills commands", () => { }, ]); expect(info).toHaveBeenCalledTimes(2); - expect(info).toHaveBeenCalledWith("code-review"); - expect(info).toHaveBeenCalledWith("security-review"); + expect(info).toHaveBeenCalledWith("code-review", "mattpocock/skills@code-review"); + expect(info).toHaveBeenCalledWith("security-review", "acme/skills@security-review"); }); it("reports unknown and error states when Skill Hub version checks cannot compare versions", async () => { @@ -534,10 +541,11 @@ describe("skills commands", () => { list: vi.fn(() => [ { slug: "missing-version", + registryRef: "acme/skills@missing-version", displayName: "Missing Version", version: "1.0.0", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/library/missing-version", installState: "installed", installedAt: 1, @@ -545,10 +553,11 @@ describe("skills commands", () => { }, { slug: "lookup-failed", + registryRef: "acme/skills@lookup-failed", displayName: "Lookup Failed", version: "1.0.0", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/library/lookup-failed", installState: "installed", installedAt: 1, @@ -1152,7 +1161,7 @@ describe("skills commands", () => { expect(get).toHaveBeenCalledWith("job-1"); }); - it("starts updates for installed Skill Hub skills only", async () => { + it("starts updates for installed skills.sh skills only", async () => { const start = vi.fn(async () => ({ jobId: "job-update-1", slug: "code-review", @@ -1164,11 +1173,12 @@ describe("skills commands", () => { skillLibraryRepo: { get: vi.fn(() => ({ slug: "code-review", + registryRef: "mattpocock/skills@code-review", displayName: "Code Review", description: "Review code changes before merge", version: "1.2.3", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/library/code-review", installState: "installed", installedAt: 1, @@ -1188,7 +1198,7 @@ describe("skills commands", () => { ); expect(result.ok).toBe(true); - expect(start).toHaveBeenCalledWith("code-review"); + expect(start).toHaveBeenCalledWith("code-review", "mattpocock/skills@code-review"); }); it("rejects update requests for non-Skill Hub skills", async () => { @@ -1223,7 +1233,7 @@ describe("skills commands", () => { expect(result.ok).toBe(false); expect(result.error).toMatchObject({ code: "skill_update_unavailable", - message: "Only installed Skill Hub skills can be updated: local-helper", + message: "Only installed skills.sh skills can be updated: local-helper", }); expect(start).not.toHaveBeenCalled(); }); @@ -1260,7 +1270,7 @@ describe("skills commands", () => { expect(result.ok).toBe(false); expect(result.error).toMatchObject({ code: "skill_update_unavailable", - message: "Only installed Skill Hub skills can be updated: code-review", + message: "Only installed skills.sh skills can be updated: code-review", }); expect(start).not.toHaveBeenCalled(); }); @@ -1272,11 +1282,12 @@ describe("skills commands", () => { skillLibraryRepo: { get: vi.fn(() => ({ slug: "code-review", + registryRef: "mattpocock/skills@code-review", displayName: "Code Review", description: "Review code changes before merge", version: "1.2.3", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/library/code-review", installState: "failed", installedAt: 1, @@ -1298,7 +1309,7 @@ describe("skills commands", () => { expect(result.ok).toBe(false); expect(result.error).toMatchObject({ code: "skill_update_unavailable", - message: "Only installed Skill Hub skills can be updated: code-review", + message: "Only installed skills.sh skills can be updated: code-review", }); expect(start).not.toHaveBeenCalled(); }); diff --git a/packages/server/src/__tests__/skills/install-manager.test.ts b/packages/server/src/__tests__/skills/install-manager.test.ts index 860ef09b..fcd3935f 100644 --- a/packages/server/src/__tests__/skills/install-manager.test.ts +++ b/packages/server/src/__tests__/skills/install-manager.test.ts @@ -46,7 +46,7 @@ async function waitForJob( } describe("SkillInstallManager", () => { - it("auto-mounts installed Skill Hub skills into installed agent skill targets", async () => { + it("auto-mounts installed skills.sh skills into installed agent skill targets", async () => { const tempDir = await mkdtemp(join(tmpdir(), "skill-install-auto-mount-")); try { const libraryRoot = join(tempDir, "library"); @@ -81,7 +81,17 @@ describe("SkillInstallManager", () => { description: "Review code changes before merge", version: "1.2.3", })), - stageInstall: vi.fn(async () => ({ tempHome: join(tempDir, "home"), exportDir })), + stageInstall: vi.fn(async () => ({ + tempHome: join(tempDir, "home"), + exportDir, + info: { + slug: "code-review", + registryRef: "mattpocock/skills@code-review", + name: "Code Review", + description: "Review code changes before merge", + version: "1.2.3", + }, + })), readStagedSkill: vi.fn(async () => "skill body"), cleanupStage: vi.fn(async () => undefined), } as never, @@ -114,7 +124,8 @@ describe("SkillInstallManager", () => { expect.objectContaining({ slug: "code-review", source: "installed", - origin: "skillhub", + origin: "skills-sh", + registryRef: "mattpocock/skills@code-review", }) ); } finally { diff --git a/packages/server/src/__tests__/skills/search-parser.test.ts b/packages/server/src/__tests__/skills/search-parser.test.ts deleted file mode 100644 index b861b9cb..00000000 --- a/packages/server/src/__tests__/skills/search-parser.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseSkillsHubSearchOutput } from "../../skills/search-parser.js"; - -describe("parseSkillsHubSearchOutput", () => { - it("extracts search rows from Skills Hub JSON output", () => { - const output = JSON.stringify([ - { - slug: "garrytan-gstack-review", - name: "review", - version: "1.2.3", - description: - "Pre-landing PR review checking SQL safety, LLM trust boundaries, conditional side effects, and structural issues.", - }, - { - slug: "frontend-design", - name: "frontend-design", - description: "Generates distinctive frontend code with strong aesthetic direction.", - }, - ]); - - expect(parseSkillsHubSearchOutput(output)).toEqual([ - { - slug: "garrytan-gstack-review", - displayName: "review", - version: "1.2.3", - description: - "Pre-landing PR review checking SQL safety, LLM trust boundaries, conditional side effects, and structural issues.", - }, - { - slug: "frontend-design", - displayName: "frontend-design", - description: "Generates distinctive frontend code with strong aesthetic direction.", - }, - ]); - }); - - it("extracts search rows from CLI text output", () => { - const output = [ - "1. code-review", - " Name: Code Review", - " Description: Review code changes before merge", - "2. security-audit", - " Name: Security Audit", - " Description: Find high-risk vulnerabilities", - ].join("\n"); - - expect(parseSkillsHubSearchOutput(output)).toEqual([ - { - slug: "code-review", - displayName: "Code Review", - description: "Review code changes before merge", - }, - { - slug: "security-audit", - displayName: "Security Audit", - description: "Find high-risk vulnerabilities", - }, - ]); - }); - - it("extracts rows from legacy Skills Hub CLI text output", () => { - const output = [ - "\u001b[1mFound 2 skills:\u001b[22m", - "", - " \u001b[90m[--]\u001b[39m \u001b[1mcode-review\u001b[22m \u001b[90mv1.0.0\u001b[39m", - " \u001b[90mThorough code review - checks correctness, security, performance\u001b[39m", - " \u001b[36mnpx @skill-hub/cli install code-review\u001b[39m \u001b[90m40 installs\u001b[39m", - "", - " \u001b[90m[--]\u001b[39m \u001b[1msecurity-review\u001b[22m \u001b[90mv2.0.0\u001b[39m", - " \u001b[90mSecurity audit and vulnerability assessment for any codebase.\u001b[39m", - " \u001b[36mnpx @skill-hub/cli install security-review\u001b[39m \u001b[90m14 installs\u001b[39m", - ].join("\n"); - - expect(parseSkillsHubSearchOutput(output)).toEqual([ - { - slug: "code-review", - displayName: "code-review", - version: "1.0.0", - description: "Thorough code review - checks correctness, security, performance", - }, - { - slug: "security-review", - displayName: "security-review", - version: "2.0.0", - description: "Security audit and vulnerability assessment for any codebase.", - }, - ]); - }); -}); diff --git a/packages/server/src/__tests__/skills/skills-hub-client.test.ts b/packages/server/src/__tests__/skills/skills-hub-client.test.ts index 56ce84fa..93bd2a34 100644 --- a/packages/server/src/__tests__/skills/skills-hub-client.test.ts +++ b/packages/server/src/__tests__/skills/skills-hub-client.test.ts @@ -1,105 +1,169 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { SkillsHubClient } from "../../skills/skills-hub-client.js"; +const SNAPSHOT_HASH = "12daafb9c4f77deb3c3303dc2e6f8a3c2a0ff7928fc004af959ba18b8bd38068"; + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +function skillSnapshot(files?: Array<{ path: string; contents: string }>) { + return { + hash: SNAPSHOT_HASH, + files: files ?? [ + { + path: "SKILL.md", + contents: [ + "---", + "name: code-review", + 'description: "Review code changes before merge"', + "---", + "", + "Review the current change.", + ].join("\n"), + }, + { path: "agents/openai.yaml", contents: "interface:\n display_name: Code Review\n" }, + ], + }; +} + describe("SkillsHubClient", () => { - it("runs search with the expected CLI arguments", async () => { - const runCommand = vi.fn(async () => ({ - stdout: JSON.stringify([ - { - slug: "code-review", - name: "Code Review", - description: "Review code changes before merge", - version: "1.2.3", - }, - ]), - stderr: "", - })); - const client = new SkillsHubClient({ runCommand }); - - const results = await client.search("review"); - - expect(runCommand).toHaveBeenCalledWith( - "npx", - ["-y", "@skill-hub/cli", "search", "review", "--limit", "20", "--json"], - expect.objectContaining({ - env: expect.objectContaining({ NO_COLOR: expect.any(String) }), - }) + it("searches skills.sh and preserves the repository coordinate", async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.hostname === "api.github.com") { + return jsonResponse({ stargazers_count: 518 }); + } + return jsonResponse({ + skills: [ + { + skillId: "code-review", + name: "code-review", + installs: 8_674, + source: "mattpocock/skills", + }, + ], + }); + }); + const client = new SkillsHubClient({ fetch: fetchMock as typeof fetch }); + + await expect(client.search("code review")).resolves.toEqual([ + { + slug: "code-review", + registryRef: "mattpocock/skills@code-review", + displayName: "code-review", + installCount: 8_674, + githubStars: 518, + }, + ]); + + const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(requestUrl.origin).toBe("https://skills.sh"); + expect(requestUrl.pathname).toBe("/api/search"); + expect(requestUrl.searchParams.get("q")).toBe("code review"); + expect(requestUrl.searchParams.get("limit")).toBe("20"); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + "https://api.github.com/repos/mattpocock/skills" ); - expect(results[0]).toMatchObject({ slug: "code-review", displayName: "Code Review" }); + expect(fetchMock).toHaveBeenCalledTimes(2); }); - it("derives skill info from exact JSON search results", async () => { - const runCommand = vi.fn(async () => ({ - stdout: JSON.stringify([ - { - slug: "other-review", - name: "Other Review", - description: "Another result", - }, - { - slug: "code-review", - name: "Code Review", - description: "Review code changes before merge", - version: "1.2.3", - }, - ]), - stderr: "", - })); - const client = new SkillsHubClient({ runCommand }); - - const info = await client.info("code-review"); - - expect(runCommand).toHaveBeenCalledWith( - "npx", - ["-y", "@skill-hub/cli", "search", "code-review", "--limit", "50", "--json"], - expect.objectContaining({ - env: expect.objectContaining({ NO_COLOR: expect.any(String) }), - }) - ); - expect(info).toEqual({ + it("keeps search results usable when GitHub star lookup fails", async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.hostname === "api.github.com") { + return new Response("rate limited", { status: 403 }); + } + return jsonResponse({ + skills: [ + { + skillId: "code-review", + name: "code-review", + installs: 42, + source: "mattpocock/skills", + }, + ], + }); + }); + const client = new SkillsHubClient({ fetch: fetchMock as typeof fetch }); + + await expect(client.search("review")).resolves.toEqual([ + { + slug: "code-review", + registryRef: "mattpocock/skills@code-review", + displayName: "code-review", + installCount: 42, + }, + ]); + }); + + it("loads exact skill metadata from the skills.sh snapshot", async () => { + const fetchMock = vi.fn(async () => jsonResponse(skillSnapshot())); + const client = new SkillsHubClient({ fetch: fetchMock as typeof fetch }); + + await expect(client.info("code-review", "mattpocock/skills@code-review")).resolves.toEqual({ slug: "code-review", - name: "Code Review", + registryRef: "mattpocock/skills@code-review", + displayName: "code-review", + name: "code-review", description: "Review code changes before merge", - version: "1.2.3", + version: SNAPSHOT_HASH, }); - }); - it("stages install directly into an export dir", async () => { - const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" })); - const client = new SkillsHubClient({ runCommand }); - - const staged = await client.stageInstall("code-review"); - - expect(runCommand).toHaveBeenNthCalledWith( - 1, - "npx", - [ - "-y", - "@skill-hub/cli", - "install", - "code-review", - "--agent", - "codex", - "--yes", - "--dir", - staged.exportDir, - ], - expect.objectContaining({ - env: expect.objectContaining({ HOME: staged.tempHome }), - }) + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "https://skills.sh/api/download/mattpocock/skills/code-review" ); - expect(runCommand).toHaveBeenCalledTimes(1); }); - it("includes CLI stderr when a Skills Hub command fails", async () => { - const runCommand = vi.fn(async () => { - throw Object.assign(new Error("Command failed with exit code 1"), { - stderr: "npm error 404 Not Found - GET https://registry.npmjs.org/missing-package", - stdout: "", + it("stages validated snapshot files in the existing export layout", async () => { + const fetchMock = vi.fn(async () => jsonResponse(skillSnapshot())); + const client = new SkillsHubClient({ fetch: fetchMock as typeof fetch }); + const staged = await client.stageInstall("code-review", "mattpocock/skills@code-review"); + + try { + await expect(client.readStagedSkill(staged.exportDir, "code-review")).resolves.toContain( + "Review the current change." + ); + await expect( + readFile(join(staged.exportDir, "code-review", "agents", "openai.yaml"), "utf8") + ).resolves.toContain("Code Review"); + expect(staged.info).toMatchObject({ + registryRef: "mattpocock/skills@code-review", + version: SNAPSHOT_HASH, }); - }); - const client = new SkillsHubClient({ runCommand }); + } finally { + await client.cleanupStage(staged.tempHome); + } + }); - await expect(client.search("review")).rejects.toThrow("npm error 404 Not Found"); + it("rejects snapshot paths that escape the staged skill directory", async () => { + const fetchMock = vi.fn(async () => + jsonResponse( + skillSnapshot([ + { path: "SKILL.md", contents: "---\nname: code-review\n---\n" }, + { path: "../outside.txt", contents: "unsafe" }, + ]) + ) + ); + const client = new SkillsHubClient({ fetch: fetchMock as typeof fetch }); + + await expect( + client.stageInstall("code-review", "mattpocock/skills@code-review") + ).rejects.toThrow("unsafe path"); + }); + + it("includes the HTTP status when skills.sh fails", async () => { + const fetchMock = vi.fn(async () => new Response("temporarily unavailable", { status: 503 })); + const client = new SkillsHubClient({ fetch: fetchMock as typeof fetch }); + + await expect(client.search("review")).rejects.toThrow( + "skills.sh request failed with HTTP 503: temporarily unavailable" + ); }); }); diff --git a/packages/server/src/__tests__/storage/skill-library-repo.test.ts b/packages/server/src/__tests__/storage/skill-library-repo.test.ts index 860625ec..e096ca5a 100644 --- a/packages/server/src/__tests__/storage/skill-library-repo.test.ts +++ b/packages/server/src/__tests__/storage/skill-library-repo.test.ts @@ -159,10 +159,12 @@ describe("SkillLibraryRepo", () => { repo.set({ slug: "code-review", + registryRef: "mattpocock/skills@code-review", displayName: "Code Review", description: "Managed copy", - version: "2.0.0", - source: "skillhub", + version: "12daafb9c4f77deb3c3303dc2e6f8a3c2a0ff7928fc004af959ba18b8bd38068", + source: "installed", + origin: "skills-sh", libraryPath: join(tempDir, "state", "skills", "library", "code-review"), installState: "installed", installedAt: 1, @@ -179,7 +181,8 @@ describe("SkillLibraryRepo", () => { expect(scannedRepo.get("code-review")).toMatchObject({ source: "installed", - origin: "skillhub", + origin: "skills-sh", + registryRef: "mattpocock/skills@code-review", description: "Managed copy", }); }); diff --git a/packages/server/src/commands/skills/install.ts b/packages/server/src/commands/skills/install.ts index f590c3b1..d1cd5910 100644 --- a/packages/server/src/commands/skills/install.ts +++ b/packages/server/src/commands/skills/install.ts @@ -6,10 +6,13 @@ import { requireSkillInstallSupport } from "./shared.js"; export function registerSkillInstallCommands(): void { registerCommand( "skills.install.start", - z.object({ slug: z.string().trim().min(1) }), + z.object({ + slug: z.string().trim().min(1), + registryRef: z.string().trim().min(1).optional(), + }), async (args, ctx) => { requireSkillInstallSupport(ctx); - return createCoderStudioSkillManager(ctx).startInstall(args.slug); + return createCoderStudioSkillManager(ctx).startInstall(args.slug, args.registryRef); } ); diff --git a/packages/server/src/commands/skills/query.ts b/packages/server/src/commands/skills/query.ts index db3832d3..d5f8f143 100644 --- a/packages/server/src/commands/skills/query.ts +++ b/packages/server/src/commands/skills/query.ts @@ -40,7 +40,10 @@ export function registerSkillQueryCommands(): void { return buildSkillRecommendations({ intelligence, - search: (query) => ctx.skillsHubClient.search(query), + search: (query) => + ctx.skillsHubClient.search(query, { + includeRepositoryStats: false, + }), isInstalled: (slug) => Boolean(ctx.skillLibraryRepo.get(slug)), limit: args.limit, offset: args.offset, @@ -50,10 +53,13 @@ export function registerSkillQueryCommands(): void { registerCommand( "skills.info", - z.object({ slug: z.string().trim().min(1) }), + z.object({ + slug: z.string().trim().min(1), + registryRef: z.string().trim().min(1).optional(), + }), async (args, ctx) => { requireSkillsQuerySupport(ctx); - return createCoderStudioSkillManager(ctx).getSkillInfo(args.slug); + return createCoderStudioSkillManager(ctx).getSkillInfo(args.slug, args.registryRef); } ); } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index d42d58bf..c39ae30d 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -270,7 +270,7 @@ export async function createServer( const skillMountRepo = new SkillMountRepo({ filePath: join(stateRoot, "state", "skills", "mounts.json"), }); - const skillsHubClient = new SkillsHubClient({ runCommand: runCommandAsString }); + const skillsHubClient = new SkillsHubClient(); const skillLibraryRoot = join(stateRoot, "state", "skills", "library"); const skillMountMgr = new SkillMountManager({ getProviderRegistry: () => activeProviderRegistry, diff --git a/packages/server/src/skills/install-manager.ts b/packages/server/src/skills/install-manager.ts index b20be299..2a1c9736 100644 --- a/packages/server/src/skills/install-manager.ts +++ b/packages/server/src/skills/install-manager.ts @@ -24,13 +24,14 @@ interface InstallRecord { } interface SkillInfoLike { + registryRef?: string; name?: string; description?: string; version?: string; } -function canOverwriteWithSkillHubInstall(existing: SkillLibraryEntry | undefined): boolean { - return !existing || (existing.source === "installed" && existing.origin === "skillhub"); +function canOverwriteWithSkillsShInstall(existing: SkillLibraryEntry | undefined): boolean { + return !existing || (existing.source === "installed" && existing.origin === "skills-sh"); } export class SkillInstallManager { @@ -41,7 +42,7 @@ export class SkillInstallManager { constructor(private readonly deps: SkillInstallManagerDeps) {} - async start(slug: string): Promise { + async start(slug: string, registryRef?: string): Promise { const active = this.getActiveJob(slug); if (active) { return cloneJobSnapshot(active); @@ -52,7 +53,7 @@ export class SkillInstallManager { return cloneJobSnapshot(await inFlight); } - const promise = this.prepareAndRun(slug); + const promise = this.prepareAndRun(slug, registryRef); this.inFlightStartsBySlug.set(slug, promise); try { @@ -69,8 +70,11 @@ export class SkillInstallManager { return job ? cloneJobSnapshot(job) : undefined; } - private async prepareAndRun(slug: string): Promise { - const job = this.createJob(slug); + private async prepareAndRun( + slug: string, + registryRef?: string + ): Promise { + const job = this.createJob(slug, registryRef); this.jobs.set(job.jobId, job); this.activeJobIdsBySlug.set(slug, job.jobId); @@ -93,14 +97,12 @@ export class SkillInstallManager { this.jobs.set(job.jobId, job); const existing = this.deps.skillLibraryRepo.get(job.slug); - if (!canOverwriteWithSkillHubInstall(existing)) { + if (!canOverwriteWithSkillsShInstall(existing)) { throw new Error(`A skill with slug ${job.slug} already exists`); } - const info = (await this.deps.skillsHubClient.info(job.slug).catch(() => undefined)) as - | SkillInfoLike - | undefined; - const staged = await this.deps.skillsHubClient.stageInstall(job.slug); + const staged = await this.deps.skillsHubClient.stageInstall(job.slug, job.registryRef); + const info = staged.info as SkillInfoLike; record.tempHome = staged.tempHome; job.version = info?.version; @@ -151,11 +153,12 @@ export class SkillInstallManager { const now = Date.now(); const entry: SkillLibraryEntry = { slug, + ...(info?.registryRef ? { registryRef: info.registryRef } : {}), displayName: info?.name?.trim() || slug, description: info?.description?.trim() || undefined, version: info?.version?.trim() || "1", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath, installState: "installed", installedAt: existing?.installedAt ?? now, @@ -165,7 +168,7 @@ export class SkillInstallManager { this.deps.skillLibraryRepo.set(entry); } - private createJob(slug: string): SkillInstallJobSnapshot { + private createJob(slug: string, registryRef?: string): SkillInstallJobSnapshot { const jobId = randomUUID(); const steps: SkillInstallStepSnapshot[] = [ { @@ -191,6 +194,7 @@ export class SkillInstallManager { const job: SkillInstallJobSnapshot = { jobId, slug, + ...(registryRef ? { registryRef } : {}), status: "queued", currentStepId: steps[0]?.id, steps, diff --git a/packages/server/src/skills/recommendation.ts b/packages/server/src/skills/recommendation.ts index b5de7857..0b3c328c 100644 --- a/packages/server/src/skills/recommendation.ts +++ b/packages/server/src/skills/recommendation.ts @@ -6,6 +6,7 @@ import type { export interface SkillRecommendationSearchResult { slug: string; + registryRef?: string; displayName: string; description?: string; } @@ -18,6 +19,7 @@ interface RecommendationQuerySeed { interface RecommendationAccumulator { slug: string; + registryRef?: string; displayName: string; description?: string; score: number; @@ -51,8 +53,10 @@ export async function buildSkillRecommendationEntries(input: { const queryScore = seed.weight + Math.max(0, 12 - index) + scoreCandidate(result, seed.query); - const current = recommendations.get(result.slug) ?? { + const recommendationKey = result.registryRef ?? result.slug; + const current = recommendations.get(recommendationKey) ?? { slug: result.slug, + registryRef: result.registryRef, displayName: result.displayName, description: result.description, score: 0, @@ -78,7 +82,7 @@ export async function buildSkillRecommendationEntries(input: { current.sourceQueryScore = queryScore; } - recommendations.set(result.slug, current); + recommendations.set(recommendationKey, current); }); }) ); @@ -93,6 +97,7 @@ export async function buildSkillRecommendationEntries(input: { }) .map((entry) => ({ slug: entry.slug, + ...(entry.registryRef ? { registryRef: entry.registryRef } : {}), displayName: entry.displayName, description: entry.description, reason: entry.reasons.join("; "), diff --git a/packages/server/src/skills/search-parser.ts b/packages/server/src/skills/search-parser.ts deleted file mode 100644 index 813fc4de..00000000 --- a/packages/server/src/skills/search-parser.ts +++ /dev/null @@ -1,119 +0,0 @@ -export interface ParsedSkillSearchRow { - slug: string; - displayName: string; - description?: string; - version?: string; -} - -export function parseSkillsHubSearchOutput(output: string): ParsedSkillSearchRow[] { - const jsonRows = parseJsonSearchOutput(output); - if (jsonRows) { - return jsonRows; - } - - const lines = output.split(/\r?\n/); - const rows: ParsedSkillSearchRow[] = []; - let current: ParsedSkillSearchRow | null = null; - - for (const line of lines) { - const cleanLine = stripAnsi(line).trimEnd(); - const slugMatch = cleanLine.match( - /^\s*(?:\d+\.\s+|\[(?:--|\d+)\]\s+)?([a-z0-9][a-z0-9-]*)(?:\s+v([^\s]+))?\s*$/i - ); - if (slugMatch) { - if (current) rows.push(current); - current = { - slug: slugMatch[1]!, - displayName: slugMatch[1]!, - ...definedField("version", cleanVersion(slugMatch[2])), - }; - continue; - } - - if (!current) continue; - - const nameMatch = cleanLine.match(/^\s*Name:\s+(.+)$/); - if (nameMatch) current.displayName = nameMatch[1]!.trim(); - - const descriptionMatch = cleanLine.match(/^\s*Description:\s+(.+)$/); - if (descriptionMatch) { - current.description = descriptionMatch[1]!.trim(); - continue; - } - - if ( - !current.description && - /^\s{2,}\S/.test(line) && - !/^\s*(?:npx|npm|pnpm|yarn)\b/i.test(cleanLine) - ) { - current.description = cleanLine.trim(); - } - } - - if (current) rows.push(current); - return rows; -} - -function parseJsonSearchOutput(output: string): ParsedSkillSearchRow[] | undefined { - const cleanOutput = stripAnsi(output).trim(); - const start = cleanOutput.indexOf("["); - const end = cleanOutput.lastIndexOf("]"); - if (start === -1 || end === -1 || end < start) { - return undefined; - } - - try { - const parsed = JSON.parse(cleanOutput.slice(start, end + 1)) as unknown; - if (!Array.isArray(parsed)) { - return undefined; - } - - return parsed.flatMap((item) => { - if (!item || typeof item !== "object") { - return []; - } - const row = item as { - slug?: unknown; - name?: unknown; - description?: unknown; - version?: unknown; - }; - if (typeof row.slug !== "string" || row.slug.trim().length === 0) { - return []; - } - - const slug = row.slug.trim(); - const name = typeof row.name === "string" ? row.name.trim() : ""; - const description = typeof row.description === "string" ? row.description.trim() : ""; - const version = typeof row.version === "string" ? cleanVersion(row.version) : undefined; - - return [ - { - slug, - displayName: name || slug, - description: description || undefined, - ...definedField("version", version), - }, - ]; - }); - } catch { - return undefined; - } -} - -function definedField(key: string, value: T | undefined): Record { - return value === undefined ? {} : { [key]: value }; -} - -function cleanVersion(value: string | undefined): string | undefined { - const version = value?.trim().replace(/^v(?=\d)/i, ""); - return version || undefined; -} - -function stripAnsi(value: string): string { - return value.replace( - // eslint-disable-next-line no-control-regex - /\u001b\[[0-9;]*m/g, - "" - ); -} diff --git a/packages/server/src/skills/skills-hub-client.ts b/packages/server/src/skills/skills-hub-client.ts index d1b801a7..394ca2d4 100644 --- a/packages/server/src/skills/skills-hub-client.ts +++ b/packages/server/src/skills/skills-hub-client.ts @@ -1,63 +1,166 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { parseSkillsHubSearchOutput } from "./search-parser.js"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; -export interface SkillsHubExecResult { - stdout: string; - stderr: string; -} +const SKILLS_SH_BASE_URL = "https://skills.sh"; +const GITHUB_API_BASE_URL = "https://api.github.com"; +const MAX_SKILL_FILE_COUNT = 1_000; +const MAX_SKILL_FILE_BYTES = 10 * 1024 * 1024; +const MAX_SKILL_TOTAL_BYTES = 50 * 1024 * 1024; +const REQUEST_TIMEOUT_MS = 20_000; +const REPOSITORY_STATS_TIMEOUT_MS = 3_000; +const REPOSITORY_STATS_CACHE_TTL_MS = 60 * 60 * 1_000; +const REPOSITORY_STATS_ERROR_CACHE_TTL_MS = 5 * 60 * 1_000; + +type SkillsFetch = typeof globalThis.fetch; export interface SkillsHubClientDeps { - runCommand: ( - file: string, - args: string[], - options?: { cwd?: string; env?: NodeJS.ProcessEnv } - ) => Promise; + fetch?: SkillsFetch; +} + +export interface SkillsHubSearchOptions { + includeRepositoryStats?: boolean; +} + +interface SkillsShSearchRow { + skillId?: unknown; + name?: unknown; + source?: unknown; + installs?: unknown; +} + +interface RepositoryStatsCacheEntry { + githubStars?: number; + expiresAt: number; +} + +interface SkillsShSnapshotFile { + path: string; + contents: string; +} + +interface SkillsShSnapshot { + files: SkillsShSnapshotFile[]; + hash: string; +} + +interface SkillsShReference { + owner: string; + repo: string; + skill: string; + registryRef: string; +} + +export interface SkillsShCatalogEntry { + slug: string; + registryRef: string; + displayName: string; + name?: string; + description?: string; + version?: string; + installCount?: number; + githubStars?: number; } export class SkillsHubClient { - constructor(private readonly deps: SkillsHubClientDeps) {} + private readonly repositoryStatsCache = new Map(); - async search(query: string) { - const result = await this.exec(["search", query, "--limit", "20", "--json"]); - return parseSkillsHubSearchOutput(result.stdout); - } + constructor(private readonly deps: SkillsHubClientDeps = {}) {} - async info(slug: string): Promise<{ - slug: string; - name?: string; - description?: string; - version?: string; - }> { - const result = await this.exec(["search", slug, "--limit", "50", "--json"]); - const rows = parseSkillsHubSearchOutput(result.stdout); - const exact = rows.find((row) => row.slug === slug); - if (!exact) { - throw new Error(`Skill not found: ${slug}`); + async search( + query: string, + options: SkillsHubSearchOptions = {} + ): Promise { + const url = new URL("/api/search", SKILLS_SH_BASE_URL); + url.searchParams.set("q", query); + url.searchParams.set("limit", "20"); + const payload = await this.fetchJson(url); + const rows = getRecord(payload)?.skills; + if (!Array.isArray(rows)) { + throw new Error("skills.sh search returned an invalid response"); } - return { - slug: exact.slug, - name: exact.displayName, - description: exact.description, - version: exact.version, - }; + const entries = rows.flatMap((value) => { + const row = getRecord(value) as SkillsShSearchRow | undefined; + const source = getNonEmptyString(row?.source); + const skillId = getNonEmptyString(row?.skillId); + if (!source || !skillId || !isValidSource(source) || !isValidReferencePart(skillId)) { + return []; + } + + const name = getNonEmptyString(row?.name) ?? skillId; + const installCount = getNonNegativeInteger(row?.installs); + return [ + { + slug: skillId, + registryRef: `${source}@${skillId}`, + displayName: name, + ...(installCount === undefined ? {} : { installCount }), + }, + ]; + }); + + if (options.includeRepositoryStats === false) { + return entries; + } + + const sources = [...new Set(entries.map((entry) => repositorySource(entry.registryRef)))]; + const githubStarsBySource = new Map( + await Promise.all( + sources.map(async (source) => [source, await this.fetchGitHubStars(source)] as const) + ) + ); + + return entries.map((entry) => { + const githubStars = githubStarsBySource.get(repositorySource(entry.registryRef)); + return githubStars === undefined ? entry : { ...entry, githubStars }; + }); } - async uninstall(slug: string): Promise { - await this.exec(["uninstall", slug]); + async info(slug: string, registryRef?: string): Promise { + const reference = await this.resolveReference(slug, registryRef); + const snapshot = await this.fetchSnapshot(reference); + const metadata = parseSkillMarkdownMetadata(getSkillMarkdown(snapshot)); + + return { + slug, + registryRef: reference.registryRef, + displayName: metadata.name ?? slug, + name: metadata.name ?? slug, + description: metadata.description, + version: snapshot.hash, + }; } - async stageInstall(slug: string): Promise<{ tempHome: string; exportDir: string }> { - const tempHome = await mkdtemp(join(tmpdir(), "skills-hub-home-")); + async stageInstall( + slug: string, + registryRef?: string + ): Promise<{ tempHome: string; exportDir: string; info: SkillsShCatalogEntry }> { + const reference = await this.resolveReference(slug, registryRef); + const snapshot = await this.fetchSnapshot(reference); + const tempHome = await mkdtemp(join(tmpdir(), "skills-sh-stage-")); const exportDir = join(tempHome, "exported"); + const skillDir = join(exportDir, slug); - await this.exec(["install", slug, "--agent", "codex", "--yes", "--dir", exportDir], { - env: { HOME: tempHome }, - }); - - return { tempHome, exportDir }; + try { + await writeSnapshot(skillDir, snapshot); + const metadata = parseSkillMarkdownMetadata(getSkillMarkdown(snapshot)); + return { + tempHome, + exportDir, + info: { + slug, + registryRef: reference.registryRef, + displayName: metadata.name ?? slug, + name: metadata.name ?? slug, + description: metadata.description, + version: snapshot.hash, + }, + }; + } catch (error) { + await rm(tempHome, { recursive: true, force: true }).catch(() => undefined); + throw error; + } } async readStagedSkill(exportDir: string, slug: string): Promise { @@ -68,51 +171,265 @@ export class SkillsHubClient { await rm(tempHome, { recursive: true, force: true }); } - private async exec(args: string[], options?: { env?: NodeJS.ProcessEnv }) { + private async resolveReference(slug: string, registryRef?: string): Promise { + if (registryRef) { + const reference = parseRegistryRef(registryRef); + if (reference.skill !== slug) { + throw new Error(`skills.sh reference does not match skill slug: ${slug}`); + } + return reference; + } + + const rows = await this.search(slug, { includeRepositoryStats: false }); + const exact = rows.find((row) => row.slug === slug); + if (!exact) { + throw new Error(`Skill not found on skills.sh: ${slug}`); + } + return parseRegistryRef(exact.registryRef); + } + + private async fetchSnapshot(reference: SkillsShReference): Promise { + const url = new URL( + `/api/download/${encodeURIComponent(reference.owner)}/${encodeURIComponent(reference.repo)}/${encodeURIComponent(reference.skill)}`, + SKILLS_SH_BASE_URL + ); + const payload = getRecord(await this.fetchJson(url)); + if (!payload || !Array.isArray(payload.files) || !isSha256(payload.hash)) { + throw new Error(`skills.sh returned an invalid snapshot for ${reference.registryRef}`); + } + if (payload.files.length === 0 || payload.files.length > MAX_SKILL_FILE_COUNT) { + throw new Error(`skills.sh returned an invalid file count for ${reference.registryRef}`); + } + + const files: SkillsShSnapshotFile[] = []; + let totalBytes = 0; + for (const value of payload.files) { + const file = getRecord(value); + const path = getNonEmptyString(file?.path); + const contents = typeof file?.contents === "string" ? file.contents : undefined; + if (!path || contents === undefined) { + throw new Error(`skills.sh returned an invalid file for ${reference.registryRef}`); + } + const size = Buffer.byteLength(contents, "utf8"); + totalBytes += size; + if (size > MAX_SKILL_FILE_BYTES || totalBytes > MAX_SKILL_TOTAL_BYTES) { + throw new Error(`skills.sh snapshot is too large: ${reference.registryRef}`); + } + files.push({ path, contents }); + } + + const snapshot = { files, hash: payload.hash }; + getSkillMarkdown(snapshot); + return snapshot; + } + + private async fetchJson(url: URL): Promise { + const fetcher = this.deps.fetch ?? globalThis.fetch; + let response: Response; try { - const commandOptions = { - ...options, - env: buildSkillHubEnv(options?.env), - }; - return await this.deps.runCommand("npx", ["-y", "@skill-hub/cli", ...args], commandOptions); + response = await fetcher(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); } catch (error) { - throw buildSkillHubError(error); + const message = error instanceof Error ? error.message : "Request failed"; + throw new Error(`skills.sh request failed: ${message}`, { cause: error }); } + + if (!response.ok) { + const detail = (await response.text().catch(() => "")).trim().slice(0, 500); + throw new Error( + `skills.sh request failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}` + ); + } + + try { + return await response.json(); + } catch (error) { + throw new Error("skills.sh returned invalid JSON", { cause: error }); + } + } + + private async fetchGitHubStars(source: string): Promise { + const cached = this.repositoryStatsCache.get(source); + if (cached && cached.expiresAt > Date.now()) { + return cached.githubStars; + } + + const parts = source.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return undefined; + } + + try { + const url = new URL( + `/repos/${encodeURIComponent(parts[0])}/${encodeURIComponent(parts[1])}`, + GITHUB_API_BASE_URL + ); + const headers: Record = { + accept: "application/vnd.github+json", + "user-agent": "@spencer-kit/coder-studio", + "x-github-api-version": "2022-11-28", + }; + const token = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim(); + if (token) { + headers.authorization = `Bearer ${token}`; + } + + const response = await (this.deps.fetch ?? globalThis.fetch)(url, { + headers, + signal: AbortSignal.timeout(REPOSITORY_STATS_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`GitHub returned HTTP ${response.status}`); + } + + const githubStars = getNonNegativeInteger(getRecord(await response.json())?.stargazers_count); + this.repositoryStatsCache.set(source, { + ...(githubStars === undefined ? {} : { githubStars }), + expiresAt: Date.now() + REPOSITORY_STATS_CACHE_TTL_MS, + }); + return githubStars; + } catch { + this.repositoryStatsCache.set(source, { + expiresAt: Date.now() + REPOSITORY_STATS_ERROR_CACHE_TTL_MS, + }); + return undefined; + } + } +} + +function parseRegistryRef(value: string): SkillsShReference { + const match = value.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)@([A-Za-z0-9_.-]+)$/); + if (!match?.[1] || !match[2] || !match[3]) { + throw new Error(`Invalid skills.sh reference: ${value}`); } + return { + owner: match[1], + repo: match[2], + skill: match[3], + registryRef: `${match[1]}/${match[2]}@${match[3]}`, + }; } -function buildSkillHubEnv(overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env, ...overrides }; - delete env.FORCE_COLOR; - env.NO_COLOR = env.NO_COLOR || "1"; - return env; +function isValidSource(value: string): boolean { + const parts = value.split("/"); + return parts.length === 2 && parts.every(isValidReferencePart); } -function buildSkillHubError(error: unknown): Error { - const message = error instanceof Error ? error.message : "Command failed"; - const stderr = getStringProperty(error, "stderr"); - const stdout = getStringProperty(error, "stdout"); - const detail = trimCommandOutput(stderr) || trimCommandOutput(stdout); - const wrapped = new Error(detail ? `Skills Hub command failed: ${message}\n${detail}` : message); - return Object.assign(wrapped, { - cause: error, - stderr, - stdout, - }); +function isValidReferencePart(value: string): boolean { + return /^[A-Za-z0-9_.-]+$/.test(value); } -function getStringProperty(value: unknown, key: "stderr" | "stdout"): string | undefined { - if (!value || typeof value !== "object" || !(key in value)) { +function isSha256(value: unknown): value is string { + return typeof value === "string" && /^[a-f0-9]{64}$/i.test(value); +} + +function getRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function getNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { return undefined; } - const field = (value as Record)[key]; - return typeof field === "string" ? field : undefined; + return value.trim() || undefined; } -function trimCommandOutput(value: string | undefined): string | undefined { - const text = value?.trim(); - if (!text) { - return undefined; +function getNonNegativeInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; +} + +function repositorySource(registryRef: string): string { + return registryRef.slice(0, registryRef.lastIndexOf("@")); +} + +function getSkillMarkdown(snapshot: SkillsShSnapshot): string { + const file = snapshot.files.find((entry) => entry.path === "SKILL.md"); + if (!file) { + throw new Error("skills.sh snapshot does not contain SKILL.md"); + } + return file.contents; +} + +async function writeSnapshot(skillDir: string, snapshot: SkillsShSnapshot): Promise { + const destinations = new Set(); + for (const file of snapshot.files) { + const destination = resolveSnapshotPath(skillDir, file.path); + const key = destination.toLowerCase(); + if (destinations.has(key)) { + throw new Error(`skills.sh snapshot contains a duplicate path: ${file.path}`); + } + destinations.add(key); + await mkdir(resolve(destination, ".."), { recursive: true }); + await writeFile(destination, file.contents, "utf8"); + } +} + +function resolveSnapshotPath(skillDir: string, filePath: string): string { + if ( + filePath.includes("\\") || + filePath.includes("\0") || + filePath.startsWith("/") || + filePath.split("/").some((part) => !part || part === "." || part === "..") + ) { + throw new Error(`skills.sh snapshot contains an unsafe path: ${filePath}`); + } + + const destination = resolve(skillDir, ...filePath.split("/")); + const relativePath = relative(skillDir, destination); + if ( + !relativePath || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + throw new Error(`skills.sh snapshot contains an unsafe path: ${filePath}`); + } + return destination; +} + +function parseSkillMarkdownMetadata(markdown: string): { + name?: string; + description?: string; +} { + const match = markdown.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/); + if (!match?.[1]) { + return {}; + } + + const metadata: { name?: string; description?: string } = {}; + for (const line of match[1].split(/\r?\n/)) { + const pair = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!pair?.[1] || pair[2] === undefined) { + continue; + } + const value = parseYamlScalar(pair[2].trim()); + if (!value) { + continue; + } + if (pair[1] === "name") { + metadata.name = value; + } else if (pair[1] === "description") { + metadata.description = value; + } + } + return metadata; +} + +function parseYamlScalar(value: string): string { + if (value.startsWith('"') && value.endsWith('"')) { + try { + const parsed = JSON.parse(value) as unknown; + return typeof parsed === "string" ? parsed : value.slice(1, -1); + } catch { + return value.slice(1, -1); + } + } + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/''/g, "'"); } - return text.split(/\r?\n/).slice(0, 8).join("\n"); + return value; } diff --git a/packages/skill-manager/src/domain/skill-management.ts b/packages/skill-manager/src/domain/skill-management.ts index b25eb886..0d01f238 100644 --- a/packages/skill-manager/src/domain/skill-management.ts +++ b/packages/skill-manager/src/domain/skill-management.ts @@ -1,6 +1,6 @@ export const SKILL_INSTALL_STATES = ["installed", "installing", "failed"] as const; export const SKILL_LIBRARY_SOURCES = ["builtin", "installed", "custom"] as const; -export const SKILL_LIBRARY_ORIGINS = ["builtin", "skillhub", "filesystem"] as const; +export const SKILL_LIBRARY_ORIGINS = ["builtin", "skillhub", "skills-sh", "filesystem"] as const; export const SKILL_LIBRARY_ITEM_STATES = [ "unmounted", "partially_mounted", @@ -58,6 +58,7 @@ export function isSkillMountStatus(value: string): value is SkillMountStatus { export interface SkillLibraryEntry { slug: string; + registryRef?: string; displayName: string; description?: string; version: string; @@ -82,9 +83,12 @@ export interface SkillLibraryListItem extends SkillLibraryEntry { export interface SkillSearchResultItem { slug: string; + registryRef?: string; displayName: string; description?: string; version?: string; + installCount?: number; + githubStars?: number; installed: boolean; installedVersion?: string; mountedProviderIds: string[]; @@ -92,6 +96,7 @@ export interface SkillSearchResultItem { export interface SkillInfoItem { slug: string; + registryRef?: string; displayName: string; description?: string; version?: string; @@ -158,6 +163,7 @@ export interface SkillInstallFailure { export interface SkillInstallJobSnapshot { jobId: string; slug: string; + registryRef?: string; version?: string; status: "queued" | "running" | "succeeded" | "failed"; currentStepId?: string; diff --git a/packages/skill-manager/src/ports/hosts.ts b/packages/skill-manager/src/ports/hosts.ts index 06e0c81e..db156b56 100644 --- a/packages/skill-manager/src/ports/hosts.ts +++ b/packages/skill-manager/src/ports/hosts.ts @@ -7,19 +7,22 @@ import type { export interface SkillCatalogEntry { slug: string; + registryRef?: string; displayName?: string; name?: string; description?: string; version?: string; + installCount?: number; + githubStars?: number; } export interface SkillCatalogHost { search(query: string): Promise; - info(slug: string): Promise; + info(slug: string, registryRef?: string): Promise; } export interface SkillInstallJobsHost { - start(slug: string): Promise; + start(slug: string, registryRef?: string): Promise; get(jobId: string): SkillInstallJobSnapshot | undefined; } diff --git a/packages/skill-manager/src/repositories/skill-library-repository.ts b/packages/skill-manager/src/repositories/skill-library-repository.ts index c5129a77..69b6be53 100644 --- a/packages/skill-manager/src/repositories/skill-library-repository.ts +++ b/packages/skill-manager/src/repositories/skill-library-repository.ts @@ -128,6 +128,11 @@ function compareEntryPriority(left: SkillLibraryEntry, right: SkillLibraryEntry) function priorityOf(entry: SkillLibraryEntry): number { if (entry.source === "builtin") return 4; if (entry.source === "custom") return 3; - if (entry.source === "installed" && entry.origin === "skillhub") return 2; + if ( + entry.source === "installed" && + (entry.origin === "skillhub" || entry.origin === "skills-sh") + ) { + return 2; + } return 1; } diff --git a/packages/skill-manager/src/services/skill-manager.test.ts b/packages/skill-manager/src/services/skill-manager.test.ts index 16292855..620fb3d8 100644 --- a/packages/skill-manager/src/services/skill-manager.test.ts +++ b/packages/skill-manager/src/services/skill-manager.test.ts @@ -29,10 +29,11 @@ describe("SkillManager", () => { const { library, mounts } = createRepositories(); library.set({ slug: "review", + registryRef: "acme/skills@review", displayName: "Review", version: "1.0.0", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/skills/review", installState: "installed", installedAt: 1, @@ -52,7 +53,13 @@ describe("SkillManager", () => { mounts, catalog: { search: vi.fn(async () => [ - { slug: "review", displayName: "Code Review", version: "2.0.0" }, + { + slug: "review", + displayName: "Code Review", + version: "2.0.0", + installCount: 8_674, + githubStars: 518, + }, ]), info: vi.fn(), }, @@ -63,6 +70,8 @@ describe("SkillManager", () => { slug: "review", installed: true, installedVersion: "1.0.0", + installCount: 8_674, + githubStars: 518, mountedProviderIds: ["codex"], }), ]); @@ -106,10 +115,11 @@ describe("SkillManager", () => { const { library, mounts } = createRepositories(); library.set({ slug: "review", + registryRef: "acme/skills@review", displayName: "Review", version: "v1.2.0", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/skills/review", installState: "installed", installedAt: 1, @@ -141,7 +151,7 @@ describe("SkillManager", () => { status: "update_available", }, ]); - expect(info).toHaveBeenCalledWith("review"); + expect(info).toHaveBeenCalledWith("review", "acme/skills@review"); }); it("imports a local skill through host content operations and persists metadata", async () => { diff --git a/packages/skill-manager/src/services/skill-manager.ts b/packages/skill-manager/src/services/skill-manager.ts index cd7bd40c..16075549 100644 --- a/packages/skill-manager/src/services/skill-manager.ts +++ b/packages/skill-manager/src/services/skill-manager.ts @@ -70,9 +70,12 @@ export class SkillManager { return { slug: item.slug, + ...(item.registryRef ? { registryRef: item.registryRef } : {}), displayName: item.displayName ?? item.name ?? item.slug, description: item.description, version: item.version, + ...(item.installCount === undefined ? {} : { installCount: item.installCount }), + ...(item.githubStars === undefined ? {} : { githubStars: item.githubStars }), installed: Boolean(installed), installedVersion: installed?.version, mountedProviderIds: mounts.map((entry) => entry.providerId), @@ -80,13 +83,18 @@ export class SkillManager { }); } - async getSkillInfo(slug: string): Promise { + async getSkillInfo(slug: string, registryRef?: string): Promise { const catalog = this.requireCatalog(); const libraryEntry = this.deps.library.get(slug); - const remote = await catalog.info(slug).catch(() => undefined); + const resolvedRegistryRef = registryRef ?? libraryEntry?.registryRef; + const remote = await (resolvedRegistryRef + ? catalog.info(slug, resolvedRegistryRef) + : catalog.info(slug) + ).catch(() => undefined); return { slug, + registryRef: remote?.registryRef ?? libraryEntry?.registryRef ?? registryRef, displayName: remote?.name ?? remote?.displayName ?? libraryEntry?.displayName ?? slug, description: remote?.description ?? libraryEntry?.description, version: remote?.version ?? libraryEntry?.version, @@ -103,23 +111,23 @@ export class SkillManager { .filter( (entry) => entry.source === "installed" && - entry.origin === "skillhub" && + entry.origin === "skills-sh" && entry.installState === "installed" ); return Promise.all(entries.map((entry) => checkVersion(entry, catalog))); } - async startInstall(slug: string): Promise { + async startInstall(slug: string, registryRef?: string): Promise { const installJobs = this.requireInstallJobs(); const existing = this.deps.library.get(slug); - if (!canInstallFromSkillHub(existing)) { + if (!canInstallFromSkillsSh(existing)) { throw { code: "skill_slug_conflict", message: `A skill with slug ${slug} already exists`, }; } - return installJobs.start(slug); + return registryRef ? installJobs.start(slug, registryRef) : installJobs.start(slug); } async startUpdate(slug: string): Promise { @@ -128,15 +136,15 @@ export class SkillManager { if ( !entry || entry.source !== "installed" || - entry.origin !== "skillhub" || + entry.origin !== "skills-sh" || entry.installState !== "installed" ) { throw { code: "skill_update_unavailable", - message: `Only installed Skill Hub skills can be updated: ${slug}`, + message: `Only installed skills.sh skills can be updated: ${slug}`, }; } - return installJobs.start(slug); + return installJobs.start(slug, entry.registryRef); } getInstallJob(jobId: string): SkillInstallJobSnapshot { @@ -342,8 +350,8 @@ export class SkillManager { } } -function canInstallFromSkillHub(entry: SkillLibraryEntry | undefined): boolean { - return !entry || (entry.source === "installed" && entry.origin === "skillhub"); +function canInstallFromSkillsSh(entry: SkillLibraryEntry | undefined): boolean { + return !entry || (entry.source === "installed" && entry.origin === "skills-sh"); } function parseVersionParts(version: string): number[] { @@ -375,7 +383,9 @@ async function checkVersion( catalog: SkillCatalogHost ): Promise { try { - const remote = await catalog.info(entry.slug); + const remote = await (entry.registryRef + ? catalog.info(entry.slug, entry.registryRef) + : catalog.info(entry.slug)); const latestVersion = remote.version?.trim(); if (!latestVersion) { return { slug: entry.slug, currentVersion: entry.version, status: "unknown" }; @@ -385,7 +395,13 @@ async function checkVersion( currentVersion: entry.version, latestVersion, status: - compareSkillVersions(latestVersion, entry.version) > 0 ? "update_available" : "up_to_date", + entry.origin === "skills-sh" + ? latestVersion === entry.version + ? "up_to_date" + : "update_available" + : compareSkillVersions(latestVersion, entry.version) > 0 + ? "update_available" + : "up_to_date", }; } catch (error) { return { diff --git a/packages/web/src/features/workspace/actions/use-skills-panel.ts b/packages/web/src/features/workspace/actions/use-skills-panel.ts index 38a5606d..b64df85a 100644 --- a/packages/web/src/features/workspace/actions/use-skills-panel.ts +++ b/packages/web/src/features/workspace/actions/use-skills-panel.ts @@ -17,9 +17,12 @@ const RECOMMENDATIONS_PAGE_SIZE = 20; export interface SkillSearchResultItem { slug: string; + registryRef?: string; displayName: string; description?: string; version?: string; + installCount?: number; + githubStars?: number; installed: boolean; installedVersion?: string; mountedProviderIds: string[]; @@ -33,6 +36,7 @@ export interface SkillLibraryListItem extends SkillLibraryEntry { export interface SkillInfoItem { slug: string; + registryRef?: string; displayName: string; description?: string; version?: string; @@ -50,8 +54,8 @@ function appendUniqueRecommendations( current: SkillRecommendationEntry[], incoming: SkillRecommendationEntry[] ) { - const seen = new Set(current.map((entry) => entry.slug)); - const appended = incoming.filter((entry) => !seen.has(entry.slug)); + const seen = new Set(current.map((entry) => entry.registryRef ?? entry.slug)); + const appended = incoming.filter((entry) => !seen.has(entry.registryRef ?? entry.slug)); return appended.length > 0 ? [...current, ...appended] : current; } @@ -196,8 +200,11 @@ export function useSkillsPanel(workspaceId: string) { }, [dispatch]); const loadSkillInfo = useCallback( - async (slug: string) => { - const result = await dispatch("skills.info", { slug }); + async (slug: string, registryRef?: string) => { + const result = await dispatch("skills.info", { + slug, + ...(registryRef ? { registryRef } : {}), + }); if (!result.ok || !result.data) { setErrorMessage(result.error?.message ?? "Failed to load skill details"); return null; @@ -205,7 +212,7 @@ export function useSkillsPanel(workspaceId: string) { setSkillInfoBySlug((current) => ({ ...current, - [slug]: result.data!, + [registryRef ?? slug]: result.data!, })); setErrorMessage(null); return result.data; @@ -303,8 +310,11 @@ export function useSkillsPanel(workspaceId: string) { ]); const installSkill = useCallback( - async (slug: string) => { - const result = await dispatch("skills.install.start", { slug }); + async (slug: string, registryRef?: string) => { + const result = await dispatch("skills.install.start", { + slug, + ...(registryRef ? { registryRef } : {}), + }); if (!result.ok || !result.data) { setErrorMessage(result.error?.message ?? "Failed to install skill"); return false; diff --git a/packages/web/src/features/workspace/views/shared/skills-panel.test.tsx b/packages/web/src/features/workspace/views/shared/skills-panel.test.tsx index 6be1a57d..9225e98c 100644 --- a/packages/web/src/features/workspace/views/shared/skills-panel.test.tsx +++ b/packages/web/src/features/workspace/views/shared/skills-panel.test.tsx @@ -67,6 +67,8 @@ const translations: Record = { "skills.empty_installed": "No installed skills yet.", "skills.empty_builtin": "No built-in skills yet.", "skills.available": "Available", + "skills.install_count": "Installs: {{count}}", + "skills.github_stars": "GitHub stars: {{count}}", "skills.installed": "Installed", "skills.install": "Install", "skills.check_versions": "Check versions", @@ -131,6 +133,7 @@ const translations: Record = { "workspace.skills.source.custom": "Custom", "workspace.skills.source.builtin": "Built-in", "workspace.skills.origin.skillhub": "skills-hub", + "workspace.skills.origin.skills-sh": "skills.sh", "workspace.skills.origin.filesystem": "Local", "skills.mount_state.unmounted": "Disabled", "skills.mount_state.partially_mounted": "Enabled", @@ -415,7 +418,7 @@ describe("SkillsPanel", () => { }); }); - it("checks installed Skill Hub versions and updates an available skill", async () => { + it("checks installed skills.sh versions and updates an available skill", async () => { const sendCommand = vi.fn(async (op: string, args?: unknown) => { if (op === "skills.library.list") { return [ @@ -425,7 +428,7 @@ describe("SkillsPanel", () => { description: "Review code changes before merge", version: "1.2.3", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/skills/library/code-review", installState: "installed", installedAt: 1, @@ -1436,11 +1439,12 @@ describe("SkillsPanel", () => { }, { slug: "code-review", + registryRef: "mattpocock/skills@code-review", displayName: "Code Review", description: "Review code changes before merge.", version: "1.2.3", source: "installed", - origin: "skillhub", + origin: "skills-sh", libraryPath: "/skills/library/code-review", installState: "installed", installedAt: 1, @@ -1552,10 +1556,10 @@ describe("SkillsPanel", () => { const filesystemCard = (await within(installedSection).findByText("External Review")).closest( "article" ); - const skillHubCard = (await within(installedSection).findByText("Code Review")).closest( + const skillsShCard = (await within(installedSection).findByText("Code Review")).closest( "article" ); - if (!filesystemCard || !skillHubCard) { + if (!filesystemCard || !skillsShCard) { throw new Error("Installed skill cards not found"); } @@ -1564,7 +1568,7 @@ describe("SkillsPanel", () => { expect(within(filesystemCard).queryByRole("button", { name: "Uninstall" })).toBeNull(); fireEvent.click(within(installedSection).getByRole("button", { name: "Check versions" })); - expect(await within(skillHubCard).findByRole("button", { name: "Update" })).toBeVisible(); + expect(await within(skillsShCard).findByRole("button", { name: "Update" })).toBeVisible(); fireEvent.click(await within(customSection).findByText("Review Ops Skill")); expect( @@ -2878,8 +2882,11 @@ describe("SkillsPanel", () => { }, { slug: "beta-helper", + registryRef: "acme/skills@beta-helper", displayName: "Beta Helper", description: "Available result", + installCount: 8_674, + githubStars: 518, installed: false, mountedProviderIds: [], }, @@ -2926,6 +2933,22 @@ describe("SkillsPanel", () => { const searchActions = searchInstallButton.closest(".skills-panel__card-head-actions"); expect(searchActions).not.toBeNull(); expect(searchActions).toContainElement(within(availableCard).getByText("Available")); + + const betaCard = screen.getByText("Beta Helper").closest("article"); + if (!betaCard) { + throw new Error("Skill metrics card not found"); + } + const installCount = within(betaCard).getByLabelText(/Installs: 8,?674/); + const githubStars = within(betaCard).getByLabelText("GitHub stars: 518"); + expect(installCount).toHaveTextContent("8.7K"); + expect(githubStars).toHaveTextContent("518"); + expect( + installCount.querySelector('[data-icon-semantic="skill.metric.installs"]') + ).not.toBeNull(); + expect( + githubStars.querySelector('[data-icon-semantic="skill.metric.githubStars"]') + ).not.toBeNull(); + expect(installCount.closest(".skills-panel__card-head-actions")).not.toBeNull(); }); it("opens an available skill detail view from search results", async () => { @@ -2946,6 +2969,7 @@ describe("SkillsPanel", () => { return [ { slug: "beta-helper", + registryRef: "acme/skills@beta-helper", displayName: "Beta Helper", description: "Available result", version: "2.0.0", @@ -2958,6 +2982,7 @@ describe("SkillsPanel", () => { if (op === "skills.info") { return { slug: "beta-helper", + registryRef: "acme/skills@beta-helper", displayName: "Beta Helper", description: "Helps with beta workflow", version: "2.0.0", @@ -2986,6 +3011,7 @@ describe("SkillsPanel", () => { "skills.info", { slug: "beta-helper", + registryRef: "acme/skills@beta-helper", }, undefined ); @@ -3005,6 +3031,7 @@ describe("SkillsPanel", () => { "skills.install.start", { slug: "beta-helper", + registryRef: "acme/skills@beta-helper", }, undefined ); diff --git a/packages/web/src/features/workspace/views/shared/skills-panel.tsx b/packages/web/src/features/workspace/views/shared/skills-panel.tsx index 12ba6b6e..313293df 100644 --- a/packages/web/src/features/workspace/views/shared/skills-panel.tsx +++ b/packages/web/src/features/workspace/views/shared/skills-panel.tsx @@ -66,6 +66,7 @@ interface SkillTargetSummary { interface SkillDetailItem { slug: string; + registryRef?: string; displayName: string; description?: string; version?: string; @@ -131,6 +132,10 @@ function sourceLabel(skill: Pick, t: Trans return t("workspace.skills.origin.skillhub"); } + if (skill.origin === "skills-sh") { + return t("workspace.skills.origin.skills-sh"); + } + if (skill.source) { return t(`workspace.skills.source.${skill.source}`); } @@ -143,9 +148,71 @@ function formatSkillVersion(version: string | undefined) { if (!trimmed) { return ""; } + if (/^[a-f0-9]{64}$/i.test(trimmed)) { + return `rev ${trimmed.slice(0, 12)}`; + } return /^v/i.test(trimmed) || trimmed === "local" ? trimmed : `v${trimmed}`; } +function formatMarketplaceCount(value: number) { + if (value < 1_000) { + return String(value); + } + + const divisor = value < 1_000_000 ? 1_000 : 1_000_000; + const suffix = value < 1_000_000 ? "K" : "M"; + const compact = value / divisor; + const precision = compact < 10 ? 1 : 0; + return `${compact.toFixed(precision).replace(/\.0$/, "")}${suffix}`; +} + +function isMarketplaceMetric(value: number | undefined): value is number { + return value !== undefined && Number.isSafeInteger(value) && value >= 0; +} + +function SkillMarketplaceStats({ + installCount, + githubStars, + t, +}: { + installCount?: number; + githubStars?: number; + t: Translate; +}) { + const hasInstallCount = isMarketplaceMetric(installCount); + const hasGithubStars = isMarketplaceMetric(githubStars); + if (!hasInstallCount && !hasGithubStars) { + return null; + } + + return ( + + {hasInstallCount ? ( + + + + {formatMarketplaceCount(installCount)} + + + ) : null} + {hasGithubStars ? ( + + + + {formatMarketplaceCount(githubStars)} + + + ) : null} + + ); +} + function versionCheckTag( t: Translate, check: SkillVersionCheckEntry | undefined @@ -204,12 +271,13 @@ function sourceForSkillInfo(info: SkillInfoItem): SkillDetailItem["source"] { } function originForSkillInfo(info: SkillInfoItem): SkillDetailItem["origin"] { - return info.libraryEntry?.origin ?? "skillhub"; + return info.libraryEntry?.origin ?? "skills-sh"; } function detailFromLibraryItem(skill: SkillLibraryListItem): SkillDetailItem { return { slug: skill.slug, + registryRef: skill.registryRef, displayName: skill.displayName, description: skill.description, version: skill.version, @@ -223,6 +291,7 @@ function detailFromLibraryItem(skill: SkillLibraryListItem): SkillDetailItem { function detailFromLibraryEntry(skill: SkillLibraryEntry): SkillDetailItem { return { slug: skill.slug, + registryRef: skill.registryRef, displayName: skill.displayName, description: skill.description, version: skill.version, @@ -236,6 +305,7 @@ function detailFromLibraryEntry(skill: SkillLibraryEntry): SkillDetailItem { function detailFromSkillInfo(info: SkillInfoItem): SkillDetailItem { return { slug: info.slug, + registryRef: info.registryRef, displayName: info.displayName, description: info.description, version: info.version, @@ -249,11 +319,12 @@ function detailFromSkillInfo(info: SkillInfoItem): SkillDetailItem { function detailFromSearchResult(item: SkillSearchResultItem): SkillDetailItem { return { slug: item.slug, + registryRef: item.registryRef, displayName: item.displayName, description: item.description, version: item.installedVersion ?? item.version, source: "installed", - origin: "skillhub", + origin: "skills-sh", installed: item.installed, }; } @@ -261,10 +332,11 @@ function detailFromSearchResult(item: SkillSearchResultItem): SkillDetailItem { function detailFromRecommendation(item: SkillRecommendationEntry): SkillDetailItem { return { slug: item.slug, + registryRef: item.registryRef, displayName: item.displayName, description: item.description, source: "installed", - origin: "skillhub", + origin: "skills-sh", installed: item.installed, }; } @@ -272,6 +344,7 @@ function detailFromRecommendation(item: SkillRecommendationEntry): SkillDetailIt function skillDetailMatches(left: SkillDetailItem, right: SkillDetailItem) { return ( left.slug === right.slug && + left.registryRef === right.registryRef && left.displayName === right.displayName && left.description === right.description && left.version === right.version && @@ -1034,7 +1107,7 @@ function SkillDetailView({ @@ -1043,7 +1116,7 @@ function SkillDetailView({

- {skill.slug} + {skill.registryRef ?? skill.slug}

@@ -1227,9 +1300,9 @@ function SkillCardOpen({ ) : null}
- +

- {detail.slug} + {detail.registryRef ?? detail.slug}

{children} @@ -1313,7 +1386,7 @@ function SkillsLibrarySection({ const versionStatus = versionCheckTag(t, versionCheck); const canUpdate = skill.source === "installed" && - skill.origin === "skillhub" && + skill.origin === "skills-sh" && versionCheck?.status === "update_available"; const configuredProviderIds = targetSummaries .filter((summary) => summary.configured) @@ -1364,7 +1437,8 @@ function SkillsLibrarySection({ name: skill.displayName, }); const isManagedInstalledSkill = - skill.source === "installed" && skill.origin === "skillhub"; + skill.source === "installed" && + (skill.origin === "skillhub" || skill.origin === "skills-sh"); const isFilesystemInstalledSkill = skill.source === "installed" && skill.origin === "filesystem"; @@ -1630,12 +1704,15 @@ export const SkillsPanel: FC = ({ workspaceId, refreshToken }) return; } - const cached = skillInfoBySlug[detail.slug]; + const cacheKey = detail.registryRef ?? detail.slug; + const cached = skillInfoBySlug[cacheKey]; setSelectedSkillDetail(cached ? detailFromSkillInfo(cached) : detail); - void loadSkillInfo(detail.slug).then((info) => { + void loadSkillInfo(detail.slug, detail.registryRef).then((info) => { if (info) { setSelectedSkillDetail((current) => - current?.slug === info.slug ? detailFromSkillInfo(info) : current + current?.slug === info.slug && current.registryRef === info.registryRef + ? detailFromSkillInfo(info) + : current ); } }); @@ -1708,7 +1785,8 @@ export const SkillsPanel: FC = ({ workspaceId, refreshToken }) return; } - const cached = skillInfoBySlug[selectedSkillDetail.slug]; + const cacheKey = selectedSkillDetail.registryRef ?? selectedSkillDetail.slug; + const cached = skillInfoBySlug[cacheKey]; if (cached) { const nextDetail = detailFromSkillInfo(cached); if (!skillDetailMatches(nextDetail, selectedSkillDetail)) { @@ -1724,7 +1802,7 @@ export const SkillsPanel: FC = ({ workspaceId, refreshToken }) installingSkillSlugs={installingSkillSlugs} mounts={ mountsBySkillSlug[selectedSkillDetail.slug] ?? - skillInfoBySlug[selectedSkillDetail.slug]?.mounts ?? + skillInfoBySlug[selectedSkillDetail.registryRef ?? selectedSkillDetail.slug]?.mounts ?? [] } onBack={() => setSelectedSkillDetail(null)} @@ -1917,7 +1995,7 @@ export const SkillsPanel: FC = ({ workspaceId, refreshToken }) ) : ( sortedSearchResults.map((item) => (
@@ -1927,6 +2005,11 @@ export const SkillsPanel: FC = ({ workspaceId, refreshToken }) onOpenSkill={openSkillDetail} badges={ <> + {(item.installedVersion ?? item.version) ? ( {formatSkillVersion(item.installedVersion ?? item.version!)} @@ -1944,7 +2027,7 @@ export const SkillsPanel: FC = ({ workspaceId, refreshToken }) size="sm" loading={installingSkillSlugs.has(item.slug)} disabled={item.installed} - onClick={() => void installSkill(item.slug)} + onClick={() => void installSkill(item.slug, item.registryRef)} > {item.installed ? t("skills.installed") : t("skills.install")} @@ -1992,7 +2075,7 @@ export const SkillsPanel: FC = ({ workspaceId, refreshToken }) <> {sortedRecommendations.map((item) => (
@@ -2010,7 +2093,7 @@ export const SkillsPanel: FC = ({ workspaceId, refreshToken }) size="sm" loading={installingSkillSlugs.has(item.slug)} disabled={item.installed} - onClick={() => void installSkill(item.slug)} + onClick={() => void installSkill(item.slug, item.registryRef)} > {item.installed ? t("skills.installed") : t("skills.install")} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 19995e0a..c4815ee8 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -372,6 +372,7 @@ }, "origin": { "skillhub": "skills-hub", + "skills-sh": "skills.sh", "filesystem": "Local" } }, @@ -471,13 +472,15 @@ "discover_expand_label": "Expand Discover", "discover_collapse_label": "Collapse Discover", "search": "Search skills", - "search_placeholder": "Search Skills Hub", - "search_hint": "Search Skills Hub to install into the shared library.", + "search_placeholder": "Search skills.sh", + "search_hint": "Search skills.sh to install into the shared library.", "no_search_results": "No matching skills found.", "no_recommendations": "No recommendations for this workspace yet.", "install": "Install", "installed": "Installed", "available": "Available", + "install_count": "Installs: {count}", + "github_stars": "GitHub stars: {count}", "scan": "Refresh", "check_versions": "Check versions", "checking_versions": "Checking...", diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index 3a0a5941..4072ab59 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -373,6 +373,7 @@ }, "origin": { "skillhub": "skills-hub", + "skills-sh": "skills.sh", "filesystem": "本地" } }, @@ -483,13 +484,15 @@ "discover_expand_label": "展开发现技能", "discover_collapse_label": "收起发现技能", "search": "搜索 Skills", - "search_placeholder": "搜索 Skills Hub", - "search_hint": "从 Skills Hub 搜索并安装到公共 Skill 库。", + "search_placeholder": "搜索 skills.sh", + "search_hint": "从 skills.sh 搜索并安装到公共 Skill 库。", "no_search_results": "未找到匹配的 Skill。", "no_recommendations": "当前工作区暂时没有推荐 Skill。", "install": "安装", "installed": "已安装", "available": "可安装", + "install_count": "下载量:{count}", + "github_stars": "GitHub 星标:{count}", "scan": "刷新", "check_versions": "检查版本", "checking_versions": "检查中...", diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index f1733fcd..7b49fd72 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -19066,6 +19066,25 @@ body.is-dragging-pane .session-action-btn-drag { gap: var(--gap-tight); } +.skills-panel__marketplace-stats { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: var(--gap-compact); + color: var(--text-tertiary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); + font-variant-numeric: tabular-nums; +} + +.skills-panel__marketplace-stat { + display: inline-flex; + align-items: center; + gap: 2px; + white-space: nowrap; +} + .skills-panel__card-title, .skill-targets-drawer__title { margin: 0; diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index 8e1a2f65..e79577a7 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -4247,6 +4247,8 @@ describe("components.css theme-sensitive surfaces", () => { ".skills-panel__card-head-actions .skills-panel__inline-actions" ); const skillsCardOpen = getLastRuleBlock(".skills-panel__card-open"); + const skillsMarketplaceStats = getLastRuleBlock(".skills-panel__marketplace-stats"); + const skillsMarketplaceStat = getLastRuleBlock(".skills-panel__marketplace-stat"); const skillsCardDescriptionTruncated = getLastRuleBlock( ".skills-panel__card-description--truncated" ); @@ -4429,6 +4431,11 @@ describe("components.css theme-sensitive surfaces", () => { expect(skillsCardHeadActions).toContain("margin-right: calc(var(--sp-3) * -1)"); expect(skillsCardHeadActions).toContain("gap: var(--gap-default)"); expect(skillsCardHeadInlineActions).toContain("gap: var(--gap-default)"); + expect(skillsMarketplaceStats).toContain("display: inline-flex"); + expect(skillsMarketplaceStats).toContain("color: var(--text-tertiary)"); + expect(skillsMarketplaceStats).toContain("font-size: var(--type-body-6-size)"); + expect(skillsMarketplaceStats).toContain("font-variant-numeric: tabular-nums"); + expect(skillsMarketplaceStat).toContain("white-space: nowrap"); expect(skillsCardOpen).toContain("display: flex"); expect(skillsCardOpen).toContain("flex: 1 1 auto"); expect(skillsCardOpen).toContain("min-width: 0"); diff --git a/packages/web/src/theme/icon-theme.test.ts b/packages/web/src/theme/icon-theme.test.ts index 99d1f34b..8b0a27dc 100644 --- a/packages/web/src/theme/icon-theme.test.ts +++ b/packages/web/src/theme/icon-theme.test.ts @@ -1,4 +1,4 @@ -import { AlertTriangle, Info, Terminal } from "lucide-react"; +import { AlertTriangle, Download, Info, Star, Terminal } from "lucide-react"; import { describe, expect, it } from "vitest"; import { BASE_ICON_THEME, getIconPresentation, ICON_SEMANTICS, THEME_IDS } from "./index"; @@ -53,6 +53,17 @@ describe("theme icon resolver", () => { expect(new Set([diffIcon, pushIcon, pullIcon, refreshIcon]).size).toBe(4); }); + it("uses stable glyphs for skill marketplace metrics", () => { + expect(BASE_ICON_THEME.icons["skill.metric.installs"]).toEqual({ + glyph: Download, + tone: "muted", + }); + expect(BASE_ICON_THEME.icons["skill.metric.githubStars"]).toEqual({ + glyph: Star, + tone: "warning", + }); + }); + it("gives footer git status semantics a stable visual hierarchy", () => { for (const themeId of builtInThemes) { expect(getIconPresentation(themeId, "git.footer.branch")).toEqual( diff --git a/packages/web/src/theme/icon-theme.ts b/packages/web/src/theme/icon-theme.ts index 30ad8764..47f42f04 100644 --- a/packages/web/src/theme/icon-theme.ts +++ b/packages/web/src/theme/icon-theme.ts @@ -35,6 +35,7 @@ import { Search, Settings, Sparkles, + Star, Terminal, TriangleAlert, Upload, @@ -82,6 +83,8 @@ export const ICON_SEMANTICS = [ "nav.memory", "nav.skills", "nav.extensions", + "skill.metric.installs", + "skill.metric.githubStars", "mobile.dock.agent", "mobile.dock.files", "mobile.dock.terminal", @@ -209,6 +212,8 @@ export const BASE_ICON_THEME: IconThemeDefinition = { "nav.memory": { glyph: FileText, tone: "secondary" }, "nav.skills": { glyph: Package, tone: "current" }, "nav.extensions": { glyph: Puzzle, tone: "current" }, + "skill.metric.installs": { glyph: Download, tone: "muted" }, + "skill.metric.githubStars": { glyph: Star, tone: "warning" }, "mobile.dock.agent": { glyph: Bot, tone: "current" }, "mobile.dock.files": { glyph: PanelLeft, tone: "current" }, "mobile.dock.terminal": { glyph: PanelBottom, tone: "current" }, From 0cd1ff78ea1a47a989d6fd8c9d37a04a70ae93cd Mon Sep 17 00:00:00 2001 From: pallyoung Date: Thu, 27 Aug 2026 16:58:51 +0800 Subject: [PATCH 2/2] chore(release): add skills marketplace patch changeset --- .changeset/switch-skill-marketplace-to-skills-sh.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/switch-skill-marketplace-to-skills-sh.md diff --git a/.changeset/switch-skill-marketplace-to-skills-sh.md b/.changeset/switch-skill-marketplace-to-skills-sh.md new file mode 100644 index 00000000..df8a0183 --- /dev/null +++ b/.changeset/switch-skill-marketplace-to-skills-sh.md @@ -0,0 +1,6 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Switch the Skill marketplace integration to skills.sh, including install support and marketplace +download and GitHub star metrics in the Skill Manager UI.