Skip to content

Commit b47d8ea

Browse files
quickbeardclaude
andauthored
feat(tui): browse and install Skill Stacks with /stacks (#366)
Adds the TUI's first way onto the CoDev SkillHub: `/stacks` (and the unbound `prompt_stacks` keybind) opens a browser for the hub's Skill Stacks, then walks scope (this project or global), an agent preset, and — only when prepare reports collisions — a skip-or-overwrite choice before installing. Escaping the conflict prompt discards the staging rather than leaving it to the server's TTL. Everything goes through the server's v2.skillhub routes (#364), so the install lands on the host that runs the agent, in the same folders `codev skill stack pull` writes. After a successful install the dialog disposes the server instance — what a config update does too — because skill discovery is cached per instance and nothing else invalidates it; without that the model would not see the new skills until a restart. Flow state lives in an awaited controller rather than components, as in dialog-configure.tsx: dialog.replace stores the element factory and can re-invoke it. And since replace() fires every prior step's onClose even when this flow advances itself, "did the user escape mid-download?" is answered by checking whether the dialog still shows the step we put up. The agent picker is a preset list because DialogSelect is single-choice (a per-row toggle needs its own keybind, like dialog.mcp.toggle); CoDev Code is in every preset and is folded in server-side regardless. Claude-Session: https://claude.ai/code/session_01P1MfYkJD3tQZRgqBKB4gig Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent bb07131 commit b47d8ea

6 files changed

Lines changed: 500 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,17 @@ key_refused`; against an older backend the previous behaviour stands.
217217

218218
### Skills
219219

220+
- **Skill Stacks in the TUI** — `/stacks` (also the `prompt_stacks`
221+
keybind, unbound by default) opens a browser for the CoDev SkillHub's Skill
222+
Stacks right in the chat: pick one, choose this project or global and which
223+
agents it is for, and it installs into the same `.claude/skills` +
224+
`.agents/skills` folders `codev skill stack pull` writes — on the machine
225+
running the agent, which is what makes it the right place. When some of the
226+
stack's skills are already installed it stops and asks whether to skip them
227+
or overwrite them, exactly as the CLI does; escaping there leaves everything
228+
untouched. The newly installed skills are visible to the model straight
229+
away — no restart — and a summary toast says how many landed, were skipped,
230+
or failed.
220231
- **Install a whole Skill Stack at once** — a team that curates its skills
221232
into a public namespace on the hub publishes them as a Skill Stack, and
222233
`codev skill stack` now installs the set in one step. `stack list` shows

‎DIVERGENCES.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ from the squash message first, or use a plain merge.
294294
| 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. |
295295
| 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. |
296296
| `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. |
297+
| `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. |
297298
| `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 <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. |
298299
| `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. |
299300
| 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. |
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { TextAttributes } from "@opentui/core"
2+
import type {
3+
SkillStackAgent,
4+
SkillStackCard,
5+
SkillStackInstallMode,
6+
SkillStackPrepared,
7+
SkillStackScope,
8+
} from "@opencode-ai/sdk/v2"
9+
import { createMemo, createResource, createSignal } from "solid-js"
10+
import { useData } from "../context/data"
11+
import { useSDK } from "../context/sdk"
12+
import { useTheme } from "../context/theme"
13+
import { useDialog } from "../ui/dialog"
14+
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
15+
import { useToast } from "../ui/toast"
16+
import { errorMessage } from "../util/error"
17+
import { StatusBox } from "./dialog-custom-provider"
18+
19+
// Fork-only: `/stacks` — browse the CoDev SkillHub's Skill Stacks and install
20+
// one into this project or globally. Every hub request goes through the
21+
// server's v2.skillhub routes, so the install lands on the host that runs the
22+
// agent (the same place `codev skill stack pull` would put it) and the TUI
23+
// never touches the hub itself.
24+
//
25+
// Architecture note (see dialog-configure.tsx): dialog.replace stores the
26+
// element FACTORY and solid may re-invoke it on unrelated re-renders, so all
27+
// flow state lives in the awaited controller below; the views it shows are
28+
// pure. And replace() fires every prior step's onClose — including when this
29+
// flow advances itself — so "did the user escape?" is answered by checking
30+
// whether the dialog still shows the step we put up, never by onClose.
31+
32+
interface FlowContext {
33+
dialog: ReturnType<typeof useDialog>
34+
sdk: ReturnType<typeof useSDK>
35+
toast: ReturnType<typeof useToast>
36+
data: ReturnType<typeof useData>
37+
}
38+
39+
// DialogSelect is single-choice (a per-row toggle would need its own keybind,
40+
// like dialog.mcp.toggle), so the agent set is picked from presets. CoDev Code
41+
// is in every one and is folded in server-side regardless.
42+
const AGENT_PRESETS: { title: string; description: string; value: SkillStackAgent[] }[] = [
43+
{ title: "CoDev Code only", description: "this agent", value: ["codev"] },
44+
{ title: "CoDev Code + Claude Code", description: ".claude/skills is shared", value: ["codev", "claude"] },
45+
{
46+
title: "All agents",
47+
description: "CoDev Code, Claude Code, Codex, OpenCode",
48+
value: ["codev", "claude", "codex", "opencode"],
49+
},
50+
]
51+
52+
// The hub's listing is paged at 50; the local fuzzy filter covers that. A hub
53+
// with more public stacks than one page needs server-side search wired to
54+
// onFilter, which is not worth a debounce until it happens.
55+
const LIST_LIMIT = "50"
56+
57+
export function DialogStack() {
58+
const ctx: FlowContext = { dialog: useDialog(), sdk: useSDK(), toast: useToast(), data: useData() }
59+
const { theme } = useTheme()
60+
ctx.dialog.setSize("large")
61+
62+
const [loadError, setLoadError] = createSignal<unknown>()
63+
const [stacks] = createResource(() =>
64+
ctx.sdk.client.v2.skillhub.stack
65+
.list({ limit: LIST_LIMIT }, { throwOnError: true })
66+
.then((result) => result.data.data.items)
67+
// Catch so the rejected resource never reaches the memo below: reading
68+
// stacks() in an errored state re-throws and tears down the dialog.
69+
.catch((error: unknown) => {
70+
setLoadError(error)
71+
return undefined
72+
}),
73+
)
74+
const showError = createMemo(() => Boolean(loadError()))
75+
76+
const options = createMemo<DialogSelectOption<SkillStackCard>[]>(() => {
77+
if (showError()) return []
78+
return (stacks() ?? []).map((card) => ({
79+
title: card.name,
80+
description: describe(card),
81+
value: card,
82+
onSelect: () => void runInstallFlow(ctx, card),
83+
}))
84+
})
85+
86+
return (
87+
<DialogSelect
88+
title="Skill Stacks"
89+
placeholder="Search stacks..."
90+
options={options()}
91+
renderFilter={!showError()}
92+
locked={showError()}
93+
emptyView={
94+
showError() ? (
95+
<box paddingLeft={4} paddingRight={4}>
96+
<text fg={theme.error} attributes={TextAttributes.BOLD}>
97+
Could not load Skill Stacks
98+
</text>
99+
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
100+
</box>
101+
) : stacks.loading ? (
102+
<box paddingLeft={4}>
103+
<text fg={theme.textMuted}>Loading...</text>
104+
</box>
105+
) : undefined
106+
}
107+
/>
108+
)
109+
}
110+
111+
function describe(card: SkillStackCard): string {
112+
const counts = `${card.skillCount} skill${card.skillCount === 1 ? "" : "s"} · ${card.memberCount} member${card.memberCount === 1 ? "" : "s"}`
113+
const summary = card.description.replace(/\s+/g, " ").trim()
114+
return summary ? `${counts} — ${summary}` : counts
115+
}
116+
117+
async function runInstallFlow(ctx: FlowContext, card: SkillStackCard) {
118+
const scope = await showSelect<SkillStackScope>(ctx.dialog, `Install ${card.name} to:`, [
119+
{ title: "This project", description: "recommended", value: "project" },
120+
{ title: "Global", description: "every project on this machine", value: "global" },
121+
])
122+
if (scope === null) return
123+
124+
const agents = await showSelect(ctx.dialog, "Install for which agents?", AGENT_PRESETS)
125+
if (agents === null) return
126+
127+
const downloading = () => <StatusBox text={`Downloading ${card.name}...`} />
128+
ctx.dialog.replace(downloading)
129+
const prepared = await ctx.sdk.client.v2.skillhub.stack.prepare({
130+
slug: card.slug,
131+
skillStackPrepareInput: { scope, agents },
132+
})
133+
if (!prepared.data) return fail(ctx, `Could not prepare ${card.name}`, prepared.error)
134+
const plan = prepared.data.data
135+
if (!stillShowing(ctx.dialog, downloading)) return void discard(ctx, plan)
136+
137+
const mode =
138+
plan.conflicts.length === 0
139+
? "skip-existing"
140+
: await showSelect<SkillStackInstallMode>(
141+
ctx.dialog,
142+
`${plan.conflicts.length} of these skills already installed`,
143+
[
144+
{ title: "Skip them, install the rest", description: plan.conflicts.join(", "), value: "skip-existing" },
145+
{ title: "Overwrite them", description: "discards any local edits", value: "overwrite" },
146+
],
147+
)
148+
if (mode === null) return void discard(ctx, plan)
149+
150+
const installing = () => <StatusBox text={`Installing ${plan.skills.length} skill(s)...`} />
151+
ctx.dialog.replace(installing)
152+
const installed = await ctx.sdk.client.v2.skillhub.stack.install({
153+
skillStackInstallInput: { stagingID: plan.stagingID, mode },
154+
})
155+
if (!installed.data) return fail(ctx, `Could not install ${card.name}`, installed.error)
156+
const outcome = installed.data.data
157+
158+
// The server discovers skills once per instance and nothing invalidates
159+
// that, so the model would not see the new skills until a restart. Disposing
160+
// the instance is what a config update does too; the next request rebuilds
161+
// it. Then refresh the TUI's own list.
162+
await ctx.sdk.client.instance.dispose().catch(() => undefined)
163+
await ctx.data.location.skill.refresh().catch(() => undefined)
164+
165+
const parts = [`Installed ${outcome.installed.length} of ${plan.skills.length} skill(s) from ${card.name}`]
166+
if (outcome.skipped.length > 0) parts.push(`${outcome.skipped.length} skipped`)
167+
if (outcome.failed.length > 0) parts.push(`${outcome.failed.length} failed`)
168+
ctx.toast.show({
169+
variant: outcome.failed.length > 0 ? "warning" : "success",
170+
title: "Skill Stack installed",
171+
message: parts.join(", "),
172+
duration: 8000,
173+
})
174+
// Only close the dialog if the install step is still what's up — escaping
175+
// mid-install already did, and something else may have opened since.
176+
if (stillShowing(ctx.dialog, installing)) ctx.dialog.clear()
177+
}
178+
179+
// Renders a select and resolves with the chosen value, or null once the user
180+
// escapes. The onClose passed to replace() also fires when this flow advances
181+
// to its next step, but by then the promise is already resolved with a value.
182+
function showSelect<T>(
183+
dialog: FlowContext["dialog"],
184+
title: string,
185+
options: (Omit<DialogSelectOption<T>, "onSelect"> & { value: T })[],
186+
): Promise<T | null> {
187+
return new Promise((resolve) => {
188+
dialog.replace(
189+
() => (
190+
<DialogSelect
191+
title={title}
192+
renderFilter={false}
193+
options={options.map((option) => ({ ...option, onSelect: () => resolve(option.value) }))}
194+
/>
195+
),
196+
() => resolve(null),
197+
)
198+
})
199+
}
200+
201+
// The dialog store types `element` as rendered JSX, but replace() stores the
202+
// factory it was handed — which is exactly what lets a step recognise itself.
203+
function stillShowing(dialog: FlowContext["dialog"], factory: () => unknown): boolean {
204+
return (dialog.stack[0]?.element as unknown) === factory
205+
}
206+
207+
// The user walked away from a prepared stack: free its staging now rather
208+
// than waiting for the server's TTL. Best effort — the TTL is the backstop.
209+
function discard(ctx: FlowContext, plan: SkillStackPrepared) {
210+
void ctx.sdk.client.v2.skillhub.stack.discard({ stagingID: plan.stagingID }).catch(() => undefined)
211+
}
212+
213+
function fail(ctx: FlowContext, title: string, error: unknown) {
214+
ctx.toast.show({ variant: "error", title, message: describeError(error), duration: 10000 })
215+
ctx.dialog.clear()
216+
}
217+
218+
// The server answers with a SkillHubError carrying a code and a message the
219+
// CLI already shows verbatim (`auth` names the command that signs in); other
220+
// wire errors carry a message too, and anything else falls back to the
221+
// generic formatter.
222+
function describeError(error: unknown): string {
223+
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
224+
return error.message
225+
}
226+
return errorMessage(error)
227+
}

0 commit comments

Comments
 (0)