hookstack-cli 0.1.3 → 0.1.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/bin/cli.mjs +193 -109
- package/bin/core.mjs +191 -0
- package/package.json +5 -1
package/bin/cli.mjs
CHANGED
|
@@ -1,162 +1,246 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
|
|
3
|
+
import { homedir } from 'os'
|
|
3
4
|
import { join } from 'path'
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
if (arg === '--hooks' && args[i + 1]) {
|
|
21
|
-
result.hooks = args[++i].split(',').filter(Boolean)
|
|
22
|
-
continue
|
|
23
|
-
}
|
|
24
|
-
if (!result.command) result.command = arg
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return result
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function mergeHooks(existing, incoming) {
|
|
31
|
-
const merged = JSON.parse(JSON.stringify(existing))
|
|
32
|
-
for (const [event, entries] of Object.entries(incoming)) {
|
|
33
|
-
merged[event] ??= []
|
|
34
|
-
for (const entry of entries) {
|
|
35
|
-
const found = merged[event].find(e => (e.matcher ?? '') === (entry.matcher ?? ''))
|
|
36
|
-
if (found) {
|
|
37
|
-
found.hooks.push(...entry.hooks)
|
|
38
|
-
} else {
|
|
39
|
-
merged[event].push({ ...entry, hooks: [...entry.hooks] })
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return merged
|
|
44
|
-
}
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
import * as p from '@clack/prompts'
|
|
7
|
+
import pc from 'picocolors'
|
|
8
|
+
import {
|
|
9
|
+
parseArgs,
|
|
10
|
+
resolveScopeRoot,
|
|
11
|
+
mergeHooks,
|
|
12
|
+
assertSafeTarget,
|
|
13
|
+
collectIncomingHooks,
|
|
14
|
+
buildSummaryRows,
|
|
15
|
+
buildSecurityRows,
|
|
16
|
+
} from './core.mjs'
|
|
17
|
+
|
|
18
|
+
const API_BASE = process.env.HOOKSTACK_API_BASE || 'https://hookstack.vercel.app'
|
|
19
|
+
const VERSION = '0.1.3'
|
|
45
20
|
|
|
46
21
|
async function fetchHooks(slugs) {
|
|
47
|
-
const url = `${API_BASE}/api/hooks?slugs=${slugs.join(',')}`
|
|
22
|
+
const url = `${API_BASE}/api/hooks?slugs=${slugs.map(encodeURIComponent).join(',')}`
|
|
48
23
|
const res = await fetch(url)
|
|
49
24
|
if (!res.ok) {
|
|
50
|
-
const body = await res.text()
|
|
25
|
+
const body = await res.text().catch(() => '')
|
|
51
26
|
throw new Error(`API error ${res.status}: ${body}`)
|
|
52
27
|
}
|
|
53
28
|
return res.json()
|
|
54
29
|
}
|
|
55
30
|
|
|
56
|
-
|
|
57
|
-
console.log(`\nFetching ${slugs.length} hook${slugs.length > 1 ? 's' : ''}…`)
|
|
31
|
+
// ── panel rendering ────────────────────────────────────────────────────────
|
|
58
32
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
33
|
+
// Truncate to a visible width then pad — operate on PLAIN text only. Color is
|
|
34
|
+
// applied afterwards so ANSI codes never throw off column alignment.
|
|
35
|
+
function truncPad(value, width) {
|
|
36
|
+
const s = String(value ?? '')
|
|
37
|
+
return (s.length > width ? s.slice(0, width - 1) + '…' : s).padEnd(width)
|
|
38
|
+
}
|
|
66
39
|
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
40
|
+
export function summaryPanel(rows) {
|
|
41
|
+
const lines = []
|
|
42
|
+
for (const r of rows) {
|
|
43
|
+
lines.push(pc.cyan(r.path ?? `${r.name} (settings only)`))
|
|
44
|
+
const meta = [
|
|
45
|
+
r.category,
|
|
46
|
+
r.events.join(', ') + (r.blocking ? pc.yellow(' · can block') : ''),
|
|
47
|
+
r.matcher ? `matcher: ${r.matcher}` : null,
|
|
48
|
+
].filter(Boolean).join(pc.dim(' · '))
|
|
49
|
+
lines.push(' ' + pc.dim(meta))
|
|
50
|
+
if (r.source) lines.push(' ' + pc.dim(`source: ${r.source}`))
|
|
75
51
|
}
|
|
52
|
+
return lines.join('\n')
|
|
53
|
+
}
|
|
76
54
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
55
|
+
// Boolean capability cell: pad the plain "yes"/"no" first, then colorize.
|
|
56
|
+
function capCell(on, width) {
|
|
57
|
+
const text = (on ? 'yes' : 'no').padEnd(width)
|
|
58
|
+
return on ? pc.yellow(text) : pc.dim(text)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SNYK_COLOR = { high: pc.red, medium: pc.yellow, low: pc.cyan, safe: pc.green, unknown: pc.dim }
|
|
62
|
+
|
|
63
|
+
export function securityPanel(rows) {
|
|
64
|
+
const W = { name: 26, cap: 8, snyk: 10 }
|
|
65
|
+
const header = pc.dim(
|
|
66
|
+
''.padEnd(W.name) + 'Shell'.padEnd(W.cap) + 'Net'.padEnd(W.cap) +
|
|
67
|
+
'Writes'.padEnd(W.cap) + 'Snyk'.padEnd(W.snyk),
|
|
68
|
+
)
|
|
69
|
+
const body = rows.map(r =>
|
|
70
|
+
truncPad(r.name, W.name) +
|
|
71
|
+
capCell(r.shell, W.cap) +
|
|
72
|
+
capCell(r.network, W.cap) +
|
|
73
|
+
capCell(r.fsWrite, W.cap) +
|
|
74
|
+
(SNYK_COLOR[r.snyk.level] ?? pc.dim)(truncPad(r.snyk.label, W.snyk)),
|
|
75
|
+
)
|
|
76
|
+
const anyUnknown = rows.some(r => r.snyk.level === 'unknown')
|
|
77
|
+
const footer = [
|
|
78
|
+
'',
|
|
79
|
+
pc.dim('Shell = runs commands · Net = network access · Writes = filesystem writes'),
|
|
80
|
+
anyUnknown ? pc.dim('Snyk "—" = not scanned yet') : null,
|
|
81
|
+
pc.dim(`Details: ${API_BASE}/hook/<slug>`),
|
|
82
|
+
].filter(v => v !== null).join('\n')
|
|
83
|
+
return [header, ...body, footer].join('\n')
|
|
84
|
+
}
|
|
80
85
|
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
// ── install ──────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function doInstall(hooks, dirs, scope, log) {
|
|
89
|
+
mkdirSync(dirs.claudeDir, { recursive: true })
|
|
90
|
+
mkdirSync(dirs.hooksDir, { recursive: true })
|
|
83
91
|
|
|
84
92
|
let settings = {}
|
|
85
|
-
if (existsSync(settingsPath)) {
|
|
93
|
+
if (existsSync(dirs.settingsPath)) {
|
|
86
94
|
try {
|
|
87
|
-
settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
|
|
88
|
-
console.log(' Found existing settings.json — merging…')
|
|
95
|
+
settings = JSON.parse(readFileSync(dirs.settingsPath, 'utf8'))
|
|
89
96
|
} catch {
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const incomingHooks = {}
|
|
95
|
-
for (const hook of hooks) {
|
|
96
|
-
const fragment = hook.config?.hooks
|
|
97
|
-
if (!fragment) continue
|
|
98
|
-
for (const [event, entries] of Object.entries(fragment)) {
|
|
99
|
-
incomingHooks[event] ??= []
|
|
100
|
-
incomingHooks[event].push(...entries)
|
|
97
|
+
log.warn('Could not parse existing settings.json — starting fresh')
|
|
101
98
|
}
|
|
102
99
|
}
|
|
103
100
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
const incoming = collectIncomingHooks(hooks, { scope, globalRoot: dirs.root })
|
|
102
|
+
settings.hooks = mergeHooks(settings.hooks ?? {}, incoming)
|
|
103
|
+
writeFileSync(dirs.settingsPath, JSON.stringify(settings, null, 2) + '\n')
|
|
107
104
|
|
|
108
105
|
let scriptCount = 0
|
|
109
106
|
for (const hook of hooks) {
|
|
110
107
|
if (!hook.script_path || !hook.code_snippet) continue
|
|
111
|
-
|
|
108
|
+
assertSafeTarget(dirs.root, hook.script_path)
|
|
109
|
+
const dest = join(dirs.root, hook.script_path)
|
|
112
110
|
mkdirSync(join(dest, '..'), { recursive: true })
|
|
113
111
|
writeFileSync(dest, hook.code_snippet, 'utf8')
|
|
114
112
|
scriptCount++
|
|
115
|
-
console.log(` ✓ ${hook.script_path}`)
|
|
116
113
|
}
|
|
117
114
|
|
|
118
|
-
|
|
119
|
-
console.log(' Restart Claude Code to activate.\n')
|
|
115
|
+
return { hookCount: hooks.length, scriptCount }
|
|
120
116
|
}
|
|
121
117
|
|
|
122
|
-
|
|
123
|
-
const { command, hooks, help, version } = parseArgs(process.argv)
|
|
118
|
+
// ── flows ──────────────────────────────────────────────────────────────────
|
|
124
119
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
120
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
121
|
+
|
|
122
|
+
async function interactiveInstall(slugs, args) {
|
|
123
|
+
p.intro(pc.bgCyan(pc.black(' hookstack-cli ')))
|
|
124
|
+
|
|
125
|
+
const s = p.spinner()
|
|
126
|
+
s.start(`Fetching ${plural(slugs.length, 'hook')}`)
|
|
127
|
+
let data
|
|
128
|
+
try {
|
|
129
|
+
data = await fetchHooks(slugs)
|
|
130
|
+
} catch (e) {
|
|
131
|
+
s.stop(pc.red('Fetch failed'))
|
|
132
|
+
p.cancel(e.message)
|
|
133
|
+
process.exit(1)
|
|
134
|
+
}
|
|
135
|
+
const { hooks } = data
|
|
136
|
+
const notFound = slugs.filter(slug => !hooks.find(h => h.slug === slug))
|
|
137
|
+
s.stop(`Fetched ${plural(hooks.length, 'hook')}`)
|
|
138
|
+
if (notFound.length) p.log.warn(`Unknown slugs skipped: ${notFound.join(', ')}`)
|
|
139
|
+
if (hooks.length === 0) {
|
|
140
|
+
p.cancel('No hooks to install.')
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const scope = await p.select({
|
|
145
|
+
message: 'Installation scope',
|
|
146
|
+
initialValue: args.scope,
|
|
147
|
+
options: [
|
|
148
|
+
{ value: 'project', label: 'Project', hint: './.claude — committed with your project' },
|
|
149
|
+
{ value: 'global', label: 'Global', hint: '~/.claude — every project on this machine' },
|
|
150
|
+
],
|
|
151
|
+
})
|
|
152
|
+
if (p.isCancel(scope)) { p.cancel('Cancelled.'); process.exit(0) }
|
|
153
|
+
|
|
154
|
+
const dirs = resolveScopeRoot(scope, { cwd: process.cwd(), home: homedir() })
|
|
155
|
+
|
|
156
|
+
p.note(summaryPanel(buildSummaryRows(hooks, { root: dirs.root })), 'Installation Summary')
|
|
157
|
+
p.note(securityPanel(buildSecurityRows(hooks)), 'Security')
|
|
158
|
+
|
|
159
|
+
const ok = await p.confirm({ message: `Install ${plural(hooks.length, 'hook')} into ${scope === 'global' ? '~/.claude' : './.claude'}?` })
|
|
160
|
+
if (p.isCancel(ok) || !ok) { p.cancel('Cancelled.'); process.exit(0) }
|
|
161
|
+
|
|
162
|
+
const s2 = p.spinner()
|
|
163
|
+
s2.start('Installing')
|
|
164
|
+
let result
|
|
165
|
+
try {
|
|
166
|
+
result = doInstall(hooks, dirs, scope, p.log)
|
|
167
|
+
} catch (e) {
|
|
168
|
+
s2.stop(pc.red('Install failed'))
|
|
169
|
+
p.cancel(e.message)
|
|
170
|
+
process.exit(1)
|
|
128
171
|
}
|
|
172
|
+
s2.stop(`Wrote ${plural(result.scriptCount, 'script')} + patched settings.json`)
|
|
129
173
|
|
|
130
|
-
|
|
131
|
-
|
|
174
|
+
p.outro(pc.green(`✓ Installed ${plural(result.hookCount, 'hook')} — restart Claude Code to activate.`))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function directInstall(slugs, args) {
|
|
178
|
+
console.log(`\nFetching ${plural(slugs.length, 'hook')}…`)
|
|
179
|
+
let data
|
|
180
|
+
try {
|
|
181
|
+
data = await fetchHooks(slugs)
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.error(`\n✗ Failed: ${e.message}`)
|
|
184
|
+
process.exit(1)
|
|
185
|
+
}
|
|
186
|
+
const { hooks } = data
|
|
187
|
+
const notFound = slugs.filter(slug => !hooks.find(h => h.slug === slug))
|
|
188
|
+
if (notFound.length) console.warn(` ! Unknown slugs (skipped): ${notFound.join(', ')}`)
|
|
189
|
+
if (hooks.length === 0) {
|
|
190
|
+
console.error('\n✗ No hooks to install.')
|
|
191
|
+
process.exit(1)
|
|
192
|
+
}
|
|
193
|
+
const dirs = resolveScopeRoot(args.scope, { cwd: process.cwd(), home: homedir() })
|
|
194
|
+
const log = { warn: m => console.warn(` ! ${m}`) }
|
|
195
|
+
const result = doInstall(hooks, dirs, args.scope, log)
|
|
196
|
+
console.log(` ✓ ${dirs.settingsPath}`)
|
|
197
|
+
console.log(`\n✅ Installed ${plural(result.hookCount, 'hook')}${result.scriptCount ? ` + ${plural(result.scriptCount, 'script')}` : ''} (${args.scope}).`)
|
|
198
|
+
console.log(' Restart Claude Code to activate.\n')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const HELP = `
|
|
132
202
|
hookstack — Claude Code hook installer
|
|
133
203
|
|
|
134
204
|
Usage:
|
|
135
|
-
npx hookstack-cli
|
|
205
|
+
npx hookstack-cli@latest install --hooks=<slug1>,<slug2>,...
|
|
136
206
|
|
|
137
207
|
Options:
|
|
138
208
|
--hooks <slugs> Comma-separated list of hook slugs
|
|
209
|
+
--global, -g Install into ~/.claude instead of ./.claude
|
|
210
|
+
--scope <s> "project" (default) or "global"
|
|
211
|
+
--yes, -y Skip prompts (non-interactive install)
|
|
139
212
|
--version, -v Show version
|
|
140
213
|
--help, -h Show this help
|
|
141
214
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
215
|
+
Runs interactively in a terminal; falls back to a direct install when piped
|
|
216
|
+
or when --yes is passed. Browse hooks at https://hookstack.vercel.app
|
|
217
|
+
`
|
|
218
|
+
|
|
219
|
+
async function main() {
|
|
220
|
+
const args = parseArgs(process.argv)
|
|
221
|
+
|
|
222
|
+
if (args.version) { console.log(VERSION); return }
|
|
223
|
+
if (args.help || (!args.command && args.hooks.length === 0)) { console.log(HELP); return }
|
|
224
|
+
|
|
225
|
+
const command = args.command ?? 'install'
|
|
226
|
+
if (command !== 'install') {
|
|
227
|
+
console.error(`✗ Unknown command: ${command}`)
|
|
228
|
+
console.error(' Run --help for usage.')
|
|
229
|
+
process.exit(1)
|
|
145
230
|
}
|
|
146
231
|
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
process.exit(1)
|
|
152
|
-
}
|
|
153
|
-
await install(hooks, process.cwd())
|
|
154
|
-
return
|
|
232
|
+
if (args.hooks.length === 0) {
|
|
233
|
+
console.error('✗ No hooks specified. Use --hooks=<slug1>,<slug2>')
|
|
234
|
+
console.error(' Browse hooks at https://hookstack.vercel.app')
|
|
235
|
+
process.exit(1)
|
|
155
236
|
}
|
|
156
237
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
238
|
+
const interactive = Boolean(process.stdout.isTTY) && !args.yes
|
|
239
|
+
if (interactive) await interactiveInstall(args.hooks, args)
|
|
240
|
+
else await directInstall(args.hooks, args)
|
|
160
241
|
}
|
|
161
242
|
|
|
162
|
-
|
|
243
|
+
/* v8 ignore next 3 */
|
|
244
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
245
|
+
main()
|
|
246
|
+
}
|
package/bin/core.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Pure, dependency-free logic for the hookstack CLI.
|
|
2
|
+
// Everything here is side-effect free and unit-tested in isolation; the
|
|
3
|
+
// interactive I/O (clack/picocolors, fs, fetch) lives in cli.mjs. This mirrors
|
|
4
|
+
// the project's "pure run() + thin I/O guard" hook convention.
|
|
5
|
+
import { join, resolve, relative, isAbsolute } from 'path'
|
|
6
|
+
|
|
7
|
+
const BLOCKING_EVENTS = new Set([
|
|
8
|
+
'PreToolUse',
|
|
9
|
+
'UserPromptSubmit',
|
|
10
|
+
'PreCompact',
|
|
11
|
+
'PermissionRequest',
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
// Matches $CLAUDE_PROJECT_DIR and ${CLAUDE_PROJECT_DIR}.
|
|
15
|
+
export const PROJECT_DIR_RE = /\$\{?CLAUDE_PROJECT_DIR\}?/g
|
|
16
|
+
|
|
17
|
+
function splitList(raw) {
|
|
18
|
+
return raw.split(',').map(s => s.trim()).filter(Boolean)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseArgs(argv) {
|
|
22
|
+
const args = argv.slice(2)
|
|
23
|
+
const result = {
|
|
24
|
+
command: null,
|
|
25
|
+
hooks: [],
|
|
26
|
+
help: false,
|
|
27
|
+
version: false,
|
|
28
|
+
scope: 'project',
|
|
29
|
+
yes: false,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < args.length; i++) {
|
|
33
|
+
const arg = args[i]
|
|
34
|
+
if (arg === '--help' || arg === '-h') { result.help = true; continue }
|
|
35
|
+
if (arg === '--version' || arg === '-v') { result.version = true; continue }
|
|
36
|
+
if (arg === '--yes' || arg === '-y') { result.yes = true; continue }
|
|
37
|
+
if (arg === '--global' || arg === '-g') { result.scope = 'global'; continue }
|
|
38
|
+
if (arg === '--project') { result.scope = 'project'; continue }
|
|
39
|
+
if (arg.startsWith('--scope=')) {
|
|
40
|
+
const v = arg.slice('--scope='.length)
|
|
41
|
+
if (v === 'global' || v === 'project') result.scope = v
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (arg.startsWith('--hooks=')) { result.hooks = splitList(arg.slice('--hooks='.length)); continue }
|
|
45
|
+
if (arg === '--hooks' && args[i + 1]) { result.hooks = splitList(args[++i]); continue }
|
|
46
|
+
if (!result.command) result.command = arg
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Resolves where .claude/ lives for a given scope. Project → cwd; global → home.
|
|
53
|
+
export function resolveScopeRoot(scope, { cwd, home }) {
|
|
54
|
+
const root = scope === 'global' ? home : cwd
|
|
55
|
+
const claudeDir = join(root, '.claude')
|
|
56
|
+
return {
|
|
57
|
+
scope,
|
|
58
|
+
root,
|
|
59
|
+
claudeDir,
|
|
60
|
+
hooksDir: join(claudeDir, 'hooks'),
|
|
61
|
+
settingsPath: join(claudeDir, 'settings.json'),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Rejects target paths that would escape destDir, even if the registry JSON was
|
|
66
|
+
// tampered with. Adapted from hyperframes' installer.assertSafeTarget.
|
|
67
|
+
export function assertSafeTarget(destDir, target) {
|
|
68
|
+
if (isAbsolute(target)) {
|
|
69
|
+
throw new Error(`Unsafe path "${target}": absolute paths are not allowed.`)
|
|
70
|
+
}
|
|
71
|
+
if (/(^|[/\\])\.\.([/\\]|$)/.test(target)) {
|
|
72
|
+
throw new Error(`Unsafe path "${target}": ".." segments are not allowed.`)
|
|
73
|
+
}
|
|
74
|
+
if (/^[A-Za-z]:[/\\]/.test(target)) {
|
|
75
|
+
throw new Error(`Unsafe path "${target}": Windows drive letters are not allowed.`)
|
|
76
|
+
}
|
|
77
|
+
const resolved = resolve(destDir, target)
|
|
78
|
+
const rel = relative(resolve(destDir), resolved)
|
|
79
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
80
|
+
throw new Error(`Unsafe path "${target}": resolves outside ${destDir}.`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Merges incoming settings.json hook fragments into existing ones, grouping by
|
|
85
|
+
// event then by matcher (no overwrite). Same contract as src/lib/mergeConfig.
|
|
86
|
+
export function mergeHooks(existing, incoming) {
|
|
87
|
+
const merged = structuredClone(existing)
|
|
88
|
+
for (const [event, entries] of Object.entries(incoming)) {
|
|
89
|
+
merged[event] ??= []
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const found = merged[event].find(e => (e.matcher ?? '') === (entry.matcher ?? ''))
|
|
92
|
+
if (found) found.hooks.push(...entry.hooks)
|
|
93
|
+
else merged[event].push({ ...entry, hooks: [...entry.hooks] })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return merged
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Gathers the hook fragments from an API hook list into a single event→entries
|
|
100
|
+
// map. For global scope, rewrites $CLAUDE_PROJECT_DIR to the absolute global
|
|
101
|
+
// root so commands resolve outside any project.
|
|
102
|
+
export function collectIncomingHooks(hooks, { scope = 'project', globalRoot } = {}) {
|
|
103
|
+
const incoming = {}
|
|
104
|
+
for (const hook of hooks) {
|
|
105
|
+
const fragment = hook.config?.hooks
|
|
106
|
+
if (!fragment) continue
|
|
107
|
+
for (const [event, entries] of Object.entries(fragment)) {
|
|
108
|
+
incoming[event] ??= []
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
const rewrite = scope === 'global' && globalRoot
|
|
111
|
+
incoming[event].push({
|
|
112
|
+
...entry,
|
|
113
|
+
hooks: entry.hooks.map(h =>
|
|
114
|
+
rewrite && typeof h.command === 'string'
|
|
115
|
+
? { ...h, command: h.command.replace(PROJECT_DIR_RE, globalRoot) }
|
|
116
|
+
: h,
|
|
117
|
+
),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return incoming
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function isBlockingEvent(event) {
|
|
126
|
+
return BLOCKING_EVENTS.has(event)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Honest static read of what a hook's code does — no external service.
|
|
130
|
+
export function analyzeSecurity(codeSnippet) {
|
|
131
|
+
const code = codeSnippet ?? ''
|
|
132
|
+
const has = (...patterns) => patterns.some(re => re.test(code))
|
|
133
|
+
return {
|
|
134
|
+
shell: has(/\b(execSync|execFileSync|execFile|exec|spawnSync|spawn|fork)\s*\(/, /child_process/),
|
|
135
|
+
network: has(
|
|
136
|
+
/\bfetch\s*\(/,
|
|
137
|
+
/['"]node:(https?|net|dgram|dns)['"]/,
|
|
138
|
+
/\brequire\(\s*['"](https?|net|dgram|dns)['"]\s*\)/,
|
|
139
|
+
/\bfrom\s+['"](node:)?https?['"]/,
|
|
140
|
+
),
|
|
141
|
+
fsWrite: has(
|
|
142
|
+
/\b(writeFileSync|writeFile|appendFileSync|appendFile|rmSync|unlinkSync|unlink|mkdirSync|renameSync|rename|rmdirSync|cpSync)\s*\(/,
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Maps a stored Snyk scan ({high, medium, low}) to a short verdict label.
|
|
148
|
+
// Returns the "unknown" placeholder when no scan data is available yet.
|
|
149
|
+
export function snykVerdict(snyk) {
|
|
150
|
+
if (!snyk || typeof snyk !== 'object') return { label: '—', level: 'unknown' }
|
|
151
|
+
const { high = 0, medium = 0, low = 0 } = snyk
|
|
152
|
+
if (high > 0) return { label: 'High Risk', level: 'high' }
|
|
153
|
+
if (medium > 0) return { label: 'Med Risk', level: 'medium' }
|
|
154
|
+
if (low > 0) return { label: 'Low Risk', level: 'low' }
|
|
155
|
+
return { label: 'Safe', level: 'safe' }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function shortRepo(url) {
|
|
159
|
+
if (!url) return null
|
|
160
|
+
return String(url)
|
|
161
|
+
.replace(/^https?:\/\/github\.com\//, '')
|
|
162
|
+
.replace(/\.git$/, '')
|
|
163
|
+
.replace(/\/$/, '')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Display rows for the "Installation Summary" panel.
|
|
167
|
+
export function buildSummaryRows(hooks, { root }) {
|
|
168
|
+
return hooks.map(h => {
|
|
169
|
+
const events = h.config?.hooks ? Object.keys(h.config.hooks) : []
|
|
170
|
+
return {
|
|
171
|
+
slug: h.slug,
|
|
172
|
+
name: h.name ?? h.slug,
|
|
173
|
+
path: h.script_path ? join(root, h.script_path) : null,
|
|
174
|
+
category: h.category ?? null,
|
|
175
|
+
events,
|
|
176
|
+
blocking: events.some(isBlockingEvent),
|
|
177
|
+
matcher: h.trigger ?? null,
|
|
178
|
+
source: shortRepo(h.community_examples?.[0]?.repo),
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Display rows for the "Security" panel: local static capabilities + Snyk verdict.
|
|
184
|
+
export function buildSecurityRows(hooks) {
|
|
185
|
+
return hooks.map(h => ({
|
|
186
|
+
slug: h.slug,
|
|
187
|
+
name: h.name ?? h.slug,
|
|
188
|
+
...analyzeSecurity(h.code_snippet),
|
|
189
|
+
snyk: snykVerdict(h.security?.snyk),
|
|
190
|
+
}))
|
|
191
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hookstack-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "CLI installer for the Hookstack catalogue of Claude Code hooks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=18"
|
|
14
14
|
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@clack/prompts": "^1.0.0",
|
|
17
|
+
"picocolors": "^1.1.1"
|
|
18
|
+
},
|
|
15
19
|
"keywords": [
|
|
16
20
|
"claude-code",
|
|
17
21
|
"hooks",
|