diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index fe87b4b91c..183bfe20aa 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -178,6 +178,7 @@ import { processTrackingModule } from "@posthog/workspace-server/services/proces import { SECURE_STORE_SERVICE } from "@posthog/workspace-server/services/secure-store/identifiers"; import { shellModule } from "@posthog/workspace-server/services/shell/shell.module"; import { skillsModule } from "@posthog/workspace-server/services/skills/skills.module"; +import { skillsMarketplaceModule } from "@posthog/workspace-server/services/skills-marketplace/skills-marketplace.module"; import { SUSPENSION_FILE_WATCHER, SUSPENSION_SERVICE, @@ -574,6 +575,7 @@ container container.load(posthogPluginModule); container.bind(MAIN_POSTHOG_PLUGIN_SERVICE).toService(POSTHOG_PLUGIN_SERVICE); container.load(skillsModule); +container.load(skillsMarketplaceModule); container.load(onboardingImportModule); container.load(additionalDirectoriesModule); container.bind(MAIN_SLEEP_SERVICE).to(SleepService); diff --git a/packages/host-router/src/routers/skills.router.ts b/packages/host-router/src/routers/skills.router.ts index 9be299b582..a9ac1600fc 100644 --- a/packages/host-router/src/routers/skills.router.ts +++ b/packages/host-router/src/routers/skills.router.ts @@ -15,6 +15,16 @@ import { skillPathOutput, } from "@posthog/workspace-server/services/skills/schemas"; import type { SkillsService } from "@posthog/workspace-server/services/skills/skills"; +import { SKILLS_MARKETPLACE_SERVICE } from "@posthog/workspace-server/services/skills-marketplace/identifiers"; +import { + marketplaceInstallInput, + marketplaceInstallOutput, + marketplacePreviewOutput, + marketplaceSearchInput, + marketplaceSearchOutput, + marketplaceSkillRef, +} from "@posthog/workspace-server/services/skills-marketplace/schemas"; +import type { SkillsMarketplaceService } from "@posthog/workspace-server/services/skills-marketplace/skills-marketplace"; export const skillsRouter = router({ list: publicProcedure @@ -89,4 +99,33 @@ export const skillsRouter = router({ yield event; } }), + marketplace: router({ + search: publicProcedure + .input(marketplaceSearchInput) + .output(marketplaceSearchOutput) + .query(({ ctx, input }) => + ctx.container + .get(SKILLS_MARKETPLACE_SERVICE) + .search(input.query), + ), + preview: publicProcedure + .input(marketplaceSkillRef) + .output(marketplacePreviewOutput) + .query(({ ctx, input }) => + ctx.container + .get(SKILLS_MARKETPLACE_SERVICE) + .preview(input), + ), + install: publicProcedure + .input(marketplaceInstallInput) + .output(marketplaceInstallOutput) + .mutation(({ ctx, input }) => + ctx.container + .get(SKILLS_MARKETPLACE_SERVICE) + .install( + { source: input.source, skillId: input.skillId }, + input.overwrite ?? false, + ), + ), + }), }); diff --git a/packages/ui/src/features/skills/MarketplaceBrowse.tsx b/packages/ui/src/features/skills/MarketplaceBrowse.tsx new file mode 100644 index 0000000000..70428566a8 --- /dev/null +++ b/packages/ui/src/features/skills/MarketplaceBrowse.tsx @@ -0,0 +1,161 @@ +import { MagnifyingGlass, Storefront } from "@phosphor-icons/react"; +import { useDebouncedValue } from "@posthog/ui/primitives/hooks/useDebouncedValue"; +import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; +import { + Badge, + Box, + Flex, + ScrollArea, + Text, + TextField, +} from "@radix-ui/themes"; +import { useState } from "react"; +import { MarketplaceSkillPanel } from "./MarketplaceSkillPanel"; +import { useSkillsSidebarStore } from "./skillsSidebarStore"; +import { + type MarketplaceSkillSummary, + useMarketplaceSearch, +} from "./useMarketplace"; + +const installsFormatter = new Intl.NumberFormat(undefined, { + notation: "compact", +}); + +export function MarketplaceBrowse() { + const [query, setQuery] = useState(""); + const { debounced: debouncedQuery } = useDebouncedValue(query, 300); + const [selected, setSelected] = useState( + null, + ); + + const { data, isLoading, error } = useMarketplaceSearch(debouncedQuery); + const results = data?.results ?? []; + + const { + width: sidebarWidth, + setWidth: setSidebarWidth, + isResizing, + setIsResizing, + } = useSkillsSidebarStore(); + + return ( + + + + + + setQuery(e.target.value)} + className="text-[13px]" + > + + + + + + + {debouncedQuery.trim().length < 2 ? ( + + ) : error ? ( + + ) : isLoading ? ( + Searching... + ) : results.length === 0 ? ( + + ) : ( + + {results.map((result) => ( + + setSelected((prev) => + prev?.id === result.id ? null : result, + ) + } + > + + + + + + {result.name} + + + {result.source} + + + {result.installed && ( + + Installed + + )} + + {installsFormatter.format(result.installs)} + + + ))} + + )} + + + + + + {selected && ( + setSelected(null)} + /> + )} + + + ); +} + +function BrowseEmptyState({ message }: { message: string }) { + return ( + + + + + + {message} + + + ); +} diff --git a/packages/ui/src/features/skills/MarketplaceSkillPanel.tsx b/packages/ui/src/features/skills/MarketplaceSkillPanel.tsx new file mode 100644 index 0000000000..9fbc5efabc --- /dev/null +++ b/packages/ui/src/features/skills/MarketplaceSkillPanel.tsx @@ -0,0 +1,216 @@ +import { DownloadSimple, Warning, X } from "@phosphor-icons/react"; +import { CodeMirrorEditor } from "@posthog/ui/features/code-editor/components/CodeMirrorEditor"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { toast } from "@posthog/ui/primitives/toast"; +import { + AlertDialog, + Badge, + Box, + Button, + Callout, + Flex, + ScrollArea, + Text, + Tooltip, +} from "@radix-ui/themes"; +import { useState } from "react"; +import { SkillFileTree } from "./SkillFileTree"; +import { stripFrontmatter } from "./stripFrontmatter"; +import { + type MarketplaceSkillSummary, + useInstallMarketplaceSkill, + useMarketplacePreview, +} from "./useMarketplace"; + +const installsFormatter = new Intl.NumberFormat(undefined, { + notation: "compact", +}); + +interface MarketplaceSkillPanelProps { + result: MarketplaceSkillSummary; + onClose: () => void; +} + +/** + * Pre-install preview: every file in the skill is visible before anything + * touches disk. Installing a skill is installing code an agent will run. + */ +export function MarketplaceSkillPanel({ + result, + onClose, +}: MarketplaceSkillPanelProps) { + const [selectedFile, setSelectedFile] = useState("SKILL.md"); + const [confirmOverwrite, setConfirmOverwrite] = useState(false); + const { data: preview, isLoading } = useMarketplacePreview({ + source: result.source, + skillId: result.skillId, + }); + const install = useInstallMarketplaceSkill(); + + const files = preview?.files ?? []; + const selected = files.find((f) => f.path === selectedFile); + const isSkillMd = selectedFile === "SKILL.md"; + + const handleInstall = async (overwrite: boolean) => { + try { + await install.mutateAsync({ + source: result.source, + skillId: result.skillId, + overwrite, + }); + setConfirmOverwrite(false); + toast.success(`Installed ${result.name}`, { + description: "Now available under Your skills", + }); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (!overwrite && message.includes("already exists")) { + setConfirmOverwrite(true); + return; + } + toast.error("Failed to install skill", { + description: message || undefined, + }); + } + }; + + return ( + <> + + + + {result.name} + + + + + + + + + {result.source} + + + {installsFormatter.format(result.installs)} installs + + {result.installed && ( + + Installed + + )} + + + + {preview?.hasScripts && ( + + + + + + This skill contains scripts that agents can execute. Review every + file before installing. + + + )} + + + {files.length > 1 && ( + + ({ path: f.path, size: f.size }))} + selectedPath={selectedFile} + onSelect={setSelectedFile} + /> + + )} + + + {isLoading ? ( + + Loading... + + ) : selected?.content == null ? ( + + + {selected + ? "Unable to display this file" + : "No content to preview"} + + + ) : isSkillMd ? ( + + + + + + + + ) : ( + + )} + + + + + Replace local skill + + A skill named "{result.skillId}" already exists in your skills. + Reinstalling will replace your local version, including any edits. + + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/features/skills/SkillDetailPanel.tsx b/packages/ui/src/features/skills/SkillDetailPanel.tsx index a72fb95664..6fb1a90864 100644 --- a/packages/ui/src/features/skills/SkillDetailPanel.tsx +++ b/packages/ui/src/features/skills/SkillDetailPanel.tsx @@ -31,6 +31,7 @@ import { SOURCE_CONFIG } from "./SkillCard"; import { SkillFileEditor } from "./SkillFileEditor"; import { SkillFileTree } from "./SkillFileTree"; import { SkillManifestEditor } from "./SkillManifestEditor"; +import { stripFrontmatter } from "./stripFrontmatter"; import { useSkillContents, useSkillFile } from "./useSkillContents"; import { useDeleteSkill, @@ -39,11 +40,6 @@ import { useSaveSkillFile, } from "./useSkillMutations"; -function stripFrontmatter(content: string): string { - const match = content.match(/^---\s*\n[\s\S]*?\n---\s*\n/); - return match ? content.slice(match[0].length).trimStart() : content; -} - interface SkillDetailPanelProps { skill: SkillInfo; onClose: () => void; diff --git a/packages/ui/src/features/skills/SkillsView.tsx b/packages/ui/src/features/skills/SkillsView.tsx index 3eaf3d5ad0..be7c438dea 100644 --- a/packages/ui/src/features/skills/SkillsView.tsx +++ b/packages/ui/src/features/skills/SkillsView.tsx @@ -1,5 +1,6 @@ import { Lightbulb, MagnifyingGlass, Plus } from "@phosphor-icons/react"; import { analyzeSkills } from "@posthog/core/skills/analyzeSkills"; +import { Tabs, TabsList, TabsTrigger } from "@posthog/quill"; import type { SkillInfo, SkillSource } from "@posthog/shared"; import { Box, @@ -12,6 +13,7 @@ import { import { useCallback, useEffect, useMemo, useState } from "react"; import { useSetHeaderContent } from "../../hooks/useSetHeaderContent"; import { ResizableSidebar } from "../../primitives/ResizableSidebar"; +import { MarketplaceBrowse } from "./MarketplaceBrowse"; import { NewSkillDialog } from "./NewSkillDialog"; import { SkillSection, SOURCE_CONFIG } from "./SkillCard"; import { SkillDetailPanel } from "./SkillDetailPanel"; @@ -25,10 +27,13 @@ import { useSkillsWatcher } from "./useSkillsWatcher"; const SOURCE_ORDER: SkillSource[] = ["user", "marketplace", "repo", "bundled"]; +type SkillsTab = "installed" | "marketplace"; + export function SkillsView() { const { data: skills = [], isLoading } = useSkills(); useSkillsWatcher(); + const [tab, setTab] = useState("installed"); const [selectedPath, setSelectedPath] = useState(null); const [scrollToPath, setScrollToPath] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -113,95 +118,115 @@ export function SkillsView() { return ( - - - - - - - + setTab(value as SkillsTab)} + > + + + Installed + + + Marketplace + + + + + + {tab === "marketplace" ? ( + + ) : ( + + + + + + + setSearchQuery(e.target.value)} + className="text-[13px]" + > + + + + + + - - {skills.length === 0 && !isLoading ? ( - - - - - - No skills found - + + New skill + - ) : ( - - {SOURCE_ORDER.map((source) => { - const items = grouped.get(source); - if (!items || items.length === 0) return null; - const config = SOURCE_CONFIG[source]; - - return ( - - ); - })} - - )} - - - - - - {selectedSkill && ( - - )} - - + {skills.length === 0 && !isLoading ? ( + + + + + + No skills found + + + ) : ( + + {SOURCE_ORDER.map((source) => { + const items = grouped.get(source); + if (!items || items.length === 0) return null; + const config = SOURCE_CONFIG[source]; + + return ( + + ); + })} + + )} + + + + + + {selectedSkill && ( + + )} + + + )} = 2, staleTime: 60_000 }, + ), + ); +} + +export function useMarketplacePreview( + ref: { source: string; skillId: string } | null, +) { + const trpc = useHostTRPC(); + return useQuery( + trpc.skills.marketplace.preview.queryOptions( + { source: ref?.source ?? "", skillId: ref?.skillId ?? "" }, + { enabled: ref !== null, staleTime: 5 * 60_000 }, + ), + ); +} + +export function useInstallMarketplaceSkill() { + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + return useMutation( + trpc.skills.marketplace.install.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.skills.pathFilter()); + }, + }), + ); +} diff --git a/packages/workspace-server/src/services/posthog-plugin/extract-zip.ts b/packages/workspace-server/src/services/posthog-plugin/extract-zip.ts index 4d9b501c22..c0db022012 100644 --- a/packages/workspace-server/src/services/posthog-plugin/extract-zip.ts +++ b/packages/workspace-server/src/services/posthog-plugin/extract-zip.ts @@ -1,15 +1,20 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { type Unzipped, unzip } from "fflate"; +import { type UnzipOptions, type Unzipped, unzip } from "fflate"; // fflate's async unzip yields the event loop so the Electron main thread // stays responsive on large archives. Do not switch back to unzipSync. -export function unzipAsync(data: Uint8Array): Promise { +export function unzipAsync( + data: Uint8Array, + opts?: UnzipOptions, +): Promise { return new Promise((resolve, reject) => { - unzip(data, (err, unzipped) => { + const done = (err: Error | null, unzipped: Unzipped) => { if (err) reject(err); else resolve(unzipped); - }); + }; + if (opts) unzip(data, opts, done); + else unzip(data, done); }); } diff --git a/packages/workspace-server/src/services/skills-marketplace/identifiers.ts b/packages/workspace-server/src/services/skills-marketplace/identifiers.ts new file mode 100644 index 0000000000..dd0e3265fc --- /dev/null +++ b/packages/workspace-server/src/services/skills-marketplace/identifiers.ts @@ -0,0 +1,3 @@ +export const SKILLS_MARKETPLACE_SERVICE = Symbol.for( + "posthog.workspace.skillsMarketplaceService", +); diff --git a/packages/workspace-server/src/services/skills-marketplace/schemas.ts b/packages/workspace-server/src/services/skills-marketplace/schemas.ts new file mode 100644 index 0000000000..2e262f47fe --- /dev/null +++ b/packages/workspace-server/src/services/skills-marketplace/schemas.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +export const marketplaceSkillRef = z.object({ + /** GitHub repository in "owner/repo" form. */ + source: z.string(), + /** Skill directory name inside the repository. */ + skillId: z.string(), +}); + +export const marketplaceSearchInput = z.object({ + query: z.string(), +}); + +export const marketplaceSearchResult = z.object({ + id: z.string(), + skillId: z.string(), + name: z.string(), + installs: z.number(), + source: z.string(), + installed: z.boolean(), +}); + +export const marketplaceSearchOutput = z.object({ + results: z.array(marketplaceSearchResult), +}); + +export const marketplacePreviewFile = z.object({ + // Path relative to the skill directory, using "/" separators. + path: z.string(), + size: z.number(), + /** Null when the file is binary or too large to preview. */ + content: z.string().nullable(), +}); + +export const marketplacePreviewOutput = z.object({ + files: z.array(marketplacePreviewFile), + hasScripts: z.boolean(), +}); + +export const marketplaceInstallInput = marketplaceSkillRef.extend({ + overwrite: z.boolean().optional(), +}); + +export const marketplaceInstallOutput = z.object({ + path: z.string(), +}); + +/** Shape of the skills.sh search API response (external boundary). */ +export const skillsShSearchResponse = z.object({ + skills: z.array( + z.object({ + id: z.string(), + skillId: z.string(), + name: z.string(), + installs: z.number().optional(), + source: z.string(), + }), + ), +}); + +export type MarketplaceSkillRef = z.infer; +export type MarketplaceSearchOutput = z.infer; +export type MarketplacePreviewOutput = z.infer; +export type MarketplacePreviewFile = z.infer; diff --git a/packages/workspace-server/src/services/skills-marketplace/skills-marketplace.module.ts b/packages/workspace-server/src/services/skills-marketplace/skills-marketplace.module.ts new file mode 100644 index 0000000000..d150a30df7 --- /dev/null +++ b/packages/workspace-server/src/services/skills-marketplace/skills-marketplace.module.ts @@ -0,0 +1,9 @@ +import { ContainerModule } from "inversify"; +import { SKILLS_MARKETPLACE_SERVICE } from "./identifiers"; +import { SkillsMarketplaceService } from "./skills-marketplace"; + +export const skillsMarketplaceModule = new ContainerModule(({ bind }) => { + bind(SKILLS_MARKETPLACE_SERVICE) + .to(SkillsMarketplaceService) + .inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/skills-marketplace/skills-marketplace.test.ts b/packages/workspace-server/src/services/skills-marketplace/skills-marketplace.test.ts new file mode 100644 index 0000000000..a4a608e180 --- /dev/null +++ b/packages/workspace-server/src/services/skills-marketplace/skills-marketplace.test.ts @@ -0,0 +1,332 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { strToU8, zipSync } from "fflate"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const testHome = vi.hoisted(() => ({ dir: "" })); + +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + const homedir = () => testHome.dir; + return { ...actual, homedir, default: { ...actual, homedir } }; +}); + +import { existsSync } from "node:fs"; +import { + collectSkillFiles, + findSkillDirPrefix, + SkillsMarketplaceService, + unzipWithLimit, +} from "./skills-marketplace"; + +let root: string; + +function makeRepoZip(extraEntries: Record = {}): Buffer { + return Buffer.from( + zipSync({ + "skills-HEAD/README.md": strToU8("# repo"), + "skills-HEAD/skills/commit/SKILL.md": strToU8( + "---\nname: commit\ndescription: Commits things\n---\nBody", + ), + "skills-HEAD/skills/commit/references/guide.md": strToU8("guide"), + "skills-HEAD/skills/commit/scripts/run.sh": strToU8("echo hi"), + "skills-HEAD/skills/other/SKILL.md": strToU8("---\nname: other\n---\n"), + ...extraEntries, + }), + ); +} + +function stubFetch(handler: (url: string) => Response | Promise) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL) => handler(String(input))), + ); +} + +function zipResponse(zip: Buffer): Response { + return new Response(new Uint8Array(zip), { status: 200 }); +} + +beforeEach(async () => { + // /tmp directly: node:os is mocked, so os.tmpdir() is off the table here. + root = await mkdtemp(path.join("/tmp", "skills-marketplace-test-")); + testHome.dir = root; +}); + +afterEach(async () => { + vi.unstubAllGlobals(); + await rm(root, { recursive: true, force: true }); +}); + +describe("findSkillDirPrefix", () => { + it("finds the shallowest directory with the skill id", () => { + const entries = { + "repo-HEAD/nested/deep/commit/SKILL.md": strToU8("x"), + "repo-HEAD/skills/commit/SKILL.md": strToU8("x"), + }; + + expect(findSkillDirPrefix(entries, "commit")).toBe( + "repo-HEAD/skills/commit/", + ); + }); + + it("returns null when absent", () => { + expect(findSkillDirPrefix({}, "commit")).toBeNull(); + }); +}); + +describe("collectSkillFiles", () => { + it("drops entries that would escape the install directory", () => { + const prefix = "repo-HEAD/skills/commit/"; + const entries = { + [`${prefix}SKILL.md`]: strToU8("ok"), + [`${prefix}../evil.md`]: strToU8("bad"), + [`${prefix}nested/../../evil2.md`]: strToU8("bad"), + [`${prefix}back\\slash.md`]: strToU8("bad"), + }; + + const files = collectSkillFiles(entries, prefix); + + expect([...files.keys()]).toEqual(["SKILL.md"]); + }); +}); + +describe("search", () => { + it("maps skills.sh results and marks installed skills", async () => { + const service = new SkillsMarketplaceService(); + // Install state: "commit" is already installed. + const stateDir = path.join(root, ".claude", "skills"); + await import("node:fs/promises").then((fsp) => + fsp.mkdir(stateDir, { recursive: true }), + ); + await writeFile( + path.join(stateDir, "installed.json"), + JSON.stringify({ + version: 1, + installed: { commit: { repo: "getsentry/skills" } }, + }), + ); + + stubFetch((url) => { + expect(url).toContain("skills.sh/api/search"); + expect(url).toContain("q=commit"); + return new Response( + JSON.stringify({ + skills: [ + { + id: "getsentry/skills/commit", + skillId: "commit", + name: "commit", + installs: 2693, + source: "getsentry/skills", + }, + { + id: "acme/tools/review", + skillId: "review", + name: "review", + source: "acme/tools", + }, + ], + }), + { status: 200 }, + ); + }); + + const { results } = await service.search("commit"); + + expect(results).toEqual([ + expect.objectContaining({ skillId: "commit", installed: true }), + expect.objectContaining({ + skillId: "review", + installs: 0, + installed: false, + }), + ]); + }); + + it("returns empty results for queries under two characters", async () => { + stubFetch(() => { + throw new Error("should not fetch"); + }); + + expect(await new SkillsMarketplaceService().search(" a ")).toEqual({ + results: [], + }); + }); +}); + +describe("preview", () => { + it("returns the full file list with contents and a scripts flag", async () => { + stubFetch(() => zipResponse(makeRepoZip())); + const service = new SkillsMarketplaceService(); + + const preview = await service.preview({ + source: "getsentry/skills", + skillId: "commit", + }); + + expect(preview.files.map((f) => f.path)).toEqual([ + "SKILL.md", + "references/guide.md", + "scripts/run.sh", + ]); + expect(preview.files[0]?.content).toContain("Commits things"); + expect(preview.hasScripts).toBe(true); + }); + + it("marks binary files as non-previewable", async () => { + stubFetch(() => + zipResponse( + makeRepoZip({ + "skills-HEAD/skills/commit/assets/logo.png": new Uint8Array([ + 0x89, 0x50, 0x00, 0x47, + ]), + }), + ), + ); + + const preview = await new SkillsMarketplaceService().preview({ + source: "getsentry/skills", + skillId: "commit", + }); + + const logo = preview.files.find((f) => f.path === "assets/logo.png"); + expect(logo?.content).toBeNull(); + }); + + it("throws when the skill is not in the repository", async () => { + stubFetch(() => zipResponse(makeRepoZip())); + + await expect( + new SkillsMarketplaceService().preview({ + source: "getsentry/skills", + skillId: "nope", + }), + ).rejects.toThrow('Skill "nope" was not found'); + }); + + it("rejects malformed repository references", async () => { + stubFetch(() => zipResponse(makeRepoZip())); + + await expect( + new SkillsMarketplaceService().preview({ + source: "https://evil.example/x", + skillId: "commit", + }), + ).rejects.toThrow("Invalid repository reference"); + }); +}); + +describe("install", () => { + it("copies the skill into the user skills dir and records the badge state", async () => { + stubFetch(() => zipResponse(makeRepoZip())); + const service = new SkillsMarketplaceService(); + + const { path: installedPath } = await service.install({ + source: "getsentry/skills", + skillId: "commit", + }); + + expect(installedPath).toBe(path.join(root, ".claude", "skills", "commit")); + expect( + await readFile(path.join(installedPath, "SKILL.md"), "utf-8"), + ).toContain("Commits things"); + expect(existsSync(path.join(installedPath, "scripts", "run.sh"))).toBe( + true, + ); + + const state = JSON.parse( + await readFile( + path.join(root, ".claude", "skills", "installed.json"), + "utf-8", + ), + ); + expect(state).toEqual({ + version: 1, + installed: { commit: { repo: "getsentry/skills" } }, + }); + }); + + it("requires overwrite when the skill already exists, then replaces it", async () => { + stubFetch(() => zipResponse(makeRepoZip())); + const service = new SkillsMarketplaceService(); + + const { path: installedPath } = await service.install({ + source: "getsentry/skills", + skillId: "commit", + }); + await writeFile(path.join(installedPath, "local-edit.md"), "mine"); + + await expect( + service.install({ source: "getsentry/skills", skillId: "commit" }), + ).rejects.toThrow("already exists"); + + await service.install( + { source: "getsentry/skills", skillId: "commit" }, + true, + ); + // Overwrite replaces the directory wholesale; local edits are gone. + expect(existsSync(path.join(installedPath, "local-edit.md"))).toBe(false); + expect(existsSync(path.join(installedPath, "SKILL.md"))).toBe(true); + }); + + it("rejects invalid skill ids", async () => { + stubFetch(() => zipResponse(makeRepoZip())); + + await expect( + new SkillsMarketplaceService().install({ + source: "getsentry/skills", + skillId: "../escape", + }), + ).rejects.toThrow("Skill names must be"); + }); +}); + +describe("archive download guards", () => { + it("rejects archives whose declared content-length exceeds the cap", async () => { + stubFetch( + () => + new Response(new Uint8Array(makeRepoZip()), { + status: 200, + headers: { "content-length": String(101 * 1024 * 1024) }, + }), + ); + + await expect( + new SkillsMarketplaceService().preview({ + source: "getsentry/skills", + skillId: "commit", + }), + ).rejects.toThrow("too large to download"); + }); + + it("reuses the cached archive within the TTL", async () => { + stubFetch(() => zipResponse(makeRepoZip())); + const service = new SkillsMarketplaceService(); + + await service.preview({ source: "getsentry/skills", skillId: "commit" }); + await service.preview({ source: "getsentry/skills", skillId: "other" }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + }); +}); + +describe("unzipWithLimit", () => { + it("rejects archives that decompress past the budget", async () => { + const zip = zipSync({ + "repo-HEAD/big.bin": new Uint8Array(64 * 1024), + }); + + await expect( + unzipWithLimit(new Uint8Array(zip), 16 * 1024, "a/b"), + ).rejects.toThrow("too large to unpack"); + }); + + it("inflates archives within the budget", async () => { + const zip = zipSync({ "repo-HEAD/small.txt": strToU8("hello") }); + + const entries = await unzipWithLimit(new Uint8Array(zip), 16 * 1024, "a/b"); + + expect(Object.keys(entries)).toContain("repo-HEAD/small.txt"); + }); +}); diff --git a/packages/workspace-server/src/services/skills-marketplace/skills-marketplace.ts b/packages/workspace-server/src/services/skills-marketplace/skills-marketplace.ts new file mode 100644 index 0000000000..14f0925557 --- /dev/null +++ b/packages/workspace-server/src/services/skills-marketplace/skills-marketplace.ts @@ -0,0 +1,315 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { Unzipped } from "fflate"; +import { injectable } from "inversify"; +import { unzipAsync } from "../posthog-plugin/extract-zip"; +import { validateSkillDirName } from "../skills/skills"; +import { + type MarketplacePreviewFile, + type MarketplacePreviewOutput, + type MarketplaceSearchOutput, + type MarketplaceSkillRef, + skillsShSearchResponse, +} from "./schemas"; + +const SKILLS_SH_SEARCH_URL = "https://skills.sh/api/search"; +const REPO_SOURCE_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; +const MAX_UNZIPPED_BYTES = 500 * 1024 * 1024; +const MAX_PREVIEW_FILE_BYTES = 256 * 1024; +const ARCHIVE_CACHE_TTL_MS = 5 * 60_000; +const ARCHIVE_CACHE_MAX_ENTRIES = 4; +const SEARCH_TIMEOUT_MS = 10_000; +const ARCHIVE_DOWNLOAD_TIMEOUT_MS = 60_000; + +interface InstalledSkillsFile { + version: number; + installed: Record; +} + +interface CachedArchive { + fetchedAt: number; + entries: Unzipped; +} + +function userSkillsDir(): string { + return path.join(os.homedir(), ".claude", "skills"); +} + +function installedStatePath(): string { + return path.join(userSkillsDir(), "installed.json"); +} + +/** + * Reads the versioned install-state file. Its only purpose is the + * "Installed" badge in browse results — installs are copy-and-forget. + */ +export async function readInstalledState(): Promise { + try { + const content = await fs.promises.readFile(installedStatePath(), "utf-8"); + const data = JSON.parse(content) as InstalledSkillsFile; + if (!data.installed || typeof data.installed !== "object") { + return { version: 1, installed: {} }; + } + return { version: 1, installed: data.installed }; + } catch { + return { version: 1, installed: {} }; + } +} + +async function writeInstalledState(state: InstalledSkillsFile): Promise { + await fs.promises.mkdir(userSkillsDir(), { recursive: true }); + await fs.promises.writeFile( + installedStatePath(), + `${JSON.stringify(state, null, 2)}\n`, + "utf-8", + ); +} + +/** + * Finds the archive prefix of the directory named `skillId` that contains a + * SKILL.md, e.g. "repo-HEAD/skills/commit/". Prefers the shallowest match. + */ +export function findSkillDirPrefix( + entries: Unzipped, + skillId: string, +): string | null { + const suffix = `/${skillId}/SKILL.md`; + const matches = Object.keys(entries) + .filter((key) => key.endsWith(suffix)) + .sort((a, b) => a.split("/").length - b.split("/").length); + const match = matches[0]; + if (!match) return null; + return match.slice(0, match.length - "SKILL.md".length); +} + +function isProbablyText(bytes: Uint8Array): boolean { + const sample = bytes.subarray(0, 4096); + return !sample.includes(0); +} + +/** Rejects zip entries that would escape the install directory (zip-slip). */ +function isSafeRelativePath(relPath: string): boolean { + if (relPath.length === 0 || relPath.includes("\\")) return false; + const segments = relPath.split("/"); + return segments.every( + (segment) => segment.length > 0 && segment !== "." && segment !== "..", + ); +} + +export function collectSkillFiles( + entries: Unzipped, + prefix: string, +): Map { + const files = new Map(); + for (const [key, bytes] of Object.entries(entries)) { + if (!key.startsWith(prefix) || key.endsWith("/")) continue; + const relPath = key.slice(prefix.length); + if (!isSafeRelativePath(relPath)) continue; + files.set(relPath, bytes); + } + return files; +} + +@injectable() +export class SkillsMarketplaceService { + private archives = new Map(); + + async search(query: string): Promise { + const trimmed = query.trim(); + if (trimmed.length < 2) return { results: [] }; + + const url = new URL(SKILLS_SH_SEARCH_URL); + url.searchParams.set("q", trimmed); + const response = await fetch(url, { + signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`skills.sh search failed: ${response.status}`); + } + const data = skillsShSearchResponse.parse(await response.json()); + const state = await readInstalledState(); + + return { + results: data.skills.map((skill) => ({ + id: skill.id, + skillId: skill.skillId, + name: skill.name, + installs: skill.installs ?? 0, + source: skill.source, + installed: skill.skillId in state.installed, + })), + }; + } + + async preview(ref: MarketplaceSkillRef): Promise { + const files = await this.getSkillFiles(ref); + + const previewFiles: MarketplacePreviewFile[] = [...files.entries()] + .map(([relPath, bytes]) => ({ + path: relPath, + size: bytes.byteLength, + content: + bytes.byteLength <= MAX_PREVIEW_FILE_BYTES && isProbablyText(bytes) + ? new TextDecoder().decode(bytes) + : null, + })) + .sort((a, b) => { + if (a.path === "SKILL.md") return -1; + if (b.path === "SKILL.md") return 1; + return a.path.localeCompare(b.path); + }); + + return { + files: previewFiles, + hasScripts: previewFiles.some((f) => f.path.startsWith("scripts/")), + }; + } + + /** + * Copy-and-forget install: extract the skill directory into + * ~/.claude/skills/. From then on it is an ordinary, editable + * user skill; installed.json only feeds the "Installed" badge. + */ + async install( + ref: MarketplaceSkillRef, + overwrite = false, + ): Promise<{ path: string }> { + validateSkillDirName(ref.skillId); + const target = path.join(userSkillsDir(), ref.skillId); + if (fs.existsSync(target) && !overwrite) { + throw new Error( + `A skill named "${ref.skillId}" already exists. Reinstalling will replace your local version.`, + ); + } + + const files = await this.getSkillFiles(ref); + + if (fs.existsSync(target)) { + await fs.promises.rm(target, { recursive: true, force: true }); + } + for (const [relPath, bytes] of files) { + const filePath = path.join(target, relPath); + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, bytes); + } + + const state = await readInstalledState(); + state.installed[ref.skillId] = { repo: ref.source }; + await writeInstalledState(state); + + return { path: target }; + } + + private async getSkillFiles( + ref: MarketplaceSkillRef, + ): Promise> { + const entries = await this.getRepoArchive(ref.source); + const prefix = findSkillDirPrefix(entries, ref.skillId); + if (!prefix) { + throw new Error(`Skill "${ref.skillId}" was not found in ${ref.source}`); + } + const files = collectSkillFiles(entries, prefix); + if (!files.has("SKILL.md")) { + throw new Error( + `Skill "${ref.skillId}" in ${ref.source} has no SKILL.md`, + ); + } + return files; + } + + private async getRepoArchive(source: string): Promise { + if (!REPO_SOURCE_PATTERN.test(source)) { + throw new Error(`Invalid repository reference: ${source}`); + } + + const cached = this.archives.get(source); + if (cached && Date.now() - cached.fetchedAt < ARCHIVE_CACHE_TTL_MS) { + // LRU: refresh recency on hit. + this.archives.delete(source); + this.archives.set(source, cached); + return cached.entries; + } + + const response = await fetch( + `https://codeload.github.com/${source}/zip/HEAD`, + { signal: AbortSignal.timeout(ARCHIVE_DOWNLOAD_TIMEOUT_MS) }, + ); + if (!response.ok) { + throw new Error(`Failed to download ${source}: ${response.status}`); + } + const declaredBytes = Number(response.headers.get("content-length") ?? 0); + if (declaredBytes > MAX_ARCHIVE_BYTES) { + throw new Error(`Repository ${source} is too large to download`); + } + // codeload responses are chunked; the cap is enforced while streaming. + const buffer = await readBodyWithLimit(response, MAX_ARCHIVE_BYTES, source); + const entries = await unzipWithLimit(buffer, MAX_UNZIPPED_BYTES, source); + + this.archives.set(source, { fetchedAt: Date.now(), entries }); + while (this.archives.size > ARCHIVE_CACHE_MAX_ENTRIES) { + const oldest = this.archives.keys().next().value; + if (oldest === undefined) break; + this.archives.delete(oldest); + } + return entries; + } +} + +async function readBodyWithLimit( + response: Response, + maxBytes: number, + source: string, +): Promise { + const tooLarge = () => + new Error(`Repository ${source} is too large to download`); + const reader = response.body?.getReader(); + if (!reader) { + const buffer = new Uint8Array(await response.arrayBuffer()); + if (buffer.byteLength > maxBytes) throw tooLarge(); + return buffer; + } + + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel(); + throw tooLarge(); + } + chunks.push(value); + } + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +/** Inflates the archive within a decompressed-bytes budget (zip-bomb guard). */ +export function unzipWithLimit( + data: Uint8Array, + maxTotalBytes: number, + source: string, +): Promise { + let total = 0; + let exceeded = false; + return unzipAsync(data, { + filter: (file) => { + total += file.originalSize; + if (total > maxTotalBytes) exceeded = true; + return !exceeded; + }, + }).then((entries) => { + if (exceeded) { + throw new Error(`Repository ${source} is too large to unpack`); + } + return entries; + }); +} diff --git a/packages/workspace-server/src/services/skills/skills.ts b/packages/workspace-server/src/services/skills/skills.ts index f6bc88473a..f27f3e1de9 100644 --- a/packages/workspace-server/src/services/skills/skills.ts +++ b/packages/workspace-server/src/services/skills/skills.ts @@ -337,7 +337,7 @@ export class SkillsService { } } -function validateSkillDirName(name: string): void { +export function validateSkillDirName(name: string): void { if ( !SKILL_DIR_NAME_PATTERN.test(name) || name.length > MAX_SKILL_DIR_NAME_LENGTH