@h0tp/shucky 0.1.0 → 0.4.4

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/lib/cli.js CHANGED
@@ -1,118 +1,782 @@
1
- 'use strict';
2
-
3
- const path = require('path');
4
- const { loadConfig } = require('./config');
5
- const { scanTarget } = require('./scan');
6
- const { addApproval } = require('./approvals');
7
- const report = require('./report');
8
-
9
- const HELP = [
10
- 'shucky pry open an agent skill and inspect it before you trust it.',
11
- '',
12
- 'Usage:',
13
- ' shucky scan <path> [options]',
14
- ' shucky approve <owner/repo> --at <version|commit> --reason <text> [--by <name>]',
15
- '',
16
- 'Scan options:',
17
- ' --source <owner/repo> provenance, for trusted-source relaxation',
18
- ' --at <version|commit> the version being scanned (enables approval matching)',
19
- ' --policy <block|warn|report>',
20
- ' --config <file> path to a config.json (defaults to packaged config)',
21
- ' --json machine-readable output (the evidence pack)',
22
- ' --quiet print only the verdict line',
23
- '',
24
- 'General:',
25
- ' -h, --help show this help',
26
- ' -v, --version print shucky version',
27
- '',
28
- 'Exit codes: 0 pass · 1 warn · 2 block · 3 error',
29
- 'shucky reads files as text and NEVER executes the skill under review.'
30
- ].join('\n');
31
-
32
- function parseArgs(argv) {
33
- const args = { _: [], flags: {} };
34
- for (let i = 0; i < argv.length; i++) {
35
- const a = argv[i];
36
- if (a === '--json' || a === '-j') args.flags.json = true;
37
- else if (a === '--quiet' || a === '-q') args.flags.quiet = true;
38
- else if (a === '--config') args.flags.config = argv[++i];
39
- else if (a === '--policy') args.flags.policy = argv[++i];
40
- else if (a === '--source') args.flags.source = argv[++i];
41
- else if (a === '--at') args.flags.at = argv[++i];
42
- else if (a === '--reason') args.flags.reason = argv[++i];
43
- else if (a === '--by') args.flags.by = argv[++i];
44
- else if (a === '-h' || a === '--help') args.flags.help = true;
45
- else if (a === '-v' || a === '--version') args.flags.version = true;
46
- else args._.push(a);
47
- }
48
- return args;
49
- }
50
-
51
- function cmdScan(args) {
52
- const target = args._[1];
53
- if (!target) { console.error('scan: missing <path>'); return 3; }
54
-
55
- const overrides = {};
56
- if (args.flags.policy) overrides.policy = args.flags.policy;
57
- if (args.flags.source) overrides.source = args.flags.source;
58
- if (args.flags.at) overrides.version = args.flags.at;
59
- const config = loadConfig(args.flags.config, overrides);
60
-
61
- let result;
62
- try { result = scanTarget(path.resolve(target), config); }
63
- catch (err) { console.error('scan error: ' + err.message); return 3; }
64
-
65
- if (args.flags.json) console.log(report.json(result));
66
- else if (args.flags.quiet) console.log('shucky: ' + result.verdict.toUpperCase() + ' (' + result.findings.length + ' findings)');
67
- else console.log(report.human(result));
68
-
69
- if (config.policy === 'report') return 0;
70
- return result.verdict === 'block' ? 2 : (result.verdict === 'warn' ? 1 : 0);
71
- }
72
-
73
- function cmdApprove(args) {
74
- const source = args._[1];
75
- if (!source) { console.error('approve: missing <owner/repo>'); return 3; }
76
- const version = args.flags.at;
77
- if (!version) { console.error('approve: missing --at <version|commit>'); return 3; }
78
-
79
- const config = loadConfig(args.flags.config, {});
80
- if (config.allowOverride === false) { console.error('approve: overrides are disabled (allowOverride=false)'); return 3; }
81
- if (config.overrideRequiresReason && !args.flags.reason) { console.error('approve: --reason <text> is required'); return 3; }
82
-
83
- const entry = {
84
- source: source,
85
- version: version,
86
- reason: args.flags.reason || '',
87
- date: new Date().toISOString().slice(0, 10),
88
- approvedBy: args.flags.by || 'user'
89
- };
90
- let p;
91
- try { p = addApproval(config, entry); }
92
- catch (err) { console.error('approve error: ' + err.message); return 3; }
93
- console.log('recorded approval: ' + source + '@' + version + ' → ' + p);
94
- return 0;
95
- }
96
-
97
- async function runCli(argv) {
98
- const args = parseArgs(argv);
99
-
100
- if (args.flags.version && args._.length === 0) {
101
- console.log(require('../package.json').version);
102
- return 0;
103
- }
104
- if (args.flags.help || args._.length === 0) {
105
- console.log(HELP);
106
- return 0;
107
- }
108
-
109
- const cmd = args._[0];
110
- if (cmd === 'scan') return cmdScan(args);
111
- if (cmd === 'approve') return cmdApprove(args);
112
-
113
- console.error('unknown command: ' + cmd);
114
- console.log(HELP);
115
- return 3;
116
- }
117
-
118
- module.exports = { runCli, parseArgs, HELP };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { loadConfig } = require('./config');
6
+ const { scanTarget } = require('./scan');
7
+ const { addApproval } = require('./approvals');
8
+ const report = require('./report');
9
+ const { parseSource, getOwnerRepo } = require('./sources');
10
+ const { fetchSource } = require('./fetch');
11
+ const { discoverSkills } = require('./discover');
12
+ const agentsLib = require('./agents');
13
+ const { placeSkill, unplaceSkill } = require('./place');
14
+ const lock = require('./lock');
15
+ const registry = require('./registry');
16
+
17
+ const HELP = [
18
+ 'shucky find, vet, and install agent skills. It shucks every skill before it lands.',
19
+ '',
20
+ 'Usage:',
21
+ ' shucky install <source> [options] fetch → scan → install (alias: add, i)',
22
+ ' shucky scan <path|source> [options] vet a skill (local path or remote source)',
23
+ ' shucky find [query] [--github] search skills.sh + your sources (+GitHub) (alias: search)',
24
+ ' shucky list [--global] [--json] list skills shucky installed (alias: ls)',
25
+ ' shucky remove <name> [--global] uninstall a skill (alias: rm)',
26
+ ' shucky update [name] [--global] re-fetch + RE-SCAN + re-place installed skills',
27
+ ' shucky self-update [--check] update shucky itself (git pull / npm -g)',
28
+ ' shucky source add|list|remove <spec> manage the sources registry + curated lists',
29
+ ' shucky approve <owner/repo> --at <version|commit> --reason <text> [--by <name>]',
30
+ '',
31
+ 'Sources (install/scan accept any of):',
32
+ ' owner/repo[/sub][@skill][#ref] github · a local ./path or /abs/path',
33
+ ' https://github.com/… (repo, /tree/…, or /blob/…/SKILL.md) · gitlab (incl. self-hosted)',
34
+ ' a git URL (git@…, ssh://…, …​.git) · gist:<id> · a raw SKILL.md URL · a .well-known host',
35
+ '',
36
+ 'Install options:',
37
+ ' -g, --global install for all your agents user-wide (default: this project)',
38
+ ' --scope <project|global>',
39
+ ' -a, --agent <name> target a specific agent (repeatable; default: auto-detected)',
40
+ ' --all target every supported agent',
41
+ ' --skill <name> only install this skill from a multi-skill source (repeatable)',
42
+ ' --list <name> install a registered curated list (a bundle of skills)',
43
+ ' --dir <path> treat <path> as a local source (same as passing it positionally)',
44
+ ' --copy copy files instead of symlinking',
45
+ ' -y, --yes assume yes (installs WARN skills; NEVER installs a BLOCK)',
46
+ '',
47
+ 'source add options: --name <n> --trust <trusted|community> --type <repo|registry|list>',
48
+ '',
49
+ 'Scan/shared options:',
50
+ ' --source <owner/repo> provenance, for trusted-source relaxation',
51
+ ' --at <version|commit> the version being scanned (enables approval matching)',
52
+ ' --policy <block|warn|report> · --config <file> · --json · --quiet',
53
+ '',
54
+ 'General: -h, --help · -v, --version',
55
+ 'Per-command help: shucky <command> --help (e.g. shucky install --help)',
56
+ '',
57
+ 'Exit codes: 0 ok/pass · 1 warn (skipped) · 2 block (refused) · 3 error',
58
+ 'A BLOCK is overridable ONLY via `shucky approve` (no --force). shucky never executes a skill.'
59
+ ].join('\n');
60
+
61
+ // Per-command help — printed by `shucky <command> --help`.
62
+ const COMMAND_HELP = {
63
+ install: [
64
+ 'shucky install <source> — fetch a skill, SCAN it, and install it only if it passes.',
65
+ 'Aliases: add, i',
66
+ '',
67
+ 'Usage:',
68
+ ' shucky install <source> [options]',
69
+ ' shucky install --list <name> [options] install a registered curated list (a bundle)',
70
+ '',
71
+ 'Argument:',
72
+ ' <source> where the skill comes from — any of:',
73
+ ' owner/repo[/subpath][@skill][#ref] GitHub shorthand',
74
+ ' https://github.com/… (repo · /tree/… · /blob/…/SKILL.md)',
75
+ ' gitlab URLs (incl. self-hosted) · any git URL (git@… · ssh://… · ….git)',
76
+ ' gist:<id> · a raw SKILL.md URL · a .well-known host',
77
+ ' a .tar.gz / .tgz / .zip archive (remote URL or local file)',
78
+ ' a local ./path or /abs/path',
79
+ '',
80
+ 'Options:',
81
+ ' -g, --global install user-wide for all agents (default: this project)',
82
+ ' --scope <p> project | global',
83
+ ' -a, --agent <name> target a specific agent, repeatable (default: auto-detected)',
84
+ ' --all target every supported agent (~71)',
85
+ ' --skill <name> install only this skill from a multi-skill source (repeatable)',
86
+ ' --list <name> install a registered curated list instead of a single source',
87
+ ' --dir <path> treat <path> as a local source (same as the positional arg)',
88
+ ' --copy copy files instead of symlinking',
89
+ ' -y, --yes assume yes — installs a WARN skill; NEVER installs a BLOCK',
90
+ ' --source <owner/repo> assert provenance for trusted relax (local / raw sources)',
91
+ ' --at <ver|commit> pin a version for approval-matching',
92
+ ' --policy <p> block | warn | report · --json · -q, --quiet',
93
+ '',
94
+ 'Gate: PASS installs · WARN installs only with -y (or an interactive yes) · BLOCK installs',
95
+ ' nothing (override only via `shucky approve`). Exit: 0 ok · 1 warn-skipped · 2 block · 3 error.',
96
+ '',
97
+ 'Examples:',
98
+ ' shucky install anthropics/skills@pdf',
99
+ ' shucky install owner/repo --global --agent claude-code --agent cursor',
100
+ ' shucky install ./my-skill --copy',
101
+ ' shucky install https://example.com/bundle.tar.gz',
102
+ ' shucky install --list my-stack'
103
+ ].join('\n'),
104
+
105
+ scan: [
106
+ 'shucky scan <path|source> — vet a skill and print a block/warn/pass verdict. Installs nothing.',
107
+ '',
108
+ 'Usage:',
109
+ ' shucky scan <path|source> [options]',
110
+ '',
111
+ 'Argument:',
112
+ ' <path|source> a local path, OR any remote source `install` accepts (fetched to a temp',
113
+ ' dir, scanned, then discarded).',
114
+ '',
115
+ 'Options:',
116
+ ' --source <owner/repo> provenance, for trusted-source relax',
117
+ ' --at <ver|commit> version being scanned (enables approval matching)',
118
+ ' --policy <p> block | warn | report',
119
+ ' --config <file> path to a config.json',
120
+ ' --json machine-readable evidence pack',
121
+ ' -q, --quiet print only the verdict line',
122
+ '',
123
+ 'Exit: 0 pass · 1 warn · 2 block · 3 error.',
124
+ '',
125
+ 'Examples:',
126
+ ' shucky scan ./some-skill',
127
+ ' shucky scan anthropics/skills@pdf --json',
128
+ ' shucky scan owner/repo --source owner/repo --at v1.2.3'
129
+ ].join('\n'),
130
+
131
+ find: [
132
+ 'shucky find [query] — search skills.sh + your registered sources. Installs nothing; each',
133
+ 'result is install-ready (and is scanned on install). Aliases: search, f, s',
134
+ '',
135
+ 'Usage:',
136
+ ' shucky find [query] [options]',
137
+ '',
138
+ 'Argument:',
139
+ ' [query] text to match (optional; omit to browse).',
140
+ '',
141
+ 'Options:',
142
+ ' --github also search GitHub — SKILL.md code search if GITHUB_TOKEN/GH_TOKEN is set,',
143
+ ' otherwise a repo search filtered to skill/agent repos',
144
+ ' --local search only your registered sources/lists (skip skills.sh)',
145
+ ' --limit <n> max results to show (default 25)',
146
+ ' --json machine-readable results',
147
+ '',
148
+ 'Examples:',
149
+ ' shucky find pdf',
150
+ ' shucky find "changelog" --github --limit 10',
151
+ ' shucky find --local'
152
+ ].join('\n'),
153
+
154
+ list: [
155
+ 'shucky list — list the skills shucky has installed (from its lockfiles). Alias: ls',
156
+ '',
157
+ 'Usage:',
158
+ ' shucky list [options]',
159
+ '',
160
+ 'Options:',
161
+ ' -g, --global list global installs only (default: project + global)',
162
+ ' --scope <p> project | global',
163
+ ' --json machine-readable',
164
+ '',
165
+ 'Examples:',
166
+ ' shucky list',
167
+ ' shucky list --global --json'
168
+ ].join('\n'),
169
+
170
+ remove: [
171
+ 'shucky remove <name> — uninstall a skill across all agent dirs + prune the lockfile.',
172
+ 'Aliases: rm, uninstall',
173
+ '',
174
+ 'Usage:',
175
+ ' shucky remove <name> [options]',
176
+ '',
177
+ 'Argument:',
178
+ ' <name> the installed skill name (see `shucky list`).',
179
+ '',
180
+ 'Options:',
181
+ ' -g, --global remove from global scope (default: project + global)',
182
+ ' --scope <p> project | global',
183
+ '',
184
+ 'Examples:',
185
+ ' shucky remove changelog-formatter',
186
+ ' shucky remove pdf --global'
187
+ ].join('\n'),
188
+
189
+ update: [
190
+ 'shucky update [name] — re-fetch installed skills, RE-SCAN them, and re-place. A skill that',
191
+ 'now BLOCKS is left as-is and flagged (never silently reinstalled). Alias: upgrade',
192
+ '',
193
+ 'Usage:',
194
+ ' shucky update [name] [options]',
195
+ '',
196
+ 'Argument:',
197
+ ' [name] a specific installed skill (optional; omit to update all).',
198
+ '',
199
+ 'Options:',
200
+ ' -g, --global update global installs only (default: project + global)',
201
+ ' --scope <p> project | global',
202
+ '',
203
+ 'Note: local / raw-file / well-known sources cannot be auto-updated and are skipped.',
204
+ '',
205
+ 'Examples:',
206
+ ' shucky update',
207
+ ' shucky update pdf'
208
+ ].join('\n'),
209
+
210
+ 'self-update': [
211
+ 'shucky self-update — update shucky itself (the CLI) to the latest version.',
212
+ '',
213
+ 'Usage:',
214
+ ' shucky self-update [--check]',
215
+ '',
216
+ 'It detects how shucky was installed and runs the matching update:',
217
+ ' • source / npm-link checkout → git pull --ff-only',
218
+ ' • global npm install → npm install -g @h0tp/shucky@latest',
219
+ ' • npx (ephemeral) → nothing to do; just invoke a newer @version',
220
+ '',
221
+ 'Options:',
222
+ ' --check print what it would run, without doing it',
223
+ '',
224
+ 'Note: this updates the shucky CLI itself. To re-fetch + RE-SCAN the skills shucky',
225
+ 'installed FOR you, use `shucky update` instead.'
226
+ ].join('\n'),
227
+
228
+ source: [
229
+ 'shucky source <add|list|remove> — manage the registry of skill sources (repos, registries,',
230
+ 'curated lists) that `find` searches and `install --list` installs.',
231
+ '',
232
+ 'Usage:',
233
+ ' shucky source add <spec> [--name <n>] [--trust <t>] [--type <t>] [-g]',
234
+ ' shucky source list [--json]',
235
+ ' shucky source remove <name> [-g]',
236
+ '',
237
+ 'Subcommands:',
238
+ ' add <spec> register a source. <spec> = owner/repo, a URL, or a .json list manifest.',
239
+ ' list show registered sources (project + global).',
240
+ ' remove <name> unregister a source by name.',
241
+ '',
242
+ 'add options:',
243
+ ' --name <n> override the auto-derived source name',
244
+ ' --trust <t> trusted | community — "trusted" feeds the relax policy (low/medium)',
245
+ ' --type <t> repo | registry | list (default: inferred from the spec)',
246
+ ' -g, --global store in the global registry (default: this project)',
247
+ '',
248
+ 'A `list` source points at a .json manifest — ["owner/repo@skill", …] or',
249
+ '{ "skills": [{ "source": "…", "skill": "…" }] } — installable via `install --list <name>`.',
250
+ '',
251
+ 'Examples:',
252
+ ' shucky source add anthropics/skills --trust trusted',
253
+ ' shucky source add https://example.com/team.json --name team --type list',
254
+ ' shucky source list',
255
+ ' shucky source remove team'
256
+ ].join('\n'),
257
+
258
+ approve: [
259
+ 'shucky approve <owner/repo> --at <ver|commit> --reason <text> — log a human override of a',
260
+ 'BLOCK, pinned to an exact version/commit, in approved-skills.json. The ONLY way past a BLOCK.',
261
+ '',
262
+ 'Usage:',
263
+ ' shucky approve <owner/repo> --at <version|commit> --reason <text> [--by <name>] [--config <file>]',
264
+ '',
265
+ 'Argument:',
266
+ ' <owner/repo> the source whose BLOCK you are overriding.',
267
+ '',
268
+ 'Options:',
269
+ ' --at <ver|commit> REQUIRED — the exact version/commit being approved',
270
+ ' --reason <text> required (by default) — why it is accepted',
271
+ ' --by <name> who approved (default: user)',
272
+ ' --config <file> path to a config.json',
273
+ '',
274
+ 'Examples:',
275
+ ' shucky approve owner/repo --at 1.2.3 --reason "reviewed by security"',
276
+ ' shucky approve owner/repo --at deadbeef0123 --reason "vetted" --by alice'
277
+ ].join('\n')
278
+ };
279
+
280
+ const CMD_ALIASES = {
281
+ install: 'install', add: 'install', i: 'install',
282
+ scan: 'scan',
283
+ find: 'find', search: 'find', f: 'find', s: 'find',
284
+ list: 'list', ls: 'list',
285
+ remove: 'remove', rm: 'remove', uninstall: 'remove',
286
+ update: 'update', upgrade: 'update',
287
+ 'self-update': 'self-update', selfupdate: 'self-update',
288
+ source: 'source',
289
+ approve: 'approve'
290
+ };
291
+
292
+ // Help for a (possibly aliased) command; falls back to the global overview.
293
+ function helpFor(cmd) {
294
+ const canon = CMD_ALIASES[cmd];
295
+ return (canon && COMMAND_HELP[canon]) ? COMMAND_HELP[canon] : HELP;
296
+ }
297
+
298
+ function parseArgs(argv) {
299
+ const args = { _: [], flags: {} };
300
+ for (let i = 0; i < argv.length; i++) {
301
+ const a = argv[i];
302
+ if (a === '--json' || a === '-j') args.flags.json = true;
303
+ else if (a === '--quiet' || a === '-q') args.flags.quiet = true;
304
+ else if (a === '--config') args.flags.config = argv[++i];
305
+ else if (a === '--policy') args.flags.policy = argv[++i];
306
+ else if (a === '--source') args.flags.source = argv[++i];
307
+ else if (a === '--at') args.flags.at = argv[++i];
308
+ else if (a === '--reason') args.flags.reason = argv[++i];
309
+ else if (a === '--by') args.flags.by = argv[++i];
310
+ else if (a === '-g' || a === '--global') args.flags.global = true;
311
+ else if (a === '--scope') args.flags.scope = argv[++i];
312
+ else if (a === '-a' || a === '--agent') { (args.flags.agent = args.flags.agent || []).push(argv[++i]); }
313
+ else if (a === '--skill') { (args.flags.skill = args.flags.skill || []).push(argv[++i]); }
314
+ else if (a === '--all') args.flags.all = true;
315
+ else if (a === '--dir') args.flags.dir = argv[++i];
316
+ else if (a === '--copy') args.flags.copy = true;
317
+ else if (a === '-y' || a === '--yes') args.flags.yes = true;
318
+ else if (a === '--list') args.flags.list = argv[++i];
319
+ else if (a === '--name') args.flags.name = argv[++i];
320
+ else if (a === '--trust') args.flags.trust = argv[++i];
321
+ else if (a === '--type') args.flags.type = argv[++i];
322
+ else if (a === '--limit') args.flags.limit = argv[++i];
323
+ else if (a === '--local') args.flags.local = true;
324
+ else if (a === '--github') args.flags.github = true;
325
+ else if (a === '--check') args.flags.check = true;
326
+ else if (a === '-h' || a === '--help') args.flags.help = true;
327
+ else if (a === '-v' || a === '--version') args.flags.version = true;
328
+ else args._.push(a);
329
+ }
330
+ return args;
331
+ }
332
+
333
+ function dropUndef(o) {
334
+ const r = {};
335
+ for (const k in o) if (o[k] !== undefined && o[k] !== null) r[k] = o[k];
336
+ return r;
337
+ }
338
+
339
+ function scopesFor(args) {
340
+ if (args.flags.global || args.flags.scope === 'global') return ['global'];
341
+ if (args.flags.scope === 'project') return ['project'];
342
+ return ['project', 'global'];
343
+ }
344
+
345
+ // ---- scan (accepts remote sources too) -----------------------------------
346
+
347
+ function scanOverrides(args) {
348
+ const o = {};
349
+ if (args.flags.policy) o.policy = args.flags.policy;
350
+ if (args.flags.source) o.source = args.flags.source;
351
+ if (args.flags.at) o.version = args.flags.at;
352
+ return o;
353
+ }
354
+
355
+ async function cmdScan(args) {
356
+ const target = args._[1];
357
+ if (!target) { console.error('scan: missing <path|source>'); return 3; }
358
+
359
+ let parsed;
360
+ try { parsed = parseSource(target); }
361
+ catch (e) { console.error('scan: ' + e.message); return 3; }
362
+
363
+ if (parsed.type === 'local') {
364
+ const config = loadConfig(args.flags.config, scanOverrides(args));
365
+ let result;
366
+ try { result = scanTarget(path.resolve(target), config); }
367
+ catch (err) { console.error('scan error: ' + err.message); return 3; }
368
+ return emitScan(result, config, args);
369
+ }
370
+
371
+ let fetched;
372
+ try { fetched = await fetchSource(parsed, {}); }
373
+ catch (e) { console.error('scan: fetch failed — ' + e.message); return 3; }
374
+ try {
375
+ const overrides = dropUndef(Object.assign({ source: getOwnerRepo(parsed), version: fetched.ref }, scanOverrides(args)));
376
+ const config = loadConfig(args.flags.config, overrides);
377
+ const result = scanTarget(fetched.dir, config);
378
+ result.target = getOwnerRepo(parsed) || parsed.url;
379
+ return emitScan(result, config, args);
380
+ } catch (err) {
381
+ console.error('scan error: ' + err.message);
382
+ return 3;
383
+ } finally {
384
+ if (fetched && fetched.cleanup) fetched.cleanup();
385
+ }
386
+ }
387
+
388
+ function emitScan(result, config, args) {
389
+ if (args.flags.json) console.log(report.json(result));
390
+ else if (args.flags.quiet) console.log('shucky: ' + result.verdict.toUpperCase() + ' (' + result.findings.length + ' findings)');
391
+ else console.log(report.human(result));
392
+ if (config.policy === 'report') return 0;
393
+ return result.verdict === 'block' ? 2 : (result.verdict === 'warn' ? 1 : 0);
394
+ }
395
+
396
+ // ---- install -------------------------------------------------------------
397
+
398
+ function promptYesNo(question) {
399
+ try {
400
+ process.stdout.write(question + ' [y/N] ');
401
+ const buf = Buffer.alloc(256);
402
+ const n = fs.readSync(0, buf, 0, 256, null);
403
+ const ans = buf.toString('utf8', 0, n).trim().toLowerCase();
404
+ return ans === 'y' || ans === 'yes';
405
+ } catch (e) { return false; }
406
+ }
407
+
408
+ // proceed | skip | abort — reuses the scan verdict (which already folds in approvals + relax).
409
+ function gateDecision(result, flags, config) {
410
+ const v = result.verdict;
411
+ if (v === 'block') return 'abort'; // only `shucky approve` can lift this
412
+ if (v === 'pass') return 'proceed';
413
+ if (config && config.policy === 'report') return 'proceed';
414
+ if (flags.yes) return 'proceed';
415
+ if (process.stdin.isTTY && promptYesNo(' install this WARN skill anyway?')) return 'proceed';
416
+ return 'skip';
417
+ }
418
+
419
+ function resolveAgentList(flags) {
420
+ if (flags.all) return Object.keys(agentsLib.agents).filter(function (t) { return t !== 'universal'; });
421
+ if (flags.agent && flags.agent.length) return flags.agent;
422
+ const detected = agentsLib.detectInstalledAgents();
423
+ return detected.length ? detected : ['universal'];
424
+ }
425
+
426
+ async function cmdInstall(args) {
427
+ // Curated list: resolve to member sources and install each (each independently re-scanned).
428
+ if (args.flags.list) {
429
+ let members;
430
+ try { members = await registry.resolveList(args.flags.list, process.cwd()); }
431
+ catch (e) { console.error('install --list: ' + e.message); return 3; }
432
+ if (!members.length) { console.error('install --list: "' + args.flags.list + '" lists no skills'); return 3; }
433
+ if (!args.flags.quiet) console.log('🦪 installing list "' + args.flags.list + '" — ' + members.length + ' skill(s)\n');
434
+ let worstList = 0;
435
+ for (const m of members) {
436
+ const memberArgs = { _: ['install', m], flags: Object.assign({}, args.flags, { list: undefined, dir: undefined }) };
437
+ worstList = Math.max(worstList, await cmdInstall(memberArgs));
438
+ }
439
+ return worstList;
440
+ }
441
+
442
+ const input = args.flags.dir || args._[1];
443
+ if (!input) { console.error('install: missing <source>'); return 3; }
444
+
445
+ let parsed;
446
+ try { parsed = parseSource(input); }
447
+ catch (e) { console.error('install: ' + e.message); return 3; }
448
+
449
+ const scope = (args.flags.global || args.flags.scope === 'global') ? 'global' : 'project';
450
+ const agentList = resolveAgentList(args.flags);
451
+ const forceCreate = !!(args.flags.agent && args.flags.agent.length) || !!args.flags.all;
452
+ // --source lets the user assert provenance (trust relax) for sources without an intrinsic
453
+ // owner/repo (local, rawfile, well-known). Otherwise we derive it from the source itself.
454
+ const ownerRepo = args.flags.source || getOwnerRepo(parsed);
455
+ const cwd = process.cwd();
456
+
457
+ let fetched;
458
+ try { fetched = await fetchSource(parsed, {}); }
459
+ catch (e) { console.error('install: fetch failed — ' + e.message); return 3; }
460
+ // --at lets the user pin a version for approval-matching when the source has no resolved SHA.
461
+ const effectiveVersion = args.flags.at || fetched.ref || null;
462
+
463
+ let worst = 0;
464
+ const summary = [];
465
+ const jsonOut = [];
466
+ try {
467
+ let skills;
468
+ try { skills = discoverSkills(fetched.dir, { subpath: parsed.subpath, skillFilter: parsed.skillFilter }); }
469
+ catch (e) { console.error('install: ' + e.message); return 3; }
470
+
471
+ if (args.flags.skill && args.flags.skill.length) {
472
+ const want = new Set(args.flags.skill.map(function (s) { return String(s).toLowerCase(); }));
473
+ skills = skills.filter(function (s) { return want.has(s.name.toLowerCase()) || want.has(path.basename(s.dir).toLowerCase()); });
474
+ }
475
+ if (!skills.length) { console.error('install: no installable SKILL.md found in ' + input); return 3; }
476
+
477
+ const config = loadConfig(args.flags.config, dropUndef({ source: ownerRepo, version: effectiveVersion, policy: args.flags.policy }));
478
+ // Sources the user registered as `trusted` get the same low/medium relax as the built-ins.
479
+ const extraTrusted = registry.trustedOwners(cwd);
480
+ if (extraTrusted.length) config.trustedSources = (config.trustedSources || []).concat(extraTrusted);
481
+
482
+ for (const sk of skills) {
483
+ const result = scanTarget(sk.dir, config);
484
+ result.target = sk.name + (ownerRepo ? ' (' + ownerRepo + ')' : '');
485
+ const decision = gateDecision(result, args.flags, config);
486
+
487
+ if (!args.flags.json && !args.flags.quiet) console.log(report.human(result) + '\n');
488
+
489
+ if (decision !== 'proceed') {
490
+ worst = Math.max(worst, decision === 'abort' ? 2 : 1);
491
+ if (decision === 'abort' && !args.flags.quiet) {
492
+ let msg = '✋ ' + sk.name + ': BLOCKED — not installed.';
493
+ if (ownerRepo && effectiveVersion) {
494
+ msg += '\n to override (only after a human review): shucky approve ' + ownerRepo + ' --at ' + effectiveVersion + ' --reason "…"';
495
+ } else {
496
+ msg += '\n review the findings above. (remote owner/repo sources can be overridden via `shucky approve` once vetted.)';
497
+ }
498
+ console.error(msg);
499
+ } else if (decision === 'skip' && !args.flags.quiet) {
500
+ console.error('⚠ ' + sk.name + ': WARN — skipped (re-run with -y to install).');
501
+ }
502
+ summary.push({ skill: sk.name, verdict: result.verdict, installed: false });
503
+ jsonOut.push({ skill: sk.name, verdict: result.verdict, installed: false });
504
+ continue;
505
+ }
506
+
507
+ let placement;
508
+ try {
509
+ placement = placeSkill(sk.dir, sk.name, agentList, { scope: scope, copy: args.flags.copy, cwd: cwd, forceCreate: forceCreate });
510
+ } catch (e) {
511
+ worst = Math.max(worst, 3);
512
+ console.error('install: placement failed for ' + sk.name + ' — ' + e.message);
513
+ summary.push({ skill: sk.name, verdict: result.verdict, installed: false, error: e.message });
514
+ continue;
515
+ }
516
+
517
+ const placedAgents = placement.results.filter(function (r) { return r.success; }).map(function (r) { return r.agent; });
518
+ lock.addSkill(scope, placement.name, {
519
+ source: ownerRepo || (parsed.type === 'local' ? null : parsed.url),
520
+ installSource: ownerRepo ? (ownerRepo + '@' + sk.name) : (parsed.type === 'local' ? null : parsed.url),
521
+ sourceType: parsed.type,
522
+ sourceUrl: parsed.url,
523
+ ref: effectiveVersion,
524
+ skillPath: path.relative(fetched.dir, sk.skillMdPath) || 'SKILL.md',
525
+ hash: lock.computeFolderHash(sk.dir),
526
+ verdict: result.verdict,
527
+ rawVerdict: result.rawVerdict,
528
+ overriddenByApproval: !!result.overriddenByApproval,
529
+ agents: placedAgents
530
+ }, cwd);
531
+
532
+ summary.push({ skill: placement.name, verdict: result.verdict, installed: true, placement: placement });
533
+ jsonOut.push({ skill: placement.name, verdict: result.verdict, installed: true, scope: scope, agents: placedAgents });
534
+ }
535
+ } finally {
536
+ if (fetched && fetched.cleanup) fetched.cleanup();
537
+ }
538
+
539
+ if (args.flags.json) console.log(JSON.stringify({ scope: scope, skills: jsonOut }, null, 2));
540
+ else if (!args.flags.quiet) printInstallSummary(summary, scope);
541
+
542
+ if (args.flags.policy === 'report') return 0;
543
+ return worst;
544
+ }
545
+
546
+ function printInstallSummary(summary, scope) {
547
+ const installed = summary.filter(function (s) { return s.installed; });
548
+ const failed = summary.filter(function (s) { return !s.installed; });
549
+ console.log('');
550
+ if (installed.length) {
551
+ console.log('🦪 installed (' + scope + ' scope):');
552
+ for (const s of installed) {
553
+ const placed = s.placement ? s.placement.results.filter(function (r) { return r.success && !r.skipped; }).map(function (r) { return r.agent; }) : [];
554
+ console.log(' ✓ ' + s.skill + ' [' + s.verdict + '] → ' + (placed.length ? placed.join(', ') : '.agents/skills (canonical)'));
555
+ }
556
+ }
557
+ for (const s of failed) {
558
+ console.log(' ✗ ' + s.skill + ' [' + s.verdict + '] not installed');
559
+ }
560
+ }
561
+
562
+ // ---- list ----------------------------------------------------------------
563
+
564
+ function cmdList(args) {
565
+ const cwd = process.cwd();
566
+ const scopes = scopesFor(args);
567
+ const rows = [];
568
+ for (const sc of scopes) for (const s of lock.listSkills(sc, cwd)) rows.push(Object.assign({ scope: sc }, s));
569
+
570
+ if (args.flags.json) { console.log(JSON.stringify(rows, null, 2)); return 0; }
571
+ if (!rows.length) { console.log('no skills installed by shucky yet. try: shucky install <source>'); return 0; }
572
+ for (const sc of scopes) {
573
+ const items = rows.filter(function (s) { return s.scope === sc; });
574
+ if (!items.length) continue;
575
+ console.log(sc + ':');
576
+ for (const s of items) {
577
+ console.log(' ' + s.name + ' [' + (s.verdict || '?') + '] ' +
578
+ (s.source || s.sourceUrl || '') + (s.ref ? '@' + String(s.ref).slice(0, 12) : '') +
579
+ ' → ' + ((s.agents || []).join(', ') || '(canonical)'));
580
+ }
581
+ }
582
+ return 0;
583
+ }
584
+
585
+ // ---- source registry -----------------------------------------------------
586
+
587
+ function cmdSource(args) {
588
+ const sub = args._[1];
589
+ const scope = (args.flags.global || args.flags.scope === 'global') ? 'global' : 'project';
590
+ const cwd = process.cwd();
591
+
592
+ if (sub === 'add') {
593
+ const spec = args._[2];
594
+ if (!spec) { console.error('source add: missing <spec> (owner/repo, a URL, or a .json list manifest)'); return 3; }
595
+ if (args.flags.trust && args.flags.trust !== 'trusted' && args.flags.trust !== 'community') {
596
+ console.error('source add: --trust must be "trusted" or "community"'); return 3;
597
+ }
598
+ let res;
599
+ try { res = registry.addSource(scope, spec, { name: args.flags.name, trust: args.flags.trust, type: args.flags.type }, cwd); }
600
+ catch (e) { console.error('source add: ' + e.message); return 3; }
601
+ console.log('added source [' + scope + ']: ' + res.entry.name + ' ' + res.entry.type +
602
+ (res.entry.trust ? ' (' + res.entry.trust + ')' : '') + ' → ' + res.entry.spec);
603
+ return 0;
604
+ }
605
+
606
+ if (sub === 'remove' || sub === 'rm') {
607
+ const name = args._[2];
608
+ if (!name) { console.error('source remove: missing <name>'); return 3; }
609
+ const ok = registry.removeSource(scope, name, cwd);
610
+ console.log(ok ? 'removed source: ' + name : 'no such source in ' + scope + ' scope: ' + name);
611
+ return ok ? 0 : 3;
612
+ }
613
+
614
+ // default / `list`
615
+ const rows = registry.listSources(cwd);
616
+ if (args.flags.json) { console.log(JSON.stringify(rows, null, 2)); return 0; }
617
+ if (!rows.length) { console.log('no sources registered. add one: shucky source add <owner/repo | url | list.json>'); return 0; }
618
+ for (const s of rows) {
619
+ console.log(' [' + s.scope + '] ' + s.name + ' ' + s.type + (s.trust ? ' (' + s.trust + ')' : '') + ' → ' + s.spec);
620
+ }
621
+ return 0;
622
+ }
623
+
624
+ // ---- remove --------------------------------------------------------------
625
+
626
+ function cmdRemove(args) {
627
+ const name = args._[1];
628
+ if (!name) { console.error('remove: missing <name>'); return 3; }
629
+ const cwd = process.cwd();
630
+ let removedAny = false;
631
+ for (const scope of scopesFor(args)) {
632
+ const entry = lock.getSkill(scope, name, cwd);
633
+ if (!entry) continue;
634
+ const res = unplaceSkill(name, entry.agents || [], { scope: scope, cwd: cwd });
635
+ lock.removeSkill(scope, name, cwd);
636
+ removedAny = true;
637
+ console.log('removed "' + res.name + '" (' + scope + '): ' + res.removed.length + ' path(s) deleted');
638
+ }
639
+ if (!removedAny) { console.error('remove: "' + name + '" is not installed by shucky'); return 3; }
640
+ return 0;
641
+ }
642
+
643
+ // ---- update (re-fetch → RE-SCAN → re-place) ------------------------------
644
+
645
+ async function cmdUpdate(args) {
646
+ const cwd = process.cwd();
647
+ const only = args._[1] || null;
648
+ let worst = 0, any = false;
649
+ for (const scope of scopesFor(args)) {
650
+ for (const entry of lock.listSkills(scope, cwd)) {
651
+ if (only && entry.name !== only) continue;
652
+ const sourceStr = entry.installSource || entry.source || entry.sourceUrl;
653
+ if (!sourceStr || ['local', 'rawfile', 'well-known'].indexOf(entry.sourceType) !== -1) {
654
+ console.log('· ' + entry.name + ' (' + scope + '): not auto-updatable (source type ' + (entry.sourceType || 'unknown') + ') — skipped');
655
+ continue;
656
+ }
657
+ any = true;
658
+ const prior = entry.verdict;
659
+ console.log('↻ updating ' + entry.name + ' (' + scope + ') ← ' + sourceStr);
660
+ const memberArgs = {
661
+ _: ['install', sourceStr],
662
+ flags: {
663
+ agent: (entry.agents && entry.agents.length) ? entry.agents.slice() : undefined,
664
+ global: scope === 'global',
665
+ quiet: true
666
+ }
667
+ };
668
+ const code = await cmdInstall(memberArgs);
669
+ worst = Math.max(worst, code);
670
+ if (code === 2) {
671
+ console.log(' ⚠ ' + entry.name + ' now BLOCKS (was ' + prior + ') — left as-is, NOT reinstalled. Run `shucky scan ' + sourceStr + '`.');
672
+ } else {
673
+ const now = lock.getSkill(scope, entry.name, cwd);
674
+ console.log(' ' + entry.name + ': ' + prior + ' → ' + (now ? now.verdict : '?'));
675
+ }
676
+ }
677
+ }
678
+ if (only && !any) { console.error('update: "' + only + '" is not installed (or not auto-updatable)'); return 3; }
679
+ if (!any) console.log('nothing to update.');
680
+ return worst;
681
+ }
682
+
683
+ // ---- self-update (update shucky itself) ----------------------------------
684
+
685
+ function cmdSelfUpdate(args) {
686
+ const pkgRoot = path.resolve(__dirname, '..');
687
+ const version = require('../package.json').version;
688
+ const check = !!args.flags.check;
689
+
690
+ // Running ephemerally via npx — there's nothing installed in place to update.
691
+ if (pkgRoot.indexOf(path.sep + '_npx' + path.sep) !== -1 || pkgRoot.indexOf('/_npx/') !== -1) {
692
+ console.log('shucky ' + version + ' is running via npx (ephemeral) — nothing to update in place.');
693
+ console.log('just invoke a newer pinned version: npx @h0tp/shucky@<version> <command>');
694
+ return 0;
695
+ }
696
+
697
+ let label, cmd, cmdArgs, cwd;
698
+ if (fs.existsSync(path.join(pkgRoot, '.git'))) {
699
+ label = 'source / npm-link checkout (' + pkgRoot + ')';
700
+ cmd = 'git'; cmdArgs = ['-C', pkgRoot, 'pull', '--ff-only']; cwd = pkgRoot;
701
+ } else {
702
+ label = 'global npm install';
703
+ cmd = 'npm'; cmdArgs = ['install', '-g', '@h0tp/shucky@latest'];
704
+ }
705
+
706
+ console.log('shucky ' + version + ' · installed from: ' + label);
707
+ console.log((check ? '↳ would run: ' : '↳ running: ') + cmd + ' ' + cmdArgs.join(' '));
708
+ if (check) return 0;
709
+
710
+ try {
711
+ const out = require('child_process').execFileSync(cmd, cmdArgs, { cwd: cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
712
+ if (out && out.trim()) console.log(out.trim());
713
+ console.log('✓ updated — verify with: shucky --version');
714
+ return 0;
715
+ } catch (e) {
716
+ console.error('self-update failed: ' + ((e.stderr || e.message || '') + '').toString().trim());
717
+ console.error('run it manually: ' + cmd + ' ' + cmdArgs.join(' '));
718
+ return 3;
719
+ }
720
+ }
721
+
722
+ // ---- approve (unchanged) -------------------------------------------------
723
+
724
+ function cmdApprove(args) {
725
+ const source = args._[1];
726
+ if (!source) { console.error('approve: missing <owner/repo>'); return 3; }
727
+ const version = args.flags.at;
728
+ if (!version) { console.error('approve: missing --at <version|commit>'); return 3; }
729
+
730
+ const config = loadConfig(args.flags.config, {});
731
+ if (config.allowOverride === false) { console.error('approve: overrides are disabled (allowOverride=false)'); return 3; }
732
+ if (config.overrideRequiresReason && !args.flags.reason) { console.error('approve: --reason <text> is required'); return 3; }
733
+
734
+ const entry = {
735
+ source: source,
736
+ version: version,
737
+ reason: args.flags.reason || '',
738
+ date: new Date().toISOString().slice(0, 10),
739
+ approvedBy: args.flags.by || 'user'
740
+ };
741
+ let p;
742
+ try { p = addApproval(config, entry); }
743
+ catch (err) { console.error('approve error: ' + err.message); return 3; }
744
+ console.log('recorded approval: ' + source + '@' + version + ' → ' + p);
745
+ return 0;
746
+ }
747
+
748
+ // ---- dispatch ------------------------------------------------------------
749
+
750
+ async function runCli(argv) {
751
+ const args = parseArgs(argv);
752
+
753
+ if (args.flags.version && args._.length === 0) {
754
+ console.log(require('../package.json').version);
755
+ return 0;
756
+ }
757
+ const cmd = args._[0];
758
+ if (args.flags.help || args._.length === 0) {
759
+ console.log(helpFor(cmd));
760
+ return 0;
761
+ }
762
+
763
+ if (cmd === 'scan') return cmdScan(args);
764
+ if (cmd === 'install' || cmd === 'add' || cmd === 'i') return cmdInstall(args);
765
+ if (cmd === 'list' || cmd === 'ls') return cmdList(args);
766
+ if (cmd === 'source') return cmdSource(args);
767
+ if (cmd === 'remove' || cmd === 'rm' || cmd === 'uninstall') return cmdRemove(args);
768
+ if (cmd === 'update' || cmd === 'upgrade') return cmdUpdate(args);
769
+ if (cmd === 'self-update' || cmd === 'selfupdate') return cmdSelfUpdate(args);
770
+ if (cmd === 'find' || cmd === 'search' || cmd === 'f' || cmd === 's') return cmdFind(args);
771
+ if (cmd === 'approve') return cmdApprove(args);
772
+
773
+ console.error('unknown command: ' + cmd);
774
+ console.log(HELP);
775
+ return 3;
776
+ }
777
+
778
+ // cmdFind is defined in find.js wiring (added below via require) — placeholder until Phase-2 find lands.
779
+ let cmdFind = function () { console.error('find: not available'); return 3; };
780
+ try { cmdFind = require('./find').cmdFind; } catch (e) { /* find.js not present yet */ }
781
+
782
+ module.exports = { runCli, parseArgs, HELP, helpFor, cmdInstall, cmdScan, cmdList, cmdSource, cmdRemove, cmdUpdate, cmdSelfUpdate, gateDecision };