Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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. |
Expand Down
227 changes: 227 additions & 0 deletions packages/tui/src/component/dialog-stack.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useDialog>
sdk: ReturnType<typeof useSDK>
toast: ReturnType<typeof useToast>
data: ReturnType<typeof useData>
}

// 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<unknown>()
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<DialogSelectOption<SkillStackCard>[]>(() => {
if (showError()) return []
return (stacks() ?? []).map((card) => ({
title: card.name,
description: describe(card),
value: card,
onSelect: () => void runInstallFlow(ctx, card),
}))
})

return (
<DialogSelect
title="Skill Stacks"
placeholder="Search stacks..."
options={options()}
renderFilter={!showError()}
locked={showError()}
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load Skill Stacks
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
</box>
) : stacks.loading ? (
<box paddingLeft={4}>
<text fg={theme.textMuted}>Loading...</text>
</box>
) : 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<SkillStackScope>(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 = () => <StatusBox text={`Downloading ${card.name}...`} />
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<SkillStackInstallMode>(
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 = () => <StatusBox text={`Installing ${plan.skills.length} skill(s)...`} />
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<T>(
dialog: FlowContext["dialog"],
title: string,
options: (Omit<DialogSelectOption<T>, "onSelect"> & { value: T })[],
): Promise<T | null> {
return new Promise((resolve) => {
dialog.replace(
() => (
<DialogSelect
title={title}
renderFilter={false}
options={options.map((option) => ({ ...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)
}
Loading
Loading