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.
Files changed (3) hide show
  1. package/bin/cli.mjs +111 -72
  2. package/bin/core.mjs +13 -1
  3. 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
- function ask(question) {
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
- // ── panel rendering ────────────────────────────────────────────────────────
36
+ const BANNER = pc.cyan([
37
+ '██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗███████╗████████╗ █████╗ ██████╗██╗ ██╗',
38
+ '██║ ██║██╔═══██╗██╔═══██╗██║ ██╔╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝',
39
+ '███████║██║ ██║██║ ██║█████╔╝ ███████╗ ██║ ███████║██║ █████╔╝ ',
40
+ '██╔══██║██║ ██║██║ ██║██╔═██╗ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ ',
41
+ '██║ ██║╚██████╔╝╚██████╔╝██║ ██╗███████║ ██║ ██║ ██║╚██████╗██║ ██╗',
42
+ '╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝',
43
+ ].join('\n'))
41
44
 
42
- function truncPad(value, width) {
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(' ' + pc.dim(meta))
57
- if (r.source) lines.push(' ' + pc.dim(`source: ${r.source}`))
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 SNYK_COLOR = { high: pc.red, medium: pc.yellow, low: pc.cyan, safe: pc.green, unknown: pc.dim }
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: 26, cap: 8, snyk: 10 }
76
+ const W = { name: 24, benefit: 34, cap: 7, snyk: 9, codeql: 8 }
71
77
  const header = pc.dim(
72
- ''.padEnd(W.name) + 'Shell'.padEnd(W.cap) + 'Net'.padEnd(W.cap) +
73
- 'Writes'.padEnd(W.cap) + 'Snyk'.padEnd(W.snyk),
74
- )
75
- const body = rows.map(r =>
76
- truncPad(r.name, W.name) +
77
- capCell(r.shell, W.cap) +
78
- capCell(r.network, W.cap) +
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 anyUnknown = rows.some(r => r.snyk.level === 'unknown')
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
- ].filter(v => v !== null).join('\n')
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
- const isDefault = slugs.length === 0
130
- console.log(pc.bgCyan(pc.black(' hookstack-cli ')))
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
- console.error(pc.red(`\n✗ Fetch failed: ${e.message}`))
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
- console.log(isDefault
143
- ? `✓ Default HookStack ${plural(hooks.length, 'hook')}`
144
- : `✓ Fetched ${plural(hooks.length, 'hook')}`)
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 selection
166
+ // Scope
152
167
  let scope = args.scope
153
- console.log('\n Where to install?')
154
- console.log(` ${pc.cyan('1')} This project ${pc.dim('./.claude committed with your repo')}`)
155
- console.log(` ${pc.cyan('2')} All my projects ${pc.dim('~/.claude — every project on this machine')}`)
156
- console.log(` ${pc.cyan('3')} This GitHub Copilot project ${pc.dim('./.claude — settings.json adapted, committed with your repo')}`)
157
- const defaultChoice = scope === 'global' ? '2' : scope === 'copilot' ? '3' : '1'
158
- const scopeAnswer = await ask(` → [${defaultChoice}]: `)
159
- if (scopeAnswer === '2' || scopeAnswer === 'global') scope = 'global'
160
- else if (scopeAnswer === '3' || scopeAnswer === 'copilot') scope = 'copilot'
161
- else if (scopeAnswer === 'q') { console.log('Cancelled.'); process.exit(0) }
162
- else scope = 'project'
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
- console.log(`\n ${pc.bold(`${plural(hooks.length, 'Hook')} to install`)}`)
167
- console.log(summaryPanel(buildSummaryRows(hooks, { root: dirs.root })))
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
- const scopeLabel = scope === 'global' ? '~/.claude' : scope === 'copilot' ? './.claude (GitHub Copilot mode)' : './.claude'
172
- const confirmAnswer = await ask(`\n Install ${plural(hooks.length, 'hook')} into ${scopeLabel}? [Y/n]: `)
173
- if (confirmAnswer.toLowerCase() === 'n' || confirmAnswer.toLowerCase() === 'no') {
174
- console.log('Cancelled.')
175
- process.exit(0)
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
- console.log('\nInstalling…')
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 => console.warn(` ! ${m}`) })
210
+ result = doInstall(hooks, dirs, scope, { warn: m => p.log.warn(m) })
182
211
  } catch (e) {
183
- console.error(pc.red(`\n✗ Install failed: ${e.message}`))
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
- console.log(` ✓ ${dirs.settingsPath}`)
188
- console.log(`\n Browse more hooks ${pc.cyan(`${API_BASE}/#catalogue`)}`)
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 "Security" panel: local static capabilities + Snyk verdict.
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.17",
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": [