diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ac273a26..4c6ed3fee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,6 +197,17 @@ key_refused`; against an older backend the previous behaviour stands. ### Skills +- **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 + agents it is for, and it installs into the same `.claude/skills` + + `.agents/skills` folders `codev skill stack pull` writes — on the machine + running the agent, which is what makes it the right place. When some of the + stack's skills are already installed it stops and asks whether to skip them + or overwrite them, exactly as the CLI does; escaping there leaves everything + untouched. The newly installed skills are visible to the model straight + away — no restart — and a summary toast says how many landed, were skipped, + or failed. - **Install a whole Skill Stack at once** — a team that curates its skills into a public namespace on the hub publishes them as a Skill Stack, and `codev skill stack` now installs the set in one step. `stack list` shows diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 085e3a8f6b..c03d4240fa 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -293,6 +293,7 @@ from the squash message first, or use a plain merge. | Skill Stack HTTP routes: `packages/protocol/src/api.ts`, `packages/server/src/handlers.ts`, `packages/server/package.json`, `packages/schema/src/index.ts` | `api.ts` imports and `.add(SkillHubGroup.middleware(locationMiddleware))` (last group; `src/groups/skillhub.ts` is fork-only); `handlers.ts` merges `SkillHubHandler` (`src/handlers/skillhub.ts`, fork-only); server `package.json` adds `"@codev/skillhub": "workspace:*"`; schema `index.ts` exports `SkillStack` (`src/skill-stack.ts`, fork-only). | The v2 group `server.skillhub` (`GET /api/skillhub/stack`, `POST …/:slug/prepare`, `POST …/install`, `DELETE …/staging/:stagingID`) puts all CoDev SkillHub Skill Stack traffic behind the server, so the TUI (which reaches the hub only through the SDK) and the desktop app share one implementation and installs land on the host that runs the agent. The dependency direction stays Schema → Protocol → Server; the server takes the wire types through the protocol group's re-export rather than a direct schema dependency, matching the other handlers. | Keep the import + `.add` line, the handler merge, the dependency, and the schema export; take upstream's surrounding changes. | | Generated clients: `packages/client/src/contract.ts`, `packages/client/test/promise.test.ts`, `packages/client/src/generated/{client,types}.ts`, `packages/client/src/generated-effect/client.ts`, `packages/sdk/js/src/v2/gen/{sdk,types}.gen.ts` | `contract.ts` maps `"server.skillhub": "skillhub"` and names its endpoints `listStacks`/`prepareStack`/`installStack`/`discardStack`; `promise.test.ts` expects `"skillhub"` last in the group list; the generated trees carry the group. | The fork-only protocol group must reach both SDKs; the endpoint names are overridden because the codegen's default (last id segment) would give `list`/`prepare`/`install`, which collide once single-skill routes join the group. | Keep our `contract.ts` entries and the test expectation; never hand-merge the generated trees — run `bun run generate` in `packages/client` and `./packages/sdk/js/script/build.ts` after resolving the protocol. | | `packages/opencode/test/server/httpapi-exercise/index.ts` | Imports and calls `installFakeSkillHub()` from the fork-only `skillhub-fake.ts` before the scenario list, and adds four `v2.skillhub.stack.*` scenarios after `v2.skill.list`. | CI runs the exerciser with `--fail-on-missing --fail-on-skip`, so every fork route needs a real passing scenario, and none may reach the live hub: the fake swaps in `@codev/skillhub`'s injectable transport (the same seam the desktop uses for Chromium's `net.fetch`), so the handler downloads, parses, plans, and stages a fixture stack exactly as it would against the hub. | Keep the import/call and the four scenarios; take upstream's surrounding scenario changes. | +| `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/config/keybind.ts` | `prompt/index.tsx` imports the fork-only `DialogStack` (`component/dialog-stack.tsx`) and registers the `prompt.stacks` command (`/stacks`, category Prompt) directly after `prompt.skills`; `keybind.ts` adds `prompt_stacks: keybind("none", …)` beside `prompt_skills` and maps it to `prompt.stacks`. | The Skill Stack browser is the TUI's only way to reach the CoDev SkillHub (the hub is CORS-closed and the TUI reaches it through the server's fork-only `v2.skillhub` routes); it sits next to `/skills` because that is where a user looks for skills. Unbound by default like `prompt_skills`. | Keep the import, the command entry, and the two keybind lines; take upstream's surrounding command/keybind changes. | | `sdks/vscode/package.json`, `sdks/vscode/src/extension.ts`, `sdks/vscode/README.md` | `name` `opencode` → `codev-code` (the extension ID is `publisher.name`); `displayName` "opencode" → "CoDev Code"; `publisher` `sst-dev` → `CoDev` (PLACEHOLDER — no such Marketplace publisher is registered; the mixed-case `CoDev` also isn't a valid Marketplace publisher id, which must be lowercase — fine for the sideloaded `.vsix` the fork ships, but replace with the real fork-owned lowercase publisher id before any `vsce publish`, and keep it in sync with the CLI auto-install target); `description` "opencode for VS Code" → "CoDev Code for VS Code"; `icon` `images/icon.png` is a fork-local generated PNG of the CoDev "c" badge (brand red `#ee0033` — the "dev" half of the wordmark, see `packages/tui/src/logo.ts` — on `#0a0a0a`), with `images/icon.svg` as its source of record — replacing the earlier symlink to the shared `packages/identity` bracket mark; the two per-theme command `icon` objects (`{light,dark}` → `images/button-{dark,light}.svg`) collapse to a single `"images/button.svg"` string and `extension.ts`'s terminal `iconPath` object collapses to a single `Uri`, both pointing at a fork-local transparent brand-red `#ee0033` "c" glyph that reads on light+dark themes (replacing the two `packages/identity` bracket-mark symlinks, now deleted); `repository.url` → `github.com/quickbeard/codev-code`; command IDs `opencode.*` → `codev.*` across `contributes.commands`/`menus`/`keybindings` and the `registerCommand` calls; command/keybinding titles "Open opencode…"/"Run opencode"/"opencode: Insert At-Mentioned" → "Open CoDev Code…"/"Run CoDev Code"/"CoDev Code: Insert At-Mentioned" (keys unchanged); `TERMINAL_NAME` `opencode` → `codev` and the terminal launches `codev --port `; scripts gain `"vsix"` (`vsce package --no-dependencies --skip-license -o dist/`) with `@vscode/vsce` added as a pinned devDependency; README body rebranded and trimmed to the details-panel essentials — CoDev Code intro linking the fork repo, `npm install -g codev-code` prerequisite, and feature prose only; upstream's Support and Development/Making-Changes sections are dropped (they're contributor-facing, not end-user extension description) | The fork ships this extension as an installable `.vsix`, so every user-visible surface — palette titles, command IDs, terminal name, launched binary, details-panel README — must say codev, and the launched binary must be the fork's `codev` CLI. Distinct `codev.*` IDs also avoid clashing with upstream's extension if both are installed. `--no-dependencies` because the bundle is self-contained (no runtime deps, bun-managed lockfile); `--skip-license` because `sdks/vscode` has no LICENSE file and vsce aborts without it. | Keep our name + displayName + publisher + repository + description, command IDs + titles, `TERMINAL_NAME`, the `codev --port` launch line, the `vsix` script + `@vscode/vsce` devDependency, and the trimmed README (branding + dropped Support/Development sections); take upstream's extension-logic and manifest-structure changes. | | `packages/opencode/src/ide/index.ts` | IDE auto-install target `sst-dev.opencode` → `CoDev.codev-code` (`publisher.name` from `sdks/vscode/package.json`) | The CLI must install the fork's extension ID (`publisher.name` from `sdks/vscode/package.json`), not upstream's Marketplace listing. `Ide.install` currently has no runtime caller (only its test imports the module), and the install only resolves once the extension is actually published — until then the local `.vsix` is the supported install path. | Keep our extension ID (and re-sync it if the registered publisher id changes); take upstream's install/detection logic changes. | | Env-var rename, repo-wide (~230 files): both flag registries (`packages/core/src/flag/flag.ts`, `packages/opencode/src/effect/runtime-flags.ts`), every `process.env`/`Bun.env` read+set site across `packages/*`, `sdks/vscode`, `infra/enterprise.ts`, `nix/opencode.nix`, `turbo.json`, `.github/workflows/test.yml`, test preloads/fixtures/snapshots, docs (en + es), `SECURITY.md`, specs | `OPENCODE_*` env vars → `CODEV_*` (hard cutover, no legacy fallback), including the bundler define identifiers (`CODEV_VERSION`, `CODEV_CHANNEL`, `CODEV_MODELS_DEV`, `CODEV_LIBC`, `CODEV_CLI_NAME`, `CODEV_WORKER_PATH`), `VITE_OPENCODE_*` → `VITE_CODEV_*`, `import.meta.env.OPENCODE_CHANNEL` → `CODEV_CHANNEL`, the VSCode extension's `OPENCODE_CALLER`/`_EXTENSION_OPENCODE_PORT`, and the bare `OPENCODE=1` marker → `CODEV=1` | The fork's env surface uses the fork's name (reverses the earlier "internal names stay upstream" decision for env vars). NOT renamed: `__OPENCODE_PHOTON_WASM_PATH` and `__OPENCODE__` globals (not env vars; the photon name is baked into `patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch`), `patches/install-korean-ime-fix.sh` script-local shell vars, upstream secret names in workflow-deletion notes, lowercase identifiers (`@opencode-ai/*`, URLs, provider id) | Keep our names; rename any new upstream `OPENCODE_*` env var (or define/`VITE_` name) to `CODEV_*` when syncing. | diff --git a/packages/tui/src/component/dialog-stack.tsx b/packages/tui/src/component/dialog-stack.tsx new file mode 100644 index 0000000000..8c4f3da5e3 --- /dev/null +++ b/packages/tui/src/component/dialog-stack.tsx @@ -0,0 +1,227 @@ +import { TextAttributes } from "@opentui/core" +import type { + SkillStackAgent, + SkillStackCard, + SkillStackInstallMode, + SkillStackPrepared, + SkillStackScope, +} from "@opencode-ai/sdk/v2" +import { createMemo, createResource, createSignal } from "solid-js" +import { useData } from "../context/data" +import { useSDK } from "../context/sdk" +import { useTheme } from "../context/theme" +import { useDialog } from "../ui/dialog" +import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" +import { useToast } from "../ui/toast" +import { errorMessage } from "../util/error" +import { StatusBox } from "./dialog-custom-provider" + +// Fork-only: `/stacks` — browse the CoDev SkillHub's Skill Stacks and install +// one into this project or globally. Every hub request goes through the +// server's v2.skillhub routes, so the install lands on the host that runs the +// agent (the same place `codev skill stack pull` would put it) and the TUI +// never touches the hub itself. +// +// Architecture note (see dialog-configure.tsx): dialog.replace stores the +// element FACTORY and solid may re-invoke it on unrelated re-renders, so all +// flow state lives in the awaited controller below; the views it shows are +// pure. And replace() fires every prior step's onClose — including when this +// flow advances itself — so "did the user escape?" is answered by checking +// whether the dialog still shows the step we put up, never by onClose. + +interface FlowContext { + dialog: ReturnType + sdk: ReturnType + toast: ReturnType + data: ReturnType +} + +// DialogSelect is single-choice (a per-row toggle would need its own keybind, +// like dialog.mcp.toggle), so the agent set is picked from presets. CoDev Code +// is in every one and is folded in server-side regardless. +const AGENT_PRESETS: { title: string; description: string; value: SkillStackAgent[] }[] = [ + { title: "CoDev Code only", description: "this agent", value: ["codev"] }, + { title: "CoDev Code + Claude Code", description: ".claude/skills is shared", value: ["codev", "claude"] }, + { + title: "All agents", + description: "CoDev Code, Claude Code, Codex, OpenCode", + value: ["codev", "claude", "codex", "opencode"], + }, +] + +// The hub's listing is paged at 50; the local fuzzy filter covers that. A hub +// with more public stacks than one page needs server-side search wired to +// onFilter, which is not worth a debounce until it happens. +const LIST_LIMIT = "50" + +export function DialogStack() { + const ctx: FlowContext = { dialog: useDialog(), sdk: useSDK(), toast: useToast(), data: useData() } + const { theme } = useTheme() + ctx.dialog.setSize("large") + + const [loadError, setLoadError] = createSignal() + const [stacks] = createResource(() => + ctx.sdk.client.v2.skillhub.stack + .list({ limit: LIST_LIMIT }, { throwOnError: true }) + .then((result) => result.data.data.items) + // Catch so the rejected resource never reaches the memo below: reading + // stacks() in an errored state re-throws and tears down the dialog. + .catch((error: unknown) => { + setLoadError(error) + return undefined + }), + ) + const showError = createMemo(() => Boolean(loadError())) + + const options = createMemo[]>(() => { + if (showError()) return [] + return (stacks() ?? []).map((card) => ({ + title: card.name, + description: describe(card), + value: card, + onSelect: () => void runInstallFlow(ctx, card), + })) + }) + + return ( + + + Could not load Skill Stacks + + {errorMessage(loadError())} + + ) : stacks.loading ? ( + + Loading... + + ) : undefined + } + /> + ) +} + +function describe(card: SkillStackCard): string { + const counts = `${card.skillCount} skill${card.skillCount === 1 ? "" : "s"} · ${card.memberCount} member${card.memberCount === 1 ? "" : "s"}` + const summary = card.description.replace(/\s+/g, " ").trim() + return summary ? `${counts} — ${summary}` : counts +} + +async function runInstallFlow(ctx: FlowContext, card: SkillStackCard) { + const scope = await showSelect(ctx.dialog, `Install ${card.name} to:`, [ + { title: "This project", description: "recommended", value: "project" }, + { title: "Global", description: "every project on this machine", value: "global" }, + ]) + if (scope === null) return + + const agents = await showSelect(ctx.dialog, "Install for which agents?", AGENT_PRESETS) + if (agents === null) return + + const downloading = () => + ctx.dialog.replace(downloading) + const prepared = await ctx.sdk.client.v2.skillhub.stack.prepare({ + slug: card.slug, + skillStackPrepareInput: { scope, agents }, + }) + if (!prepared.data) return fail(ctx, `Could not prepare ${card.name}`, prepared.error) + const plan = prepared.data.data + if (!stillShowing(ctx.dialog, downloading)) return void discard(ctx, plan) + + const mode = + plan.conflicts.length === 0 + ? "skip-existing" + : await showSelect( + ctx.dialog, + `${plan.conflicts.length} of these skills already installed`, + [ + { title: "Skip them, install the rest", description: plan.conflicts.join(", "), value: "skip-existing" }, + { title: "Overwrite them", description: "discards any local edits", value: "overwrite" }, + ], + ) + if (mode === null) return void discard(ctx, plan) + + const installing = () => + ctx.dialog.replace(installing) + const installed = await ctx.sdk.client.v2.skillhub.stack.install({ + skillStackInstallInput: { stagingID: plan.stagingID, mode }, + }) + if (!installed.data) return fail(ctx, `Could not install ${card.name}`, installed.error) + const outcome = installed.data.data + + // The server discovers skills once per instance and nothing invalidates + // that, so the model would not see the new skills until a restart. Disposing + // the instance is what a config update does too; the next request rebuilds + // it. Then refresh the TUI's own list. + await ctx.sdk.client.instance.dispose().catch(() => undefined) + await ctx.data.location.skill.refresh().catch(() => undefined) + + const parts = [`Installed ${outcome.installed.length} of ${plan.skills.length} skill(s) from ${card.name}`] + if (outcome.skipped.length > 0) parts.push(`${outcome.skipped.length} skipped`) + if (outcome.failed.length > 0) parts.push(`${outcome.failed.length} failed`) + ctx.toast.show({ + variant: outcome.failed.length > 0 ? "warning" : "success", + title: "Skill Stack installed", + message: parts.join(", "), + duration: 8000, + }) + // Only close the dialog if the install step is still what's up — escaping + // mid-install already did, and something else may have opened since. + if (stillShowing(ctx.dialog, installing)) ctx.dialog.clear() +} + +// Renders a select and resolves with the chosen value, or null once the user +// escapes. The onClose passed to replace() also fires when this flow advances +// to its next step, but by then the promise is already resolved with a value. +function showSelect( + dialog: FlowContext["dialog"], + title: string, + options: (Omit, "onSelect"> & { value: T })[], +): Promise { + return new Promise((resolve) => { + dialog.replace( + () => ( + ({ ...option, onSelect: () => resolve(option.value) }))} + /> + ), + () => resolve(null), + ) + }) +} + +// The dialog store types `element` as rendered JSX, but replace() stores the +// factory it was handed — which is exactly what lets a step recognise itself. +function stillShowing(dialog: FlowContext["dialog"], factory: () => unknown): boolean { + return (dialog.stack[0]?.element as unknown) === factory +} + +// The user walked away from a prepared stack: free its staging now rather +// than waiting for the server's TTL. Best effort — the TTL is the backstop. +function discard(ctx: FlowContext, plan: SkillStackPrepared) { + void ctx.sdk.client.v2.skillhub.stack.discard({ stagingID: plan.stagingID }).catch(() => undefined) +} + +function fail(ctx: FlowContext, title: string, error: unknown) { + ctx.toast.show({ variant: "error", title, message: describeError(error), duration: 10000 }) + ctx.dialog.clear() +} + +// The server answers with a SkillHubError carrying a code and a message the +// CLI already shows verbatim (`auth` names the command that signs in); other +// wire errors carry a message too, and anything else falls back to the +// generic formatter. +function describeError(error: unknown): string { + if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") { + return error.message + } + return errorMessage(error) +} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index cfaf64c60c..13d06b27b3 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -51,6 +51,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { DialogSkill } from "../dialog-skill" +import { DialogStack } from "../dialog-stack" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "../../context/args" import { CODEV_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" @@ -555,6 +556,17 @@ export function Prompt(props: PromptProps) { )) }, }, + { + // Fork-only: browse and install CoDev SkillHub Skill Stacks. + title: "Skill stacks", + desc: "Install a Skill Stack from the CoDev SkillHub", + name: "prompt.stacks", + category: "Prompt", + slashName: "stacks", + run: () => { + dialog.replace(() => ) + }, + }, { title: "Warp", desc: "Change the workspace for the session", diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 9492dcb801..82fd295c4e 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -156,6 +156,7 @@ export const Definitions = { prompt_submit: keybind("none", "Submit prompt"), prompt_editor_context_clear: keybind("none", "Clear editor context"), prompt_skills: keybind("none", "Open skill selector"), + prompt_stacks: keybind("none", "Open Skill Stack browser"), prompt_stash: keybind("none", "Stash prompt"), prompt_stash_pop: keybind("none", "Pop stashed prompt"), prompt_stash_list: keybind("none", "List stashed prompts"), @@ -364,6 +365,7 @@ export const CommandMap = { prompt_submit: "prompt.submit", prompt_editor_context_clear: "prompt.editor_context.clear", prompt_skills: "prompt.skills", + prompt_stacks: "prompt.stacks", prompt_stash: "prompt.stash", prompt_stash_pop: "prompt.stash.pop", prompt_stash_list: "prompt.stash.list", diff --git a/packages/tui/test/skill-stack.view.test.tsx b/packages/tui/test/skill-stack.view.test.tsx new file mode 100644 index 0000000000..501e69f15e --- /dev/null +++ b/packages/tui/test/skill-stack.view.test.tsx @@ -0,0 +1,247 @@ +import { expect, mock, test } from "bun:test" +import type { TuiPluginApi, TuiSlotPlugin } from "@opencode-ai/plugin/tui" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/core/global" +import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createEventSource, createFetch, directory, json, worktree, type FetchHandler } from "./fixture/tui-sdk" + +// Fork: `/stacks` end to end — browse, choose a scope and agents, see the +// conflict prompt, install, and have the server's instance refreshed — against +// a fetch fixture standing in for the server's v2.skillhub routes. + +const location = { directory, project: { id: "proj_test", directory: worktree } } + +const session = { + id: "ses_stack", + title: "Demo session", + slug: "stack", + projectID: "proj_test", + directory, + version: "0.0.0-test", + time: { created: 1, updated: 1 }, +} + +const card = { + id: "ns-1", + slug: "data-eng", + name: "Data Engineering", + description: "Everything a data team needs", + owner: { username: "ann" }, + memberCount: 4, + skillCount: 2, + updatedAt: null, + skills: [], + previewTruncated: false, +} + +const prepared = { + stagingID: "staging_1", + slug: "data-eng", + name: "Data Engineering", + skills: [ + { + name: "dbt-helper", + files: 1, + bytes: 10, + destinations: [`${worktree}/.claude/skills/dbt-helper`], + conflict: false, + }, + { name: "pg-tuner", files: 2, bytes: 20, destinations: [`${worktree}/.claude/skills/pg-tuner`], conflict: true }, + ], + conflicts: ["pg-tuner"], + skipped: [], + totalBytes: 30, + expiresAt: Date.now() + 600_000, +} + +async function waitFrame(setup: Awaited>, fn: (frame: string) => boolean) { + const start = Date.now() + while (true) { + await setup.renderOnce() + const frame = setup.captureCharFrame() + if (fn(frame)) return frame + if (Date.now() - start > 15_000) throw new Error("timed out waiting for frame:\n" + frame) + await Bun.sleep(25) + } +} + +// Boots the real app with a configured provider (so the setup gate stays +// closed) and the sidebar registered (so the chat settles and paints its +// footer, which is how the test knows the app is ready for a command). +async function boot(override: FetchHandler) { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + const calls = createFetch((url, method) => { + if (url.pathname === "/session") return json([session]) + if (url.pathname.startsWith(`/session/${session.id}/message`)) return json([]) + if (url.pathname === `/session/${session.id}`) return json(session) + if (url.pathname === "/project/proj_test/directories") return json([]) + if (url.pathname === "/config/providers") + return json({ + providers: [{ id: "test", name: "Test", models: { m1: { name: "M1", cost: { input: 1 } } } }], + default: { test: "m1" }, + }) + return override(url, method) + }, events) + let api: TuiPluginApi | undefined + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch: calls.fetch, + events: events.source, + args: { continue: true }, + pluginHost: { + async start(input) { + const hostSlots = input.runtime.setupSlots(input.api) + const { createBuiltinPlugins } = await import("../src/feature-plugins/builtins") + const sidebar = createBuiltinPlugins({ experimentalEventSystem: false }).filter((plugin) => + plugin.id.startsWith("internal:sidebar-"), + ) + for (const plugin of sidebar) { + const scoped = { + ...input.api, + slots: { + register: (registration: TuiSlotPlugin) => { + hostSlots.register({ ...registration, id: plugin.id }) + return plugin.id + }, + }, + } as typeof input.api + await plugin.tui(scoped, undefined, {} as never) + } + api = input.api + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + return { setup, task, api: () => api } +} + +test("/stacks browses, asks about conflicts, installs, and refreshes the instance", async () => { + const seen = { disposed: 0, discarded: 0 } + const boot_ = await boot((url, method) => { + if (url.pathname === "/api/skillhub/stack" && method === "GET") + return json({ + location, + data: { items: [card], pagination: { page: 1, limit: 50, total: 1, totalPages: 1 }, totalStacks: 1 }, + }) + if (url.pathname === "/api/skillhub/stack/data-eng/prepare" && method === "POST") + return json({ location, data: prepared }) + if (url.pathname === "/api/skillhub/stack/install" && method === "POST") + return json({ + location, + data: { + slug: "data-eng", + mode: "skip-existing", + installed: [{ name: "dbt-helper", dir: `${worktree}/.claude/skills/dbt-helper`, placements: [] }], + skipped: [{ name: "pg-tuner", reason: "already installed" }], + failed: [], + }, + }) + if (url.pathname.startsWith("/api/skillhub/stack/staging/") && method === "DELETE") { + seen.discarded++ + return new Response(null, { status: 204 }) + } + if (url.pathname === "/instance/dispose") { + seen.disposed++ + return json(true) + } + return undefined + }) + try { + await waitFrame(boot_.setup, (frame) => frame.includes("ctrl+p commands")) + + boot_.api()?.keymap.dispatchCommand("prompt.stacks") + await waitFrame(boot_.setup, (frame) => frame.includes("Skill Stacks") && frame.includes("Data Engineering")) + + // Pick the stack → scope → agent preset (the first option each time). + boot_.setup.mockInput.pressEnter() + await waitFrame(boot_.setup, (frame) => frame.includes("Install Data Engineering to:")) + boot_.setup.mockInput.pressEnter() + await waitFrame(boot_.setup, (frame) => frame.includes("Install for which agents?")) + boot_.setup.mockInput.pressEnter() + + // prepare reported one collision, so the flow stops to ask. + await waitFrame(boot_.setup, (frame) => frame.includes("already installed") && frame.includes("pg-tuner")) + boot_.setup.mockInput.pressEnter() + + const frame = await waitFrame(boot_.setup, (frame) => frame.includes("Skill Stack installed")) + // The toast wraps at the frame width and the rows to its left (the prompt + // box) interleave with it in a captured frame, so the wrapped halves are + // asserted separately with runs of whitespace collapsed. + const toast = frame.replace(/\s+/g, " ") + expect(toast).toContain("Installed 1 of 2 skill(s) from Data Engineering, 1") + expect(toast).toContain("skipped") + expect(seen.disposed).toBe(1) + expect(seen.discarded).toBe(0) + } finally { + boot_.api()?.keymap.dispatchCommand("app.exit") + await boot_.task + if (!boot_.setup.renderer.isDestroyed) boot_.setup.renderer.destroy() + mock.restore() + } +}) + +test("escaping the conflict prompt discards the staging instead of installing", async () => { + const seen = { installed: 0, discarded: 0 } + const boot_ = await boot((url, method) => { + if (url.pathname === "/api/skillhub/stack" && method === "GET") + return json({ + location, + data: { items: [card], pagination: { page: 1, limit: 50, total: 1, totalPages: 1 }, totalStacks: 1 }, + }) + if (url.pathname === "/api/skillhub/stack/data-eng/prepare" && method === "POST") + return json({ location, data: prepared }) + if (url.pathname === "/api/skillhub/stack/install") { + seen.installed++ + return json({ + location, + data: { slug: "data-eng", mode: "skip-existing", installed: [], skipped: [], failed: [] }, + }) + } + if (url.pathname === "/api/skillhub/stack/staging/staging_1" && method === "DELETE") { + seen.discarded++ + return new Response(null, { status: 204 }) + } + return undefined + }) + try { + await waitFrame(boot_.setup, (frame) => frame.includes("ctrl+p commands")) + boot_.api()?.keymap.dispatchCommand("prompt.stacks") + await waitFrame(boot_.setup, (frame) => frame.includes("Data Engineering")) + boot_.setup.mockInput.pressEnter() + await waitFrame(boot_.setup, (frame) => frame.includes("Install Data Engineering to:")) + boot_.setup.mockInput.pressEnter() + await waitFrame(boot_.setup, (frame) => frame.includes("Install for which agents?")) + boot_.setup.mockInput.pressEnter() + await waitFrame(boot_.setup, (frame) => frame.includes("already installed")) + + boot_.setup.mockInput.pressEscape() + await waitFrame(boot_.setup, (frame) => !frame.includes("already installed")) + // The discard is fire-and-forget; give it a turn to reach the fixture. + const start = Date.now() + while (seen.discarded === 0 && Date.now() - start < 5_000) await Bun.sleep(25) + expect(seen.discarded).toBe(1) + expect(seen.installed).toBe(0) + } finally { + boot_.api()?.keymap.dispatchCommand("app.exit") + await boot_.task + if (!boot_.setup.renderer.isDestroyed) boot_.setup.renderer.destroy() + mock.restore() + } +})