@bretwardjames/ghp-cli 0.16.3 → 0.18.0

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 (47) hide show
  1. package/dist/commands/agents.d.ts.map +1 -1
  2. package/dist/commands/agents.js +18 -19
  3. package/dist/commands/agents.js.map +1 -1
  4. package/dist/commands/dashboard-pipeline.d.ts.map +1 -1
  5. package/dist/commands/dashboard-pipeline.js +360 -23
  6. package/dist/commands/dashboard-pipeline.js.map +1 -1
  7. package/dist/commands/done.d.ts.map +1 -1
  8. package/dist/commands/done.js +14 -1
  9. package/dist/commands/done.js.map +1 -1
  10. package/dist/commands/mcp.d.ts +1 -0
  11. package/dist/commands/mcp.d.ts.map +1 -1
  12. package/dist/commands/mcp.js +30 -11
  13. package/dist/commands/mcp.js.map +1 -1
  14. package/dist/commands/pipeline-commands.d.ts +75 -3
  15. package/dist/commands/pipeline-commands.d.ts.map +1 -1
  16. package/dist/commands/pipeline-commands.js +370 -5
  17. package/dist/commands/pipeline-commands.js.map +1 -1
  18. package/dist/commands/pipeline-setup.d.ts +23 -0
  19. package/dist/commands/pipeline-setup.d.ts.map +1 -0
  20. package/dist/commands/pipeline-setup.js +817 -0
  21. package/dist/commands/pipeline-setup.js.map +1 -0
  22. package/dist/commands/start.js +1 -1
  23. package/dist/commands/start.js.map +1 -1
  24. package/dist/commands/stop.d.ts.map +1 -1
  25. package/dist/commands/stop.js +14 -1
  26. package/dist/commands/stop.js.map +1 -1
  27. package/dist/commands/worktree.d.ts.map +1 -1
  28. package/dist/commands/worktree.js +10 -0
  29. package/dist/commands/worktree.js.map +1 -1
  30. package/dist/config.d.ts +37 -2
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/config.js +20 -0
  33. package/dist/config.js.map +1 -1
  34. package/dist/index.js +45 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/pipeline-registry.d.ts +1 -1
  37. package/dist/pipeline-registry.d.ts.map +1 -1
  38. package/dist/pipeline-registry.js +7 -27
  39. package/dist/pipeline-registry.js.map +1 -1
  40. package/dist/terminal-utils.d.ts +56 -5
  41. package/dist/terminal-utils.d.ts.map +1 -1
  42. package/dist/terminal-utils.js +161 -37
  43. package/dist/terminal-utils.js.map +1 -1
  44. package/dist/worktree-utils.d.ts.map +1 -1
  45. package/dist/worktree-utils.js +22 -7
  46. package/dist/worktree-utils.js.map +1 -1
  47. package/package.json +1 -1
