From 93067fa297c5b5e804b364627c517f87f988e0ca Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 11 Sep 2026 12:38:51 +0700 Subject: [PATCH] feat(opencode): install a whole Skill Stack with codev skill stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Skill Stack is a public namespace on the hub — a team's curated skill set, served as one composite ZIP. `codev skill stack list` browses them and `codev skill stack pull ` installs every skill in one into the same .claude/skills + .agents/skills folders `skill pull` writes, with the same agent picker and one real copy plus links. The extract-and-link half of installResolvedSkill moves into a shared placeSkill(), so a stack reuses the single-skill conflict ordering, zip guards and Claude-symlink fallback rather than growing a second copy of them. The stack lifts each skill's subtree straight out of the composite archive instead of re-zipping it, and probes `claude --version` once for the whole bundle. A stack is N installs, not a transaction: a skill that fails is reported by name and the rest still land, a folder with no SKILL.md is listed as excluded, and the interactive flow says when the hub's advertised skill count exceeds what the archive actually carries (the hub keys bundle folders by skill name, so two same-named skills in a namespace collapse into one). Archive caps are parameterized: a stack gets its own ceiling (500 MB / 50k entries) while each skill inside it still gets the 100 MB / 15k one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P1MfYkJD3tQZRgqBKB4gig --- CHANGELOG.md | 16 + DIVERGENCES.md | 2 +- packages/codev-skillhub/src/archive.ts | 64 +++- packages/codev-skillhub/src/index.ts | 25 ++ packages/codev-skillhub/src/skill-install.ts | 64 +++- packages/codev-skillhub/src/stack-install.ts | 336 +++++++++++++++++ packages/codev-skillhub/src/stack-search.ts | 52 +++ packages/codev-skillhub/src/stack.ts | 171 +++++++++ .../codev-skillhub/test/stack-install.test.ts | 352 ++++++++++++++++++ .../codev-skillhub/test/stack-search.test.ts | 120 ++++++ packages/codev-skillhub/test/stack.test.ts | 180 +++++++++ packages/opencode/src/cli/cmd/skill.ts | 269 +++++++++++++ .../__snapshots__/help-snapshots.test.ts.snap | 64 ++++ .../test/cli/help/help-snapshots.test.ts | 3 + 14 files changed, 1687 insertions(+), 31 deletions(-) create mode 100644 packages/codev-skillhub/src/stack-install.ts create mode 100644 packages/codev-skillhub/src/stack-search.ts create mode 100644 packages/codev-skillhub/src/stack.ts create mode 100644 packages/codev-skillhub/test/stack-install.test.ts create mode 100644 packages/codev-skillhub/test/stack-search.test.ts create mode 100644 packages/codev-skillhub/test/stack.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f168171a..4008d81c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,6 +197,22 @@ key_refused`; against an older backend the previous behaviour stands. ### Skills +- **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 + the public stacks with their owner, skill and member counts, and the slug + to use; `stack pull ` downloads the bundle and installs every skill + in it into exactly the same `.claude/skills` + `.agents/skills` folders a + single `skill pull` writes — one real copy plus links for the other agents, + with the same agent picker and CoDev Code always included. Run it in a + terminal and it previews what the stack actually contains before asking + where to put it. When some of the skills are already installed it says + which, and asks whether to skip them or replace them, rather than guessing + — `--skip-existing` and `--overwrite` pick for scripts, which otherwise + stop and list the collisions. A stack is not all-or-nothing: a skill that + fails to install is reported by name and the rest still land, and a folder + in the bundle with no `SKILL.md` is listed as excluded rather than dropped + silently. - **Skill Hub in the desktop app** — a new Skill Hub entry in the sidebar (just above Settings) opens a native browser for the CoDev SkillHub: search, sort, and filter the registry by domain, read a skill's SKILL.md diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 213b1be42..f89ac6b66 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -107,7 +107,7 @@ from the squash message first, or use a plain merge. | File | Change | Why | On conflict | | --- | --- | --- | --- | | `packages/core/src/global.ts`, `packages/core/test/global.test.ts`, `packages/opencode/test/cli/mcp-add.test.ts` | `const app = "opencode"` → `"codev"`; test expectations `tmpdir()/opencode` → `tmpdir()/codev` and `.config/opencode/` → `.config/codev/` | Config/data/cache/state dirs under `~/.config/codev` etc.; upstream tests assert the literal dir name. | Keep ours (the one constant + expectations); take upstream's surrounding changes. | -| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/help-snapshots.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; registered the fork-only `ConfigureCommand` (cli/cmd/configure.ts, new file), `CodegraphCommand` (cli/cmd/codegraph.ts, new file), `UploadCommand` (cli/cmd/upload.ts, new file — `codev upload`, the standalone `codevhub upload`: exports sessions and ships them to the analysis backend, `--force` to re-upload unchanged, `--logs` to print the last run's diagnostic trail; its inline SSO prompt now lives in cli/cmd/sso-prompt.ts, new file, shared with `codev skill push`), and `SkillHubCommand` (cli/cmd/skill.ts, new file — `codev skill search|pull|push|office`, the standalone `codevhub skill`: searches/installs/publishes SkillHub skills and fetches the CoDev Office offline bundle, backed by the fork-only `packages/codev-skillhub` workspace package with SSO via `@codev/gateway`) and added `configure` + `codegraph` + `upload` + `skill` to the help-snapshot TOP_LEVEL list (and the four `skill` subcommands to SUBCOMMANDS); test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command; `codev configure` is the standalone gateway-setup entry point, and `codev codegraph` is the standalone CodeGraph MCP wiring for installs not managed by CoDev Hub. The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings, command registration + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | +| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/help-snapshots.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; registered the fork-only `ConfigureCommand` (cli/cmd/configure.ts, new file), `CodegraphCommand` (cli/cmd/codegraph.ts, new file), `UploadCommand` (cli/cmd/upload.ts, new file — `codev upload`, the standalone `codevhub upload`: exports sessions and ships them to the analysis backend, `--force` to re-upload unchanged, `--logs` to print the last run's diagnostic trail; its inline SSO prompt now lives in cli/cmd/sso-prompt.ts, new file, shared with `codev skill push`), and `SkillHubCommand` (cli/cmd/skill.ts, new file — `codev skill search|pull|push|stack|office`, the standalone `codevhub skill`: searches/installs/publishes SkillHub skills, installs a Skill Stack — a public hub namespace's whole skill set — as one bundle, and fetches the CoDev Office offline bundle, backed by the fork-only `packages/codev-skillhub` workspace package with SSO via `@codev/gateway`) and added `configure` + `codegraph` + `upload` + `skill` to the help-snapshot TOP_LEVEL list (and the `skill` subcommands — `search`, `pull`, `push`, `stack`, `stack list`, `stack pull`, `office` — to SUBCOMMANDS); test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command; `codev configure` is the standalone gateway-setup entry point, and `codev codegraph` is the standalone CodeGraph MCP wiring for installs not managed by CoDev Hub. The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings, command registration + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | | `package.json` (root) | `name` `opencode` → `codev-code`; `repository.url` → `github.com/quickbeard/codev-code`; added `release` script delegating to packages/opencode; added `bump` script pointing at the fork-only `script/bump-version.ts`; removed the `sso`, `dev:stats`, and `random` scripts; added the `lint-staged` devDependency and a `lint-staged` config block (`"*": "prettier --write --ignore-unknown"`) driving the fork-only `.husky/pre-commit` | The root manifest carries the fork's name (nothing reads it — root is private and unpublished; `packages/script` reads only `packageManager` — so this is cosmetic and also removes the name collision with the `packages/opencode` workspace member, whose name MUST stay `opencode`: build.ts derives bun compile targets and dist dir names from it). `bun run release` and `bun run bump` work from the repo root. `sso` logs into upstream's AWS org (`--sso-session=opencode`) and `dev:stats` runs inside an sst shell against upstream's production stage — neither works without anomalyco credentials; `random` is inert cruft (`echo 'Random script'`). `lint-staged` keeps staged files at Prettier's fixed point on commit, so the formatting drift PR #261 had to clean up cannot silently reaccumulate; `--ignore-unknown` skips files Prettier has no parser for (`bun.lock`, `.husky/*`, binaries) and `.prettierignore` still exempts `DIVERGENCES.md` and the docs merge base. | Keep our name + release/bump lines and the removals (drop any new upstream-infra-only scripts too); take upstream's other fields. | | `packages/opencode/package.json` | bin key `opencode` → `codev` (shim path `./bin/opencode` unchanged); added `release` script; added dependencies `"@codev/brand": "workspace:*"`, `"@codev/gateway": "workspace:*"` (the latter for the gateway-key migration's shared-auth-file read, see the config.ts migration row), and `"@codev/upload": "workspace:*"` (the session uploader — see the session-upload wiring row) | Installed command is `codev` — matches the CLI's `scriptName` and help text (the hub's command is `codevhub`; it relinquished the `codev` bin in codev-ai ≥ 0.4.0). `bun run release` publishes the version in this file (upstream-synced; script/release.ts is fork-only). | Keep our bin key, release line, and dependency lines; take upstream's other fields including `version`. | | Session-upload wiring: `packages/opencode/src/cli/cmd/tui.ts`, `packages/opencode/src/cli/cmd/run.ts`, `packages/opencode/src/cli/cmd/debug/index.ts` | tui.ts + run.ts: `spawnUploadDaemon()` (from the fork-only `@codev/upload` package) fire-and-forgotten at handler start — spawns a detached, unref'd, stdio-ignored `codev debug upload` child. debug/index.ts: registered the fork-only `UploadCommand` (debug/upload.ts, new file), the entry that child runs. | Standalone parity with the hub's launch-time upload daemon: every agent launch ships prior codev sessions to the analysis backend in the background. All gating (opt-out env `CODEV_DISABLE_AUTO_UPLOAD`, signed-in check, the hub-shared lockfile) lives inside `@codev/upload` — the wiring is one guarded call per entry point that can never block or fail the launch. | Keep the import + `spawnUploadDaemon()` line in both handlers and the debug registration; take upstream's surrounding changes. | diff --git a/packages/codev-skillhub/src/archive.ts b/packages/codev-skillhub/src/archive.ts index e7f759d21..ef20cdf87 100644 --- a/packages/codev-skillhub/src/archive.ts +++ b/packages/codev-skillhub/src/archive.ts @@ -8,6 +8,22 @@ import AdmZip from "adm-zip" const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 const MAX_ARCHIVE_ENTRIES = 15_000 +export interface ArchiveLimits { + maxBytes: number + maxEntries: number +} + +// One skill: the publish caps above, applied to reads as well so a hub download +// can't exceed what a publish could have produced. +export const SKILL_LIMITS: ArchiveLimits = { maxBytes: MAX_ARCHIVE_BYTES, maxEntries: MAX_ARCHIVE_ENTRIES } + +// A Skill Stack ZIP is every public skill in a namespace concatenated, so it +// needs a ceiling of its own. This is a real memory ceiling rather than a +// formality: the whole archive is held as one buffer and adm-zip inflates each +// entry fully into memory on top of it. SKILL_LIMITS still applies to each +// individual skill folder inside, so one skill can't spend the stack's budget. +export const STACK_LIMITS: ArchiveLimits = { maxBytes: 500 * 1024 * 1024, maxEntries: 50_000 } + // Directories never bundled into a skill archive: VCS metadata, dependency // trees, and local test/log output. Excluded wholesale (their contents are not // walked). @@ -34,17 +50,30 @@ function isIgnoredFile(name: string): boolean { // single entry fully into memory, so a crafted per-entry decompression bomb is // still bounded only by the compressed input size (see downloadSkill's cap) — // the cumulative check below is a backstop against many/large honest entries. -export async function extractZip(buffer: Buffer, targetDir: string): Promise { - await mkdir(targetDir, { recursive: true }) - const zip = new AdmZip(buffer) - const entries = zip.getEntries() - if (entries.length > MAX_ARCHIVE_ENTRIES) { - throw new Error(`Archive has too many files (limit ${MAX_ARCHIVE_ENTRIES}).`) +export async function extractZip(buffer: Buffer, targetDir: string, limits: ArchiveLimits = SKILL_LIMITS) { + return extractEntries(new AdmZip(buffer).getEntries(), targetDir, { limits }) +} + +// The body of extractZip, over entries the caller already holds. `strip` is a +// path prefix removed from each entry name before it becomes a destination — +// how one skill's subtree is lifted out of a Skill Stack archive and written +// into its own directory without re-zipping it first. +export async function extractEntries( + entries: AdmZip.IZipEntry[], + targetDir: string, + opts: { strip?: string; limits?: ArchiveLimits } = {}, +): Promise { + const limits = opts.limits ?? SKILL_LIMITS + if (entries.length > limits.maxEntries) { + throw new Error(`Archive has too many files (limit ${limits.maxEntries}).`) } + await mkdir(targetDir, { recursive: true }) const root = resolve(targetDir) let written = 0 for (const entry of entries) { - const entryName = entry.entryName + if (opts.strip !== undefined && !entry.entryName.startsWith(opts.strip)) continue + const entryName = opts.strip === undefined ? entry.entryName : entry.entryName.slice(opts.strip.length) + if (!entryName) continue if (isAbsolute(entryName)) { throw new Error(`unsafe zip entry: ${entryName}`) } @@ -59,8 +88,8 @@ export async function extractZip(buffer: Buffer, targetDir: string): Promise MAX_ARCHIVE_BYTES) { - throw new Error(`Archive exceeds the ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB limit.`) + if (written > limits.maxBytes) { + throw new Error(`Archive exceeds the ${limits.maxBytes / 1024 / 1024} MB limit.`) } await mkdir(dirname(dest), { recursive: true }) await writeFile(dest, data) @@ -183,12 +212,15 @@ export async function zipDirectory(dir: string, rootName: string): Promise MAX_ARCHIVE_BYTES) { - throw new Error(`Archive exceeds the ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB limit.`) + if (zipBuffer.length > limits.maxBytes) { + throw new Error(`Archive exceeds the ${limits.maxBytes / 1024 / 1024} MB limit.`) } const zip = new AdmZip(zipBuffer) const files: string[] = [] @@ -198,11 +230,11 @@ export function inspectZip(zipBuffer: Buffer): { files.push(e.entryName) totalBytes += e.header.size } - if (totalBytes > MAX_ARCHIVE_BYTES) { - throw new Error(`Archive exceeds the ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB limit.`) + if (totalBytes > limits.maxBytes) { + throw new Error(`Archive exceeds the ${limits.maxBytes / 1024 / 1024} MB limit.`) } - if (files.length > MAX_ARCHIVE_ENTRIES) { - throw new Error(`Archive has too many files (limit ${MAX_ARCHIVE_ENTRIES}).`) + if (files.length > limits.maxEntries) { + throw new Error(`Archive has too many files (limit ${limits.maxEntries}).`) } return { files, totalBytes } } diff --git a/packages/codev-skillhub/src/index.ts b/packages/codev-skillhub/src/index.ts index 277579be3..a7e8da02a 100644 --- a/packages/codev-skillhub/src/index.ts +++ b/packages/codev-skillhub/src/index.ts @@ -35,4 +35,29 @@ export { type HubSkillDetail, type HubSort, } from "./hub" +export { + browseStacks, + downloadStack, + findStack, + ownerLabel, + type StackBrowseParams, + type StackBrowseResult, + type StackCard, + type StackSkillPreview, +} from "./stack" +export { + formatStackResult, + installStack, + planStackInstall, + readStackArchive, + runStackPull, + type StackArchive, + type StackInstalled, + type StackInstallMode, + type StackInstallResult, + type StackPlan, + type StackSkillEntry, + type StackSkipped, +} from "./stack-install" +export { DEFAULT_STACK_LIMIT, runStackList } from "./stack-search" export { officeCrossFlagError, runSkillOffice, type OfficeArch, type OfficeArgs, type OfficePlatform } from "./office" diff --git a/packages/codev-skillhub/src/skill-install.ts b/packages/codev-skillhub/src/skill-install.ts index 164566ec8..e7c87b59b 100644 --- a/packages/codev-skillhub/src/skill-install.ts +++ b/packages/codev-skillhub/src/skill-install.ts @@ -96,10 +96,18 @@ function targetRoot(target: Exclude): string { return target.kind === "agents" ? scopeRoot(target.scope) : resolve(target.root) } +export interface PlacementPlan { + // The single real extraction directory. + store: string + // Paths that must end up pointing at `store`. + links: string[] + agents: SkillAgent[] +} + // Resolve where this install writes, and check every destination is safe BEFORE // any filesystem work — especially before the rm() below, so a hostile hub name // can never delete a directory outside the intended root. -function planPlacements(target: InstallTarget, name: string): { store: string; links: string[]; agents: SkillAgent[] } { +export function planPlacements(target: InstallTarget, name: string): PlacementPlan { if (target.kind === "dir") { return { store: safeSkillDir(target.rootDir, name), links: [], agents: [] } } @@ -130,6 +138,41 @@ export async function installResolvedSkill( inspectZip(zip) const { buffer, stripped } = detectAndStripRoot(zip) + const placed = await placeSkill({ + name: meta.name, + target: opts.target, + plan, + force: opts.force, + extract: (dir) => extractZip(buffer, dir), + }) + + return { + name: meta.name, + version: meta.version, + id: meta.id, + dir: placed.dir, + strippedRoot: stripped, + placements: placed.placements, + } +} + +// Write one planned skill to disk: clear its destinations, extract the single +// real copy into `store`, then point every other agent directory at it. Shared +// by the single-skill install and by each skill of a stack — the conflict/rm +// ordering and the Claude-version fallback are subtle enough that a second copy +// of them would drift. `extract` is how the bytes arrive, so a stack can lift a +// subtree straight out of its composite archive instead of re-zipping it. +export async function placeSkill(opts: { + name: string + target: InstallTarget + plan: PlacementPlan + force: boolean + extract: (dir: string) => Promise + // Precomputed answer to claudeFollowsSymlinks(). A stack passes it so one + // `claude --version` covers every skill in it rather than one probe per link. + claudeSymlinks?: boolean +}): Promise<{ dir: string; placements: InstallPlacement[] }> { + const { plan } = opts // Every destination is checked before anything is removed, so a refusal on // the second one can't leave the first already deleted. for (const dir of [plan.store, ...plan.links]) { @@ -143,13 +186,13 @@ export async function installResolvedSkill( } await mkdir(plan.store, { recursive: true }) - await extractZip(buffer, plan.store) + await opts.extract(plan.store) const placements: InstallPlacement[] = [ { path: plan.store, mode: "store", - agents: agentsFor(plan.store, opts.target, meta.name), + agents: agentsFor(plan.store, opts.target, opts.name), }, ] for (const link of plan.links) { @@ -158,24 +201,17 @@ export async function installResolvedSkill( // to be a real copy. const mustCopy = opts.target.kind !== "dir" && - isClaudeLinkAt(targetRoot(opts.target), link, meta.name) && - !(await claudeFollowsSymlinks()) + isClaudeLinkAt(targetRoot(opts.target), link, opts.name) && + !(opts.claudeSymlinks ?? (await claudeFollowsSymlinks())) const mode = mustCopy ? await copyInto(plan.store, link) : await linkOrCopy(plan.store, link) placements.push({ path: link, mode, - agents: agentsFor(link, opts.target, meta.name), + agents: agentsFor(link, opts.target, opts.name), }) } - return { - name: meta.name, - version: meta.version, - id: meta.id, - dir: plan.store, - strippedRoot: stripped, - placements, - } + return { dir: plan.store, placements } } function agentsFor(path: string, target: InstallTarget, name: string): SkillAgent[] { diff --git a/packages/codev-skillhub/src/stack-install.ts b/packages/codev-skillhub/src/stack-install.ts new file mode 100644 index 000000000..4b91cac7a --- /dev/null +++ b/packages/codev-skillhub/src/stack-install.ts @@ -0,0 +1,336 @@ +import { resolve } from "node:path" +import AdmZip from "adm-zip" +import { defaultAgents } from "./agents" +import { extractEntries, inspectZip, pathExists, SKILL_LIMITS, STACK_LIMITS } from "./archive" +import { stripControlChars } from "./sanitize" +import { claudeFollowsSymlinks, safeSegment, type Scope, type SkillAgent } from "./skill-dirs" +import { + placeSkill, + planPlacements, + type InstallPlacement, + type InstallTarget, + type PlacementPlan, +} from "./skill-install" +import { downloadStack } from "./stack" + +// Registry download behind an object so tests can stub it without module mocks +// (Bun spyOn on ESM namespaces is unreliable) — the package's own idiom, see +// skill-install.ts#skillhubApi. +export const stackApi = { + downloadStack, +} + +// Installing a Skill Stack: one composite ZIP from the hub, N skills onto disk. +// +// The hub composes the archive itself (skillhub src/services/namespace.service.ts +// #downloadAllSkills) as a single root folder named after the namespace slug: +// +// /README.md generated manifest, not a skill +// //... one folder per PUBLIC skill, its own zip root +// already stripped server-side +// +// Every skill is placed through skill-install.ts#placeSkill, so a stack lands in +// exactly the same agent directories, with the same linking and the same safety +// checks, as `skill pull` — the only thing that differs is where the bytes come +// from. + +// A folder the archive carried that will not be installed, and why. Reported +// rather than dropped: a stack that quietly installs fewer skills than it +// advertises is worse than one that says what it left out. +export interface StackSkipped { + name: string + reason: string +} + +export interface StackSkillEntry { + name: string + // This skill's entry-name prefix inside the archive, e.g. "data-eng/pg-tuner/". + prefix: string + files: number + bytes: number + entries: AdmZip.IZipEntry[] +} + +export interface StackArchive { + slug: string + skills: StackSkillEntry[] + skipped: StackSkipped[] + totalBytes: number + // The hub's generated README.md. Never installed — it documents the download, + // not a skill — but worth surfacing. + manifest?: string +} + +// Parse a downloaded stack ZIP without writing anything. Throws only when the +// archive as a whole is unusable; an individual bad folder is reported in +// `skipped` so one malformed skill can't cost the user the other nineteen. +export function readStackArchive(buffer: Buffer, expectedSlug?: string): StackArchive { + // Vet size/entry counts against the stack ceiling before inflating anything. + inspectZip(buffer, STACK_LIMITS) + const entries = new AdmZip(buffer).getEntries() + + const roots = new Set(entries.map((entry) => entry.entryName.split("/")[0]).filter((segment) => segment)) + if (roots.size !== 1) { + throw new Error("Unexpected stack archive layout: expected a single top-level folder.") + } + const root = [...roots][0] as string + const prefix = `${root}/` + + const grouped = new Map() + let manifest: string | undefined + for (const entry of entries) { + if (entry.isDirectory) continue + if (!entry.entryName.startsWith(prefix)) continue + const relative = entry.entryName.slice(prefix.length) + if (!relative) continue + const slash = relative.indexOf("/") + if (slash === -1) { + // A file directly under the root — the hub only puts its README there. + if (relative.toLowerCase() === "readme.md") manifest = entry.getData().toString("utf-8") + continue + } + const name = relative.slice(0, slash) + const bucket = grouped.get(name) + if (bucket) bucket.push(entry) + else grouped.set(name, [entry]) + } + + const skills: StackSkillEntry[] = [] + const skipped: StackSkipped[] = [] + for (const [name, group] of [...grouped].sort((a, b) => a[0].localeCompare(b[0]))) { + // The folder name becomes a path segment under the skills directory, so it + // gets the same guard `skill pull` applies to a hub-supplied skill name. + if (!safeSegment(name)) { + skipped.push({ name: stripControlChars(name), reason: "unsafe folder name" }) + continue + } + const skillPrefix = `${prefix}${name}/` + // Without a SKILL.md the agents' `skills/**/SKILL.md` discovery never sees + // the folder, so installing it would create a directory nothing reads. + const hasSkillMd = group.some((entry) => { + const inner = entry.entryName.slice(skillPrefix.length) + return inner === "SKILL.md" || inner.endsWith("/SKILL.md") + }) + if (!hasSkillMd) { + skipped.push({ name, reason: "no SKILL.md" }) + continue + } + skills.push({ + name, + prefix: skillPrefix, + files: group.length, + bytes: group.reduce((sum, entry) => sum + entry.header.size, 0), + entries: group, + }) + } + + if (skills.length === 0) { + throw new Error("This stack contains no installable skills.") + } + + return { + slug: expectedSlug ?? root, + skills, + skipped, + totalBytes: skills.reduce((sum, skill) => sum + skill.bytes, 0), + manifest, + } +} + +export interface StackPlanEntry { + name: string + plan: PlacementPlan + // Everything this skill would write: the real copy plus any agent links. + destinations: string[] + // True when any destination already exists. + conflict: boolean +} + +export interface StackPlan { + slug: string + entries: StackPlanEntry[] + // Skill names already on disk. What the caller shows before asking the user + // whether to overwrite them or install around them. + conflicts: string[] + fresh: string[] +} + +// Work out where every skill in the stack would land and which of them are +// already installed. Pure planning — nothing is created, removed, or downloaded. +export async function planStackInstall(archive: StackArchive, target: InstallTarget): Promise { + const entries: StackPlanEntry[] = [] + for (const skill of archive.skills) { + // Safe by construction: readStackArchive already rejected any name that + // isn't a single path segment, which is what planPlacements guards. + const plan = planPlacements(target, skill.name) + const destinations = [plan.store, ...plan.links] + const existing = await Promise.all(destinations.map(pathExists)) + entries.push({ + name: skill.name, + plan, + destinations, + conflict: existing.some((found) => found), + }) + } + return { + slug: archive.slug, + entries, + conflicts: entries.filter((entry) => entry.conflict).map((entry) => entry.name), + fresh: entries.filter((entry) => !entry.conflict).map((entry) => entry.name), + } +} + +// What to do about skills the plan found already installed. +export type StackInstallMode = "skip-existing" | "overwrite" + +export interface StackInstalled { + name: string + dir: string + placements: InstallPlacement[] +} + +export interface StackInstallResult { + slug: string + mode: StackInstallMode + installed: StackInstalled[] + skipped: StackSkipped[] + failed: { name: string; message: string }[] +} + +// Install every skill in the stack. Failures are per-skill: a broken or +// unwritable skill is recorded and the rest still land, because a stack is a +// convenience over N independent installs, not a transaction. +export async function installStack( + archive: StackArchive, + opts: { target: InstallTarget; mode: StackInstallMode; plan?: StackPlan }, +): Promise { + const plan = opts.plan ?? (await planStackInstall(archive, opts.target)) + const byName = new Map(archive.skills.map((skill) => [skill.name, skill])) + + // Links exist only when Claude Code is among the selected agents, so this + // probes `claude --version` at most once for the whole stack instead of once + // per skill. + const claudeSymlinks = plan.entries.some((entry) => entry.plan.links.length > 0) + ? await claudeFollowsSymlinks() + : undefined + + const installed: StackInstalled[] = [] + const skipped = [...archive.skipped] + const failed: { name: string; message: string }[] = [] + + for (const entry of plan.entries) { + const skill = byName.get(entry.name) + if (!skill) continue + if (entry.conflict && opts.mode === "skip-existing") { + skipped.push({ name: entry.name, reason: "already installed" }) + continue + } + const outcome = await placeSkill({ + name: entry.name, + target: opts.target, + plan: entry.plan, + force: opts.mode === "overwrite", + claudeSymlinks, + extract: (dir) => extractEntries(skill.entries, dir, { strip: skill.prefix, limits: SKILL_LIMITS }), + }).then( + (placed) => ({ ok: true as const, placed }), + (error: unknown) => ({ ok: false as const, message: error instanceof Error ? error.message : String(error) }), + ) + if (!outcome.ok) { + failed.push({ name: entry.name, message: outcome.message }) + continue + } + installed.push({ name: entry.name, dir: outcome.placed.dir, placements: outcome.placed.placements }) + } + + return { slug: archive.slug, mode: opts.mode, installed, skipped, failed } +} + +export function formatStackResult(result: StackInstallResult, json: boolean): string { + if (json) { + return JSON.stringify({ + ok: result.failed.length === 0, + slug: result.slug, + mode: result.mode, + installed: result.installed, + skipped: result.skipped, + failed: result.failed, + }) + } + // Stack and skill names are hub-sourced; paths are local and already + // validated. JSON output above needs no scrubbing — stringify escapes control + // characters. + const lines = [ + `Installed ${result.installed.length} skill(s) from ${stripControlChars(result.slug)}:`, + ...result.installed.map((skill) => ` ${stripControlChars(skill.name)} -> ${skill.dir}`), + ] + if (result.skipped.length > 0) { + lines.push("Skipped:") + for (const item of result.skipped) { + lines.push(` ${stripControlChars(item.name)} (${item.reason})`) + } + } + if (result.failed.length > 0) { + lines.push("Failed:") + for (const item of result.failed) { + lines.push(` ${stripControlChars(item.name)}: ${stripControlChars(item.message)}`) + } + } + return lines.join("\n") +} + +// Non-interactive path (a location flag given, or piped/CI). The interactive +// prompt lives in the CLI command; here the location MUST be explicit, and so +// must the conflict mode once anything collides — neither is silently chosen. +// The agent set does default though (defaultAgents()), as it does for +// `skill pull`: CI should not have to spell out --agent. +export async function runStackPull(opts: { + slug: string + dir?: string + location?: Scope + agents?: SkillAgent[] + mode?: StackInstallMode + json: boolean +}): Promise { + const target: InstallTarget | null = + opts.dir !== undefined + ? { kind: "dir", rootDir: resolve(opts.dir) } + : opts.location !== undefined + ? { + kind: "agents", + agents: opts.agents ?? defaultAgents(), + scope: opts.location, + } + : null + if (target === null) { + console.error("Not a terminal — pass --here, --global, or --dir to choose an install location.") + return 1 + } + + const outcome = await pullStack(opts.slug, target, opts.mode).then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, message: error instanceof Error ? error.message : String(error) }), + ) + if (!outcome.ok) { + console.error(outcome.message) + return 1 + } + console.log(formatStackResult(outcome.value, opts.json)) + // A stack is not a transaction, so a partial install is still reported in + // full — but it must not look like success to a script. + return outcome.value.failed.length > 0 ? 1 : 0 +} + +// Download, plan, and install in one pass. Refuses rather than guesses when the +// stack collides with skills already on disk and the caller named no mode. +async function pullStack(slug: string, target: InstallTarget, mode?: StackInstallMode) { + const archive = readStackArchive(await stackApi.downloadStack(slug), slug) + const plan = await planStackInstall(archive, target) + if (plan.conflicts.length > 0 && mode === undefined) { + throw new Error( + `Already installed: ${plan.conflicts.join(", ")}. ` + + "Pass --overwrite to replace them, or --skip-existing to install the rest.", + ) + } + return installStack(archive, { target, mode: mode ?? "skip-existing", plan }) +} diff --git a/packages/codev-skillhub/src/stack-search.ts b/packages/codev-skillhub/src/stack-search.ts new file mode 100644 index 000000000..fb1b191a3 --- /dev/null +++ b/packages/codev-skillhub/src/stack-search.ts @@ -0,0 +1,52 @@ +import { stripControlChars } from "./sanitize" +import { browseStacks, ownerLabel, type StackCard } from "./stack" + +export const DEFAULT_STACK_LIMIT = 20 +const DESCRIPTION_MAX = 90 + +// `codev skill stack list [query] [--json] [--limit ]`. Plain console output +// so results pipe cleanly and `--json` is script-friendly. Arg parsing lives in +// the CLI's yargs layer; this runner takes the parsed values and returns the +// exit code. +export async function runStackList(opts: { query?: string; limit?: number; json: boolean }): Promise { + const result = await browseStacks({ + search: opts.query, + limit: opts.limit ?? DEFAULT_STACK_LIMIT, + }).then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ) + if (!result.ok) { + console.error(result.error instanceof Error ? result.error.message : String(result.error)) + return 1 + } + + if (opts.json) { + console.log(JSON.stringify({ ok: true, total: result.value.pagination.total, items: result.value.items })) + return 0 + } + if (result.value.items.length === 0) { + console.log(opts.query ? `No stacks match "${opts.query}".` : "No public stacks yet.") + return 0 + } + console.log(formatStacks(result.value.pagination.total, result.value.items)) + return 0 +} + +function formatStacks(total: number, items: StackCard[]): string { + const lines = [`Found ${total} stack(s) — showing ${items.length}:`, ""] + for (const stack of items) { + // Hub-sourced fields are sanitized before printing (see stripControlChars). + const name = stripControlChars(stack.name) + const counts = `${stack.skillCount} skill(s), ${stack.memberCount} member(s)` + lines.push(` ${name} by ${stripControlChars(ownerLabel(stack.owner))} — ${counts}`) + const description = stripControlChars(stack.description) + const trimmed = + description.length > DESCRIPTION_MAX ? `${description.slice(0, DESCRIPTION_MAX - 3)}...` : description + if (trimmed) lines.push(` ${trimmed}`) + // The slug, not the id: it is what `skill stack pull` takes. + lines.push(` slug: ${stripControlChars(stack.slug)}`) + lines.push("") + } + return lines.join("\n").trimEnd() +} diff --git a/packages/codev-skillhub/src/stack.ts b/packages/codev-skillhub/src/stack.ts new file mode 100644 index 000000000..e829f3c62 --- /dev/null +++ b/packages/codev-skillhub/src/stack.ts @@ -0,0 +1,171 @@ +import { skillhubFetch } from "./skillhub" + +// Skill Stacks are the hub's public namespaces: a curated set of skills that +// installs as one unit. Shapes mirror what /api/v1/explore/namespaces and +// /api/v1/namespaces//download-all actually return (skillhub +// src/services/namespace.service.ts#explorePublicNamespaces / #downloadAllSkills); +// neither endpoint is schema'd in the hub's openapi.json, so every field is +// coerced and the types are total. + +// A stack ZIP is every public skill in the namespace at once — up to +// STACK_LIMITS, five times one skill's cap — so it needs far more headroom than +// the JSON handshakes skillhub.ts caps at 15s. +const STACK_DOWNLOAD_TIMEOUT_MS = 300_000 + +export interface StackSkillPreview { + id: string + name: string + description: string + version: string + stars: number + downloadCount: number +} + +export interface StackCard { + id: string + slug: string + name: string + description: string + owner: { username?: string; firstName?: string; lastName?: string; avatarUrl?: string | null } + memberCount: number + // Every PUBLIC skill in the namespace — which is more than `skills` holds. + skillCount: number + updatedAt: string | null + // The listing returns at most six skills per stack whatever skillCount says + // (`take: 6` server-side), so this is a preview, not the contents. + skills: StackSkillPreview[] + previewTruncated: boolean +} + +export interface StackBrowseParams { + search?: string + page?: number + // Server clamps to 1-50 (default 20). + limit?: number +} + +export interface StackBrowseResult { + items: StackCard[] + pagination: { page: number; limit: number; total: number; totalPages: number } + // Public stacks on the hub overall, ignoring the search filter. + totalStacks: number +} + +// Browse the public stacks. Anonymous works — the listing is public — and a +// stored session rides along when present, like browseHubSkills. +export async function browseStacks(params: StackBrowseParams = {}): Promise { + const query = new URLSearchParams() + if (params.search) query.set("search", params.search) + if (params.page) query.set("page", String(params.page)) + if (params.limit) query.set("limit", String(params.limit)) + const qs = query.toString() + + const res = await skillhubFetch(`/api/v1/explore/namespaces${qs ? `?${qs}` : ""}`, { + label: "skillhub.stack-browse", + optionalAuth: true, + }) + if (!res.ok) { + throw new Error(`Stack search failed (${res.status}).`) + } + const body = (await res.json().catch(() => ({}))) as { + success?: boolean + data?: unknown[] + pagination?: { page?: number; limit?: number; total?: number; totalPages?: number } + stats?: { totalStacks?: number } + } + if (!body.success || !body.data) { + throw new Error("SkillHub returned an unexpected response.") + } + const items = body.data.map(toStack) + return { + items, + pagination: { + page: body.pagination?.page ?? params.page ?? 1, + limit: body.pagination?.limit ?? params.limit ?? items.length, + total: body.pagination?.total ?? items.length, + totalPages: body.pagination?.totalPages ?? 1, + }, + totalStacks: asNumber(body.stats?.totalStacks), + } +} + +// Resolve one stack by slug so a pull can name it before downloading. There is +// no public per-slug endpoint — GET /api/v1/namespaces/ requires +// membership — so this searches the public listing instead. Returns null rather +// than throwing when nothing matches: a private stack the caller is a member of +// is absent from the listing but still downloads, so a miss must not abort. +export async function findStack(slug: string): Promise { + const result = await browseStacks({ search: slug, limit: 50 }).catch(() => null) + return result?.items.find((stack) => stack.slug === slug) ?? null +} + +// Download a stack's composite ZIP. Public when the namespace is public; +// otherwise the server allows members, owners, and site admins, which the +// stored session covers when there is one. +export async function downloadStack(slug: string): Promise { + const res = await skillhubFetch(`/api/v1/namespaces/${encodeURIComponent(slug)}/download-all`, { + label: "skillhub.stack-download", + optionalAuth: true, + signal: AbortSignal.timeout(STACK_DOWNLOAD_TIMEOUT_MS), + }) + if (res.status === 403) { + throw new Error(`Stack "${slug}" is private. Sign in with an account that can see it and re-run.`) + } + if (res.status === 404) { + throw new Error(`Stack "${slug}" not found, or it has no public skills.`) + } + if (!res.ok) { + throw new Error(`Stack download failed (${res.status}).`) + } + return Buffer.from(await res.arrayBuffer()) +} + +function toStack(raw: unknown): StackCard { + const data = (raw ?? {}) as Record + const owner = (data.owner ?? {}) as Record + const skills = Array.isArray(data.skills) ? data.skills.map(toSkillPreview) : [] + const skillCount = asNumber(data.skillCount) + return { + id: asString(data.id) ?? "", + slug: asString(data.slug) ?? "", + name: asString(data.name) ?? "", + description: asString(data.description) ?? "", + owner: { + username: asString(owner.username), + firstName: asString(owner.firstName), + lastName: asString(owner.lastName), + avatarUrl: asString(owner.avatarUrl) ?? null, + }, + memberCount: asNumber(data.memberCount), + skillCount, + updatedAt: asString(data.updatedAt) ?? null, + skills, + previewTruncated: skillCount > skills.length, + } +} + +function toSkillPreview(raw: unknown): StackSkillPreview { + const data = (raw ?? {}) as Record + return { + id: asString(data.id) ?? "", + name: asString(data.name) ?? "", + description: asString(data.description) ?? "", + version: asString(data.version) ?? "", + stars: asNumber(data.stars), + downloadCount: asNumber(data.downloadCount), + } +} + +// The owner's display name the way the hub's own Skill Stack page builds it. +export function ownerLabel(owner: StackCard["owner"]): string { + const full = [owner.firstName, owner.lastName].filter((part) => part).join(" ") + return full || owner.username || "unknown" +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +function asNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0 +} diff --git a/packages/codev-skillhub/test/stack-install.test.ts b/packages/codev-skillhub/test/stack-install.test.ts new file mode 100644 index 000000000..8ca43162a --- /dev/null +++ b/packages/codev-skillhub/test/stack-install.test.ts @@ -0,0 +1,352 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import AdmZip from "adm-zip" +import { execApi } from "../src/exec" +import { + installStack, + planStackInstall, + readStackArchive, + runStackPull, + stackApi, + type StackArchive, +} from "../src/stack-install" + +let tempDir: string +let tempHome: string +let originalTestHome: string | undefined + +const SLUG = "data-eng" + +// A composite archive shaped like the hub's downloadAllSkills output: one root +// folder named after the slug, a generated README beside one folder per skill. +function stackZip(skills: Record>, opts: { readme?: string; slug?: string } = {}) { + const slug = opts.slug ?? SLUG + const zip = new AdmZip() + if (opts.readme !== undefined) zip.addFile(`${slug}/README.md`, Buffer.from(opts.readme)) + for (const [name, files] of Object.entries(skills)) { + for (const [path, content] of Object.entries(files)) { + zip.addFile(`${slug}/${name}/${path}`, Buffer.from(content)) + } + } + return zip.toBuffer() +} + +const TWO_SKILLS = { + "pg-tuner": { "SKILL.md": "# pg-tuner", "scripts/run.sh": "echo hi" }, + "dbt-helper": { "SKILL.md": "# dbt-helper" }, +} + +// One healthy skill beside one whose payload escapes its own folder. AdmZip +// sanitizes `../` on addFile, so the malicious name is set on the entry before +// serializing (same technique as archive.test.ts). +function slipZip(): Buffer { + const zip = new AdmZip() + zip.addFile(`${SLUG}/dbt-helper/SKILL.md`, Buffer.from("# dbt-helper")) + zip.addFile(`${SLUG}/evil/SKILL.md`, Buffer.from("# evil")) + zip.addFile(`${SLUG}/evil/placeholder.txt`, Buffer.from("pwned")) + const entry = zip.getEntries().find((e) => e.entryName.endsWith("placeholder.txt")) + if (!entry) throw new Error("fixture setup failed") + entry.entryName = `${SLUG}/evil/../../../escaped.txt` + return zip.toBuffer() +} + +function archive(skills: Record> = TWO_SKILLS, opts?: { readme?: string }) { + return readStackArchive(stackZip(skills, opts), SLUG) +} + +function targetAt(root: string, agents: ("claude" | "codex" | "opencode" | "codev")[] = ["codev"]) { + return { kind: "agents-at" as const, agents, root } +} + +function captureLog() { + const out: string[] = [] + spyOn(console, "log").mockImplementation((m?: unknown) => { + out.push(String(m)) + }) + return out +} +function captureErr() { + const out: string[] = [] + spyOn(console, "error").mockImplementation((m?: unknown) => { + out.push(String(m)) + }) + return out +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-stack-install-")) + tempHome = mkdtempSync(join(tmpdir(), "codev-stack-install-home-")) + originalTestHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempHome + // Pin `claude --version` above the symlink floor so these tests never spawn a + // real process and don't change behavior on a machine without Claude Code. + spyOn(execApi, "execAsync").mockResolvedValue({ error: null, stdout: "2.1.220 (Claude Code)", stderr: "" }) +}) +afterEach(() => { + mock.restore() + if (originalTestHome === undefined) delete process.env.CODEV_TEST_HOME + if (originalTestHome !== undefined) process.env.CODEV_TEST_HOME = originalTestHome + rmSync(tempDir, { recursive: true, force: true }) + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe("readStackArchive", () => { + test("groups the root folder's children into skills", () => { + const result = archive() + expect(result.slug).toBe(SLUG) + expect(result.skills.map((s) => s.name)).toEqual(["dbt-helper", "pg-tuner"]) + expect(result.skills.find((s) => s.name === "pg-tuner")?.files).toBe(2) + expect(result.totalBytes).toBeGreaterThan(0) + }) + + test("lifts the generated README out as the manifest instead of installing it", () => { + const result = archive(TWO_SKILLS, { readme: "# Data Engineering — Skill Stack" }) + expect(result.manifest).toBe("# Data Engineering — Skill Stack") + expect(result.skills.map((s) => s.name)).not.toContain("README.md") + }) + + test("skips a folder with no SKILL.md — the agents would never discover it", () => { + const result = archive({ ...TWO_SKILLS, notes: { "readme.txt": "hi" } }) + expect(result.skills.map((s) => s.name)).not.toContain("notes") + expect(result.skipped).toContainEqual({ name: "notes", reason: "no SKILL.md" }) + }) + + test("accepts a SKILL.md nested below the skill root", () => { + const result = archive({ deep: { "inner/SKILL.md": "# deep" } }) + expect(result.skills.map((s) => s.name)).toEqual(["deep"]) + }) + + test("skips an unsafe folder name rather than letting it become a path segment", () => { + const result = archive({ ...TWO_SKILLS, ".evil": { "SKILL.md": "# evil" } }) + expect(result.skills.map((s) => s.name)).not.toContain(".evil") + expect(result.skipped).toContainEqual({ name: ".evil", reason: "unsafe folder name" }) + }) + + test("refuses an archive without a single top-level folder", () => { + const zip = new AdmZip() + zip.addFile("a/SKILL.md", Buffer.from("# a")) + zip.addFile("b/SKILL.md", Buffer.from("# b")) + expect(() => readStackArchive(zip.toBuffer())).toThrow("Unexpected stack archive layout") + }) + + test("refuses an archive with nothing installable in it", () => { + expect(() => archive({ notes: { "readme.txt": "hi" } })).toThrow("no installable skills") + }) + + test("falls back to the archive's own root when no slug is given", () => { + expect(readStackArchive(stackZip(TWO_SKILLS)).slug).toBe(SLUG) + }) +}) + +describe("planStackInstall", () => { + test("splits the stack into fresh and already-installed skills without writing", async () => { + mkdirSync(join(tempDir, ".claude", "skills", "pg-tuner"), { recursive: true }) + const plan = await planStackInstall(archive(), targetAt(tempDir)) + expect(plan.conflicts).toEqual(["pg-tuner"]) + expect(plan.fresh).toEqual(["dbt-helper"]) + // Planning is pure: the fresh skill's directory must not exist yet. + expect(existsSync(join(tempDir, ".claude", "skills", "dbt-helper"))).toBe(false) + }) + + test("lists every destination a skill would write, across agent directories", async () => { + const plan = await planStackInstall(archive(), targetAt(tempDir, ["claude", "codex", "codev"])) + const entry = plan.entries.find((e) => e.name === "pg-tuner") + expect(entry?.destinations).toEqual([ + join(tempDir, ".agents", "skills", "pg-tuner"), + join(tempDir, ".claude", "skills", "pg-tuner"), + ]) + }) +}) + +describe("installStack", () => { + test("extracts every skill into the agent directory, root folder stripped", async () => { + const result = await installStack(archive(), { target: targetAt(tempDir), mode: "skip-existing" }) + expect(result.installed.map((s) => s.name).toSorted()).toEqual(["dbt-helper", "pg-tuner"]) + const dir = join(tempDir, ".claude", "skills", "pg-tuner") + expect(readFileSync(join(dir, "SKILL.md"), "utf-8")).toBe("# pg-tuner") + expect(readFileSync(join(dir, "scripts", "run.sh"), "utf-8")).toBe("echo hi") + }) + + test("skip-existing leaves an installed skill's contents alone and installs the rest", async () => { + const existing = join(tempDir, ".claude", "skills", "pg-tuner") + mkdirSync(existing, { recursive: true }) + writeFileSync(join(existing, "SKILL.md"), "# local edits") + + const result = await installStack(archive(), { target: targetAt(tempDir), mode: "skip-existing" }) + expect(result.installed.map((s) => s.name)).toEqual(["dbt-helper"]) + expect(result.skipped).toContainEqual({ name: "pg-tuner", reason: "already installed" }) + expect(readFileSync(join(existing, "SKILL.md"), "utf-8")).toBe("# local edits") + }) + + test("overwrite replaces an installed skill", async () => { + const existing = join(tempDir, ".claude", "skills", "pg-tuner") + mkdirSync(existing, { recursive: true }) + writeFileSync(join(existing, "SKILL.md"), "# local edits") + writeFileSync(join(existing, "stale.txt"), "gone") + + const result = await installStack(archive(), { target: targetAt(tempDir), mode: "overwrite" }) + expect(result.installed.map((s) => s.name).toSorted()).toEqual(["dbt-helper", "pg-tuner"]) + expect(readFileSync(join(existing, "SKILL.md"), "utf-8")).toBe("# pg-tuner") + expect(existsSync(join(existing, "stale.txt"))).toBe(false) + }) + + test("links the other agent directories to the one real copy", async () => { + const result = await installStack(archive(), { + target: targetAt(tempDir, ["claude", "codex", "codev"]), + mode: "skip-existing", + }) + const entry = result.installed.find((s) => s.name === "pg-tuner") + expect(entry?.placements.map((p) => p.mode)).toEqual(["store", "symlink"]) + expect(lstatSync(join(tempDir, ".claude", "skills", "pg-tuner")).isSymbolicLink()).toBe(true) + }) + + test("probes `claude --version` once for the whole stack, not once per skill", async () => { + const exec = spyOn(execApi, "execAsync").mockResolvedValue({ + error: null, + stdout: "2.1.220 (Claude Code)", + stderr: "", + }) + await installStack(archive(), { target: targetAt(tempDir, ["claude", "codex", "codev"]), mode: "skip-existing" }) + expect(exec).toHaveBeenCalledTimes(1) + }) + + test("never probes at all when Claude Code is not among the selected agents", async () => { + const exec = spyOn(execApi, "execAsync") + await installStack(archive(), { target: targetAt(tempDir, ["codev"]), mode: "skip-existing" }) + expect(exec).not.toHaveBeenCalled() + }) + + test("isolates a failing skill: the zip-slip guard still applies inside a stack", async () => { + const result = await installStack(readStackArchive(slipZip(), SLUG), { + target: targetAt(tempDir), + mode: "skip-existing", + }) + expect(result.failed.map((f) => f.name)).toEqual(["evil"]) + expect(result.failed[0]?.message).toMatch(/unsafe zip entry/) + // The other skill still landed — a stack is N installs, not a transaction. + expect(result.installed.map((s) => s.name)).toEqual(["dbt-helper"]) + expect(existsSync(join(tempDir, "escaped.txt"))).toBe(false) + }) + + test("applies the per-skill size ceiling inside a stack, not just the stack one", async () => { + const zip = new AdmZip() + zip.addFile(`${SLUG}/dbt-helper/SKILL.md`, Buffer.from("# dbt-helper")) + zip.addFile(`${SLUG}/big/SKILL.md`, Buffer.from("# big")) + // Over SKILL_LIMITS (15k entries) but inside STACK_LIMITS (50k), so the + // archive parses and only this one skill is refused at extraction. + for (let i = 0; i < 16_000; i++) zip.addFile(`${SLUG}/big/f${i}.txt`, Buffer.from("x")) + + const result = await installStack(readStackArchive(zip.toBuffer(), SLUG), { + target: targetAt(tempDir), + mode: "skip-existing", + }) + expect(result.failed.map((f) => f.name)).toEqual(["big"]) + expect(result.failed[0]?.message).toMatch(/too many files/) + expect(result.installed.map((s) => s.name)).toEqual(["dbt-helper"]) + }) + + test("carries the archive's own skipped folders into the result", async () => { + const result = await installStack(archive({ ...TWO_SKILLS, notes: { "readme.txt": "hi" } }), { + target: targetAt(tempDir), + mode: "skip-existing", + }) + expect(result.skipped).toContainEqual({ name: "notes", reason: "no SKILL.md" }) + }) +}) + +describe("runStackPull", () => { + function mockDownload(buffer: Buffer = stackZip(TWO_SKILLS)) { + return spyOn(stackApi, "downloadStack").mockResolvedValue(buffer) + } + + test("refuses without an explicit location", async () => { + const download = mockDownload() + const errs = captureErr() + expect(await runStackPull({ slug: SLUG, json: false })).toBe(1) + expect(errs.join("\n")).toMatch(/pass --here, --global, or --dir/i) + expect(download).not.toHaveBeenCalled() + }) + + test("installs into an explicit directory", async () => { + mockDownload() + const out = captureLog() + expect(await runStackPull({ slug: SLUG, dir: tempDir, json: false })).toBe(0) + expect(existsSync(join(tempDir, "pg-tuner", "SKILL.md"))).toBe(true) + expect(out.join("\n")).toMatch(/Installed 2 skill\(s\) from data-eng/) + }) + + test("installs globally under the home directory", async () => { + mockDownload() + captureLog() + expect(await runStackPull({ slug: SLUG, location: "global", agents: ["codev"], json: false })).toBe(0) + expect(existsSync(join(tempHome, ".claude", "skills", "dbt-helper", "SKILL.md"))).toBe(true) + }) + + test("refuses to guess when skills collide and no mode was named", async () => { + mockDownload() + mkdirSync(join(tempHome, ".claude", "skills", "pg-tuner"), { recursive: true }) + const errs = captureErr() + expect(await runStackPull({ slug: SLUG, location: "global", agents: ["codev"], json: false })).toBe(1) + expect(errs.join("\n")).toMatch(/Already installed: pg-tuner/) + expect(errs.join("\n")).toMatch(/--overwrite|--skip-existing/) + }) + + test("--skip-existing installs around the collision", async () => { + mockDownload() + const existing = join(tempHome, ".claude", "skills", "pg-tuner") + mkdirSync(existing, { recursive: true }) + writeFileSync(join(existing, "SKILL.md"), "# local edits") + captureLog() + expect( + await runStackPull({ + slug: SLUG, + location: "global", + agents: ["codev"], + mode: "skip-existing", + json: false, + }), + ).toBe(0) + expect(readFileSync(join(existing, "SKILL.md"), "utf-8")).toBe("# local edits") + expect(existsSync(join(tempHome, ".claude", "skills", "dbt-helper"))).toBe(true) + }) + + test("--json prints one machine-readable line", async () => { + mockDownload() + const out = captureLog() + expect(await runStackPull({ slug: SLUG, dir: tempDir, json: true })).toBe(0) + const parsed = JSON.parse(out[0] ?? "{}") + expect(parsed.ok).toBe(true) + expect(parsed.slug).toBe(SLUG) + expect(parsed.installed.map((s: { name: string }) => s.name).toSorted()).toEqual(["dbt-helper", "pg-tuner"]) + }) + + test("exits non-zero when a skill failed, having still installed the others", async () => { + mockDownload(slipZip()) + const out = captureLog() + expect(await runStackPull({ slug: SLUG, dir: tempDir, json: false })).toBe(1) + expect(out.join("\n")).toMatch(/Failed:/) + expect(existsSync(join(tempDir, "dbt-helper", "SKILL.md"))).toBe(true) + }) + + test("reports a download failure without a stack trace", async () => { + spyOn(stackApi, "downloadStack").mockRejectedValue( + new Error('Stack "ghost" not found, or it has no public skills.'), + ) + const errs = captureErr() + expect(await runStackPull({ slug: "ghost", dir: tempDir, json: false })).toBe(1) + expect(errs.join("\n")).toBe('Stack "ghost" not found, or it has no public skills.') + }) +}) + +describe("archive caps", () => { + test("a stack may exceed one skill's entry ceiling", () => { + const zip = new AdmZip() + zip.addFile(`${SLUG}/big/SKILL.md`, Buffer.from("# big")) + for (let i = 0; i < 16_000; i++) zip.addFile(`${SLUG}/big/f${i}.txt`, Buffer.from("x")) + // 16k entries is over SKILL_LIMITS (15k) but inside STACK_LIMITS (50k). + const result: StackArchive = readStackArchive(zip.toBuffer(), SLUG) + expect(result.skills[0]?.files).toBe(16_001) + }) +}) diff --git a/packages/codev-skillhub/test/stack-search.test.ts b/packages/codev-skillhub/test/stack-search.test.ts new file mode 100644 index 000000000..02f1cc556 --- /dev/null +++ b/packages/codev-skillhub/test/stack-search.test.ts @@ -0,0 +1,120 @@ +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { httpApi } from "../src/http" +import { runStackList } from "../src/stack-search" + +let tempDir: string +let savedHome: string | undefined + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }) +} + +const STACK = { + id: "ns-1", + slug: "data-eng", + name: "Data Engineering", + description: "Everything a data team needs", + owner: { username: "ann", firstName: "Ann", lastName: "Lee", avatarUrl: null }, + memberCount: 4, + skillCount: 9, + updatedAt: "2026-08-01T00:00:00.000Z", + skills: [], +} + +function stubList(items: unknown[] = [STACK]) { + return spyOn(httpApi, "fetch").mockResolvedValue( + jsonResponse(200, { + success: true, + data: items, + pagination: { page: 1, limit: 20, total: items.length, totalPages: 1 }, + stats: { totalStacks: items.length }, + }), + ) +} + +function captureLog() { + const out: string[] = [] + spyOn(console, "log").mockImplementation((m?: unknown) => { + out.push(String(m)) + }) + return out +} +function captureErr() { + const out: string[] = [] + spyOn(console, "error").mockImplementation((m?: unknown) => { + out.push(String(m)) + }) + return out +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-stack-search-")) + savedHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempDir +}) +afterEach(() => { + mock.restore() + if (savedHome === undefined) delete process.env.CODEV_TEST_HOME + if (savedHome !== undefined) process.env.CODEV_TEST_HOME = savedHome + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe("runStackList", () => { + test("prints the slug, because that is what `stack pull` takes", async () => { + stubList() + const out = captureLog() + expect(await runStackList({ json: false })).toBe(0) + const text = out.join("\n") + expect(text).toMatch(/Data Engineering by Ann Lee — 9 skill\(s\), 4 member\(s\)/) + expect(text).toMatch(/slug: data-eng/) + }) + + test("--json prints one machine-readable line", async () => { + stubList() + const out = captureLog() + expect(await runStackList({ json: true })).toBe(0) + const parsed = JSON.parse(out[0] ?? "{}") + expect(parsed.ok).toBe(true) + expect(parsed.total).toBe(1) + expect(parsed.items[0].slug).toBe("data-eng") + }) + + test("distinguishes an empty search from an empty hub", async () => { + stubList([]) + const out = captureLog() + await runStackList({ query: "nope", json: false }) + expect(out.join("\n")).toBe('No stacks match "nope".') + mock.restore() + stubList([]) + const out2 = captureLog() + await runStackList({ json: false }) + expect(out2.join("\n")).toBe("No public stacks yet.") + }) + + test("truncates a long description instead of wrapping the listing", async () => { + stubList([{ ...STACK, description: "x".repeat(200) }]) + const out = captureLog() + await runStackList({ json: false }) + expect(out.join("\n")).toMatch(/x{87}\.\.\./) + }) + + test("strips control characters out of hub-supplied text", async () => { + stubList([{ ...STACK, name: "Data\u0007Eng", slug: "data\u001b[31m-eng" }]) + const out = captureLog() + await runStackList({ json: false }) + const text = out.join("\n") + expect(text).toContain("DataEng") + expect(text).not.toContain("\u0007") + expect(text).not.toContain("\u001b") + }) + + test("reports a failure on stderr and exits non-zero", async () => { + spyOn(httpApi, "fetch").mockResolvedValue(jsonResponse(500, {})) + const errs = captureErr() + expect(await runStackList({ json: false })).toBe(1) + expect(errs.join("\n")).toBe("Stack search failed (500).") + }) +}) diff --git a/packages/codev-skillhub/test/stack.test.ts b/packages/codev-skillhub/test/stack.test.ts new file mode 100644 index 000000000..b7c55677d --- /dev/null +++ b/packages/codev-skillhub/test/stack.test.ts @@ -0,0 +1,180 @@ +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { SKILLHUB_URL } from "../src/const" +import { httpApi } from "../src/http" +import { browseStacks, downloadStack, findStack, ownerLabel } from "../src/stack" + +let tempDir: string +let savedHome: string | undefined + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }) +} + +function stubFetch(response: Response) { + return spyOn(httpApi, "fetch").mockResolvedValue(response) +} + +function lastUrl(spy: ReturnType): string { + const call = spy.mock.calls.at(-1) + if (!call) throw new Error("httpApi.fetch was not called") + return String(call[1]) +} + +const STACK = { + id: "ns-1", + slug: "data-eng", + name: "Data Engineering", + description: "Everything a data team needs", + status: "ACTIVE", + isPublic: true, + updatedAt: "2026-08-01T00:00:00.000Z", + owner: { username: "ann", firstName: "Ann", lastName: "Lee", avatarUrl: null }, + memberCount: 4, + skillCount: 9, + skills: [{ id: "s1", name: "pg-tuner", description: "d", version: "1.0.0", stars: 4, downloadCount: 12 }], +} + +function listBody(items: unknown[] = [STACK], overrides: Record = {}) { + return { + success: true, + data: items, + pagination: { page: 1, limit: 20, total: items.length, totalPages: 1 }, + stats: { totalStacks: 7 }, + ...overrides, + } +} + +beforeEach(() => { + // No auth file: every stack endpoint is optionalAuth, so anonymous must work. + tempDir = mkdtempSync(join(tmpdir(), "codev-stack-")) + savedHome = process.env.CODEV_TEST_HOME + process.env.CODEV_TEST_HOME = tempDir +}) +afterEach(() => { + mock.restore() + if (savedHome === undefined) delete process.env.CODEV_TEST_HOME + if (savedHome !== undefined) process.env.CODEV_TEST_HOME = savedHome + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe("browseStacks", () => { + test("hits the public explore endpoint and passes the query through", async () => { + const spy = stubFetch(jsonResponse(200, listBody())) + await browseStacks({ search: "data", page: 2, limit: 5 }) + const url = new URL(lastUrl(spy)) + expect(`${url.origin}${url.pathname}`).toBe(`${SKILLHUB_URL}/api/v1/explore/namespaces`) + expect(url.searchParams.get("search")).toBe("data") + expect(url.searchParams.get("page")).toBe("2") + expect(url.searchParams.get("limit")).toBe("5") + }) + + test("works anonymously — no auth header when there is no session", async () => { + const spy = stubFetch(jsonResponse(200, listBody())) + await browseStacks() + const init = spy.mock.calls.at(-1)?.[2] as RequestInit + const headers = init.headers as Record + expect(headers.Authorization).toBeUndefined() + expect(headers.Cookie).toBeUndefined() + }) + + test("maps the card and carries pagination plus the global stack count", async () => { + stubFetch(jsonResponse(200, listBody())) + const result = await browseStacks() + expect(result.items[0]).toMatchObject({ + slug: "data-eng", + name: "Data Engineering", + memberCount: 4, + skillCount: 9, + }) + expect(result.pagination).toEqual({ page: 1, limit: 20, total: 1, totalPages: 1 }) + expect(result.totalStacks).toBe(7) + }) + + test("flags the preview as truncated when skillCount exceeds the listed skills", async () => { + stubFetch(jsonResponse(200, listBody())) + const result = await browseStacks() + // The server returns at most six skills per stack whatever skillCount says. + expect(result.items[0]?.skills).toHaveLength(1) + expect(result.items[0]?.previewTruncated).toBe(true) + }) + + test("coerces missing and wrong-typed fields rather than trusting the hub", async () => { + stubFetch(jsonResponse(200, listBody([{ slug: "x", skillCount: "nope", owner: null, skills: "nope" }]))) + const result = await browseStacks() + expect(result.items[0]).toMatchObject({ id: "", name: "", description: "", skillCount: 0, memberCount: 0 }) + expect(result.items[0]?.skills).toEqual([]) + expect(result.items[0]?.previewTruncated).toBe(false) + }) + + test("rejects a non-OK status and an unexpected body", async () => { + stubFetch(jsonResponse(500, {})) + expect(browseStacks()).rejects.toThrow("Stack search failed (500).") + mock.restore() + stubFetch(jsonResponse(200, { success: false })) + expect(browseStacks()).rejects.toThrow("SkillHub returned an unexpected response.") + }) +}) + +describe("findStack", () => { + test("returns the exact slug match from the public listing", async () => { + stubFetch(jsonResponse(200, listBody([{ ...STACK, slug: "data-eng-extra" }, STACK]))) + expect((await findStack("data-eng"))?.name).toBe("Data Engineering") + }) + + test("returns null instead of throwing when the listing has no match", async () => { + // A private stack the caller is a member of is absent from the public + // listing but still downloads, so a miss must not abort the pull. + stubFetch(jsonResponse(200, listBody([]))) + expect(await findStack("private-one")).toBeNull() + }) + + test("returns null when the listing itself fails", async () => { + stubFetch(jsonResponse(500, {})) + expect(await findStack("data-eng")).toBeNull() + }) +}) + +describe("downloadStack", () => { + test("requests the bulk endpoint with the slug encoded", async () => { + const spy = stubFetch(new Response(new Uint8Array([1, 2, 3]))) + const buffer = await downloadStack("data eng") + expect(lastUrl(spy)).toBe(`${SKILLHUB_URL}/api/v1/namespaces/data%20eng/download-all`) + expect([...buffer]).toEqual([1, 2, 3]) + }) + + test("names the private case rather than reporting a bare 403", async () => { + stubFetch(jsonResponse(403, {})) + expect(downloadStack("secret")).rejects.toThrow('Stack "secret" is private.') + }) + + test("names the not-found case, including a stack with no public skills", async () => { + stubFetch(jsonResponse(404, {})) + expect(downloadStack("ghost")).rejects.toThrow('Stack "ghost" not found, or it has no public skills.') + }) + + test("surfaces any other status", async () => { + stubFetch(jsonResponse(502, {})) + expect(downloadStack("data-eng")).rejects.toThrow("Stack download failed (502).") + }) + + test("allows far more time than a JSON handshake", async () => { + const spy = stubFetch(new Response(new Uint8Array())) + await downloadStack("data-eng") + const init = spy.mock.calls.at(-1)?.[2] as RequestInit + // Not the 15s default skillhubFetch would otherwise apply. + expect(init.signal).toBeInstanceOf(AbortSignal) + expect(init.signal?.aborted).toBe(false) + }) +}) + +describe("ownerLabel", () => { + test("prefers the full name, falls back to the username, then to unknown", () => { + expect(ownerLabel({ firstName: "Ann", lastName: "Lee", username: "ann" })).toBe("Ann Lee") + expect(ownerLabel({ username: "ann" })).toBe("ann") + expect(ownerLabel({ firstName: "Ann" })).toBe("Ann") + expect(ownerLabel({})).toBe("unknown") + }) +}) diff --git a/packages/opencode/src/cli/cmd/skill.ts b/packages/opencode/src/cli/cmd/skill.ts index 01e2a10e1..3ed17e16a 100644 --- a/packages/opencode/src/cli/cmd/skill.ts +++ b/packages/opencode/src/cli/cmd/skill.ts @@ -26,20 +26,29 @@ import { import { AGENT_LABELS, SKILL_AGENTS, + downloadStack, + findStack, formatInstallResult, formatPublishResult, + formatStackResult, getSkillMeta, hasSkillhubAuth, installResolvedSkill, + installStack, officeCrossFlagError, + ownerLabel, parseAgentList, + planStackInstall, plannedSteps, preparePublishArchive, publishSkill, + readStackArchive, runSkillInstall, runSkillOffice, runSkillPublish, runSkillSearch, + runStackList, + runStackPull, stripControlChars, type OfficeArgs, type OfficePlatform, @@ -47,6 +56,9 @@ import { type PublishStep, type Scope, type SkillAgent, + type StackArchive, + type StackCard, + type StackInstallMode, } from "@codev/skillhub" import { UI } from "../ui" import { cmd } from "./cmd" @@ -62,6 +74,7 @@ export const SkillHubCommand = cmd({ .command(SkillSearchCommand) .command(SkillPullCommand) .command(SkillPushCommand) + .command(SkillStackCommand) .command(SkillOfficeCommand) .demandCommand(), async handler() {}, @@ -248,6 +261,262 @@ const SkillPushCommand = cmd({ }, }) +// A Skill Stack is a public namespace on the hub: a curated set of skills the +// hub serves as one composite ZIP, installed here as N ordinary skills into the +// same agent directories `skill pull` writes. +const SkillStackCommand = cmd({ + command: "stack", + describe: "browse and install Skill Stacks — a team's whole skill set at once", + builder: (yargs) => yargs.command(StackListCommand).command(StackPullCommand).demandCommand(), + async handler() {}, +}) + +const StackListCommand = cmd({ + command: "list [query..]", + describe: "list the public Skill Stacks", + builder: (yargs) => + yargs + .positional("query", { + describe: "filter by name, slug, or description", + type: "string", + array: true, + }) + .option("limit", { + type: "number", + default: 20, + describe: "maximum number of results", + }) + .option("json", { + type: "boolean", + default: false, + describe: "print the results as one JSON line", + }), + handler: async (args) => { + if (!Number.isInteger(args.limit) || args.limit <= 0) { + console.error("Invalid --limit: expected a positive integer.") + process.exitCode = 1 + return + } + const query = (args.query ?? []).join(" ").trim() + process.exitCode = await runStackList({ + query: query === "" ? undefined : query, + limit: args.limit, + json: args.json, + }) + }, +}) + +const StackPullCommand = cmd({ + command: "pull ", + describe: "install every skill in a Skill Stack", + builder: (yargs) => + yargs + .positional("slug", { + describe: "stack slug (see `codev skill stack list`)", + type: "string", + demandOption: true, + }) + .option("here", { + type: "boolean", + default: false, + describe: "install under the current directory (.claude/.agents skills folders)", + }) + .option("global", { + type: "boolean", + default: false, + describe: "install under the home directory", + }) + .option("dir", { + type: "string", + describe: "install into this exact directory — no agent folders, no links", + }) + .option("agent", { + type: "string", + describe: `comma-separated agents to install for (${SKILL_AGENTS.join(", ")})`, + }) + .option("all-agents", { + type: "boolean", + default: false, + describe: "install for every supported agent", + }) + // Deliberately no -o alias, for the same reason `skill pull` has no -f: + // this flag is an rm -rf of skill directories that may hold local edits. + .option("overwrite", { + type: "boolean", + default: false, + describe: "replace skills from this stack that are already installed", + }) + .option("skip-existing", { + type: "boolean", + default: false, + describe: "leave already-installed skills alone and install the rest", + }) + .option("json", { + type: "boolean", + default: false, + describe: "print the result as one JSON line", + }), + handler: async (args) => { + // Cross-flag rules match `skill pull` verbatim. Checked by hand rather than + // yargs .conflicts() so the messages stay identical. + if (args.here && args.global) { + console.error("Pass either --here or --global, not both.") + process.exitCode = 1 + return + } + const location: Scope | undefined = args.here ? "current" : args.global ? "global" : undefined + if (args.dir !== undefined && location !== undefined) { + console.error("Pass either --dir or --here/--global, not both.") + process.exitCode = 1 + return + } + if (args.dir !== undefined && (args.agent !== undefined || args.allAgents)) { + console.error("--dir installs to an exact path; it can't be combined with --agent.") + process.exitCode = 1 + return + } + if (args.overwrite && args.skipExisting) { + console.error("Pass either --overwrite or --skip-existing, not both.") + process.exitCode = 1 + return + } + const agents = args.allAgents + ? [...SKILL_AGENTS] + : args.agent !== undefined + ? parseAgentList(args.agent) + : undefined + if (typeof agents === "string") { + console.error(agents) + process.exitCode = 1 + return + } + const mode: StackInstallMode | undefined = args.overwrite + ? "overwrite" + : args.skipExisting + ? "skip-existing" + : undefined + + const located = args.dir !== undefined || location !== undefined + const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true + if (located || !interactive) { + process.exitCode = await runStackPull({ + slug: args.slug, + dir: args.dir, + location, + agents, + mode, + json: args.json, + }) + return + } + process.exitCode = await interactiveStackPull(args.slug, { agents, mode, json: args.json }) + }, +}) + +// The interactive `skill stack pull`: name the stack before downloading it, +// preview what the archive actually holds, pick a scope and agents, and only +// then decide what to do about anything already installed. With --json the +// prompts render on stderr so stdout carries only the JSON result line. +async function interactiveStackPull( + slug: string, + opts: { agents?: SkillAgent[]; mode?: StackInstallMode; json: boolean }, +): Promise { + const output: Writable | undefined = opts.json ? process.stderr : undefined + if (!opts.json) { + UI.empty() + intro("Install skill stack") + } + + const resolving = spinner({ output }) + resolving.start("Resolving stack...") + // A private stack the user can see is absent from the public listing but + // still downloads, so a miss here is not fatal — it only costs the title. + const card = await findStack(slug) + const shownName = stripControlChars(card?.name ?? slug) + resolving.stop(card ? `Found ${shownName} by ${stripControlChars(ownerLabel(card.owner))}` : `Fetching ${slug}`) + + const downloading = spinner({ output }) + downloading.start("Downloading stack...") + let archive: StackArchive + try { + archive = readStackArchive(await downloadStack(slug), slug) + } catch (err) { + downloading.stop(err instanceof Error ? err.message : String(err), 1) + if (!opts.json) outro("Not installed") + return 1 + } + downloading.stop(`Downloaded ${archive.skills.length} skill(s)`) + + if (!opts.json) note(stackPreview(archive, card).join("\n"), "Stack contents") + + const location = await select({ + message: `Install ${shownName} to:`, + options: [ + { label: "Current directory (recommended)", value: "current" as Scope }, + { label: "Global", value: "global" as Scope }, + ], + output, + }) + if (isCancel(location)) return 1 + + const agents = opts.agents ?? (await pickAgents(output)) + if (agents === null) return 1 + + const target = { kind: "agents" as const, agents, scope: location } + const plan = await planStackInstall(archive, target) + const mode = + opts.mode ?? (plan.conflicts.length === 0 ? "skip-existing" : await pickStackMode(plan.conflicts, output)) + if (mode === null) return 1 + + const installing = spinner({ output }) + installing.start("Installing...") + const result = await installStack(archive, { target, mode, plan }) + installing.stop(`Installed ${result.installed.length} of ${archive.skills.length} skill(s)`) + console.log(formatStackResult(result, opts.json)) + if (!opts.json) outro(result.failed.length > 0 ? "Done, with failures" : "Done") + return result.failed.length > 0 ? 1 : 0 +} + +// Ask what to do about skills the stack shares with what is already on disk. +// Null means the user cancelled. +async function pickStackMode(conflicts: string[], output: Writable | undefined): Promise { + note(conflicts.map((name) => ` ${stripControlChars(name)}`).join("\n"), "Already installed") + const choice = await select({ + message: `${conflicts.length} skill(s) from this stack are already installed:`, + options: [ + { label: "Skip them, install the rest", value: "skip-existing" as StackInstallMode }, + { label: "Overwrite them (discards local edits)", value: "overwrite" as StackInstallMode }, + ], + output, + }) + if (isCancel(choice)) return null + return choice +} + +function stackPreview(archive: StackArchive, card: StackCard | null): string[] { + const lines = [`${archive.skills.length} skill(s), ${formatBytes(archive.totalBytes)}`] + for (const skill of archive.skills.slice(0, PREVIEW_FILE_LIMIT)) { + lines.push(` ${stripControlChars(skill.name)}`) + } + if (archive.skills.length > PREVIEW_FILE_LIMIT) { + lines.push(` … and ${archive.skills.length - PREVIEW_FILE_LIMIT} more`) + } + if (archive.skipped.length > 0) { + lines.push( + `Excluded: ${archive.skipped.map((item) => `${stripControlChars(item.name)} (${item.reason})`).join(", ")}`, + ) + } + // The listing counts skill records, but the archive holds folders keyed by + // skill NAME — so two same-named skills in one namespace collapse into a + // single folder server-side, and a skill with no uploaded ZIP is dropped + // entirely. Say so rather than installing quietly fewer than advertised. + const carried = archive.skills.length + archive.skipped.length + if (card && card.skillCount > carried) { + lines.push(`Note: the hub lists ${card.skillCount} skill(s) here, but the archive carries ${carried}.`) + } + return lines +} + const OFFICE_PLATFORMS = ["ubuntu", "macos", "windows"] as const // Spellings people actually type, mapped to the `uname -m` names the bundles // are published under (mirrors the hub's alias table). diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index f7a1e65dc..db6c0bc15 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -458,6 +458,7 @@ Commands: codev skill search search the public SkillHub registry codev skill pull install a skill from the SkillHub codev skill push publish a skill to the SkillHub + codev skill stack browse and install Skill Stacks — a team's whole skill set at once codev skill office download and install the CoDev Office offline skill bundle Options: @@ -748,6 +749,69 @@ Options: --json print the result as one JSON line [boolean] [default: false]" `; +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev skill stack --help 1`] = ` +"codev skill stack + +browse and install Skill Stacks — a team's whole skill set at once + +Commands: + codev skill stack list [query..] list the public Skill Stacks + codev skill stack pull install every skill in a Skill Stack + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean]" +`; + +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev skill stack list --help 1`] = ` +"codev skill stack list [query..] + +list the public Skill Stacks + +Positionals: + query filter by name, slug, or description [array] [default: []] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --limit maximum number of results [number] [default: 20] + --json print the results as one JSON line [boolean] [default: false]" +`; + +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev skill stack pull --help 1`] = ` +"codev skill stack pull + +install every skill in a Skill Stack + +Positionals: + slug stack slug (see \`codev skill stack list\`) [string] [required] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --here install under the current directory (.claude/.agents skills folders) + [boolean] [default: false] + --global install under the home directory [boolean] [default: false] + --dir install into this exact directory — no agent folders, no links [string] + --agent comma-separated agents to install for (claude, codex, opencode, codev) + [string] + --all-agents install for every supported agent [boolean] [default: false] + --overwrite replace skills from this stack that are already installed + [boolean] [default: false] + --skip-existing leave already-installed skills alone and install the rest + [boolean] [default: false] + --json print the result as one JSON line [boolean] [default: false]" +`; + exports[`codev CLI help-text snapshots every documented command emits stable help text: codev skill office --help 1`] = ` "codev skill office diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index d2e57797e..06c8776f9 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -90,6 +90,9 @@ const SUBCOMMANDS = [ ["skill", "search"], ["skill", "pull"], ["skill", "push"], + ["skill", "stack"], + ["skill", "stack", "list"], + ["skill", "stack", "pull"], ["skill", "office"], ] as const