hookstack-cli 0.1.16 → 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 (3) hide show
  1. package/bin/cli.mjs +98 -58
  2. package/bin/core.mjs +11 -7
  3. 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,69 +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
- const scopeAnswer = await ask(` → [${scope === 'global' ? '2' : '1'}]: `)
157
- if (scopeAnswer === '2' || scopeAnswer === 'global') scope = 'global'
158
- else if (scopeAnswer === 'q') { console.log('Cancelled.'); process.exit(0) }
159
- 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
160
185
 
161
186
  const dirs = resolveScopeRoot(scope, { cwd: process.cwd(), home: homedir() })
162
-
163
- console.log(`\n ${pc.bold(`${plural(hooks.length, 'Hook')} to install`)}`)
164
- console.log(summaryPanel(buildSummaryRows(hooks, { root: dirs.root })))
165
- console.log(`\n ${pc.bold('Security')}`)
166
- console.log(securityPanel(buildSecurityRows(hooks)))
167
-
168
- const confirmAnswer = await ask(`\n Install ${plural(hooks.length, 'hook')} into ${scope === 'global' ? '~/.claude' : './.claude'}? [Y/n]: `)
169
- if (confirmAnswer.toLowerCase() === 'n' || confirmAnswer.toLowerCase() === 'no') {
170
- console.log('Cancelled.')
171
- process.exit(0)
172
- }
173
-
174
- 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…')
175
204
  let result
176
205
  try {
177
- result = doInstall(hooks, dirs, scope, { warn: m => console.warn(` ! ${m}`) })
206
+ result = doInstall(hooks, dirs, scope, { warn: m => p.log.warn(m) })
178
207
  } catch (e) {
179
- console.error(pc.red(`\n✗ Install failed: ${e.message}`))
208
+ spin2.stop(pc.red(`Install failed: ${e.message}`), 1)
180
209
  process.exit(1)
181
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')
182
223
 
183
- console.log(` ✓ ${dirs.settingsPath}`)
184
- console.log(`\n Browse more hooks ${pc.cyan(`${API_BASE}/#catalogue`)}`)
185
- console.log(` ⭐ star us → ${pc.cyan(REPO_URL)}`)
186
- 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."))
187
226
  }
188
227
 
189
228
  async function directInstall(slugs, args) {
@@ -221,7 +260,8 @@ const HELP = `
221
260
  Options:
222
261
  --hooks <slugs> Comma-separated list of hook slugs (omit for default set)
223
262
  --global, -g Install into ~/.claude instead of ./.claude
224
- --scope <s> "project" (default) or "global"
263
+ --copilot Install into ./.claude with paths adapted for GitHub Copilot
264
+ --scope <s> "project" (default), "global", or "copilot"
225
265
  --yes, -y Skip prompts (non-interactive install)
226
266
  --version, -v Show version
227
267
  --help, -h Show this help
package/bin/core.mjs CHANGED
@@ -36,6 +36,7 @@ export function parseArgs(argv) {
36
36
  if (arg === '--yes' || arg === '-y') { result.yes = true; continue }
37
37
  if (arg === '--global' || arg === '-g') { result.scope = 'global'; continue }
38
38
  if (arg === '--project') { result.scope = 'project'; continue }
39
+ if (arg === '--copilot') { result.scope = 'copilot'; continue }
39
40
  if (arg.startsWith('--scope=')) {
40
41
  const v = arg.slice('--scope='.length)
41
42
  if (v === 'global' || v === 'project') result.scope = v
@@ -98,7 +99,8 @@ export function mergeHooks(existing, incoming) {
98
99
 
99
100
  // Gathers the hook fragments from an API hook list into a single event→entries
100
101
  // map. For global scope, rewrites $CLAUDE_PROJECT_DIR to the absolute global
101
- // root so commands resolve outside any project.
102
+ // root so commands resolve outside any project. For copilot scope, strips
103
+ // $CLAUDE_PROJECT_DIR/ so paths become relative (GitHub Copilot compatible).
102
104
  export function collectIncomingHooks(hooks, { scope = 'project', globalRoot } = {}) {
103
105
  const incoming = {}
104
106
  for (const hook of hooks) {
@@ -107,14 +109,16 @@ export function collectIncomingHooks(hooks, { scope = 'project', globalRoot } =
107
109
  for (const [event, entries] of Object.entries(fragment)) {
108
110
  incoming[event] ??= []
109
111
  for (const entry of entries) {
110
- const rewrite = scope === 'global' && globalRoot
111
112
  incoming[event].push({
112
113
  ...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
- ),
114
+ hooks: entry.hooks.map(h => {
115
+ if (!h.command || typeof h.command !== 'string') return h
116
+ if (scope === 'global' && globalRoot)
117
+ return { ...h, command: h.command.replace(PROJECT_DIR_RE, globalRoot) }
118
+ if (scope === 'copilot')
119
+ return { ...h, command: h.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?\//g, '') }
120
+ return h
121
+ }),
118
122
  })
119
123
  }
120
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookstack-cli",
3
- "version": "0.1.16",
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": [