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,959 @@
1
+ import { Command } from 'commander';
2
+ import kleur from 'kleur';
3
+ import path from 'path';
4
+ import fs from 'fs-extra';
5
+ import { spawnSync } from 'child_process';
6
+ import { installGitHooks as installServiceGitHooks } from './install-service-skills.js';
7
+
8
+ declare const __dirname: string;
9
+ function resolvePkgRoot(): string {
10
+ const candidates = [
11
+ path.resolve(__dirname, '../..'),
12
+ path.resolve(__dirname, '../../..'),
13
+ ];
14
+
15
+ const match = candidates.find(candidate => fs.existsSync(path.join(candidate, 'project-skills')));
16
+ if (!match) {
17
+ throw new Error('Unable to locate project-skills directory from CLI runtime.');
18
+ }
19
+ return match;
20
+ }
21
+
22
+ const PKG_ROOT = resolvePkgRoot();
23
+ const PROJECT_SKILLS_DIR = path.join(PKG_ROOT, 'project-skills');
24
+ const MCP_CORE_CONFIG_PATH = path.join(PKG_ROOT, 'config', 'mcp_servers.json');
25
+ const INSTRUCTIONS_DIR = path.join(PKG_ROOT, 'config', 'instructions');
26
+ const XTRM_BLOCK_START = '<!-- xtrm:start -->';
27
+ const XTRM_BLOCK_END = '<!-- xtrm:end -->';
28
+ const syncedProjectMcpRoots = new Set<string>();
29
+
30
+ interface ProjectDetectionResult {
31
+ hasTypeScript: boolean;
32
+ hasPython: boolean;
33
+ dockerServices: string[];
34
+ generatedRegistry: boolean;
35
+ registryPath?: string;
36
+ }
37
+
38
+ function toServiceId(name: string): string {
39
+ return name
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9]+/g, '-')
42
+ .replace(/^-+|-+$/g, '') || 'service';
43
+ }
44
+
45
+ function parseComposeServices(content: string): string[] {
46
+ const lines = content.split('\n');
47
+ const services = new Set<string>();
48
+
49
+ let inServices = false;
50
+ for (const line of lines) {
51
+ const raw = line.replace(/\t/g, ' ');
52
+
53
+ if (!inServices) {
54
+ if (/^services:\s*$/.test(raw)) {
55
+ inServices = true;
56
+ }
57
+ continue;
58
+ }
59
+
60
+ if (/^[^\s#].*:\s*$/.test(raw) && !/^services:\s*$/.test(raw)) {
61
+ break;
62
+ }
63
+
64
+ const serviceMatch = raw.match(/^\s{2}([A-Za-z0-9._-]+):\s*(?:#.*)?$/);
65
+ if (serviceMatch) {
66
+ services.add(serviceMatch[1]);
67
+ }
68
+ }
69
+
70
+ return [...services];
71
+ }
72
+
73
+ export async function detectProjectFeatures(projectRoot: string): Promise<ProjectDetectionResult> {
74
+ const hasTypeScript = await fs.pathExists(path.join(projectRoot, 'tsconfig.json'));
75
+
76
+ const hasPython =
77
+ await fs.pathExists(path.join(projectRoot, 'pyproject.toml')) ||
78
+ await fs.pathExists(path.join(projectRoot, 'setup.py')) ||
79
+ await fs.pathExists(path.join(projectRoot, 'requirements.txt'));
80
+
81
+ const composeCandidates = [
82
+ 'docker-compose.yml',
83
+ 'docker-compose.yaml',
84
+ 'compose.yml',
85
+ 'compose.yaml',
86
+ ];
87
+
88
+ const dockerServices = new Set<string>();
89
+ for (const composeFile of composeCandidates) {
90
+ const composePath = path.join(projectRoot, composeFile);
91
+ if (!await fs.pathExists(composePath)) continue;
92
+
93
+ try {
94
+ const content = await fs.readFile(composePath, 'utf8');
95
+ for (const service of parseComposeServices(content)) {
96
+ dockerServices.add(service);
97
+ }
98
+ } catch {
99
+ // Ignore malformed compose file and continue
100
+ }
101
+ }
102
+
103
+ const hasDockerfile = await fs.pathExists(path.join(projectRoot, 'Dockerfile'));
104
+ if (hasDockerfile && dockerServices.size === 0) {
105
+ dockerServices.add(path.basename(projectRoot));
106
+ }
107
+
108
+ return {
109
+ hasTypeScript,
110
+ hasPython,
111
+ dockerServices: [...dockerServices],
112
+ generatedRegistry: false,
113
+ };
114
+ }
115
+
116
+ export async function ensureServiceRegistry(projectRoot: string, services: string[]): Promise<{ generated: boolean; registryPath: string }> {
117
+ const registryPath = path.join(projectRoot, 'service-registry.json');
118
+ if (services.length === 0) {
119
+ return { generated: false, registryPath };
120
+ }
121
+
122
+ const existedBefore = await fs.pathExists(registryPath);
123
+ const now = new Date().toISOString();
124
+ let registry: any = { version: '1.0.0', services: {} };
125
+
126
+ if (existedBefore) {
127
+ try {
128
+ registry = await fs.readJson(registryPath);
129
+ if (!registry.services || typeof registry.services !== 'object') {
130
+ registry.services = {};
131
+ }
132
+ } catch {
133
+ registry = { version: '1.0.0', services: {} };
134
+ }
135
+ }
136
+
137
+ let changed = false;
138
+ for (const serviceName of services) {
139
+ const serviceId = toServiceId(serviceName);
140
+ if (registry.services[serviceId]) continue;
141
+
142
+ registry.services[serviceId] = {
143
+ name: serviceName,
144
+ description: `Detected from Docker configuration (${serviceName}).`,
145
+ territory: [],
146
+ skill_path: `.claude/skills/${serviceId}/SKILL.md`,
147
+ last_sync: now,
148
+ };
149
+ changed = true;
150
+ }
151
+
152
+ if (changed || !existedBefore) {
153
+ await fs.writeJson(registryPath, registry, { spaces: 2 });
154
+ }
155
+
156
+ return { generated: changed || !existedBefore, registryPath };
157
+ }
158
+
159
+ function resolveEnvVars(value: string): string {
160
+ if (typeof value !== 'string') return value;
161
+ return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_m, name) => process.env[name] || '');
162
+ }
163
+
164
+ function hasClaudeCli(): boolean {
165
+ const r = spawnSync('claude', ['--version'], { stdio: 'pipe' });
166
+ return r.status === 0;
167
+ }
168
+
169
+ function buildProjectMcpArgs(name: string, server: any): string[] | null {
170
+ const transport = server.type || (server.url?.includes('/sse') ? 'sse' : 'http');
171
+
172
+ if (server.command) {
173
+ const args = ['mcp', 'add', '-s', 'project'];
174
+ if (server.env && typeof server.env === 'object') {
175
+ for (const [k, v] of Object.entries(server.env)) {
176
+ args.push('-e', `${k}=${resolveEnvVars(String(v))}`);
177
+ }
178
+ }
179
+ args.push(name, '--', server.command, ...((server.args || []) as string[]));
180
+ return args;
181
+ }
182
+
183
+ if (server.url || server.serverUrl) {
184
+ const url = server.url || server.serverUrl;
185
+ const args = ['mcp', 'add', '-s', 'project', '--transport', transport, name, url];
186
+ if (server.headers && typeof server.headers === 'object') {
187
+ for (const [k, v] of Object.entries(server.headers)) {
188
+ args.push('--header', `${k}: ${resolveEnvVars(String(v))}`);
189
+ }
190
+ }
191
+ return args;
192
+ }
193
+
194
+ return null;
195
+ }
196
+
197
+ async function syncProjectMcpServers(projectRoot: string): Promise<void> {
198
+ if (syncedProjectMcpRoots.has(projectRoot)) return;
199
+ syncedProjectMcpRoots.add(projectRoot);
200
+
201
+ if (!await fs.pathExists(MCP_CORE_CONFIG_PATH)) return;
202
+
203
+ console.log(kleur.bold('\n── Installing MCP (project scope) ─────────'));
204
+
205
+ if (!hasClaudeCli()) {
206
+ console.log(kleur.yellow(' ⚠ Claude CLI not found; skipping project-scope MCP registration.'));
207
+ return;
208
+ }
209
+
210
+ const mcpConfig = await fs.readJson(MCP_CORE_CONFIG_PATH);
211
+ const servers = Object.entries(mcpConfig?.mcpServers ?? {}) as Array<[string, any]>;
212
+ if (servers.length === 0) {
213
+ console.log(kleur.dim(' ℹ No core MCP servers configured.'));
214
+ return;
215
+ }
216
+
217
+ let added = 0;
218
+ let existing = 0;
219
+ let failed = 0;
220
+
221
+ for (const [name, server] of servers) {
222
+ const args = buildProjectMcpArgs(name, server);
223
+ if (!args) continue;
224
+
225
+ const r = spawnSync('claude', args, {
226
+ cwd: projectRoot,
227
+ encoding: 'utf8',
228
+ stdio: ['pipe', 'pipe', 'pipe'],
229
+ });
230
+
231
+ if (r.status === 0) {
232
+ added++;
233
+ console.log(`${kleur.green(' ✓')} ${name}`);
234
+ continue;
235
+ }
236
+
237
+ const stderr = `${r.stderr || ''}`.toLowerCase();
238
+ if (stderr.includes('already exists') || stderr.includes('already configured')) {
239
+ existing++;
240
+ console.log(kleur.dim(` ✓ ${name} (already configured)`));
241
+ continue;
242
+ }
243
+
244
+ failed++;
245
+ console.log(kleur.red(` ✗ ${name} (${(r.stderr || r.stdout || 'failed').toString().trim()})`));
246
+ }
247
+
248
+ console.log(kleur.dim(` ↳ MCP project-scope result: ${added} added, ${existing} existing, ${failed} failed`));
249
+ }
250
+
251
+ export function upsertManagedBlock(
252
+ fileContent: string,
253
+ blockBody: string,
254
+ startMarker: string = XTRM_BLOCK_START,
255
+ endMarker: string = XTRM_BLOCK_END,
256
+ ): string {
257
+ const normalizedBody = blockBody.trim();
258
+ const managedBlock = `${startMarker}\n${normalizedBody}\n${endMarker}`;
259
+ const escapedStart = startMarker.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
260
+ const escapedEnd = endMarker.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
261
+ const existingBlockPattern = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, 'm');
262
+
263
+ if (existingBlockPattern.test(fileContent)) {
264
+ return fileContent.replace(existingBlockPattern, managedBlock);
265
+ }
266
+
267
+ const trimmed = fileContent.trimStart();
268
+ if (!trimmed) return `${managedBlock}\n`;
269
+ return `${managedBlock}\n\n${trimmed}`;
270
+ }
271
+
272
+ export async function injectProjectInstructionHeaders(projectRoot: string): Promise<void> {
273
+ const targets = [
274
+ { output: 'AGENTS.md', template: 'agents-top.md' },
275
+ { output: 'CLAUDE.md', template: 'claude-top.md' },
276
+ ];
277
+
278
+ console.log(kleur.bold('Injecting xtrm agent instruction headers...'));
279
+
280
+ for (const target of targets) {
281
+ const templatePath = path.join(INSTRUCTIONS_DIR, target.template);
282
+ if (!await fs.pathExists(templatePath)) {
283
+ console.log(kleur.yellow(` ⚠ Missing template: ${target.template}`));
284
+ continue;
285
+ }
286
+
287
+ const template = await fs.readFile(templatePath, 'utf8');
288
+ const outputPath = path.join(projectRoot, target.output);
289
+ const existing = await fs.pathExists(outputPath) ? await fs.readFile(outputPath, 'utf8') : '';
290
+ const next = upsertManagedBlock(existing, template);
291
+
292
+ if (next === existing) {
293
+ console.log(kleur.dim(` ✓ ${target.output} already up to date`));
294
+ continue;
295
+ }
296
+
297
+ await fs.writeFile(outputPath, next.endsWith('\n') ? next : `${next}\n`, 'utf8');
298
+ console.log(`${kleur.green(' ✓')} updated ${target.output}`);
299
+ }
300
+ }
301
+
302
+ export async function getAvailableProjectSkills(): Promise<string[]> {
303
+ if (!await fs.pathExists(PROJECT_SKILLS_DIR)) {
304
+ return [];
305
+ }
306
+
307
+ const entries = await fs.readdir(PROJECT_SKILLS_DIR);
308
+ const skills: string[] = [];
309
+
310
+ for (const entry of entries) {
311
+ const entryPath = path.join(PROJECT_SKILLS_DIR, entry);
312
+ const stat = await fs.stat(entryPath);
313
+ if (stat.isDirectory() && await fs.pathExists(path.join(entryPath, '.claude'))) {
314
+ skills.push(entry);
315
+ }
316
+ }
317
+
318
+ return skills.sort();
319
+ }
320
+
321
+ /**
322
+ * Deep merge settings.json hooks without overwriting existing user hooks.
323
+ * Appends new hooks to existing events intelligently.
324
+ */
325
+ /**
326
+ * Extract script filename from a hook command.
327
+ */
328
+ function getScriptFilename(hook: any): string | null {
329
+ const cmd = hook.command || hook.hooks?.[0]?.command || '';
330
+ if (typeof cmd !== 'string') return null;
331
+ // Match script filename including subdirectory (e.g., "gitnexus/gitnexus-hook.cjs")
332
+ const m = cmd.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
333
+ if (m) return m[1];
334
+ const m2 = cmd.match(/([A-Za-z0-9_-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
335
+ return m2?.[1] ?? null;
336
+ }
337
+
338
+ /**
339
+ * Prune hooks from settings.json that are NOT in the canonical config.
340
+ * This removes stale entries from old versions before merging new ones.
341
+ *
342
+ * @param existing Current settings.json hooks
343
+ * @param canonical Canonical hooks config from hooks.json
344
+ * @returns Pruned settings with stale hooks removed
345
+ */
346
+ export function pruneStaleHooks(
347
+ existing: Record<string, any>,
348
+ canonical: Record<string, any>,
349
+ ): { result: Record<string, any>; removed: string[] } {
350
+ const result = { ...existing };
351
+ const removed: string[] = [];
352
+
353
+ if (!result.hooks || typeof result.hooks !== 'object') {
354
+ return { result, removed };
355
+ }
356
+ if (!canonical.hooks || typeof canonical.hooks !== 'object') {
357
+ return { result, removed };
358
+ }
359
+
360
+ // Collect canonical script paths + basenames for this skill only.
361
+ // We only prune hooks that look like stale variants of this skill's own scripts.
362
+ const canonicalScripts = new Set<string>();
363
+ const canonicalBasenames = new Set<string>();
364
+ for (const hooks of Object.values(canonical.hooks)) {
365
+ const hookList = Array.isArray(hooks) ? hooks : [hooks];
366
+ for (const wrapper of hookList) {
367
+ const innerHooks = wrapper.hooks || [wrapper];
368
+ for (const hook of innerHooks) {
369
+ const script = getScriptFilename(hook);
370
+ if (!script) continue;
371
+ canonicalScripts.add(script);
372
+ canonicalBasenames.add(path.basename(script));
373
+ }
374
+ }
375
+ }
376
+
377
+ for (const [event, hooks] of Object.entries(result.hooks)) {
378
+ if (!Array.isArray(hooks)) continue;
379
+
380
+ const prunedWrappers: any[] = [];
381
+ for (const wrapper of hooks) {
382
+ const innerHooks = wrapper.hooks || [wrapper];
383
+ const keptInner: any[] = [];
384
+
385
+ for (const hook of innerHooks) {
386
+ const script = getScriptFilename(hook);
387
+ if (!script) {
388
+ keptInner.push(hook);
389
+ continue;
390
+ }
391
+
392
+ if (canonicalScripts.has(script)) {
393
+ keptInner.push(hook);
394
+ continue;
395
+ }
396
+
397
+ const sameSkillFamily = canonicalBasenames.has(path.basename(script));
398
+ if (sameSkillFamily) {
399
+ removed.push(`${event}:${script}`);
400
+ continue;
401
+ }
402
+
403
+ // Foreign/non-related hook — preserve it.
404
+ keptInner.push(hook);
405
+ }
406
+
407
+ if (keptInner.length > 0) {
408
+ if (wrapper.hooks) {
409
+ prunedWrappers.push({ ...wrapper, hooks: keptInner });
410
+ } else if (keptInner.length === 1) {
411
+ prunedWrappers.push(keptInner[0]);
412
+ } else {
413
+ prunedWrappers.push({ ...wrapper, hooks: keptInner });
414
+ }
415
+ }
416
+ }
417
+
418
+ if (prunedWrappers.length > 0) {
419
+ result.hooks[event] = prunedWrappers;
420
+ } else {
421
+ delete result.hooks[event];
422
+ }
423
+ }
424
+
425
+ return { result, removed };
426
+ }
427
+
428
+ export function deepMergeHooks(existing: Record<string, any>, incoming: Record<string, any>): Record<string, any> {
429
+ const result = { ...existing };
430
+
431
+ if (!result.hooks) result.hooks = {};
432
+ if (!incoming.hooks) return result;
433
+
434
+ for (const [event, incomingHooks] of Object.entries(incoming.hooks)) {
435
+ if (!result.hooks[event]) {
436
+ // Event doesn't exist — add it
437
+ result.hooks[event] = incomingHooks;
438
+ } else {
439
+ // Event exists — merge hooks intelligently
440
+ const existingEventHooks = Array.isArray(result.hooks[event]) ? result.hooks[event] : [result.hooks[event]];
441
+ const incomingEventHooks = Array.isArray(incomingHooks) ? incomingHooks : [incomingHooks];
442
+
443
+ const getCommand = (h: any) => h.command || h.hooks?.[0]?.command;
444
+ const getCommandKey = (cmd?: string): string | null => {
445
+ if (!cmd || typeof cmd !== 'string') return null;
446
+ const m = cmd.match(/([A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
447
+ return m?.[1] ?? null;
448
+ };
449
+ const mergeMatcher = (existingMatcher: string, incomingMatcher: string): string => {
450
+ const existingParts = existingMatcher.split('|').map((s: string) => s.trim()).filter(Boolean);
451
+ const incomingParts = incomingMatcher.split('|').map((s: string) => s.trim()).filter(Boolean);
452
+ const merged = [...existingParts];
453
+ for (const part of incomingParts) {
454
+ if (!merged.includes(part)) merged.push(part);
455
+ }
456
+ return merged.join('|');
457
+ };
458
+
459
+ const mergedEventHooks = [...existingEventHooks];
460
+ for (const incomingHook of incomingEventHooks) {
461
+ const incomingCmd = getCommand(incomingHook);
462
+ if (!incomingCmd) {
463
+ mergedEventHooks.push(incomingHook);
464
+ continue;
465
+ }
466
+
467
+ const incomingKey = getCommandKey(incomingCmd);
468
+ const existingIndex = mergedEventHooks.findIndex((h: any) => {
469
+ const existingCmd = getCommand(h);
470
+ if (existingCmd === incomingCmd) return true;
471
+ if (!incomingKey) return false;
472
+ return getCommandKey(existingCmd) === incomingKey;
473
+ });
474
+ if (existingIndex === -1) {
475
+ mergedEventHooks.push(incomingHook);
476
+ continue;
477
+ }
478
+
479
+ const existingHook = mergedEventHooks[existingIndex];
480
+ if (typeof existingHook.matcher === 'string' && typeof incomingHook.matcher === 'string') {
481
+ existingHook.matcher = mergeMatcher(existingHook.matcher, incomingHook.matcher);
482
+ }
483
+ }
484
+
485
+ result.hooks[event] = mergedEventHooks;
486
+ }
487
+ }
488
+
489
+ return result;
490
+ }
491
+
492
+ export function extractReadmeDescription(readmeContent: string): string {
493
+ const lines = readmeContent.split('\n');
494
+ const headingIndex = lines.findIndex(line => line.trim().startsWith('# '));
495
+ const searchStart = headingIndex >= 0 ? headingIndex + 1 : 0;
496
+
497
+ for (const rawLine of lines.slice(searchStart)) {
498
+ const line = rawLine.trim();
499
+ if (!line || line.startsWith('#') || line.startsWith('[![') || line.startsWith('<')) {
500
+ continue;
501
+ }
502
+
503
+ return line
504
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
505
+ .replace(/[*_`]/g, '')
506
+ .trim();
507
+ }
508
+
509
+ return 'No description available';
510
+ }
511
+
512
+ /**
513
+ * Install a project skill package into the current project.
514
+ */
515
+ export async function installProjectSkill(toolName: string, projectRootOverride?: string): Promise<void> {
516
+ const skillPath = path.join(PROJECT_SKILLS_DIR, toolName);
517
+
518
+ // Validation: Check if project skill exists
519
+ if (!await fs.pathExists(skillPath)) {
520
+ console.error(kleur.red(`\n✗ Project skill '${toolName}' not found.\n`));
521
+ console.error(kleur.dim(` Available project skills:\n`));
522
+ await listProjectSkills();
523
+ process.exit(1);
524
+ }
525
+
526
+ // Get target project root
527
+ const projectRoot = projectRootOverride ?? getProjectRoot();
528
+ const claudeDir = path.join(projectRoot, '.claude');
529
+
530
+ console.log(kleur.dim(`\n Installing project skill: ${kleur.cyan(toolName)}`));
531
+ console.log(kleur.dim(` Target: ${projectRoot}\n`));
532
+
533
+ const skillClaudeDir = path.join(skillPath, '.claude');
534
+ const skillSettingsPath = path.join(skillClaudeDir, 'settings.json');
535
+ const skillSkillsDir = path.join(skillClaudeDir, 'skills');
536
+ const skillReadmePath = path.join(skillPath, 'README.md');
537
+
538
+ // Step 1: Hook Injection (deep merge settings.json)
539
+ if (await fs.pathExists(skillSettingsPath)) {
540
+ console.log(kleur.bold('── Installing Hooks ──────────────────────'));
541
+ const targetSettingsPath = path.join(claudeDir, 'settings.json');
542
+
543
+ await fs.mkdirp(path.dirname(targetSettingsPath));
544
+
545
+ let existingSettings: Record<string, any> = {};
546
+ if (await fs.pathExists(targetSettingsPath)) {
547
+ try {
548
+ existingSettings = JSON.parse(await fs.readFile(targetSettingsPath, 'utf8'));
549
+ } catch {
550
+ // malformed JSON — start fresh
551
+ }
552
+ }
553
+
554
+ const incomingSettings = JSON.parse(await fs.readFile(skillSettingsPath, 'utf8'));
555
+
556
+ // First prune stale hooks not in canonical config
557
+ const { result: prunedSettings, removed } = pruneStaleHooks(existingSettings, incomingSettings);
558
+ if (removed.length > 0) {
559
+ console.log(kleur.yellow(` ↳ Pruned ${removed.length} stale hook(s): ${removed.join(', ')}`));
560
+ }
561
+
562
+ // Then merge canonical hooks
563
+ const mergedSettings = deepMergeHooks(prunedSettings, incomingSettings);
564
+
565
+ await fs.writeFile(targetSettingsPath, JSON.stringify(mergedSettings, null, 2) + '\n');
566
+ console.log(`${kleur.green(' ✓')} settings.json (hooks merged)`);
567
+ }
568
+
569
+ await syncProjectMcpServers(projectRoot);
570
+
571
+ // Step 2: Skill Copy
572
+ if (await fs.pathExists(skillSkillsDir)) {
573
+ console.log(kleur.bold('\n── Installing Skills ─────────────────────'));
574
+ const targetSkillsDir = path.join(claudeDir, 'skills');
575
+
576
+ const skillEntries = await fs.readdir(skillSkillsDir);
577
+ for (const entry of skillEntries) {
578
+ const src = path.join(skillSkillsDir, entry);
579
+ const dest = path.join(targetSkillsDir, entry);
580
+ await fs.copy(src, dest, {
581
+ filter: (src: string) => !src.includes('.Zone.Identifier')
582
+ && !src.includes('__pycache__')
583
+ && !src.includes('.pytest_cache')
584
+ && !src.endsWith('.pyc'),
585
+ });
586
+ console.log(`${kleur.green(' ✓')} .claude/skills/${entry}/`);
587
+ }
588
+ }
589
+
590
+ // Step 2b: Copy additional Claude assets (hooks, docs, etc.) shipped with the skill
591
+ if (await fs.pathExists(skillClaudeDir)) {
592
+ const claudeEntries = await fs.readdir(skillClaudeDir);
593
+
594
+ for (const entry of claudeEntries) {
595
+ if (entry === 'settings.json' || entry === 'skills') {
596
+ continue;
597
+ }
598
+
599
+ const src = path.join(skillClaudeDir, entry);
600
+ const dest = path.join(claudeDir, entry);
601
+ await fs.copy(src, dest, {
602
+ filter: (src: string) => !src.includes('.Zone.Identifier')
603
+ && !src.includes('__pycache__')
604
+ && !src.includes('.pytest_cache')
605
+ && !src.endsWith('.pyc'),
606
+ });
607
+ console.log(`${kleur.green(' ✓')} .claude/${entry}/`);
608
+ }
609
+ }
610
+
611
+ // Step 2c: Symlink .agents/skills → ../.claude/skills for Pi compatibility
612
+ // .claude/skills is the SSOT; Pi reads .agents/skills natively from cwd.
613
+ const claudeSkillsDir = path.join(claudeDir, 'skills');
614
+ if (await fs.pathExists(claudeSkillsDir)) {
615
+ const agentsDir = path.join(projectRoot, '.agents');
616
+ const agentsSkillsLink = path.join(agentsDir, 'skills');
617
+ const symlinkTarget = path.join('..', '.claude', 'skills');
618
+
619
+ let needsSymlink = true;
620
+ if (await fs.pathExists(agentsSkillsLink)) {
621
+ try {
622
+ const stat = await fs.lstat(agentsSkillsLink);
623
+ if (stat.isSymbolicLink()) {
624
+ const current = await fs.readlink(agentsSkillsLink);
625
+ if (current === symlinkTarget) {
626
+ needsSymlink = false;
627
+ } else {
628
+ await fs.remove(agentsSkillsLink); // stale symlink — recreate
629
+ }
630
+ } else {
631
+ console.log(kleur.yellow(' ⚠ .agents/skills/ is a real directory — skipping Pi symlink'));
632
+ needsSymlink = false;
633
+ }
634
+ } catch {
635
+ needsSymlink = true;
636
+ }
637
+ }
638
+
639
+ if (needsSymlink) {
640
+ await fs.mkdirp(agentsDir);
641
+ await fs.symlink(symlinkTarget, agentsSkillsLink);
642
+ console.log(`${kleur.green(' ✓')} .agents/skills → ../.claude/skills`);
643
+ } else {
644
+ console.log(kleur.dim(' ✓ .agents/skills symlink already in place'));
645
+ }
646
+ }
647
+
648
+ // Step 3: Documentation Copy
649
+ if (await fs.pathExists(skillReadmePath)) {
650
+ console.log(kleur.bold('\n── Installing Documentation ──────────────'));
651
+ const docsDir = path.join(claudeDir, 'docs');
652
+ await fs.mkdirp(docsDir);
653
+
654
+ const destReadme = path.join(docsDir, `${toolName}-readme.md`);
655
+ await fs.copy(skillReadmePath, destReadme);
656
+ console.log(`${kleur.green(' ✓')} .claude/docs/${toolName}-readme.md`);
657
+ }
658
+
659
+ // Step 4: Post-Install Guidance
660
+ if (toolName === 'service-skills-set') {
661
+ console.log(kleur.bold('\n── Installing Git Hooks ─────────────────'));
662
+ await installServiceGitHooks(projectRoot, skillClaudeDir);
663
+ console.log(`${kleur.green(' ✓')} .githooks/pre-commit`);
664
+ console.log(`${kleur.green(' ✓')} .githooks/pre-push`);
665
+ console.log(`${kleur.green(' ✓')} activated in .git/hooks/`);
666
+ }
667
+
668
+ // Step 5: Post-Install Guidance
669
+ console.log(kleur.bold('\n── Post-Install Steps ────────────────────'));
670
+ console.log(kleur.yellow('\n ⚠ IMPORTANT: Manual setup required!\n'));
671
+ console.log(kleur.white(` ${toolName} requires additional configuration.`));
672
+ console.log(kleur.white(` Please read: ${kleur.cyan('.claude/docs/' + toolName + '-readme.md')}\n`));
673
+
674
+ if (toolName === 'tdd-guard') {
675
+ const tddGuardCheck = spawnSync('tdd-guard', ['--version'], { stdio: 'pipe' });
676
+ if (tddGuardCheck.status !== 0) {
677
+ console.log(kleur.red(' ✗ tdd-guard CLI not found globally!\n'));
678
+ console.log(kleur.white(' Install the global CLI:'));
679
+ console.log(kleur.cyan(' npm install -g tdd-guard\n'));
680
+ } else {
681
+ console.log(kleur.green(' ✓ tdd-guard CLI found globally'));
682
+ }
683
+ console.log(kleur.white('\n Install a test reporter (choose one):'));
684
+ console.log(kleur.dim(' npm install --save-dev tdd-guard-vitest # Vitest'));
685
+ console.log(kleur.dim(' npm install --save-dev tdd-guard-jest # Jest'));
686
+ console.log(kleur.dim(' pip install tdd-guard-pytest # pytest\n'));
687
+ }
688
+
689
+ if (toolName === 'quality-gates') {
690
+ console.log(kleur.white(' Install language dependencies:\n'));
691
+ console.log(kleur.white(' TypeScript:'));
692
+ console.log(kleur.dim(' npm install --save-dev typescript eslint prettier'));
693
+ console.log(kleur.white('\n Python:'));
694
+ console.log(kleur.dim(' pip install ruff mypy'));
695
+ console.log(kleur.white('\n For TDD (test-first) enforcement, install separately:'));
696
+ console.log(kleur.dim(' npm install -g tdd-guard'));
697
+ console.log(kleur.dim(' xtrm install project tdd-guard\n'));
698
+ }
699
+
700
+ console.log(kleur.green(' ✓ Installation complete!\n'));
701
+ }
702
+
703
+ export async function installAllProjectSkills(projectRootOverride?: string): Promise<void> {
704
+ const skills = await getAvailableProjectSkills();
705
+
706
+ if (skills.length === 0) {
707
+ console.log(kleur.dim(' No project skills available.\n'));
708
+ return;
709
+ }
710
+
711
+ const projectRoot = projectRootOverride ?? getProjectRoot();
712
+
713
+ console.log(kleur.bold(`\nInstalling ${skills.length} project skills:\n`));
714
+ for (const skill of skills) {
715
+ console.log(kleur.dim(` • ${skill}`));
716
+ }
717
+ console.log('');
718
+
719
+ for (const skill of skills) {
720
+ await installProjectSkill(skill, projectRoot);
721
+ }
722
+ }
723
+
724
+ export function buildProjectInitGuide(): string {
725
+ const lines = [
726
+ kleur.bold('\nProject Init — Global-first baseline\n'),
727
+ kleur.dim('xtrm init bootstraps project data (beads, GitNexus, service registry) while hooks/skills stay global.\n'),
728
+ `${kleur.cyan('1) Run initialization once per repository:')}`,
729
+ kleur.dim(' xtrm init (alias: xtrm project init)'),
730
+ kleur.dim(' - Initializes beads workspace (bd init)'),
731
+ kleur.dim(' - Refreshes GitNexus index if missing/stale'),
732
+ kleur.dim(' - Syncs project-scoped MCP entries'),
733
+ kleur.dim(' - Detects TS/Python/Docker project signals'),
734
+ kleur.dim(' - Scaffolds service-registry.json when Docker services are detected'),
735
+ '',
736
+ `${kleur.cyan('2) What is already global (no per-project install needed):')}`,
737
+ kleur.dim(' - quality gates hooks (formerly installed via quality-gates)'),
738
+ kleur.dim(' - service-skills routing and drift checks (formerly service-skills-set)'),
739
+ kleur.dim(' - main-guard + beads workflow gates'),
740
+ kleur.dim(' - optional TDD strategy guidance (legacy name: tdd-guard)'),
741
+ '',
742
+ `${kleur.cyan('3) Configure repo quality tools (hooks enforce what exists):')}`,
743
+ kleur.dim(' - TS: eslint + prettier + tsc'),
744
+ kleur.dim(' - PY: ruff + mypy/pyright'),
745
+ kleur.dim(' - tests: failing tests should block regressions'),
746
+ '',
747
+ `${kleur.cyan('4) Beads workflow (required for gated edit/commit flow):')}`,
748
+ kleur.dim(' - Claim work: bd ready --json -> bd update <id> --claim --json'),
749
+ kleur.dim(' - During work: keep issue status current; create discovered follow-ups'),
750
+ kleur.dim(' - Finish work: bd close <id> --reason "Done" --json'),
751
+ '',
752
+ `${kleur.cyan('5) Git workflow (main-guard expected path):')}`,
753
+ kleur.dim(' - git checkout -b feature/<name>'),
754
+ kleur.dim(' - commit on feature branch only'),
755
+ kleur.dim(' - git push -u origin feature/<name>'),
756
+ kleur.dim(' - gh pr create --fill && gh pr merge --squash'),
757
+ kleur.dim(' - git checkout main && git pull --ff-only'),
758
+ '',
759
+ ];
760
+
761
+ return lines.join('\n');
762
+ }
763
+
764
+ export async function runProjectInit(): Promise<void> {
765
+ console.log(buildProjectInitGuide());
766
+ await bootstrapProjectInit();
767
+ }
768
+
769
+
770
+ async function bootstrapProjectInit(): Promise<void> {
771
+ let projectRoot: string;
772
+ try {
773
+ projectRoot = getProjectRoot();
774
+ } catch (err: any) {
775
+ console.log(kleur.yellow(`\n ⚠ Skipping project bootstrap: ${err.message}\n`));
776
+ return;
777
+ }
778
+
779
+ const detected = await detectProjectFeatures(projectRoot);
780
+
781
+ await runBdInitForProject(projectRoot);
782
+ await injectProjectInstructionHeaders(projectRoot);
783
+ await runGitNexusInitForProject(projectRoot);
784
+ await syncProjectMcpServers(projectRoot);
785
+
786
+ if (detected.dockerServices.length > 0) {
787
+ const { generated, registryPath } = await ensureServiceRegistry(projectRoot, detected.dockerServices);
788
+ detected.generatedRegistry = generated;
789
+ detected.registryPath = registryPath;
790
+ if (generated) {
791
+ console.log(`${kleur.green(' ✓')} service registry scaffolded at ${path.relative(projectRoot, registryPath)}`);
792
+ } else {
793
+ console.log(kleur.dim(' ✓ service-registry.json already includes detected services'));
794
+ }
795
+ }
796
+
797
+ const projectTypes: string[] = [];
798
+ if (detected.hasTypeScript) projectTypes.push('TypeScript');
799
+ if (detected.hasPython) projectTypes.push('Python');
800
+ if (detected.dockerServices.length > 0) projectTypes.push('Docker');
801
+
802
+ console.log(kleur.bold('\nProject initialized.'));
803
+ console.log(kleur.white(` Quality gates active globally.`));
804
+ console.log(kleur.white(` Project types: ${projectTypes.length > 0 ? projectTypes.join(', ') : 'none detected'}.`));
805
+ console.log(kleur.white(` Services detected: ${detected.dockerServices.length > 0 ? detected.dockerServices.join(', ') : 'none'}.`));
806
+ if (detected.registryPath) {
807
+ console.log(kleur.dim(` Service registry: ${detected.registryPath}`));
808
+ }
809
+ console.log('');
810
+ }
811
+
812
+ async function runBdInitForProject(projectRoot: string): Promise<void> {
813
+
814
+ console.log(kleur.bold('Running beads initialization (bd init)...'));
815
+
816
+ const result = spawnSync('bd', ['init'], {
817
+ cwd: projectRoot,
818
+ encoding: 'utf8',
819
+ timeout: 15000,
820
+ });
821
+
822
+ if (result.error) {
823
+ console.log(kleur.yellow(` ⚠ Could not run bd init (${result.error.message})`));
824
+ return;
825
+ }
826
+
827
+ if (result.status !== 0) {
828
+ const text = `${result.stdout || ''}\n${result.stderr || ''}`.toLowerCase();
829
+ if (text.includes('already initialized')) {
830
+ console.log(kleur.dim(' ✓ beads workspace already initialized'));
831
+ return;
832
+ }
833
+ if (result.stdout) process.stdout.write(result.stdout);
834
+ if (result.stderr) process.stderr.write(result.stderr);
835
+ console.log(kleur.yellow(` ⚠ bd init exited with code ${result.status}`));
836
+ return;
837
+ }
838
+
839
+ if (result.stdout) process.stdout.write(result.stdout);
840
+ if (result.stderr) process.stderr.write(result.stderr);
841
+ }
842
+
843
+ async function runGitNexusInitForProject(projectRoot: string): Promise<void> {
844
+ const gitnexusCheck = spawnSync('gitnexus', ['--version'], {
845
+ cwd: projectRoot,
846
+ encoding: 'utf8',
847
+ timeout: 5000,
848
+ });
849
+
850
+ if (gitnexusCheck.status !== 0) {
851
+ console.log(kleur.yellow(' ⚠ gitnexus not found; skipping index bootstrap'));
852
+ console.log(kleur.dim(' Install with: npm install -g gitnexus'));
853
+ return;
854
+ }
855
+
856
+ console.log(kleur.bold('Checking GitNexus index status...'));
857
+
858
+ const status = spawnSync('gitnexus', ['status'], {
859
+ cwd: projectRoot,
860
+ encoding: 'utf8',
861
+ timeout: 10000,
862
+ });
863
+
864
+ const statusText = `${status.stdout || ''}\n${status.stderr || ''}`.toLowerCase();
865
+ const needsAnalyze = status.status !== 0 ||
866
+ statusText.includes('stale') ||
867
+ statusText.includes('not indexed') ||
868
+ statusText.includes('missing');
869
+
870
+ if (!needsAnalyze) {
871
+ console.log(kleur.dim(' ✓ GitNexus index is ready'));
872
+ return;
873
+ }
874
+
875
+ console.log(kleur.bold('Running GitNexus indexing (gitnexus analyze)...'));
876
+ const analyze = spawnSync('gitnexus', ['analyze'], {
877
+ cwd: projectRoot,
878
+ encoding: 'utf8',
879
+ timeout: 120000,
880
+ });
881
+
882
+ if (analyze.status === 0) {
883
+ console.log(kleur.green(' ✓ GitNexus index updated'));
884
+ return;
885
+ }
886
+
887
+ if (analyze.stdout) process.stdout.write(analyze.stdout);
888
+ if (analyze.stderr) process.stderr.write(analyze.stderr);
889
+ console.log(kleur.yellow(` ⚠ gitnexus analyze exited with code ${analyze.status}`));
890
+ }
891
+
892
+ /**
893
+ * List available project skills.
894
+ */
895
+ async function listProjectSkills(): Promise<void> {
896
+ printProjectInstallDeprecationWarning();
897
+
898
+ const entries = await getAvailableProjectSkills();
899
+ if (entries.length === 0) {
900
+ console.log(kleur.dim(' No project skills available.\n'));
901
+ return;
902
+ }
903
+
904
+ const skills: Array<{ name: string; description: string }> = [];
905
+
906
+ for (const entry of entries) {
907
+ const readmePath = path.join(PROJECT_SKILLS_DIR, entry, 'README.md');
908
+ let description = 'No description available';
909
+
910
+ if (await fs.pathExists(readmePath)) {
911
+ const readmeContent = await fs.readFile(readmePath, 'utf8');
912
+ description = extractReadmeDescription(readmeContent).slice(0, 80);
913
+ }
914
+
915
+ skills.push({ name: entry, description });
916
+ }
917
+
918
+ if (skills.length === 0) {
919
+ console.log(kleur.dim(' No project skills available.\n'));
920
+ return;
921
+ }
922
+
923
+ console.log(kleur.bold('\nAvailable Project Skills:\n'));
924
+
925
+ // Dynamic import for Table
926
+ const Table = require('cli-table3');
927
+ const table = new Table({
928
+ head: [kleur.cyan('Skill'), kleur.cyan('Description')],
929
+ colWidths: [25, 60],
930
+ style: { head: [], border: [] },
931
+ });
932
+
933
+ for (const skill of skills) {
934
+ table.push([kleur.white(skill.name), kleur.dim(skill.description)]);
935
+ }
936
+
937
+ console.log(table.toString());
938
+
939
+ console.log(kleur.bold('\n\nUsage (legacy):\n'));
940
+ console.log(kleur.dim(' xtrm install project <skill-name> Install a legacy project skill'));
941
+ console.log(kleur.dim(' xtrm install project all Install all legacy project skills'));
942
+ console.log(kleur.dim(' xtrm install project list List available legacy skills\n'));
943
+
944
+ console.log(kleur.bold('Preferred:\n'));
945
+ console.log(kleur.dim(' xtrm init Bootstrap project data for global hooks/skills\n'));
946
+ }
947
+
948
+ function getProjectRoot(): string {
949
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
950
+ encoding: 'utf8',
951
+ timeout: 5000,
952
+ });
953
+ if (result.status !== 0) {
954
+ throw new Error('Not inside a git repository. Run this command from your target project directory.');
955
+ }
956
+ return path.resolve(result.stdout.trim());
957
+ }
958
+
959
+