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 +90 -0
- package/bin/vault.mjs +272 -0
- package/package.json +22 -0
- package/src/api.mjs +106 -0
- package/src/install.mjs +101 -0
- package/src/paths.mjs +87 -0
- package/src/unzip.mjs +95 -0
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
|
+
}
|
package/src/install.mjs
ADDED
|
@@ -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
|
+
}
|