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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>` 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
Expand Down
2 changes: 1 addition & 1 deletion DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
64 changes: 48 additions & 16 deletions packages/codev-skillhub/src/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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<void> {
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<void> {
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}`)
}
Expand All @@ -59,8 +88,8 @@ export async function extractZip(buffer: Buffer, targetDir: string): Promise<voi
}
const data = entry.getData()
written += data.length
if (written > 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)
Expand Down Expand Up @@ -183,12 +212,15 @@ export async function zipDirectory(dir: string, rootName: string): Promise<ZipDi
// Checks the compressed buffer, the declared uncompressed total, and the entry
// count — all cheap (central-directory reads, no inflation). Throws if any cap
// is exceeded.
export function inspectZip(zipBuffer: Buffer): {
export function inspectZip(
zipBuffer: Buffer,
limits: ArchiveLimits = SKILL_LIMITS,
): {
files: string[]
totalBytes: number
} {
if (zipBuffer.length > 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[] = []
Expand All @@ -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 }
}
Expand Down
25 changes: 25 additions & 0 deletions packages/codev-skillhub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
64 changes: 50 additions & 14 deletions packages/codev-skillhub/src/skill-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,18 @@ function targetRoot(target: Exclude<InstallTarget, { kind: "dir" }>): 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: [] }
}
Expand Down Expand Up @@ -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<void>
// 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]) {
Expand All @@ -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) {
Expand All @@ -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[] {
Expand Down
Loading
Loading