From 6eaa0ca0eb1d7eb8c4dd8be9763c006e7f20c2d9 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 14 Sep 2026 11:02:43 +0700 Subject: [PATCH] feat(app): add a Skill Stacks tab to the desktop Skill Hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Skill Hub gains a Skills | Skill Stacks tab strip. The Stacks tab lists the hub's stacks — owner, skill and member counts, a preview of the skills inside — and installs one through the server the app is connected to (the v2.skillhub routes from #364), so the install lands on the host that runs the agent and works against a remote server. No Electron bridge is involved: this is the first Skill Hub surface in the app that the web build could serve too. The install flow is a state machine over injected server calls (stack-install-model.ts) so its transitions — prepare, stop to ask about collisions, install, then dispose the instance so the model sees the new skills — are unit-tested without rendering. The dialog is a view over it, reusing the single-skill dialog's agent rows and remembered choice; scope "project" is carried as the request directory and resolved server-side, never as a path the renderer chooses. Numeric fields from the generated client are coerced at the two places they are compared or subtracted: Effect's Schema.Number admits the "NaN"/"Infinity" string encodings, so the generated type is not plain number. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P1MfYkJD3tQZRgqBKB4gig --- CHANGELOG.md | 9 + DIVERGENCES.md | 2 +- packages/app/src/app.tsx | 2 + packages/app/src/i18n/en.ts | 34 ++ packages/app/src/i18n/es.ts | 34 ++ packages/app/src/i18n/vi.ts | 34 ++ packages/app/src/pages/skill-hub.tsx | 2 + packages/app/src/pages/skill-stacks.tsx | 137 ++++++++ packages/app/src/skillhub/dialog-install.tsx | 7 +- .../app/src/skillhub/dialog-stack-install.tsx | 304 ++++++++++++++++++ packages/app/src/skillhub/stack-card.tsx | 60 ++++ .../src/skillhub/stack-install-model.test.ts | 109 +++++++ .../app/src/skillhub/stack-install-model.ts | 96 ++++++ packages/app/src/skillhub/tabs.tsx | 26 ++ 14 files changed, 852 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/pages/skill-stacks.tsx create mode 100644 packages/app/src/skillhub/dialog-stack-install.tsx create mode 100644 packages/app/src/skillhub/stack-card.tsx create mode 100644 packages/app/src/skillhub/stack-install-model.test.ts create mode 100644 packages/app/src/skillhub/stack-install-model.ts create mode 100644 packages/app/src/skillhub/tabs.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 29235e178..be9d5b6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -217,6 +217,15 @@ key_refused`; against an older backend the previous behaviour stands. ### Skills +- **Skill Stacks in the desktop app** — the Skill Hub now has two tabs, + Skills and Skill Stacks. The Stacks tab lists the hub's stacks with their + owner, skill and member counts and a preview of what's inside; Install + asks where (this project or global) and for which agents, then installs + the whole set through the server the app is connected to — so it lands on + the machine running the agent, and works against a remote server too. + Skills already installed are named and you choose to skip or overwrite + them; the result lists what was installed, skipped, or failed, and the + new skills are visible to the model straight away. - **Skill Stacks in the TUI** — `/stacks` (also the `prompt_stacks` keybind, unbound by default) opens a browser for the CoDev SkillHub's Skill Stacks right in the chat: pick one, choose this project or global and which diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 38d2f97d4..f5830c8cd 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -225,7 +225,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/components/prompt-input-v2.tsx` | `onUnpaidClick` opens `DialogConnectProvider` (lazy import) instead of the free-models "unpaid" dialogs; their now-unused imports dropped. (The legacy `prompt-input.tsx` carried the same swap until its deletion — see the legacy prompt-input removal row.) | With the hosted providers hidden, "unpaid" means nothing is connected, and the free-models dialogs would render empty lists (this path is reachable on the web deployment; on desktop the gate covers it). | Keep the handler swap; take upstream's surrounding prompt-input changes. | | `packages/desktop/src/renderer/index.tsx` | Calls `installConsoleFormatting()` (fork-only `./console-format`) before anything else logs. | `spyRendererConsole` receives Chromium's flattened console-message string, so any renderer `console.*` with an object argument reached the terminal and the exported debug logs as `[object Object]`; the wrapper serializes structured arguments (Error-aware, circular-safe, truncated) at the source. | Keep the import and call; take upstream's surrounding entry changes. | | Desktop gateway SSO wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Main instantiates the fork-only `src/main/gateway-sso/` controller (wrapping `@codev/gateway` `login`/`silentSso` — node-only loopback OAuth — with `shell.openExternal` and a `net.fetch` wrapper) and registers its `gateway-sso-*` IPC channels next to the sandbox ones, passing an `onConfigured` callback that restarts the running sandboxes (`SandboxController.restartRunning`) because a guest is handed its codev config once at boot and cannot see credentials that appear afterwards; preload exposes the `gatewaySso` namespace (subscribe fan-out, sandbox pattern) with types re-exported from `@opencode-ai/app/gateway/types`; the renderer platform maps `gatewaySso: window.api.gatewaySso`. | SSO login for the desktop configure gate must run in the main process (`node:http` loopback server, `~/.codev/auth.json` writes); `net.fetch` so enterprise proxies/PAC apply (egress precedent). | Keep the wiring; take upstream's surrounding changes. Keep channel names `gateway-sso-*` stable. | -| Desktop Skill Hub wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx`, `packages/desktop/package.json`, `packages/app/src/app.tsx`, `packages/app/src/i18n/{en,es}.ts` | Main registers the fork-only `src/main/skillhub/` controller (over the fork-only `@codev/skillhub` package) and its `skillhub-*` IPC channels, passing a `net.fetch` wrapper as the hub transport (`setHubTransport`) and a `warmProxy` hook — `session.defaultSession.resolveProxy(SKILLHUB_URL)` — so hub traffic honors enterprise proxies/PAC like the gateway SSO calls do and Chromium's proxy initialization is waited out before the first hub request; preload exposes the `skillhub` namespace (types aliased from `@opencode-ai/app/skillhub/types`); renderer maps `skillhub: window.api.skillhub` into the platform; `@codev/skillhub` added under desktop devDependencies (electron-vite bundles devDependency workspace packages); app.tsx registers lazy `/skills` + `/skills/: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. | +| Desktop Skill Hub wiring: `packages/desktop/src/main/index.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx`, `packages/desktop/package.json`, `packages/app/src/app.tsx`, `packages/app/src/i18n/{en,es}.ts` | Main registers the fork-only `src/main/skillhub/` controller (over the fork-only `@codev/skillhub` package) and its `skillhub-*` IPC channels, passing a `net.fetch` wrapper as the hub transport (`setHubTransport`) and a `warmProxy` hook — `session.defaultSession.resolveProxy(SKILLHUB_URL)` — so hub traffic honors enterprise proxies/PAC like the gateway SSO calls do and Chromium's proxy initialization is waited out before the first hub request; preload exposes the `skillhub` namespace (types aliased from `@opencode-ai/app/skillhub/types`); renderer maps `skillhub: window.api.skillhub` into the platform; `@codev/skillhub` added under desktop devDependencies (electron-vite bundles devDependency workspace packages); app.tsx registers lazy `/skills` + `/skills/stacks` + `/skills/:id` routes inside the new-layout `` (`/skills/stacks` before `/skills/:id`; it is the fork-only Skill Stacks page, `src/pages/skill-stacks.tsx`, served by the server's `v2.skillhub` routes rather than the Electron bridge); i18n adds `sidebar.skillhub` + the `skillhub.*` and `skillstack.*` families (vi.ts is fork-only). | The Skill Hub page is native (fork-only `packages/app/src/{skillhub/**,pages/skill-hub*.tsx,components/skill-hub-nav.tsx}`); all hub HTTP must originate in the Electron main process because the hub serves no ACAO on /api/v1 and 403s cross-origin writes, so the renderer reaches it only over IPC. | Keep the registration/preload/renderer/package.json lines, the two route lines, and the i18n keys; take upstream's surrounding changes. | | `packages/app/vite.js` | Added `optimizeDeps.entries`: the default `index.html` plus session-ui's `markdown.worker.ts` (absolute path derived from the plugin file, forward-slashed for fast-glob), so the dev-time dep scanner crawls the markdown worker at startup. | Vite's dep scanner does not crawl `?worker&url` entries, so the worker's deps (`@shikijs/stream`, `katex`, `marked`, `marked-shiki`, `remend`, `shiki`) were first discovered when the first markdown rendered; that mid-session re-optimize full-page reloads the dev app — e.g. a Skill Hub search reset itself the moment results rendered. A bare `optimizeDeps.include` list cannot express this fix: with bun's isolated linker the deps live only in the workspace packages' own `node_modules`, and no single include chain resolves from both consumers' roots (`packages/app` and the desktop renderer's `src/renderer`). Dev-only; production builds ignore `optimizeDeps`. | Keep the `optimizeDeps` block and keep `index.html` listed in it — setting `entries` replaces Vite's default entry inference, so dropping it unscans the app itself. If upstream moves or renames the worker file, update the path: a stale pattern matches nothing, logs nothing, and silently brings the reload back. | | `packages/app/package.json` | Added `"./gateway/types": "./src/gateway/types.ts"`, `"./skillhub/types": "./src/skillhub/types.ts"`, and `"./i18n/*": "./src/i18n/*.ts"` to `exports`. | The desktop preload aliases the app-owned gateway SSO wire types (sandbox/types pattern); the desktop main process imports the locale dicts to translate the macOS menu bar (`./i18n/*` mirrors how `@opencode-ai/ui` already exports its own — see the localized desktop menu row). | Keep both exports; take upstream's other manifest changes. | | `packages/app/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. | diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 4bcea0794..790285ab0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -76,6 +76,7 @@ const NewSession = lazy(() => import("@/pages/new-session")) const SkillHubPage = lazy(() => import("@/pages/skill-hub")) const SkillHubDetailPage = lazy(() => import("@/pages/skill-hub-detail")) +const SkillStacksPage = lazy(() => import("@/pages/skill-stacks")) const SessionRoute = () => { const settings = useSettings() @@ -708,6 +709,7 @@ function Routes(props: { serverScoped?: JSX.Element }) { + diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 9bae07776..d9106e64d 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1098,6 +1098,40 @@ export const dict = { "skillhub.install.error.missingDirectory": "That project folder is not on this computer.", "skillhub.install.success.title": "Installed {{name}}", "skillhub.install.done": "Done", + "skillhub.tabs.skills": "Skills", + "skillhub.tabs.stacks": "Skill Stacks", + "skillstack.title": "Skill Stacks", + "skillstack.subtitle": "A team's whole skill set, installed in one step.", + "skillstack.search.placeholder": "Search stacks…", + "skillstack.empty": "No stacks match your search.", + "skillstack.card.skills": "{{count}} skills", + "skillstack.card.members": "{{count}} members", + "skillstack.card.more": "+{{count}} more", + "skillstack.card.install": "Install", + "skillstack.install.title": "Install {{name}}", + "skillstack.install.scope.label": "Install to", + "skillstack.install.scope.project": "This project", + "skillstack.install.scope.global": "Global (all projects)", + "skillstack.install.agents.label": "For which agents?", + "skillstack.install.agents.locked": "Always included", + "skillstack.install.confirm": "Install", + "skillstack.install.cancel": "Cancel", + "skillstack.install.done": "Done", + "skillstack.install.preparing": "Downloading the stack…", + "skillstack.install.installing": "Installing…", + "skillstack.install.conflicts.title": "{{count}} already installed", + "skillstack.install.conflicts.description": + "These skills are already in place: {{names}}. Skip them, or overwrite them and lose any local edits?", + "skillstack.install.conflicts.skip": "Skip them, install the rest", + "skillstack.install.conflicts.overwrite": "Overwrite them", + "skillstack.install.success.title": "Installed {{installed}} of {{total}} skills from {{name}}", + "skillstack.install.result.installed": "Installed", + "skillstack.install.result.skipped": "Skipped", + "skillstack.install.result.failed": "Failed", + "skillstack.error.auth": "Sign in to the Skill Hub first — run `codev skill push` in a terminal.", + "skillstack.error.notFound": "That stack is gone, or has no public skills.", + "skillstack.error.expired": "That download expired before it was installed. Try again.", + "skillstack.error.unknown": "The Skill Hub could not deliver this stack.", "skillhub.desktopOnly.title": "Skill Hub is available in the desktop app", "skillhub.desktopOnly.description": "Browse and install skills from the CoDev desktop app, or use `codev skill` in the terminal.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index a3e2e9a59..4f3191571 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -1092,6 +1092,40 @@ export const dict = { "skillhub.install.error.missingDirectory": "Esa carpeta de proyecto no está en este equipo.", "skillhub.install.success.title": "{{name}} instalado", "skillhub.install.done": "Hecho", + "skillhub.tabs.skills": "Habilidades", + "skillhub.tabs.stacks": "Skill Stacks", + "skillstack.title": "Skill Stacks", + "skillstack.subtitle": "Todas las habilidades de un equipo, instaladas en un solo paso.", + "skillstack.search.placeholder": "Buscar stacks…", + "skillstack.empty": "Ningún stack coincide con tu búsqueda.", + "skillstack.card.skills": "{{count}} habilidades", + "skillstack.card.members": "{{count}} miembros", + "skillstack.card.more": "+{{count}} más", + "skillstack.card.install": "Instalar", + "skillstack.install.title": "Instalar {{name}}", + "skillstack.install.scope.label": "Instalar en", + "skillstack.install.scope.project": "Este proyecto", + "skillstack.install.scope.global": "Global (todos los proyectos)", + "skillstack.install.agents.label": "¿Para qué agentes?", + "skillstack.install.agents.locked": "Siempre incluido", + "skillstack.install.confirm": "Instalar", + "skillstack.install.cancel": "Cancelar", + "skillstack.install.done": "Hecho", + "skillstack.install.preparing": "Descargando el stack…", + "skillstack.install.installing": "Instalando…", + "skillstack.install.conflicts.title": "{{count}} ya instaladas", + "skillstack.install.conflicts.description": + "Estas habilidades ya están instaladas: {{names}}. ¿Omitirlas, o sobrescribirlas y perder los cambios locales?", + "skillstack.install.conflicts.skip": "Omitirlas e instalar el resto", + "skillstack.install.conflicts.overwrite": "Sobrescribirlas", + "skillstack.install.success.title": "Instaladas {{installed}} de {{total}} habilidades de {{name}}", + "skillstack.install.result.installed": "Instaladas", + "skillstack.install.result.skipped": "Omitidas", + "skillstack.install.result.failed": "Fallidas", + "skillstack.error.auth": "Primero inicia sesión en el Skill Hub: ejecuta `codev skill push` en una terminal.", + "skillstack.error.notFound": "Ese stack ya no existe o no tiene habilidades públicas.", + "skillstack.error.expired": "Esa descarga caducó antes de instalarse. Inténtalo de nuevo.", + "skillstack.error.unknown": "El Skill Hub no pudo entregar este stack.", "skillhub.desktopOnly.title": "Skill Hub está disponible en la aplicación de escritorio", "skillhub.desktopOnly.description": "Explora e instala skills desde la aplicación de escritorio de CoDev, o usa `codev skill` en la terminal.", diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts index 9cfb40efc..4ceabe9ad 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -1055,6 +1055,40 @@ export const dict = { "skillhub.install.error.missingDirectory": "Thư mục dự án đó không có trên máy tính này.", "skillhub.install.success.title": "Đã cài đặt {{name}}", "skillhub.install.done": "Xong", + "skillhub.tabs.skills": "Kỹ năng", + "skillhub.tabs.stacks": "Skill Stack", + "skillstack.title": "Skill Stack", + "skillstack.subtitle": "Trọn bộ kỹ năng của một nhóm, cài đặt trong một bước.", + "skillstack.search.placeholder": "Tìm kiếm stack…", + "skillstack.empty": "Không có stack nào khớp với tìm kiếm.", + "skillstack.card.skills": "{{count}} kỹ năng", + "skillstack.card.members": "{{count}} thành viên", + "skillstack.card.more": "+{{count}} nữa", + "skillstack.card.install": "Cài đặt", + "skillstack.install.title": "Cài đặt {{name}}", + "skillstack.install.scope.label": "Cài đặt vào", + "skillstack.install.scope.project": "Dự án này", + "skillstack.install.scope.global": "Toàn cục (mọi dự án)", + "skillstack.install.agents.label": "Cho agent nào?", + "skillstack.install.agents.locked": "Luôn được bao gồm", + "skillstack.install.confirm": "Cài đặt", + "skillstack.install.cancel": "Hủy", + "skillstack.install.done": "Xong", + "skillstack.install.preparing": "Đang tải stack…", + "skillstack.install.installing": "Đang cài đặt…", + "skillstack.install.conflicts.title": "{{count}} kỹ năng đã được cài", + "skillstack.install.conflicts.description": + "Các kỹ năng này đã có sẵn: {{names}}. Bỏ qua chúng, hay ghi đè và mất các chỉnh sửa cục bộ?", + "skillstack.install.conflicts.skip": "Bỏ qua, cài phần còn lại", + "skillstack.install.conflicts.overwrite": "Ghi đè", + "skillstack.install.success.title": "Đã cài {{installed}}/{{total}} kỹ năng từ {{name}}", + "skillstack.install.result.installed": "Đã cài", + "skillstack.install.result.skipped": "Đã bỏ qua", + "skillstack.install.result.failed": "Thất bại", + "skillstack.error.auth": "Hãy đăng nhập Skill Hub trước — chạy `codev skill push` trong terminal.", + "skillstack.error.notFound": "Stack này không còn tồn tại hoặc không có kỹ năng công khai.", + "skillstack.error.expired": "Bản tải này đã hết hạn trước khi được cài. Hãy thử lại.", + "skillstack.error.unknown": "Skill Hub không thể tải stack này về.", "skillhub.desktopOnly.title": "Skill Hub có sẵn trong ứng dụng desktop", "skillhub.desktopOnly.description": "Duyệt và cài skill từ ứng dụng desktop CoDev, hoặc dùng `codev skill` trong terminal.", diff --git a/packages/app/src/pages/skill-hub.tsx b/packages/app/src/pages/skill-hub.tsx index e29d16f9c..49fcc48c2 100644 --- a/packages/app/src/pages/skill-hub.tsx +++ b/packages/app/src/pages/skill-hub.tsx @@ -10,6 +10,7 @@ import { usePlatform } from "@/context/platform" import { browseModel } from "@/skillhub/browse-model" import { SkillCard } from "@/skillhub/skill-card" import { SkillHubDesktopOnly } from "@/skillhub/desktop-only" +import { SkillHubTabs } from "@/skillhub/tabs" import type { SkillHubDomain, SkillHubSort } from "@/skillhub/types" const SORTS: SkillHubSort[] = ["featured", "newest", "downloads", "rating"] @@ -38,6 +39,7 @@ export default function SkillHubPage() { return (
+

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

diff --git a/packages/app/src/pages/skill-stacks.tsx b/packages/app/src/pages/skill-stacks.tsx new file mode 100644 index 000000000..96ee74788 --- /dev/null +++ b/packages/app/src/pages/skill-stacks.tsx @@ -0,0 +1,137 @@ +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 { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createResource, For, Match, Show, Switch } from "solid-js" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { createBrowseModel } from "@/skillhub/browse-model" +import { DialogStackInstall } from "@/skillhub/dialog-stack-install" +import { StackCard } from "@/skillhub/stack-card" +import { SkillHubTabs } from "@/skillhub/tabs" + +const PAGE_SIZE = 24 + +// Browse state at module level for the same reason the Skills page keeps +// its own: the desktop's MemoryRouter has no shareable URL to hold search and +// page across a detour and back. Sort and domain go unused here — stacks are +// listed newest-updated first by the hub and have no domain. +const stackModel = createBrowseModel() + +// The Skill Stacks browse page. Unlike the single-skill page this needs no +// Electron bridge: everything comes from the connected server's v2.skillhub +// routes, so it works against a remote server and the install lands where +// the agent runs. +export default function SkillStacksPage() { + const language = useLanguage() + const serverSDK = useServerSDK() + const dialog = useDialog() + + const [result, { refetch }] = createResource( + () => ({ search: stackModel.search() || undefined, page: stackModel.page() }), + async (params) => { + const response = await serverSDK() + .createClient({ throwOnError: false }) + .v2.skillhub.stack.list({ search: params.search, page: String(params.page), limit: String(PAGE_SIZE) }) + if (!response.data) return { ok: false as const, error: response.error } + return { ok: true as const, value: response.data.data } + }, + ) + + return ( + +
+ +
+
+

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

+
{language.t("skillstack.subtitle")}
+
+ stackModel.setInput(event.currentTarget.value)} + /> +
+ + + +
+ +
+
+ +
+
{language.t("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: PAGE_SIZE, total: 0, totalPages: 1 }, totalStacks: 0 } + } + // Effect's Schema.Number also admits the "NaN"/"Infinity" string + // encodings, so the generated client types every count as + // `number | "NaN" | …`; the server sends plain numbers. + const totalPages = () => Number(value().pagination.totalPages) + return ( + <> + 0} + fallback={ +
+ {language.t("skillstack.empty")} +
+ } + > +
+ + {(stack) => ( + dialog.show(() => )} + /> + )} + +
+
+ 1}> +
+ stackModel.setPage(stackModel.page() - 1)} + > + {language.t("skillhub.page.previous")} + + + {language.t("skillhub.page.label", { + page: String(value().pagination.page), + total: String(value().pagination.totalPages), + })} + + = totalPages() || result.loading} + onClick={() => stackModel.setPage(stackModel.page() + 1)} + > + {language.t("skillhub.page.next")} + +
+
+ + ) + }} +
+
+
+
+ ) +} diff --git a/packages/app/src/skillhub/dialog-install.tsx b/packages/app/src/skillhub/dialog-install.tsx index 14eb27f36..b7ad498d0 100644 --- a/packages/app/src/skillhub/dialog-install.tsx +++ b/packages/app/src/skillhub/dialog-install.tsx @@ -20,16 +20,17 @@ import type { SkillHubAgent, SkillHubInstallOutcome, SkillHubScope } from "./typ // 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 }[] = [ +export 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" +// Shared with the stack install dialog so one remembered choice serves both. +export const AGENT_CHOICE_KEY = "skillhub.install.agents" -function rememberedAgents(): SkillHubAgent[] { +export function rememberedAgents(): SkillHubAgent[] { try { const raw = localStorage.getItem(AGENT_CHOICE_KEY) if (!raw) return ["codev"] diff --git a/packages/app/src/skillhub/dialog-stack-install.tsx b/packages/app/src/skillhub/dialog-stack-install.tsx new file mode 100644 index 000000000..b6c238ed0 --- /dev/null +++ b/packages/app/src/skillhub/dialog-stack-install.tsx @@ -0,0 +1,304 @@ +import type { SkillStackAgent, SkillStackCard, SkillStackErrorCode } from "@opencode-ai/sdk/v2/client" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { CheckboxV2 } from "@opencode-ai/ui/v2/checkbox-v2" +import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" +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 { useServerSDK } from "@/context/server-sdk" +import { displayName } from "@/pages/layout/helpers" +import { showToast } from "@/utils/toast" +import { AGENT_CHOICE_KEY, AGENT_ROWS, rememberedAgents } from "./dialog-install" +import { createStackInstallModel, type StackInstallDeps, type StackResult } from "./stack-install-model" + +// Install a Skill Stack through the server's v2.skillhub routes — no Electron +// bridge, so this works against whichever server the app is connected to and +// the install lands on the host that runs the agent. The scope is a project of +// that server or global; "project" is resolved server-side from the directory +// the request is scoped to, never from a path the renderer sends. + +export function DialogStackInstall(props: { stack: SkillStackCard }) { + const dialog = useDialog() + const language = useLanguage() + const layout = useLayout() + const serverSDK = useServerSDK() + + const projects = createMemo(() => layout.projects.list()) + const preselected = createMemo( + () => projects().find((project) => project.worktree === layout.home.selection().directory) ?? projects()[0], + ) + const [selectedProject, setSelectedProject] = createSignal(preselected()) + const [form, setForm] = createStore({ + scope: (preselected() ? "project" : "global") as "project" | "global", + agents: rememberedAgents() as SkillStackAgent[], + }) + + const model = createStackInstallModel(deps(serverSDK)) + + const toggleAgent = (agent: SkillStackAgent, checked: boolean) => { + if (agent === "codev") return + setForm("agents", (current) => { + const next = current.filter((a) => a !== agent) + if (checked) next.push(agent) + return next + }) + } + + const start = async () => { + try { + localStorage.setItem(AGENT_CHOICE_KEY, JSON.stringify(form.agents)) + } catch { + // Remember-last-choice only; a blocked localStorage changes nothing. + } + const directory = form.scope === "project" ? selectedProject()?.worktree : undefined + await model.prepare({ + slug: props.stack.slug, + scope: form.scope === "project" && directory ? "project" : "global", + // Copied out of the store: the SDK serialises the body, and a store's + // reactive Proxy is not what we want on the wire. + agents: [...form.agents], + directory, + }) + if (model.state.phase === "done" && model.state.outcome) { + showToast( + language.t("skillstack.install.success.title", { + installed: String(model.state.outcome.installed.length), + total: String(model.state.prepared?.skills.length ?? model.state.outcome.installed.length), + name: props.stack.name, + }), + ) + } + } + + const finish = async (mode: "skip-existing" | "overwrite") => { + const total = model.state.prepared?.skills.length ?? 0 + await model.install(mode) + if (model.state.phase === "done" && model.state.outcome) { + showToast( + language.t("skillstack.install.success.title", { + installed: String(model.state.outcome.installed.length), + total: String(total), + name: props.stack.name, + }), + ) + } + } + + const errorText = () => { + const error = model.state.error + if (!error) return undefined + return `${language.t(errorKey(error.code))} (${error.message})` + } + + const busy = () => model.state.phase === "preparing" || model.state.phase === "installing" + + return ( + + + {language.t("skillstack.install.title", { name: props.stack.name })} + + + + +
+
+ {language.t("skillstack.install.scope.label")} +
+ setForm("scope", value as "project" | "global")} + class="flex flex-col gap-2" + > + + 0}> + project.worktree} + label={(project) => displayName(project)} + onSelect={(project) => { + if (project) setSelectedProject(project) + }} + /> + + + +
+ +
+
+ {language.t("skillstack.install.agents.label")} +
+ + {(row) => ( + toggleAgent(row.agent, checked)} + /> + )} + +
+ + +
+ + {language.t( + model.state.phase === "preparing" ? "skillstack.install.preparing" : "skillstack.install.installing", + )} +
+
+ + +
{errorText()}
+
+
+ + + {(prepared) => ( +
+
+ {language.t("skillstack.install.conflicts.title", { count: String(prepared().conflicts.length) })} +
+
+ {language.t("skillstack.install.conflicts.description", { names: prepared().conflicts.join(", ") })} +
+
+ )} +
+ + + {(outcome) => ( +
+ s.name)} + /> + `${s.name} (${s.reason})`)} + /> + `${s.name}: ${s.message}`)} + danger + /> +
+ )} +
+
+ + + dialog.close()}> + {language.t("skillstack.install.cancel")} + + void start()}> + {language.t("skillstack.install.confirm")} + + + + void model.cancel()}> + {language.t("skillstack.install.cancel")} + + void finish("skip-existing")}> + {language.t("skillstack.install.conflicts.skip")} + + void finish("overwrite")}> + {language.t("skillstack.install.conflicts.overwrite")} + + + + dialog.close()}>{language.t("skillstack.install.done")} + + +
+ ) +} + +function ResultList(props: { label: string; items: string[]; danger?: boolean }) { + return ( + 0}> +
+
{props.label}
+ + {(item) => ( +
+ {item} +
+ )} +
+
+
+ ) +} + +// Bind the model to the connected server. A per-directory client stamps the +// request with that directory, which is how the server knows which project +// "scope: project" means; global installs and stagings without a directory +// ride the server-level client. +function deps(serverSDK: ReturnType): StackInstallDeps { + const client = (directory?: string) => + directory + ? serverSDK().createClient({ directory, throwOnError: false }) + : serverSDK().createClient({ throwOnError: false }) + return { + async prepare(target) { + const result = await client(target.directory).v2.skillhub.stack.prepare({ + slug: target.slug, + skillStackPrepareInput: { scope: target.scope, agents: target.agents }, + }) + return result.data ? { ok: true, value: result.data.data } : failure(result.error) + }, + async install(input) { + const result = await client(input.directory).v2.skillhub.stack.install({ + skillStackInstallInput: { stagingID: input.stagingID, mode: input.mode }, + }) + return result.data ? { ok: true, value: result.data.data } : failure(result.error) + }, + async discard(input) { + await client(input.directory).v2.skillhub.stack.discard({ stagingID: input.stagingID }) + }, + // What a config update does too; the next request rebuilds the instance + // with the new skills discovered. + async refresh(directory) { + await client(directory).instance.dispose() + }, + } +} + +function failure(error: unknown): StackResult { + if (typeof error === "object" && error !== null && "_tag" in error && error._tag === "SkillHubError") { + const typed = error as unknown as { code: SkillStackErrorCode; message: string } + return { ok: false, code: typed.code, message: typed.message } + } + const message = + typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : String(error) + return { ok: false, code: "unknown", message } +} + +function errorKey(code: SkillStackErrorCode) { + if (code === "auth") return "skillstack.error.auth" + if (code === "network") return "skillhub.error.network" + if (code === "not-found") return "skillstack.error.notFound" + if (code === "expired") return "skillstack.error.expired" + return "skillstack.error.unknown" +} diff --git a/packages/app/src/skillhub/stack-card.tsx b/packages/app/src/skillhub/stack-card.tsx new file mode 100644 index 000000000..d033b2f35 --- /dev/null +++ b/packages/app/src/skillhub/stack-card.tsx @@ -0,0 +1,60 @@ +import type { SkillStackCard } from "@opencode-ai/sdk/v2/client" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { For, Show } from "solid-js" +import { useLanguage } from "@/context/language" + +// One Skill Stack. Text renders through Solid's interpolation only — hub +// strings are attacker-influenced and must never reach innerHTML. +export function StackCard(props: { stack: SkillStackCard; onInstall: () => void }) { + const language = useLanguage() + const owner = () => { + const full = [props.stack.owner.firstName, props.stack.owner.lastName].filter((part) => part).join(" ") + return full || props.stack.owner.username || "" + } + + return ( +
+
+
+ {props.stack.name} +
+ + {owner()} + + + {language.t("skillstack.card.skills", { count: String(props.stack.skillCount) })} + + + {language.t("skillstack.card.members", { count: String(props.stack.memberCount) })} + +
+
+ props.onInstall()}> + {language.t("skillstack.card.install")} + +
+ +
{props.stack.description}
+
+ 0}> +
+ + {(skill) => ( + + {skill.name} + + )} + + + + {language.t("skillstack.card.more", { + // Schema.Number admits "NaN"/"Infinity" strings, so the generated type is not plain `number`. + count: String(Number(props.stack.skillCount) - props.stack.skills.length), + })} + + +
+
+
+ ) +} diff --git a/packages/app/src/skillhub/stack-install-model.test.ts b/packages/app/src/skillhub/stack-install-model.test.ts new file mode 100644 index 000000000..69882893d --- /dev/null +++ b/packages/app/src/skillhub/stack-install-model.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createStackInstallModel, type StackInstallDeps, type StackInstallTarget } from "./stack-install-model" + +const target: StackInstallTarget = { slug: "data-eng", scope: "project", agents: ["codev"], directory: "/proj" } + +function prepared(conflicts: string[]) { + return { + stagingID: "staging_1", + slug: "data-eng", + name: "Data Engineering", + skills: [ + { name: "dbt-helper", files: 1, bytes: 10, destinations: ["/proj/.claude/skills/dbt-helper"], conflict: false }, + { name: "pg-tuner", files: 2, bytes: 20, destinations: ["/proj/.claude/skills/pg-tuner"], conflict: true }, + ], + conflicts, + skipped: [], + totalBytes: 30, + expiresAt: Date.now() + 600_000, + } +} + +const outcome = { + slug: "data-eng", + mode: "skip-existing" as const, + installed: [{ name: "dbt-helper", dir: "/proj/.claude/skills/dbt-helper", placements: [] }], + skipped: [{ name: "pg-tuner", reason: "already installed" }], + failed: [], +} + +function harness(overrides: Partial = {}) { + const calls = { install: [] as unknown[], discard: [] as unknown[], refresh: [] as unknown[] } + const deps: StackInstallDeps = { + prepare: async () => ({ ok: true, value: prepared([]) }), + install: async (input) => { + calls.install.push(input) + return { ok: true, value: outcome } + }, + discard: async (input) => { + calls.discard.push(input) + }, + refresh: async (directory) => { + calls.refresh.push(directory) + }, + ...overrides, + } + return { calls, model: createRoot(() => createStackInstallModel(deps)) } +} + +describe("stack install model", () => { + test("installs straight through when nothing collides, then refreshes the instance", async () => { + const { model, calls } = harness() + await model.prepare(target) + expect(model.state.phase).toBe("done") + expect(calls.install).toEqual([{ stagingID: "staging_1", mode: "skip-existing", directory: "/proj" }]) + expect(calls.refresh).toEqual(["/proj"]) + expect(model.state.outcome?.installed.map((s) => s.name)).toEqual(["dbt-helper"]) + }) + + test("stops to ask when the server reports collisions, and installs with the chosen mode", async () => { + const { model, calls } = harness({ prepare: async () => ({ ok: true, value: prepared(["pg-tuner"]) }) }) + await model.prepare(target) + expect(model.state.phase).toBe("conflicts") + expect(calls.install).toEqual([]) + await model.install("overwrite") + expect(model.state.phase).toBe("done") + expect(calls.install).toEqual([{ stagingID: "staging_1", mode: "overwrite", directory: "/proj" }]) + }) + + test("cancelling at the conflict question discards the staging and installs nothing", async () => { + const { model, calls } = harness({ prepare: async () => ({ ok: true, value: prepared(["pg-tuner"]) }) }) + await model.prepare(target) + await model.cancel() + expect(model.state.phase).toBe("form") + expect(calls.discard).toEqual([{ stagingID: "staging_1", directory: "/proj" }]) + expect(calls.install).toEqual([]) + expect(calls.refresh).toEqual([]) + }) + + test("a failed prepare returns to the form with the server's code", async () => { + const { model } = harness({ prepare: async () => ({ ok: false, code: "auth", message: "sign in" }) }) + await model.prepare(target) + expect(model.state.phase).toBe("form") + expect(model.state.error).toEqual({ code: "auth", message: "sign in" }) + }) + + test("a failed install drops the consumed staging so a retry prepares again", async () => { + const { model, calls } = harness({ + prepare: async () => ({ ok: true, value: prepared(["pg-tuner"]) }), + install: async () => ({ ok: false, code: "expired", message: "expired" }), + }) + await model.prepare(target) + await model.install("skip-existing") + expect(model.state.phase).toBe("form") + expect(model.state.prepared).toBeUndefined() + expect(model.state.error?.code).toBe("expired") + expect(calls.refresh).toEqual([]) + }) + + test("a refresh failure never hides a completed install", async () => { + const { model } = harness({ + refresh: async () => { + throw new Error("instance gone") + }, + }) + await model.prepare(target) + expect(model.state.phase).toBe("done") + }) +}) diff --git a/packages/app/src/skillhub/stack-install-model.ts b/packages/app/src/skillhub/stack-install-model.ts new file mode 100644 index 000000000..2ce778750 --- /dev/null +++ b/packages/app/src/skillhub/stack-install-model.ts @@ -0,0 +1,96 @@ +import type { + SkillStackAgent, + SkillStackErrorCode, + SkillStackInstallMode, + SkillStackOutcome, + SkillStackPrepared, + SkillStackScope, +} from "@opencode-ai/sdk/v2/client" +import { createStore } from "solid-js/store" + +// The Skill Stack install flow as a state machine over injected server calls, +// so the dialog is a view and the transitions — prepare, stop to ask about +// conflicts, install, refresh — are testable without rendering or a server. +// +// Two steps rather than one because a stack can collide with skills already +// on disk and the user must be asked before anything is overwritten; the +// server holds the downloaded archive between them under a stagingID. + +export type StackResult = { ok: true; value: T } | { ok: false; code: SkillStackErrorCode; message: string } + +export type StackInstallTarget = { + slug: string + scope: SkillStackScope + agents: SkillStackAgent[] + // The project root when scope is "project"; the server resolves the install + // there. Absent for a global install. + directory?: string +} + +export type StackInstallDeps = { + prepare(target: StackInstallTarget): Promise> + install(input: { + stagingID: string + mode: SkillStackInstallMode + directory?: string + }): Promise> + discard(input: { stagingID: string; directory?: string }): Promise + // Make the new skills visible to a running session: the server discovers + // skills once per instance and nothing else invalidates that. + refresh(directory?: string): Promise +} + +export type StackInstallPhase = "form" | "preparing" | "conflicts" | "installing" | "done" + +export function createStackInstallModel(deps: StackInstallDeps) { + const [state, setState] = createStore({ + phase: "form" as StackInstallPhase, + target: undefined as StackInstallTarget | undefined, + prepared: undefined as SkillStackPrepared | undefined, + outcome: undefined as SkillStackOutcome | undefined, + error: undefined as { code: SkillStackErrorCode; message: string } | undefined, + }) + + const install = async (mode: SkillStackInstallMode) => { + const prepared = state.prepared + const target = state.target + if (!prepared || !target) return + setState({ phase: "installing", error: undefined }) + const result = await deps.install({ stagingID: prepared.stagingID, mode, directory: target.directory }) + if (!result.ok) { + // The staging is consumed either way; a retry has to prepare again. + setState({ phase: "form", prepared: undefined, error: { code: result.code, message: result.message } }) + return + } + await deps.refresh(target.directory).catch(() => undefined) + setState({ phase: "done", outcome: result.value }) + } + + return { + state, + async prepare(target: StackInstallTarget) { + setState({ phase: "preparing", target, prepared: undefined, outcome: undefined, error: undefined }) + const result = await deps.prepare(target) + if (!result.ok) { + setState({ phase: "form", error: { code: result.code, message: result.message } }) + return + } + setState("prepared", result.value) + // Nothing to ask about: install straight away, skipping nothing. + if (result.value.conflicts.length === 0) return install("skip-existing") + setState("phase", "conflicts") + }, + install, + // Walking away from the conflict question frees the server's staging now + // instead of leaving it to the TTL. Best effort — the TTL is the backstop. + async cancel() { + const prepared = state.prepared + if (state.phase === "conflicts" && prepared) { + void deps.discard({ stagingID: prepared.stagingID, directory: state.target?.directory }).catch(() => undefined) + } + setState({ phase: "form", prepared: undefined }) + }, + } +} + +export type StackInstallModel = ReturnType diff --git a/packages/app/src/skillhub/tabs.tsx b/packages/app/src/skillhub/tabs.tsx new file mode 100644 index 000000000..d4aa0d03b --- /dev/null +++ b/packages/app/src/skillhub/tabs.tsx @@ -0,0 +1,26 @@ +import { SegmentedControlItemV2, SegmentedControlV2 } from "@opencode-ai/ui/v2/segmented-control-v2" +import { useLocation, useNavigate } from "@solidjs/router" +import { useLanguage } from "@/context/language" + +// The Skill Hub's two halves — single skills (desktop-only, over the Electron +// bridge) and Skill Stacks (over the server) — share one sidebar entry, so +// they switch here rather than each taking a nav row. +export function SkillHubTabs() { + const language = useLanguage() + const location = useLocation() + const navigate = useNavigate() + const current = () => (location.pathname === "/skills/stacks" ? "stacks" : "skills") + + return ( + { + if (value === "stacks") navigate("/skills/stacks") + if (value === "skills") navigate("/skills") + }} + > + {language.t("skillhub.tabs.skills")} + {language.t("skillhub.tabs.stacks")} + + ) +}