From 7b0eeae5310bc4576dd2a5767dd8eae518c511a4 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 14 Sep 2026 13:58:59 +0700 Subject: [PATCH 1/2] feat(app): serve the whole Skill Hub through the server and retire the Electron bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-skill browse, detail, and install join the Skill Stacks on the server's fork-only v2.skillhub group (skillhub.skill.list / get / install), and the app's Skill Hub pages call them over the SDK like the Stacks tab already did. The Electron IPC bridge — the main-process controller, its proxy warm-up, the preload namespace, platform.skillhub, and the app-owned wire types — is deleted; nothing desktop-side remains. That removes #363's off-host refusal rather than keeping it: the server downloads the skill and installs it where the agent runs, so "This project" is valid for every project the connected server knows — local, WSL, sandbox, or remote — and "project" is resolved server-side from the request's directory, never from a path the renderer sends. It also gives the web app the Skill Hub: the sidebar entry is no longer gated on a desktop capability. A newly installed skill is made visible to the model by disposing the instance, as the stack install already does. The sign-in offer on an auth failure stays, but only when the app's own SSO writes the session the server will read — i.e. when this machine is the server. Hub traffic now takes the sidecar's network path, which honours the system trust store and HTTPS_PROXY-style settings but not a proxy configured only through OS settings or a PAC file; the changelog says so. The plaintext netmind host in the detail page is base64'd like every other one in shipped source. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P1MfYkJD3tQZRgqBKB4gig --- CHANGELOG.md | 30 +- DIVERGENCES.md | 12 +- bun.lock | 1 - packages/app/package.json | 1 - packages/app/src/components/skill-hub-nav.tsx | 29 +- packages/app/src/context/platform.tsx | 4 - packages/app/src/i18n/en.ts | 6 - packages/app/src/i18n/es.ts | 6 - packages/app/src/i18n/vi.ts | 6 - packages/app/src/pages/skill-hub-detail.tsx | 252 ++++++++-------- packages/app/src/pages/skill-hub.tsx | 283 +++++++++--------- packages/app/src/skillhub/browse-model.ts | 2 +- packages/app/src/skillhub/desktop-only.tsx | 13 - packages/app/src/skillhub/dialog-install.tsx | 101 ++++--- .../app/src/skillhub/dialog-stack-install.tsx | 11 +- packages/app/src/skillhub/hub-error.ts | 17 ++ packages/app/src/skillhub/skill-card.tsx | 5 +- packages/app/src/skillhub/types.ts | 92 ------ packages/client/src/contract.ts | 3 + .../client/src/generated-effect/client.ts | 49 +++ packages/client/src/generated/client.ts | 50 ++++ packages/client/src/generated/types.ts | 168 +++++++++++ packages/desktop/package.json | 1 - packages/desktop/src/main/index.ts | 17 -- .../src/main/skillhub/controller.test.ts | 172 ----------- .../desktop/src/main/skillhub/controller.ts | 227 -------------- packages/desktop/src/main/skillhub/ipc.ts | 63 ---- packages/desktop/src/preload/index.ts | 5 - packages/desktop/src/preload/types.ts | 3 - packages/desktop/src/renderer/index.tsx | 1 - .../test/server/httpapi-exercise/index.ts | 39 ++- .../server/httpapi-exercise/skillhub-fake.ts | 78 +++-- packages/protocol/src/groups/skillhub.ts | 63 +++- packages/schema/src/index.ts | 1 + packages/schema/src/skill-hub.ts | 92 ++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 134 +++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 212 +++++++++++++ packages/server/src/handlers/skillhub.ts | 42 ++- 38 files changed, 1279 insertions(+), 1012 deletions(-) delete mode 100644 packages/app/src/skillhub/desktop-only.tsx create mode 100644 packages/app/src/skillhub/hub-error.ts delete mode 100644 packages/app/src/skillhub/types.ts delete mode 100644 packages/desktop/src/main/skillhub/controller.test.ts delete mode 100644 packages/desktop/src/main/skillhub/controller.ts delete mode 100644 packages/desktop/src/main/skillhub/ipc.ts create mode 100644 packages/schema/src/skill-hub.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be9d5b6a0..b760266ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -264,22 +264,20 @@ key_refused`; against an older backend the previous behaviour stands. the install dialog offers the browser sign-in right in the app. ([#331](https://github.com/quickbeard/codev-code/pull/331), [#335](https://github.com/quickbeard/codev-code/pull/335)) -- **Skill Hub works behind a corporate proxy** — browsing and installing from - the desktop app used to go straight to the network, ignoring the system - proxy and PAC settings that the app's own sign-in already honored, so on a - proxied network the Skill Hub page showed "Couldn't reach the Skill Hub" - while sign-in worked. Hub traffic, including the silent session refresh - behind it, now goes through the same proxy-aware network stack, and the app - waits for the proxy configuration to finish loading before the first hub - request instead of counting that wait against the request's own timeout. -- **Installing into a project on another machine is refused, not misplaced** - — when the app is connected to a WSL distro, a sandbox, or a remote server, - the install dialog no longer offers "This project": the app installs onto - the computer it runs on, so that folder is not here, and the previous - behavior created a stray folder on the wrong disk (or failed with an - unhelpful error). The dialog says which server you are connected to and - points at `codev skill pull` on that machine; the desktop side also checks - that a project folder really exists before it downloads anything. +- **The Skill Hub now goes through the server** — browsing and installing + skills from the app used to happen inside the desktop app itself, which is + why only the desktop app had a Skill Hub, why installing into a project on + a WSL distro, sandbox, or remote server had to be refused (the files would + have landed on the wrong disk), and why the page needed its own + corporate-proxy handling. The app now asks the server it is connected to, + which downloads the skill and installs it where the agent actually runs: + "This project" works for every project that server knows, the web app has + the Skill Hub too, and a newly installed skill is visible to the model + straight away. Hub traffic takes the same network path as the server's + model-gateway traffic — the system trust store and `HTTPS_PROXY`-style + proxy settings apply; a proxy configured only through the operating + system's settings or a PAC file is not picked up, which is the one place + the previous desktop-side approach was stronger. ### Language diff --git a/DIVERGENCES.md b/DIVERGENCES.md index f5830c8cd..4097191f4 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -221,13 +221,13 @@ from the squash message first, or use a plain merge. | `packages/app/src/context/global-sync/utils.ts` | `normalizeProviderList` returns through `filterHiddenProviders` (fork-only `src/gateway/hidden-providers.ts`): upstream's hosted providers `opencode` (Zen) and `opencode-go` are dropped from `all`/`default`, and the unauthenticated free-tier `opencode` from `connected` (a deliberately configured paid Zen key still counts as connected, mirroring the TUI's `isConnectedProvider`). | Client-side hiding at the one choke point both the global bootstrap and per-directory catalogs flow through — mirrors the TUI's dialog filters without writing `disabled_providers`. Downstream surfaces keyed off those catalog entries (`dialog-select-model-unpaid{,-v2}.tsx`, the `usage-exceeded-dialogs.tsx` Go upsell, Zen branches in `dialog-connect-provider.tsx`, hosted entries in `use-providers.ts` `popularProviders`, settings badges) become deliberately-dead code left byte-identical to upstream so merges stay cheap. | Keep the import + wrap; take upstream's normalization changes. Do not "clean up" the dead hosted-provider surfaces — they cost nothing and would churn every sync. | | `packages/ui/src/context/dialog.tsx` | Per-dialog `persistent` flag (precedent: the TUI's persistent dialog, see the `packages/tui/src/ui/dialog.tsx` row): Escape, backdrop click, and Kobalte `onOpenChange` are no-ops for a dialog marked persistent; programmatic `close()` still works; `setPersistent` targets the top dialog so pushed sub-dialogs stay dismissable; `useDialog()` gains `persistent`/`setPersistent`. | The desktop configure gate needs a dialog the user cannot dismiss while no provider is configured. | Keep the flag and the three guards; take upstream's surrounding dialog changes. | | `packages/app/src/app.tsx` | `` (fork-only `components/gateway/`) mounted as the first child inside `ServerScopedProviders`' `ModelsProvider`. | The desktop configure gate; the placement is load-bearing — completing the flow seeds the live `useModels()` recents store (`buildGatewayConfig` deliberately writes no `config.model` pin). | Keep the mount inside `ModelsProvider`; take upstream's surrounding shell changes. | -| `packages/app/src/context/platform.tsx` | Added optional `gatewaySso?: GatewaySsoPlatform` and `skillhub?: SkillHubPlatform` capabilities (skillhub wire types in fork-only `src/skillhub/types.ts`) (wire types in fork-only `src/gateway/types.ts`, exported as `@opencode-ai/app/gateway/types` — see the `packages/app/package.json` row). | Desktop-only SSO login for the configure gate; absence of the capability is the web deployment's kill-switch. | Keep the field + import; take upstream's surrounding platform changes. | +| `packages/app/src/context/platform.tsx` | Added optional `gatewaySso?: GatewaySsoPlatform` capability (wire types in fork-only `src/gateway/types.ts`, exported as `@opencode-ai/app/gateway/types` — see the `packages/app/package.json` row). | Desktop-only SSO login for the configure gate; absence of the capability is the web deployment's kill-switch. | Keep the field + import; take upstream's surrounding platform changes. | | `packages/app/src/components/prompt-input-v2.tsx` | `onUnpaidClick` opens `DialogConnectProvider` (lazy import) instead of the free-models "unpaid" dialogs; their now-unused imports dropped. (The legacy `prompt-input.tsx` carried the same swap until its deletion — see the legacy prompt-input removal row.) | With the hosted providers hidden, "unpaid" means nothing is connected, and the free-models dialogs would render empty lists (this path is reachable on the web deployment; on desktop the gate covers it). | Keep the handler swap; take upstream's surrounding prompt-input changes. | | `packages/desktop/src/renderer/index.tsx` | Calls `installConsoleFormatting()` (fork-only `./console-format`) before anything else logs. | `spyRendererConsole` receives Chromium's flattened console-message string, so any renderer `console.*` with an object argument reached the terminal and the exported debug logs as `[object Object]`; the wrapper serializes structured arguments (Error-aware, circular-safe, truncated) at the source. | Keep the import and call; take upstream's surrounding entry changes. | | Desktop gateway SSO wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Main instantiates the fork-only `src/main/gateway-sso/` controller (wrapping `@codev/gateway` `login`/`silentSso` — node-only loopback OAuth — with `shell.openExternal` and a `net.fetch` wrapper) and registers its `gateway-sso-*` IPC channels next to the sandbox ones, passing an `onConfigured` callback that restarts the running sandboxes (`SandboxController.restartRunning`) because a guest is handed its codev config once at boot and cannot see credentials that appear afterwards; preload exposes the `gatewaySso` namespace (subscribe fan-out, sandbox pattern) with types re-exported from `@opencode-ai/app/gateway/types`; the renderer platform maps `gatewaySso: window.api.gatewaySso`. | SSO login for the desktop configure gate must run in the main process (`node:http` loopback server, `~/.codev/auth.json` writes); `net.fetch` so enterprise proxies/PAC apply (egress precedent). | Keep the wiring; take upstream's surrounding changes. Keep channel names `gateway-sso-*` stable. | -| Desktop Skill Hub wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx`, `packages/desktop/package.json`, `packages/app/src/app.tsx`, `packages/app/src/i18n/{en,es}.ts` | Main registers the fork-only `src/main/skillhub/` controller (over the fork-only `@codev/skillhub` package) and its `skillhub-*` IPC channels, passing a `net.fetch` wrapper as the hub transport (`setHubTransport`) and a `warmProxy` hook — `session.defaultSession.resolveProxy(SKILLHUB_URL)` — so hub traffic honors enterprise proxies/PAC like the gateway SSO calls do and Chromium's proxy initialization is waited out before the first hub request; preload exposes the `skillhub` namespace (types aliased from `@opencode-ai/app/skillhub/types`); renderer maps `skillhub: window.api.skillhub` into the platform; `@codev/skillhub` added under desktop devDependencies (electron-vite bundles devDependency workspace packages); app.tsx registers lazy `/skills` + `/skills/stacks` + `/skills/:id` routes inside the new-layout `` (`/skills/stacks` before `/skills/:id`; it is the fork-only Skill Stacks page, `src/pages/skill-stacks.tsx`, served by the server's `v2.skillhub` routes rather than the Electron bridge); i18n adds `sidebar.skillhub` + the `skillhub.*` and `skillstack.*` families (vi.ts is fork-only). | The Skill Hub page is native (fork-only `packages/app/src/{skillhub/**,pages/skill-hub*.tsx,components/skill-hub-nav.tsx}`); all hub HTTP must originate in the Electron main process because the hub serves no ACAO on /api/v1 and 403s cross-origin writes, so the renderer reaches it only over IPC. | Keep the registration/preload/renderer/package.json lines, the two route lines, and the i18n keys; take upstream's surrounding changes. | +| Skill Hub app wiring: `packages/app/src/app.tsx`, `packages/app/src/i18n/{en,es}.ts` | app.tsx registers lazy `/skills` + `/skills/stacks` + `/skills/:id` routes inside the new-layout `` (`/skills/stacks` before `/skills/:id`); i18n adds `sidebar.skillhub` + the `skillhub.*` and `skillstack.*` families (vi.ts is fork-only). | The Skill Hub pages are native and fork-only (`packages/app/src/{skillhub/**,pages/skill-hub*.tsx,pages/skill-stacks.tsx,components/skill-hub-nav.tsx}`) and talk to the connected server's fork-only `v2.skillhub` routes: the hub serves no ACAO on /api/v1, so a browser cannot reach it and the server proxies it — which also puts the install on the host that runs the agent. The earlier Electron IPC bridge (`packages/desktop/src/main/skillhub/`, a preload `skillhub` namespace, `platform.skillhub`) is gone; nothing desktop-side remains. | Keep the three route lines and the i18n keys; take upstream's surrounding changes. | | `packages/app/vite.js` | Added `optimizeDeps.entries`: the default `index.html` plus session-ui's `markdown.worker.ts` (absolute path derived from the plugin file, forward-slashed for fast-glob), so the dev-time dep scanner crawls the markdown worker at startup. | Vite's dep scanner does not crawl `?worker&url` entries, so the worker's deps (`@shikijs/stream`, `katex`, `marked`, `marked-shiki`, `remend`, `shiki`) were first discovered when the first markdown rendered; that mid-session re-optimize full-page reloads the dev app — e.g. a Skill Hub search reset itself the moment results rendered. A bare `optimizeDeps.include` list cannot express this fix: with bun's isolated linker the deps live only in the workspace packages' own `node_modules`, and no single include chain resolves from both consumers' roots (`packages/app` and the desktop renderer's `src/renderer`). Dev-only; production builds ignore `optimizeDeps`. | Keep the `optimizeDeps` block and keep `index.html` listed in it — setting `entries` replaces Vite's default entry inference, so dropping it unscans the app itself. If upstream moves or renames the worker file, update the path: a stale pattern matches nothing, logs nothing, and silently brings the reload back. | -| `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"`, `"./skillhub/types": "./src/skillhub/types.ts"`, and `"./i18n/*": "./src/i18n/*.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern); the desktop main process imports the locale dicts to translate the macOS menu bar (`./i18n/*` mirrors how `@opencode-ai/ui` already exports its own — see the localized desktop menu row). | Keep both exports; take upstream's other manifest changes. | +| `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"` and `"./i18n/*": "./src/i18n/*.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern); the desktop main process imports the locale dicts to translate the macOS menu bar (`./i18n/*` mirrors how `@opencode-ai/ui` already exports its own — see the localized desktop menu row). | Keep both exports; take upstream's other manifest changes. | | `packages/app/src/i18n/{en,es}.ts` | Added the `gateway.configure.*` key family (configure-gate dialog) and reworded `sidebar.gettingStarted.line1` — the "includes free models" pitch no longer holds with the hosted providers hidden. | Strings for the fork's configure gate; the getting-started card must not advertise models the fork does not offer. | Keep our keys and copy; take upstream's surrounding i18n changes. | | `packages/desktop/src/main/index.ts` | Deleted upstream's `app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")`. | Chromium's `<-loopback>` bypass rule means the opposite of what it reads as: it *subtracts* the implicit rules that keep loopback off the proxy, i.e. it opts localhost traffic *into* proxying (`net/docs/proxy.md`). Today it is inert — Electron reads `--proxy-bypass-list` only together with `--proxy-server` (`shell/browser/browser_process_impl.cc`), which the app never passes — but it becomes actively harmful the moment anything launches the app with `--proxy-server`: the renderer's sidecar traffic would be routed into the proxy. Chromium already bypasses loopback by default, so nothing replaces it. | Keep deleted. If upstream adds new proxy switches, check them against Chromium's implicit-bypass semantics before taking them. | | Startup proxy-stall gate: `packages/desktop/src/main/{index,ipc}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx`, `packages/app/src/app.tsx`, `packages/app/src/context/{platform,server-sdk}.tsx`, `packages/app/src/utils/server-protocol.ts` (+ `.test.ts`), `packages/app/src/context/global-sync/utils.ts` (+ `bootstrap.test.ts`) | Five additive pieces. (1) `main/index.ts` fires `session.defaultSession.resolveProxy(url)` for the sidecar URL as soon as the port is known (fire-and-forget, result logged). (2) A `local-server-health` IPC chain: main answers with the Node-fetch `checkHealth` against the spawned sidecar once `serverReady` resolves (`false` if initialization failed), preload exposes `localServerHealth()`, the renderer platform maps it, and `platform.tsx` gains the optional `localServerHealth?()` capability. (3) `app.tsx` `ConnectionGate`: the health-check effect is restructured around an inner `poll`; when the 10 s blocking window expires without a healthy answer and the connection is the built-in sidecar, it asks `platform.localServerHealth` — if the host process vouches for the server it keeps blocking on the splash for up to another 50 s instead of showing the unreachable error. Background mode and `type: "http"` behavior are unchanged, and without the capability (web) the gate behaves exactly as upstream. (4) A `local-server-protocol` IPC chain beside the health one: main answers with the `SIDECAR_VERSION` it launched, preload exposes `localServerProtocol()`, the renderer platform maps it, `platform.tsx` gains the optional `localServerProtocol?()` capability, and `server-sdk.tsx` uses it in place of `detectServerProtocol` when the connection is `ServerConnection.builtin`. (5) `server-protocol.ts` `probe` reports whether the server answered at all (`reached`); `detectServerProtocol` returns `"v1"` when neither health probe got any response (a timeout or transport failure) and keeps upstream's `"v2"` default for a server that answered with neither health shape. `global-sync/utils.ts` `normalizeAgentList` reads `agent.request.settings` optionally (`?.`, `?? {}`) — this repo's V2 `Provider.Request` schema has no `settings`, so the previous unconditional read threw on the first V2 agent. | Chromium queues every renderer request — loopback included — until proxy initialization (PAC download / WPAD discovery) resolves; the implicit loopback-bypass rules are only applied once the resolution service is READY (`net/proxy_resolution/configured_proxy_resolution_service.cc`). On corporate networks that takes tens of seconds (measured 21 s vs 64 ms with an unreachable PAC host), so users behind a proxy saw "Could not reach Local Server" at every launch while the server was healthy the whole time — main's own Node-fetch health check (loopback in `NO_PROXY`) had already passed before the window opened. The prewarm overlaps proxy init with sidecar startup; the gate fallback distinguishes "server down" from "renderer network stack not ready". The same stall hit `detectServerProtocol` after the gate: its two 5 s renderer probes timed out and its fallthrough answered `"v2"` for the v1 sidecar, so the app bootstrapped over the transitional `/api` routes — `/api/model/default` does not exist on v1 (the provider query failed with `UnexpectedStatus`, `connected` stayed empty and the configure gate reopened after every successful sign-in) and the V2 agent payload has no `request.settings` (`normalizeAgentList` threw "reading 'temperature'"). Users behind a corporate proxy could log in and pick a model but never leave "Set up CoDev". Main knows which sidecar it launched, so the desktop no longer probes for it; the detector's silence rule covers remote servers reached through the same stall. | Keep all five pieces; take upstream's surrounding changes. If upstream restructures `ConnectionGate`, re-apply the vouch-then-extend fallback around whatever its blocking window becomes. Keep the `local-server-health` and `local-server-protocol` channel names stable. If upstream changes `detectServerProtocol`'s fallthrough, keep silence (no response at all) mapping to `"v1"` while every server this fork ships answers `/global/health`. If upstream's V2 agent schema gains `request.settings`, the optional reads in `normalizeAgentList` become no-ops and can be dropped. | @@ -291,9 +291,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. | +| 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` and `SkillHub` (`src/skill-stack.ts`, `src/skill-hub.ts`, fork-only). | The v2 group `server.skillhub` (stacks: `GET /api/skillhub/stack`, `POST …/:slug/prepare`, `POST …/install`, `DELETE …/staging/:stagingID`; skills: `GET /api/skillhub/skill`, `GET …/:idOrName`, `POST …/:id/install`) puts all CoDev SkillHub 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` and `listSkills`/`getSkill`/`installSkill`; `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 the `v2.skillhub.*` scenarios (four stack, three skill) 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. | | `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/config/keybind.ts` | `prompt/index.tsx` imports the fork-only `DialogStack` (`component/dialog-stack.tsx`) and registers the `prompt.stacks` command (`/stacks`, category Prompt) directly after `prompt.skills`; `keybind.ts` adds `prompt_stacks: keybind("none", …)` beside `prompt_skills` and maps it to `prompt.stacks`. | The Skill Stack browser is the TUI's only way to reach the CoDev SkillHub (the hub is CORS-closed and the TUI reaches it through the server's fork-only `v2.skillhub` routes); it sits next to `/skills` because that is where a user looks for skills. Unbound by default like `prompt_skills`. | Keep the import, the command entry, and the two keybind lines; take upstream's surrounding command/keybind 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. | diff --git a/bun.lock b/bun.lock index 79073339e..d52266687 100644 --- a/bun.lock +++ b/bun.lock @@ -445,7 +445,6 @@ "@actions/artifact": "4.0.0", "@codev/gateway": "workspace:*", "@codev/sandbox": "workspace:*", - "@codev/skillhub": "workspace:*", "@codev/upload": "workspace:*", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/app/package.json b/packages/app/package.json index 7f98000d7..44f4ed5f6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -11,7 +11,6 @@ "./wsl/types": "./src/wsl/types.ts", "./sandbox/types": "./src/sandbox/types.ts", "./gateway/types": "./src/gateway/types.ts", - "./skillhub/types": "./src/skillhub/types.ts", "./i18n/*": "./src/i18n/*.ts", "./vite": "./vite.js", "./index.css": "./src/index.css" diff --git a/packages/app/src/components/skill-hub-nav.tsx b/packages/app/src/components/skill-hub-nav.tsx index b37d99259..7a1cfaba0 100644 --- a/packages/app/src/components/skill-hub-nav.tsx +++ b/packages/app/src/components/skill-hub-nav.tsx @@ -1,32 +1,27 @@ import { Icon } from "@opencode-ai/ui/v2/icon" import { useLocation, useNavigate } from "@solidjs/router" -import { Show } from "solid-js" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" import { HOME_PROJECT_NAV_LABEL, HOME_PROJECT_NAV_ROW } from "@/pages/home" // Fork-only: the sidebar's Skill Hub entry, rendered directly above Settings -// (see app-sidebar.tsx). Hidden entirely when the platform has no skillhub -// bridge (the web build), matching the page's desktop-only fallback. +// (see app-sidebar.tsx). The pages behind it talk to the connected server, so +// the entry is there in the web app and the desktop app alike. export function SkillHubNav() { const language = useLanguage() - const platform = usePlatform() const location = useLocation() const navigate = useNavigate() const active = () => location.pathname === "/skills" || location.pathname.startsWith("/skills/") return ( - - - + ) } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 9c2c2f75b..a3efe848a 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -6,7 +6,6 @@ import { ServerConnection } from "./server" import type { WslServersPlatform } from "../wsl/types" import type { SandboxesPlatform } from "../sandbox/types" import type { GatewaySsoPlatform } from "../gateway/types" -import type { SkillHubPlatform } from "../skillhub/types" import type { UpdaterPlatform } from "../updater" import type { DraftStore } from "@/utils/draft-store" import type { ServerProtocol } from "@/utils/server-protocol" @@ -111,9 +110,6 @@ type PlatformBase = { /** Gateway SSO login for the configure gate (Electron only) */ gatewaySso?: GatewaySsoPlatform - /** SkillHub registry browsing and installs (Electron only) */ - skillhub?: SkillHubPlatform - /** Get the preferred display backend (desktop only) */ getDisplayBackend?(): Promise | DisplayBackend | null diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d9106e64d..d1568be2c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1080,8 +1080,6 @@ export const dict = { "skillhub.install.scope.label": "Install to", "skillhub.install.scope.project": "This project", "skillhub.install.scope.global": "Global (all projects)", - "skillhub.install.scope.remote": - "You are connected to {{server}}. The app installs skills on this computer, so a project install is unavailable and a global install will not be seen there. Run `codev skill pull` on that machine instead.", "skillhub.install.agents.label": "For which agents?", "skillhub.install.agents.locked": "Always included", "skillhub.install.overwrite.title": "Already installed", @@ -1095,7 +1093,6 @@ export const dict = { "skillhub.install.signin": "Sign in", "skillhub.install.signin.waiting": "Finish signing in in your browser…", "skillhub.install.error.failed": "The Skill Hub could not deliver this skill.", - "skillhub.install.error.missingDirectory": "That project folder is not on this computer.", "skillhub.install.success.title": "Installed {{name}}", "skillhub.install.done": "Done", "skillhub.tabs.skills": "Skills", @@ -1132,9 +1129,6 @@ export const dict = { "skillstack.error.notFound": "That stack is gone, or has no public skills.", "skillstack.error.expired": "That download expired before it was installed. Try again.", "skillstack.error.unknown": "The Skill Hub could not deliver this stack.", - "skillhub.desktopOnly.title": "Skill Hub is available in the desktop app", - "skillhub.desktopOnly.description": - "Browse and install skills from the CoDev desktop app, or use `codev skill` in the terminal.", "sidebar.help": "Help", "sidebar.pinned": "Pinned", "sidebar.recents": "Recents", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 4f3191571..01c3307d6 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -1074,8 +1074,6 @@ export const dict = { "skillhub.install.scope.label": "Instalar en", "skillhub.install.scope.project": "Este proyecto", "skillhub.install.scope.global": "Global (todos los proyectos)", - "skillhub.install.scope.remote": - "Estás conectado a {{server}}. La aplicación instala las habilidades en este equipo, así que la instalación en el proyecto no está disponible y una instalación global no se verá allí. Ejecuta `codev skill pull` en esa máquina.", "skillhub.install.agents.label": "¿Para qué agentes?", "skillhub.install.agents.locked": "Siempre incluido", "skillhub.install.overwrite.title": "Ya instalado", @@ -1089,7 +1087,6 @@ export const dict = { "skillhub.install.signin": "Iniciar sesión", "skillhub.install.signin.waiting": "Termina de iniciar sesión en tu navegador…", "skillhub.install.error.failed": "El Skill Hub no pudo entregar esta habilidad.", - "skillhub.install.error.missingDirectory": "Esa carpeta de proyecto no está en este equipo.", "skillhub.install.success.title": "{{name}} instalado", "skillhub.install.done": "Hecho", "skillhub.tabs.skills": "Habilidades", @@ -1126,9 +1123,6 @@ export const dict = { "skillstack.error.notFound": "Ese stack ya no existe o no tiene habilidades públicas.", "skillstack.error.expired": "Esa descarga caducó antes de instalarse. Inténtalo de nuevo.", "skillstack.error.unknown": "El Skill Hub no pudo entregar este stack.", - "skillhub.desktopOnly.title": "Skill Hub está disponible en la aplicación de escritorio", - "skillhub.desktopOnly.description": - "Explora e instala skills desde la aplicación de escritorio de CoDev, o usa `codev skill` en la terminal.", "sidebar.help": "Ayuda", "sidebar.pinned": "Fijadas", "sidebar.recents": "Recientes", diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts index 4ceabe9ad..ba338c239 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -1037,8 +1037,6 @@ export const dict = { "skillhub.install.scope.label": "Cài vào", "skillhub.install.scope.project": "Dự án này", "skillhub.install.scope.global": "Toàn cục (mọi dự án)", - "skillhub.install.scope.remote": - "Bạn đang kết nối tới {{server}}. Ứng dụng cài kỹ năng lên máy tính này, nên không thể cài vào dự án và cài toàn cục sẽ không hiện ở đó. Hãy chạy `codev skill pull` trên máy đó.", "skillhub.install.agents.label": "Cho những agent nào?", "skillhub.install.agents.locked": "Luôn được bao gồm", "skillhub.install.overwrite.title": "Đã được cài đặt", @@ -1052,7 +1050,6 @@ export const dict = { "skillhub.install.signin": "Đăng nhập", "skillhub.install.signin.waiting": "Hoàn tất đăng nhập trong trình duyệt…", "skillhub.install.error.failed": "Skill Hub không thể tải kỹ năng này về.", - "skillhub.install.error.missingDirectory": "Thư mục dự án đó không có trên máy tính này.", "skillhub.install.success.title": "Đã cài đặt {{name}}", "skillhub.install.done": "Xong", "skillhub.tabs.skills": "Kỹ năng", @@ -1089,9 +1086,6 @@ export const dict = { "skillstack.error.notFound": "Stack này không còn tồn tại hoặc không có kỹ năng công khai.", "skillstack.error.expired": "Bản tải này đã hết hạn trước khi được cài. Hãy thử lại.", "skillstack.error.unknown": "Skill Hub không thể tải stack này về.", - "skillhub.desktopOnly.title": "Skill Hub có sẵn trong ứng dụng desktop", - "skillhub.desktopOnly.description": - "Duyệt và cài skill từ ứng dụng desktop CoDev, hoặc dùng `codev skill` trong terminal.", "sidebar.help": "Trợ giúp", "sidebar.pinned": "Đã ghim", "sidebar.recents": "Gần đây", diff --git a/packages/app/src/pages/skill-hub-detail.tsx b/packages/app/src/pages/skill-hub-detail.tsx index 93181e6d7..f86e6c0df 100644 --- a/packages/app/src/pages/skill-hub-detail.tsx +++ b/packages/app/src/pages/skill-hub-detail.tsx @@ -1,3 +1,4 @@ +import type { SkillHubDetail } from "@opencode-ai/sdk/v2/client" import { Markdown } from "@opencode-ai/session-ui/markdown" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" @@ -7,149 +8,146 @@ import { useNavigate, useParams } from "@solidjs/router" import { createResource, Match, Show, Switch } from "solid-js" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" +import { useServerSDK } from "@/context/server-sdk" import { DialogSkillInstall } from "@/skillhub/dialog-install" -import { SkillHubDesktopOnly } from "@/skillhub/desktop-only" -import type { SkillHubDetail } from "@/skillhub/types" +import { hubError } from "@/skillhub/hub-error" -const HUB_URL = "https://netmind.viettel.vn/netmindhub" +// Base64 like every other netmind host in shipped source, to keep it out of +// trivial greps (the convention @codev/skillhub, @codev/gateway and the +// updater all follow). +const HUB_URL = `${atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4=")}/netmindhub` // A skill's detail page: header with install + external link, SKILL.md // rendered through the app's existing Markdown pipeline (hub text never -// reaches innerHTML outside it). +// reaches innerHTML outside it). Served by the connected server's v2.skillhub +// routes, so it works in the web app and against a remote server alike. export default function SkillHubDetailPage() { const language = useLanguage() const platform = usePlatform() + const serverSDK = useServerSDK() const params = useParams<{ id: string }>() + const navigate = useNavigate() + const dialog = useDialog() - return ( - }> - {(skillhub) => { - const navigate = useNavigate() - const dialog = useDialog() - const [result, { refetch }] = createResource( - () => params.id, - (id) => skillhub().detail(id), - ) + const [result, { refetch }] = createResource( + () => params.id, + async (id) => { + const response = await serverSDK().createClient({ throwOnError: false }).v2.skillhub.skill.get({ idOrName: id }) + if (!response.data) return { ok: false as const, error: hubError(response.error) } + return { ok: true as const, value: response.data.data } + }, + ) - const readme = (detail: SkillHubDetail) => - detail.files.find((file) => { - const path = file.filePath.toLowerCase() - return path === "skill.md" || path.endsWith("/skill.md") - }) + const readme = (detail: SkillHubDetail) => + detail.files.find((file) => { + const path = file.filePath.toLowerCase() + return path === "skill.md" || path.endsWith("/skill.md") + }) - return ( - -
-
- (history.length > 1 ? navigate(-1) : navigate("/skills"))}> - ← {language.t("skillhub.detail.back")} - -
+ return ( + +
+
+ (history.length > 1 ? navigate(-1) : navigate("/skills"))}> + ← {language.t("skillhub.detail.back")} + +
- - -
- -
-
- - {(failed) => { - const errored = failed() - const key = - errored.ok === false && errored.error.code === "network" - ? "skillhub.error.network" - : "skillhub.error.unknown" - return ( -
-
{language.t(key)}
- void refetch()}> - {language.t("skillhub.error.retry")} - + + +
+ +
+
+ + {(failed) => { + const errored = failed() + const key = + errored.ok === false && errored.error.code === "network" + ? "skillhub.error.network" + : "skillhub.error.unknown" + return ( +
+
{language.t(key)}
+ void refetch()}> + {language.t("skillhub.error.retry")} + +
+ ) + }} +
+ + {(loaded) => { + const detail = () => { + const current = loaded() + return current.ok ? current.value : undefined + } + return ( + + {(skill) => ( + <> +
+
+
+

+ {skill().displayName || skill().name} +

+ + v{skill().version} + +
+
+ {skill().author?.name || skill().author?.username || skill().provider} + + ★ {Number(skill().stars) ? Number(skill().stars).toFixed(1) : "–"} ({skill().totalRatings}{" "} + {language.t("skillhub.detail.ratings")}) + + + ↓ {skill().downloadCount} {language.t("skillhub.detail.downloads")} + +
+
+
+ platform.openExternal(`${HUB_URL}/skills/${encodeURIComponent(skill().id)}`)} + > + {language.t("skillhub.detail.openInHub")} + + + dialog.show(() => ( + + )) + } + > + {language.t("skillhub.detail.install")} + +
- ) - }} -
- - {(loaded) => { - const detail = () => { - const current = loaded() - return current.ok ? current.value : undefined - } - return ( - - {(skill) => ( - <> -
-
-
-

- {skill().displayName || skill().name} -

- - v{skill().version} - -
-
- {skill().author?.name || skill().author?.username || skill().provider} - - ★ {skill().stars ? skill().stars.toFixed(1) : "–"} ({skill().totalRatings}{" "} - {language.t("skillhub.detail.ratings")}) - - - ↓ {skill().downloadCount} {language.t("skillhub.detail.downloads")} - -
-
-
- - platform.openExternal(`${HUB_URL}/skills/${encodeURIComponent(skill().id)}`) - } - > - {language.t("skillhub.detail.openInHub")} - - - dialog.show(() => ( - - )) - } - > - {language.t("skillhub.detail.install")} - -
-
- - {language.t("skillhub.detail.noReadme")} -
- } - > - {(file) => ( -
- -
- )} - - + + {language.t("skillhub.detail.noReadme")} +
+ } + > + {(file) => ( +
+ +
)} - ) - }} - - -
-
- ) - }} -
+ + )} +
+ ) + }} + + + + ) } diff --git a/packages/app/src/pages/skill-hub.tsx b/packages/app/src/pages/skill-hub.tsx index 49fcc48c2..79ddbfe0f 100644 --- a/packages/app/src/pages/skill-hub.tsx +++ b/packages/app/src/pages/skill-hub.tsx @@ -1,3 +1,4 @@ +import type { SkillHubDomain, SkillHubSort } from "@opencode-ai/sdk/v2/client" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" @@ -6,158 +7,166 @@ import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { createResource, For, Match, Show, Switch } from "solid-js" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" +import { useServerSDK } from "@/context/server-sdk" import { browseModel } from "@/skillhub/browse-model" +import { hubError } from "@/skillhub/hub-error" import { SkillCard } from "@/skillhub/skill-card" -import { SkillHubDesktopOnly } from "@/skillhub/desktop-only" import { SkillHubTabs } from "@/skillhub/tabs" -import type { SkillHubDomain, SkillHubSort } from "@/skillhub/types" const SORTS: SkillHubSort[] = ["featured", "newest", "downloads", "rating"] const DOMAINS: (SkillHubDomain | "all")[] = ["all", "developer", "personal-assistant", "workflow-builder"] +const PAGE_SIZE = 24 -// The Skill Hub browse page — fork-only, desktop-backed. Everything the page -// shows comes over IPC from the Electron main process (the hub API is -// CORS-closed to the renderer). +// The Skill Hub browse page — fork-only. Everything the page shows comes from +// the connected server's v2.skillhub routes (the hub is CORS-closed to +// browsers, so the server proxies it), which is what lets this page work in +// the web app and against a remote server alike. export default function SkillHubPage() { const language = useLanguage() - const platform = usePlatform() + const serverSDK = useServerSDK() - return ( - }> - {(skillhub) => { - const [result, { refetch }] = createResource( - () => ({ - search: browseModel.search() || undefined, - sort: browseModel.sort(), - domain: browseModel.domain(), - page: browseModel.page(), - }), - (params) => skillhub().browse({ ...params, limit: 24 }), - ) + const [result, { refetch }] = createResource( + () => ({ + search: browseModel.search() || undefined, + sort: browseModel.sort(), + domain: browseModel.domain(), + page: browseModel.page(), + }), + async (params) => { + const response = await serverSDK() + .createClient({ throwOnError: false }) + .v2.skillhub.skill.list({ + search: params.search, + sort: params.sort, + domain: params.domain, + page: String(params.page), + limit: String(PAGE_SIZE), + }) + if (!response.data) return { ok: false as const, error: hubError(response.error) } + return { ok: true as const, value: response.data.data } + }, + ) - return ( - -
- -
-

