From 069c33d1127b91ccb4597a86d1420c4cd48b6e9d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 14 Sep 2026 08:52:08 +0700 Subject: [PATCH] feat(server): serve Skill Stack browse and install over the v2 HttpApi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puts CoDev SkillHub Skill Stack traffic behind the server as the fork-only v2 group `server.skillhub`, so the TUI — which reaches the hub only through the SDK — and the desktop app share one implementation, and an install lands on the host that actually runs the agent rather than wherever the client happens to be. Install is two steps so a client can show what is already installed and ask before overwriting, without downloading the archive twice: prepare downloads, parses, and plans against the request location's project root (resolved server-side, never taken from the client) and parks the ZIP on disk under a stagingID with a 10-minute TTL; install consumes it, re- planning against the disk as it is then. Failures carry a machine-readable code (auth, not-found, already-installed, network, expired, unknown) so the clients never parse English. The exerciser gates every fork route in CI with --fail-on-missing --fail-on-skip and must never reach the live hub, so it installs a fake through the package's injectable transport — the same seam the desktop uses for Chromium's net.fetch — and the four scenarios drive the real handler end to end against a fixture stack. Clients regenerated: @opencode-ai/client (skillhub.listStacks/ prepareStack/installStack/discardStack) and the legacy JS SDK the TUI uses. No user-facing change yet; the TUI dialog and desktop tab build on this next. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P1MfYkJD3tQZRgqBKB4gig --- DIVERGENCES.md | 3 + bun.lock | 1 + packages/client/src/contract.ts | 5 + .../client/src/generated-effect/client.ts | 57 ++++ packages/client/src/generated/client.ts | 65 +++++ packages/client/src/generated/types.ts | 155 +++++++++++ packages/client/test/promise.test.ts | 1 + packages/codev-skillhub/src/index.ts | 2 +- .../test/server/httpapi-exercise/index.ts | 47 ++++ .../server/httpapi-exercise/skillhub-fake.ts | 72 +++++ packages/protocol/src/api.ts | 2 + packages/protocol/src/groups/skillhub.ts | 105 +++++++ packages/schema/src/index.ts | 1 + packages/schema/src/skill-stack.ts | 146 ++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 184 ++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 261 ++++++++++++++++++ packages/server/package.json | 1 + packages/server/src/handlers.ts | 2 + packages/server/src/handlers/skillhub.ts | 175 ++++++++++++ 19 files changed, 1284 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts create mode 100644 packages/protocol/src/groups/skillhub.ts create mode 100644 packages/schema/src/skill-stack.ts create mode 100644 packages/server/src/handlers/skillhub.ts diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 9a596593e0..085e3a8f6b 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -290,6 +290,9 @@ from the squash message first, or use a plain merge. | `packages/opencode/test/tool/write.test.ts` | "sets file permissions when writing sensitive data" asserts `stats.mode & 0o777` equals `0o666 & ~process.umask()` instead of a hardcoded `0o644` | The write tool never chmods — it calls `fs.writeWithDirs`, so the mode is just the 0o666 creation default masked by the process umask. `0o644` silently encodes the umask 022 that CI runners happen to use, and the test fails with `0o664` on any developer machine running the equally common umask 002. The assertion still pins the tool to the platform default (a stray chmod would break it). | Keep our umask-derived expectation; take upstream's test changes. If upstream ever makes the tool chmod explicitly, drop this row and assert the explicit mode. | | `packages/opencode/test/provider/provider.test.ts`, `packages/opencode/test/provider/transform.test.ts` | Appended two fork-only blocks at the end of each file: an `it.instance` ("declared image modality makes a custom-provider model attachment capable") asserting a config model with no `modalities` resolves `attachment`/`input.image` to false while one carrying the `@codev/gateway` block resolves both true, and a `describe` ("ProviderTransform.message - image input on a custom provider") asserting `unsupportedParts` passes `image`/`file` parts through when the model declares image input and swaps them for the `ERROR: Cannot read …` note when it doesn't. No upstream test changed. | These pin the two upstream code paths the fork's config writer depends on (`provider.ts` modality→capability resolution, `transform.ts` part filtering); upstream leaves both untested, so a refactor there would silently re-break gateway image attachments. | Keep our appended blocks; take upstream's test changes. Append-only, so conflicts should be end-of-file context alone. | | `packages/opencode/test/server/httpapi-exercise/environment.ts` | Exercise config/data dirs `config/opencode`,`data/opencode` → `…/codev`; seeds `codev.json` with `{"share":"manual"}` | Follows the `global.ts` app-dir rename (row above); the fork's `share: "disabled"` default (config.ts row) would 500 the share/unshare scenarios, so the isolated environment opts back in (network stays stubbed by `CODEV_DISABLE_SHARE`). Seed file follows the config-file rename row (global `config.json` is no longer read). | Keep our dir names + seeded config; take upstream's surrounding changes. | +| Skill Stack HTTP routes: `packages/protocol/src/api.ts`, `packages/server/src/handlers.ts`, `packages/server/package.json`, `packages/schema/src/index.ts` | `api.ts` imports and `.add(SkillHubGroup.middleware(locationMiddleware))` (last group; `src/groups/skillhub.ts` is fork-only); `handlers.ts` merges `SkillHubHandler` (`src/handlers/skillhub.ts`, fork-only); server `package.json` adds `"@codev/skillhub": "workspace:*"`; schema `index.ts` exports `SkillStack` (`src/skill-stack.ts`, fork-only). | The v2 group `server.skillhub` (`GET /api/skillhub/stack`, `POST …/:slug/prepare`, `POST …/install`, `DELETE …/staging/:stagingID`) puts all CoDev SkillHub Skill Stack traffic behind the server, so the TUI (which reaches the hub only through the SDK) and the desktop app share one implementation and installs land on the host that runs the agent. The dependency direction stays Schema → Protocol → Server; the server takes the wire types through the protocol group's re-export rather than a direct schema dependency, matching the other handlers. | Keep the import + `.add` line, the handler merge, the dependency, and the schema export; take upstream's surrounding changes. | +| Generated clients: `packages/client/src/contract.ts`, `packages/client/test/promise.test.ts`, `packages/client/src/generated/{client,types}.ts`, `packages/client/src/generated-effect/client.ts`, `packages/sdk/js/src/v2/gen/{sdk,types}.gen.ts` | `contract.ts` maps `"server.skillhub": "skillhub"` and names its endpoints `listStacks`/`prepareStack`/`installStack`/`discardStack`; `promise.test.ts` expects `"skillhub"` last in the group list; the generated trees carry the group. | The fork-only protocol group must reach both SDKs; the endpoint names are overridden because the codegen's default (last id segment) would give `list`/`prepare`/`install`, which collide once single-skill routes join the group. | Keep our `contract.ts` entries and the test expectation; never hand-merge the generated trees — run `bun run generate` in `packages/client` and `./packages/sdk/js/script/build.ts` after resolving the protocol. | +| `packages/opencode/test/server/httpapi-exercise/index.ts` | Imports and calls `installFakeSkillHub()` from the fork-only `skillhub-fake.ts` before the scenario list, and adds four `v2.skillhub.stack.*` scenarios after `v2.skill.list`. | CI runs the exerciser with `--fail-on-missing --fail-on-skip`, so every fork route needs a real passing scenario, and none may reach the live hub: the fake swaps in `@codev/skillhub`'s injectable transport (the same seam the desktop uses for Chromium's `net.fetch`), so the handler downloads, parses, plans, and stages a fixture stack exactly as it would against the hub. | Keep the import/call and the four scenarios; take upstream's surrounding scenario changes. | | `sdks/vscode/package.json`, `sdks/vscode/src/extension.ts`, `sdks/vscode/README.md` | `name` `opencode` → `codev-code` (the extension ID is `publisher.name`); `displayName` "opencode" → "CoDev Code"; `publisher` `sst-dev` → `CoDev` (PLACEHOLDER — no such Marketplace publisher is registered; the mixed-case `CoDev` also isn't a valid Marketplace publisher id, which must be lowercase — fine for the sideloaded `.vsix` the fork ships, but replace with the real fork-owned lowercase publisher id before any `vsce publish`, and keep it in sync with the CLI auto-install target); `description` "opencode for VS Code" → "CoDev Code for VS Code"; `icon` `images/icon.png` is a fork-local generated PNG of the CoDev "c" badge (brand red `#ee0033` — the "dev" half of the wordmark, see `packages/tui/src/logo.ts` — on `#0a0a0a`), with `images/icon.svg` as its source of record — replacing the earlier symlink to the shared `packages/identity` bracket mark; the two per-theme command `icon` objects (`{light,dark}` → `images/button-{dark,light}.svg`) collapse to a single `"images/button.svg"` string and `extension.ts`'s terminal `iconPath` object collapses to a single `Uri`, both pointing at a fork-local transparent brand-red `#ee0033` "c" glyph that reads on light+dark themes (replacing the two `packages/identity` bracket-mark symlinks, now deleted); `repository.url` → `github.com/quickbeard/codev-code`; command IDs `opencode.*` → `codev.*` across `contributes.commands`/`menus`/`keybindings` and the `registerCommand` calls; command/keybinding titles "Open opencode…"/"Run opencode"/"opencode: Insert At-Mentioned" → "Open CoDev Code…"/"Run CoDev Code"/"CoDev Code: Insert At-Mentioned" (keys unchanged); `TERMINAL_NAME` `opencode` → `codev` and the terminal launches `codev --port `; scripts gain `"vsix"` (`vsce package --no-dependencies --skip-license -o dist/`) with `@vscode/vsce` added as a pinned devDependency; README body rebranded and trimmed to the details-panel essentials — CoDev Code intro linking the fork repo, `npm install -g codev-code` prerequisite, and feature prose only; upstream's Support and Development/Making-Changes sections are dropped (they're contributor-facing, not end-user extension description) | The fork ships this extension as an installable `.vsix`, so every user-visible surface — palette titles, command IDs, terminal name, launched binary, details-panel README — must say codev, and the launched binary must be the fork's `codev` CLI. Distinct `codev.*` IDs also avoid clashing with upstream's extension if both are installed. `--no-dependencies` because the bundle is self-contained (no runtime deps, bun-managed lockfile); `--skip-license` because `sdks/vscode` has no LICENSE file and vsce aborts without it. | Keep our name + displayName + publisher + repository + description, command IDs + titles, `TERMINAL_NAME`, the `codev --port` launch line, the `vsix` script + `@vscode/vsce` devDependency, and the trimmed README (branding + dropped Support/Development sections); take upstream's extension-logic and manifest-structure changes. | | `packages/opencode/src/ide/index.ts` | IDE auto-install target `sst-dev.opencode` → `CoDev.codev-code` (`publisher.name` from `sdks/vscode/package.json`) | The CLI must install the fork's extension ID (`publisher.name` from `sdks/vscode/package.json`), not upstream's Marketplace listing. `Ide.install` currently has no runtime caller (only its test imports the module), and the install only resolves once the extension is actually published — until then the local `.vsix` is the supported install path. | Keep our extension ID (and re-sync it if the registered publisher id changes); take upstream's install/detection logic changes. | | Env-var rename, repo-wide (~230 files): both flag registries (`packages/core/src/flag/flag.ts`, `packages/opencode/src/effect/runtime-flags.ts`), every `process.env`/`Bun.env` read+set site across `packages/*`, `sdks/vscode`, `infra/enterprise.ts`, `nix/opencode.nix`, `turbo.json`, `.github/workflows/test.yml`, test preloads/fixtures/snapshots, docs (en + es), `SECURITY.md`, specs | `OPENCODE_*` env vars → `CODEV_*` (hard cutover, no legacy fallback), including the bundler define identifiers (`CODEV_VERSION`, `CODEV_CHANNEL`, `CODEV_MODELS_DEV`, `CODEV_LIBC`, `CODEV_CLI_NAME`, `CODEV_WORKER_PATH`), `VITE_OPENCODE_*` → `VITE_CODEV_*`, `import.meta.env.OPENCODE_CHANNEL` → `CODEV_CHANNEL`, the VSCode extension's `OPENCODE_CALLER`/`_EXTENSION_OPENCODE_PORT`, and the bare `OPENCODE=1` marker → `CODEV=1` | The fork's env surface uses the fork's name (reverses the earlier "internal names stay upstream" decision for env vars). NOT renamed: `__OPENCODE_PHOTON_WASM_PATH` and `__OPENCODE__` globals (not env vars; the photon name is baked into `patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch`), `patches/install-korean-ime-fix.sh` script-local shell vars, upstream secret names in workflow-deletion notes, lowercase identifiers (`@opencode-ai/*`, URLs, provider id) | Keep our names; rename any new upstream `OPENCODE_*` env var (or define/`VITE_` name) to `CODEV_*` when syncing. | diff --git a/bun.lock b/bun.lock index b965a543ed..79073339e7 100644 --- a/bun.lock +++ b/bun.lock @@ -840,6 +840,7 @@ "name": "@opencode-ai/server", "version": "1.18.23-4", "dependencies": { + "@codev/skillhub": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 413fea9dc3..6824d6400d 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -35,9 +35,14 @@ export const groupNames = { "server.question": "questions", "server.reference": "references", "server.projectCopy": "projectCopies", + "server.skillhub": "skillhub", } as const export const endpointNames = { + "skillhub.stack.list": "listStacks", + "skillhub.stack.prepare": "prepareStack", + "skillhub.stack.install": "installStack", + "skillhub.stack.discard": "discardStack", "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 024c978280..bc7c77740a 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -681,6 +681,62 @@ const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({ refresh: Endpoint17_2(raw), }) +type Endpoint18_0Request = Parameters[0] +type Endpoint18_0Input = { + readonly location?: Endpoint18_0Request["query"]["location"] + readonly search?: Endpoint18_0Request["query"]["search"] + readonly page?: Endpoint18_0Request["query"]["page"] + readonly limit?: Endpoint18_0Request["query"]["limit"] +} +const Endpoint18_0 = (raw: RawClient["server.skillhub"]) => (input?: Endpoint18_0Input) => + raw["skillhub.stack.list"]({ + query: { location: input?.["location"], search: input?.["search"], page: input?.["page"], limit: input?.["limit"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint18_1Request = Parameters[0] +type Endpoint18_1Input = { + readonly slug: Endpoint18_1Request["params"]["slug"] + readonly location?: Endpoint18_1Request["query"]["location"] + readonly scope: Endpoint18_1Request["payload"]["scope"] + readonly agents: Endpoint18_1Request["payload"]["agents"] +} +const Endpoint18_1 = (raw: RawClient["server.skillhub"]) => (input: Endpoint18_1Input) => + raw["skillhub.stack.prepare"]({ + params: { slug: input["slug"] }, + query: { location: input["location"] }, + payload: { scope: input["scope"], agents: input["agents"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint18_2Request = Parameters[0] +type Endpoint18_2Input = { + readonly location?: Endpoint18_2Request["query"]["location"] + readonly stagingID: Endpoint18_2Request["payload"]["stagingID"] + readonly mode: Endpoint18_2Request["payload"]["mode"] +} +const Endpoint18_2 = (raw: RawClient["server.skillhub"]) => (input: Endpoint18_2Input) => + raw["skillhub.stack.install"]({ + query: { location: input["location"] }, + payload: { stagingID: input["stagingID"], mode: input["mode"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint18_3Request = Parameters[0] +type Endpoint18_3Input = { + readonly stagingID: Endpoint18_3Request["params"]["stagingID"] + readonly location?: Endpoint18_3Request["query"]["location"] +} +const Endpoint18_3 = (raw: RawClient["server.skillhub"]) => (input: Endpoint18_3Input) => + raw["skillhub.stack.discard"]({ + params: { stagingID: input["stagingID"] }, + query: { location: input["location"] }, + }).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), +}) + const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), location: adaptGroup1(raw["server.location"]), @@ -700,6 +756,7 @@ const adaptClient = (raw: RawClient) => ({ questions: adaptGroup15(raw["server.question"]), references: adaptGroup16(raw["server.reference"]), projectCopies: adaptGroup17(raw["server.projectCopy"]), + skillhub: adaptGroup18(raw["server.skillhub"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 27ec3d81ba..aeb1677ed4 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -112,6 +112,14 @@ import type { ProjectCopiesRemoveOutput, ProjectCopiesRefreshInput, ProjectCopiesRefreshOutput, + SkillhubListStacksInput, + SkillhubListStacksOutput, + SkillhubPrepareStackInput, + SkillhubPrepareStackOutput, + SkillhubInstallStackInput, + SkillhubInstallStackOutput, + SkillhubDiscardStackInput, + SkillhubDiscardStackOutput, } from "./types" import { ClientError } from "./client-error" @@ -987,6 +995,63 @@ export function make(options: ClientOptions) { requestOptions, ), }, + skillhub: { + listStacks: (input?: SkillhubListStacksInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/skillhub/stack`, + query: { + location: input?.["location"], + search: input?.["search"], + page: input?.["page"], + limit: input?.["limit"], + }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + prepareStack: (input: SkillhubPrepareStackInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/skillhub/stack/${encodeURIComponent(input.slug)}/prepare`, + query: { location: input["location"] }, + body: { scope: input["scope"], agents: input["agents"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + installStack: (input: SkillhubInstallStackInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/skillhub/stack/install`, + query: { location: input["location"] }, + body: { stagingID: input["stagingID"], mode: input["mode"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + discardStack: (input: SkillhubDiscardStackInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/skillhub/stack/staging/${encodeURIComponent(input.stagingID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3b3188c874..d058fd8240 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -101,6 +101,14 @@ export type ProjectCopyError = { export const isProjectCopyError = (value: unknown): value is ProjectCopyError => typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError" +export type SkillHubError = { + readonly _tag: "SkillHubError" + readonly code: "auth" | "not-found" | "already-installed" | "network" | "expired" | "unknown" + readonly message: string +} +export const isSkillHubError = (value: unknown): value is SkillHubError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillHubError" + export type HealthGetOutput = { readonly healthy: true } export type LocationGetInput = { @@ -2805,3 +2813,150 @@ export type ProjectCopiesRefreshInput = { } export type ProjectCopiesRefreshOutput = void + +export type SkillhubListStacksInput = { + 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 + }["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 + }["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 + }["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 + }["limit"] +} + +export type SkillhubListStacksOutput = { + 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 slug: string + readonly name: string + readonly description: string + readonly owner: { + readonly username?: string + readonly firstName?: string + readonly lastName?: string + readonly avatarUrl?: string | null + } + readonly memberCount: number | "Infinity" | "-Infinity" | "NaN" + readonly skillCount: number | "Infinity" | "-Infinity" | "NaN" + readonly updatedAt: string | null + readonly skills: ReadonlyArray<{ + readonly id: string + readonly name: string + readonly description: string + readonly version: string + readonly stars: number | "Infinity" | "-Infinity" | "NaN" + readonly downloadCount: number | "Infinity" | "-Infinity" | "NaN" + }> + readonly previewTruncated: boolean + }> + 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" + } + readonly totalStacks: number | "Infinity" | "-Infinity" | "NaN" + } +} + +export type SkillhubPrepareStackInput = { + readonly slug: { readonly slug: string }["slug"] + 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"> + }["scope"] + readonly agents: { + readonly scope: "project" | "global" + readonly agents: ReadonlyArray<"claude" | "codex" | "opencode" | "codev"> + }["agents"] +} + +export type SkillhubPrepareStackOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly stagingID: string + readonly slug: string + readonly name?: string + readonly skills: ReadonlyArray<{ + readonly name: string + readonly files: number | "Infinity" | "-Infinity" | "NaN" + readonly bytes: number | "Infinity" | "-Infinity" | "NaN" + readonly destinations: ReadonlyArray + readonly conflict: boolean + }> + readonly conflicts: ReadonlyArray + readonly skipped: ReadonlyArray<{ readonly name: string; readonly reason: string }> + readonly totalBytes: number | "Infinity" | "-Infinity" | "NaN" + readonly expiresAt: number | "Infinity" | "-Infinity" | "NaN" + } +} + +export type SkillhubInstallStackInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly stagingID: { readonly stagingID: string; readonly mode: "skip-existing" | "overwrite" }["stagingID"] + readonly mode: { readonly stagingID: string; readonly mode: "skip-existing" | "overwrite" }["mode"] +} + +export type SkillhubInstallStackOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly slug: string + readonly mode: "skip-existing" | "overwrite" + readonly installed: ReadonlyArray<{ + readonly name: string + readonly dir: string + readonly placements: ReadonlyArray<{ + readonly path: string + readonly mode: "store" | "symlink" | "junction" | "copy" + readonly agents: ReadonlyArray<"claude" | "codex" | "opencode" | "codev"> + }> + }> + readonly skipped: ReadonlyArray<{ readonly name: string; readonly reason: string }> + readonly failed: ReadonlyArray<{ readonly name: string; readonly message: string }> + } +} + +export type SkillhubDiscardStackInput = { + readonly stagingID: { readonly stagingID: string }["stagingID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type SkillhubDiscardStackOutput = void diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 322a39cd6b..c7aff66c2e 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -23,6 +23,7 @@ test("exposes every standard HTTP API group", () => { "questions", "references", "projectCopies", + "skillhub", ]) expect(Object.keys(client.messages)).toEqual(["list"]) expect(Object.keys(client.integrations)).toEqual([ diff --git a/packages/codev-skillhub/src/index.ts b/packages/codev-skillhub/src/index.ts index a364a35ad7..b5a826d8fb 100644 --- a/packages/codev-skillhub/src/index.ts +++ b/packages/codev-skillhub/src/index.ts @@ -1,5 +1,5 @@ export { defaultAgents } from "./agents" -export { pathExists } from "./archive" +export { pathExists, zipDirectory } from "./archive" export { SKILLHUB_URL } from "./const" export { setHubTransport } from "./http" export { describeNetworkError } from "./tls" diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 5ecb66db46..356c1b0d13 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -36,6 +36,10 @@ 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" + +// Fork: the v2.skillhub.* scenarios must never reach the live hub. +installFakeSkillHub() function cursor(input: Record) { return Buffer.from(JSON.stringify(input)).toString("base64url") @@ -727,6 +731,49 @@ const scenarios: Scenario[] = [ .status(204, undefined, "status"), http.protected.get("/api/command", "v2.command.list").json(200, locationData(array)), http.protected.get("/api/skill", "v2.skill.list").json(200, locationData(array)), + // Fork: Skill Stack routes, served by the fake hub installed above. + http.protected.get("/api/skillhub/stack", "v2.skillhub.stack.list").json( + 200, + locationData((data) => { + object(data) + array(data.items) + if (data.items.length !== 1) throw new Error(`expected 1 stack, got ${data.items.length}`) + }), + ), + http.protected + .post("/api/skillhub/stack/{slug}/prepare", "v2.skillhub.stack.prepare") + .at((ctx) => ({ + path: route("/api/skillhub/stack/{slug}/prepare", { slug: FAKE_STACK_SLUG }), + headers: ctx.headers(), + body: { scope: "project", agents: ["claude"] }, + })) + .json( + 200, + locationData((data) => { + object(data) + array(data.skills) + array(data.conflicts) + if (typeof data.stagingID !== "string") throw new Error("expected a stagingID") + if (data.skills.length !== 2) throw new Error(`expected 2 planned skills, got ${data.skills.length}`) + }), + ), + // A staging that never existed and one that expired look the same — the + // archive is gone either way — so an unknown id is the expiry path. + http.protected + .post("/api/skillhub/stack/install", "v2.skillhub.stack.install") + .at((ctx) => ({ + path: "/api/skillhub/stack/install", + headers: ctx.headers(), + body: { stagingID: "staging_httpapi_missing", mode: "skip-existing" }, + })) + .json(400, object, "status"), + http.protected + .delete("/api/skillhub/stack/staging/{stagingID}", "v2.skillhub.stack.discard") + .at((ctx) => ({ + path: route("/api/skillhub/stack/staging/{stagingID}", { stagingID: "staging_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(400, object, "status"), 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 new file mode 100644 index 0000000000..5b7ff57994 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts @@ -0,0 +1,72 @@ +import { setHubTransport, zipDirectory } from "@codev/skillhub" +import fs from "fs" +import path from "path" +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. + +export const FAKE_STACK_SLUG = "httpapi-stack" +export const FAKE_STACK_SKILLS = ["alpha-skill", "beta-skill"] as const + +const listing = { + success: true, + data: [ + { + id: "ns-httpapi", + slug: FAKE_STACK_SLUG, + name: "HttpApi Stack", + description: "Fixture stack for the HttpApi exerciser", + owner: { username: "exerciser", firstName: null, lastName: null, avatarUrl: null }, + memberCount: 1, + skillCount: FAKE_STACK_SKILLS.length, + updatedAt: "2026-01-01T00:00:00.000Z", + skills: FAKE_STACK_SKILLS.map((name, index) => ({ + id: `skill-${index}`, + name, + description: `${name} description`, + version: "1.0.0", + stars: 0, + downloadCount: 0, + })), + }, + ], + pagination: { page: 1, limit: 20, total: 1, totalPages: 1 }, + 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`) + } + return (await zipDirectory(dir, FAKE_STACK_SLUG)).buffer + })() + return bundle +} + +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" } }) + } + return new Response(`fake skillhub: unexpected request ${url.pathname}`, { status: 404 }) + } + // Bun's `typeof fetch` also carries a `preconnect` hint the package never + // calls; the desktop's Electron transport has no such member either. + setHubTransport(transport as typeof fetch) +} diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index 7b422d0600..53f13c2265 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -21,6 +21,7 @@ import { LocationGroup } from "./groups/location" import { IntegrationGroup } from "./groups/integration" import { CredentialGroup } from "./groups/credential" import { ProjectCopyGroup } from "./groups/project-copy" +import { SkillHubGroup } from "./groups/skillhub" // Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream. const makeApiFromGroup = < @@ -53,6 +54,7 @@ const makeApiFromGroup = < .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware)) .add(ReferenceGroup.middleware(locationMiddleware)) .add(ProjectCopyGroup.middleware(locationMiddleware)) + .add(SkillHubGroup.middleware(locationMiddleware)) .annotateMerge( OpenApi.annotations({ title: "opencode HttpApi", diff --git a/packages/protocol/src/groups/skillhub.ts b/packages/protocol/src/groups/skillhub.ts new file mode 100644 index 0000000000..5f0f771cd6 --- /dev/null +++ b/packages/protocol/src/groups/skillhub.ts @@ -0,0 +1,105 @@ +import { Location } from "@opencode-ai/schema/location" +import { SkillStack } from "@opencode-ai/schema/skill-stack" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +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 } + +// 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 +// the desktop app share one implementation, and so an install lands on the +// host that actually runs the agent. + +// One error shape with a machine-readable code: the clients key their UI off +// it (`already-installed` drives an overwrite offer, `network` the +// hub-unreachable state, `auth` a sign-in prompt) and a bare message would +// force them to parse English. +export class SkillHubError extends Schema.TaggedErrorClass()( + "SkillHubError", + { code: SkillStack.ErrorCode, message: Schema.String }, + { httpApiStatus: 400 }, +) {} + +const BrowseQuery = Schema.Struct({ + ...LocationQuery.fields, + search: Schema.optional(Schema.String), + page: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1))), + // The hub clamps to 1-50; refusing above that here saves a round trip. + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(50)), + ), +}).annotate({ identifier: "SkillStack.BrowseQuery" }) + +const root = "/api/skillhub/stack" + +export const SkillHubGroup = HttpApiGroup.make("server.skillhub") + .add( + HttpApiEndpoint.get("skillhub.stack.list", root, { + query: BrowseQuery, + success: Location.response(SkillStack.BrowseResult), + error: SkillHubError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.skillhub.stack.list", + summary: "List Skill Stacks", + description: "Browse the public Skill Stacks on the CoDev SkillHub.", + }), + ), + ) + .add( + HttpApiEndpoint.post("skillhub.stack.prepare", `${root}/:slug/prepare`, { + params: { slug: Schema.String }, + query: LocationQuery, + payload: SkillStack.PrepareInput, + success: Location.response(SkillStack.Prepared), + error: SkillHubError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.skillhub.stack.prepare", + summary: "Prepare a Skill Stack install", + description: + "Download and plan a Skill Stack against the location without writing anything. Returns the skills it holds, which are already installed, and a stagingID to install with.", + }), + ), + ) + .add( + HttpApiEndpoint.post("skillhub.stack.install", `${root}/install`, { + query: LocationQuery, + payload: SkillStack.InstallInput, + success: Location.response(SkillStack.Outcome), + error: SkillHubError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.skillhub.stack.install", + summary: "Install a prepared Skill Stack", + description: + "Install every skill from a prepared staging, skipping or overwriting the ones already installed.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("skillhub.stack.discard", `${root}/staging/:stagingID`, { + params: { stagingID: Schema.String }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: SkillHubError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.skillhub.stack.discard", + summary: "Discard a prepared Skill Stack", + description: "Drop a staging without installing it.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "skillhub", description: "CoDev SkillHub Skill Stack routes." })) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index b7c8e5110f..b58d5d6d48 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -19,6 +19,7 @@ export { Session } from "./session" export { SessionInput } from "./session-input" export { SessionMessage } from "./session-message" export { Skill } from "./skill" +export { SkillStack } from "./skill-stack" export { Pty } from "./pty" export { PtyTicket } from "./pty-ticket" export { Question } from "./question" diff --git a/packages/schema/src/skill-stack.ts b/packages/schema/src/skill-stack.ts new file mode 100644 index 0000000000..2a8e83ca4f --- /dev/null +++ b/packages/schema/src/skill-stack.ts @@ -0,0 +1,146 @@ +export * as SkillStack from "./skill-stack" + +import { Schema } from "effect" +import { optional } from "./schema" + +// Fork-only: the wire shapes for CoDev SkillHub Skill Stacks — a public hub +// namespace whose skills install as one bundle. They mirror @codev/skillhub's +// StackCard / StackPlan / StackInstallResult, restated here so the protocol +// depends only on Schema (the node-only package stays behind the server). + +export const Agent = Schema.Literals(["claude", "codex", "opencode", "codev"]).annotate({ + identifier: "SkillStack.Agent", +}) +export type Agent = typeof Agent.Type + +// "project" resolves server-side to the request location's project root; the +// client never names a directory, so it cannot point an install off-project. +export const Scope = Schema.Literals(["project", "global"]).annotate({ identifier: "SkillStack.Scope" }) +export type Scope = typeof Scope.Type + +export const InstallMode = Schema.Literals(["skip-existing", "overwrite"]).annotate({ + identifier: "SkillStack.InstallMode", +}) +export type InstallMode = typeof InstallMode.Type + +export const ErrorCode = Schema.Literals([ + "auth", + "not-found", + "already-installed", + "network", + "expired", + "unknown", +]).annotate({ identifier: "SkillStack.ErrorCode" }) +export type ErrorCode = typeof ErrorCode.Type + +export const SkillPreview = Schema.Struct({ + id: Schema.String, + name: Schema.String, + description: Schema.String, + version: Schema.String, + stars: Schema.Number, + downloadCount: Schema.Number, +}).annotate({ identifier: "SkillStack.SkillPreview" }) +export interface SkillPreview extends Schema.Schema.Type {} + +export const Card = Schema.Struct({ + id: Schema.String, + slug: Schema.String, + name: Schema.String, + description: Schema.String, + owner: Schema.Struct({ + username: optional(Schema.String), + firstName: optional(Schema.String), + lastName: optional(Schema.String), + avatarUrl: optional(Schema.Union([Schema.String, Schema.Null])), + }), + memberCount: Schema.Number, + skillCount: Schema.Number, + updatedAt: Schema.Union([Schema.String, Schema.Null]), + // At most six per stack whatever skillCount says — a preview, not the contents. + skills: Schema.Array(SkillPreview), + previewTruncated: Schema.Boolean, +}).annotate({ identifier: "SkillStack.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, + }), + totalStacks: Schema.Number, +}).annotate({ identifier: "SkillStack.BrowseResult" }) +export interface BrowseResult extends Schema.Schema.Type {} + +export const PrepareInput = Schema.Struct({ + scope: Scope, + // CoDev Code is folded in server-side whatever the client sends. + agents: Schema.Array(Agent), +}).annotate({ identifier: "SkillStack.PrepareInput" }) +export interface PrepareInput extends Schema.Schema.Type {} + +export const Skipped = Schema.Struct({ + name: Schema.String, + reason: Schema.String, +}).annotate({ identifier: "SkillStack.Skipped" }) +export interface Skipped extends Schema.Schema.Type {} + +export const PreparedSkill = Schema.Struct({ + name: Schema.String, + files: Schema.Number, + bytes: Schema.Number, + // Everything this skill would write: the real copy plus any agent links. + destinations: Schema.Array(Schema.String), + // True when any destination already exists on disk. + conflict: Schema.Boolean, +}).annotate({ identifier: "SkillStack.PreparedSkill" }) +export interface PreparedSkill extends Schema.Schema.Type {} + +// What prepare hands back: the archive is downloaded, parsed, and planned +// against the target, but nothing has been written. The client shows the +// conflicts, asks the user, then installs by stagingID within the TTL. +export const Prepared = Schema.Struct({ + stagingID: Schema.String, + slug: Schema.String, + name: optional(Schema.String), + skills: Schema.Array(PreparedSkill), + conflicts: Schema.Array(Schema.String), + skipped: Schema.Array(Skipped), + totalBytes: Schema.Number, + // Epoch milliseconds after which the staging is gone. + expiresAt: Schema.Number, +}).annotate({ identifier: "SkillStack.Prepared" }) +export interface Prepared extends Schema.Schema.Type {} + +export const InstallInput = Schema.Struct({ + stagingID: Schema.String, + mode: InstallMode, +}).annotate({ identifier: "SkillStack.InstallInput" }) +export interface InstallInput extends Schema.Schema.Type {} + +export const Placement = Schema.Struct({ + path: Schema.String, + // A fallback copy is never reported as a link. + mode: Schema.Literals(["store", "symlink", "junction", "copy"]), + agents: Schema.Array(Agent), +}).annotate({ identifier: "SkillStack.Placement" }) +export interface Placement extends Schema.Schema.Type {} + +export const Installed = Schema.Struct({ + name: Schema.String, + dir: Schema.String, + placements: Schema.Array(Placement), +}).annotate({ identifier: "SkillStack.Installed" }) +export interface Installed extends Schema.Schema.Type {} + +export const Outcome = Schema.Struct({ + slug: Schema.String, + mode: InstallMode, + installed: Schema.Array(Installed), + skipped: Schema.Array(Skipped), + failed: Schema.Array(Schema.Struct({ name: Schema.String, message: Schema.String })), +}).annotate({ identifier: "SkillStack.Outcome" }) +export interface Outcome 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 a2bcd4252c..b5371857c4 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -223,6 +223,8 @@ import type { SessionUnshareResponses, SessionUpdateErrors, SessionUpdateResponses, + SkillStackInstallInput, + SkillStackPrepareInput, SubtaskPartInput, SyncHistoryListErrors, SyncHistoryListResponses, @@ -383,6 +385,14 @@ import type { V2SessionSwitchModelResponses, V2SessionWaitErrors, V2SessionWaitResponses, + V2SkillhubStackDiscardErrors, + V2SkillhubStackDiscardResponses, + V2SkillhubStackInstallErrors, + V2SkillhubStackInstallResponses, + V2SkillhubStackListErrors, + V2SkillhubStackListResponses, + V2SkillhubStackPrepareErrors, + V2SkillhubStackPrepareResponses, V2SkillListErrors, V2SkillListResponses, VcsApplyErrors, @@ -6987,6 +6997,175 @@ export class ProjectCopy2 extends HeyApiClient { } } +export class Stack extends HeyApiClient { + /** + * List Skill Stacks + * + * Browse the public Skill Stacks on the CoDev SkillHub. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + search?: string + page?: string + limit?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "query", key: "search" }, + { in: "query", key: "page" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/skillhub/stack", + ...options, + ...params, + }) + } + + /** + * Prepare a Skill Stack install + * + * Download and plan a Skill Stack against the location without writing anything. Returns the skills it holds, which are already installed, and a stagingID to install with. + */ + public prepare( + parameters: { + slug: string + location?: { + directory?: string + workspace?: string + } + skillStackPrepareInput: SkillStackPrepareInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "slug" }, + { in: "query", key: "location" }, + { key: "skillStackPrepareInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SkillhubStackPrepareResponses, + V2SkillhubStackPrepareErrors, + ThrowOnError + >({ + url: "/api/skillhub/stack/{slug}/prepare", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Install a prepared Skill Stack + * + * Install every skill from a prepared staging, skipping or overwriting the ones already installed. + */ + public install( + parameters: { + location?: { + directory?: string + workspace?: string + } + skillStackInstallInput: SkillStackInstallInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { key: "skillStackInstallInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SkillhubStackInstallResponses, + V2SkillhubStackInstallErrors, + ThrowOnError + >({ + url: "/api/skillhub/stack/install", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Discard a prepared Skill Stack + * + * Drop a staging without installing it. + */ + public discard( + parameters: { + stagingID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "stagingID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + V2SkillhubStackDiscardResponses, + V2SkillhubStackDiscardErrors, + ThrowOnError + >({ + url: "/api/skillhub/stack/staging/{stagingID}", + ...options, + ...params, + }) + } +} + +export class Skillhub extends HeyApiClient { + private _stack?: Stack + get stack(): Stack { + return (this._stack ??= new Stack({ client: this.client })) + } +} + export class V2 extends HeyApiClient { private _health?: Health get health(): Health { @@ -7072,6 +7251,11 @@ export class V2 extends HeyApiClient { get projectCopy(): ProjectCopy2 { return (this._projectCopy ??= new ProjectCopy2({ client: this.client })) } + + private _skillhub?: Skillhub + get skillhub(): Skillhub { + return (this._skillhub ??= new Skillhub({ client: this.client })) + } } export class OpencodeClient 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 72b5e6f30a..d24c267895 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2957,6 +2957,12 @@ export type ProjectCopyError = { } } +export type SkillHubError = { + _tag: "SkillHubError" + code: SkillStackErrorCode + message: string +} + export type EffectHttpApiErrorForbidden = { _tag: "Forbidden" } @@ -6151,6 +6157,109 @@ export type ProjectCopyCopy = { directory: string } +export type SkillStackSkillPreview = { + id: string + name: string + description: string + version: string + stars: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + downloadCount: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" +} + +export type SkillStackCard = { + id: string + slug: string + name: string + description: string + owner: { + username?: string + firstName?: string + lastName?: string + avatarUrl?: string + } + memberCount: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + skillCount: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + updatedAt: string + skills: Array + previewTruncated: boolean +} + +export type SkillStackBrowseResult = { + 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" + } + totalStacks: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" +} + +export type SkillStackErrorCode = "auth" | "not-found" | "already-installed" | "network" | "expired" | "unknown" + +export type SkillStackScope = "project" | "global" + +export type SkillStackAgent = "claude" | "codex" | "opencode" | "codev" + +export type SkillStackPrepareInput = { + scope: SkillStackScope + agents: Array +} + +export type SkillStackPreparedSkill = { + name: string + files: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + bytes: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + destinations: Array + conflict: boolean +} + +export type SkillStackSkipped = { + name: string + reason: string +} + +export type SkillStackPrepared = { + stagingID: string + slug: string + name?: string + skills: Array + conflicts: Array + skipped: Array + totalBytes: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + expiresAt: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" +} + +export type SkillStackInstallMode = "skip-existing" | "overwrite" + +export type SkillStackInstallInput = { + stagingID: string + mode: SkillStackInstallMode +} + +export type SkillStackPlacement = { + path: string + mode: "store" | "symlink" | "junction" | "copy" + agents: Array +} + +export type SkillStackInstalled = { + name: string + dir: string + placements: Array +} + +export type SkillStackOutcome = { + slug: string + mode: SkillStackInstallMode + installed: Array + skipped: Array + failed: Array<{ + name: string + message: string + }> +} + export type EventModelsDevRefreshed = { id: string type: "models-dev.refreshed" @@ -13585,6 +13694,158 @@ export type V2ProjectCopyRefreshResponses = { export type V2ProjectCopyRefreshResponse = V2ProjectCopyRefreshResponses[keyof V2ProjectCopyRefreshResponses] +export type V2SkillhubStackListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + search?: string + page?: string + limit?: string + } + url: "/api/skillhub/stack" +} + +export type V2SkillhubStackListErrors = { + /** + * SkillHubError | InvalidRequestError + */ + 400: SkillHubError | InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SkillhubStackListError = V2SkillhubStackListErrors[keyof V2SkillhubStackListErrors] + +export type V2SkillhubStackListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: SkillStackBrowseResult + } +} + +export type V2SkillhubStackListResponse = V2SkillhubStackListResponses[keyof V2SkillhubStackListResponses] + +export type V2SkillhubStackPrepareData = { + body: SkillStackPrepareInput + path: { + slug: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/skillhub/stack/{slug}/prepare" +} + +export type V2SkillhubStackPrepareErrors = { + /** + * SkillHubError | InvalidRequestError + */ + 400: SkillHubError | InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SkillhubStackPrepareError = V2SkillhubStackPrepareErrors[keyof V2SkillhubStackPrepareErrors] + +export type V2SkillhubStackPrepareResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: SkillStackPrepared + } +} + +export type V2SkillhubStackPrepareResponse = V2SkillhubStackPrepareResponses[keyof V2SkillhubStackPrepareResponses] + +export type V2SkillhubStackInstallData = { + body: SkillStackInstallInput + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/skillhub/stack/install" +} + +export type V2SkillhubStackInstallErrors = { + /** + * SkillHubError | InvalidRequestError + */ + 400: SkillHubError | InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SkillhubStackInstallError = V2SkillhubStackInstallErrors[keyof V2SkillhubStackInstallErrors] + +export type V2SkillhubStackInstallResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: SkillStackOutcome + } +} + +export type V2SkillhubStackInstallResponse = V2SkillhubStackInstallResponses[keyof V2SkillhubStackInstallResponses] + +export type V2SkillhubStackDiscardData = { + body?: never + path: { + stagingID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/skillhub/stack/staging/{stagingID}" +} + +export type V2SkillhubStackDiscardErrors = { + /** + * SkillHubError | InvalidRequestError + */ + 400: SkillHubError | InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2SkillhubStackDiscardError = V2SkillhubStackDiscardErrors[keyof V2SkillhubStackDiscardErrors] + +export type V2SkillhubStackDiscardResponses = { + /** + * + */ + 204: void +} + +export type V2SkillhubStackDiscardResponse = V2SkillhubStackDiscardResponses[keyof V2SkillhubStackDiscardResponses] + export type PtyConnectData = { body?: never path: { diff --git a/packages/server/package.json b/packages/server/package.json index 22cf20d7f7..e93936ee48 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,6 +12,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@codev/skillhub": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", "drizzle-orm": "catalog:", diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 3a5e2e777e..24f1ec1057 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -17,6 +17,7 @@ import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" import { ProjectCopyHandler } from "./handlers/project-copy" +import { SkillHubHandler } from "./handlers/skillhub" export const handlers = Layer.mergeAll( HealthHandler, @@ -37,4 +38,5 @@ export const handlers = Layer.mergeAll( QuestionHandler, ReferenceHandler, ProjectCopyHandler, + SkillHubHandler, ) diff --git a/packages/server/src/handlers/skillhub.ts b/packages/server/src/handlers/skillhub.ts new file mode 100644 index 0000000000..725f968c68 --- /dev/null +++ b/packages/server/src/handlers/skillhub.ts @@ -0,0 +1,175 @@ +import { randomUUID } from "node:crypto" +import { mkdir, readFile, rm, writeFile } from "node:fs/promises" +import path from "node:path" +import { + browseStacks, + describeNetworkError, + downloadStack, + findStack, + installStack, + planStackInstall, + readStackArchive, + SKILL_AGENTS, + SkillhubAuthError, + type InstallTarget, + type StackInstallMode, +} 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 { Effect } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +// Fork-only: the Skill Stack routes, over @codev/skillhub — the same package +// `codev skill stack` and the desktop app use, so a stack installed from the +// TUI lands exactly where one installed from the CLI would. Hub auth is the +// server host's shared ~/.codev-hub session, which is the user's own machine +// for every local server (the TUI always spawns one). +// +// Install is two steps so the client can show what is already installed and +// ask before anything is overwritten, without downloading the archive twice: +// prepare parks the ZIP on disk under a stagingID, install consumes it. + +// On disk rather than in memory: a stack can run to hundreds of megabytes, and +// holding a few concurrently would let a handful of prepares exhaust the server. +const STAGING_DIR = path.join(Global.Path.cache, "skillhub", "stacks") +// Long enough to read a conflict list and decide; short enough that an +// abandoned dialog doesn't leave archives behind for the day. +const STAGING_TTL_MS = 10 * 60_000 + +interface Staging { + slug: string + file: string + target: InstallTarget + expiresAt: number +} + +const stagings = new Map() + +export const SkillHubHandler = HttpApiBuilder.group(Api, "server.skillhub", (handlers) => + handlers + .handle("skillhub.stack.list", (ctx) => + response(hub(() => browseStacks({ search: ctx.query.search, page: ctx.query.page, limit: ctx.query.limit }))), + ) + .handle("skillhub.stack.prepare", (ctx) => + response( + Effect.gen(function* () { + const location = yield* Location.Service + return yield* hub(() => prepare(ctx.params.slug, targetFor(ctx.payload, location.project.directory))) + }), + ), + ) + .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())), + ), +) + +// "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 { + const chosen = new Set(input.agents) + chosen.add("codev") + const agents = SKILL_AGENTS.filter((agent) => chosen.has(agent)) + if (input.scope === "global") return { kind: "agents", agents, scope: "global" } + return { kind: "agents-at", agents, root: projectDirectory } +} + +async function prepare(slug: string, target: InstallTarget): Promise { + await sweep() + // The card only lends the stack its display name; a private stack the user + // can see is absent from the public listing but still downloads. + const [card, zip] = await Promise.all([findStack(slug), downloadStack(slug)]) + const archive = readStackArchive(zip, slug) + const plan = await planStackInstall(archive, target) + + const stagingID = randomUUID() + const file = path.join(STAGING_DIR, `${stagingID}.zip`) + await mkdir(STAGING_DIR, { recursive: true }) + await writeFile(file, zip) + const expiresAt = Date.now() + STAGING_TTL_MS + stagings.set(stagingID, { slug, file, target, expiresAt }) + + const planned = new Map(plan.entries.map((entry) => [entry.name, entry])) + return { + stagingID, + slug: archive.slug, + name: card?.name, + skills: archive.skills.map((skill) => { + const entry = planned.get(skill.name) + return { + name: skill.name, + files: skill.files, + bytes: skill.bytes, + destinations: entry?.destinations ?? [], + conflict: entry?.conflict ?? false, + } + }), + conflicts: plan.conflicts, + skipped: archive.skipped, + totalBytes: archive.totalBytes, + expiresAt, + } +} + +async function install(stagingID: string, mode: StackInstallMode): Promise { + const staging = take(stagingID) + const archive = readStackArchive(await readFile(staging.file), staging.slug) + // installStack re-plans against the disk as it is now, not as it was at + // prepare time, so a skill that appeared in between is still honored by + // skip-existing rather than clobbered. + const result = await installStack(archive, { target: staging.target, mode }) + await rm(staging.file, { force: true }) + return result +} + +async function discard(stagingID: string): Promise { + const staging = take(stagingID) + await rm(staging.file, { force: true }) +} + +// Claim a staging exactly once. Missing and expired look the same to the +// client — the archive is gone either way, and the fix is the same: prepare +// again. +function take(stagingID: string): Staging { + const staging = stagings.get(stagingID) + stagings.delete(stagingID) + if (!staging || staging.expiresAt <= Date.now()) { + if (staging) void rm(staging.file, { force: true }) + throw new SkillHubError({ code: "expired", message: "This stack preparation has expired. Prepare it again." }) + } + return staging +} + +async function sweep(): Promise { + const now = Date.now() + for (const [id, staging] of stagings) { + if (staging.expiresAt > now) continue + stagings.delete(id) + await rm(staging.file, { force: true }) + } +} + +function hub(work: () => Promise) { + return Effect.tryPromise({ try: work, catch: toError }) +} + +// Normalize @codev/skillhub failures into the wire codes the clients key off. +// Mirrors the desktop controller's classification, minus its `cause !== +// undefined` heuristic — under Node every failed fetch already says so. +function toError(err: unknown): SkillHubError { + if (err instanceof SkillHubError) return err + if (err instanceof SkillhubAuthError) return new SkillHubError({ code: "auth", message: err.message }) + const message = err instanceof Error ? err.message : String(err) + if (message.startsWith("Already installed at ")) return new SkillHubError({ code: "already-installed", message }) + if (message.includes("is private")) return new SkillHubError({ code: "auth", message }) + if (message.includes("not found")) return new SkillHubError({ code: "not-found", message }) + if (err instanceof Error) { + const network = err.name === "AbortError" || err.name === "TimeoutError" || message === "fetch failed" + if (network) return new SkillHubError({ code: "network", message: describeNetworkError(err) }) + } + return new SkillHubError({ code: "unknown", message }) +}