diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3695c4828a..3c4b3bc792 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ file is the human-readable summary of what changed and why.
## Unreleased
+### Skills
+
+- **Skill Hub in the desktop app** — a new Skill Hub entry in the sidebar
+ (just above Settings) opens a native browser for the CoDev SkillHub:
+ search, sort, and filter the registry by domain, read a skill's SKILL.md
+ with stars and download counts, and install it into the current project or
+ globally with the same agent picker the CLI uses — CoDev Code always
+ included, one real copy plus links for the other agents. Re-installing
+ offers a safe overwrite, and everything rides the same shared sign-in as
+ `codev skill` and `codevhub`.
+ ([#331](https://github.com/quickbeard/codev-code/pull/331))
+
### Language
- **CoDev speaks Vietnamese** — the desktop and web app, the shared UI
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index 0837e23353..d98fc7d2e3 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -203,11 +203,12 @@ 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` 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/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/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. |
-| `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. |
+| 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; 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/:id` routes inside the new-layout ``; i18n adds `sidebar.skillhub` + the `skillhub.*` family (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. |
+| `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/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.tsx` | Three 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. | 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". | Keep all three 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` channel name stable. |
diff --git a/bun.lock b/bun.lock
index 4b00bcbd2d..e234a69352 100644
--- a/bun.lock
+++ b/bun.lock
@@ -444,6 +444,7 @@
"@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 b0c05a0982..cdcab3fb8a 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -11,6 +11,7 @@
"./wsl/types": "./src/wsl/types.ts",
"./sandbox/types": "./src/sandbox/types.ts",
"./gateway/types": "./src/gateway/types.ts",
+ "./skillhub/types": "./src/skillhub/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 5890702f83..e9367b0c5e 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -74,6 +74,9 @@ import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
+const SkillHubPage = lazy(() => import("@/pages/skill-hub"))
+const SkillHubDetailPage = lazy(() => import("@/pages/skill-hub-detail"))
+
const SessionRoute = () => {
const settings = useSettings()
const params = useParams()
@@ -671,6 +674,8 @@ function Routes(props: { serverScoped?: JSX.Element }) {
+
+
diff --git a/packages/app/src/components/app-sidebar.tsx b/packages/app/src/components/app-sidebar.tsx
index d4a7427e88..76113002c9 100644
--- a/packages/app/src/components/app-sidebar.tsx
+++ b/packages/app/src/components/app-sidebar.tsx
@@ -25,6 +25,7 @@ import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { createHomeModel, isBackgroundOpen, type HomeSessionRecord } from "@/pages/home-model"
+import { SkillHubNav } from "@/components/skill-hub-nav"
// The project rows, server rows and utility nav still live in home.tsx, where
// upstream keeps them — this file composes them into a persistent column and
// adds the Recents section. Only the `export` keywords there are ours.
@@ -258,13 +259,20 @@ export function AppSidebar(props: { class?: string; debugTools?: { visible: bool
{/* An expanded section is the flexible child that holds this down. When
every section is collapsed nothing grows, so the auto margin takes
over and the slack opens below the last section instead of pushing
- it — and Settings/Help — to the bottom of the column. */}
- platform.openLink(DOCS_URL)}
- language={language}
- />
+ it — and Skill Hub/Settings/Help — to the bottom of the column.
+ The wrapper carries the auto margin so the fork's Skill Hub row can
+ sit directly above Settings without editing HomeUtilityNav. */}
+