@jaggerxtrm/specialists 3.2.0 → 3.2.1

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.
package/bin/install.js CHANGED
@@ -1,386 +1,209 @@
1
1
  #!/usr/bin/env node
2
- // Specialists Installer
3
- // Usage: npx --package=@jaggerxtrm/specialists install
4
2
 
5
3
  import { spawnSync } from 'node:child_process';
6
- import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, readdirSync, chmodSync } from 'node:fs';
4
+ import {
5
+ chmodSync,
6
+ copyFileSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
7
13
  import { homedir } from 'node:os';
8
14
  import { join } from 'node:path';
9
15
 
10
- const HOME = homedir();
11
- const SPECIALISTS_DIR = join(HOME, '.agents', 'specialists');
12
- const CLAUDE_DIR = join(HOME, '.claude');
13
- const HOOKS_DIR = join(CLAUDE_DIR, 'hooks');
14
- const SETTINGS_FILE = join(CLAUDE_DIR, 'settings.json');
15
- const HOOK_FILE = join(HOOKS_DIR, 'specialists-main-guard.mjs');
16
- const MCP_NAME = 'specialists';
17
- const GITHUB_PKG = '@jaggerxtrm/specialists';
18
-
19
- // Bundled specialists dir — resolved relative to this file (bin/../specialists/)
16
+ const CWD = process.cwd();
17
+ const CLAUDE_DIR = join(CWD, '.claude');
18
+ const HOOKS_DIR = join(CLAUDE_DIR, 'hooks');
19
+ const SETTINGS_FILE = join(CLAUDE_DIR, 'settings.json');
20
+ const MCP_FILE = join(CWD, '.mcp.json');
21
+ const BUNDLED_HOOKS_DIR = new URL('../hooks', import.meta.url).pathname;
20
22
  const BUNDLED_SPECIALISTS_DIR = new URL('../specialists', import.meta.url).pathname;
21
- const BUNDLED_HOOKS_DIR = new URL('../hooks', import.meta.url).pathname;
23
+ const USER_SPECIALISTS_DIR = join(homedir(), '.agents', 'specialists');
22
24
 
23
- // ── ANSI helpers ──────────────────────────────────────────────────────────────
24
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
25
- const green = (s) => `\x1b[32m${s}\x1b[0m`;
25
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
26
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
26
27
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
27
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
28
- const red = (s) => `\x1b[31m${s}\x1b[0m`;
28
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
29
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
29
30
 
30
31
  function section(label) {
31
- const line = '─'.repeat(Math.max(0, 40 - label.length));
32
+ const line = '─'.repeat(Math.max(0, 44 - label.length));
32
33
  console.log(`\n${bold(`── ${label} ${line}`)}`);
33
34
  }
34
35
 
35
- function ok(label) { console.log(` ${green('✓')} ${label}`); }
36
+ function ok(label) { console.log(` ${green('✓')} ${label}`); }
36
37
  function skip(label) { console.log(` ${yellow('○')} ${label}`); }
37
38
  function info(label) { console.log(` ${dim(label)}`); }
38
39
  function fail(label) { console.log(` ${red('✗')} ${label}`); }
39
40
 
40
- function isInstalled(cmd) {
41
- const r = spawnSync('which', [cmd], { encoding: 'utf8' });
42
- return r.status === 0 && r.stdout.trim().length > 0;
41
+ function run(cmd, args, options = {}) {
42
+ return spawnSync(cmd, args, {
43
+ encoding: 'utf8',
44
+ stdio: 'pipe',
45
+ ...options,
46
+ });
43
47
  }
44
48
 
45
- function npmInstallGlobal(pkg) {
46
- const r = spawnSync('npm', ['install', '-g', pkg], { stdio: 'inherit', encoding: 'utf8' });
47
- if (r.status !== 0) throw new Error(`npm install -g ${pkg} failed`);
49
+ function commandOk(cmd, args = ['--version']) {
50
+ const result = run(cmd, args);
51
+ return result.status === 0 && !result.error;
48
52
  }
49
53
 
50
- function installDolt() {
51
- if (process.platform === 'darwin') {
52
- info('Installing dolt via brew...');
53
- const r = spawnSync('brew', ['install', 'dolt'], { stdio: 'inherit', encoding: 'utf8' });
54
- r.status === 0 ? ok('dolt installed') : fail('brew install dolt failed — install manually: brew install dolt');
55
- } else {
56
- info('Installing dolt (requires sudo)...');
57
- const r = spawnSync(
58
- 'sudo', ['bash', '-c', 'curl -L https://github.com/dolthub/dolt/releases/latest/download/install.sh | bash'],
59
- { stdio: 'inherit', encoding: 'utf8' }
60
- );
61
- if (r.status === 0) {
62
- ok('dolt installed');
63
- } else {
64
- fail('dolt install failed — install manually:');
65
- info(" sudo bash -c 'curl -L https://github.com/dolthub/dolt/releases/latest/download/install.sh | bash'");
66
- }
54
+ function loadJson(path, fallback) {
55
+ if (!existsSync(path)) return structuredClone(fallback);
56
+ try {
57
+ return JSON.parse(readFileSync(path, 'utf8'));
58
+ } catch {
59
+ return structuredClone(fallback);
67
60
  }
68
61
  }
69
62
 
70
- function registerMCP() {
71
- const check = spawnSync('claude', ['mcp', 'get', MCP_NAME], { encoding: 'utf8' });
72
- if (check.status === 0) return false;
73
-
74
- npmInstallGlobal(GITHUB_PKG);
75
-
76
- const r = spawnSync('claude', [
77
- 'mcp', 'add', '--scope', 'user', MCP_NAME, '--', MCP_NAME,
78
- ], { stdio: 'inherit', encoding: 'utf8' });
79
- if (r.status !== 0) throw new Error('claude mcp add failed');
80
- return true;
63
+ function saveJson(path, value) {
64
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n', 'utf8');
81
65
  }
82
66
 
83
- // ── Hook installation ─────────────────────────────────────────────────────────
84
-
85
-
86
- const HOOK_ENTRY = {
87
- matcher: 'Edit|Write|MultiEdit|NotebookEdit|Bash',
88
- hooks: [{ type: 'command', command: HOOK_FILE }],
89
- };
90
-
91
-
92
- const BEADS_EDIT_GATE_FILE = join(HOOKS_DIR, 'beads-edit-gate.mjs');
93
- const BEADS_COMMIT_GATE_FILE = join(HOOKS_DIR, 'beads-commit-gate.mjs');
94
- const BEADS_STOP_GATE_FILE = join(HOOKS_DIR, 'beads-stop-gate.mjs');
95
-
96
- const BEADS_EDIT_GATE_ENTRY = {
97
- matcher: 'Edit|Write|MultiEdit|NotebookEdit|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
98
- hooks: [{ type: 'command', command: BEADS_EDIT_GATE_FILE, timeout: 10000 }],
99
- };
100
- const BEADS_COMMIT_GATE_ENTRY = {
101
- matcher: 'Bash',
102
- hooks: [{ type: 'command', command: BEADS_COMMIT_GATE_FILE, timeout: 10000 }],
103
- };
104
- const BEADS_STOP_GATE_ENTRY = {
105
- hooks: [{ type: 'command', command: BEADS_STOP_GATE_FILE, timeout: 10000 }],
106
- };
107
- const BEADS_CLOSE_MEMORY_PROMPT_FILE = join(HOOKS_DIR, 'beads-close-memory-prompt.mjs');
108
- const BEADS_CLOSE_MEMORY_PROMPT_ENTRY = {
109
- matcher: 'Bash',
110
- hooks: [{ type: 'command', command: BEADS_CLOSE_MEMORY_PROMPT_FILE, timeout: 10000 }],
111
- };
112
- const SPECIALISTS_COMPLETE_FILE = join(HOOKS_DIR, 'specialists-complete.mjs');
113
- const SPECIALISTS_COMPLETE_ENTRY = {
114
- hooks: [{ type: 'command', command: SPECIALISTS_COMPLETE_FILE, timeout: 5000 }],
67
+ function sameFileContent(a, b) {
68
+ if (!existsSync(a) || !existsSync(b)) return false;
69
+ return readFileSync(a, 'utf8') === readFileSync(b, 'utf8');
115
70
  }
116
- const SPECIALISTS_SESSION_START_FILE = join(HOOKS_DIR, 'specialists-session-start.mjs');
117
- const SPECIALISTS_SESSION_START_ENTRY = {
118
- hooks: [{ type: 'command', command: SPECIALISTS_SESSION_START_FILE, timeout: 8000 }],
119
- };
120
- const BUNDLED_SKILLS_DIR = new URL('../skills', import.meta.url).pathname;
121
- const CLAUDE_SKILLS_DIR = join(CLAUDE_DIR, 'skills');;
122
71
 
123
- function promptYN(question) {
124
- if (!process.stdin.isTTY) return true; // non-interactive: default yes
125
- process.stdout.write(`${question} [Y/n]: `);
126
- const r = spawnSync('/bin/sh', ['-c', 'read ans; printf "%s" "$ans"'], {
127
- stdio: ['inherit', 'pipe', 'inherit'],
128
- encoding: 'utf8',
129
- });
130
- const ans = (r.stdout ?? '').trim().toLowerCase();
131
- return ans === '' || ans === 'y' || ans === 'yes';
132
- }
72
+ const HOOKS = [
73
+ {
74
+ event: 'UserPromptSubmit',
75
+ file: 'specialists-complete.mjs',
76
+ timeout: 5000,
77
+ },
78
+ {
79
+ event: 'SessionStart',
80
+ file: 'specialists-session-start.mjs',
81
+ timeout: 8000,
82
+ },
83
+ ];
133
84
 
134
- function getHookDrift() {
135
- const pairs = [
136
- ['specialists-main-guard.mjs', HOOK_FILE],
137
- ['beads-edit-gate.mjs', BEADS_EDIT_GATE_FILE],
138
- ['beads-commit-gate.mjs', BEADS_COMMIT_GATE_FILE],
139
- ['beads-stop-gate.mjs', BEADS_STOP_GATE_FILE],
140
- ['beads-close-memory-prompt.mjs', BEADS_CLOSE_MEMORY_PROMPT_FILE],
141
- ['specialists-complete.mjs', SPECIALISTS_COMPLETE_FILE],
142
- ['specialists-session-start.mjs', SPECIALISTS_SESSION_START_FILE],
143
- ];
144
- return pairs
145
- .map(([bundled, dest]) => ({
146
- name: bundled,
147
- dest,
148
- missing: !existsSync(dest),
149
- changed: existsSync(dest) &&
150
- readFileSync(join(BUNDLED_HOOKS_DIR, bundled), 'utf8') !==
151
- readFileSync(dest, 'utf8'),
152
- }))
153
- .filter(h => h.missing || h.changed);
85
+ function findHookCommands(settings, event, fileName) {
86
+ const entries = settings?.hooks?.[event];
87
+ if (!Array.isArray(entries)) return [];
88
+ return entries.flatMap((entry) =>
89
+ (entry.hooks ?? [])
90
+ .map((hook) => hook.command)
91
+ .filter((command) => typeof command === 'string' && command.includes(fileName)),
92
+ );
154
93
  }
155
94
 
156
- function installHook() {
157
- mkdirSync(HOOKS_DIR, { recursive: true });
158
-
159
- // Copy hook files from bundled hooks/ directory
160
- copyFileSync(join(BUNDLED_HOOKS_DIR, 'specialists-main-guard.mjs'), HOOK_FILE);
161
- chmodSync(HOOK_FILE, 0o755);
162
- copyFileSync(join(BUNDLED_HOOKS_DIR, 'beads-edit-gate.mjs'), BEADS_EDIT_GATE_FILE);
163
- chmodSync(BEADS_EDIT_GATE_FILE, 0o755);
164
- copyFileSync(join(BUNDLED_HOOKS_DIR, 'beads-commit-gate.mjs'), BEADS_COMMIT_GATE_FILE);
165
- chmodSync(BEADS_COMMIT_GATE_FILE, 0o755);
166
- copyFileSync(join(BUNDLED_HOOKS_DIR, 'beads-stop-gate.mjs'), BEADS_STOP_GATE_FILE);
167
- chmodSync(BEADS_STOP_GATE_FILE, 0o755);
168
- copyFileSync(join(BUNDLED_HOOKS_DIR, 'beads-close-memory-prompt.mjs'), BEADS_CLOSE_MEMORY_PROMPT_FILE);
169
- chmodSync(BEADS_CLOSE_MEMORY_PROMPT_FILE, 0o755);
170
- copyFileSync(join(BUNDLED_HOOKS_DIR, 'specialists-complete.mjs'), SPECIALISTS_COMPLETE_FILE);
171
- chmodSync(SPECIALISTS_COMPLETE_FILE, 0o755);
172
- copyFileSync(join(BUNDLED_HOOKS_DIR, 'specialists-session-start.mjs'), SPECIALISTS_SESSION_START_FILE);
173
- chmodSync(SPECIALISTS_SESSION_START_FILE, 0o755);
95
+ function ensureHook(settings, hook) {
96
+ const dest = join(HOOKS_DIR, hook.file);
97
+ const source = join(BUNDLED_HOOKS_DIR, hook.file);
98
+ const existingCommands = findHookCommands(settings, hook.event, hook.file);
99
+ const externalOwner = existingCommands.find((command) => command !== dest);
174
100
 
175
- let settings = {};
176
- if (existsSync(SETTINGS_FILE)) {
177
- try { settings = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8')); } catch {}
101
+ if (externalOwner) {
102
+ skip(`${hook.file} already managed externally — deferring`);
103
+ info(`existing command: ${externalOwner}`);
104
+ return false;
178
105
  }
179
106
 
180
- settings.hooks = settings.hooks ?? {};
181
-
182
- // PreToolUse — replace any existing specialists-managed entries
183
- if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];
184
- settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(e =>
185
- !e.hooks?.some(h =>
186
- h.command?.includes('specialists-main-guard') ||
187
- h.command?.includes('beads-edit-gate') ||
188
- h.command?.includes('beads-commit-gate')
189
- )
190
- );
191
- settings.hooks.PreToolUse.push(HOOK_ENTRY);
192
- settings.hooks.PreToolUse.push(BEADS_EDIT_GATE_ENTRY);
193
- settings.hooks.PreToolUse.push(BEADS_COMMIT_GATE_ENTRY);
194
-
195
- // PostToolUse — replace any existing beads-close-memory-prompt entry
196
- if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
197
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(e =>
198
- !e.hooks?.some(h => h.command?.includes('beads-close-memory-prompt'))
199
- );
200
- settings.hooks.PostToolUse.push(BEADS_CLOSE_MEMORY_PROMPT_ENTRY);
201
-
202
- // Stop — replace any existing beads-stop-gate entry
203
- if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
204
- settings.hooks.Stop = settings.hooks.Stop.filter(e =>
205
- !e.hooks?.some(h => h.command?.includes('beads-stop-gate'))
206
- );
207
- settings.hooks.Stop.push(BEADS_STOP_GATE_ENTRY);
208
-
209
- // UserPromptSubmit — replace any existing specialists-complete entry
210
- if (!Array.isArray(settings.hooks.UserPromptSubmit)) settings.hooks.UserPromptSubmit = [];
211
- settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(e =>
212
- !e.hooks?.some(h => h.command?.includes('specialists-complete'))
213
- );
214
- settings.hooks.UserPromptSubmit.push(SPECIALISTS_COMPLETE_ENTRY);
107
+ mkdirSync(HOOKS_DIR, { recursive: true });
108
+ const changed = !sameFileContent(source, dest);
109
+ if (changed) {
110
+ copyFileSync(source, dest);
111
+ chmodSync(dest, 0o755);
112
+ ok(`${hook.file} installed in .claude/hooks/`);
113
+ } else {
114
+ skip(`${hook.file} already up to date`);
115
+ }
215
116
 
216
- // SessionStart — replace any existing specialists-session-start entry
217
- if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
218
- settings.hooks.SessionStart = settings.hooks.SessionStart.filter(e =>
219
- !e.hooks?.some(h => h.command?.includes('specialists-session-start'))
117
+ settings.hooks ??= {};
118
+ settings.hooks[hook.event] ??= [];
119
+ settings.hooks[hook.event] = settings.hooks[hook.event].filter(
120
+ (entry) => !(entry.hooks ?? []).some((h) => h.command === dest),
220
121
  );
221
- settings.hooks.SessionStart.push(SPECIALISTS_SESSION_START_ENTRY);
222
-
223
- mkdirSync(CLAUDE_DIR, { recursive: true });
224
- writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + '\n', 'utf8');
122
+ settings.hooks[hook.event].push({
123
+ hooks: [{ type: 'command', command: dest, timeout: hook.timeout }],
124
+ });
125
+ ok(`${hook.file} registered for ${hook.event}`);
126
+ return true;
225
127
  }
226
128
 
227
-
228
- function installSkills() {
229
- if (!existsSync(BUNDLED_SKILLS_DIR)) return { installed: 0, skipped: 0 };
230
- mkdirSync(CLAUDE_SKILLS_DIR, { recursive: true });
231
-
232
- let installed = 0;
233
- let skippedCount = 0;
234
- let skillNames;
235
- try {
236
- skillNames = readdirSync(BUNDLED_SKILLS_DIR, { withFileTypes: true })
237
- .filter(d => d.isDirectory())
238
- .map(d => d.name);
239
- } catch {
240
- return { installed: 0, skipped: 0 };
129
+ function installBundledSpecialists() {
130
+ if (!existsSync(BUNDLED_SPECIALISTS_DIR)) {
131
+ skip('bundled specialists dir not found skipping');
132
+ return;
241
133
  }
242
-
243
- for (const skillName of skillNames) {
244
- const srcDir = join(BUNDLED_SKILLS_DIR, skillName);
245
- const destDir = join(CLAUDE_SKILLS_DIR, skillName);
246
- const skillFile = join(srcDir, 'SKILL.md');
247
- const destSkillFile = join(destDir, 'SKILL.md');
248
-
249
- if (!existsSync(skillFile)) continue;
250
-
251
- if (existsSync(destSkillFile)) {
252
- // Check if content matches bundled version
253
- try {
254
- if (readFileSync(skillFile, 'utf8') === readFileSync(destSkillFile, 'utf8')) {
255
- skippedCount++;
256
- continue;
257
- }
258
- } catch { /* fall through to copy */ }
134
+ mkdirSync(USER_SPECIALISTS_DIR, { recursive: true });
135
+ const files = readdirSync(BUNDLED_SPECIALISTS_DIR).filter(f => f.endsWith('.specialist.yaml'));
136
+ for (const file of files) {
137
+ const source = join(BUNDLED_SPECIALISTS_DIR, file);
138
+ const dest = join(USER_SPECIALISTS_DIR, file);
139
+ if (sameFileContent(source, dest)) {
140
+ skip(`${file} already up to date`);
141
+ } else {
142
+ copyFileSync(source, dest);
143
+ ok(`${file} installed in ~/.agents/specialists/`);
259
144
  }
260
-
261
- mkdirSync(destDir, { recursive: true });
262
- copyFileSync(skillFile, destSkillFile);
263
- installed++;
264
145
  }
265
-
266
- return { installed, skipped: skippedCount };
267
146
  }
268
147
 
269
- // ── Main ──────────────────────────────────────────────────────────────────────
270
- console.log('\n' + bold(' Specialists full-stack installer'));
271
-
272
- // 1. pi
273
- section('pi (coding agent runtime)');
274
- if (isInstalled('pi')) {
275
- skip('pi already installed');
276
- } else {
277
- info('Installing @mariozechner/pi-coding-agent...');
278
- npmInstallGlobal('@mariozechner/pi-coding-agent');
279
- ok('pi installed');
280
- }
281
-
282
- // 2. beads
283
- section('beads (issue tracker)');
284
- if (isInstalled('bd')) {
285
- skip('bd already installed');
286
- } else {
287
- info('Installing @beads/bd...');
288
- npmInstallGlobal('@beads/bd');
289
- ok('bd installed');
290
- }
148
+ function ensureMcpRegistration() {
149
+ const mcp = loadJson(MCP_FILE, { mcpServers: {} });
150
+ mcp.mcpServers ??= {};
151
+ const existing = mcp.mcpServers.specialists;
152
+ const desired = { command: 'specialists', args: [] };
153
+
154
+ if (
155
+ existing &&
156
+ existing.command === desired.command &&
157
+ Array.isArray(existing.args) &&
158
+ existing.args.length === 0
159
+ ) {
160
+ skip('.mcp.json already registers specialists');
161
+ return;
162
+ }
291
163
 
292
- // 3. dolt
293
- section('dolt (beads sync backend)');
294
- if (isInstalled('dolt')) {
295
- skip('dolt already installed');
296
- } else {
297
- installDolt();
164
+ mcp.mcpServers.specialists = desired;
165
+ saveJson(MCP_FILE, mcp);
166
+ ok('registered specialists in .mcp.json');
298
167
  }
299
168
 
300
- // 4. Specialists MCP
301
- section('Specialists MCP');
302
- const registered = registerMCP();
303
- registered
304
- ? ok(`MCP '${MCP_NAME}' registered at user scope`)
305
- : skip(`MCP '${MCP_NAME}' already registered`);
169
+ console.log(`\n${bold('Specialists installer')}`);
170
+ console.log(dim('Project scope: prerequisite check, bundled specialists, hooks, MCP registration'));
306
171
 
307
- // 5. Scaffold + copy built-in specialists
308
- section('Specialists');
309
- mkdirSync(SPECIALISTS_DIR, { recursive: true });
172
+ section('Prerequisite check');
173
+ const prereqs = [
174
+ { name: 'pi', ok: commandOk('pi', ['--version']), required: true, help: 'Install pi first.' },
175
+ { name: 'bd', ok: commandOk('bd', ['--version']), required: true, help: 'Install beads (bd) first.' },
176
+ { name: 'xt', ok: commandOk('xt', ['--version']), required: true, help: 'xtrm-tools is required for hooks and workflow integration.' },
177
+ ];
310
178
 
311
- const yamlFiles = existsSync(BUNDLED_SPECIALISTS_DIR)
312
- ? readdirSync(BUNDLED_SPECIALISTS_DIR).filter(f => f.endsWith('.specialist.yaml'))
313
- : [];
314
-
315
- let installed = 0;
316
- let skipped = 0;
317
- for (const file of yamlFiles) {
318
- const dest = join(SPECIALISTS_DIR, file);
319
- if (existsSync(dest)) {
320
- skipped++;
179
+ let prereqFailed = false;
180
+ for (const prereq of prereqs) {
181
+ if (prereq.ok) {
182
+ ok(`${prereq.name} available`);
321
183
  } else {
322
- copyFileSync(join(BUNDLED_SPECIALISTS_DIR, file), dest);
323
- installed++;
184
+ prereqFailed = prereqFailed || prereq.required;
185
+ fail(`${prereq.name} not found`);
186
+ info(prereq.help);
324
187
  }
325
188
  }
326
189
 
327
- if (installed > 0) ok(`${installed} specialist(s) installed → ~/.agents/specialists/`);
328
- if (skipped > 0) skip(`${skipped} specialist(s) already exist (user-modified, keeping)`);
329
- if (installed === 0 && skipped === 0) skip('No built-in specialists found');
330
- info('Edit any .specialist.yaml in ~/.agents/specialists/ to customise models, prompts, permissions');
331
-
332
- // 6. Claude Code hooks
333
- section('Claude Code hooks');
334
- const drift = getHookDrift();
335
- const hooksExist = existsSync(HOOK_FILE);
336
-
337
- if (!hooksExist) {
338
- installHook();
339
- ok('hooks installed → ~/.claude/hooks/');
340
- } else if (drift.length === 0) {
341
- skip('hooks up to date');
342
- } else {
343
- const label = (h) => h.missing ? red('missing') : yellow('updated');
344
- console.log(` ${yellow('○')} ${drift.length} of 7 hook(s) have changes:`);
345
- for (const h of drift) info(` ${h.name} ${label(h)}`);
346
- console.log();
347
- const confirmed = promptYN(' Update hooks?');
348
- if (confirmed) {
349
- installHook();
350
- ok('hooks updated');
351
- } else {
352
- skip('hooks update skipped');
353
- }
190
+ if (prereqFailed) {
191
+ console.log(`\n${red('Install aborted: required prerequisites are missing.')}`);
192
+ process.exit(1);
354
193
  }
355
- info('main-guard: blocks file edits and direct master pushes (enforces PR workflow)');
356
- info('beads-edit-gate: requires in_progress bead before editing files');
357
- info('beads-commit-gate: requires issues closed before git commit');
358
- info('beads-stop-gate: requires issues closed before session end');
359
- info('beads-close-memory-prompt: nudges knowledge capture after bd close');
360
- info('specialists-complete: injects completion banners for background jobs');
361
- info('specialists-session-start: injects context (jobs, specialists, commands) at session start');
362
194
 
363
- // 7. Skills
364
- section('Skills');
365
- const skillResult = installSkills();
366
- if (skillResult.installed > 0) ok(`${skillResult.installed} skill(s) installed → ~/.claude/skills/`);
367
- if (skillResult.skipped > 0) skip(`${skillResult.skipped} skill(s) already up to date`);
368
- if (skillResult.installed === 0 && skillResult.skipped === 0) skip('No bundled skills found');
369
- info("specialists-usage: teaches agents when/how to use specialists CLI and MCP tools");
195
+ section('Specialists hooks');
196
+ mkdirSync(CLAUDE_DIR, { recursive: true });
197
+ const settings = loadJson(SETTINGS_FILE, {});
198
+ for (const hook of HOOKS) ensureHook(settings, hook);
199
+ saveJson(SETTINGS_FILE, settings);
200
+ ok(`updated ${SETTINGS_FILE}`);
370
201
 
371
- // 8. Health check
372
- section('Health check');
373
- if (isInstalled('pi')) {
374
- const r = spawnSync('pi', ['--list-models'], { encoding: 'utf8' });
375
- r.status === 0
376
- ? ok('pi has at least one active provider')
377
- : skip('No active provider — run pi config to set one up');
378
- }
202
+ section('Bundled specialists');
203
+ installBundledSpecialists();
204
+
205
+ section('MCP registration');
206
+ ensureMcpRegistration();
379
207
 
380
- // 9. Done
381
- console.log('\n' + bold(green(' Done!')));
382
- console.log('\n' + bold(' Next steps:'));
383
- console.log(` 1. ${bold('Configure pi:')} run ${yellow('pi')} then ${yellow('pi config')} to enable model providers`);
384
- console.log(` 2. ${bold('Restart Claude Code')} to load the MCP and hooks`);
385
- console.log(` 3. ${bold('Customise specialists:')} edit files in ${yellow('~/.agents/specialists/')}`);
386
- console.log(` 4. ${bold('Update later:')} re-run this installer (existing specialists preserved)\n`);
208
+ console.log(`\n${bold(green('Done!'))}`);
209
+ console.log(` ${dim('Hooks are project-local in .claude/, and MCP is project-local in .mcp.json.')}`);