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.
Files changed (2) hide show
  1. package/bin/cli.mjs +96 -61
  2. 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
- 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
- }
35
+ // ── ASCII banner ──────────────────────────────────────────────────────────────
39
36
 
40
- // ── panel rendering ────────────────────────────────────────────────────────
37
+ const BANNER = pc.cyan([
38
+ '██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗███████╗████████╗ █████╗ ██████╗██╗ ██╗',
39
+ '██║ ██║██╔═══██╗██╔═══██╗██║ ██╔╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝',
40
+ '███████║██║ ██║██║ ██║█████╔╝ ███████╗ ██║ ███████║██║ █████╔╝ ',
41
+ '██╔══██║██║ ██║██║ ██║██╔═██╗ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ ',
42
+ '██║ ██║╚██████╔╝╚██████╔╝██║ ██╗███████║ ██║ ██║ ██║╚██████╗██║ ██╗',
43
+ '╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝',
44
+ ].join('\n'))
41
45
 
42
- function truncPad(value, width) {
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(' ' + pc.dim(meta))
57
- if (r.source) lines.push(' ' + pc.dim(`source: ${r.source}`))
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: 26, cap: 8, snyk: 10 }
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'.padEnd(W.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
- 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')}…`)
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
- console.error(pc.red(`\n✗ Fetch failed: ${e.message}`))
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
- 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
- }
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 selection
160
+ // Scope
152
161
  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'
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
- 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)))
170
-
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
- }
177
-
178
- console.log('\nInstalling…')
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 => console.warn(` ! ${m}`) })
206
+ result = doInstall(hooks, dirs, scope, { warn: m => p.log.warn(m) })
182
207
  } catch (e) {
183
- console.error(pc.red(`\n✗ Install failed: ${e.message}`))
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
- 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`))
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.17",
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": [