xtrm-cli 0.5.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 (93) hide show
  1. package/.gemini/settings.json +39 -0
  2. package/dist/index.cjs +57378 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2 -0
  5. package/extensions/beads.ts +109 -0
  6. package/extensions/core/adapter.ts +45 -0
  7. package/extensions/core/lib.ts +3 -0
  8. package/extensions/core/logger.ts +45 -0
  9. package/extensions/core/runner.ts +71 -0
  10. package/extensions/custom-footer.ts +160 -0
  11. package/extensions/main-guard-post-push.ts +44 -0
  12. package/extensions/main-guard.ts +126 -0
  13. package/extensions/minimal-mode.ts +201 -0
  14. package/extensions/quality-gates.ts +67 -0
  15. package/extensions/service-skills.ts +150 -0
  16. package/extensions/xtrm-loader.ts +89 -0
  17. package/hooks/gitnexus-impact-reminder.py +13 -0
  18. package/lib/atomic-config.js +236 -0
  19. package/lib/config-adapter.js +231 -0
  20. package/lib/config-injector.js +80 -0
  21. package/lib/context.js +73 -0
  22. package/lib/diff.js +142 -0
  23. package/lib/env-manager.js +160 -0
  24. package/lib/sync-mcp-cli.js +345 -0
  25. package/lib/sync.js +227 -0
  26. package/package.json +47 -0
  27. package/src/adapters/base.ts +29 -0
  28. package/src/adapters/claude.ts +38 -0
  29. package/src/adapters/registry.ts +21 -0
  30. package/src/commands/claude.ts +122 -0
  31. package/src/commands/clean.ts +371 -0
  32. package/src/commands/end.ts +239 -0
  33. package/src/commands/finish.ts +25 -0
  34. package/src/commands/help.ts +180 -0
  35. package/src/commands/init.ts +959 -0
  36. package/src/commands/install-pi.ts +276 -0
  37. package/src/commands/install-service-skills.ts +281 -0
  38. package/src/commands/install.ts +427 -0
  39. package/src/commands/pi-install.ts +119 -0
  40. package/src/commands/pi.ts +128 -0
  41. package/src/commands/reset.ts +12 -0
  42. package/src/commands/status.ts +170 -0
  43. package/src/commands/worktree.ts +193 -0
  44. package/src/core/context.ts +141 -0
  45. package/src/core/diff.ts +174 -0
  46. package/src/core/interactive-plan.ts +165 -0
  47. package/src/core/manifest.ts +26 -0
  48. package/src/core/preflight.ts +142 -0
  49. package/src/core/rollback.ts +32 -0
  50. package/src/core/session-state.ts +139 -0
  51. package/src/core/sync-executor.ts +427 -0
  52. package/src/core/xtrm-finish.ts +267 -0
  53. package/src/index.ts +87 -0
  54. package/src/tests/policy-parity.test.ts +204 -0
  55. package/src/tests/session-flow-parity.test.ts +118 -0
  56. package/src/tests/session-state.test.ts +124 -0
  57. package/src/tests/xtrm-finish.test.ts +148 -0
  58. package/src/types/config.ts +51 -0
  59. package/src/types/models.ts +52 -0
  60. package/src/utils/atomic-config.ts +467 -0
  61. package/src/utils/banner.ts +194 -0
  62. package/src/utils/config-adapter.ts +90 -0
  63. package/src/utils/config-injector.ts +81 -0
  64. package/src/utils/env-manager.ts +193 -0
  65. package/src/utils/hash.ts +42 -0
  66. package/src/utils/repo-root.ts +39 -0
  67. package/src/utils/sync-mcp-cli.ts +395 -0
  68. package/src/utils/theme.ts +37 -0
  69. package/src/utils/worktree-session.ts +93 -0
  70. package/test/atomic-config-prune.test.ts +101 -0
  71. package/test/atomic-config.test.ts +138 -0
  72. package/test/clean.test.ts +172 -0
  73. package/test/config-schema.test.ts +52 -0
  74. package/test/context.test.ts +33 -0
  75. package/test/end-worktree.test.ts +168 -0
  76. package/test/extensions/beads.test.ts +166 -0
  77. package/test/extensions/extension-harness.ts +85 -0
  78. package/test/extensions/main-guard.test.ts +77 -0
  79. package/test/extensions/minimal-mode.test.ts +107 -0
  80. package/test/extensions/quality-gates.test.ts +79 -0
  81. package/test/extensions/service-skills.test.ts +84 -0
  82. package/test/extensions/xtrm-loader.test.ts +53 -0
  83. package/test/hooks/quality-check-hooks.test.ts +45 -0
  84. package/test/hooks.test.ts +1075 -0
  85. package/test/install-pi.test.ts +185 -0
  86. package/test/install-project.test.ts +378 -0
  87. package/test/install-service-skills.test.ts +131 -0
  88. package/test/install-surface.test.ts +72 -0
  89. package/test/runtime-subcommands.test.ts +121 -0
  90. package/test/session-launcher.test.ts +139 -0
  91. package/tsconfig.json +22 -0
  92. package/tsup.config.ts +17 -0
  93. package/vitest.config.ts +10 -0