@@ -0,0 +1,817 @@
1
+ /**
2
+ * ghp pipeline setup — Agent-friendly setup wizard for pipeline configuration.
3
+ *
4
+ * Three modes:
5
+ * --questions Output JSON question schema (no side effects)
6
+ * --apply Read answers JSON from stdin, write config + hook scripts
7
+ * (no flags) Interactive CLI wizard
8
+ *
9
+ * Flavors (saved presets):
10
+ * --save <name> Save answers from stdin as a named flavor
11
+ * --flavor <name> Load and apply a saved flavor
12
+ * --flavors List saved flavors
13
+ * --delete-flavor <n> Delete a saved flavor
14
+ */
15
+ import chalk from 'chalk';
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs';
17
+ import { join } from 'path';
18
+ import { setConfigByPath, getUserConfigPath, } from '../config.js';
19
+ import { isInteractive, promptSelect, promptWithDefault, confirmWithDefault, } from '../prompts.js';
20
+ import { getMainWorktreeRoot } from '../git-utils.js';
21
+ import { exit } from '../exit.js';
22
+ const QUESTIONS = [
23
+ // ── General config ──────────────────────────────────────────────────────
24
+ {
25
+ id: 'config_scope',
26
+ question: 'Where should this configuration be saved?',
27
+ type: 'select',
28
+ options: [
29
+ { value: 'workspace', label: 'This project only (.ghp/config.json)', description: 'Checked into the repo — the whole team gets this config' },
30
+ { value: 'user', label: 'Global (~/.config/ghp-cli/config.json)', description: 'Your personal preference across all projects' },
31
+ ],
32
+ default: 'user',
33
+ },
34
+ {
35
+ id: 'save_flavor',
36
+ question: 'Also save these answers as a reusable flavor?',
37
+ type: 'confirm',
38
+ default: false,
39
+ hint: 'Flavors let you reapply this exact setup later with: ghp pipeline setup --flavor <name>',
40
+ },
41
+ {
42
+ id: 'flavor_name',
43
+ question: 'Flavor name:',
44
+ type: 'text',
45
+ default: '',
46
+ dependsOn: { save_flavor: true },
47
+ hint: 'Reapply later with: ghp pipeline setup --flavor <name>',
48
+ },
49
+ // ── Agent spawn mode ─────────────────────────────────────────────────────
50
+ {
51
+ id: 'agent_spawn_mode',
52
+ question: 'How should agents be spawned in tmux?',
53
+ type: 'select',
54
+ options: [
55
+ { value: 'window', label: 'Window (default)', description: 'Each agent gets its own tmux window' },
56
+ { value: 'pane', label: 'Pane', description: 'Split the current tmux window into panes' },
57
+ { value: 'session', label: 'Session', description: 'Each agent gets its own tmux session (nested attach in dashboard viewport)' },
58
+ ],
59
+ default: 'window',
60
+ },
61
+ {
62
+ id: 'tmux_prefix',
63
+ question: 'Tmux naming prefix (used for windows, sessions, admin):',
64
+ type: 'text',
65
+ default: 'ghp',
66
+ hint: 'All tmux names use this prefix (e.g., myproj → myproj-86, myproj-admin). Useful when multiple projects share a tmux server.',
67
+ },
68
+ // ── Dashboard layout ────────────────────────────────────────────────────
69
+ {
70
+ id: 'dashboard_mode',
71
+ question: 'Where should the pipeline dashboard open?',
72
+ type: 'select',
73
+ options: [
74
+ { value: 'pane', label: 'Split pane', description: 'Split the current tmux window' },
75
+ { value: 'window', label: 'New window', description: 'Separate tmux window' },
76
+ ],
77
+ default: 'window',
78
+ },
79
+ {
80
+ id: 'dashboard_direction',
81
+ question: 'When opening as a pane, which direction should it split?',
82
+ type: 'select',
83
+ dependsOn: { dashboard_mode: 'pane' },
84
+ options: [
85
+ { value: 'horizontal', label: 'Side by side' },
86
+ { value: 'vertical', label: 'Stacked (top/bottom)' },
87
+ ],
88
+ default: 'horizontal',
89
+ },
90
+ {
91
+ id: 'dashboard_size',
92
+ question: 'What percentage of space should the dashboard take?',
93
+ type: 'text',
94
+ dependsOn: { dashboard_mode: 'pane' },
95
+ default: '50%',
96
+ hint: 'e.g., 30%, 50%, 40',
97
+ },
98
+ {
99
+ id: 'focused_agent_direction',
100
+ question: 'When you pull an agent into view, where should it appear relative to the dashboard?',
101
+ type: 'select',
102
+ options: [
103
+ { value: 'vertical', label: 'Below the dashboard' },
104
+ { value: 'horizontal', label: 'Beside the dashboard' },
105
+ ],
106
+ default: 'vertical',
107
+ },
108
+ {
109
+ id: 'focused_agent_size',
110
+ question: 'What percentage of space should the focused agent pane take?',
111
+ type: 'text',
112
+ default: '50%',
113
+ hint: 'e.g., 50%, 60%, 70%',
114
+ },
115
+ // ── Pipeline stages ─────────────────────────────────────────────────────
116
+ {
117
+ id: 'custom_stages',
118
+ question: 'Do you want to use custom pipeline stages beyond the defaults (working, stopped)?',
119
+ type: 'confirm',
120
+ default: false,
121
+ },
122
+ {
123
+ id: 'stage_list',
124
+ question: 'List your pipeline stages in order (comma-separated):',
125
+ type: 'text',
126
+ dependsOn: { custom_stages: true },
127
+ default: 'working, stopped',
128
+ hint: 'e.g., planning, working, code_review, pr_submitted, stopped',
129
+ },
130
+ // ── Hook modes ──────────────────────────────────────────────────────────
131
+ {
132
+ id: 'hook_modes',
133
+ question: 'What workflow modes do you want? (comma-separated, leave blank for none)',
134
+ type: 'text',
135
+ default: '',
136
+ hint: 'Modes change which hook scripts run. e.g., "planning, testing, review". When mode is "testing", hooks resolve as .ghp/hooks/<name>.testing before falling back to .ghp/hooks/<name>. Press [m] in the dashboard to cycle modes. Leave blank to skip mode support.',
137
+ },
138
+ {
139
+ id: 'hook_default_mode',
140
+ question: 'Which mode should be active when the dashboard starts?',
141
+ type: 'text',
142
+ default: '',
143
+ hint: 'Must be one of the modes listed above. Leave blank to start with no mode active.',
144
+ },
145
+ {
146
+ id: 'hook_swap_order',
147
+ question: 'When hot-swapping agents, should the old agent\'s hooks run first or the new agent\'s?',
148
+ type: 'select',
149
+ options: [
150
+ { value: 'unfocus-first', label: 'Unfocus first (default)', description: 'Stop old servers before starting new ones — avoids port conflicts' },
151
+ { value: 'focus-first', label: 'Focus first', description: 'Start new servers before stopping old ones — minimizes downtime' },
152
+ ],
153
+ default: 'unfocus-first',
154
+ },
155
+ // ── Directory hooks (.ghp/hooks/<name>) ─────────────────────────────────
156
+ {
157
+ id: 'hook_dashboard_opened',
158
+ question: 'What should happen when the pipeline dashboard opens?',
159
+ type: 'text',
160
+ default: '',
161
+ hint: 'Creates .ghp/hooks/dashboard-opened. Fires when the dashboard starts. Stdin JSON: {"pane_id":"%42","window_name":"ghp-admin"}. Runs from the main repo root. Use this for companion panes (dev servers, log viewers). If modes are configured, mode-specific variants (.ghp/hooks/dashboard-opened.<mode>) will also be scaffolded. Leave blank to skip.',
162
+ },
163
+ {
164
+ id: 'hook_agent_active',
165
+ question: 'What should happen when an agent starts working (PostToolUse)?',
166
+ type: 'text',
167
+ default: '',
168
+ hint: 'Creates .ghp/hooks/agent-active. Fires on Claude Code PostToolUse hook. Stdin: Claude Code hook JSON (includes "cwd"). Runs from the agent\'s cwd. If modes are configured, mode-specific variants will also be scaffolded. Leave blank to skip.',
169
+ },
170
+ {
171
+ id: 'hook_agent_stopped',
172
+ question: 'What should happen when an agent stops?',
173
+ type: 'text',
174
+ default: '',
175
+ hint: 'Creates .ghp/hooks/agent-stopped. Fires on Claude Code Stop hook. Stdin: Claude Code hook JSON (includes "cwd"). Runs from the agent\'s cwd. If modes are configured, mode-specific variants will also be scaffolded. Leave blank to skip.',
176
+ },
177
+ {
178
+ id: 'hook_agent_focused',
179
+ question: 'What should happen when you pull an agent into the dashboard view?',
180
+ type: 'text',
181
+ default: '',
182
+ hint: 'Creates .ghp/hooks/agent-focused. Fires when an agent pane is focused via [1-9] in the dashboard. Stdin JSON: {"issueNumber":123,"worktreePath":"/path/to/worktree","branch":"user/123-feature"}. Runs from the worktree directory. If modes are configured, mode-specific variants will also be scaffolded. Leave blank to skip.',
183
+ },
184
+ {
185
+ id: 'hook_agent_unfocused',
186
+ question: 'What should happen when an agent is released from the dashboard view?',
187
+ type: 'text',
188
+ default: '',
189
+ hint: 'Creates .ghp/hooks/agent-unfocused. Fires when an agent pane is sent back via [esc]. Same stdin payload as agent-focused. Runs from the worktree directory. If modes are configured, mode-specific variants will also be scaffolded. Leave blank to skip.',
190
+ },
191
+ {
192
+ id: 'hook_agent_swapped',
193
+ question: 'What should happen when switching directly between focused agents (hot-swap)?',
194
+ type: 'text',
195
+ default: '',
196
+ hint: 'Creates .ghp/hooks/agent-swapped. Fires when you press [3] while [1] is focused — an atomic swap. Stdin JSON: {"old":{"issueNumber":123,"worktreePath":"/old","branch":"..."},"new":{"issueNumber":456,"worktreePath":"/new","branch":"..."}}. If this hook doesn\'t exist, falls back to sequential unfocus→focus. If modes are configured, mode-specific variants will also be scaffolded. Leave blank to skip.',
197
+ },
198
+ {
199
+ id: 'hook_mode_switched',
200
+ question: 'What should happen when the dashboard hook mode changes (via [m] key)?',
201
+ type: 'text',
202
+ default: '',
203
+ hint: 'Creates .ghp/hooks/mode-switched. Fires when you press [m] to cycle hook modes. Stdin JSON: {"oldMode":"planning","newMode":"testing"} (null = default/no mode). Runs from the main repo root. Unlike other hooks, mode-specific variants are NOT created (this hook IS the mode change notification). Leave blank to skip.',
204
+ },
205
+ // ── Event hooks (registered via ghp hooks add) ──────────────────────────
206
+ {
207
+ id: 'hook_issue_created',
208
+ question: 'What command should run when a new issue is created (ghp add)?',
209
+ type: 'text',
210
+ default: '',
211
+ hint: 'Registers an event hook for "issue-created". Template vars: ${number}, ${title}, ${url}. Example: "echo Issue #${number} created: ${title}". This is an event hook (registered with ghp hooks add --event issue-created), not a directory hook. Leave blank to skip.',
212
+ },
213
+ {
214
+ id: 'hook_issue_started',
215
+ question: 'What command should run when work starts on an issue (ghp start)?',
216
+ type: 'text',
217
+ default: '',
218
+ hint: 'Registers an event hook for "issue-started". Template vars: ${number}, ${branch}, ${worktreePath}. Leave blank to skip.',
219
+ },
220
+ {
221
+ id: 'hook_worktree_created',
222
+ question: 'What command should run after a worktree is created?',
223
+ type: 'text',
224
+ default: '',
225
+ hint: 'Registers an event hook for "worktree-created". Template vars: ${number}, ${worktreePath}, ${branch}. Leave blank to skip.',
226
+ },
227
+ {
228
+ id: 'hook_worktree_removed',
229
+ question: 'What command should run after a worktree is removed?',
230
+ type: 'text',
231
+ default: '',
232
+ hint: 'Registers an event hook for "worktree-removed". Template vars: ${number}, ${worktreePath}, ${branch}. Leave blank to skip.',
233
+ },
234
+ {
235
+ id: 'hook_pre_pr',
236
+ question: 'What command should run before PR creation?',
237
+ type: 'text',
238
+ default: '',
239
+ hint: 'Registers an event hook for "pre-pr". Template vars: ${number}, ${branch}. Runs in blocking mode by default. Leave blank to skip.',
240
+ },
241
+ {
242
+ id: 'hook_pr_created',
243
+ question: 'What command should run after a PR is created?',
244
+ type: 'text',
245
+ default: '',
246
+ hint: 'Registers an event hook for "pr-created". Template vars: ${number}, ${prNumber}, ${prUrl}. Leave blank to skip.',
247
+ },
248
+ {
249
+ id: 'hook_pr_merged',
250
+ question: 'What command should run after a PR is merged?',
251
+ type: 'text',
252
+ default: '',
253
+ hint: 'Registers an event hook for "pr-merged". Template vars: ${number}, ${prNumber}, ${branch}. Leave blank to skip.',
254
+ },
255
+ ];
256
+ // ─────────────────────────────────────────────────────────────────────────────
257
+ // Helpers
258
+ // ─────────────────────────────────────────────────────────────────────────────
259
+ /** Read all of stdin as a string. */
260
+ async function readStdin() {
261
+ return new Promise((resolve, reject) => {
262
+ const chunks = [];
263
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
264
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
265
+ process.stdin.on('error', reject);
266
+ setTimeout(() => resolve(Buffer.concat(chunks).toString('utf-8')), 3000);
267
+ });
268
+ }
269
+ /** Check if a question's dependsOn conditions are met. */
270
+ function isDependencyMet(question, answers) {
271
+ if (!question.dependsOn)
272
+ return true;
273
+ for (const [key, expected] of Object.entries(question.dependsOn)) {
274
+ if (answers[key] !== expected)
275
+ return false;
276
+ }
277
+ return true;
278
+ }
279
+ /** Read the raw user config JSON (including non-Config fields like flavors). */
280
+ function loadRawUserConfig() {
281
+ const configPath = getUserConfigPath();
282
+ try {
283
+ if (existsSync(configPath)) {
284
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
285
+ }
286
+ }
287
+ catch { /* ignore */ }
288
+ return {};
289
+ }
290
+ /** Write raw user config JSON. */
291
+ function saveRawUserConfig(data) {
292
+ const configPath = getUserConfigPath();
293
+ const dir = join(configPath, '..');
294
+ if (!existsSync(dir)) {
295
+ mkdirSync(dir, { recursive: true });
296
+ }
297
+ writeFileSync(configPath, JSON.stringify(data, null, 2));
298
+ }
299
+ /** Parse comma-separated string into array, filtering empties. */
300
+ function parseCommaSeparated(value) {
301
+ if (!value || typeof value !== 'string')
302
+ return [];
303
+ return value.split(',').map(s => s.trim()).filter(Boolean);
304
+ }
305
+ // ─────────────────────────────────────────────────────────────────────────────
306
+ // Flavor CRUD
307
+ // ─────────────────────────────────────────────────────────────────────────────
308
+ function getFlavors() {
309
+ const raw = loadRawUserConfig();
310
+ return raw.pipelineSetupFlavors ?? {};
311
+ }
312
+ function saveFlavor(name, answers) {
313
+ const raw = loadRawUserConfig();
314
+ const flavors = raw.pipelineSetupFlavors ?? {};
315
+ flavors[name] = answers;
316
+ raw.pipelineSetupFlavors = flavors;
317
+ saveRawUserConfig(raw);
318
+ }
319
+ function deleteFlavor(name) {
320
+ const raw = loadRawUserConfig();
321
+ const flavors = raw.pipelineSetupFlavors ?? {};
322
+ if (!(name in flavors))
323
+ return false;
324
+ delete flavors[name];
325
+ raw.pipelineSetupFlavors = flavors;
326
+ saveRawUserConfig(raw);
327
+ return true;
328
+ }
329
+ // ─────────────────────────────────────────────────────────────────────────────
330
+ // Directory hook scaffolding
331
+ // ─────────────────────────────────────────────────────────────────────────────
332
+ /** Map of directory hook answer IDs to hook script names and templates. */
333
+ const DIRECTORY_HOOKS = {
334
+ hook_dashboard_opened: { hookName: 'dashboard-opened', template: DEFAULT_DASHBOARD_OPENED_SCRIPT() },
335
+ hook_agent_active: { hookName: 'agent-active', template: DEFAULT_AGENT_ACTIVE_SCRIPT() },
336
+ hook_agent_stopped: { hookName: 'agent-stopped', template: DEFAULT_AGENT_STOPPED_SCRIPT() },
337
+ hook_agent_focused: { hookName: 'agent-focused', template: DEFAULT_AGENT_FOCUSED_SCRIPT() },
338
+ hook_agent_unfocused: { hookName: 'agent-unfocused', template: DEFAULT_AGENT_UNFOCUSED_SCRIPT() },
339
+ hook_agent_swapped: { hookName: 'agent-swapped', template: DEFAULT_AGENT_SWAPPED_SCRIPT() },
340
+ hook_mode_switched: { hookName: 'mode-switched', template: DEFAULT_MODE_SWITCHED_SCRIPT() },
341
+ };
342
+ /** Map of event hook answer IDs to event names. */
343
+ const EVENT_HOOKS = {
344
+ hook_issue_created: { event: 'issue-created' },
345
+ hook_issue_started: { event: 'issue-started' },
346
+ hook_worktree_created: { event: 'worktree-created' },
347
+ hook_worktree_removed: { event: 'worktree-removed' },
348
+ hook_pre_pr: { event: 'pre-pr', defaultMode: 'blocking' },
349
+ hook_pr_created: { event: 'pr-created' },
350
+ hook_pr_merged: { event: 'pr-merged' },
351
+ };
352
+ /**
353
+ * Scaffold a directory hook script (and mode-specific variants).
354
+ * Returns array of summary messages.
355
+ */
356
+ function scaffoldDirectoryHook(hooksDir, hookName, template, description, modes) {
357
+ const summary = [];
358
+ // Base hook
359
+ const basePath = join(hooksDir, hookName);
360
+ if (existsSync(basePath)) {
361
+ summary.push(`Skipped ${basePath} (already exists)`);
362
+ }
363
+ else {
364
+ writeFileSync(basePath, template);
365
+ chmodSync(basePath, 0o755);
366
+ summary.push(`Created ${basePath} — ${description}`);
367
+ }
368
+ // Mode-specific variants
369
+ for (const mode of modes) {
370
+ const modePath = join(hooksDir, `${hookName}.${mode}`);
371
+ if (existsSync(modePath)) {
372
+ summary.push(`Skipped ${modePath} (already exists)`);
373
+ }
374
+ else {
375
+ const modeTemplate = template.replace(/^(# Hook: .+)$/m, `$1 (mode: ${mode})`);
376
+ writeFileSync(modePath, modeTemplate);
377
+ chmodSync(modePath, 0o755);
378
+ summary.push(`Created ${modePath}`);
379
+ }
380
+ }
381
+ return summary;
382
+ }
383
+ // ─────────────────────────────────────────────────────────────────────────────
384
+ // Apply logic
385
+ // ─────────────────────────────────────────────────────────────────────────────
386
+ async function applyAnswers(answers) {
387
+ const scope = answers.config_scope || 'user';
388
+ const summary = [];
389
+ // Save flavor if requested
390
+ if (answers.save_flavor === true && answers.flavor_name && typeof answers.flavor_name === 'string' && answers.flavor_name.trim()) {
391
+ saveFlavor(answers.flavor_name.trim(), answers);
392
+ summary.push(`Saved flavor "${answers.flavor_name.trim()}" — reapply with: ghp pipeline setup --flavor ${answers.flavor_name.trim()}`);
393
+ }
394
+ // Agent spawn mode + tmux prefix
395
+ if (answers.agent_spawn_mode) {
396
+ setConfigByPath('parallelWork.tmux.mode', answers.agent_spawn_mode, scope);
397
+ summary.push(`tmux.mode = ${answers.agent_spawn_mode}`);
398
+ }
399
+ if (answers.tmux_prefix && typeof answers.tmux_prefix === 'string' && answers.tmux_prefix.trim() && answers.tmux_prefix !== 'ghp') {
400
+ const trimmed = answers.tmux_prefix.trim();
401
+ if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
402
+ console.log(chalk.yellow('Warning:'), 'Prefix contains invalid characters — use only letters, numbers, hyphens, and underscores. Skipped.');
403
+ }
404
+ else {
405
+ setConfigByPath('parallelWork.tmux.prefix', trimmed, scope);
406
+ summary.push(`tmux.prefix = ${trimmed}`);
407
+ }
408
+ }
409
+ // Dashboard config
410
+ if (answers.dashboard_mode) {
411
+ setConfigByPath('parallelWork.dashboard.mode', answers.dashboard_mode, scope);
412
+ summary.push(`dashboard.mode = ${answers.dashboard_mode}`);
413
+ }
414
+ if (answers.dashboard_direction) {
415
+ setConfigByPath('parallelWork.dashboard.direction', answers.dashboard_direction, scope);
416
+ summary.push(`dashboard.direction = ${answers.dashboard_direction}`);
417
+ }
418
+ if (answers.dashboard_size) {
419
+ setConfigByPath('parallelWork.dashboard.size', answers.dashboard_size, scope);
420
+ summary.push(`dashboard.size = ${answers.dashboard_size}`);
421
+ }
422
+ if (answers.focused_agent_direction) {
423
+ setConfigByPath('parallelWork.dashboard.focusedAgent.direction', answers.focused_agent_direction, scope);
424
+ summary.push(`focusedAgent.direction = ${answers.focused_agent_direction}`);
425
+ }
426
+ if (answers.focused_agent_size) {
427
+ setConfigByPath('parallelWork.dashboard.focusedAgent.size', answers.focused_agent_size, scope);
428
+ summary.push(`focusedAgent.size = ${answers.focused_agent_size}`);
429
+ }
430
+ // Pipeline stages
431
+ if (answers.custom_stages === true && answers.stage_list) {
432
+ const stages = parseCommaSeparated(answers.stage_list);
433
+ if (stages.length > 0) {
434
+ setConfigByPath('pipeline.stages', stages, scope);
435
+ summary.push(`pipeline.stages = [${stages.join(', ')}]`);
436
+ }
437
+ }
438
+ // Hook modes config
439
+ const hookModes = parseCommaSeparated(answers.hook_modes);
440
+ if (hookModes.length > 0) {
441
+ setConfigByPath('pipeline.hookModes', hookModes, scope);
442
+ summary.push(`pipeline.hookModes = [${hookModes.join(', ')}]`);
443
+ }
444
+ if (answers.hook_default_mode && typeof answers.hook_default_mode === 'string' && answers.hook_default_mode.trim()) {
445
+ setConfigByPath('pipeline.defaultHookMode', answers.hook_default_mode.trim(), scope);
446
+ summary.push(`pipeline.defaultHookMode = ${answers.hook_default_mode}`);
447
+ }
448
+ if (answers.hook_swap_order && answers.hook_swap_order !== 'unfocus-first') {
449
+ setConfigByPath('pipeline.hookModeSwapOrder', answers.hook_swap_order, scope);
450
+ summary.push(`pipeline.hookModeSwapOrder = ${answers.hook_swap_order}`);
451
+ }
452
+ // Directory hooks
453
+ const repoRoot = await getMainWorktreeRoot();
454
+ if (repoRoot) {
455
+ const hooksDir = join(repoRoot, '.ghp', 'hooks');
456
+ for (const [answerId, { hookName, template }] of Object.entries(DIRECTORY_HOOKS)) {
457
+ const answer = answers[answerId];
458
+ if (answer && typeof answer === 'string' && answer.trim()) {
459
+ if (!existsSync(hooksDir)) {
460
+ mkdirSync(hooksDir, { recursive: true });
461
+ }
462
+ // mode-switched should never have mode-specific variants (it IS the mode notification)
463
+ const modes = hookName === 'mode-switched' ? [] : hookModes;
464
+ const msgs = scaffoldDirectoryHook(hooksDir, hookName, template, answer.trim(), modes);
465
+ summary.push(...msgs);
466
+ }
467
+ }
468
+ }
469
+ else if (Object.keys(DIRECTORY_HOOKS).some(id => {
470
+ const a = answers[id];
471
+ return a && typeof a === 'string' && a.trim();
472
+ })) {
473
+ console.error(chalk.yellow('Warning:'), 'Not in a git repository — skipped hook script generation');
474
+ }
475
+ // Event hooks (register via ghp hooks add)
476
+ for (const [answerId, { event, defaultMode }] of Object.entries(EVENT_HOOKS)) {
477
+ const answer = answers[answerId];
478
+ if (answer && typeof answer === 'string' && answer.trim()) {
479
+ const hookName = `setup-${event}`;
480
+ const mode = defaultMode || 'fire-and-forget';
481
+ try {
482
+ const { execFileSync } = await import('child_process');
483
+ execFileSync('ghp', [
484
+ 'hooks', 'add', hookName,
485
+ '--event', event,
486
+ '--command', answer.trim(),
487
+ '--mode', mode,
488
+ ], { stdio: 'pipe' });
489
+ summary.push(`Registered event hook: ${hookName} (${event}) → ${answer.trim().substring(0, 60)}`);
490
+ }
491
+ catch (err) {
492
+ summary.push(`Failed to register event hook ${hookName}: ${err instanceof Error ? err.message : 'unknown error'}`);
493
+ }
494
+ }
495
+ }
496
+ // Print summary
497
+ console.log();
498
+ console.log(chalk.bold.green('Setup complete!'));
499
+ console.log();
500
+ const scopeLabel = scope === 'workspace' ? '.ghp/config.json' : '~/.config/ghp-cli/config.json';
501
+ console.log(chalk.dim(`Config scope: ${scopeLabel}`));
502
+ console.log();
503
+ for (const line of summary) {
504
+ console.log(` ${chalk.green('✓')} ${line}`);
505
+ }
506
+ }
507
+ // ─────────────────────────────────────────────────────────────────────────────
508
+ // Default hook scripts (scaffolded for devs/agents to customize)
509
+ // ─────────────────────────────────────────────────────────────────────────────
510
+ function DEFAULT_DASHBOARD_OPENED_SCRIPT() {
511
+ return `#!/usr/bin/env bash
512
+ # Hook: dashboard-opened
513
+ # Runs when the pipeline dashboard opens. Use this to spawn companion panes
514
+ # (dev servers, log viewers, test watchers, etc.) alongside the dashboard.
515
+ #
516
+ # Receives JSON on stdin with these exact keys:
517
+ # { "pane_id": "%42", "window_name": "ghp-admin" }
518
+ #
519
+ # IMPORTANT: The pane_id is the tmux pane where the dashboard is running.
520
+ # All split-window commands should target this pane with -t "$DASHBOARD_PANE".
521
+ # This script runs from the main repo root as its working directory.
522
+
523
+ INPUT=$(cat)
524
+ DASHBOARD_PANE=$(echo "$INPUT" | jq -r '.pane_id')
525
+
526
+ if [ -z "$DASHBOARD_PANE" ] || [ "$DASHBOARD_PANE" = "null" ]; then
527
+ DASHBOARD_PANE=$(tmux display-message -p "#{pane_id}")
528
+ fi
529
+
530
+ # ── Add your companion panes below ──────────────────────────────────────────
531
+ # Examples:
532
+ #
533
+ # Split left of dashboard, run dev server (50% width):
534
+ # DEV=$(tmux split-window -hb -l 50% -t "$DASHBOARD_PANE" -P -F '#{pane_id}' 'pnpm dev')
535
+ # tmux select-pane -t "$DEV" -T 'Dev Server'
536
+ #
537
+ # Split below dashboard, run tests (30% height):
538
+ # TESTS=$(tmux split-window -v -l 30% -t "$DASHBOARD_PANE" -P -F '#{pane_id}' 'pnpm test --watch')
539
+ # tmux select-pane -t "$TESTS" -T 'Tests'
540
+ # ─────────────────────────────────────────────────────────────────────────────
541
+
542
+ # Refocus the dashboard
543
+ tmux select-pane -t "$DASHBOARD_PANE"
544
+ `;
545
+ }
546
+ function DEFAULT_AGENT_ACTIVE_SCRIPT() {
547
+ return `#!/usr/bin/env bash
548
+ # Hook: agent-active
549
+ # Runs when a Claude agent starts working (PostToolUse hook).
550
+ # Stdin: Claude Code hook JSON (includes "cwd", "tool_name", etc.)
551
+ # This script runs from the agent's cwd.
552
+
553
+ INPUT=$(cat)
554
+ CWD=$(echo "$INPUT" | jq -r '.cwd')
555
+
556
+ # ── Add your actions below ───────────────────────────────────────────────────
557
+ # ─────────────────────────────────────────────────────────────────────────────
558
+ `;
559
+ }
560
+ function DEFAULT_AGENT_STOPPED_SCRIPT() {
561
+ return `#!/usr/bin/env bash
562
+ # Hook: agent-stopped
563
+ # Runs when a Claude agent stops (Stop hook).
564
+ # Stdin: Claude Code hook JSON (includes "cwd")
565
+ # This script runs from the agent's cwd.
566
+
567
+ INPUT=$(cat)
568
+ CWD=$(echo "$INPUT" | jq -r '.cwd')
569
+
570
+ # ── Add your actions below ───────────────────────────────────────────────────
571
+ # ─────────────────────────────────────────────────────────────────────────────
572
+ `;
573
+ }
574
+ function DEFAULT_AGENT_FOCUSED_SCRIPT() {
575
+ return `#!/usr/bin/env bash
576
+ # Hook: agent-focused
577
+ # Runs when an agent pane is pulled into the dashboard view (via [1-9] key).
578
+ # Use this to show extra context for the focused agent.
579
+ #
580
+ # Receives JSON on stdin with these exact keys:
581
+ # { "issueNumber": 123, "worktreePath": "/path/to/worktree", "branch": "user/123-feature" }
582
+ #
583
+ # This script runs from the worktree directory.
584
+
585
+ INPUT=$(cat)
586
+ ISSUE=$(echo "$INPUT" | jq -r '.issueNumber')
587
+ WORKTREE=$(echo "$INPUT" | jq -r '.worktreePath')
588
+ BRANCH=$(echo "$INPUT" | jq -r '.branch')
589
+
590
+ # ── Add your focus actions below ─────────────────────────────────────────────
591
+ # Examples:
592
+ #
593
+ # Show git log for this agent's branch:
594
+ # tmux split-window -v -l 20% -t "$TMUX_PANE" "cd '$WORKTREE' && git log --oneline -20"
595
+ #
596
+ # Tail the agent's conversation log:
597
+ # tmux split-window -v -l 30% -t "$TMUX_PANE" "tail -f '$WORKTREE/.claude/logs/latest.log'"
598
+ # ─────────────────────────────────────────────────────────────────────────────
599
+ `;
600
+ }
601
+ function DEFAULT_AGENT_UNFOCUSED_SCRIPT() {
602
+ return `#!/usr/bin/env bash
603
+ # Hook: agent-unfocused
604
+ # Runs when an agent pane is released from the dashboard view (via [esc] key).
605
+ # Use this to clean up anything spawned by agent-focused.
606
+ #
607
+ # Receives JSON on stdin with these exact keys:
608
+ # { "issueNumber": 123, "worktreePath": "/path/to/worktree", "branch": "user/123-feature" }
609
+ #
610
+ # This script runs from the worktree directory.
611
+
612
+ INPUT=$(cat)
613
+ ISSUE=$(echo "$INPUT" | jq -r '.issueNumber')
614
+ WORKTREE=$(echo "$INPUT" | jq -r '.worktreePath')
615
+ BRANCH=$(echo "$INPUT" | jq -r '.branch')
616
+
617
+ # ── Add your cleanup actions below ───────────────────────────────────────────
618
+ # Examples:
619
+ #
620
+ # Kill any extra panes spawned by agent-focused:
621
+ # # (track pane IDs in a temp file from agent-focused, then kill them here)
622
+ # ─────────────────────────────────────────────────────────────────────────────
623
+ `;
624
+ }
625
+ function DEFAULT_AGENT_SWAPPED_SCRIPT() {
626
+ return `#!/usr/bin/env bash
627
+ # Hook: agent-swapped
628
+ # Runs when switching directly from one focused agent to another (hot-swap).
629
+ # This is an atomic swap — use it to stop old servers and start new ones
630
+ # without port conflicts.
631
+ #
632
+ # Receives JSON on stdin with these exact keys:
633
+ # {
634
+ # "old": { "issueNumber": 123, "worktreePath": "/path/old", "branch": "user/123-feat" },
635
+ # "new": { "issueNumber": 456, "worktreePath": "/path/new", "branch": "user/456-other" }
636
+ # }
637
+ #
638
+ # If this hook doesn't exist, falls back to sequential unfocus→focus.
639
+ # This script runs from the NEW agent's worktree directory.
640
+
641
+ INPUT=$(cat)
642
+ OLD_ISSUE=$(echo "$INPUT" | jq -r '.old.issueNumber')
643
+ OLD_WORKTREE=$(echo "$INPUT" | jq -r '.old.worktreePath')
644
+ OLD_BRANCH=$(echo "$INPUT" | jq -r '.old.branch')
645
+ NEW_ISSUE=$(echo "$INPUT" | jq -r '.new.issueNumber')
646
+ NEW_WORKTREE=$(echo "$INPUT" | jq -r '.new.worktreePath')
647
+ NEW_BRANCH=$(echo "$INPUT" | jq -r '.new.branch')
648
+
649
+ # ── Add your swap actions below ──────────────────────────────────────────────
650
+ # Examples:
651
+ #
652
+ # Stop old dev server, start new one:
653
+ # kill $(cat "$OLD_WORKTREE/.dev-server.pid") 2>/dev/null
654
+ # cd "$NEW_WORKTREE" && pnpm dev &
655
+ # echo $! > "$NEW_WORKTREE/.dev-server.pid"
656
+ # ─────────────────────────────────────────────────────────────────────────────
657
+ `;
658
+ }
659
+ function DEFAULT_MODE_SWITCHED_SCRIPT() {
660
+ return `#!/usr/bin/env bash
661
+ # Hook: mode-switched
662
+ # Runs when the dashboard hook mode changes (via [m] key).
663
+ # Use this to start/stop dev servers, swap tmux layouts, toggle test watchers, etc.
664
+ #
665
+ # Receives JSON on stdin with these exact keys:
666
+ # { "oldMode": "planning", "newMode": "testing" }
667
+ #
668
+ # null means "default" (no mode active).
669
+ # This script runs from the main repo root as its working directory.
670
+ # NOTE: Mode-specific variants of this hook are NOT created — this hook
671
+ # IS the mode change notification.
672
+
673
+ INPUT=$(cat)
674
+ OLD_MODE=$(echo "$INPUT" | jq -r '.oldMode // "null"')
675
+ NEW_MODE=$(echo "$INPUT" | jq -r '.newMode // "null"')
676
+
677
+ # ── Add your mode-switch actions below ────────────────────────────────────────
678
+ # Examples:
679
+ #
680
+ # Start test watcher when entering testing mode:
681
+ # if [ "$NEW_MODE" = "testing" ]; then
682
+ # pnpm test --watch &
683
+ # fi
684
+ #
685
+ # Stop dev server when leaving planning mode:
686
+ # if [ "$OLD_MODE" = "planning" ]; then
687
+ # kill $(cat .dev-server.pid) 2>/dev/null
688
+ # fi
689
+ # ─────────────────────────────────────────────────────────────────────────────
690
+ `;
691
+ }
692
+ // ─────────────────────────────────────────────────────────────────────────────
693
+ // Interactive wizard
694
+ // ─────────────────────────────────────────────────────────────────────────────
695
+ async function runInteractiveWizard() {
696
+ const answers = {};
697
+ console.log(chalk.bold('Pipeline Setup Wizard'));
698
+ console.log(chalk.dim('Configure your pipeline dashboard.\n'));
699
+ for (const q of QUESTIONS) {
700
+ if (!isDependencyMet(q, answers))
701
+ continue;
702
+ if (q.type === 'select' && q.options) {
703
+ const optionLabels = q.options.map(o => o.description ? `${o.label} ${chalk.dim(`— ${o.description}`)}` : o.label);
704
+ const defaultIdx = q.options.findIndex(o => o.value === q.default);
705
+ const idx = await promptSelect(chalk.bold(q.question), optionLabels);
706
+ answers[q.id] = q.options[idx >= 0 ? idx : (defaultIdx >= 0 ? defaultIdx : 0)].value;
707
+ console.log();
708
+ }
709
+ else if (q.type === 'confirm') {
710
+ const defaultVal = q.default === true;
711
+ const result = await confirmWithDefault(chalk.bold(q.question), defaultVal);
712
+ answers[q.id] = result;
713
+ console.log();
714
+ }
715
+ else if (q.type === 'text') {
716
+ const defaultVal = q.default || '';
717
+ if (q.hint)
718
+ console.log(chalk.dim(` ${q.hint}`));
719
+ const val = await promptWithDefault(`${chalk.bold(q.question)} [${defaultVal}] `, defaultVal);
720
+ answers[q.id] = val || defaultVal;
721
+ console.log();
722
+ }
723
+ }
724
+ return answers;
725
+ }
726
+ // ─────────────────────────────────────────────────────────────────────────────
727
+ // Main command
728
+ // ─────────────────────────────────────────────────────────────────────────────
729
+ export async function pipelineSetupCommand(options) {
730
+ // ── --questions: output schema ───────────────────────────────────────────
731
+ if (options.questions) {
732
+ console.log(JSON.stringify(QUESTIONS, null, 2));
733
+ return;
734
+ }
735
+ // ── --flavors: list saved flavors ────────────────────────────────────────
736
+ if (options.flavors) {
737
+ const flavors = getFlavors();
738
+ const names = Object.keys(flavors);
739
+ if (names.length === 0) {
740
+ console.log(chalk.dim('No saved flavors.'));
741
+ console.log(chalk.dim('Save one with: echo \'{"answers":"..."}\' | ghp pipeline setup --save <name>'));
742
+ return;
743
+ }
744
+ console.log(chalk.bold('Saved flavors:'));
745
+ for (const name of names) {
746
+ const flavor = flavors[name];
747
+ const mode = flavor.dashboard_mode || 'default';
748
+ console.log(` ${chalk.cyan(name)} ${chalk.dim(`— dashboard: ${mode}`)}`);
749
+ }
750
+ return;
751
+ }
752
+ // ── --delete-flavor <name> ───────────────────────────────────────────────
753
+ if (options.deleteFlavor) {
754
+ if (deleteFlavor(options.deleteFlavor)) {
755
+ console.log(chalk.green('✓'), `Deleted flavor "${options.deleteFlavor}"`);
756
+ }
757
+ else {
758
+ console.error(chalk.red('Error:'), `Flavor "${options.deleteFlavor}" not found`);
759
+ exit(1);
760
+ }
761
+ return;
762
+ }
763
+ // ── --save <name>: save answers from stdin as a flavor ───────────────────
764
+ if (options.save) {
765
+ let answers;
766
+ try {
767
+ const input = await readStdin();
768
+ answers = JSON.parse(input.trim());
769
+ }
770
+ catch {
771
+ console.error(chalk.red('Error:'), 'Could not parse JSON from stdin');
772
+ exit(1);
773
+ return;
774
+ }
775
+ saveFlavor(options.save, answers);
776
+ console.log(chalk.green('✓'), `Saved flavor "${options.save}"`);
777
+ return;
778
+ }
779
+ // ── --flavor <name>: load and apply a saved flavor ───────────────────────
780
+ if (options.flavor) {
781
+ const flavors = getFlavors();
782
+ const answers = flavors[options.flavor];
783
+ if (!answers) {
784
+ console.error(chalk.red('Error:'), `Flavor "${options.flavor}" not found`);
785
+ console.error('Available flavors:', Object.keys(flavors).join(', ') || '(none)');
786
+ exit(1);
787
+ return;
788
+ }
789
+ console.log(chalk.dim(`Applying flavor "${options.flavor}"...`));
790
+ await applyAnswers(answers);
791
+ return;
792
+ }
793
+ // ── --apply: read answers from stdin and apply ───────────────────────────
794
+ if (options.apply) {
795
+ let answers;
796
+ try {
797
+ const input = await readStdin();
798
+ answers = JSON.parse(input.trim());
799
+ }
800
+ catch {
801
+ console.error(chalk.red('Error:'), 'Could not parse JSON from stdin');
802
+ exit(1);
803
+ return;
804
+ }
805
+ await applyAnswers(answers);
806
+ return;
807
+ }
808
+ // ── Interactive wizard (no flags) ────────────────────────────────────────
809
+ if (!isInteractive()) {
810
+ console.error(chalk.red('Error:'), 'Interactive mode requires a TTY. Use --questions/--apply for non-interactive use.');
811
+ exit(1);
812
+ return;
813
+ }
814
+ const answers = await runInteractiveWizard();
815
+ await applyAnswers(answers);
816
+ }
817
+ //# sourceMappingURL=pipeline-setup.js.map