hookstack-cli 0.1.17 → 0.1.18
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/bin/cli.mjs +96 -61
- package/package.json +2 -1
package/bin/cli.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs'
|
|
3
|
-
import { createInterface } from 'readline'
|
|
4
3
|
import { homedir } from 'os'
|
|
5
4
|
import { join, dirname } from 'path'
|
|
6
5
|
import { fileURLToPath } from 'url'
|
|
7
6
|
import pc from 'picocolors'
|
|
7
|
+
import * as p from '@clack/prompts'
|
|
8
8
|
import {
|
|
9
9
|
parseArgs,
|
|
10
10
|
resolveScopeRoot,
|
|
@@ -32,17 +32,18 @@ async function fetchHooks(slugs) {
|
|
|
32
32
|
return res.json()
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
37
|
-
return new Promise(resolve => rl.question(question, a => { rl.close(); resolve(a.trim()) }))
|
|
38
|
-
}
|
|
35
|
+
// ── ASCII banner ──────────────────────────────────────────────────────────────
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
const BANNER = pc.cyan([
|
|
38
|
+
'██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗███████╗████████╗ █████╗ ██████╗██╗ ██╗',
|
|
39
|
+
'██║ ██║██╔═══██╗██╔═══██╗██║ ██╔╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝',
|
|
40
|
+
'███████║██║ ██║██║ ██║█████╔╝ ███████╗ ██║ ███████║██║ █████╔╝ ',
|
|
41
|
+
'██╔══██║██║ ██║██║ ██║██╔═██╗ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ ',
|
|
42
|
+
'██║ ██║╚██████╔╝╚██████╔╝██║ ██╗███████║ ██║ ██║ ██║╚██████╗██║ ██╗',
|
|
43
|
+
'╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝',
|
|
44
|
+
].join('\n'))
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
const s = String(value ?? '')
|
|
44
|
-
return (s.length > width ? s.slice(0, width - 1) + '…' : s).padEnd(width)
|
|
45
|
-
}
|
|
46
|
+
// ── panel content builders ────────────────────────────────────────────────────
|
|
46
47
|
|
|
47
48
|
export function summaryPanel(rows) {
|
|
48
49
|
const lines = []
|
|
@@ -50,13 +51,19 @@ export function summaryPanel(rows) {
|
|
|
50
51
|
lines.push(pc.cyan(r.path ?? `${r.name} (settings only)`))
|
|
51
52
|
const meta = [
|
|
52
53
|
r.category,
|
|
53
|
-
r.events.join(', ') + (r.blocking ? pc.yellow(' · can block') : ''),
|
|
54
|
+
r.events.join(', ') + (r.blocking ? pc.yellow(' · ⚡ can block') : ''),
|
|
54
55
|
r.matcher ? `matcher: ${r.matcher}` : null,
|
|
55
56
|
].filter(Boolean).join(pc.dim(' · '))
|
|
56
|
-
lines.push(' ' +
|
|
57
|
-
if (r.source) lines.push(
|
|
57
|
+
lines.push(pc.dim(' ' + meta))
|
|
58
|
+
if (r.source) lines.push(pc.dim(` source: ${r.source}`))
|
|
59
|
+
lines.push('')
|
|
58
60
|
}
|
|
59
|
-
return lines.join('\n')
|
|
61
|
+
return lines.join('\n').trimEnd()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function truncPad(value, width) {
|
|
65
|
+
const s = String(value ?? '')
|
|
66
|
+
return (s.length > width ? s.slice(0, width - 1) + '…' : s).padEnd(width)
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
function capCell(on, width) {
|
|
@@ -67,10 +74,10 @@ function capCell(on, width) {
|
|
|
67
74
|
const SNYK_COLOR = { high: pc.red, medium: pc.yellow, low: pc.cyan, safe: pc.green, unknown: pc.dim }
|
|
68
75
|
|
|
69
76
|
export function securityPanel(rows) {
|
|
70
|
-
const W = { name:
|
|
77
|
+
const W = { name: 28, cap: 8, snyk: 10 }
|
|
71
78
|
const header = pc.dim(
|
|
72
79
|
''.padEnd(W.name) + 'Shell'.padEnd(W.cap) + 'Net'.padEnd(W.cap) +
|
|
73
|
-
'Writes'.padEnd(W.cap) + 'Snyk'
|
|
80
|
+
'Writes'.padEnd(W.cap) + 'Snyk',
|
|
74
81
|
)
|
|
75
82
|
const body = rows.map(r =>
|
|
76
83
|
truncPad(r.name, W.name) +
|
|
@@ -89,7 +96,7 @@ export function securityPanel(rows) {
|
|
|
89
96
|
return [header, ...body, footer].join('\n')
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
// ── install
|
|
99
|
+
// ── install ───────────────────────────────────────────────────────────────────
|
|
93
100
|
|
|
94
101
|
function doInstall(hooks, dirs, scope, log) {
|
|
95
102
|
mkdirSync(dirs.claudeDir, { recursive: true })
|
|
@@ -121,73 +128,101 @@ function doInstall(hooks, dirs, scope, log) {
|
|
|
121
128
|
return { hookCount: hooks.length, scriptCount }
|
|
122
129
|
}
|
|
123
130
|
|
|
124
|
-
// ── flows
|
|
131
|
+
// ── flows ─────────────────────────────────────────────────────────────────────
|
|
125
132
|
|
|
126
133
|
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
127
134
|
|
|
135
|
+
function onCancel() {
|
|
136
|
+
p.cancel('Installation cancelled.')
|
|
137
|
+
process.exit(0)
|
|
138
|
+
}
|
|
139
|
+
|
|
128
140
|
async function interactiveInstall(slugs, args) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
console.log(isDefault ? '\nFetching default HookStack…' : `\nFetching ${plural(slugs.length, 'hook')}…`)
|
|
141
|
+
console.log('\n' + BANNER + '\n')
|
|
142
|
+
p.intro(pc.bold('hooks') + pc.dim(` Claude Code hook installer v${VERSION}`))
|
|
132
143
|
|
|
144
|
+
// Fetch
|
|
145
|
+
const spin = p.spinner()
|
|
146
|
+
spin.start(slugs.length === 0 ? 'Fetching default HookStack…' : `Fetching ${plural(slugs.length, 'hook')}…`)
|
|
133
147
|
let data
|
|
134
148
|
try {
|
|
135
149
|
data = await fetchHooks(slugs)
|
|
136
150
|
} catch (e) {
|
|
137
|
-
|
|
151
|
+
spin.stop(pc.red(`Fetch failed: ${e.message}`), 1)
|
|
138
152
|
process.exit(1)
|
|
139
153
|
}
|
|
140
154
|
const { hooks } = data
|
|
141
155
|
const notFound = slugs.filter(slug => !hooks.find(h => h.slug === slug))
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (notFound.length) console.warn(` ! Unknown slugs skipped: ${notFound.join(', ')}`)
|
|
146
|
-
if (hooks.length === 0) {
|
|
147
|
-
console.error('\n✗ No hooks to install.')
|
|
148
|
-
process.exit(1)
|
|
149
|
-
}
|
|
156
|
+
spin.stop(`Selected ${pc.bold(String(hooks.length))} hook${hooks.length === 1 ? '' : 's'}`)
|
|
157
|
+
if (notFound.length) p.log.warn(`Unknown slugs skipped: ${notFound.join(', ')}`)
|
|
158
|
+
if (hooks.length === 0) { p.cancel('No hooks to install.'); process.exit(1) }
|
|
150
159
|
|
|
151
|
-
// Scope
|
|
160
|
+
// Scope
|
|
152
161
|
let scope = args.scope
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
const scopeResult = await p.select({
|
|
163
|
+
message: 'Where to install?',
|
|
164
|
+
options: [
|
|
165
|
+
{
|
|
166
|
+
value: 'project',
|
|
167
|
+
label: 'This project',
|
|
168
|
+
hint: './.claude — committed with your repo',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
value: 'global',
|
|
172
|
+
label: 'All my projects',
|
|
173
|
+
hint: '~/.claude — every project on this machine',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
value: 'copilot',
|
|
177
|
+
label: 'GitHub Copilot project',
|
|
178
|
+
hint: './.claude — paths adapted for Copilot',
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
initialValue: scope,
|
|
182
|
+
})
|
|
183
|
+
if (p.isCancel(scopeResult)) return onCancel()
|
|
184
|
+
scope = scopeResult
|
|
163
185
|
|
|
164
186
|
const dirs = resolveScopeRoot(scope, { cwd: process.cwd(), home: homedir() })
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
const summaryRows = buildSummaryRows(hooks, { root: dirs.root })
|
|
188
|
+
const secRows = buildSecurityRows(hooks)
|
|
189
|
+
|
|
190
|
+
// Panels
|
|
191
|
+
p.note(summaryPanel(summaryRows), 'Installation Summary')
|
|
192
|
+
p.note(securityPanel(secRows), 'Security Assessment')
|
|
193
|
+
|
|
194
|
+
// Confirm
|
|
195
|
+
const scopeLabel = scope === 'global' ? '~/.claude' : scope === 'copilot' ? './.claude (Copilot mode)' : './.claude'
|
|
196
|
+
const confirmed = await p.confirm({
|
|
197
|
+
message: `Install ${plural(hooks.length, 'hook')} into ${pc.cyan(scopeLabel)}?`,
|
|
198
|
+
})
|
|
199
|
+
if (p.isCancel(confirmed) || !confirmed) return onCancel()
|
|
200
|
+
|
|
201
|
+
// Install
|
|
202
|
+
const spin2 = p.spinner()
|
|
203
|
+
spin2.start('Installing…')
|
|
179
204
|
let result
|
|
180
205
|
try {
|
|
181
|
-
result = doInstall(hooks, dirs, scope, { warn: m =>
|
|
206
|
+
result = doInstall(hooks, dirs, scope, { warn: m => p.log.warn(m) })
|
|
182
207
|
} catch (e) {
|
|
183
|
-
|
|
208
|
+
spin2.stop(pc.red(`Install failed: ${e.message}`), 1)
|
|
184
209
|
process.exit(1)
|
|
185
210
|
}
|
|
211
|
+
spin2.stop(`Installed ${pc.bold(String(result.hookCount))} hook${result.hookCount === 1 ? '' : 's'}`)
|
|
212
|
+
|
|
213
|
+
// Result panel
|
|
214
|
+
const resultLines = [
|
|
215
|
+
pc.green(`✓ ${dirs.settingsPath}`),
|
|
216
|
+
result.scriptCount > 0
|
|
217
|
+
? pc.green(`✓ ${result.scriptCount} script${result.scriptCount === 1 ? '' : 's'} written to ${dirs.hooksDir}`)
|
|
218
|
+
: null,
|
|
219
|
+
'',
|
|
220
|
+
`Browse more hooks ${pc.cyan(`${API_BASE}/#catalogue`)}`,
|
|
221
|
+
`⭐ Star us ${pc.cyan(REPO_URL)}`,
|
|
222
|
+
].filter(v => v !== null).join('\n')
|
|
186
223
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
console.log(` ⭐ star us → ${pc.cyan(REPO_URL)}`)
|
|
190
|
-
console.log(pc.green(`\n✓ ${plural(result.hookCount, 'hook')} installed — restart Claude Code to activate.\n`))
|
|
224
|
+
p.note(resultLines, 'Resume installation')
|
|
225
|
+
p.outro(pc.green('Done!') + pc.dim(" You've just HookStacked your agent workflow."))
|
|
191
226
|
}
|
|
192
227
|
|
|
193
228
|
async function directInstall(slugs, args) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hookstack-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"description": "CLI installer for the Hookstack catalogue of Claude Code hooks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"node": ">=18"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
+
"@clack/prompts": "^0.9.1",
|
|
17
18
|
"picocolors": "^1.1.1"
|
|
18
19
|
},
|
|
19
20
|
"keywords": [
|