hookstack-cli 0.1.17 → 0.1.19
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 +111 -72
- package/bin/core.mjs +13 -1
- package/package.json +2 -1
package/bin/cli.mjs
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
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,
|
|
11
11
|
mergeHooks,
|
|
12
12
|
assertSafeTarget,
|
|
13
13
|
collectIncomingHooks,
|
|
14
|
-
buildSummaryRows,
|
|
15
14
|
buildSecurityRows,
|
|
16
15
|
} from './core.mjs'
|
|
17
16
|
|
|
@@ -32,17 +31,18 @@ async function fetchHooks(slugs) {
|
|
|
32
31
|
return res.json()
|
|
33
32
|
}
|
|
34
33
|
|
|
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
|
-
}
|
|
34
|
+
// ── ASCII banner ──────────────────────────────────────────────────────────────
|
|
39
35
|
|
|
40
|
-
|
|
36
|
+
const BANNER = pc.cyan([
|
|
37
|
+
'██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗███████╗████████╗ █████╗ ██████╗██╗ ██╗',
|
|
38
|
+
'██║ ██║██╔═══██╗██╔═══██╗██║ ██╔╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝',
|
|
39
|
+
'███████║██║ ██║██║ ██║█████╔╝ ███████╗ ██║ ███████║██║ █████╔╝ ',
|
|
40
|
+
'██╔══██║██║ ██║██║ ██║██╔═██╗ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ ',
|
|
41
|
+
'██║ ██║╚██████╔╝╚██████╔╝██║ ██╗███████║ ██║ ██║ ██║╚██████╗██║ ██╗',
|
|
42
|
+
'╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝',
|
|
43
|
+
].join('\n'))
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
const s = String(value ?? '')
|
|
44
|
-
return (s.length > width ? s.slice(0, width - 1) + '…' : s).padEnd(width)
|
|
45
|
-
}
|
|
45
|
+
// ── panel content builders ────────────────────────────────────────────────────
|
|
46
46
|
|
|
47
47
|
export function summaryPanel(rows) {
|
|
48
48
|
const lines = []
|
|
@@ -50,13 +50,19 @@ export function summaryPanel(rows) {
|
|
|
50
50
|
lines.push(pc.cyan(r.path ?? `${r.name} (settings only)`))
|
|
51
51
|
const meta = [
|
|
52
52
|
r.category,
|
|
53
|
-
r.events.join(', ') + (r.blocking ? pc.yellow(' · can block') : ''),
|
|
53
|
+
r.events.join(', ') + (r.blocking ? pc.yellow(' · ⚡ can block') : ''),
|
|
54
54
|
r.matcher ? `matcher: ${r.matcher}` : null,
|
|
55
55
|
].filter(Boolean).join(pc.dim(' · '))
|
|
56
|
-
lines.push(' ' +
|
|
57
|
-
if (r.source) lines.push(
|
|
56
|
+
lines.push(pc.dim(' ' + meta))
|
|
57
|
+
if (r.source) lines.push(pc.dim(` source: ${r.source}`))
|
|
58
|
+
lines.push('')
|
|
58
59
|
}
|
|
59
|
-
return lines.join('\n')
|
|
60
|
+
return lines.join('\n').trimEnd()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function truncPad(value, width) {
|
|
64
|
+
const s = String(value ?? '')
|
|
65
|
+
return (s.length > width ? s.slice(0, width - 1) + '…' : s).padEnd(width)
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
function capCell(on, width) {
|
|
@@ -64,32 +70,39 @@ function capCell(on, width) {
|
|
|
64
70
|
return on ? pc.yellow(text) : pc.dim(text)
|
|
65
71
|
}
|
|
66
72
|
|
|
67
|
-
const
|
|
73
|
+
const VERDICT_COLOR = { high: pc.red, medium: pc.yellow, low: pc.cyan, safe: pc.green, unknown: pc.dim }
|
|
68
74
|
|
|
69
75
|
export function securityPanel(rows) {
|
|
70
|
-
const W = { name:
|
|
76
|
+
const W = { name: 24, benefit: 34, cap: 7, snyk: 9, codeql: 8 }
|
|
71
77
|
const header = pc.dim(
|
|
72
|
-
''.padEnd(W.name) +
|
|
73
|
-
'
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
capCell(r.fsWrite, W.cap) +
|
|
80
|
-
(SNYK_COLOR[r.snyk.level] ?? pc.dim)(truncPad(r.snyk.label, W.snyk)),
|
|
78
|
+
''.padEnd(W.name) +
|
|
79
|
+
''.padEnd(W.benefit) +
|
|
80
|
+
'Shell'.padEnd(W.cap) +
|
|
81
|
+
'Net'.padEnd(W.cap) +
|
|
82
|
+
'Writes'.padEnd(W.cap) +
|
|
83
|
+
'Snyk'.padEnd(W.snyk) +
|
|
84
|
+
'CodeQL',
|
|
81
85
|
)
|
|
82
|
-
const
|
|
86
|
+
const body = rows.flatMap(r => {
|
|
87
|
+
const benefitText = r.benefit ? truncPad(r.benefit, W.benefit) : ''.padEnd(W.benefit)
|
|
88
|
+
const row = truncPad(r.name, W.name) +
|
|
89
|
+
pc.dim(benefitText) +
|
|
90
|
+
capCell(r.shell, W.cap) +
|
|
91
|
+
capCell(r.network, W.cap) +
|
|
92
|
+
capCell(r.fsWrite, W.cap) +
|
|
93
|
+
(VERDICT_COLOR[r.snyk.level] ?? pc.dim)(truncPad(r.snyk.label, W.snyk)) +
|
|
94
|
+
(VERDICT_COLOR[r.codeql.level] ?? pc.dim)(r.codeql.label)
|
|
95
|
+
return [row]
|
|
96
|
+
})
|
|
83
97
|
const footer = [
|
|
84
98
|
'',
|
|
85
99
|
pc.dim('Shell = runs commands · Net = network access · Writes = filesystem writes'),
|
|
86
|
-
anyUnknown ? pc.dim('Snyk "—" = not scanned yet') : null,
|
|
87
100
|
pc.dim(`Details: ${API_BASE}/hook/<slug>`),
|
|
88
|
-
].
|
|
89
|
-
return [header, ...body, footer].join('\n')
|
|
101
|
+
].join('\n')
|
|
102
|
+
return [header, '', ...body, footer].join('\n')
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
// ── install
|
|
105
|
+
// ── install ───────────────────────────────────────────────────────────────────
|
|
93
106
|
|
|
94
107
|
function doInstall(hooks, dirs, scope, log) {
|
|
95
108
|
mkdirSync(dirs.claudeDir, { recursive: true })
|
|
@@ -121,73 +134,99 @@ function doInstall(hooks, dirs, scope, log) {
|
|
|
121
134
|
return { hookCount: hooks.length, scriptCount }
|
|
122
135
|
}
|
|
123
136
|
|
|
124
|
-
// ── flows
|
|
137
|
+
// ── flows ─────────────────────────────────────────────────────────────────────
|
|
125
138
|
|
|
126
139
|
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
127
140
|
|
|
141
|
+
function onCancel() {
|
|
142
|
+
p.cancel('Installation cancelled.')
|
|
143
|
+
process.exit(0)
|
|
144
|
+
}
|
|
145
|
+
|
|
128
146
|
async function interactiveInstall(slugs, args) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
console.log(isDefault ? '\nFetching default HookStack…' : `\nFetching ${plural(slugs.length, 'hook')}…`)
|
|
147
|
+
console.log('\n' + BANNER + '\n')
|
|
148
|
+
p.intro(pc.bold('hooks') + pc.dim(` Claude Code hook installer v${VERSION}`))
|
|
132
149
|
|
|
150
|
+
// Fetch
|
|
151
|
+
const spin = p.spinner()
|
|
152
|
+
spin.start(slugs.length === 0 ? 'Fetching default HookStack…' : `Fetching ${plural(slugs.length, 'hook')}…`)
|
|
133
153
|
let data
|
|
134
154
|
try {
|
|
135
155
|
data = await fetchHooks(slugs)
|
|
136
156
|
} catch (e) {
|
|
137
|
-
|
|
157
|
+
spin.stop(pc.red(`Fetch failed: ${e.message}`), 1)
|
|
138
158
|
process.exit(1)
|
|
139
159
|
}
|
|
140
160
|
const { hooks } = data
|
|
141
161
|
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
|
-
}
|
|
162
|
+
spin.stop(`Selected ${pc.bold(String(hooks.length))} hook${hooks.length === 1 ? '' : 's'}`)
|
|
163
|
+
if (notFound.length) p.log.warn(`Unknown slugs skipped: ${notFound.join(', ')}`)
|
|
164
|
+
if (hooks.length === 0) { p.cancel('No hooks to install.'); process.exit(1) }
|
|
150
165
|
|
|
151
|
-
// Scope
|
|
166
|
+
// Scope
|
|
152
167
|
let scope = args.scope
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
const scopeResult = await p.select({
|
|
169
|
+
message: 'Where to install?',
|
|
170
|
+
options: [
|
|
171
|
+
{
|
|
172
|
+
value: 'project',
|
|
173
|
+
label: 'This project',
|
|
174
|
+
hint: './.claude — committed with your repo',
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
value: 'global',
|
|
178
|
+
label: 'All my projects',
|
|
179
|
+
hint: '~/.claude — every project on this machine',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
value: 'copilot',
|
|
183
|
+
label: 'GitHub Copilot project',
|
|
184
|
+
hint: './.claude — paths adapted for Copilot',
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
initialValue: scope,
|
|
188
|
+
})
|
|
189
|
+
if (p.isCancel(scopeResult)) return onCancel()
|
|
190
|
+
scope = scopeResult
|
|
163
191
|
|
|
164
192
|
const dirs = resolveScopeRoot(scope, { cwd: process.cwd(), home: homedir() })
|
|
193
|
+
const secRows = buildSecurityRows(hooks)
|
|
165
194
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
console.log(`\n ${pc.bold('Security')}`)
|
|
169
|
-
console.log(securityPanel(buildSecurityRows(hooks)))
|
|
195
|
+
// Panel
|
|
196
|
+
p.note(securityPanel(secRows), 'Installation Summary')
|
|
170
197
|
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
198
|
+
// Confirm
|
|
199
|
+
const scopeLabel = scope === 'global' ? '~/.claude' : scope === 'copilot' ? './.claude (Copilot mode)' : './.claude'
|
|
200
|
+
const confirmed = await p.confirm({
|
|
201
|
+
message: `Install ${plural(hooks.length, 'hook')} into ${pc.cyan(scopeLabel)}?`,
|
|
202
|
+
})
|
|
203
|
+
if (p.isCancel(confirmed) || !confirmed) return onCancel()
|
|
177
204
|
|
|
178
|
-
|
|
205
|
+
// Install
|
|
206
|
+
const spin2 = p.spinner()
|
|
207
|
+
spin2.start('Installing…')
|
|
179
208
|
let result
|
|
180
209
|
try {
|
|
181
|
-
result = doInstall(hooks, dirs, scope, { warn: m =>
|
|
210
|
+
result = doInstall(hooks, dirs, scope, { warn: m => p.log.warn(m) })
|
|
182
211
|
} catch (e) {
|
|
183
|
-
|
|
212
|
+
spin2.stop(pc.red(`Install failed: ${e.message}`), 1)
|
|
184
213
|
process.exit(1)
|
|
185
214
|
}
|
|
215
|
+
spin2.stop(`Installed ${pc.bold(String(result.hookCount))} hook${result.hookCount === 1 ? '' : 's'}`)
|
|
216
|
+
|
|
217
|
+
// Result panel
|
|
218
|
+
const resultLines = [
|
|
219
|
+
pc.green(`✓ ${dirs.settingsPath}`),
|
|
220
|
+
result.scriptCount > 0
|
|
221
|
+
? pc.green(`✓ ${result.scriptCount} script${result.scriptCount === 1 ? '' : 's'} written to ${dirs.hooksDir}`)
|
|
222
|
+
: null,
|
|
223
|
+
'',
|
|
224
|
+
`Browse more hooks ${pc.cyan(`${API_BASE}/#catalogue`)}`,
|
|
225
|
+
`⭐ Star us ${pc.cyan(REPO_URL)}`,
|
|
226
|
+
].filter(v => v !== null).join('\n')
|
|
186
227
|
|
|
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`))
|
|
228
|
+
p.note(resultLines, 'Resume installation')
|
|
229
|
+
p.outro(pc.green('Done!') + pc.dim(" You've just HookStacked your agent workflow."))
|
|
191
230
|
}
|
|
192
231
|
|
|
193
232
|
async function directInstall(slugs, args) {
|
package/bin/core.mjs
CHANGED
|
@@ -159,6 +159,16 @@ export function snykVerdict(snyk) {
|
|
|
159
159
|
return { label: 'Safe', level: 'safe' }
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// Maps a stored CodeQL scan to a short verdict label.
|
|
163
|
+
export function codeqlVerdict(codeql) {
|
|
164
|
+
if (!codeql || typeof codeql !== 'object') return { label: '—', level: 'unknown' }
|
|
165
|
+
const { critical = 0, high = 0, medium = 0, low = 0 } = codeql
|
|
166
|
+
if (critical > 0 || high > 0) return { label: 'High Risk', level: 'high' }
|
|
167
|
+
if (medium > 0) return { label: 'Med Risk', level: 'medium' }
|
|
168
|
+
if (low > 0) return { label: 'Low Risk', level: 'low' }
|
|
169
|
+
return { label: 'Safe', level: 'safe' }
|
|
170
|
+
}
|
|
171
|
+
|
|
162
172
|
export function shortRepo(url) {
|
|
163
173
|
if (!url) return null
|
|
164
174
|
return String(url)
|
|
@@ -184,12 +194,14 @@ export function buildSummaryRows(hooks, { root }) {
|
|
|
184
194
|
})
|
|
185
195
|
}
|
|
186
196
|
|
|
187
|
-
// Display rows for the "
|
|
197
|
+
// Display rows for the "Installation Summary" panel: description + static capabilities + verdicts.
|
|
188
198
|
export function buildSecurityRows(hooks) {
|
|
189
199
|
return hooks.map(h => ({
|
|
190
200
|
slug: h.slug,
|
|
191
201
|
name: h.name ?? h.slug,
|
|
202
|
+
benefit: h.benefit ?? null,
|
|
192
203
|
...analyzeSecurity(h.code_snippet),
|
|
193
204
|
snyk: snykVerdict(h.security?.snyk),
|
|
205
|
+
codeql: codeqlVerdict(h.security?.codeql),
|
|
194
206
|
}))
|
|
195
207
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hookstack-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
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": [
|