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.
Files changed (3) hide show
  1. package/bin/cli.mjs +193 -109
  2. package/bin/core.mjs +191 -0
  3. 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
- const API_BASE = 'https://hookstack.vercel.app'
6
- const VERSION = '0.1.0'
7
-
8
- function parseArgs(argv) {
9
- const args = argv.slice(2)
10
- const result = { command: null, hooks: [], help: false, version: false }
11
-
12
- for (let i = 0; i < args.length; i++) {
13
- const arg = args[i]
14
- if (arg === '--help' || arg === '-h') { result.help = true; continue }
15
- if (arg === '--version' || arg === '-v') { result.version = true; continue }
16
- if (arg.startsWith('--hooks=')) {
17
- result.hooks = arg.slice('--hooks='.length).split(',').filter(Boolean)
18
- continue
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
- async function install(slugs, root) {
57
- console.log(`\nFetching ${slugs.length} hook${slugs.length > 1 ? 's' : ''}…`)
31
+ // ── panel rendering ────────────────────────────────────────────────────────
58
32
 
59
- let data
60
- try {
61
- data = await fetchHooks(slugs)
62
- } catch (e) {
63
- console.error(`\n✗ Failed to fetch hooks: ${e.message}`)
64
- process.exit(1)
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
- const { hooks } = data
68
- const notFound = slugs.filter(s => !hooks.find(h => h.slug === s))
69
- if (notFound.length > 0) {
70
- console.warn(` ! Unknown slugs (skipped): ${notFound.join(', ')}`)
71
- }
72
- if (hooks.length === 0) {
73
- console.error('\n✗ No hooks to install.')
74
- process.exit(1)
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
- const claudeDir = join(root, '.claude')
78
- const settingsPath = join(claudeDir, 'settings.json')
79
- const hooksDir = join(claudeDir, 'hooks')
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
- mkdirSync(claudeDir, { recursive: true })
82
- mkdirSync(hooksDir, { recursive: true })
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
- console.warn(' ! Could not parse settings.json — starting fresh')
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
- settings.hooks = mergeHooks(settings.hooks ?? {}, incomingHooks)
105
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n')
106
- console.log(' ✓ .claude/settings.json updated')
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
- const dest = join(root, hook.script_path)
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
- console.log(`\n✅ Installed ${hooks.length} hook${hooks.length > 1 ? 's' : ''}${scriptCount > 0 ? ` + ${scriptCount} script${scriptCount > 1 ? 's' : ''}` : ''}.`)
119
- console.log(' Restart Claude Code to activate.\n')
115
+ return { hookCount: hooks.length, scriptCount }
120
116
  }
121
117
 
122
- async function main() {
123
- const { command, hooks, help, version } = parseArgs(process.argv)
118
+ // ── flows ──────────────────────────────────────────────────────────────────
124
119
 
125
- if (version) {
126
- console.log(VERSION)
127
- return
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
- if (help || (!command && hooks.length === 0)) {
131
- console.log(`
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-cli install --hooks=<slug1>,<slug2>,...
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
- Browse hooks at https://hookstack.vercel.app
143
- `)
144
- return
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 (command === 'install' || command === null) {
148
- if (hooks.length === 0) {
149
- console.error(' No hooks specified. Use --hooks=<slug1>,<slug2>')
150
- console.error(' Browse hooks at https://hookstack.vercel.app')
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
- console.error(`✗ Unknown command: ${command}`)
158
- console.error(' Run --help for usage.')
159
- process.exit(1)
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
- main()
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",
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",