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. */} +
+
+ +
+ platform.openLink(DOCS_URL)} + language={language} + /> +
{/* The DEV / BETA badge, moved out of the titlebar so Home can lead it. The condition mirrors ChannelIndicator's own — kept here rather than diff --git a/packages/app/src/components/skill-hub-nav.tsx b/packages/app/src/components/skill-hub-nav.tsx new file mode 100644 index 0000000000..b37d99259c --- /dev/null +++ b/packages/app/src/components/skill-hub-nav.tsx @@ -0,0 +1,32 @@ +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. +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 8e6157e042..dc50d87ca8 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -6,6 +6,7 @@ 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" type PickerPaths = string | string[] | null @@ -100,6 +101,9 @@ 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 2315db405b..172502fc67 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -999,6 +999,48 @@ export const dict = { "sidebar.menu.toggle": "Toggle menu", "sidebar.nav.projectsAndSessions": "Projects and sessions", "sidebar.settings": "Settings", + "sidebar.skillhub": "Skill Hub", + "skillhub.title": "Skill Hub", + "skillhub.search.placeholder": "Search skills…", + "skillhub.sort.featured": "Featured", + "skillhub.sort.newest": "Newest", + "skillhub.sort.downloads": "Most downloaded", + "skillhub.sort.rating": "Top rated", + "skillhub.domain.all": "All", + "skillhub.domain.developer": "Developer", + "skillhub.domain.personal-assistant": "Personal assistant", + "skillhub.domain.workflow-builder": "Workflow builder", + "skillhub.empty": "No skills match your search.", + "skillhub.error.network": "Couldn't reach the Skill Hub. Check your connection and try again.", + "skillhub.error.unknown": "Something went wrong loading the Skill Hub.", + "skillhub.error.retry": "Retry", + "skillhub.page.label": "Page {{page}} of {{total}}", + "skillhub.page.previous": "Previous", + "skillhub.page.next": "Next", + "skillhub.detail.back": "Back to Skill Hub", + "skillhub.detail.install": "Install", + "skillhub.detail.openInHub": "Open in SkillHub", + "skillhub.detail.noReadme": "This skill has no SKILL.md to preview.", + "skillhub.detail.downloads": "downloads", + "skillhub.detail.ratings": "ratings", + "skillhub.install.title": "Install {{name}}", + "skillhub.install.scope.label": "Install to", + "skillhub.install.scope.project": "This project", + "skillhub.install.scope.global": "Global (all projects)", + "skillhub.install.agents.label": "For which agents?", + "skillhub.install.agents.locked": "Always included", + "skillhub.install.overwrite.title": "Already installed", + "skillhub.install.overwrite.description": + "This skill already exists at {{path}}. Overwrite it? Local edits will be lost.", + "skillhub.install.overwrite.confirm": "Overwrite", + "skillhub.install.cancel": "Cancel", + "skillhub.install.confirm": "Install", + "skillhub.install.installing": "Installing…", + "skillhub.install.success.title": "Installed {{name}}", + "skillhub.install.done": "Done", + "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 4cf7c5ce80..02e4e447e9 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -936,6 +936,48 @@ export const dict = { "sidebar.menu.toggle": "Alternar menú", "sidebar.nav.projectsAndSessions": "Proyectos y sesiones", "sidebar.settings": "Ajustes", + "sidebar.skillhub": "Skill Hub", + "skillhub.title": "Skill Hub", + "skillhub.search.placeholder": "Buscar skills…", + "skillhub.sort.featured": "Destacados", + "skillhub.sort.newest": "Más recientes", + "skillhub.sort.downloads": "Más descargados", + "skillhub.sort.rating": "Mejor valorados", + "skillhub.domain.all": "Todos", + "skillhub.domain.developer": "Desarrollador", + "skillhub.domain.personal-assistant": "Asistente personal", + "skillhub.domain.workflow-builder": "Creador de flujos", + "skillhub.empty": "Ningún skill coincide con tu búsqueda.", + "skillhub.error.network": "No se pudo conectar con el Skill Hub. Comprueba tu conexión e inténtalo de nuevo.", + "skillhub.error.unknown": "Algo salió mal al cargar el Skill Hub.", + "skillhub.error.retry": "Reintentar", + "skillhub.page.label": "Página {{page}} de {{total}}", + "skillhub.page.previous": "Anterior", + "skillhub.page.next": "Siguiente", + "skillhub.detail.back": "Volver al Skill Hub", + "skillhub.detail.install": "Instalar", + "skillhub.detail.openInHub": "Abrir en SkillHub", + "skillhub.detail.noReadme": "Este skill no tiene un SKILL.md para previsualizar.", + "skillhub.detail.downloads": "descargas", + "skillhub.detail.ratings": "valoraciones", + "skillhub.install.title": "Instalar {{name}}", + "skillhub.install.scope.label": "Instalar en", + "skillhub.install.scope.project": "Este proyecto", + "skillhub.install.scope.global": "Global (todos los proyectos)", + "skillhub.install.agents.label": "¿Para qué agentes?", + "skillhub.install.agents.locked": "Siempre incluido", + "skillhub.install.overwrite.title": "Ya instalado", + "skillhub.install.overwrite.description": + "Este skill ya existe en {{path}}. ¿Sobrescribirlo? Se perderán las ediciones locales.", + "skillhub.install.overwrite.confirm": "Sobrescribir", + "skillhub.install.cancel": "Cancelar", + "skillhub.install.confirm": "Instalar", + "skillhub.install.installing": "Instalando…", + "skillhub.install.success.title": "{{name}} instalado", + "skillhub.install.done": "Hecho", + "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 d8d6312543..4081bac85c 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -996,6 +996,48 @@ export const dict = { "sidebar.menu.toggle": "Bật/tắt menu", "sidebar.nav.projectsAndSessions": "Dự án và session", "sidebar.settings": "Cài đặt", + "sidebar.skillhub": "Skill Hub", + "skillhub.title": "Skill Hub", + "skillhub.search.placeholder": "Tìm skill…", + "skillhub.sort.featured": "Nổi bật", + "skillhub.sort.newest": "Mới nhất", + "skillhub.sort.downloads": "Tải nhiều nhất", + "skillhub.sort.rating": "Đánh giá cao", + "skillhub.domain.all": "Tất cả", + "skillhub.domain.developer": "Developer", + "skillhub.domain.personal-assistant": "Trợ lý cá nhân", + "skillhub.domain.workflow-builder": "Workflow builder", + "skillhub.empty": "Không có skill nào khớp với tìm kiếm của bạn.", + "skillhub.error.network": "Không thể kết nối tới Skill Hub. Kiểm tra kết nối mạng và thử lại.", + "skillhub.error.unknown": "Đã xảy ra lỗi khi tải Skill Hub.", + "skillhub.error.retry": "Thử lại", + "skillhub.page.label": "Trang {{page}} / {{total}}", + "skillhub.page.previous": "Trước", + "skillhub.page.next": "Sau", + "skillhub.detail.back": "Quay lại Skill Hub", + "skillhub.detail.install": "Cài đặt", + "skillhub.detail.openInHub": "Mở trong SkillHub", + "skillhub.detail.noReadme": "Skill này không có SKILL.md để xem trước.", + "skillhub.detail.downloads": "lượt tải", + "skillhub.detail.ratings": "đánh giá", + "skillhub.install.title": "Cài đặt {{name}}", + "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.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", + "skillhub.install.overwrite.description": + "Skill này đã tồn tại tại {{path}}. Ghi đè lên? Các chỉnh sửa cục bộ sẽ bị mất.", + "skillhub.install.overwrite.confirm": "Ghi đè", + "skillhub.install.cancel": "Hủy", + "skillhub.install.confirm": "Cài đặt", + "skillhub.install.installing": "Đang cài đặt…", + "skillhub.install.success.title": "Đã cài đặt {{name}}", + "skillhub.install.done": "Xong", + "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 new file mode 100644 index 0000000000..fa063cb3ee --- /dev/null +++ b/packages/app/src/pages/skill-hub-detail.tsx @@ -0,0 +1,155 @@ +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" +import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +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 { DialogSkillInstall } from "@/skillhub/dialog-install" +import { SkillHubDesktopOnly } from "@/skillhub/desktop-only" +import type { SkillHubDetail } from "@/skillhub/types" + +const HUB_URL = "https://netmind.viettel.vn/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). +export default function SkillHubDetailPage() { + const language = useLanguage() + const platform = usePlatform() + const params = useParams<{ id: string }>() + + return ( + }> + {(skillhub) => { + const navigate = useNavigate() + const dialog = useDialog() + const [result, { refetch }] = createResource( + () => params.id, + (id) => skillhub().detail(id), + ) + + 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")} + +
+ + + +
+ +
+
+ + {(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} + + ★ {skill().stars ? skill().stars.toFixed(1) : "–"} ({skill().totalRatings}{" "} + {language.t("skillhub.detail.ratings")}) + + + ↓ {skill().downloadCount} {language.t("skillhub.detail.downloads")} + +
+
+
+ + platform.openLink(`${HUB_URL}/skills/${encodeURIComponent(skill().id)}`) + } + > + {language.t("skillhub.detail.openInHub")} + + + dialog.show(() => ( + + )) + } + > + {language.t("skillhub.detail.install")} + +
+
+ + + {language.t("skillhub.detail.noReadme")} +
+ } + > + {(file) => ( +
+ +
+ )} +
+ + )} +
+ ) + }} + + + + + ) + }} + + ) +} diff --git a/packages/app/src/pages/skill-hub.tsx b/packages/app/src/pages/skill-hub.tsx new file mode 100644 index 0000000000..e29d16f9c4 --- /dev/null +++ b/packages/app/src/pages/skill-hub.tsx @@ -0,0 +1,161 @@ +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" +import { SegmentedControlItemV2, SegmentedControlV2 } from "@opencode-ai/ui/v2/segmented-control-v2" +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 { browseModel } from "@/skillhub/browse-model" +import { SkillCard } from "@/skillhub/skill-card" +import { SkillHubDesktopOnly } from "@/skillhub/desktop-only" +import type { SkillHubDomain, SkillHubSort } from "@/skillhub/types" + +const SORTS: SkillHubSort[] = ["featured", "newest", "downloads", "rating"] +const DOMAINS: (SkillHubDomain | "all")[] = ["all", "developer", "personal-assistant", "workflow-builder"] + +// 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). +export default function SkillHubPage() { + const language = useLanguage() + const platform = usePlatform() + + 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 }), + ) + + 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}`)} + + )} + + + + + +
+ +
+
+ + {(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")} + +
+ ) + }} +
+ + {(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")} + +
+
+ + ) + }} +
+
+
+
+ ) + }} +
+ ) +} diff --git a/packages/app/src/skillhub/browse-model.test.ts b/packages/app/src/skillhub/browse-model.test.ts new file mode 100644 index 0000000000..1ac2564d0a --- /dev/null +++ b/packages/app/src/skillhub/browse-model.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { createBrowseModel } from "./browse-model" + +const settle = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe("skillhub browse model", () => { + test("input debounces into the search query and resets the page", async () => { + const model = createBrowseModel(10) + model.setPage(3) + model.setInput("p") + model.setInput("pg ") + expect(model.input()).toBe("pg ") + // Not yet settled: the query and page are untouched. + expect(model.search()).toBe("") + expect(model.page()).toBe(3) + await settle(25) + expect(model.search()).toBe("pg") + expect(model.page()).toBe(1) + }) + + test("sort and domain changes reset the page", () => { + const model = createBrowseModel(10) + model.setPage(4) + model.setSort("downloads") + expect(model.page()).toBe(1) + model.setPage(2) + model.setDomain("developer") + expect(model.page()).toBe(1) + expect(model.sort()).toBe("downloads") + expect(model.domain()).toBe("developer") + }) +}) diff --git a/packages/app/src/skillhub/browse-model.ts b/packages/app/src/skillhub/browse-model.ts new file mode 100644 index 0000000000..11ce1c2c57 --- /dev/null +++ b/packages/app/src/skillhub/browse-model.ts @@ -0,0 +1,45 @@ +import { createSignal } from "solid-js" +import type { SkillHubDomain, SkillHubSort } from "./types" + +// 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 +// keeps search/sort/domain/page alive across a detour into a skill's detail +// page and back. +export function createBrowseModel(debounceMs = 300) { + const [input, setInput] = createSignal("") + const [search, setSearch] = createSignal("") + const [sort, setSort] = createSignal("featured") + const [domain, setDomain] = createSignal(undefined) + const [page, setPage] = createSignal(1) + let timer: ReturnType | undefined + + return { + input, + search, + sort, + domain, + page, + setPage, + // The raw input updates immediately; the query it drives settles after the + // debounce so a keystroke burst costs one request. Any filter change lands + // back on page 1 — a page number only means something within one query. + setInput(value: string) { + setInput(value) + clearTimeout(timer) + timer = setTimeout(() => { + setSearch(value.trim()) + setPage(1) + }, debounceMs) + }, + setSort(value: SkillHubSort) { + setSort(value) + setPage(1) + }, + setDomain(value: SkillHubDomain | undefined) { + setDomain(value) + setPage(1) + }, + } +} + +export const browseModel = createBrowseModel() diff --git a/packages/app/src/skillhub/desktop-only.tsx b/packages/app/src/skillhub/desktop-only.tsx new file mode 100644 index 0000000000..22488e5659 --- /dev/null +++ b/packages/app/src/skillhub/desktop-only.tsx @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000000..a19e2fad8d --- /dev/null +++ b/packages/app/src/skillhub/dialog-install.tsx @@ -0,0 +1,218 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { CheckboxV2 } from "@opencode-ai/ui/v2/checkbox-v2" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { RadioGroupV2, RadioItemV2 } from "@opencode-ai/ui/v2/radio-v2" +import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createMemo, createSignal, For, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { displayName } from "@/pages/layout/helpers" +import { showToast } from "@/utils/toast" +import type { SkillHubAgent, SkillHubInstallOutcome, SkillHubScope } from "./types" + +// 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. +const AGENT_ROWS: { agent: SkillHubAgent; label: string }[] = [ + { agent: "codev", label: "CoDev Code" }, + { agent: "claude", label: "Claude Code" }, + { agent: "codex", label: "Codex" }, + { agent: "opencode", label: "OpenCode" }, +] + +const AGENT_CHOICE_KEY = "skillhub.install.agents" + +function rememberedAgents(): SkillHubAgent[] { + try { + const raw = localStorage.getItem(AGENT_CHOICE_KEY) + if (!raw) return ["codev"] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return ["codev"] + const valid = AGENT_ROWS.map((row) => row.agent).filter((agent) => parsed.includes(agent)) + return valid.includes("codev") ? valid : ["codev", ...valid] + } catch { + return ["codev"] + } +} + +export function DialogSkillInstall(props: { id: string; name: string; version?: string }) { + const dialog = useDialog() + const language = useLanguage() + const layout = useLayout() + const platform = usePlatform() + + const projects = createMemo(() => layout.projects.list()) + const preselected = createMemo( + () => projects().find((project) => project.worktree === layout.home.selection().directory) ?? projects()[0], + ) + + const [state, setState] = createStore({ + scope: (preselected() ? "project" : "global") as "project" | "global", + directory: preselected()?.worktree as string | undefined, + agents: rememberedAgents(), + phase: "form" as "form" | "installing" | "overwrite" | "done", + error: undefined as string | undefined, + existingPath: undefined as string | undefined, + outcome: undefined as SkillHubInstallOutcome | undefined, + }) + + const toggleAgent = (agent: SkillHubAgent, checked: boolean) => { + if (agent === "codev") return + setState("agents", (current) => { + const next = current.filter((a) => a !== agent) + if (checked) next.push(agent) + return next + }) + } + + 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" } + setState({ phase: "installing", error: undefined }) + try { + localStorage.setItem(AGENT_CHOICE_KEY, JSON.stringify(state.agents)) + } catch { + // Remember-last-choice only; a blocked localStorage changes nothing. + } + const result = await skillhub.install({ id: props.id, scope, agents: state.agents, force }) + if (result.ok) { + setState({ phase: "done", outcome: result.value }) + 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 }) + return + } + const key = result.error.code === "network" ? "skillhub.error.network" : "skillhub.error.unknown" + setState({ phase: "form", error: `${language.t(key)} (${result.error.message})` }) + } + + const [selectedProject, setSelectedProject] = createSignal(preselected()) + + return ( + + + {language.t("skillhub.install.title", { name: props.name })} + + + + +
+
+ {language.t("skillhub.install.scope.label")} +
+ setState("scope", value as "project" | "global")} + class="flex flex-col gap-2" + > + + 0}> + project.worktree} + label={(project) => displayName(project)} + onSelect={(project) => { + if (!project) return + setSelectedProject(project) + setState("directory", project.worktree) + }} + /> + + + +
+ +
+
+ {language.t("skillhub.install.agents.label")} +
+ + {(row) => ( + toggleAgent(row.agent, checked)} + /> + )} + +
+ + +
{state.error}
+
+
+ + +
+
+ {language.t("skillhub.install.overwrite.title")} +
+
+ {language.t("skillhub.install.overwrite.description", { path: state.existingPath ?? "" })} +
+
+
+ + + {(outcome) => ( +
+
+ {language.t("skillhub.install.success.title", { name: outcome().name })} +
+ + {(placement) => ( +
+ {placement.path} + {placement.mode === "store" ? "" : ` (${placement.mode})`} +
+ )} +
+
+ )} +
+
+ + + dialog.close()}> + {language.t("skillhub.install.cancel")} + + void install(false)}> + {state.phase === "installing" + ? language.t("skillhub.install.installing") + : language.t("skillhub.install.confirm")} + + + + dialog.close()}> + {language.t("skillhub.install.cancel")} + + void install(true)}>{language.t("skillhub.install.overwrite.confirm")} + + + dialog.close()}>{language.t("skillhub.install.done")} + + +
+ ) +} diff --git a/packages/app/src/skillhub/skill-card.tsx b/packages/app/src/skillhub/skill-card.tsx new file mode 100644 index 0000000000..2f926d648f --- /dev/null +++ b/packages/app/src/skillhub/skill-card.tsx @@ -0,0 +1,39 @@ +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. +export function SkillCard(props: { skill: SkillHubCard }) { + const navigate = useNavigate() + const language = useLanguage() + const author = () => props.skill.author?.name || props.skill.author?.username || props.skill.provider + + return ( + + ) +} diff --git a/packages/app/src/skillhub/types.ts b/packages/app/src/skillhub/types.ts new file mode 100644 index 0000000000..21f62c3a8c --- /dev/null +++ b/packages/app/src/skillhub/types.ts @@ -0,0 +1,91 @@ +// 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 and `network` the +// hub-unreachable state. +export type SkillHubError = { + code: "auth" | "not-found" | "already-installed" | "network" | "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/codev-skillhub/src/hub.ts b/packages/codev-skillhub/src/hub.ts new file mode 100644 index 0000000000..7194feca55 --- /dev/null +++ b/packages/codev-skillhub/src/hub.ts @@ -0,0 +1,153 @@ +import { skillhubFetch } from "./skillhub" + +// The desktop app's richer view of the hub, kept separate from skillhub.ts so +// the CLI surface stays byte-stable. Shapes mirror what /api/v1/hub/skills and +// /api/v1/skills/ actually return (skillhub src/services/hub.service.ts / +// skill.service.ts); fields the server may omit are optional or coerced so the +// types are total. + +export interface HubSkillCard { + 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 HubSort = "featured" | "newest" | "downloads" | "rating" +export type HubDomain = "developer" | "personal-assistant" | "workflow-builder" + +export interface HubBrowseParams { + search?: string + page?: number + // Server clamps to 1-50 (default 12). + limit?: number + sort?: HubSort + domain?: HubDomain +} + +export interface HubBrowseResult { + items: HubSkillCard[] + pagination: { page: number; limit: number; total: number; totalPages: number } +} + +// Browse the public hub with the full card shape. Anonymous works — a stored +// session is attached when present, like listHubSkills. +export async function browseHubSkills(params: HubBrowseParams = {}): Promise { + const query = new URLSearchParams() + if (params.search) query.set("search", params.search) + if (params.page) query.set("page", String(params.page)) + if (params.limit) query.set("limit", String(params.limit)) + if (params.sort) query.set("sort", params.sort) + if (params.domain) query.set("domain", params.domain) + const qs = query.toString() + + const res = await skillhubFetch(`/api/v1/hub/skills${qs ? `?${qs}` : ""}`, { + label: "skillhub.hub-browse", + optionalAuth: true, + }) + if (!res.ok) { + throw new Error(`Skill search failed (${res.status}).`) + } + const body = (await res.json().catch(() => ({}))) as { + success?: boolean + data?: unknown[] + pagination?: { page?: number; limit?: number; total?: number; totalPages?: number } + } + if (!body.success || !body.data) { + throw new Error("SkillHub returned an unexpected response.") + } + const items = body.data.map(toCard) + return { + items, + pagination: { + page: body.pagination?.page ?? params.page ?? 1, + limit: body.pagination?.limit ?? params.limit ?? items.length, + total: body.pagination?.total ?? items.length, + totalPages: body.pagination?.totalPages ?? 1, + }, + } +} + +export interface HubSkillDetail extends HubSkillCard { + slug?: string + displayName?: string + summary?: string + changelog?: string + // SKILL.md (and every other text file) arrives inlined — no second call. + files: { filePath: string; content: string }[] +} + +// Full skill detail by id OR name. Public for PUBLIC skills (optional auth); +// 404 gets the same clear message getSkillMeta uses. +export async function getSkillDetail(idOrName: string): Promise { + const res = await skillhubFetch(`/api/v1/skills/${encodeURIComponent(idOrName)}`, { + label: "skillhub.detail-full", + optionalAuth: true, + }) + if (res.status === 404) { + throw new Error(`Skill "${idOrName}" not found or not public.`) + } + if (!res.ok) { + throw new Error(`Skill lookup failed (${res.status}).`) + } + const body = (await res.json().catch(() => ({}))) as { + success?: boolean + data?: Record & { id?: string; name?: string } + } + if (!body.success || !body.data?.id || !body.data.name) { + throw new Error("SkillHub returned an unexpected response.") + } + const data = body.data + const files = Array.isArray(data.files) + ? (data.files as { filePath?: string; content?: string }[]) + .filter((f) => typeof f.filePath === "string") + .map((f) => ({ filePath: f.filePath as string, content: typeof f.content === "string" ? f.content : "" })) + : [] + return { + ...toCard(data), + slug: asString(data.slug), + displayName: asString(data.displayName), + summary: asString(data.summary), + changelog: asString(data.changelog), + files, + } +} + +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 + return { + id: asString(data.id) ?? "", + name: asString(data.name) ?? "", + provider: asString(data.provider) ?? "", + description: asString(data.description) ?? "", + version: asString(data.version) ?? "", + publishedAt: asString(data.publishedAt) ?? null, + downloadCount: asNumber(data.downloadCount), + stars: asNumber(data.stars), + totalRatings: asNumber(data.totalRatings), + targetRole: asString(data.targetRole), + author, + metadata, + runtimes, + } +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +function asNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0 +} diff --git a/packages/codev-skillhub/src/index.ts b/packages/codev-skillhub/src/index.ts index 5d0e9e77ac..277579be39 100644 --- a/packages/codev-skillhub/src/index.ts +++ b/packages/codev-skillhub/src/index.ts @@ -25,4 +25,14 @@ export { } from "./skill-publish" export { DEFAULT_SEARCH_LIMIT, runSkillSearch } from "./skill-search" export { getSkillMeta, hasSkillhubAuth, SkillhubAuthError, type SkillMeta } from "./skillhub" +export { + browseHubSkills, + getSkillDetail, + type HubBrowseParams, + type HubBrowseResult, + type HubDomain, + type HubSkillCard, + type HubSkillDetail, + type HubSort, +} from "./hub" export { officeCrossFlagError, runSkillOffice, type OfficeArch, type OfficeArgs, type OfficePlatform } from "./office" diff --git a/packages/codev-skillhub/src/skill-dirs.ts b/packages/codev-skillhub/src/skill-dirs.ts index 41b8a001fe..2999b37729 100644 --- a/packages/codev-skillhub/src/skill-dirs.ts +++ b/packages/codev-skillhub/src/skill-dirs.ts @@ -83,7 +83,13 @@ export interface SkillTargets { // Code/OpenCode never see the skill twice. Adding Codex is what forces the // second directory into existence. export function resolveTargets(agents: readonly SkillAgent[], scope: Scope, name: string): SkillTargets { - const root = scopeRoot(scope) + return resolveTargetsAt(scopeRoot(scope), agents, name) +} + +// Root-based core: hosts that know the exact project directory (the desktop +// app's Electron main process, whose cwd is NOT the project) pass it directly +// instead of going through scopeRoot's cwd/home mapping. +export function resolveTargetsAt(root: string, agents: readonly SkillAgent[], name: string): SkillTargets { const claudePath = join(root, CLAUDE_DIR, name) const agentsPath = join(root, AGENTS_DIR, name) @@ -100,7 +106,15 @@ export function resolveTargets(agents: readonly SkillAgent[], scope: Scope, name // Which selected agents a given path serves — so the result can say what each // agent got rather than printing bare directories. export function agentsServedBy(path: string, agents: readonly SkillAgent[], scope: Scope, name: string): SkillAgent[] { - const root = scopeRoot(scope) + return agentsServedByAt(scopeRoot(scope), path, agents, name) +} + +export function agentsServedByAt( + root: string, + path: string, + agents: readonly SkillAgent[], + name: string, +): SkillAgent[] { const isClaudeDir = path === join(root, CLAUDE_DIR, name) const table = isClaudeDir ? READS_CLAUDE_DIR : READS_AGENTS_DIR return agents.filter((a) => table[a]) @@ -178,7 +192,11 @@ export async function claudeFollowsSymlinks(): Promise { // True when `linkPath` is the Claude directory — the only link whose mechanism // depends on the agent's version. export function isClaudeLink(linkPath: string, scope: Scope, name: string): boolean { - return linkPath === join(scopeRoot(scope), CLAUDE_DIR, name) + return isClaudeLinkAt(scopeRoot(scope), linkPath, name) +} + +export function isClaudeLinkAt(root: string, linkPath: string, name: string): boolean { + return linkPath === join(root, CLAUDE_DIR, name) } // Guard the skill name the same way skill-install does before it becomes a path diff --git a/packages/codev-skillhub/src/skill-install.ts b/packages/codev-skillhub/src/skill-install.ts index 086919665c..164566ec8b 100644 --- a/packages/codev-skillhub/src/skill-install.ts +++ b/packages/codev-skillhub/src/skill-install.ts @@ -5,13 +5,14 @@ import { detectAndStripRoot, extractZip, inspectZip, pathExists } from "./archiv import { stripControlChars } from "./sanitize" import { AGENT_LABELS, - agentsServedBy, + agentsServedByAt, claudeFollowsSymlinks, - isClaudeLink, + isClaudeLinkAt, isSkillAgent, linkOrCopy, - resolveTargets, + resolveTargetsAt, safeSegment, + scopeRoot, ALWAYS_AGENT, SKILL_AGENTS, type LinkMode, @@ -82,9 +83,18 @@ export interface InstallResult { placements: InstallPlacement[] } -// Where an install should go: either one exact directory (--dir) or the agent -// directories resolved from a scope. -export type InstallTarget = { kind: "dir"; rootDir: string } | { kind: "agents"; agents: SkillAgent[]; scope: Scope } +// Where an install should go: one exact directory (--dir), the agent +// directories resolved from a scope, or the agent directories under an +// explicit root — for hosts (the desktop app's Electron main process) whose +// cwd is not the project the user is installing into. +export type InstallTarget = + | { kind: "dir"; rootDir: string } + | { kind: "agents"; agents: SkillAgent[]; scope: Scope } + | { kind: "agents-at"; agents: SkillAgent[]; root: string } + +function targetRoot(target: Exclude): string { + return target.kind === "agents" ? scopeRoot(target.scope) : resolve(target.root) +} // Resolve where this install writes, and check every destination is safe BEFORE // any filesystem work — especially before the rm() below, so a hostile hub name @@ -93,12 +103,12 @@ function planPlacements(target: InstallTarget, name: string): { store: string; l if (target.kind === "dir") { return { store: safeSkillDir(target.rootDir, name), links: [], agents: [] } } - // resolveTargets joins the name into two fixed roots, so validating the name - // as a single path segment covers both destinations at once. + // resolveTargetsAt joins the name into two fixed roots, so validating the + // name as a single path segment covers both destinations at once. if (!safeSegment(name)) { throw new Error(`Refusing to install skill with unsafe name "${name}".`) } - const { store, links } = resolveTargets(target.agents, target.scope, name) + const { store, links } = resolveTargetsAt(targetRoot(target), target.agents, name) return { store, links, agents: target.agents } } @@ -147,8 +157,8 @@ export async function installResolvedSkill( // older build the link reads as a file with no SKILL.md inside, so it has // to be a real copy. const mustCopy = - opts.target.kind === "agents" && - isClaudeLink(link, opts.target.scope, meta.name) && + opts.target.kind !== "dir" && + isClaudeLinkAt(targetRoot(opts.target), link, meta.name) && !(await claudeFollowsSymlinks()) const mode = mustCopy ? await copyInto(plan.store, link) : await linkOrCopy(plan.store, link) placements.push({ @@ -170,7 +180,7 @@ export async function installResolvedSkill( function agentsFor(path: string, target: InstallTarget, name: string): SkillAgent[] { if (target.kind === "dir") return [] - return agentsServedBy(path, target.agents, target.scope, name) + return agentsServedByAt(targetRoot(target), path, target.agents, name) } async function copyInto(store: string, dest: string): Promise { diff --git a/packages/codev-skillhub/test/hub.test.ts b/packages/codev-skillhub/test/hub.test.ts new file mode 100644 index 0000000000..5f937b5f04 --- /dev/null +++ b/packages/codev-skillhub/test/hub.test.ts @@ -0,0 +1,186 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { SKILLHUB_URL } from "../src/const" +import { httpApi } from "../src/http" +import { browseHubSkills, getSkillDetail } from "../src/hub" + +let tempDir: string +let savedHome: string | undefined + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-skillhub-hub-")) + savedHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempDir +}) + +afterEach(() => { + if (savedHome === undefined) delete process.env.CODEV_TEST_HOME + if (savedHome !== undefined) process.env.CODEV_TEST_HOME = savedHome + rmSync(tempDir, { recursive: true, force: true }) + mock.restore() +}) + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +type FetchSpy = ReturnType + +function stubFetch(response: Response) { + return spyOn(httpApi, "fetch").mockResolvedValue(response) +} + +function lastUrl(spy: FetchSpy): string { + const call = spy.mock.calls.at(-1) + if (!call) throw new Error("httpApi.fetch was not called") + return String(call[1]) +} + +function lastHeaders(spy: FetchSpy): Record { + const call = spy.mock.calls.at(-1) + if (!call) throw new Error("httpApi.fetch was not called") + return ((call[2] as RequestInit | undefined)?.headers ?? {}) as Record +} + +const CARD = { + id: "id-1", + name: "pg-tuner", + provider: "acme", + description: "Tunes Postgres", + version: "1.2.0", + publishedAt: "2026-08-01T00:00:00Z", + downloadCount: 42, + stars: 4.5, + totalRatings: 8, + targetRole: "BACKEND", + author: { name: "Ann", avatar: null, username: "ann" }, + metadata: { tags: ["db"], domain: "developer", publisher: "acme" }, + runtimes: [{ name: "Python", color: "#3776ab" }], +} + +describe("browseHubSkills", () => { + test("serializes every param and maps the full card", async () => { + const spy = stubFetch( + jsonResponse(200, { + success: true, + data: [CARD], + pagination: { page: 2, limit: 24, total: 60, totalPages: 3 }, + }), + ) + const result = await browseHubSkills({ + search: "pg tuner", + page: 2, + limit: 24, + sort: "downloads", + domain: "developer", + }) + expect(lastUrl(spy)).toBe( + `${SKILLHUB_URL}/api/v1/hub/skills?search=pg+tuner&page=2&limit=24&sort=downloads&domain=developer`, + ) + expect(result.pagination).toEqual({ page: 2, limit: 24, total: 60, totalPages: 3 }) + expect(result.items).toHaveLength(1) + expect(result.items[0]).toMatchObject({ + id: "id-1", + name: "pg-tuner", + stars: 4.5, + downloadCount: 42, + author: { username: "ann" }, + runtimes: [{ name: "Python", color: "#3776ab" }], + }) + }) + + test("no params hits the bare endpoint and coerces missing fields", async () => { + const spy = stubFetch( + jsonResponse(200, { + success: true, + data: [{ id: "x", name: "n", provider: "p", description: "", version: "1" }], + }), + ) + const result = await browseHubSkills() + expect(lastUrl(spy)).toBe(`${SKILLHUB_URL}/api/v1/hub/skills`) + expect(result.items[0]).toMatchObject({ downloadCount: 0, stars: 0, totalRatings: 0, publishedAt: null }) + expect(result.pagination.page).toBe(1) + }) + + test("anonymous browse sends no credential header", async () => { + const spy = stubFetch(jsonResponse(200, { success: true, data: [] })) + await browseHubSkills({ search: "x" }) + const headers = lastHeaders(spy) + expect(headers.Cookie).toBeUndefined() + expect(headers.Authorization).toBeUndefined() + }) + + test("a stored session rides along as Bearer", async () => { + const dir = join(tempDir, ".codev") + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, "auth.json"), + JSON.stringify({ + access_token: "at", + id_token: "it", + expires_at: Date.now() + 3_600_000, + user: { sub: "s", email: "e@x", displayName: "E" }, + }), + ) + const spy = stubFetch(jsonResponse(200, { success: true, data: [] })) + await browseHubSkills() + expect(lastHeaders(spy).Authorization).toBe("Bearer at") + }) + + test("non-2xx and malformed bodies throw", async () => { + stubFetch(jsonResponse(500, { success: false })) + expect(browseHubSkills()).rejects.toThrow("Skill search failed (500).") + mock.restore() + stubFetch(new Response("not json", { status: 200 })) + expect(browseHubSkills()).rejects.toThrow("SkillHub returned an unexpected response.") + }) +}) + +describe("getSkillDetail", () => { + test("maps card fields plus detail extras and inlined files", async () => { + const spy = stubFetch( + jsonResponse(200, { + success: true, + data: { + ...CARD, + slug: "pg-tuner", + displayName: "PG Tuner", + summary: "short", + changelog: "v1.2", + files: [ + { id: "f1", filePath: "SKILL.md", content: "# PG Tuner" }, + { id: "f2", filePath: "scripts/run.sh" }, + ], + }, + }), + ) + const detail = await getSkillDetail("pg-tuner") + expect(lastUrl(spy)).toBe(`${SKILLHUB_URL}/api/v1/skills/pg-tuner`) + expect(detail.displayName).toBe("PG Tuner") + expect(detail.files).toEqual([ + { filePath: "SKILL.md", content: "# PG Tuner" }, + { filePath: "scripts/run.sh", content: "" }, + ]) + expect(detail.stars).toBe(4.5) + }) + + test("encodes the target and reports 404 clearly", async () => { + const spy = stubFetch(jsonResponse(404, { success: false })) + expect(getSkillDetail("weird name")).rejects.toThrow('Skill "weird name" not found or not public.') + expect(lastUrl(spy)).toBe(`${SKILLHUB_URL}/api/v1/skills/weird%20name`) + }) + + test("missing files array becomes empty, other errors surface", async () => { + stubFetch(jsonResponse(200, { success: true, data: { id: "x", name: "n" } })) + const detail = await getSkillDetail("x") + expect(detail.files).toEqual([]) + mock.restore() + stubFetch(jsonResponse(503, { success: false })) + expect(getSkillDetail("x")).rejects.toThrow("Skill lookup failed (503).") + }) +}) diff --git a/packages/codev-skillhub/test/skill-dirs.test.ts b/packages/codev-skillhub/test/skill-dirs.test.ts index d78c9fc079..347c44a87c 100644 --- a/packages/codev-skillhub/test/skill-dirs.test.ts +++ b/packages/codev-skillhub/test/skill-dirs.test.ts @@ -14,11 +14,14 @@ import { isAbsolute, join } from "node:path" import { execApi } from "../src/exec" import { agentsServedBy, + agentsServedByAt, claudeFollowsSymlinks, isClaudeLink, + isClaudeLinkAt, linkApi, linkOrCopy, resolveTargets, + resolveTargetsAt, safeSegment, type SkillAgent, } from "../src/skill-dirs" @@ -199,3 +202,38 @@ describe("safeSegment", () => { expect(safeSegment(".hidden")).toBe(false) }) }) + +// The -At variants take an explicit root instead of scopeRoot's cwd/home +// mapping — the desktop app's Electron main process installs into a project +// directory that is neither its cwd nor the home dir. +describe("explicit-root variants", () => { + const root = join("/", "explicit", "project") + + test("resolveTargetsAt applies the same store/link selection under the root", () => { + expect(resolveTargetsAt(root, ["codev", "claude"], "x")).toEqual({ + store: join(root, CLAUDE, "x"), + links: [], + }) + expect(resolveTargetsAt(root, ["codev", "codex", "claude"], "x")).toEqual({ + store: join(root, AGENTS, "x"), + links: [join(root, CLAUDE, "x")], + }) + }) + + test("scope-based functions delegate to the root-based cores", () => { + expect(resolveTargets(["codev"], "global", "x")).toEqual(resolveTargetsAt(tempHome, ["codev"], "x")) + }) + + test("agentsServedByAt and isClaudeLinkAt key off the given root", () => { + const claudePath = join(root, CLAUDE, "x") + // Filter preserves the caller's order. + expect(agentsServedByAt(root, claudePath, ["codev", "claude", "codex"] as SkillAgent[], "x")).toEqual([ + "codev", + "claude", + ]) + expect(isClaudeLinkAt(root, claudePath, "x")).toBe(true) + expect(isClaudeLinkAt(root, join(root, AGENTS, "x"), "x")).toBe(false) + // A different root does not match. + expect(isClaudeLinkAt(tempHome, claudePath, "x")).toBe(false) + }) +}) diff --git a/packages/codev-skillhub/test/skill-install.test.ts b/packages/codev-skillhub/test/skill-install.test.ts index 4d9a1cb52a..4d2d3abc21 100644 --- a/packages/codev-skillhub/test/skill-install.test.ts +++ b/packages/codev-skillhub/test/skill-install.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import AdmZip from "adm-zip" import { execApi } from "../src/exec" -import { parseAgentList, runSkillInstall, skillhubApi } from "../src/skill-install" +import { installResolvedSkill, parseAgentList, runSkillInstall, skillhubApi } from "../src/skill-install" import type { SkillMeta } from "../src/skillhub" let tempDir: string @@ -367,3 +367,45 @@ describe("parseAgentList", () => { expect(parseAgentList(" , ")).toBe("Missing value for --agent.") }) }) + +// The desktop app installs into a project directory that is neither the +// process cwd nor home — the agents-at target carries that root explicitly. +describe("installResolvedSkill with agents-at", () => { + test("places the store and link under the given root", async () => { + mockDownload() + const result = await installResolvedSkill(META, { + target: { kind: "agents-at", agents: ["claude", "codex", "codev"], root: tempDir }, + force: false, + }) + expect(result.dir).toBe(join(tempDir, ".agents", "skills", "pg-tuner")) + expect(readFileSync(join(tempDir, ".agents", "skills", "pg-tuner", "SKILL.md"), "utf8")).toBe("# pg-tuner") + expect(lstatSync(join(tempDir, ".claude", "skills", "pg-tuner")).isSymbolicLink()).toBe(true) + // Nothing leaked into the isolated home (the global scope root). + expect(existsSync(join(tempHome, ".agents"))).toBe(false) + expect(existsSync(join(tempHome, ".claude"))).toBe(false) + expect(result.placements.map((p) => p.mode)).toEqual(["store", "symlink"]) + expect(result.placements[1]?.agents).toEqual(["claude", "codev"]) + }) + + test("refuses an unsafe name before downloading anything", async () => { + const download = mockDownload() + expect( + installResolvedSkill( + { ...META, name: "../evil" }, + { target: { kind: "agents-at", agents: ["codev"], root: tempDir }, force: false }, + ), + ).rejects.toThrow('Refusing to install skill with unsafe name "../evil".') + expect(download).not.toHaveBeenCalled() + }) + + test("preserves the already-installed guard under the explicit root", async () => { + mockDownload() + const target = { kind: "agents-at", agents: ["codev"], root: tempDir } as const + await installResolvedSkill(META, { target: { ...target, agents: [...target.agents] }, force: false }) + expect( + installResolvedSkill(META, { target: { ...target, agents: [...target.agents] }, force: false }), + ).rejects.toThrow( + `Already installed at ${join(tempDir, ".claude", "skills", "pg-tuner")}. Pass --force to overwrite.`, + ) + }) +}) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index e0c35bba0c..5c18b56f8d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -38,6 +38,7 @@ "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 08c5c4378b..e8b100c34a 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -54,6 +54,8 @@ 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" @@ -398,6 +400,7 @@ const main = Effect.gen(function* () { }) registerWslIpcHandlers(wslServers) registerSandboxIpcHandlers(sandboxes) + registerSkillHubIpcHandlers(createSkillHubController()) 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 new file mode 100644 index 0000000000..5d3167c581 --- /dev/null +++ b/packages/desktop/src/main/skillhub/controller.test.ts @@ -0,0 +1,120 @@ +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, "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("already-installed keeps the path in the message for the overwrite offer", async () => { + 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 new file mode 100644 index 0000000000..c5d98558da --- /dev/null +++ b/packages/desktop/src/main/skillhub/controller.ts @@ -0,0 +1,172 @@ +import { + browseHubSkills, + describeNetworkError, + getSkillDetail, + getSkillMeta, + installResolvedSkill, + 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 (the package's +// own idiom — see codev-skillhub skill-install.ts). +export const skillhubApi = { + browseHubSkills, + getSkillDetail, + getSkillMeta, + installResolvedSkill, +} + +export type SkillHubController = SkillHubPlatform + +export function createSkillHubController(): SkillHubController { + return { + browse: (params) => + run(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(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(async () => { + // 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 + }), + } +} + +async function run(work: () => Promise): Promise> { + try { + 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 } + 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) { + const network = + err.name === "AbortError" || err.name === "TimeoutError" || message === "fetch failed" || 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 new file mode 100644 index 0000000000..9b112e1ae8 --- /dev/null +++ b/packages/desktop/src/main/skillhub/ipc.ts @@ -0,0 +1,63 @@ +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 b8ea71996c..30599b740a 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -74,6 +74,11 @@ 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 7c09afddfd..ed4b97ba36 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -2,6 +2,7 @@ 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" export type { WslDistroProbe, @@ -42,6 +43,7 @@ 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 @@ -76,6 +78,7 @@ 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 488ab1773e..720e63c356 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -308,6 +308,7 @@ 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)