{language.t("skillhub.title")}

-
- browseModel.setInput(event.currentTarget.value)} - /> - sort} - label={(sort) => language.t(`skillhub.sort.${sort}`)} - onSelect={(sort) => sort && browseModel.setSort(sort)} - /> -
-
+ return ( + +
+ +
+

{language.t("skillhub.title")}

+
+ browseModel.setInput(event.currentTarget.value)} + /> + sort} + label={(sort) => language.t(`skillhub.sort.${sort}`)} + onSelect={(sort) => sort && browseModel.setSort(sort)} + /> +
+
- - browseModel.setDomain(value === "all" || value === null ? undefined : (value as SkillHubDomain)) - } - > - - {(domain) => ( - - {language.t(`skillhub.domain.${domain}`)} - - )} - - + + browseModel.setDomain(value === "all" || value === null ? undefined : (value as SkillHubDomain)) + } + > + + {(domain) => ( + {language.t(`skillhub.domain.${domain}`)} + )} + + - - -
- + + +
+ +
+
+ + {(failed) => { + const errored = failed() + return ( +
+
+ {language.t( + errored.ok === false && errored.error.code === "network" + ? "skillhub.error.network" + : "skillhub.error.unknown", + )}
- - - {(failed) => { - const errored = failed() - return ( -
-
- {language.t( - errored.ok === false && errored.error.code === "network" - ? "skillhub.error.network" - : "skillhub.error.unknown", - )} -
- void refetch()}> - {language.t("skillhub.error.retry")} - + void refetch()}> + {language.t("skillhub.error.retry")} + +
+ ) + }} +
+ + {(loaded) => { + const value = () => { + const current = loaded() + return current.ok + ? current.value + : { items: [], pagination: { page: 1, limit: PAGE_SIZE, total: 0, totalPages: 1 } } + } + // Schema.Number admits "NaN"/"Infinity" strings, so the generated + // type is not plain `number`; the server sends numbers. + const totalPages = () => Number(value().pagination.totalPages) + return ( + <> + 0} + fallback={ +
+ {language.t("skillhub.empty")}
- ) - }} -
- - {(loaded) => { - const value = () => { - const current = loaded() - return current.ok - ? current.value - : { items: [], pagination: { page: 1, limit: 24, total: 0, totalPages: 1 } } } - return ( - <> - 0} - fallback={ -
- {language.t("skillhub.empty")} -
- } - > -
- {(skill) => } -
-
- 1}> -
- browseModel.setPage(browseModel.page() - 1)} - > - {language.t("skillhub.page.previous")} - - - {language.t("skillhub.page.label", { - page: String(value().pagination.page), - total: String(value().pagination.totalPages), - })} - - = value().pagination.totalPages || result.loading} - onClick={() => browseModel.setPage(browseModel.page() + 1)} - > - {language.t("skillhub.page.next")} - -
-
- - ) - }} -
- -
- - ) - }} - + > +
+ {(skill) => } +
+ + 1}> +
+ browseModel.setPage(browseModel.page() - 1)} + > + {language.t("skillhub.page.previous")} + + + {language.t("skillhub.page.label", { + page: String(value().pagination.page), + total: String(value().pagination.totalPages), + })} + + = totalPages() || result.loading} + onClick={() => browseModel.setPage(browseModel.page() + 1)} + > + {language.t("skillhub.page.next")} + +
+
+ + ) + }} +
+
+
+ ) } diff --git a/packages/app/src/skillhub/browse-model.ts b/packages/app/src/skillhub/browse-model.ts index 11ce1c2c5..8e8954e20 100644 --- a/packages/app/src/skillhub/browse-model.ts +++ b/packages/app/src/skillhub/browse-model.ts @@ -1,5 +1,5 @@ import { createSignal } from "solid-js" -import type { SkillHubDomain, SkillHubSort } from "./types" +import type { SkillHubDomain, SkillHubSort } from "@opencode-ai/sdk/v2/client" // Browse state lives at module level rather than in the page: the desktop's // MemoryRouter has no shareable URLs, so the model — not the URL — is what diff --git a/packages/app/src/skillhub/desktop-only.tsx b/packages/app/src/skillhub/desktop-only.tsx deleted file mode 100644 index 22488e565..000000000 --- a/packages/app/src/skillhub/desktop-only.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useLanguage } from "@/context/language" - -// The web build has no Electron main process to proxy the hub's CORS-closed -// API, so the page (route is registered unconditionally) explains itself. -export function SkillHubDesktopOnly() { - const language = useLanguage() - return ( -
-
{language.t("skillhub.desktopOnly.title")}
-
{language.t("skillhub.desktopOnly.description")}
-
- ) -} diff --git a/packages/app/src/skillhub/dialog-install.tsx b/packages/app/src/skillhub/dialog-install.tsx index b7ad498d0..d0a3cfa22 100644 --- a/packages/app/src/skillhub/dialog-install.tsx +++ b/packages/app/src/skillhub/dialog-install.tsx @@ -1,3 +1,4 @@ +import type { SkillHubInstallOutcome, SkillStackAgent } from "@opencode-ai/sdk/v2/client" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { CheckboxV2 } from "@opencode-ai/ui/v2/checkbox-v2" import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" @@ -12,15 +13,16 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" import { displayName } from "@/pages/layout/helpers" import { showToast } from "@/utils/toast" -import type { SkillHubAgent, SkillHubInstallOutcome, SkillHubScope } from "./types" +import { hubError } from "./hub-error" // Fixed display order and labels — mirrors @codev/skillhub's SKILL_AGENTS / // AGENT_LABELS without importing the node package. CoDev Code is rendered -// locked; the main-process controller re-adds it regardless, so the lock -// holds even against a tampered renderer. -export const AGENT_ROWS: { agent: SkillHubAgent; label: string }[] = [ +// locked; the server re-adds it regardless, so the lock holds even against a +// tampered client. +export const AGENT_ROWS: { agent: SkillStackAgent; label: string }[] = [ { agent: "codev", label: "CoDev Code" }, { agent: "claude", label: "Claude Code" }, { agent: "codex", label: "Codex" }, @@ -30,7 +32,7 @@ export const AGENT_ROWS: { agent: SkillHubAgent; label: string }[] = [ // Shared with the stack install dialog so one remembered choice serves both. export const AGENT_CHOICE_KEY = "skillhub.install.agents" -export function rememberedAgents(): SkillHubAgent[] { +export function rememberedAgents(): SkillStackAgent[] { try { const raw = localStorage.getItem(AGENT_CHOICE_KEY) if (!raw) return ["codev"] @@ -43,26 +45,27 @@ export function rememberedAgents(): SkillHubAgent[] { } } +// Install one skill through the server's v2.skillhub routes: the server it is +// connected to downloads the skill and installs it where the agent runs, so +// "This project" is valid for every project that server knows — local, WSL, +// sandbox, or remote — and "project" is resolved server-side from the +// directory the request is scoped to, never from a path the client sends. export function DialogSkillInstall(props: { id: string; name: string; version?: string }) { const dialog = useDialog() const language = useLanguage() const layout = useLayout() const platform = usePlatform() const server = useServer() + const serverSDK = useServerSDK() const projects = createMemo(() => layout.projects.list()) const preselected = createMemo( () => projects().find((project) => project.worktree === layout.home.selection().directory) ?? projects()[0], ) - // The main process installs onto this machine's disk, but the project list - // belongs to the connected server. A WSL distro, sandbox, or remote host has - // worktrees that are not here, so "This project" only makes sense against - // the local server; global still works but that server will not see it. - const local = createMemo(() => server.isLocal()) + const [selectedProject, setSelectedProject] = createSignal(preselected()) const [state, setState] = createStore({ - scope: (preselected() && local() ? "project" : "global") as "project" | "global", - directory: preselected()?.worktree as string | undefined, + scope: (preselected() ? "project" : "global") as "project" | "global", agents: rememberedAgents(), phase: "form" as "form" | "installing" | "signing-in" | "overwrite" | "done", needsAuth: false, @@ -71,7 +74,12 @@ export function DialogSkillInstall(props: { id: string; name: string; version?: outcome: undefined as SkillHubInstallOutcome | undefined, }) - const toggleAgent = (agent: SkillHubAgent, checked: boolean) => { + // The hub's sign-in is the shared ~/.codev-hub session on the machine that + // talks to the hub — the server's. The app's own SSO flow writes that file + // on this machine, which only helps when this machine is the server. + const canSignIn = createMemo(() => Boolean(platform.gatewaySso) && server.isLocal()) + + const toggleAgent = (agent: SkillStackAgent, checked: boolean) => { if (agent === "codev") return setState("agents", (current) => { const next = current.filter((a) => a !== agent) @@ -81,45 +89,45 @@ export function DialogSkillInstall(props: { id: string; name: string; version?: } const install = async (force: boolean) => { - const skillhub = platform.skillhub - if (!skillhub) return - const scope: SkillHubScope = - state.scope === "project" && state.directory - ? { kind: "project", directory: state.directory } - : { kind: "global" } + const directory = state.scope === "project" ? selectedProject()?.worktree : undefined setState({ phase: "installing", needsAuth: false, error: undefined }) try { localStorage.setItem(AGENT_CHOICE_KEY, JSON.stringify(state.agents)) } catch { // Remember-last-choice only; a blocked localStorage changes nothing. } - // Copy the agents out of the store: ipcRenderer.invoke structured-clones - // its arguments, and a store's reactive Proxy is not cloneable. - const result = await skillhub.install({ id: props.id, scope, agents: [...state.agents], force }) - if (result.ok) { - setState({ phase: "done", outcome: result.value }) + // A per-directory client stamps the request with that project, which is + // how the server knows what "project" means; global rides the default. + const client = directory + ? serverSDK().createClient({ directory, throwOnError: false }) + : serverSDK().createClient({ throwOnError: false }) + const result = await client.v2.skillhub.skill.install({ + id: props.id, + // Copied out of the store: the SDK serialises the body, and a store's + // reactive Proxy is not what we want on the wire. + skillHubInstallInput: { scope: directory ? "project" : "global", agents: [...state.agents], force }, + }) + if (result.data) { + // Skill discovery is cached per server instance and nothing else + // invalidates it — what a config update does too; the next request + // rebuilds it with the new skill discovered. + await client.instance.dispose().catch(() => undefined) + setState({ phase: "done", outcome: result.data.data }) showToast(language.t("skillhub.install.success.title", { name: props.name })) return } - if (result.error.code === "already-installed") { - const match = /^Already installed at (.+)\. Pass --force/.exec(result.error.message) - setState({ phase: "overwrite", existingPath: match?.[1] ?? result.error.message }) + const error = hubError(result.error) + if (error.code === "already-installed") { + const match = /^Already installed at (.+)\. Pass --force/.exec(error.message) + setState({ phase: "overwrite", existingPath: match?.[1] ?? error.message }) return } - if (result.error.code === "auth") { - // The hub wants a signed-in session — the dialog can run the same SSO - // flow the configure gate uses, so offer that instead of the raw - // message's run-a-CLI hint. + if (error.code === "auth") { setState({ phase: "form", needsAuth: true, error: language.t("skillhub.install.error.auth") }) return } - const key = - result.error.code === "network" - ? "skillhub.error.network" - : result.error.code === "missing-directory" - ? "skillhub.install.error.missingDirectory" - : "skillhub.install.error.failed" - setState({ phase: "form", error: `${language.t(key)} (${result.error.message})` }) + const key = error.code === "network" ? "skillhub.error.network" : "skillhub.install.error.failed" + setState({ phase: "form", error: `${language.t(key)} (${error.message})` }) } const signIn = async () => { @@ -146,8 +154,6 @@ export function DialogSkillInstall(props: { id: string; name: string; version?: setState({ phase: "form" }) } - const [selectedProject, setSelectedProject] = createSignal(preselected()) - return ( @@ -167,10 +173,10 @@ export function DialogSkillInstall(props: { id: string; name: string; version?: > - 0 && local()}> + 0}> project.worktree} label={(project) => displayName(project)} onSelect={(project) => { - if (!project) return - setSelectedProject(project) - setState("directory", project.worktree) + if (project) setSelectedProject(project) }} /> - -
- {language.t("skillhub.install.scope.remote", { server: server.name })} -
-
@@ -258,7 +257,7 @@ export function DialogSkillInstall(props: { id: string; name: string; version?: {language.t("skillhub.install.cancel")} void install(false)}> {state.phase === "installing" diff --git a/packages/app/src/skillhub/dialog-stack-install.tsx b/packages/app/src/skillhub/dialog-stack-install.tsx index b6c238ed0..54ba542af 100644 --- a/packages/app/src/skillhub/dialog-stack-install.tsx +++ b/packages/app/src/skillhub/dialog-stack-install.tsx @@ -15,6 +15,7 @@ import { useServerSDK } from "@/context/server-sdk" import { displayName } from "@/pages/layout/helpers" import { showToast } from "@/utils/toast" import { AGENT_CHOICE_KEY, AGENT_ROWS, rememberedAgents } from "./dialog-install" +import { hubError } from "./hub-error" import { createStackInstallModel, type StackInstallDeps, type StackResult } from "./stack-install-model" // Install a Skill Stack through the server's v2.skillhub routes — no Electron @@ -284,15 +285,7 @@ function deps(serverSDK: ReturnType): StackInstallDeps { } function failure(error: unknown): StackResult { - 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/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..b10f4aa51 100644 --- a/packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts +++ b/packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts @@ -5,16 +5,42 @@ 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, + author: { username: "exerciser" }, + metadata: { tags: ["fixture"] }, +} + +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 +66,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)) From 5bd4225f766b6dcfc04527f68856fd6490e451ed Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 14 Sep 2026 14:17:19 +0700 Subject: [PATCH 2/2] fix(skillhub): tolerate the hub's nulls so one card can't fail the whole page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live hub sends unset columns as null (`metadata.publisher`, `author.avatar`, …). The old desktop bridge never validated shapes, but the server's response schema types those fields as optional strings, so a single null card made the Skills tab 500 into "Something went wrong". Nulls now become absent fields at the package's coercion seam, once, so the CLI, the TUI, and the server's strict schema all see one total shape (avatar keeps its null: the hub means "no picture" by it). The exerciser fixture carries the same nulls so CI covers the class, and the browse pages print the server's own message under the generic line instead of hiding it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P1MfYkJD3tQZRgqBKB4gig --- packages/app/src/pages/skill-hub.tsx | 6 +++ packages/app/src/pages/skill-stacks.tsx | 34 +++++++++++---- packages/codev-skillhub/src/hub.ts | 43 +++++++++++++++++-- packages/codev-skillhub/test/hub.test.ts | 26 +++++++++++ .../server/httpapi-exercise/skillhub-fake.ts | 8 +++- 5 files changed, 104 insertions(+), 13 deletions(-) diff --git a/packages/app/src/pages/skill-hub.tsx b/packages/app/src/pages/skill-hub.tsx index 79ddbfe0f..e7a711461 100644 --- a/packages/app/src/pages/skill-hub.tsx +++ b/packages/app/src/pages/skill-hub.tsx @@ -102,6 +102,12 @@ export default function SkillHubPage() { : "skillhub.error.unknown", )}
+ {/* The server's own words — never hide a real failure behind the generic line. */} + +
+ {errored.ok === false ? errored.error.message : ""} +
+
void refetch()}> {language.t("skillhub.error.retry")} diff --git a/packages/app/src/pages/skill-stacks.tsx b/packages/app/src/pages/skill-stacks.tsx index 96ee74788..fca94a3ea 100644 --- a/packages/app/src/pages/skill-stacks.tsx +++ b/packages/app/src/pages/skill-stacks.tsx @@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language" import { useServerSDK } from "@/context/server-sdk" import { createBrowseModel } from "@/skillhub/browse-model" import { DialogStackInstall } from "@/skillhub/dialog-stack-install" +import { hubError } from "@/skillhub/hub-error" import { StackCard } from "@/skillhub/stack-card" import { SkillHubTabs } from "@/skillhub/tabs" @@ -34,7 +35,7 @@ export default function SkillStacksPage() { const response = await serverSDK() .createClient({ throwOnError: false }) .v2.skillhub.stack.list({ search: params.search, page: String(params.page), limit: String(PAGE_SIZE) }) - if (!response.data) return { ok: false as const, error: response.error } + if (!response.data) return { ok: false as const, error: hubError(response.error) } return { ok: true as const, value: response.data.data } }, ) @@ -61,13 +62,30 @@ export default function SkillStacksPage() {
- -
-
{language.t("skillhub.error.unknown")}
- void refetch()}> - {language.t("skillhub.error.retry")} - -
+ + {(failed) => { + const errored = failed() + return ( +
+
+ {language.t( + errored.ok === false && errored.error.code === "network" + ? "skillhub.error.network" + : "skillhub.error.unknown", + )} +
+ {/* The server's own words — never hide a real failure behind the generic line. */} + +
+ {errored.ok === false ? errored.error.message : ""} +
+
+ void refetch()}> + {language.t("skillhub.error.retry")} + +
+ ) + }}
{(loaded) => { 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/opencode/test/server/httpapi-exercise/skillhub-fake.ts b/packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts index b10f4aa51..0d944aed7 100644 --- a/packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts +++ b/packages/opencode/test/server/httpapi-exercise/skillhub-fake.ts @@ -25,8 +25,12 @@ const skill = { downloadCount: 0, stars: 0, totalRatings: 0, - author: { username: "exerciser" }, - metadata: { tags: ["fixture"] }, + // 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 = {