@@ -0,0 +1,395 @@
1
+ import { execSync, exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ const execAsync = promisify(exec);
4
+ import fs from 'fs-extra';
5
+ import path from 'path';
6
+ import kleur from 'kleur';
7
+ // @ts-ignore
8
+ import ora from 'ora';
9
+ import { ensureEnvFile, loadEnvFile, checkRequiredEnvVars, handleMissingEnvVars, getEnvFilePath } from './env-manager.js';
10
+
11
+ export type AgentName = 'claude';
12
+
13
+ interface AgentCLI {
14
+ command: string;
15
+ listArgs: string[];
16
+ addStdio: (name: string, cmd: string, args?: string[], env?: Record<string, string>) => string[];
17
+ addHttp: (name: string, url: string, headers?: Record<string, string>) => string[];
18
+ addSse: (name: string, url: string) => string[];
19
+ remove: (name: string) => string[];
20
+ parseList: (output: string) => string[];
21
+ }
22
+
23
+ const AGENT_CLI: Record<AgentName, AgentCLI> = {
24
+ claude: {
25
+ command: 'claude',
26
+ listArgs: ['mcp', 'list'],
27
+ addStdio: (name, cmd, args, env) => {
28
+ const base = ['mcp', 'add', '-s', 'user', name, '--'];
29
+ if (env && Object.keys(env).length > 0) {
30
+ for (const [key, value] of Object.entries(env)) {
31
+ base.push('-e', `${key}=${resolveEnvVar(value)}`);
32
+ }
33
+ }
34
+ base.push(cmd, ...(args || []));
35
+ return base;
36
+ },
37
+ addHttp: (name, url, headers) => {
38
+ const base = ['mcp', 'add', '-s', 'user', '--transport', 'http', name, url];
39
+ if (headers) {
40
+ for (const [key, value] of Object.entries(headers)) {
41
+ base.push('--header', `${key}: ${resolveEnvVar(value)}`);
42
+ }
43
+ }
44
+ return base;
45
+ },
46
+ addSse: (name, url) => {
47
+ return ['mcp', 'add', '-s', 'user', '--transport', 'sse', name, url];
48
+ },
49
+ remove: (name) => ['mcp', 'remove', '-s', 'user', name],
50
+ parseList: (output) => parseMcpListOutput(output, /^([a-zA-Z0-9_-]+):/)
51
+ },
52
+ };
53
+
54
+ // Strip ANSI escape codes (e.g. qwen wraps ✓ in color codes)
55
+ function stripAnsi(str: string): string {
56
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
57
+ }
58
+
59
+ function parseMcpListOutput(output: string, pattern: RegExp): string[] {
60
+ const servers: string[] = [];
61
+ for (const line of output.split('\n')) {
62
+ const match = stripAnsi(line).match(pattern);
63
+ if (match) {
64
+ servers.push(match[1]);
65
+ }
66
+ }
67
+ return servers;
68
+ }
69
+
70
+ function resolveEnvVar(value: string): string {
71
+ if (typeof value !== 'string') return value;
72
+
73
+ return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_match, envName) => {
74
+ const upperName = envName.toUpperCase();
75
+ const envValue = process.env[upperName];
76
+ if (envValue) {
77
+ return envValue;
78
+ } else {
79
+ console.warn(kleur.yellow(` ⚠️ Environment variable ${upperName} is not set in ${getEnvFilePath()}`));
80
+ return '';
81
+ }
82
+ });
83
+ }
84
+
85
+ export function detectAgent(systemRoot: string): AgentName | null {
86
+ const normalizedRoot = systemRoot.replace(/\\/g, '/').toLowerCase();
87
+ if (normalizedRoot.includes('.claude') || normalizedRoot.includes('/claude')) {
88
+ return 'claude';
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function buildAddCommand(agent: AgentName, name: string, server: any): string[] | null {
94
+ const cli = AGENT_CLI[agent];
95
+ if (!cli) return null;
96
+
97
+ if (server.url || server.serverUrl) {
98
+ const url = server.url || server.serverUrl;
99
+ const type = server.type || (url.includes('/sse') ? 'sse' : 'http');
100
+
101
+ if (type === 'sse') {
102
+ return cli.addSse(name, url);
103
+ } else {
104
+ return cli.addHttp(name, url, server.headers);
105
+ }
106
+ }
107
+
108
+ if (server.command) {
109
+ return cli.addStdio(name, server.command, server.args, server.env);
110
+ }
111
+
112
+ console.warn(kleur.yellow(` ⚠️ Skipping server "${name}": Unknown configuration`));
113
+ return null;
114
+ }
115
+
116
+ interface CommandResult {
117
+ success: boolean;
118
+ dryRun?: boolean;
119
+ skipped?: boolean;
120
+ error?: string;
121
+ }
122
+
123
+ function executeCommand(agent: AgentName, args: string[], dryRun: boolean = false, displayName?: string): CommandResult {
124
+ const cli = AGENT_CLI[agent];
125
+
126
+ const quotedArgs = args.map(arg => {
127
+ if (arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
128
+ return `"${arg}"`;
129
+ }
130
+ return arg;
131
+ });
132
+ const command = `${cli.command} ${quotedArgs.join(' ')}`;
133
+
134
+ if (dryRun) {
135
+ console.log(kleur.cyan(` [DRY RUN] ${displayName ?? args.slice(2).join(' ')}`));
136
+ return { success: true, dryRun: true };
137
+ }
138
+
139
+ try {
140
+ execSync(command, { stdio: 'pipe', timeout: 10000 });
141
+ console.log(kleur.green(` ✓ ${displayName ?? args.slice(2).join(' ')}`));
142
+ return { success: true };
143
+ } catch (error: any) {
144
+ const stderr = error.stderr?.toString() || error.message;
145
+
146
+ if (stderr.includes('already exists') || stderr.includes('already configured')) {
147
+ let serverName = 'unknown';
148
+ if (agent === 'claude') {
149
+ const addIndex = args.indexOf('add');
150
+ for (let i = addIndex + 1; i < args.length; i++) {
151
+ const arg = args[i];
152
+ if (arg === '--') continue;
153
+ if (arg.startsWith('-')) continue;
154
+ if (['local', 'user', 'project', 'http', 'sse', 'stdio'].includes(arg)) continue;
155
+ serverName = arg;
156
+ break;
157
+ }
158
+ }
159
+ console.log(kleur.dim(` ✓ ${serverName} (already configured)`));
160
+ return { success: true, skipped: true };
161
+ }
162
+
163
+ console.log(kleur.red(` ✗ Failed: ${stderr.trim()}`));
164
+ return { success: false, error: stderr };
165
+ }
166
+ }
167
+
168
+ export function getCurrentServers(agent: AgentName): string[] {
169
+ const cli = AGENT_CLI[agent];
170
+ try {
171
+ const output = execSync(`${cli.command} ${cli.listArgs.join(' ')}`, {
172
+ encoding: 'utf8',
173
+ stdio: ['pipe', 'pipe', 'pipe']
174
+ });
175
+ return cli.parseList(output);
176
+ } catch (error: any) {
177
+ // Some CLIs (e.g. gemini) write server list to stderr
178
+ const combined = (error.stdout || '') + '\n' + (error.stderr || '');
179
+ return cli.parseList(combined);
180
+ }
181
+ }
182
+
183
+ export async function getCurrentServersAsync(agent: AgentName): Promise<string[]> {
184
+ const cli = AGENT_CLI[agent];
185
+ try {
186
+ const { stdout, stderr } = await execAsync(`${cli.command} ${cli.listArgs.join(' ')}`, {
187
+ timeout: 10000,
188
+ });
189
+ // Some CLIs (e.g. gemini) write server list to stderr
190
+ return cli.parseList(stdout + '\n' + stderr);
191
+ } catch (error) {
192
+ return [];
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Extract ${VAR_NAME} references from a list of server config objects
198
+ */
199
+ function getServerEnvVarNames(servers: any[]): string[] {
200
+ const vars = new Set<string>();
201
+ const pattern = /\$\{([A-Z0-9_]+)\}/g;
202
+ for (const server of servers) {
203
+ const json = JSON.stringify(server);
204
+ let match;
205
+ const re = new RegExp(pattern.source, pattern.flags);
206
+ while ((match = re.exec(json)) !== null) {
207
+ vars.add(match[1]);
208
+ }
209
+ }
210
+ return Array.from(vars);
211
+ }
212
+
213
+ /**
214
+ * Sync MCP servers to an agent using official CLI
215
+ */
216
+ // Prevents ensureEnvFile/loadEnvFile from firing once per target directory
217
+ let envInitialized = false;
218
+
219
+ export async function syncMcpServersWithCli(
220
+ agent: AgentName,
221
+ mcpConfig: any,
222
+ dryRun: boolean = false,
223
+ prune: boolean = false
224
+ ): Promise<void> {
225
+ const cli = AGENT_CLI[agent];
226
+ if (!cli) {
227
+ console.log(kleur.yellow(` ⚠️ Unsupported agent: ${agent}`));
228
+ return;
229
+ }
230
+
231
+ if (!envInitialized) {
232
+ ensureEnvFile();
233
+ loadEnvFile();
234
+ envInitialized = true;
235
+ }
236
+
237
+ const spinner = ora({ text: kleur.dim(' checking installed servers…'), indent: 2 }).start();
238
+ const currentServers = await getCurrentServersAsync(agent);
239
+ spinner.stop();
240
+ const currentServersSet = new Set(currentServers);
241
+ const canonicalServers = new Set(Object.keys(mcpConfig.mcpServers || {}));
242
+
243
+ if (prune) {
244
+ const toRemove = currentServers.filter(s => !canonicalServers.has(s));
245
+ if (toRemove.length > 0) {
246
+ console.log(kleur.red(` Pruning ${toRemove.length} server(s)...`));
247
+ for (const serverName of toRemove) {
248
+ executeCommand(agent, cli.remove(serverName), dryRun);
249
+ }
250
+ }
251
+ }
252
+
253
+ // Determine which servers actually need to be added
254
+ const toAdd = Object.entries(mcpConfig.mcpServers || {}).filter(([name]) => !currentServersSet.has(name));
255
+ const skippedCount = canonicalServers.size - toAdd.length;
256
+
257
+ if (toAdd.length === 0) {
258
+ // Nothing to add — skip env warning, no CLI calls needed
259
+ console.log(kleur.dim(` ✓ ${skippedCount} server(s) already installed`));
260
+ return;
261
+ }
262
+
263
+ // Step 1: Multiselect — all servers pre-selected, user can deselect with space
264
+ let selectedNames: string[] = toAdd.map(([name]) => name);
265
+
266
+ if (!dryRun) {
267
+ // @ts-ignore
268
+ const prompts = await import('prompts');
269
+ const { selected } = await prompts.default({
270
+ type: 'multiselect',
271
+ name: 'selected',
272
+ message: `Select MCP servers to install via ${agent} CLI:`,
273
+ choices: toAdd.map(([name, server]: [string, any]) => ({
274
+ title: name,
275
+ description: (server as any)._notes?.description || '',
276
+ value: name,
277
+ selected: true
278
+ })),
279
+ hint: '- Space to toggle. Enter to confirm.',
280
+ instructions: false
281
+ });
282
+
283
+ if (!selected || selected.length === 0) {
284
+ console.log(kleur.gray(' Skipped MCP installation.'));
285
+ return;
286
+ }
287
+
288
+ selectedNames = selected;
289
+
290
+ // Step 2: Only ask for env vars needed by the selected servers
291
+ const selectedEntries = toAdd.filter(([name]) => new Set(selectedNames).has(name));
292
+ const neededVarNames = getServerEnvVarNames(selectedEntries.map(([, s]) => s as any));
293
+ const missingEnvVars = checkRequiredEnvVars(neededVarNames);
294
+ if (missingEnvVars.length > 0) {
295
+ const shouldProceed = await handleMissingEnvVars(missingEnvVars);
296
+ if (!shouldProceed) return;
297
+ }
298
+ }
299
+
300
+ // Step 3: Build and execute commands for selected servers only
301
+ const selectedSet = new Set(selectedNames);
302
+ const commandsToRun: Array<{ name: string; cmd: string[] }> = [];
303
+ for (const [name, server] of toAdd) {
304
+ if (!selectedSet.has(name)) continue;
305
+ const cmd = buildAddCommand(agent, name, server as any);
306
+ if (cmd) commandsToRun.push({ name, cmd });
307
+ }
308
+
309
+ if (commandsToRun.length === 0) return;
310
+
311
+ let successCount = 0;
312
+ for (const { name, cmd } of commandsToRun) {
313
+ const result = executeCommand(agent, cmd, dryRun, name);
314
+ if (result.success && !result.skipped) {
315
+ successCount++;
316
+ }
317
+ }
318
+
319
+ if (skippedCount > 0) {
320
+ console.log(kleur.dim(` ✓ ${skippedCount} already installed, ${successCount} added`));
321
+ } else {
322
+ console.log(kleur.green(` ✓ ${successCount} server(s) added`));
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Load canonical MCP config from repository
328
+ */
329
+ export function loadCanonicalMcpConfig(repoRoot: string, includeOptional: boolean = false): any {
330
+ const corePath = path.join(repoRoot, 'config', 'mcp_servers.json');
331
+ const optionalPath = path.join(repoRoot, 'config', 'mcp_servers_optional.json');
332
+
333
+ const config: any = { mcpServers: {} };
334
+
335
+ if (fs.existsSync(corePath)) {
336
+ const core = fs.readJsonSync(corePath);
337
+ config.mcpServers = { ...config.mcpServers, ...core.mcpServers };
338
+ }
339
+
340
+ if (includeOptional && fs.existsSync(optionalPath)) {
341
+ const optional = fs.readJsonSync(optionalPath);
342
+ config.mcpServers = { ...config.mcpServers, ...optional.mcpServers };
343
+ }
344
+
345
+ return config;
346
+ }
347
+
348
+ /**
349
+ * Prompt user to select optional MCP servers
350
+ */
351
+ export async function promptOptionalServers(repoRoot: string): Promise<string[] | false> {
352
+ const optionalPath = path.join(repoRoot, 'config', 'mcp_servers_optional.json');
353
+
354
+ if (!fs.existsSync(optionalPath)) {
355
+ return false;
356
+ }
357
+
358
+ const optional = fs.readJsonSync(optionalPath);
359
+ const servers = Object.entries(optional.mcpServers || {}).map(([name, server]: [string, any]) => ({
360
+ name,
361
+ description: server._notes?.description || 'No description',
362
+ prerequisite: server._notes?.prerequisite || ''
363
+ }));
364
+
365
+ if (servers.length === 0) {
366
+ return false;
367
+ }
368
+
369
+ // @ts-ignore
370
+ const prompts = await import('prompts');
371
+
372
+ const { selected } = await prompts.default({
373
+ type: 'multiselect',
374
+ name: 'selected',
375
+ message: 'Optional MCP servers available — select to install (space to toggle, enter to confirm):',
376
+ choices: servers.map(s => ({
377
+ title: s.name,
378
+ description: s.prerequisite
379
+ ? `${s.description} — ⚠️ ${s.prerequisite}`
380
+ : s.description,
381
+ value: s.name,
382
+ selected: false
383
+ })),
384
+ hint: '- Space to select. Enter to skip or confirm.',
385
+ instructions: false
386
+ });
387
+
388
+ if (!selected || selected.length === 0) {
389
+ console.log(kleur.gray(' Skipping optional servers.\n'));
390
+ return false;
391
+ }
392
+
393
+ console.log(kleur.green(` Selected: ${selected.join(', ')}\n`));
394
+ return selected;
395
+ }
@@ -0,0 +1,37 @@
1
+ import kleur from 'kleur';
2
+
3
+ /** Semantic color tokens */
4
+ export const t = {
5
+ // Status
6
+ success: (s: string) => kleur.green(s),
7
+ error: (s: string) => kleur.red(s),
8
+ warning: (s: string) => kleur.yellow(s),
9
+ info: (s: string) => kleur.cyan(s), // was blue — unreadable on dark terminals
10
+
11
+ // Hierarchy (3-tier weight system)
12
+ header: (s: string) => kleur.bold().white(s), // section headers
13
+ label: (s: string) => kleur.dim(s), // metadata labels
14
+ muted: (s: string) => kleur.gray(s),
15
+ accent: (s: string) => kleur.cyan(s),
16
+ bold: (s: string) => kleur.bold(s),
17
+ dim: (s: string) => kleur.dim(s),
18
+
19
+ // Compound
20
+ boldGreen: (s: string) => kleur.bold().green(s),
21
+ boldRed: (s: string) => kleur.bold().red(s),
22
+ };
23
+
24
+ /** Status symbols with colour baked in */
25
+ export const sym = {
26
+ ok: kleur.green('✓'),
27
+ fail: kleur.red('✗'),
28
+ warn: kleur.yellow('⚠'),
29
+
30
+ // File change states — directional metaphor
31
+ missing: kleur.green('+'),
32
+ outdated: kleur.yellow('↑'),
33
+ drifted: kleur.magenta('≠'), // was red '!' — magenta = conflict/divergence
34
+
35
+ arrow: kleur.gray('→'),
36
+ bullet: kleur.gray('•'),
37
+ };
@@ -0,0 +1,93 @@
1
+ import kleur from 'kleur';
2
+ import path from 'node:path';
3
+ import fs from 'fs-extra';
4
+ import { execSync, spawnSync } from 'node:child_process';
5
+ import { findRepoRoot } from './repo-root.js';
6
+
7
+ export interface WorktreeSessionOptions {
8
+ runtime: 'claude' | 'pi';
9
+ name?: string;
10
+ }
11
+
12
+ function randomSlug(len: number = 4): string {
13
+ return Math.random().toString(36).slice(2, 2 + len);
14
+ }
15
+
16
+ function shortDate(): string {
17
+ const d = new Date();
18
+ return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
19
+ }
20
+
21
+ /**
22
+ * Launch a Claude or Pi session in a sandboxed git worktree.
23
+ *
24
+ * Worktree path: sibling to CWD, named <cwd-basename>-xt-<runtime>-<shortdate>
25
+ * Branch: xt/<name> if name provided, xt/<4-char-random> otherwise
26
+ * Dolt bootstrap: redirect worktree to main's canonical beads db
27
+ */
28
+ export async function launchWorktreeSession(opts: WorktreeSessionOptions): Promise<void> {
29
+ const { runtime, name } = opts;
30
+ const cwd = process.cwd();
31
+ const repoRoot = await findRepoRoot();
32
+ const cwdBasename = path.basename(cwd);
33
+
34
+ // Resolve worktree path (sibling to cwd)
35
+ const date = shortDate();
36
+ const worktreeName = `${cwdBasename}-xt-${runtime}-${date}`;
37
+ const worktreePath = path.join(path.dirname(cwd), worktreeName);
38
+
39
+ // Resolve branch name
40
+ const branchName = `xt/${name ?? randomSlug(4)}`;
41
+
42
+ console.log(kleur.bold(`\n Launching ${runtime} session`));
43
+ console.log(kleur.dim(` worktree: ${worktreePath}`));
44
+ console.log(kleur.dim(` branch: ${branchName}\n`));
45
+
46
+ // Create worktree (create branch if it doesn't exist)
47
+ const branchExists = spawnSync('git', ['rev-parse', '--verify', branchName], {
48
+ cwd: repoRoot, stdio: 'pipe',
49
+ }).status === 0;
50
+
51
+ const worktreeArgs = branchExists
52
+ ? ['worktree', 'add', worktreePath, branchName]
53
+ : ['worktree', 'add', '-b', branchName, worktreePath];
54
+
55
+ const worktreeResult = spawnSync('git', worktreeArgs, { cwd: repoRoot, stdio: 'inherit' });
56
+ if (worktreeResult.status !== 0) {
57
+ console.error(kleur.red(`\n ✗ Failed to create worktree at ${worktreePath}\n`));
58
+ process.exit(1);
59
+ }
60
+
61
+ // Dolt bootstrap: redirect worktree to main's canonical beads db
62
+ const mainBeadsDir = path.join(repoRoot, '.beads');
63
+ const worktreeBeadsDir = path.join(worktreePath, '.beads');
64
+ const mainPortFile = path.join(mainBeadsDir, 'dolt-server.port');
65
+
66
+ if (await fs.pathExists(mainBeadsDir)) {
67
+ const worktreePortFile = path.join(worktreeBeadsDir, 'dolt-server.port');
68
+
69
+ // Stop the auto-spawned isolated dolt server in the worktree
70
+ spawnSync('bd', ['dolt', 'stop'], { cwd: worktreePath, stdio: 'pipe' });
71
+
72
+ // Read main checkout's port and write to worktree
73
+ if (await fs.pathExists(mainPortFile)) {
74
+ const mainPort = (await fs.readFile(mainPortFile, 'utf8')).trim();
75
+ await fs.ensureDir(worktreeBeadsDir);
76
+ await fs.writeFile(worktreePortFile, mainPort, 'utf8');
77
+ console.log(kleur.dim(` beads: redirected to main server (port ${mainPort})`));
78
+ } else {
79
+ console.log(kleur.dim(' beads: no port file found in main checkout, skipping redirect'));
80
+ }
81
+ }
82
+
83
+ console.log(kleur.green(`\n ✓ Worktree ready — launching ${runtime}...\n`));
84
+
85
+ // Launch the runtime in the worktree
86
+ const runtimeCmd = runtime === 'claude' ? 'claude' : 'pi';
87
+ const launchResult = spawnSync(runtimeCmd, [], {
88
+ cwd: worktreePath,
89
+ stdio: 'inherit',
90
+ });
91
+
92
+ process.exit(launchResult.status ?? 0);
93
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { deepMergeWithProtection } from '../src/utils/atomic-config.js';
3
+
4
+ const hooksDir = '/home/user/.claude/hooks';
5
+ const cmd = (script: string) => `node "${hooksDir}/${script}"`;
6
+
7
+ function wrap(matcher: string, script: string, timeout = 5000) {
8
+ return { matcher, hooks: [{ type: 'command', command: cmd(script), timeout }] };
9
+ }
10
+
11
+ function wrapNoMatcher(script: string) {
12
+ return { hooks: [{ type: 'command', command: cmd(script) }] };
13
+ }
14
+
15
+ const canonicalHooks = {
16
+ SessionStart: [wrapNoMatcher('beads-compact-restore.mjs'), wrapNoMatcher('serena-workflow-reminder.py')],
17
+ UserPromptSubmit: [{ ...wrapNoMatcher('branch-state.mjs'), timeout: 3000 }],
18
+ PreToolUse: [
19
+ wrap('Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol', 'main-guard.mjs'),
20
+ wrap('Bash', 'main-guard.mjs'),
21
+ ],
22
+ PostToolUse: [
23
+ wrap('Bash|mcp__serena__find_symbol|mcp__serena__get_symbols_overview', 'gitnexus/gitnexus-hook.cjs', 10000),
24
+ ],
25
+ };
26
+
27
+ describe('deepMergeWithProtection — pruneHooks mode', () => {
28
+ it('replaces stale PreToolUse matcher with canonical one', () => {
29
+ const existing = {
30
+ hooks: {
31
+ PreToolUse: [
32
+ wrap('Read|Grep|Glob|Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol', 'main-guard.mjs'),
33
+ wrap('Bash', 'main-guard.mjs'),
34
+ ],
35
+ },
36
+ };
37
+
38
+ const result = deepMergeWithProtection(existing, { hooks: canonicalHooks }, '', { pruneHooks: true });
39
+ const preToolUse = result.hooks.PreToolUse;
40
+ expect(preToolUse).toHaveLength(2);
41
+ expect(preToolUse[0].matcher).toBe('Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol');
42
+ expect(preToolUse[1].matcher).toBe('Bash');
43
+ });
44
+
45
+ it('removes script wired to wrong event (serena-workflow-reminder.py in PreToolUse)', () => {
46
+ const existing = {
47
+ hooks: {
48
+ SessionStart: [wrapNoMatcher('serena-workflow-reminder.py')],
49
+ PreToolUse: [
50
+ wrap('Read|Edit', 'serena-workflow-reminder.py'),
51
+ wrap('Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol', 'main-guard.mjs'),
52
+ wrap('Bash', 'main-guard.mjs'),
53
+ ],
54
+ },
55
+ };
56
+
57
+ const result = deepMergeWithProtection(existing, { hooks: canonicalHooks }, '', { pruneHooks: true });
58
+ const preToolUse = result.hooks.PreToolUse;
59
+ const hasStale = preToolUse.some((w: any) =>
60
+ w.hooks?.some((h: any) => h.command?.includes('serena-workflow-reminder.py'))
61
+ );
62
+ expect(hasStale).toBe(false);
63
+ expect(preToolUse).toHaveLength(2);
64
+ });
65
+
66
+ it('preserves user-local hooks (not from ~/.claude/hooks/) in canonical events', () => {
67
+ const userLocalHook = { hooks: [{ type: 'command', command: '/usr/local/bin/my-custom-hook.sh' }] };
68
+ const existing = {
69
+ hooks: {
70
+ PreToolUse: [
71
+ wrap('Bash', 'main-guard.mjs'),
72
+ userLocalHook,
73
+ ],
74
+ },
75
+ };
76
+
77
+ const result = deepMergeWithProtection(existing, { hooks: canonicalHooks }, '', { pruneHooks: true });
78
+ const preToolUse = result.hooks.PreToolUse;
79
+ const hasUserLocal = preToolUse.some((w: any) =>
80
+ w.hooks?.some((h: any) => h.command === '/usr/local/bin/my-custom-hook.sh')
81
+ );
82
+ expect(hasUserLocal).toBe(true);
83
+ });
84
+
85
+ it('normal merge (no pruneHooks) preserves stale matcher tokens', () => {
86
+ const existing = {
87
+ hooks: {
88
+ PreToolUse: [
89
+ wrap('Read|Grep|Glob|Write|Edit|Bash', 'main-guard.mjs'),
90
+ ],
91
+ },
92
+ };
93
+
94
+ const result = deepMergeWithProtection(existing, { hooks: canonicalHooks }, '', { pruneHooks: false });
95
+ const preToolUse = result.hooks.PreToolUse;
96
+ const matcher = preToolUse.find((w: any) =>
97
+ w.hooks?.some((h: any) => h.command?.includes('main-guard.mjs'))
98
+ )?.matcher ?? '';
99
+ expect(matcher).toContain('Read');
100
+ });
101
+ });