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