{
- if (typeof error === "object" && error !== null && "_tag" in error && error._tag === "SkillHubError") {
- const typed = error as unknown as { code: SkillStackErrorCode; message: string }
- return { ok: false, code: typed.code, message: typed.message }
- }
- const message =
- typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
- ? error.message
- : String(error)
- return { ok: false, code: "unknown", message }
+ return { ok: false, ...hubError(error) }
}
function errorKey(code: SkillStackErrorCode) {
diff --git a/packages/app/src/skillhub/hub-error.ts b/packages/app/src/skillhub/hub-error.ts
new file mode 100644
index 000000000..302698a75
--- /dev/null
+++ b/packages/app/src/skillhub/hub-error.ts
@@ -0,0 +1,17 @@
+import type { SkillStackErrorCode } from "@opencode-ai/sdk/v2/client"
+
+// The server answers Skill Hub failures with a SkillHubError carrying a
+// machine-readable code the dialogs key their UI off (`already-installed`
+// drives an overwrite offer, `auth` a sign-in prompt, `network` the
+// hub-unreachable state); anything else on the wire still carries a message.
+export function hubError(error: unknown): { code: SkillStackErrorCode; message: string } {
+ if (typeof error === "object" && error !== null && "_tag" in error && error._tag === "SkillHubError") {
+ const typed = error as unknown as { code: SkillStackErrorCode; message: string }
+ return { code: typed.code, message: typed.message }
+ }
+ const message =
+ typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
+ ? error.message
+ : String(error)
+ return { code: "unknown", message }
+}
diff --git a/packages/app/src/skillhub/skill-card.tsx b/packages/app/src/skillhub/skill-card.tsx
index 2f926d648..063744ae3 100644
--- a/packages/app/src/skillhub/skill-card.tsx
+++ b/packages/app/src/skillhub/skill-card.tsx
@@ -1,7 +1,7 @@
+import type { SkillHubCard } from "@opencode-ai/sdk/v2/client"
import { useNavigate } from "@solidjs/router"
import { Show } from "solid-js"
import { useLanguage } from "@/context/language"
-import type { SkillHubCard } from "./types"
// One hub result. Text renders through Solid's interpolation only — hub
// strings are attacker-influenced and must never reach innerHTML.
@@ -28,7 +28,8 @@ export function SkillCard(props: { skill: SkillHubCard }) {
{author()}
- ★ {props.skill.stars ? props.skill.stars.toFixed(1) : "–"}
+ {/* Schema.Number admits "NaN"/"Infinity" strings, so the generated type is not plain `number`. */}★{" "}
+ {Number(props.skill.stars) ? Number(props.skill.stars).toFixed(1) : "–"}
↓ {props.skill.downloadCount}
diff --git a/packages/app/src/skillhub/types.ts b/packages/app/src/skillhub/types.ts
deleted file mode 100644
index 1edbfc356..000000000
--- a/packages/app/src/skillhub/types.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-// Wire contract between the desktop main process's SkillHub controller and
-// the renderer, mirroring the gateway pattern (src/gateway/types.ts): the app
-// owns the types, preload aliases them, and the main process keeps the
-// node-flavored implementation in packages/desktop — the app must never import
-// @codev/skillhub. Everything here must be structured-clone-safe plain JSON.
-//
-// The renderer cannot call the SkillHub API itself: the hub serves no
-// Access-Control-Allow-Origin on /api/v1 and rejects cross-origin writes, so
-// every request originates in the Electron main process.
-
-export type SkillHubAgent = "claude" | "codex" | "opencode" | "codev"
-
-export type SkillHubSort = "featured" | "newest" | "downloads" | "rating"
-export type SkillHubDomain = "developer" | "personal-assistant" | "workflow-builder"
-
-export type SkillHubBrowseParams = {
- search?: string
- page?: number
- // Server clamps to 1-50.
- limit?: number
- sort?: SkillHubSort
- domain?: SkillHubDomain
-}
-
-export type SkillHubCard = {
- id: string
- name: string
- provider: string
- description: string
- version: string
- publishedAt: string | null
- downloadCount: number
- stars: number
- totalRatings: number
- targetRole?: string
- author?: { name?: string; avatar?: string | null; username?: string }
- metadata?: { tags?: string[]; category?: string; domain?: string; publisher?: string }
- runtimes?: { name: string; color?: string }[]
-}
-
-export type SkillHubDetail = SkillHubCard & {
- slug?: string
- displayName?: string
- summary?: string
- changelog?: string
- // Text files inlined by the hub — SKILL.md renders from here directly.
- files: { filePath: string; content: string }[]
-}
-
-export type SkillHubPagination = { page: number; limit: number; total: number; totalPages: number }
-
-export type SkillHubScope = { kind: "global" } | { kind: "project"; directory: string }
-
-export type SkillHubInstallRequest = {
- id: string
- scope: SkillHubScope
- agents: SkillHubAgent[]
- force: boolean
-}
-
-// How one installed path was produced; a fallback copy is never called a link.
-export type SkillHubPlacement = {
- path: string
- mode: "store" | "symlink" | "junction" | "copy"
- agents: SkillHubAgent[]
-}
-
-export type SkillHubInstallOutcome = {
- name: string
- version?: string
- placements: SkillHubPlacement[]
-}
-
-// Discriminated results instead of throws: ipcMain.handle rejections arrive as
-// mangled "Error invoking remote method" strings, while `already-installed`
-// must stay machine-readable to drive the overwrite offer, `network` the
-// hub-unreachable state, and `missing-directory` the case where the chosen
-// project folder is not on the machine the app runs on.
-export type SkillHubError = {
- code: "auth" | "not-found" | "already-installed" | "network" | "missing-directory" | "unknown"
- message: string
-}
-
-export type SkillHubResult = { ok: true; value: T } | { ok: false; error: SkillHubError }
-
-export type SkillHubPlatform = {
- browse(
- params: SkillHubBrowseParams,
- ): Promise>
- detail(idOrName: string): Promise>
- install(request: SkillHubInstallRequest): Promise>
-}
diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts
index 6824d6400..113515507 100644
--- a/packages/client/src/contract.ts
+++ b/packages/client/src/contract.ts
@@ -43,6 +43,9 @@ export const endpointNames = {
"skillhub.stack.prepare": "prepareStack",
"skillhub.stack.install": "installStack",
"skillhub.stack.discard": "discardStack",
+ "skillhub.skill.list": "listSkills",
+ "skillhub.skill.get": "getSkill",
+ "skillhub.skill.install": "installSkill",
"session.messages": "list",
"integration.connect.key": "connectKey",
"integration.connect.oauth": "connectOauth",
diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts
index bc7c77740..51ab4860e 100644
--- a/packages/client/src/generated-effect/client.ts
+++ b/packages/client/src/generated-effect/client.ts
@@ -730,11 +730,60 @@ const Endpoint18_3 = (raw: RawClient["server.skillhub"]) => (input: Endpoint18_3
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
+type Endpoint18_4Request = Parameters[0]
+type Endpoint18_4Input = {
+ readonly location?: Endpoint18_4Request["query"]["location"]
+ readonly search?: Endpoint18_4Request["query"]["search"]
+ readonly page?: Endpoint18_4Request["query"]["page"]
+ readonly limit?: Endpoint18_4Request["query"]["limit"]
+ readonly sort?: Endpoint18_4Request["query"]["sort"]
+ readonly domain?: Endpoint18_4Request["query"]["domain"]
+}
+const Endpoint18_4 = (raw: RawClient["server.skillhub"]) => (input?: Endpoint18_4Input) =>
+ raw["skillhub.skill.list"]({
+ query: {
+ location: input?.["location"],
+ search: input?.["search"],
+ page: input?.["page"],
+ limit: input?.["limit"],
+ sort: input?.["sort"],
+ domain: input?.["domain"],
+ },
+ }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint18_5Request = Parameters[0]
+type Endpoint18_5Input = {
+ readonly idOrName: Endpoint18_5Request["params"]["idOrName"]
+ readonly location?: Endpoint18_5Request["query"]["location"]
+}
+const Endpoint18_5 = (raw: RawClient["server.skillhub"]) => (input: Endpoint18_5Input) =>
+ raw["skillhub.skill.get"]({ params: { idOrName: input["idOrName"] }, query: { location: input["location"] } }).pipe(
+ Effect.mapError(mapClientError),
+ )
+
+type Endpoint18_6Request = Parameters[0]
+type Endpoint18_6Input = {
+ readonly id: Endpoint18_6Request["params"]["id"]
+ readonly location?: Endpoint18_6Request["query"]["location"]
+ readonly scope: Endpoint18_6Request["payload"]["scope"]
+ readonly agents: Endpoint18_6Request["payload"]["agents"]
+ readonly force: Endpoint18_6Request["payload"]["force"]
+}
+const Endpoint18_6 = (raw: RawClient["server.skillhub"]) => (input: Endpoint18_6Input) =>
+ raw["skillhub.skill.install"]({
+ params: { id: input["id"] },
+ query: { location: input["location"] },
+ payload: { scope: input["scope"], agents: input["agents"], force: input["force"] },
+ }).pipe(Effect.mapError(mapClientError))
+
const adaptGroup18 = (raw: RawClient["server.skillhub"]) => ({
listStacks: Endpoint18_0(raw),
prepareStack: Endpoint18_1(raw),
installStack: Endpoint18_2(raw),
discardStack: Endpoint18_3(raw),
+ listSkills: Endpoint18_4(raw),
+ getSkill: Endpoint18_5(raw),
+ installSkill: Endpoint18_6(raw),
})
const adaptClient = (raw: RawClient) => ({
diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts
index aeb1677ed..76bbd0828 100644
--- a/packages/client/src/generated/client.ts
+++ b/packages/client/src/generated/client.ts
@@ -120,6 +120,12 @@ import type {
SkillhubInstallStackOutput,
SkillhubDiscardStackInput,
SkillhubDiscardStackOutput,
+ SkillhubListSkillsInput,
+ SkillhubListSkillsOutput,
+ SkillhubGetSkillInput,
+ SkillhubGetSkillOutput,
+ SkillhubInstallSkillInput,
+ SkillhubInstallSkillOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -1051,6 +1057,50 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
+ listSkills: (input?: SkillhubListSkillsInput, requestOptions?: RequestOptions) =>
+ request(
+ {
+ method: "GET",
+ path: `/api/skillhub/skill`,
+ query: {
+ location: input?.["location"],
+ search: input?.["search"],
+ page: input?.["page"],
+ limit: input?.["limit"],
+ sort: input?.["sort"],
+ domain: input?.["domain"],
+ },
+ successStatus: 200,
+ declaredStatuses: [400, 401],
+ empty: false,
+ },
+ requestOptions,
+ ),
+ getSkill: (input: SkillhubGetSkillInput, requestOptions?: RequestOptions) =>
+ request(
+ {
+ method: "GET",
+ path: `/api/skillhub/skill/${encodeURIComponent(input.idOrName)}`,
+ query: { location: input["location"] },
+ successStatus: 200,
+ declaredStatuses: [400, 401],
+ empty: false,
+ },
+ requestOptions,
+ ),
+ installSkill: (input: SkillhubInstallSkillInput, requestOptions?: RequestOptions) =>
+ request(
+ {
+ method: "POST",
+ path: `/api/skillhub/skill/${encodeURIComponent(input.id)}/install`,
+ query: { location: input["location"] },
+ body: { scope: input["scope"], agents: input["agents"], force: input["force"] },
+ successStatus: 200,
+ declaredStatuses: [400, 401],
+ empty: false,
+ },
+ requestOptions,
+ ),
},
}
}
diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts
index d058fd824..9da0ef4c6 100644
--- a/packages/client/src/generated/types.ts
+++ b/packages/client/src/generated/types.ts
@@ -2960,3 +2960,171 @@ export type SkillhubDiscardStackInput = {
}
export type SkillhubDiscardStackOutput = void
+
+export type SkillhubListSkillsInput = {
+ readonly location?: {
+ readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+ readonly search?: string | undefined
+ readonly page?: number | undefined
+ readonly limit?: number | undefined
+ readonly sort?: ("featured" | "newest" | "downloads" | "rating") | undefined
+ readonly domain?: ("developer" | "personal-assistant" | "workflow-builder") | undefined
+ }["location"]
+ readonly search?: {
+ readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+ readonly search?: string | undefined
+ readonly page?: number | undefined
+ readonly limit?: number | undefined
+ readonly sort?: ("featured" | "newest" | "downloads" | "rating") | undefined
+ readonly domain?: ("developer" | "personal-assistant" | "workflow-builder") | undefined
+ }["search"]
+ readonly page?: {
+ readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+ readonly search?: string | undefined
+ readonly page?: number | undefined
+ readonly limit?: number | undefined
+ readonly sort?: ("featured" | "newest" | "downloads" | "rating") | undefined
+ readonly domain?: ("developer" | "personal-assistant" | "workflow-builder") | undefined
+ }["page"]
+ readonly limit?: {
+ readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+ readonly search?: string | undefined
+ readonly page?: number | undefined
+ readonly limit?: number | undefined
+ readonly sort?: ("featured" | "newest" | "downloads" | "rating") | undefined
+ readonly domain?: ("developer" | "personal-assistant" | "workflow-builder") | undefined
+ }["limit"]
+ readonly sort?: {
+ readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+ readonly search?: string | undefined
+ readonly page?: number | undefined
+ readonly limit?: number | undefined
+ readonly sort?: ("featured" | "newest" | "downloads" | "rating") | undefined
+ readonly domain?: ("developer" | "personal-assistant" | "workflow-builder") | undefined
+ }["sort"]
+ readonly domain?: {
+ readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+ readonly search?: string | undefined
+ readonly page?: number | undefined
+ readonly limit?: number | undefined
+ readonly sort?: ("featured" | "newest" | "downloads" | "rating") | undefined
+ readonly domain?: ("developer" | "personal-assistant" | "workflow-builder") | undefined
+ }["domain"]
+}
+
+export type SkillhubListSkillsOutput = {
+ readonly location: {
+ readonly directory: string
+ readonly workspaceID?: string
+ readonly project: { readonly id: string; readonly directory: string }
+ }
+ readonly data: {
+ readonly items: ReadonlyArray<{
+ readonly id: string
+ readonly name: string
+ readonly provider: string
+ readonly description: string
+ readonly version: string
+ readonly publishedAt: string | null
+ readonly downloadCount: number | "Infinity" | "-Infinity" | "NaN"
+ readonly stars: number | "Infinity" | "-Infinity" | "NaN"
+ readonly totalRatings: number | "Infinity" | "-Infinity" | "NaN"
+ readonly targetRole?: string
+ readonly author?: { readonly name?: string; readonly avatar?: string | null; readonly username?: string }
+ readonly metadata?: {
+ readonly tags?: ReadonlyArray
+ readonly category?: string
+ readonly domain?: string
+ readonly publisher?: string
+ }
+ readonly runtimes?: ReadonlyArray<{ readonly name: string; readonly color?: string }>
+ }>
+ readonly pagination: {
+ readonly page: number | "Infinity" | "-Infinity" | "NaN"
+ readonly limit: number | "Infinity" | "-Infinity" | "NaN"
+ readonly total: number | "Infinity" | "-Infinity" | "NaN"
+ readonly totalPages: number | "Infinity" | "-Infinity" | "NaN"
+ }
+ }
+}
+
+export type SkillhubGetSkillInput = {
+ readonly idOrName: { readonly idOrName: string }["idOrName"]
+ readonly location?: {
+ readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+ }["location"]
+}
+
+export type SkillhubGetSkillOutput = {
+ readonly location: {
+ readonly directory: string
+ readonly workspaceID?: string
+ readonly project: { readonly id: string; readonly directory: string }
+ }
+ readonly data: {
+ readonly id: string
+ readonly name: string
+ readonly provider: string
+ readonly description: string
+ readonly version: string
+ readonly publishedAt: string | null
+ readonly downloadCount: number | "Infinity" | "-Infinity" | "NaN"
+ readonly stars: number | "Infinity" | "-Infinity" | "NaN"
+ readonly totalRatings: number | "Infinity" | "-Infinity" | "NaN"
+ readonly targetRole?: string
+ readonly author?: { readonly name?: string; readonly avatar?: string | null; readonly username?: string }
+ readonly metadata?: {
+ readonly tags?: ReadonlyArray
+ readonly category?: string
+ readonly domain?: string
+ readonly publisher?: string
+ }
+ readonly runtimes?: ReadonlyArray<{ readonly name: string; readonly color?: string }>
+ readonly slug?: string
+ readonly displayName?: string
+ readonly summary?: string
+ readonly changelog?: string
+ readonly files: ReadonlyArray<{ readonly filePath: string; readonly content: string }>
+ }
+}
+
+export type SkillhubInstallSkillInput = {
+ readonly id: { readonly id: string }["id"]
+ readonly location?: {
+ readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+ }["location"]
+ readonly scope: {
+ readonly scope: "project" | "global"
+ readonly agents: ReadonlyArray<"claude" | "codex" | "opencode" | "codev">
+ readonly force: boolean
+ }["scope"]
+ readonly agents: {
+ readonly scope: "project" | "global"
+ readonly agents: ReadonlyArray<"claude" | "codex" | "opencode" | "codev">
+ readonly force: boolean
+ }["agents"]
+ readonly force: {
+ readonly scope: "project" | "global"
+ readonly agents: ReadonlyArray<"claude" | "codex" | "opencode" | "codev">
+ readonly force: boolean
+ }["force"]
+}
+
+export type SkillhubInstallSkillOutput = {
+ readonly location: {
+ readonly directory: string
+ readonly workspaceID?: string
+ readonly project: { readonly id: string; readonly directory: string }
+ }
+ readonly data: {
+ readonly id: string
+ readonly name: string
+ readonly version?: string
+ readonly dir: string
+ readonly placements: ReadonlyArray<{
+ readonly path: string
+ readonly mode: "store" | "symlink" | "junction" | "copy"
+ readonly agents: ReadonlyArray<"claude" | "codex" | "opencode" | "codev">
+ }>
+ }
+}
diff --git a/packages/codev-skillhub/src/hub.ts b/packages/codev-skillhub/src/hub.ts
index 7194feca5..d5e07ef1d 100644
--- a/packages/codev-skillhub/src/hub.ts
+++ b/packages/codev-skillhub/src/hub.ts
@@ -124,9 +124,9 @@ export async function getSkillDetail(idOrName: string): Promise
function toCard(raw: unknown): HubSkillCard {
const data = (raw ?? {}) as Record
- const metadata = (data.metadata ?? undefined) as HubSkillCard["metadata"]
- const author = (data.author ?? undefined) as HubSkillCard["author"]
- const runtimes = Array.isArray(data.runtimes) ? (data.runtimes as HubSkillCard["runtimes"]) : undefined
+ const metadata = toMetadata(data.metadata)
+ const author = toAuthor(data.author)
+ const runtimes = toRuntimes(data.runtimes)
return {
id: asString(data.id) ?? "",
name: asString(data.name) ?? "",
@@ -151,3 +151,40 @@ function asString(value: unknown): string | undefined {
function asNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0
}
+
+// The hub stores unset columns as null and sends them as such (`publisher:
+// null`, `avatar: null`), while the nested objects are typed with absent
+// fields. Nulls become absent here, once, so every consumer — the CLI's
+// printer, the server's strict response schema — sees the same total shape.
+// Only `avatar` keeps its null: the hub means "no picture" by it.
+function toAuthor(raw: unknown): HubSkillCard["author"] {
+ if (typeof raw !== "object" || raw === null) return undefined
+ const data = raw as Record
+ return {
+ name: asString(data.name),
+ avatar: asString(data.avatar) ?? null,
+ username: asString(data.username),
+ }
+}
+
+function toMetadata(raw: unknown): HubSkillCard["metadata"] {
+ if (typeof raw !== "object" || raw === null) return undefined
+ const data = raw as Record
+ return {
+ tags: Array.isArray(data.tags) ? data.tags.filter((tag): tag is string => typeof tag === "string") : undefined,
+ category: asString(data.category),
+ domain: asString(data.domain),
+ publisher: asString(data.publisher),
+ }
+}
+
+function toRuntimes(raw: unknown): HubSkillCard["runtimes"] {
+ if (!Array.isArray(raw)) return undefined
+ return raw.flatMap((item) => {
+ if (typeof item !== "object" || item === null) return []
+ const data = item as Record
+ const name = asString(data.name)
+ if (name === undefined) return []
+ return [{ name, color: asString(data.color) }]
+ })
+}
diff --git a/packages/codev-skillhub/test/hub.test.ts b/packages/codev-skillhub/test/hub.test.ts
index 5f937b5f0..5d12a29b5 100644
--- a/packages/codev-skillhub/test/hub.test.ts
+++ b/packages/codev-skillhub/test/hub.test.ts
@@ -94,6 +94,32 @@ describe("browseHubSkills", () => {
})
})
+ test("turns the hub's nulls into absent fields so a strict consumer never sees them", async () => {
+ // The hub sends unset columns as null; the server's response schema types
+ // these as optional strings and would refuse the whole page over one.
+ stubFetch(
+ jsonResponse(200, {
+ success: true,
+ data: [
+ {
+ id: "id-1",
+ name: "prism-noc",
+ targetRole: null,
+ author: { name: null, username: "ann", avatar: null },
+ metadata: { tags: ["a", 7, null], category: null, domain: "developer", publisher: null },
+ runtimes: [{ name: "Python", color: null }, { name: null }, "junk"],
+ },
+ ],
+ pagination: { page: 1, limit: 12, total: 1, totalPages: 1 },
+ }),
+ )
+ const card = (await browseHubSkills()).items[0]
+ expect(card?.targetRole).toBeUndefined()
+ expect(card?.author).toEqual({ name: undefined, username: "ann", avatar: null })
+ expect(card?.metadata).toEqual({ tags: ["a"], category: undefined, domain: "developer", publisher: undefined })
+ expect(card?.runtimes).toEqual([{ name: "Python", color: undefined }])
+ })
+
test("no params hits the bare endpoint and coerces missing fields", async () => {
const spy = stubFetch(
jsonResponse(200, {
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 97fb9690a..6a5d3d0ca 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -38,7 +38,6 @@
"devDependencies": {
"@actions/artifact": "4.0.0",
"@codev/gateway": "workspace:*",
- "@codev/skillhub": "workspace:*",
"@codev/sandbox": "workspace:*",
"@codev/upload": "workspace:*",
"@opencode-ai/app": "workspace:*",
diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts
index 8bae9ddde..3753cbda3 100644
--- a/packages/desktop/src/main/index.ts
+++ b/packages/desktop/src/main/index.ts
@@ -7,7 +7,6 @@ import { join } from "node:path"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import type { Event } from "electron"
import { app, BrowserWindow, net, Notification, session, shell } from "electron"
-import { SKILLHUB_URL } from "@codev/skillhub"
import { shouldNudgeSignIn } from "@codev/upload/nudge"
import type { ServerSource as UploadServerSource } from "@codev/upload"
@@ -54,8 +53,6 @@ import { registerSandboxIpcHandlers } from "./sandbox/ipc"
import { flushStoreWrites } from "./store"
import { createGatewaySsoController } from "./gateway-sso/controller"
import { registerGatewaySsoIpcHandlers } from "./gateway-sso/ipc"
-import { createSkillHubController } from "./skillhub/controller"
-import { registerSkillHubIpcHandlers } from "./skillhub/ipc"
import { migrate } from "./migrate"
import { cleanupStoreFiles } from "./store-cleanup"
import { renameLegacyStoreFiles } from "./store-rebrand"
@@ -418,20 +415,6 @@ const main = Effect.gen(function* () {
})
registerWslIpcHandlers(wslServers)
registerSandboxIpcHandlers(sandboxes)
- registerSkillHubIpcHandlers(
- createSkillHubController({
- // Chromium's fetch so host proxies, PAC files, and enterprise roots
- // apply to hub traffic exactly as they do to the gateway SSO calls below.
- fetchImpl: (input, init) =>
- net.fetch(input instanceof URL ? input.toString() : input, init as Parameters[1]),
- // Settles once Chromium's proxy initialization is done; logged so a
- // report from a proxied network says which proxy the hub went through.
- warmProxy: () =>
- session.defaultSession.resolveProxy(SKILLHUB_URL).then((proxy) => {
- logger.log("skillhub proxy resolved", { proxy })
- }),
- }),
- )
registerGatewaySsoIpcHandlers(
createGatewaySsoController({
openUrl: (url) => shell.openExternal(url),
diff --git a/packages/desktop/src/main/skillhub/controller.test.ts b/packages/desktop/src/main/skillhub/controller.test.ts
deleted file mode 100644
index 19316d4c3..000000000
--- a/packages/desktop/src/main/skillhub/controller.test.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
-import { SkillhubAuthError } from "@codev/skillhub"
-import { createSkillHubController, skillhubApi } from "./controller"
-
-const controller = createSkillHubController()
-
-const META = { id: "id-1", name: "pg-tuner", version: "1.2.0" }
-
-afterEach(() => {
- mock.restore()
-})
-
-describe("skillhub controller", () => {
- test("browse maps items and pagination into plain wire shapes", async () => {
- spyOn(skillhubApi, "browseHubSkills").mockResolvedValue({
- items: [
- {
- id: "id-1",
- name: "pg-tuner",
- provider: "acme",
- description: "d",
- version: "1.2.0",
- publishedAt: null,
- downloadCount: 3,
- stars: 4,
- totalRatings: 2,
- author: { username: "ann" },
- runtimes: [{ name: "Python", color: "#123" }],
- },
- ],
- pagination: { page: 1, limit: 24, total: 1, totalPages: 1 },
- })
- const result = await controller.browse({ search: "pg" })
- if (!result.ok) throw new Error("expected ok")
- expect(result.value.items[0]).toMatchObject({ id: "id-1", stars: 4, author: { username: "ann" } })
- expect(result.value.pagination).toEqual({ page: 1, limit: 24, total: 1, totalPages: 1 })
- })
-
- test("install folds CoDev Code into the agent set whatever the renderer sent", async () => {
- spyOn(skillhubApi, "getSkillMeta").mockResolvedValue(META)
- const install = spyOn(skillhubApi, "installResolvedSkill").mockResolvedValue({
- name: "pg-tuner",
- version: "1.2.0",
- id: "id-1",
- dir: "/tmp/x",
- strippedRoot: null,
- placements: [{ path: "/tmp/x", mode: "store", agents: ["claude", "codev"] }],
- })
- const result = await controller.install({
- id: "id-1",
- scope: { kind: "global" },
- agents: ["claude"],
- force: false,
- })
- expect(result.ok).toBe(true)
- expect(install.mock.calls[0]?.[1]).toEqual({
- target: { kind: "agents", agents: ["claude", "codev"], scope: "global" },
- force: false,
- })
- })
-
- test("project scope becomes an agents-at target rooted at the given directory", async () => {
- spyOn(skillhubApi, "pathExists").mockResolvedValue(true)
- spyOn(skillhubApi, "getSkillMeta").mockResolvedValue(META)
- const install = spyOn(skillhubApi, "installResolvedSkill").mockResolvedValue({
- name: "pg-tuner",
- version: "1.2.0",
- id: "id-1",
- dir: "/proj/.claude/skills/pg-tuner",
- strippedRoot: null,
- placements: [{ path: "/proj/.claude/skills/pg-tuner", mode: "store", agents: ["codev"] }],
- })
- const result = await controller.install({
- id: "id-1",
- scope: { kind: "project", directory: "/proj" },
- agents: [],
- force: true,
- })
- expect(result.ok).toBe(true)
- expect(install.mock.calls[0]?.[1]).toEqual({
- target: { kind: "agents-at", agents: ["codev"], root: "/proj" },
- force: true,
- })
- })
-
- test("error normalization: auth, already-installed, not-found, network, unknown", async () => {
- const fail = async (err: unknown) => {
- spyOn(skillhubApi, "getSkillDetail").mockRejectedValue(err)
- const result = await controller.detail("x")
- mock.restore()
- if (result.ok) throw new Error("expected error")
- return result.error
- }
-
- expect((await fail(new SkillhubAuthError("sign in"))).code).toBe("auth")
- expect((await fail(new Error("Already installed at /p/x. Pass --force to overwrite."))).code).toBe(
- "already-installed",
- )
- expect((await fail(new Error('Skill "x" not found or not public.'))).code).toBe("not-found")
- const withCause = new Error("fetch failed")
- withCause.cause = new Error("ECONNREFUSED")
- expect((await fail(withCause)).code).toBe("network")
- expect((await fail(new Error("boom"))).code).toBe("unknown")
- })
-
- test("a project folder that is not on this machine is refused before any hub traffic", async () => {
- spyOn(skillhubApi, "pathExists").mockResolvedValue(false)
- const meta = spyOn(skillhubApi, "getSkillMeta").mockResolvedValue(META)
- const result = await controller.install({
- id: "id-1",
- scope: { kind: "project", directory: "/home/wsl-user/proj" },
- agents: [],
- force: false,
- })
- if (result.ok) throw new Error("expected error")
- expect(result.error.code).toBe("missing-directory")
- expect(result.error.message).toContain("/home/wsl-user/proj")
- expect(meta).not.toHaveBeenCalled()
- })
-
- test("Chromium network codes classify as network errors", async () => {
- spyOn(skillhubApi, "getSkillDetail").mockRejectedValue(new Error("net::ERR_PROXY_CONNECTION_FAILED"))
- const result = await controller.detail("x")
- if (result.ok) throw new Error("expected error")
- expect(result.error.code).toBe("network")
- })
-
- test("requests wait for the proxy warm-up once, then never again", async () => {
- let release = () => {}
- const warm = mock(() => new Promise((resolve) => (release = resolve)))
- const browse = spyOn(skillhubApi, "browseHubSkills").mockResolvedValue({
- items: [],
- pagination: { page: 1, limit: 24, total: 0, totalPages: 1 },
- })
- const warmed = createSkillHubController({ warmProxy: warm })
- const first = warmed.browse({})
- const second = warmed.browse({})
- await Promise.resolve()
- expect(browse).not.toHaveBeenCalled()
- release()
- expect((await first).ok).toBe(true)
- expect((await second).ok).toBe(true)
- await warmed.detail("x").catch(() => {})
- expect(warm).toHaveBeenCalledTimes(1)
- })
-
- test("a failed proxy warm-up does not block requests", async () => {
- spyOn(skillhubApi, "browseHubSkills").mockResolvedValue({
- items: [],
- pagination: { page: 1, limit: 24, total: 0, totalPages: 1 },
- })
- const warmed = createSkillHubController({ warmProxy: () => Promise.reject(new Error("no proxy service")) })
- expect((await warmed.browse({})).ok).toBe(true)
- })
-
- test("already-installed keeps the path in the message for the overwrite offer", async () => {
- spyOn(skillhubApi, "pathExists").mockResolvedValue(true)
- spyOn(skillhubApi, "getSkillMeta").mockResolvedValue(META)
- spyOn(skillhubApi, "installResolvedSkill").mockRejectedValue(
- new Error("Already installed at /proj/.claude/skills/pg-tuner. Pass --force to overwrite."),
- )
- const result = await controller.install({
- id: "id-1",
- scope: { kind: "project", directory: "/proj" },
- agents: ["claude"],
- force: false,
- })
- if (result.ok) throw new Error("expected error")
- expect(result.error.code).toBe("already-installed")
- expect(result.error.message).toContain("/proj/.claude/skills/pg-tuner")
- })
-})
diff --git a/packages/desktop/src/main/skillhub/controller.ts b/packages/desktop/src/main/skillhub/controller.ts
deleted file mode 100644
index 5ffa17e7f..000000000
--- a/packages/desktop/src/main/skillhub/controller.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import {
- browseHubSkills,
- describeNetworkError,
- getSkillDetail,
- getSkillMeta,
- installResolvedSkill,
- pathExists,
- setHubTransport,
- SKILL_AGENTS,
- SkillhubAuthError,
- type InstallTarget,
- type SkillAgent,
-} from "@codev/skillhub"
-import type {
- SkillHubAgent,
- SkillHubBrowseParams,
- SkillHubCard,
- SkillHubDetail,
- SkillHubError,
- SkillHubInstallOutcome,
- SkillHubInstallRequest,
- SkillHubPagination,
- SkillHubPlatform,
- SkillHubResult,
-} from "@opencode-ai/app/skillhub/types"
-
-// The SkillHub bridge for the renderer's Skill Hub page. All hub HTTP happens
-// here in the main process: the hub serves no Access-Control-Allow-Origin on
-// /api/v1 and 403s cross-origin writes, so the renderer cannot talk to it.
-// Auth is the shared @codev/gateway session file — the same one the CLIs use —
-// picked up inside @codev/skillhub; browsing and installs work anonymously.
-
-// Stubbable seam so controller tests never touch the network or disk (the
-// package's own idiom — see codev-skillhub skill-install.ts).
-export const skillhubApi = {
- browseHubSkills,
- getSkillDetail,
- getSkillMeta,
- installResolvedSkill,
- pathExists,
-}
-
-export type SkillHubController = SkillHubPlatform
-
-// Chromium holds every request — the main process's net.fetch included — until
-// its proxy initialization (PAC download / WPAD discovery) is done, which on
-// corporate networks outlasts the hub's 15 s request timeout. The warm-up is
-// awaited once, before the first request, so that wait is never charged to a
-// request's own budget; past this bound the request goes ahead regardless.
-const PROXY_WARM_TIMEOUT_MS = 60_000
-
-export type SkillHubControllerDeps = {
- // Transport for every hub request (and the silent SSO refresh behind it).
- // The desktop passes Electron's net.fetch so enterprise proxies and PAC
- // files apply; omitted, the package keeps Node's fetch.
- fetchImpl?: typeof fetch
- // Settles once the network stack behind fetchImpl is ready to send — the
- // desktop passes session.resolveProxy. A failed warm-up never blocks.
- warmProxy?: () => Promise
-}
-
-export function createSkillHubController(deps: SkillHubControllerDeps = {}): SkillHubController {
- if (deps.fetchImpl) setHubTransport(deps.fetchImpl)
-
- let warm: Promise | undefined
- const ready = () => {
- if (!deps.warmProxy) return Promise.resolve()
- warm ??= Promise.race([
- deps.warmProxy().then(() => undefined),
- new Promise((resolve) => setTimeout(resolve, PROXY_WARM_TIMEOUT_MS).unref()),
- ]).catch(() => undefined)
- return warm
- }
-
- return {
- browse: (params) =>
- run(ready, async () => {
- const result = await skillhubApi.browseHubSkills({
- search: params.search,
- page: params.page,
- limit: params.limit,
- sort: params.sort,
- domain: params.domain,
- })
- return {
- items: result.items.map(toCard),
- pagination: toPagination(result.pagination),
- }
- }),
- detail: (idOrName) =>
- run(ready, async () => {
- const detail = await skillhubApi.getSkillDetail(idOrName)
- const card = toCard(detail)
- const full: SkillHubDetail = {
- ...card,
- slug: detail.slug,
- displayName: detail.displayName,
- summary: detail.summary,
- changelog: detail.changelog,
- files: detail.files.map((file) => ({ filePath: file.filePath, content: file.content })),
- }
- return full
- }),
- install: (request) =>
- run(ready, async () => {
- // The install writes to this machine's disk, but the project list the
- // dialog offers belongs to whichever server the window is connected
- // to — a WSL distro or a remote host has worktrees that do not exist
- // here (or resolve to an unrelated local path). Refuse before any hub
- // traffic rather than create a stray folder.
- if (request.scope.kind === "project" && !(await skillhubApi.pathExists(request.scope.directory))) {
- throw new MissingDirectoryError(request.scope.directory)
- }
- // CoDev Code is folded in here, never trusted from the renderer — the
- // dialog renders it locked, but the lock is enforced on this side.
- const agents = lockAgents(request.agents)
- const meta = await skillhubApi.getSkillMeta(request.id)
- const target: InstallTarget =
- request.scope.kind === "global"
- ? { kind: "agents", agents, scope: "global" }
- : { kind: "agents-at", agents, root: request.scope.directory }
- const result = await skillhubApi.installResolvedSkill(meta, { target, force: request.force })
- const outcome: SkillHubInstallOutcome = {
- name: result.name,
- version: result.version,
- placements: result.placements.map((placement) => ({
- path: placement.path,
- mode: placement.mode,
- agents: placement.agents as SkillHubAgent[],
- })),
- }
- return outcome
- }),
- }
-}
-
-class MissingDirectoryError extends Error {
- constructor(directory: string) {
- super(`Project folder not found on this computer: ${directory}`)
- this.name = "MissingDirectoryError"
- }
-}
-
-async function run(ready: () => Promise, work: () => Promise): Promise> {
- try {
- await ready()
- return { ok: true, value: await work() }
- } catch (err) {
- return { ok: false, error: toError(err) }
- }
-}
-
-// Normalize failures into the wire codes the page keys off. Result unions
-// rather than throws: ipcMain.handle rejections reach the renderer as mangled
-// "Error invoking remote method" strings.
-function toError(err: unknown): SkillHubError {
- if (err instanceof SkillhubAuthError) return { code: "auth", message: err.message }
- if (err instanceof MissingDirectoryError) return { code: "missing-directory", message: err.message }
- const message = err instanceof Error ? err.message : String(err)
- if (message.startsWith("Already installed at ")) return { code: "already-installed", message }
- if (message.includes("not found or not public")) return { code: "not-found", message }
- if (err instanceof Error) {
- // Node's fetch reports "fetch failed" with the reason on `cause`; Electron's
- // net.fetch reports Chromium's own codes ("net::ERR_PROXY_CONNECTION_FAILED").
- const network =
- err.name === "AbortError" ||
- err.name === "TimeoutError" ||
- message === "fetch failed" ||
- message.includes("net::ERR_") ||
- err.cause !== undefined
- if (network) return { code: "network", message: describeNetworkError(err) }
- }
- return { code: "unknown", message }
-}
-
-function lockAgents(requested: readonly string[]): SkillAgent[] {
- const chosen = new Set(requested)
- chosen.add("codev")
- return SKILL_AGENTS.filter((agent) => chosen.has(agent))
-}
-
-function toCard(raw: {
- id: string
- name: string
- provider: string
- description: string
- version: string
- publishedAt: string | null
- downloadCount: number
- stars: number
- totalRatings: number
- targetRole?: string
- author?: { name?: string; avatar?: string | null; username?: string }
- metadata?: { tags?: string[]; category?: string; domain?: string; publisher?: string }
- runtimes?: { name: string; color?: string }[]
-}): SkillHubCard {
- // Rebuilt field-by-field so only structured-clone-safe plain JSON crosses
- // the IPC boundary, whatever the package adds to its types later.
- return {
- id: raw.id,
- name: raw.name,
- provider: raw.provider,
- description: raw.description,
- version: raw.version,
- publishedAt: raw.publishedAt,
- downloadCount: raw.downloadCount,
- stars: raw.stars,
- totalRatings: raw.totalRatings,
- targetRole: raw.targetRole,
- author: raw.author
- ? { name: raw.author.name, avatar: raw.author.avatar, username: raw.author.username }
- : undefined,
- metadata: raw.metadata
- ? {
- tags: raw.metadata.tags?.filter((tag) => typeof tag === "string"),
- category: raw.metadata.category,
- domain: raw.metadata.domain,
- publisher: raw.metadata.publisher,
- }
- : undefined,
- runtimes: raw.runtimes?.map((runtime) => ({ name: runtime.name, color: runtime.color })),
- }
-}
-
-function toPagination(raw: { page: number; limit: number; total: number; totalPages: number }): SkillHubPagination {
- return { page: raw.page, limit: raw.limit, total: raw.total, totalPages: raw.totalPages }
-}
diff --git a/packages/desktop/src/main/skillhub/ipc.ts b/packages/desktop/src/main/skillhub/ipc.ts
deleted file mode 100644
index 9b112e1ae..000000000
--- a/packages/desktop/src/main/skillhub/ipc.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { ipcMain } from "electron"
-import type { IpcMainInvokeEvent } from "electron"
-import type { SkillHubBrowseParams, SkillHubInstallRequest, SkillHubScope } from "@opencode-ai/app/skillhub/types"
-import type { SkillHubController } from "./controller"
-
-const AGENTS = new Set(["claude", "codex", "opencode", "codev"])
-const SORTS = new Set(["featured", "newest", "downloads", "rating"])
-const DOMAINS = new Set(["developer", "personal-assistant", "workflow-builder"])
-
-export function registerSkillHubIpcHandlers(controller: SkillHubController) {
- ipcMain.handle("skillhub-browse", (_event: IpcMainInvokeEvent, params: unknown) =>
- controller.browse(parseBrowseParams(params)),
- )
- ipcMain.handle("skillhub-detail", (_event: IpcMainInvokeEvent, idOrName: unknown) =>
- controller.detail(requireString("skill id", idOrName)),
- )
- ipcMain.handle("skillhub-install", (_event: IpcMainInvokeEvent, request: unknown) =>
- controller.install(parseInstallRequest(request)),
- )
-}
-
-function parseBrowseParams(value: unknown): SkillHubBrowseParams {
- const raw = (value ?? {}) as Record
- const params: SkillHubBrowseParams = {}
- if (typeof raw.search === "string" && raw.search !== "") params.search = raw.search
- if (typeof raw.page === "number" && Number.isInteger(raw.page) && raw.page >= 1) params.page = raw.page
- if (typeof raw.limit === "number" && Number.isInteger(raw.limit) && raw.limit >= 1 && raw.limit <= 50) {
- params.limit = raw.limit
- }
- if (typeof raw.sort === "string" && SORTS.has(raw.sort)) params.sort = raw.sort as SkillHubBrowseParams["sort"]
- if (typeof raw.domain === "string" && DOMAINS.has(raw.domain)) {
- params.domain = raw.domain as SkillHubBrowseParams["domain"]
- }
- return params
-}
-
-function parseInstallRequest(value: unknown): SkillHubInstallRequest {
- const raw = (value ?? {}) as Record
- const scope = parseScope(raw.scope)
- const agents = Array.isArray(raw.agents) ? raw.agents.filter(isAgent) : []
- return {
- id: requireString("skill id", raw.id),
- scope,
- agents,
- force: raw.force === true,
- }
-}
-
-function parseScope(value: unknown): SkillHubScope {
- const raw = (value ?? {}) as Record
- if (raw.kind === "global") return { kind: "global" }
- if (raw.kind === "project") return { kind: "project", directory: requireString("project directory", raw.directory) }
- throw new Error("Invalid install scope")
-}
-
-function isAgent(value: unknown): value is SkillHubInstallRequest["agents"][number] {
- return typeof value === "string" && AGENTS.has(value)
-}
-
-function requireString(name: string, value: unknown): string {
- if (typeof value === "string" && value.length > 0) return value
- throw new Error(`Invalid ${name}`)
-}
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index 2df47aeb0..4e7c23fa7 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -75,11 +75,6 @@ const api: ElectronAPI = {
stop: (id) => ipcRenderer.invoke("sandbox-stop", id),
updateMounts: (id, mounts) => ipcRenderer.invoke("sandbox-update-mounts", id, mounts),
},
- skillhub: {
- browse: (params) => ipcRenderer.invoke("skillhub-browse", params),
- detail: (idOrName) => ipcRenderer.invoke("skillhub-detail", idOrName),
- install: (request) => ipcRenderer.invoke("skillhub-install", request),
- },
gatewaySso: {
getState: () => ipcRenderer.invoke("gateway-sso-get-state"),
subscribe: (cb) => {
diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts
index 011647805..a708ea26a 100644
--- a/packages/desktop/src/preload/types.ts
+++ b/packages/desktop/src/preload/types.ts
@@ -2,7 +2,6 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type { SandboxesPlatform } from "@opencode-ai/app/sandbox/types"
import type { GatewaySsoPlatform } from "@opencode-ai/app/gateway/types"
-import type { SkillHubPlatform } from "@opencode-ai/app/skillhub/types"
import type { UpdaterPrefs, UpdaterState } from "@opencode-ai/app/updater"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
export type {
@@ -44,7 +43,6 @@ export type ServerReadyData = {
export type WslServersAPI = WslServersPlatform
export type SandboxesAPI = SandboxesPlatform
export type GatewaySsoAPI = GatewaySsoPlatform
-export type SkillHubAPI = SkillHubPlatform
export type UpdaterAPI = {
subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void>
// Registered by updater-subscribe alongside the state stream, so this only
@@ -82,7 +80,6 @@ export type ElectronAPI = {
wslServers: WslServersAPI
sandboxes: SandboxesAPI
gatewaySso: GatewaySsoAPI
- skillhub: SkillHubAPI
updater: UpdaterAPI
consumeInitialDeepLinks: () => Promise
getDefaultServerUrl: () => Promise
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 325c63a3e..c3fda9a37 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -312,7 +312,6 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
sandboxes: window.api.sandboxes,
gatewaySso: window.api.gatewaySso,
- skillhub: window.api.skillhub,
getDisplayBackend: async () => {
return window.api.getDisplayBackend().catch(() => null)
diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts
index 356c1b0d1..a31bb4e15 100644
--- a/packages/opencode/test/server/httpapi-exercise/index.ts
+++ b/packages/opencode/test/server/httpapi-exercise/index.ts
@@ -36,7 +36,7 @@ import { runScenario } from "./runner"
import { disposeApps } from "./backend"
import { runtime } from "./runtime"
import { type Scenario } from "./types"
-import { FAKE_STACK_SLUG, installFakeSkillHub } from "./skillhub-fake"
+import { FAKE_SKILL_ID, FAKE_STACK_SLUG, installFakeSkillHub } from "./skillhub-fake"
// Fork: the v2.skillhub.* scenarios must never reach the live hub.
installFakeSkillHub()
@@ -774,6 +774,43 @@ const scenarios: Scenario[] = [
headers: ctx.headers(),
}))
.json(400, object, "status"),
+ http.protected.get("/api/skillhub/skill", "v2.skillhub.skill.list").json(
+ 200,
+ locationData((data) => {
+ object(data)
+ array(data.items)
+ if (data.items.length !== 1) throw new Error(`expected 1 skill, got ${data.items.length}`)
+ }),
+ ),
+ http.protected
+ .get("/api/skillhub/skill/{idOrName}", "v2.skillhub.skill.get")
+ .at((ctx) => ({
+ path: route("/api/skillhub/skill/{idOrName}", { idOrName: FAKE_SKILL_ID }),
+ headers: ctx.headers(),
+ }))
+ .json(
+ 200,
+ locationData((data) => {
+ object(data)
+ array(data.files)
+ if (data.id !== FAKE_SKILL_ID) throw new Error(`expected the fixture skill, got ${data.id}`)
+ }),
+ ),
+ http.protected
+ .post("/api/skillhub/skill/{id}/install", "v2.skillhub.skill.install")
+ .at((ctx) => ({
+ path: route("/api/skillhub/skill/{id}/install", { id: FAKE_SKILL_ID }),
+ headers: ctx.headers(),
+ body: { scope: "project", agents: ["claude"], force: false },
+ }))
+ .json(
+ 200,
+ locationData((data) => {
+ object(data)
+ array(data.placements)
+ if (typeof data.dir !== "string") throw new Error("expected the install directory")
+ }),
+ ),
http.protected
.get("/api/event", "v2.event.subscribe")
.stream()
diff --git a/packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts b/packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts
index 5b7ff5799..0d944aed7 100644
--- a/packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts
+++ b/packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts
@@ -5,16 +5,46 @@ import { exerciseGlobalRoot } from "./environment"
// Fork: a stand-in for the CoDev SkillHub so the v2.skillhub.* scenarios
// exercise the real handler end to end without touching the network. The
-// package routes every hub request through one injectable transport (the
-// desktop app swaps in Chromium's net.fetch the same way), so nothing here is
-// a mock — the handler downloads, parses, plans, and stages exactly as it
-// would against the live hub. Anything else the transport is asked for is a
-// bug in the scenario, and 404s loudly.
+// package routes every hub request through one injectable transport, so
+// nothing here is a mock — the handler downloads, parses, plans, and installs
+// exactly as it would against the live hub. Anything else the transport is
+// asked for is a bug in the scenario, and 404s loudly.
export const FAKE_STACK_SLUG = "httpapi-stack"
export const FAKE_STACK_SKILLS = ["alpha-skill", "beta-skill"] as const
+export const FAKE_SKILL_ID = "00000000-0000-4000-8000-00000000abcd"
+export const FAKE_SKILL_NAME = "solo-skill"
-const listing = {
+const skill = {
+ id: FAKE_SKILL_ID,
+ name: FAKE_SKILL_NAME,
+ provider: "exerciser",
+ description: "Fixture skill for the HttpApi exerciser",
+ version: "1.0.0",
+ publishedAt: "2026-01-01T00:00:00.000Z",
+ downloadCount: 0,
+ stars: 0,
+ totalRatings: 0,
+ // Nulls on purpose: the live hub sends unset columns this way, and the
+ // server's strict response schema once refused a whole page over one.
+ targetRole: null,
+ author: { username: "exerciser", name: null, avatar: null },
+ metadata: { tags: ["fixture"], category: null, domain: null, publisher: null },
+ runtimes: [{ name: "Python", color: null }],
+}
+
+const skillListing = {
+ success: true,
+ data: [skill],
+ pagination: { page: 1, limit: 24, total: 1, totalPages: 1 },
+}
+
+const skillDetail = {
+ success: true,
+ data: { ...skill, slug: FAKE_SKILL_NAME, files: [{ filePath: "SKILL.md", content: `# ${FAKE_SKILL_NAME}\n` }] },
+}
+
+const stackListing = {
success: true,
data: [
{
@@ -40,30 +70,36 @@ const listing = {
stats: { totalStacks: 1 },
}
-// Built once, lazily: the composite ZIP the hub would serve, in its exact
-// layout — one root folder named after the slug, one folder per skill.
-let bundle: Promise | undefined
-function stackZip(): Promise {
- bundle ??= (async () => {
- const dir = path.join(exerciseGlobalRoot, "skillhub-fixture")
- for (const name of FAKE_STACK_SKILLS) {
- fs.mkdirSync(path.join(dir, name), { recursive: true })
- fs.writeFileSync(path.join(dir, name, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`)
+// Built once, lazily, in the exact layouts the hub serves: a stack is one root
+// folder named after the slug with one folder per skill; a single skill is
+// one root folder named after the skill.
+const bundles = new Map>()
+function bundle(kind: "stack" | "skill"): Promise {
+ const existing = bundles.get(kind)
+ if (existing) return existing
+ const built = (async () => {
+ const dir = path.join(exerciseGlobalRoot, `skillhub-fixture-${kind}`)
+ const names = kind === "stack" ? FAKE_STACK_SKILLS.map((name) => path.join(dir, name)) : [dir]
+ for (const skillDir of names) {
+ fs.mkdirSync(skillDir, { recursive: true })
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), `---\nname: ${path.basename(skillDir)}\n---\n# skill\n`)
}
- return (await zipDirectory(dir, FAKE_STACK_SLUG)).buffer
+ return (await zipDirectory(dir, kind === "stack" ? FAKE_STACK_SLUG : FAKE_SKILL_NAME)).buffer
})()
- return bundle
+ bundles.set(kind, built)
+ return built
}
export function installFakeSkillHub() {
const transport = async (input: string | URL | Request) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url)
- if (url.pathname.endsWith("/api/v1/explore/namespaces")) {
- return Response.json(listing)
- }
- if (url.pathname.endsWith(`/api/v1/namespaces/${FAKE_STACK_SLUG}/download-all`)) {
- return new Response(new Uint8Array(await stackZip()), { headers: { "Content-Type": "application/zip" } })
- }
+ const zip = async (kind: "stack" | "skill") =>
+ new Response(new Uint8Array(await bundle(kind)), { headers: { "Content-Type": "application/zip" } })
+ if (url.pathname.endsWith("/api/v1/explore/namespaces")) return Response.json(stackListing)
+ if (url.pathname.endsWith(`/api/v1/namespaces/${FAKE_STACK_SLUG}/download-all`)) return zip("stack")
+ if (url.pathname.endsWith("/api/v1/hub/skills")) return Response.json(skillListing)
+ if (url.pathname.endsWith(`/api/v1/skills/${FAKE_SKILL_ID}/download`)) return zip("skill")
+ if (url.pathname.endsWith(`/api/v1/skills/${FAKE_SKILL_ID}`)) return Response.json(skillDetail)
return new Response(`fake skillhub: unexpected request ${url.pathname}`, { status: 404 })
}
// Bun's `typeof fetch` also carries a `preconnect` hint the package never
diff --git a/packages/protocol/src/groups/skillhub.ts b/packages/protocol/src/groups/skillhub.ts
index 5f0f771cd..54664d019 100644
--- a/packages/protocol/src/groups/skillhub.ts
+++ b/packages/protocol/src/groups/skillhub.ts
@@ -1,4 +1,5 @@
import { Location } from "@opencode-ai/schema/location"
+import { SkillHub } from "@opencode-ai/schema/skill-hub"
import { SkillStack } from "@opencode-ai/schema/skill-stack"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
@@ -6,7 +7,7 @@ import { LocationQuery, locationQueryOpenApi } from "./location"
// The server reaches schema types only through protocol, so the wire shapes
// this group speaks are re-exported here for its handler.
-export { SkillStack }
+export { SkillHub, SkillStack }
// Fork-only: CoDev SkillHub Skill Stack routes. All hub HTTP happens in the
// server so both the TUI (which can only reach the hub through the SDK) and
@@ -33,7 +34,14 @@ const BrowseQuery = Schema.Struct({
),
}).annotate({ identifier: "SkillStack.BrowseQuery" })
+const SkillBrowseQuery = Schema.Struct({
+ ...BrowseQuery.fields,
+ sort: Schema.optional(SkillHub.Sort),
+ domain: Schema.optional(SkillHub.Domain),
+}).annotate({ identifier: "SkillHub.BrowseQuery" })
+
const root = "/api/skillhub/stack"
+const skillRoot = "/api/skillhub/skill"
export const SkillHubGroup = HttpApiGroup.make("server.skillhub")
.add(
@@ -102,4 +110,55 @@ export const SkillHubGroup = HttpApiGroup.make("server.skillhub")
}),
),
)
- .annotateMerge(OpenApi.annotations({ title: "skillhub", description: "CoDev SkillHub Skill Stack routes." }))
+ .add(
+ HttpApiEndpoint.get("skillhub.skill.list", skillRoot, {
+ query: SkillBrowseQuery,
+ success: Location.response(SkillHub.BrowseResult),
+ error: SkillHubError,
+ })
+ .annotateMerge(locationQueryOpenApi)
+ .annotateMerge(
+ OpenApi.annotations({
+ identifier: "v2.skillhub.skill.list",
+ summary: "List skills",
+ description: "Browse the public skills on the CoDev SkillHub.",
+ }),
+ ),
+ )
+ .add(
+ HttpApiEndpoint.get("skillhub.skill.get", `${skillRoot}/:idOrName`, {
+ params: { idOrName: Schema.String },
+ query: LocationQuery,
+ success: Location.response(SkillHub.Detail),
+ error: SkillHubError,
+ })
+ .annotateMerge(locationQueryOpenApi)
+ .annotateMerge(
+ OpenApi.annotations({
+ identifier: "v2.skillhub.skill.get",
+ summary: "Get a skill",
+ description: "A skill's full detail by id or name, with its text files inlined.",
+ }),
+ ),
+ )
+ .add(
+ HttpApiEndpoint.post("skillhub.skill.install", `${skillRoot}/:id/install`, {
+ params: { id: Schema.String },
+ query: LocationQuery,
+ payload: SkillHub.InstallInput,
+ success: Location.response(SkillHub.InstallOutcome),
+ error: SkillHubError,
+ })
+ .annotateMerge(locationQueryOpenApi)
+ .annotateMerge(
+ OpenApi.annotations({
+ identifier: "v2.skillhub.skill.install",
+ summary: "Install a skill",
+ description:
+ "Download and install one skill into the location's project (or globally). Refuses an existing install with `already-installed` unless force is set.",
+ }),
+ ),
+ )
+ .annotateMerge(
+ OpenApi.annotations({ title: "skillhub", description: "CoDev SkillHub routes: skills and Skill Stacks." }),
+ )
diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts
index b58d5d6d4..8ddff0496 100644
--- a/packages/schema/src/index.ts
+++ b/packages/schema/src/index.ts
@@ -20,6 +20,7 @@ export { SessionInput } from "./session-input"
export { SessionMessage } from "./session-message"
export { Skill } from "./skill"
export { SkillStack } from "./skill-stack"
+export { SkillHub } from "./skill-hub"
export { Pty } from "./pty"
export { PtyTicket } from "./pty-ticket"
export { Question } from "./question"
diff --git a/packages/schema/src/skill-hub.ts b/packages/schema/src/skill-hub.ts
new file mode 100644
index 000000000..fde485ea5
--- /dev/null
+++ b/packages/schema/src/skill-hub.ts
@@ -0,0 +1,92 @@
+export * as SkillHub from "./skill-hub"
+
+import { Schema } from "effect"
+import { optional } from "./schema"
+import { SkillStack } from "./skill-stack"
+
+// Fork-only: the wire shapes for single CoDev SkillHub skills — browse, detail,
+// install — restated from @codev/skillhub's HubSkillCard / HubSkillDetail /
+// InstallResult so the protocol depends only on Schema. Scope, agents, and
+// placements are the Skill Stack ones: a single skill and a stack land in the
+// same directories by the same rules.
+
+export const Sort = Schema.Literals(["featured", "newest", "downloads", "rating"]).annotate({
+ identifier: "SkillHub.Sort",
+})
+export type Sort = typeof Sort.Type
+
+export const Domain = Schema.Literals(["developer", "personal-assistant", "workflow-builder"]).annotate({
+ identifier: "SkillHub.Domain",
+})
+export type Domain = typeof Domain.Type
+
+export const Card = Schema.Struct({
+ id: Schema.String,
+ name: Schema.String,
+ provider: Schema.String,
+ description: Schema.String,
+ version: Schema.String,
+ publishedAt: Schema.Union([Schema.String, Schema.Null]),
+ downloadCount: Schema.Number,
+ stars: Schema.Number,
+ totalRatings: Schema.Number,
+ targetRole: optional(Schema.String),
+ author: optional(
+ Schema.Struct({
+ name: optional(Schema.String),
+ avatar: optional(Schema.Union([Schema.String, Schema.Null])),
+ username: optional(Schema.String),
+ }),
+ ),
+ metadata: optional(
+ Schema.Struct({
+ tags: optional(Schema.Array(Schema.String)),
+ category: optional(Schema.String),
+ domain: optional(Schema.String),
+ publisher: optional(Schema.String),
+ }),
+ ),
+ runtimes: optional(Schema.Array(Schema.Struct({ name: Schema.String, color: optional(Schema.String) }))),
+}).annotate({ identifier: "SkillHub.Card" })
+export interface Card extends Schema.Schema.Type {}
+
+export const BrowseResult = Schema.Struct({
+ items: Schema.Array(Card),
+ pagination: Schema.Struct({
+ page: Schema.Number,
+ limit: Schema.Number,
+ total: Schema.Number,
+ totalPages: Schema.Number,
+ }),
+}).annotate({ identifier: "SkillHub.BrowseResult" })
+export interface BrowseResult extends Schema.Schema.Type {}
+
+export const Detail = Schema.Struct({
+ ...Card.fields,
+ slug: optional(Schema.String),
+ displayName: optional(Schema.String),
+ summary: optional(Schema.String),
+ changelog: optional(Schema.String),
+ // SKILL.md (and every other text file) arrives inlined — no second call.
+ files: Schema.Array(Schema.Struct({ filePath: Schema.String, content: Schema.String })),
+}).annotate({ identifier: "SkillHub.Detail" })
+export interface Detail extends Schema.Schema.Type {}
+
+export const InstallInput = Schema.Struct({
+ scope: SkillStack.Scope,
+ // CoDev Code is folded in server-side whatever the client sends.
+ agents: Schema.Array(SkillStack.Agent),
+ // Replace an existing install. Without it a collision is refused with the
+ // `already-installed` code so the client can ask first.
+ force: Schema.Boolean,
+}).annotate({ identifier: "SkillHub.InstallInput" })
+export interface InstallInput extends Schema.Schema.Type {}
+
+export const InstallOutcome = Schema.Struct({
+ id: Schema.String,
+ name: Schema.String,
+ version: optional(Schema.String),
+ dir: Schema.String,
+ placements: Schema.Array(SkillStack.Placement),
+}).annotate({ identifier: "SkillHub.InstallOutcome" })
+export interface InstallOutcome extends Schema.Schema.Type {}
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index b5371857c..f55e7c25a 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -223,6 +223,9 @@ import type {
SessionUnshareResponses,
SessionUpdateErrors,
SessionUpdateResponses,
+ SkillHubDomain,
+ SkillHubInstallInput,
+ SkillHubSort,
SkillStackInstallInput,
SkillStackPrepareInput,
SubtaskPartInput,
@@ -385,6 +388,12 @@ import type {
V2SessionSwitchModelResponses,
V2SessionWaitErrors,
V2SessionWaitResponses,
+ V2SkillhubSkillGetErrors,
+ V2SkillhubSkillGetResponses,
+ V2SkillhubSkillInstallErrors,
+ V2SkillhubSkillInstallResponses,
+ V2SkillhubSkillListErrors,
+ V2SkillhubSkillListResponses,
V2SkillhubStackDiscardErrors,
V2SkillhubStackDiscardResponses,
V2SkillhubStackInstallErrors,
@@ -7159,11 +7168,136 @@ export class Stack extends HeyApiClient {
}
}
+export class Skill2 extends HeyApiClient {
+ /**
+ * List skills
+ *
+ * Browse the public skills on the CoDev SkillHub.
+ */
+ public list(
+ parameters?: {
+ location?: {
+ directory?: string
+ workspace?: string
+ }
+ search?: string
+ page?: string
+ limit?: string
+ sort?: SkillHubSort
+ domain?: SkillHubDomain
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "location" },
+ { in: "query", key: "search" },
+ { in: "query", key: "page" },
+ { in: "query", key: "limit" },
+ { in: "query", key: "sort" },
+ { in: "query", key: "domain" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/api/skillhub/skill",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Get a skill
+ *
+ * A skill's full detail by id or name, with its text files inlined.
+ */
+ public get(
+ parameters: {
+ idOrName: string
+ location?: {
+ directory?: string
+ workspace?: string
+ }
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "idOrName" },
+ { in: "query", key: "location" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/api/skillhub/skill/{idOrName}",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Install a skill
+ *
+ * Download and install one skill into the location's project (or globally). Refuses an existing install with `already-installed` unless force is set.
+ */
+ public install(
+ parameters: {
+ id: string
+ location?: {
+ directory?: string
+ workspace?: string
+ }
+ skillHubInstallInput: SkillHubInstallInput
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "id" },
+ { in: "query", key: "location" },
+ { key: "skillHubInstallInput", map: "body" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ V2SkillhubSkillInstallResponses,
+ V2SkillhubSkillInstallErrors,
+ ThrowOnError
+ >({
+ url: "/api/skillhub/skill/{id}/install",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
export class Skillhub extends HeyApiClient {
private _stack?: Stack
get stack(): Stack {
return (this._stack ??= new Stack({ client: this.client }))
}
+
+ private _skill?: Skill2
+ get skill(): Skill2 {
+ return (this._skill ??= new Skill2({ client: this.client }))
+ }
}
export class V2 extends HeyApiClient {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index d24c26789..da5851354 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -6260,6 +6260,98 @@ export type SkillStackOutcome = {
}>
}
+export type SkillHubSort = "featured" | "newest" | "downloads" | "rating"
+
+export type SkillHubDomain = "developer" | "personal-assistant" | "workflow-builder"
+
+export type SkillHubCard = {
+ id: string
+ name: string
+ provider: string
+ description: string
+ version: string
+ publishedAt: string
+ downloadCount: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ stars: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ totalRatings: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ targetRole?: string
+ author?: {
+ name?: string
+ avatar?: string
+ username?: string
+ }
+ metadata?: {
+ tags?: Array
+ category?: string
+ domain?: string
+ publisher?: string
+ }
+ runtimes?: Array<{
+ name: string
+ color?: string
+ }>
+}
+
+export type SkillHubBrowseResult = {
+ items: Array
+ pagination: {
+ page: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ limit: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ total: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ totalPages: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ }
+}
+
+export type SkillHubDetail = {
+ id: string
+ name: string
+ provider: string
+ description: string
+ version: string
+ publishedAt: string
+ downloadCount: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ stars: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ totalRatings: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
+ targetRole?: string
+ author?: {
+ name?: string
+ avatar?: string
+ username?: string
+ }
+ metadata?: {
+ tags?: Array
+ category?: string
+ domain?: string
+ publisher?: string
+ }
+ runtimes?: Array<{
+ name: string
+ color?: string
+ }>
+ slug?: string
+ displayName?: string
+ summary?: string
+ changelog?: string
+ files: Array<{
+ filePath: string
+ content: string
+ }>
+}
+
+export type SkillHubInstallInput = {
+ scope: SkillStackScope
+ agents: Array
+ force: boolean
+}
+
+export type SkillHubInstallOutcome = {
+ id: string
+ name: string
+ version?: string
+ dir: string
+ placements: Array
+}
+
export type EventModelsDevRefreshed = {
id: string
type: "models-dev.refreshed"
@@ -13846,6 +13938,126 @@ export type V2SkillhubStackDiscardResponses = {
export type V2SkillhubStackDiscardResponse = V2SkillhubStackDiscardResponses[keyof V2SkillhubStackDiscardResponses]
+export type V2SkillhubSkillListData = {
+ body?: never
+ path?: never
+ query?: {
+ location?: {
+ directory?: string
+ workspace?: string
+ }
+ search?: string
+ page?: string
+ limit?: string
+ sort?: SkillHubSort
+ domain?: SkillHubDomain
+ }
+ url: "/api/skillhub/skill"
+}
+
+export type V2SkillhubSkillListErrors = {
+ /**
+ * SkillHubError | InvalidRequestError
+ */
+ 400: SkillHubError | InvalidRequestError
+ /**
+ * UnauthorizedError
+ */
+ 401: UnauthorizedError
+}
+
+export type V2SkillhubSkillListError = V2SkillhubSkillListErrors[keyof V2SkillhubSkillListErrors]
+
+export type V2SkillhubSkillListResponses = {
+ /**
+ * Success
+ */
+ 200: {
+ location: LocationInfo
+ data: SkillHubBrowseResult
+ }
+}
+
+export type V2SkillhubSkillListResponse = V2SkillhubSkillListResponses[keyof V2SkillhubSkillListResponses]
+
+export type V2SkillhubSkillGetData = {
+ body?: never
+ path: {
+ idOrName: string
+ }
+ query?: {
+ location?: {
+ directory?: string
+ workspace?: string
+ }
+ }
+ url: "/api/skillhub/skill/{idOrName}"
+}
+
+export type V2SkillhubSkillGetErrors = {
+ /**
+ * SkillHubError | InvalidRequestError
+ */
+ 400: SkillHubError | InvalidRequestError
+ /**
+ * UnauthorizedError
+ */
+ 401: UnauthorizedError
+}
+
+export type V2SkillhubSkillGetError = V2SkillhubSkillGetErrors[keyof V2SkillhubSkillGetErrors]
+
+export type V2SkillhubSkillGetResponses = {
+ /**
+ * Success
+ */
+ 200: {
+ location: LocationInfo
+ data: SkillHubDetail
+ }
+}
+
+export type V2SkillhubSkillGetResponse = V2SkillhubSkillGetResponses[keyof V2SkillhubSkillGetResponses]
+
+export type V2SkillhubSkillInstallData = {
+ body: SkillHubInstallInput
+ path: {
+ id: string
+ }
+ query?: {
+ location?: {
+ directory?: string
+ workspace?: string
+ }
+ }
+ url: "/api/skillhub/skill/{id}/install"
+}
+
+export type V2SkillhubSkillInstallErrors = {
+ /**
+ * SkillHubError | InvalidRequestError
+ */
+ 400: SkillHubError | InvalidRequestError
+ /**
+ * UnauthorizedError
+ */
+ 401: UnauthorizedError
+}
+
+export type V2SkillhubSkillInstallError = V2SkillhubSkillInstallErrors[keyof V2SkillhubSkillInstallErrors]
+
+export type V2SkillhubSkillInstallResponses = {
+ /**
+ * Success
+ */
+ 200: {
+ location: LocationInfo
+ data: SkillHubInstallOutcome
+ }
+}
+
+export type V2SkillhubSkillInstallResponse = V2SkillhubSkillInstallResponses[keyof V2SkillhubSkillInstallResponses]
+
export type PtyConnectData = {
body?: never
path: {
diff --git a/packages/server/src/handlers/skillhub.ts b/packages/server/src/handlers/skillhub.ts
index 725f968c6..88bd6460b 100644
--- a/packages/server/src/handlers/skillhub.ts
+++ b/packages/server/src/handlers/skillhub.ts
@@ -2,10 +2,13 @@ import { randomUUID } from "node:crypto"
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
import path from "node:path"
import {
+ browseHubSkills,
browseStacks,
describeNetworkError,
downloadStack,
findStack,
+ getSkillDetail,
+ installSkill,
installStack,
planStackInstall,
readStackArchive,
@@ -16,7 +19,7 @@ import {
} from "@codev/skillhub"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
-import { SkillHubError, type SkillStack } from "@opencode-ai/protocol/groups/skillhub"
+import { SkillHubError, type SkillHub, type SkillStack } from "@opencode-ai/protocol/groups/skillhub"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
@@ -64,13 +67,48 @@ export const SkillHubHandler = HttpApiBuilder.group(Api, "server.skillhub", (han
.handle("skillhub.stack.install", (ctx) => response(hub(() => install(ctx.payload.stagingID, ctx.payload.mode))))
.handle("skillhub.stack.discard", (ctx) =>
hub(() => discard(ctx.params.stagingID)).pipe(Effect.as(HttpApiSchema.NoContent.make())),
+ )
+ .handle("skillhub.skill.list", (ctx) =>
+ response(
+ hub(() =>
+ browseHubSkills({
+ search: ctx.query.search,
+ page: ctx.query.page,
+ limit: ctx.query.limit,
+ sort: ctx.query.sort,
+ domain: ctx.query.domain,
+ }),
+ ),
+ ),
+ )
+ .handle("skillhub.skill.get", (ctx) => response(hub(() => getSkillDetail(ctx.params.idOrName))))
+ .handle("skillhub.skill.install", (ctx) =>
+ response(
+ Effect.gen(function* () {
+ const location = yield* Location.Service
+ return yield* hub(() =>
+ installOne(ctx.params.id, targetFor(ctx.payload, location.project.directory), ctx.payload.force),
+ )
+ }),
+ ),
),
)
+// One skill, by the same rules as a stack: resolved name, one real copy plus
+// links. The package refuses an existing install unless forced, and that
+// refusal reaches the client as `already-installed` so it can ask first.
+async function installOne(id: string, target: InstallTarget, force: boolean): Promise {
+ const result = await installSkill(id, { target, force })
+ return { id: result.id, name: result.name, version: result.version, dir: result.dir, placements: result.placements }
+}
+
// "project" is the request location's project root — resolved here, never
// taken from the client — so an install can't be pointed off-project. CoDev
// Code is folded in whatever the client sent; the lock lives on this side.
-function targetFor(input: SkillStack.PrepareInput, projectDirectory: string): InstallTarget {
+function targetFor(
+ input: { scope: SkillStack.Scope; agents: readonly SkillStack.Agent[] },
+ projectDirectory: string,
+): InstallTarget {
const chosen = new Set(input.agents)
chosen.add("codev")
const agents = SKILL_AGENTS.filter((agent) => chosen.has(agent))