lazybiz 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # lazybiz
2
+
3
+ Install Lazy Biz vault skills into your own machine with one command.
4
+
5
+ A skill is a folder your coding agent reads: instructions, reference notes and
6
+ sometimes scripts. This CLI downloads the ones you have access to, puts them
7
+ where your agent looks for them, and keeps them current.
8
+
9
+ Requires **Node 18 or newer**. Nothing else — no install, no account, no
10
+ dependencies.
11
+
12
+ ## Install a skill
13
+
14
+ Open the skill in Whop and copy the command. It already carries your personal
15
+ install key:
16
+
17
+ ```bash
18
+ npx lazybiz add brand-kit --key YOUR_KEY
19
+ ```
20
+
21
+ That puts the skill in `~/.agents/skills/brand-kit/` and, if you have a
22
+ `~/.claude` directory, links it into `~/.claude/skills/` so Claude Code picks it
23
+ up. Then tell your agent: *"use the brand-kit skill"*.
24
+
25
+ `npx github:lazybizai/vault-cli add …` also works — it is the same code from
26
+ the public mirror, and the older command in your notes keeps running.
27
+
28
+ ## Keep them current
29
+
30
+ ```bash
31
+ npx lazybiz update --key YOUR_KEY
32
+ ```
33
+
34
+ Compares every installed skill against the vault and reinstalls only the ones
35
+ whose version moved. Add a skill id to update just that one.
36
+
37
+ ## See what is available
38
+
39
+ ```bash
40
+ npx lazybiz list
41
+ ```
42
+
43
+ Shows the whole catalog with each skill's state: installed, update available,
44
+ or coming soon. The catalog needs no key.
45
+
46
+ ## Options
47
+
48
+ | Option | What it does |
49
+ |---|---|
50
+ | `--key <KEY>` | Your install key. Also read from `$VAULT_KEY`. |
51
+ | `--base-url <URL>` | Which vault to talk to. Default `https://lazybiz-skills.whop.site`, also read from `$VAULT_BASE_URL`. |
52
+ | `--global` | Install into `~/.agents/skills`. This is the default. |
53
+ | `--dir <PATH>` | Install somewhere else instead. Also read from `$VAULT_SKILLS_DIR`. |
54
+ | `--force` | Overwrite a skill directory this CLI did not install. |
55
+
56
+ ## Where files land
57
+
58
+ ```
59
+ ~/.agents/skills/<skill-id>/ the skill, plus a .vault-version marker
60
+ ~/.claude/skills/<skill-id> a symlink to it (when ~/.claude exists)
61
+ ```
62
+
63
+ One copy on disk, read by every agent that looks for skills. The
64
+ `.vault-version` marker is how `update` knows which version you have — deleting
65
+ it does not break the skill, but `update` will stop tracking it.
66
+
67
+ **A directory the CLI did not install is never overwritten.** If you already
68
+ have a hand-written `~/.agents/skills/writing/`, `add writing` stops and says so
69
+ rather than replacing it. `--force` overrides that, and deletes what was there.
70
+
71
+ ## Your install key is a secret
72
+
73
+ Anyone holding the key can download the vault from any terminal. Treat it like a
74
+ password: do not paste it into a shared channel or commit it. If it leaks, ask
75
+ for a new one — the old one stops working.
76
+
77
+ ## Troubleshooting
78
+
79
+ **"the vault rejected your install key"** — the key is wrong, or it was
80
+ replaced. Open the skill in Whop and copy the command again.
81
+
82
+ **"returned JSON instead of a zip"** — you are pointed at a host that redirects
83
+ away from Whop's proxy. Use the `.whop.site` address, not `.whop.app`.
84
+
85
+ **"already exists and was not installed by this CLI"** — you have your own
86
+ folder by that name. Move it aside, or use `--force` if you want the vault's
87
+ version to win.
88
+
89
+ **An old version keeps running** — npx caches what it downloaded. Ask for the
90
+ newest one with `npx lazybiz@latest add …`.
package/bin/vault.mjs ADDED
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lazybiz — install and update Lazy Biz vault skills.
4
+ *
5
+ * npx lazybiz add <skill-id> --key <KEY>
6
+ * npx lazybiz update --key <KEY>
7
+ * npx lazybiz list
8
+ *
9
+ * Published on npm as `lazybiz`; `npx github:lazybizai/vault-cli` still works
10
+ * for anyone who copied the old command.
11
+ *
12
+ * No dependencies on purpose: npx installs the package before our first line
13
+ * runs, so every dependency is time the member waits and code we did not
14
+ * write. Node 18 ships everything this needs.
15
+ */
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import {
20
+ DEFAULT_BASE_URL,
21
+ VaultError,
22
+ downloadZip,
23
+ fetchCatalog,
24
+ resolveBaseUrl,
25
+ resolveKey,
26
+ } from '../src/api.mjs'
27
+ import { installSkill, InstallError } from '../src/install.mjs'
28
+ import { MARKER, aliasRoot, linkAlias, listInstalled, readMarker, skillsRoot } from '../src/paths.mjs'
29
+
30
+ const pkg = JSON.parse(
31
+ fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
32
+ )
33
+
34
+ const HELP = `lazybiz ${pkg.version} — install Lazy Biz vault skills
35
+
36
+ Usage
37
+ npx lazybiz add <skill-id> --key <KEY> install or reinstall one skill
38
+ npx lazybiz update [skill-id] --key <KEY> reinstall every installed skill whose version changed
39
+ npx lazybiz list [--key <KEY>] show what is installed and what is available
40
+ npx lazybiz --help this text
41
+ npx lazybiz --version print the CLI version
42
+
43
+ Options
44
+ --key <KEY> your personal install key. Copy the whole command from the
45
+ skill's page in Whop and it is already filled in.
46
+ Falls back to $VAULT_KEY.
47
+ --base-url <URL> the vault to talk to. Default ${DEFAULT_BASE_URL}
48
+ (or $VAULT_BASE_URL).
49
+ --global install into ~/.agents/skills. This is the default.
50
+ --dir <PATH> install into <PATH> instead of ~/.agents/skills.
51
+ Falls back to $VAULT_SKILLS_DIR.
52
+ --force overwrite a skill directory this CLI did not install.
53
+
54
+ Where things land
55
+ ~/.agents/skills/<skill-id>/ the skill itself, plus a ${MARKER} marker
56
+ ~/.claude/skills/<skill-id> a symlink to it, when ~/.claude exists
57
+
58
+ Your key is a secret. Anyone holding it can download the vault.
59
+ `
60
+
61
+ /** Parse argv into {command, positionals, flags}. Unknown flags are an error. */
62
+ function parseArgs(argv) {
63
+ const known = {
64
+ '--key': 'key',
65
+ '--base-url': 'baseUrl',
66
+ '--dir': 'dir',
67
+ }
68
+ const booleans = { '--global': 'global', '--force': 'force', '--help': 'help', '-h': 'help', '--version': 'version', '-v': 'version' }
69
+ const flags = {}
70
+ const positionals = []
71
+
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const arg = argv[i]
74
+ if (arg in booleans) {
75
+ flags[booleans[arg]] = true
76
+ continue
77
+ }
78
+ const eq = arg.indexOf('=')
79
+ const name = eq > 0 ? arg.slice(0, eq) : arg
80
+ if (name in known) {
81
+ const value = eq > 0 ? arg.slice(eq + 1) : argv[++i]
82
+ if (value === undefined) throw new VaultError(`${name} needs a value`)
83
+ flags[known[name]] = value
84
+ continue
85
+ }
86
+ if (arg.startsWith('-')) throw new VaultError(`unknown option ${arg} (try --help)`)
87
+ positionals.push(arg)
88
+ }
89
+ return { command: positionals[0], positionals: positionals.slice(1), flags }
90
+ }
91
+
92
+ function requireKey(flags) {
93
+ const key = resolveKey(flags.key)
94
+ if (!key) {
95
+ throw new VaultError(
96
+ 'no install key.\n' +
97
+ ' Open the skill in Whop and copy the command — it carries your key.\n' +
98
+ ' Or pass --key <KEY>, or set $VAULT_KEY.',
99
+ )
100
+ }
101
+ return key
102
+ }
103
+
104
+ function findInCatalog(catalog, id) {
105
+ const hit = catalog.find((s) => s.id === id)
106
+ if (!hit) {
107
+ const names = catalog.map((s) => s.id)
108
+ const near = names.filter((n) => n.includes(id) || id.includes(n)).slice(0, 3)
109
+ throw new VaultError(
110
+ `the vault has no skill called "${id}".` +
111
+ (near.length ? `\n Did you mean: ${near.join(', ')}?` : '') +
112
+ `\n Run "vault list" to see all ${names.length}.`,
113
+ )
114
+ }
115
+ return hit
116
+ }
117
+
118
+ /** Download, unpack and alias one skill. Returns a printable summary. */
119
+ async function installOne({ entry, key, baseUrl, root, alias, force }) {
120
+ const zip = await downloadZip(baseUrl, entry.id, key)
121
+ const { files, replaced } = installSkill({
122
+ root,
123
+ id: entry.id,
124
+ zip,
125
+ version: entry.version,
126
+ baseUrl,
127
+ force,
128
+ })
129
+ const linked = linkAlias(alias, root, entry.id)
130
+ return { files, replaced, linked }
131
+ }
132
+
133
+ async function cmdAdd(positionals, flags) {
134
+ const id = positionals[0]
135
+ if (!id) throw new VaultError('which skill? Usage: npx lazybiz add <skill-id> --key <KEY>')
136
+
137
+ const baseUrl = resolveBaseUrl(flags.baseUrl)
138
+ const key = requireKey(flags)
139
+ const root = skillsRoot(flags)
140
+ const alias = aliasRoot(flags)
141
+
142
+ const entry = findInCatalog(await fetchCatalog(baseUrl), id)
143
+ if (!entry.available) {
144
+ throw new VaultError(`"${id}" is announced but has no file yet. Nothing to install.`)
145
+ }
146
+
147
+ const summary = await installOne({ entry, key, baseUrl, root, alias, force: flags.force })
148
+ console.log(
149
+ `${summary.replaced ? 'Reinstalled' : 'Installed'} ${entry.id} v${entry.version} ` +
150
+ `(${summary.files} files) in ${path.join(root, entry.id)}`,
151
+ )
152
+ if (summary.linked === 'linked') {
153
+ console.log(`Linked ${path.join(alias, entry.id)} -> the install above`)
154
+ } else if (summary.linked === 'skipped' && alias) {
155
+ console.log(`Left ${path.join(alias, entry.id)} alone (something real is already there)`)
156
+ }
157
+ console.log(`\nTell your agent to use it: "use the ${entry.id} skill".`)
158
+ }
159
+
160
+ async function cmdUpdate(positionals, flags) {
161
+ const baseUrl = resolveBaseUrl(flags.baseUrl)
162
+ const key = requireKey(flags)
163
+ const root = skillsRoot(flags)
164
+ const alias = aliasRoot(flags)
165
+
166
+ const only = positionals[0]
167
+ const installed = listInstalled(root).filter((s) => !only || s.id === only)
168
+
169
+ if (installed.length === 0) {
170
+ if (only) {
171
+ throw new VaultError(
172
+ `"${only}" is not installed in ${root} (no ${MARKER}).\n` +
173
+ ` Install it first: vault add ${only} --key <KEY>`,
174
+ )
175
+ }
176
+ console.log(`Nothing installed in ${root} yet. Start with: vault add <skill-id> --key <KEY>`)
177
+ return
178
+ }
179
+
180
+ const catalog = await fetchCatalog(baseUrl)
181
+ const stale = []
182
+ const gone = []
183
+ for (const local of installed) {
184
+ const remote = catalog.find((s) => s.id === local.id)
185
+ if (!remote) {
186
+ gone.push(local)
187
+ } else if (remote.available && remote.version !== local.version) {
188
+ stale.push({ local, remote })
189
+ }
190
+ }
191
+
192
+ if (stale.length === 0) {
193
+ console.log(`Up to date — ${installed.length} skill(s) match the vault.`)
194
+ }
195
+ for (const { local, remote } of stale) {
196
+ const summary = await installOne({ entry: remote, key, baseUrl, root, alias, force: true })
197
+ console.log(`Updated ${remote.id} ${local.version} -> ${remote.version} (${summary.files} files)`)
198
+ }
199
+ for (const local of gone) {
200
+ console.log(`Note: ${local.id} is installed but no longer in the vault. Left untouched.`)
201
+ }
202
+ }
203
+
204
+ async function cmdList(_positionals, flags) {
205
+ const baseUrl = resolveBaseUrl(flags.baseUrl)
206
+ const root = skillsRoot(flags)
207
+ const installed = new Map(listInstalled(root).map((s) => [s.id, s]))
208
+
209
+ // The catalog route needs no key — a version comparison should not have to
210
+ // pass the download gate. --key is accepted so one habit fits every command.
211
+ const catalog = await fetchCatalog(baseUrl)
212
+
213
+ console.log(`${baseUrl} -> ${root}\n`)
214
+ const ids = [...catalog.map((s) => s.id), ...installed.keys()]
215
+ const width = Math.max(4, ...ids.map((id) => id.length))
216
+ for (const s of catalog) {
217
+ const local = installed.get(s.id)
218
+ let state
219
+ if (!s.available) state = 'coming soon'
220
+ else if (!local) state = 'available'
221
+ else if (local.version === s.version) state = `installed v${local.version}`
222
+ else state = `update: v${local.version} -> v${s.version}`
223
+ console.log(` ${s.id.padEnd(width)} ${state.padEnd(24)} ${s.oneLiner ?? ''}`)
224
+ installed.delete(s.id)
225
+ }
226
+ for (const [id, local] of installed) {
227
+ console.log(` ${id.padEnd(width)} ${`v${local.version}, not in vault`.padEnd(24)}`)
228
+ }
229
+ console.log(`\n${catalog.length} skill(s) in the vault.`)
230
+ }
231
+
232
+ async function main() {
233
+ let parsed
234
+ try {
235
+ parsed = parseArgs(process.argv.slice(2))
236
+ } catch (e) {
237
+ console.error(`lazybiz: ${e.message}`)
238
+ process.exit(2)
239
+ }
240
+ const { command, positionals, flags } = parsed
241
+
242
+ if (flags.version) {
243
+ console.log(pkg.version)
244
+ return
245
+ }
246
+ if (flags.help || !command || command === 'help') {
247
+ console.log(HELP)
248
+ return
249
+ }
250
+
251
+ const commands = { add: cmdAdd, update: cmdUpdate, list: cmdList }
252
+ const run = commands[command]
253
+ if (!run) {
254
+ console.error(`lazybiz: unknown command "${command}". Try: add, update, list, --help`)
255
+ process.exit(2)
256
+ }
257
+
258
+ try {
259
+ await run(positionals, flags)
260
+ } catch (e) {
261
+ if (e instanceof VaultError || e instanceof InstallError) {
262
+ console.error(`lazybiz: ${e.message}`)
263
+ process.exit(1)
264
+ }
265
+ throw e
266
+ }
267
+ }
268
+
269
+ main().catch((e) => {
270
+ console.error(`lazybiz: unexpected failure — ${e?.stack ?? e}`)
271
+ process.exit(1)
272
+ })
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "lazybiz",
3
+ "version": "1.0.0",
4
+ "description": "Install and update Lazy Biz vault skills into your agent's skills folder.",
5
+ "type": "module",
6
+ "bin": {
7
+ "lazybiz": "bin/vault.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test test/*.test.mjs"
19
+ },
20
+ "license": "UNLICENSED",
21
+ "private": false
22
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The vault's HTTP surface. Two routes, and the base URL is a parameter so the
3
+ * same CLI serves a future vault without a code change.
4
+ *
5
+ * DEFAULT HOST IS `.whop.site`, NEVER `.whop.app`. whop.app answers 307 and the
6
+ * client follows that redirect OUTSIDE Whop's proxy, which drops the auth header
7
+ * — a download then returns a 401 JSON body that looks exactly like a zip until
8
+ * you open it. Every request this CLI makes rides on this one value.
9
+ */
10
+ export const DEFAULT_BASE_URL = 'https://lazybiz-skills.whop.site'
11
+
12
+ export class VaultError extends Error {}
13
+
14
+ export function resolveBaseUrl(flag) {
15
+ const raw = flag || process.env.VAULT_BASE_URL || DEFAULT_BASE_URL
16
+ const url = raw.replace(/\/+$/, '')
17
+ if (!/^https?:\/\//.test(url)) {
18
+ throw new VaultError(`base URL must start with http:// or https:// — got "${raw}"`)
19
+ }
20
+ return url
21
+ }
22
+
23
+ export function resolveKey(flag) {
24
+ const key = (flag || process.env.VAULT_KEY || '').trim()
25
+ return key || null
26
+ }
27
+
28
+ async function getJson(url, what) {
29
+ let res
30
+ try {
31
+ res = await fetch(url, { headers: { accept: 'application/json' } })
32
+ } catch (e) {
33
+ throw new VaultError(`could not reach ${new URL(url).origin} — ${e.message}`)
34
+ }
35
+ if (!res.ok) {
36
+ throw new VaultError(`${what} failed: HTTP ${res.status} from ${url}`)
37
+ }
38
+ let body
39
+ try {
40
+ body = await res.json()
41
+ } catch {
42
+ throw new VaultError(`${what} returned something that is not JSON — is the base URL right?`)
43
+ }
44
+ if (body?.ok === false) {
45
+ throw new VaultError(`${what} failed: ${body.error ?? 'unknown error'}`)
46
+ }
47
+ return body
48
+ }
49
+
50
+ /**
51
+ * The catalog. Ungated on purpose — it carries ids, names and versions only, so
52
+ * a version comparison never needs the key. The bytes still do.
53
+ */
54
+ export async function fetchCatalog(baseUrl) {
55
+ const body = await getJson(`${baseUrl}/api/skills`, 'catalog lookup')
56
+ if (!Array.isArray(body?.skills)) {
57
+ throw new VaultError(`catalog lookup returned no skills list — is ${baseUrl} the vault?`)
58
+ }
59
+ return body.skills
60
+ }
61
+
62
+ /**
63
+ * Download one skill's zip. The key is a bearer secret and goes in the query
64
+ * string because that is what the route accepts; it is never logged.
65
+ */
66
+ export async function downloadZip(baseUrl, id, key) {
67
+ const url = `${baseUrl}/api/install/${encodeURIComponent(id)}?key=${encodeURIComponent(key)}`
68
+ let res
69
+ try {
70
+ res = await fetch(url)
71
+ } catch (e) {
72
+ throw new VaultError(`could not reach ${baseUrl} — ${e.message}`)
73
+ }
74
+
75
+ if (res.status === 401 || res.status === 403) {
76
+ throw new VaultError(
77
+ 'the vault rejected your install key.\n' +
78
+ ' Open the skill in Whop and copy the command again — it carries a fresh key.\n' +
79
+ ' Nothing was installed.',
80
+ )
81
+ }
82
+ if (res.status === 404) {
83
+ throw new VaultError(
84
+ `the vault has no downloadable file for "${id}" yet (it may be marked as coming soon).`,
85
+ )
86
+ }
87
+ if (!res.ok) {
88
+ throw new VaultError(`download failed: HTTP ${res.status} for ${id}`)
89
+ }
90
+
91
+ // A JSON body here means an error page slipped through with a 200, which is
92
+ // what a redirect off the proxy looks like. Treat it as a failure, not a zip.
93
+ const type = res.headers.get('content-type') ?? ''
94
+ if (type.includes('application/json')) {
95
+ throw new VaultError(
96
+ `the vault returned JSON instead of a zip for "${id}".\n` +
97
+ ` Check that the base URL is the .whop.site host, not .whop.app.`,
98
+ )
99
+ }
100
+
101
+ const buf = Buffer.from(await res.arrayBuffer())
102
+ if (buf.length < 22 || buf.readUInt16LE(0) !== 0x4b50) {
103
+ throw new VaultError(`the download for "${id}" is not a zip file (${buf.length} bytes).`)
104
+ }
105
+ return buf
106
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Unpack one skill zip into the skills root.
3
+ *
4
+ * Two rules do the real work here:
5
+ *
6
+ * 1. **A directory without our marker is never overwritten.** The skills root
7
+ * is where hand-written skills live too, and on a maintainer's machine it is
8
+ * a symlink into the repository those skills come FROM. An `add` that
9
+ * silently replaced `writing/` there would delete the source, not a copy.
10
+ * Refuse, name the file, and let --force be a decision the member makes.
11
+ * 2. **Extract to a temporary directory first, then swap.** A download that
12
+ * fails halfway must leave the previous install intact.
13
+ */
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+ import { listEntries, readEntry } from './unzip.mjs'
17
+ import { MARKER, readMarker } from './paths.mjs'
18
+
19
+ export class InstallError extends Error {}
20
+
21
+ /** Reject anything that would write outside the target directory. */
22
+ function safeName(name) {
23
+ if (name.startsWith('/') || /^[a-zA-Z]:/.test(name) || name.includes('\\')) return null
24
+ const parts = name.split('/').filter((p) => p !== '' && p !== '.')
25
+ if (parts.some((p) => p === '..')) return null
26
+ return parts.join('/')
27
+ }
28
+
29
+ /**
30
+ * Extract the zip into `dest`.
31
+ *
32
+ * The pipeline's packages are `<id>/…` PLUS a loose `VAULT-INFO.md` at the zip
33
+ * root — the wrapper is not the only thing up there. So the rule is per entry,
34
+ * not per archive: strip the `<id>/` prefix where it appears, keep root-level
35
+ * files as they are. The member ends up with the skill's own files at the top of
36
+ * `<id>/` and the provenance note beside them, which is the layout the skill
37
+ * pages describe. A flat zip with no wrapper lands as-is under the same rule.
38
+ *
39
+ * (Found the hard way: assuming every entry carried the prefix produced
40
+ * `<id>/<id>/SKILL.md`, and an agent looking for `<id>/SKILL.md` found nothing.)
41
+ */
42
+ function extractInto(zip, dest, id) {
43
+ const entries = listEntries(zip)
44
+ const files = entries.filter((e) => !e.isDir)
45
+ if (files.length === 0) throw new InstallError('the package is empty')
46
+
47
+ const prefix = `${id}/`
48
+
49
+ let written = 0
50
+ for (const entry of files) {
51
+ const stripped = entry.name.startsWith(prefix) ? entry.name.slice(prefix.length) : entry.name
52
+ const rel = safeName(stripped)
53
+ if (!rel) throw new InstallError(`the package contains an unsafe path: ${entry.name}`)
54
+
55
+ const target = path.join(dest, rel)
56
+ fs.mkdirSync(path.dirname(target), { recursive: true })
57
+ fs.writeFileSync(target, readEntry(zip, entry))
58
+ // Keep the executable bit; skills ship scripts that are meant to run.
59
+ if (entry.mode & 0o111) fs.chmodSync(target, 0o755)
60
+ written++
61
+ }
62
+ return written
63
+ }
64
+
65
+ /**
66
+ * Install `id` from `zip` under `root`.
67
+ * @returns {{files: number, replaced: boolean}}
68
+ */
69
+ export function installSkill({ root, id, zip, version, baseUrl, force = false }) {
70
+ const finalDir = path.join(root, id)
71
+ // lstat, not existsSync: a dangling symlink is in the way too.
72
+ const existing = fs.lstatSync(finalDir, { throwIfNoEntry: false }) != null
73
+
74
+ if (existing && !readMarker(root, id) && !force) {
75
+ throw new InstallError(
76
+ `${finalDir} already exists and was not installed by this CLI (no ${MARKER}).\n` +
77
+ ` Refusing to overwrite it. Move it aside, or rerun with --force.`,
78
+ )
79
+ }
80
+
81
+ fs.mkdirSync(root, { recursive: true })
82
+ const staging = path.join(root, `.${id}.vault-tmp`)
83
+ fs.rmSync(staging, { recursive: true, force: true })
84
+ fs.mkdirSync(staging, { recursive: true })
85
+
86
+ let files
87
+ try {
88
+ files = extractInto(zip, staging, id)
89
+ fs.writeFileSync(
90
+ path.join(staging, MARKER),
91
+ `${JSON.stringify({ id, version, source: baseUrl, installedAt: new Date().toISOString() }, null, 2)}\n`,
92
+ )
93
+ // Swap last: until this point the previous install is still the live one.
94
+ fs.rmSync(finalDir, { recursive: true, force: true })
95
+ fs.renameSync(staging, finalDir)
96
+ } finally {
97
+ fs.rmSync(staging, { recursive: true, force: true })
98
+ }
99
+
100
+ return { files, replaced: existing }
101
+ }
package/src/paths.mjs ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Where a skill lands, and how it becomes visible to the agents.
3
+ *
4
+ * `<root>/.agents/skills/<id>/` is the real directory; `~/.claude/skills/<id>`
5
+ * is a symlink pointing at it. That split is the workspace's own layout, not an
6
+ * invention here: one copy on disk, read by every agent that looks for skills.
7
+ *
8
+ * The symlink target is RELATIVE. An absolute $HOME path written on one machine
9
+ * is a dead link on another, and these directories get synced between machines.
10
+ */
11
+ import { homedir } from 'node:os'
12
+ import path from 'node:path'
13
+ import fs from 'node:fs'
14
+
15
+ export const MARKER = '.vault-version'
16
+
17
+ /** The directory skills are installed into. */
18
+ export function skillsRoot({ dir } = {}) {
19
+ if (dir) return path.resolve(dir)
20
+ if (process.env.VAULT_SKILLS_DIR) return path.resolve(process.env.VAULT_SKILLS_DIR)
21
+ return path.join(homedir(), '.agents', 'skills')
22
+ }
23
+
24
+ /**
25
+ * The Claude-side alias directory, or null when this machine has no ~/.claude.
26
+ * Only the default (global) root is aliased: a skill installed into an explicit
27
+ * --dir belongs to that tree and linking it into the home directory would make
28
+ * a throwaway install look permanent.
29
+ */
30
+ export function aliasRoot({ dir } = {}) {
31
+ if (dir || process.env.VAULT_SKILLS_DIR) return null
32
+ const claude = path.join(homedir(), '.claude')
33
+ if (!fs.existsSync(claude)) return null
34
+ return path.join(claude, 'skills')
35
+ }
36
+
37
+ /** Read the install marker, or null when the skill is not a vault install. */
38
+ export function readMarker(root, id) {
39
+ try {
40
+ const raw = fs.readFileSync(path.join(root, id, MARKER), 'utf8')
41
+ const parsed = JSON.parse(raw)
42
+ return typeof parsed?.version === 'string' ? parsed : null
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ /** Every vault-installed skill under `root`, as {id, version, …}. */
49
+ export function listInstalled(root) {
50
+ let names = []
51
+ try {
52
+ names = fs.readdirSync(root, { withFileTypes: true })
53
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
54
+ .map((e) => e.name)
55
+ } catch {
56
+ return []
57
+ }
58
+ const out = []
59
+ for (const id of names.sort()) {
60
+ const marker = readMarker(root, id)
61
+ if (marker) out.push({ id, ...marker })
62
+ }
63
+ return out
64
+ }
65
+
66
+ /**
67
+ * Point `<aliasRoot>/<id>` at the installed skill.
68
+ * Returns 'linked' | 'kept' | 'skipped'. An existing real directory is never
69
+ * touched: it holds content this CLI did not write.
70
+ */
71
+ export function linkAlias(alias, root, id) {
72
+ if (!alias) return 'skipped'
73
+ fs.mkdirSync(alias, { recursive: true })
74
+ const link = path.join(alias, id)
75
+ const target = path.relative(alias, path.join(root, id))
76
+
77
+ let current = null
78
+ try {
79
+ current = fs.readlinkSync(link)
80
+ } catch {
81
+ if (fs.existsSync(link)) return 'skipped'
82
+ }
83
+ if (current === target) return 'kept'
84
+ if (current !== null) fs.rmSync(link)
85
+ fs.symlinkSync(target, link)
86
+ return 'linked'
87
+ }
package/src/unzip.mjs ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Minimal ZIP reader — stored + deflate, nothing else.
3
+ *
4
+ * The CLI runs through `npx github:…`, which installs whatever `dependencies`
5
+ * says before the first line of our code runs. Every dependency is therefore a
6
+ * download the member waits through and a supply chain we do not control, for a
7
+ * job Node's own `zlib` already does. Shelling out to `unzip` was the other
8
+ * candidate and was dropped: it is absent on a plain Windows box, and the point
9
+ * of the npx route is that it works on the machine the member already has.
10
+ *
11
+ * The vault's zips come from Python's `zipfile` with ZIP_DEFLATED, so store and
12
+ * deflate cover them. Zip64 is detected and refused rather than mis-parsed; a
13
+ * skill package that large is a pipeline bug, not something to guess around.
14
+ */
15
+ import { inflateRawSync } from 'node:zlib'
16
+
17
+ const EOCD_SIG = 0x06054b50
18
+ const CD_SIG = 0x02014b50
19
+ const LFH_SIG = 0x04034b50
20
+ const ZIP64_LOCATOR_SIG = 0x07064b50
21
+
22
+ export class ZipError extends Error {}
23
+
24
+ /** Scan backwards for the end-of-central-directory record. */
25
+ function findEocd(buf) {
26
+ const min = Math.max(0, buf.length - 66_000)
27
+ for (let i = buf.length - 22; i >= min; i--) {
28
+ if (buf.readUInt32LE(i) === EOCD_SIG) return i
29
+ }
30
+ throw new ZipError('not a zip file (no end-of-central-directory record)')
31
+ }
32
+
33
+ /**
34
+ * List the entries in a zip.
35
+ * @returns {{name: string, isDir: boolean, mode: number, method: number,
36
+ * compressedSize: number, size: number, offset: number}[]}
37
+ */
38
+ export function listEntries(buf) {
39
+ const eocd = findEocd(buf)
40
+
41
+ if (eocd >= 20 && buf.readUInt32LE(eocd - 20) === ZIP64_LOCATOR_SIG) {
42
+ throw new ZipError('zip64 archives are not supported')
43
+ }
44
+
45
+ const count = buf.readUInt16LE(eocd + 10)
46
+ let p = buf.readUInt32LE(eocd + 16)
47
+ if (count === 0xffff || p === 0xffffffff) {
48
+ throw new ZipError('zip64 archives are not supported')
49
+ }
50
+
51
+ const entries = []
52
+ for (let i = 0; i < count; i++) {
53
+ if (buf.readUInt32LE(p) !== CD_SIG) {
54
+ throw new ZipError(`corrupt central directory at entry ${i + 1}`)
55
+ }
56
+ const method = buf.readUInt16LE(p + 10)
57
+ const compressedSize = buf.readUInt32LE(p + 20)
58
+ const size = buf.readUInt32LE(p + 24)
59
+ const nameLen = buf.readUInt16LE(p + 28)
60
+ const extraLen = buf.readUInt16LE(p + 30)
61
+ const commentLen = buf.readUInt16LE(p + 32)
62
+ const externalAttrs = buf.readUInt32LE(p + 38)
63
+ const offset = buf.readUInt32LE(p + 42)
64
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen)
65
+
66
+ entries.push({
67
+ name,
68
+ isDir: name.endsWith('/'),
69
+ // Unix permissions live in the high 16 bits. 0 means the zip was written
70
+ // by a tool that did not record them; the caller falls back to a default.
71
+ mode: (externalAttrs >>> 16) & 0o7777,
72
+ method,
73
+ compressedSize,
74
+ size,
75
+ offset,
76
+ })
77
+ p += 46 + nameLen + extraLen + commentLen
78
+ }
79
+ return entries
80
+ }
81
+
82
+ /** Inflate one entry's bytes. */
83
+ export function readEntry(buf, entry) {
84
+ if (buf.readUInt32LE(entry.offset) !== LFH_SIG) {
85
+ throw new ZipError(`corrupt local header for ${entry.name}`)
86
+ }
87
+ const nameLen = buf.readUInt16LE(entry.offset + 26)
88
+ const extraLen = buf.readUInt16LE(entry.offset + 28)
89
+ const start = entry.offset + 30 + nameLen + extraLen
90
+ const raw = buf.subarray(start, start + entry.compressedSize)
91
+
92
+ if (entry.method === 0) return Buffer.from(raw)
93
+ if (entry.method === 8) return inflateRawSync(raw)
94
+ throw new ZipError(`unsupported compression method ${entry.method} for ${entry.name}`)
95
+ }