xtrm-cli 2.1.4 → 2.1.14
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/dist/index.cjs +1704 -1397
- package/dist/index.cjs.map +1 -1
- package/hooks/gitnexus-impact-reminder.py +13 -0
- package/package.json +4 -1
- package/src/commands/help.ts +1 -0
- package/src/commands/install-pi.ts +117 -0
- package/src/commands/install-project.ts +134 -23
- package/src/commands/install-service-skills.ts +37 -10
- package/src/commands/install.ts +2 -0
- package/src/utils/atomic-config.ts +89 -0
- package/test/atomic-config.test.ts +83 -0
- package/test/hooks.test.ts +343 -32
- package/test/install-pi.test.ts +119 -0
- package/test/install-project.test.ts +34 -5
- package/test/install-service-skills.test.ts +20 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|
6
|
+
from agent_context import AgentContext
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
ctx = AgentContext()
|
|
10
|
+
ctx.fail_open()
|
|
11
|
+
except Exception as e:
|
|
12
|
+
print(f"Hook error: {e}", file=sys.stderr)
|
|
13
|
+
sys.exit(0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xtrm-cli",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.14",
|
|
4
4
|
"description": "Claude Code tools installer (skills, hooks, MCP servers)",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -39,5 +39,8 @@
|
|
|
39
39
|
"tsx": "^4.21.0",
|
|
40
40
|
"typescript": "^5.9.3",
|
|
41
41
|
"vitest": "^4.0.18"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20.0.0"
|
|
42
45
|
}
|
|
43
46
|
}
|
package/src/commands/help.ts
CHANGED
|
@@ -8,6 +8,7 @@ declare const __dirname: string;
|
|
|
8
8
|
|
|
9
9
|
const HOOK_CATALOG: Array<{ file: string; event: string; desc: string; beads?: true }> = [
|
|
10
10
|
{ file: 'main-guard.mjs', event: 'PreToolUse', desc: 'Blocks direct edits on protected branches' },
|
|
11
|
+
{ file: 'main-guard-post-push.mjs', event: 'PostToolUse', desc: 'After feature-branch push, reminds PR/merge/sync steps' },
|
|
11
12
|
{ file: 'skill-suggestion.py', event: 'UserPromptSubmit', desc: 'Suggests relevant skills based on user prompt' },
|
|
12
13
|
{ file: 'serena-workflow-reminder.py', event: 'SessionStart', desc: 'Injects Serena semantic editing workflow reminder' },
|
|
13
14
|
{ file: 'type-safety-enforcement.py', event: 'PreToolUse', desc: 'Prevents risky Bash and enforces safe edit patterns' },
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import kleur from 'kleur';
|
|
3
|
+
import prompts from 'prompts';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { findRepoRoot } from '../utils/repo-root.js';
|
|
9
|
+
import { t, sym } from '../utils/theme.js';
|
|
10
|
+
|
|
11
|
+
const PI_AGENT_DIR = path.join(homedir(), '.pi', 'agent');
|
|
12
|
+
|
|
13
|
+
interface SchemaField { key: string; label: string; hint: string; secret: boolean; required: boolean; }
|
|
14
|
+
interface OAuthProvider { key: string; instruction: string; }
|
|
15
|
+
interface InstallSchema { fields: SchemaField[]; oauth_providers: OAuthProvider[]; packages: string[]; }
|
|
16
|
+
|
|
17
|
+
export function fillTemplate(template: string, values: Record<string, string>): string {
|
|
18
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => values[key] ?? '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export function readExistingPiValues(piAgentDir: string): Record<string, string> {
|
|
23
|
+
const values: Record<string, string> = {};
|
|
24
|
+
try {
|
|
25
|
+
const auth = JSON.parse(require('fs').readFileSync(path.join(piAgentDir, 'auth.json'), 'utf8'));
|
|
26
|
+
if (auth?.dashscope?.key) values['DASHSCOPE_API_KEY'] = auth.dashscope.key;
|
|
27
|
+
if (auth?.zai?.key) values['ZAI_API_KEY'] = auth.zai.key;
|
|
28
|
+
} catch { /* file doesn't exist or invalid */ }
|
|
29
|
+
try {
|
|
30
|
+
const models = JSON.parse(require('fs').readFileSync(path.join(piAgentDir, 'models.json'), 'utf8'));
|
|
31
|
+
if (!values['DASHSCOPE_API_KEY'] && models?.providers?.dashscope?.apiKey) {
|
|
32
|
+
values['DASHSCOPE_API_KEY'] = models.providers.dashscope.apiKey;
|
|
33
|
+
}
|
|
34
|
+
} catch { /* file doesn't exist or invalid */ }
|
|
35
|
+
return values;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isPiInstalled(): boolean {
|
|
39
|
+
return spawnSync('pi', ['--version'], { encoding: 'utf8' }).status === 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createInstallPiCommand(): Command {
|
|
43
|
+
const cmd = new Command('pi');
|
|
44
|
+
cmd
|
|
45
|
+
.description('Install Pi coding agent with providers, extensions, and npm packages')
|
|
46
|
+
.option('-y, --yes', 'Skip confirmation prompts', false)
|
|
47
|
+
.action(async (opts) => {
|
|
48
|
+
const { yes } = opts;
|
|
49
|
+
const repoRoot = await findRepoRoot();
|
|
50
|
+
const piConfigDir = path.join(repoRoot, 'config', 'pi');
|
|
51
|
+
|
|
52
|
+
console.log(t.bold('\n Pi Coding Agent Setup\n'));
|
|
53
|
+
|
|
54
|
+
if (!isPiInstalled()) {
|
|
55
|
+
console.log(kleur.yellow(' pi not found — installing oh-pi globally...\n'));
|
|
56
|
+
const r = spawnSync('npm', ['install', '-g', 'oh-pi'], { stdio: 'inherit' });
|
|
57
|
+
if (r.status !== 0) {
|
|
58
|
+
console.error(kleur.red('\n Failed to install oh-pi. Run: npm install -g oh-pi\n'));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
console.log(t.success(' pi installed\n'));
|
|
62
|
+
} else {
|
|
63
|
+
const v = spawnSync('pi', ['--version'], { encoding: 'utf8' });
|
|
64
|
+
console.log(t.success(` pi ${v.stdout.trim()} already installed\n`));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const schema: InstallSchema = await fs.readJson(path.join(piConfigDir, 'install-schema.json'));
|
|
68
|
+
const existing = readExistingPiValues(PI_AGENT_DIR);
|
|
69
|
+
const values: Record<string, string> = { ...existing };
|
|
70
|
+
|
|
71
|
+
console.log(t.bold(' API Keys\n'));
|
|
72
|
+
for (const field of schema.fields) {
|
|
73
|
+
if (existing[field.key]) {
|
|
74
|
+
console.log(t.success(` ${sym.ok} ${field.label} [already set]`));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!field.required && !yes) {
|
|
78
|
+
const { include } = await prompts({ type: 'confirm', name: 'include', message: ` Configure ${field.label}? (optional)`, initial: false });
|
|
79
|
+
if (!include) continue;
|
|
80
|
+
}
|
|
81
|
+
const { value } = await prompts({ type: field.secret ? 'password' : 'text', name: 'value', message: ` ${field.label}`, hint: field.hint, validate: (v) => (field.required && !v) ? 'Required' : true });
|
|
82
|
+
if (value) values[field.key] = value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await fs.ensureDir(PI_AGENT_DIR);
|
|
86
|
+
console.log(t.muted(`\n Writing config to ${PI_AGENT_DIR}`));
|
|
87
|
+
|
|
88
|
+
for (const name of ['models.json', 'auth.json', 'settings.json']) {
|
|
89
|
+
const destPath = path.join(PI_AGENT_DIR, name);
|
|
90
|
+
if (name === 'auth.json' && await fs.pathExists(destPath) && !yes) {
|
|
91
|
+
const { overwrite } = await prompts({ type: 'confirm', name: 'overwrite', message: ` ${name} already exists — overwrite? (OAuth tokens will be lost)`, initial: false });
|
|
92
|
+
if (!overwrite) { console.log(t.muted(` skipped ${name}`)); continue; }
|
|
93
|
+
}
|
|
94
|
+
const raw = await fs.readFile(path.join(piConfigDir, `${name}.template`), 'utf8');
|
|
95
|
+
await fs.writeFile(destPath, fillTemplate(raw, values), 'utf8');
|
|
96
|
+
console.log(t.success(` ${sym.ok} ${name}`));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await fs.copy(path.join(piConfigDir, 'extensions'), path.join(PI_AGENT_DIR, 'extensions'), { overwrite: true });
|
|
100
|
+
console.log(t.success(` ${sym.ok} extensions/`));
|
|
101
|
+
|
|
102
|
+
console.log(t.bold('\n npm Packages\n'));
|
|
103
|
+
for (const pkg of schema.packages) {
|
|
104
|
+
const r = spawnSync('pi', ['install', pkg], { stdio: 'inherit' });
|
|
105
|
+
if (r.status === 0) console.log(t.success(` ${sym.ok} ${pkg}`));
|
|
106
|
+
else console.log(kleur.yellow(` ${pkg} — failed, run manually: pi install ${pkg}`));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(t.bold('\n OAuth (manual steps)\n'));
|
|
110
|
+
for (const provider of schema.oauth_providers) {
|
|
111
|
+
console.log(t.muted(` ${provider.key}: ${provider.instruction}`));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(t.boldGreen('\n Pi setup complete\n'));
|
|
115
|
+
});
|
|
116
|
+
return cmd;
|
|
117
|
+
}
|
|
@@ -155,6 +155,11 @@ export function deepMergeHooks(existing: Record<string, any>, incoming: Record<s
|
|
|
155
155
|
const incomingEventHooks = Array.isArray(incomingHooks) ? incomingHooks : [incomingHooks];
|
|
156
156
|
|
|
157
157
|
const getCommand = (h: any) => h.command || h.hooks?.[0]?.command;
|
|
158
|
+
const getCommandKey = (cmd?: string): string | null => {
|
|
159
|
+
if (!cmd || typeof cmd !== 'string') return null;
|
|
160
|
+
const m = cmd.match(/([A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
|
|
161
|
+
return m?.[1] ?? null;
|
|
162
|
+
};
|
|
158
163
|
const mergeMatcher = (existingMatcher: string, incomingMatcher: string): string => {
|
|
159
164
|
const existingParts = existingMatcher.split('|').map((s: string) => s.trim()).filter(Boolean);
|
|
160
165
|
const incomingParts = incomingMatcher.split('|').map((s: string) => s.trim()).filter(Boolean);
|
|
@@ -173,7 +178,13 @@ export function deepMergeHooks(existing: Record<string, any>, incoming: Record<s
|
|
|
173
178
|
continue;
|
|
174
179
|
}
|
|
175
180
|
|
|
176
|
-
const
|
|
181
|
+
const incomingKey = getCommandKey(incomingCmd);
|
|
182
|
+
const existingIndex = mergedEventHooks.findIndex((h: any) => {
|
|
183
|
+
const existingCmd = getCommand(h);
|
|
184
|
+
if (existingCmd === incomingCmd) return true;
|
|
185
|
+
if (!incomingKey) return false;
|
|
186
|
+
return getCommandKey(existingCmd) === incomingKey;
|
|
187
|
+
});
|
|
177
188
|
if (existingIndex === -1) {
|
|
178
189
|
mergedEventHooks.push(incomingHook);
|
|
179
190
|
continue;
|
|
@@ -324,8 +335,30 @@ export async function installProjectSkill(toolName: string, projectRootOverride?
|
|
|
324
335
|
console.log(kleur.white(` Please read: ${kleur.cyan('.claude/docs/' + toolName + '-readme.md')}\n`));
|
|
325
336
|
|
|
326
337
|
if (toolName === 'tdd-guard') {
|
|
327
|
-
|
|
328
|
-
|
|
338
|
+
// Check if tdd-guard CLI is installed globally
|
|
339
|
+
const tddGuardCheck = spawnSync('tdd-guard', ['--version'], { stdio: 'pipe' });
|
|
340
|
+
if (tddGuardCheck.status !== 0) {
|
|
341
|
+
console.log(kleur.red(' ✗ tdd-guard CLI not found globally!\n'));
|
|
342
|
+
console.log(kleur.white(' Install the global CLI:'));
|
|
343
|
+
console.log(kleur.cyan(' npm install -g tdd-guard\n'));
|
|
344
|
+
} else {
|
|
345
|
+
console.log(kleur.green(' ✓ tdd-guard CLI found globally'));
|
|
346
|
+
}
|
|
347
|
+
console.log(kleur.white('\n Install a test reporter (choose one):'));
|
|
348
|
+
console.log(kleur.dim(' npm install --save-dev tdd-guard-vitest # Vitest'));
|
|
349
|
+
console.log(kleur.dim(' npm install --save-dev tdd-guard-jest # Jest'));
|
|
350
|
+
console.log(kleur.dim(' pip install tdd-guard-pytest # pytest\n'));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (toolName === 'quality-gates') {
|
|
354
|
+
console.log(kleur.white(' Install language dependencies:\n'));
|
|
355
|
+
console.log(kleur.white(' TypeScript:'));
|
|
356
|
+
console.log(kleur.dim(' npm install --save-dev typescript eslint prettier'));
|
|
357
|
+
console.log(kleur.white('\n Python:'));
|
|
358
|
+
console.log(kleur.dim(' pip install ruff mypy'));
|
|
359
|
+
console.log(kleur.white('\n For TDD (test-first) enforcement, install separately:'));
|
|
360
|
+
console.log(kleur.dim(' npm install -g tdd-guard'));
|
|
361
|
+
console.log(kleur.dim(' xtrm install project tdd-guard\n'));
|
|
329
362
|
}
|
|
330
363
|
|
|
331
364
|
console.log(kleur.green(' ✓ Installation complete!\n'));
|
|
@@ -354,26 +387,48 @@ export async function installAllProjectSkills(projectRootOverride?: string): Pro
|
|
|
354
387
|
|
|
355
388
|
export function buildProjectInitGuide(): string {
|
|
356
389
|
const lines = [
|
|
357
|
-
kleur.bold('\nProject Init —
|
|
358
|
-
|
|
359
|
-
kleur.
|
|
360
|
-
kleur.dim('
|
|
361
|
-
kleur.dim(' -
|
|
390
|
+
kleur.bold('\nProject Init — Production baseline\n'),
|
|
391
|
+
kleur.dim('This command prints a complete setup checklist and then bootstraps beads + GitNexus for this repo.\n'),
|
|
392
|
+
`${kleur.cyan('1) Install core project enforcement (quality-gates):')}`,
|
|
393
|
+
kleur.dim(' xtrm install project quality-gates'),
|
|
394
|
+
kleur.dim(' - Installs TypeScript + Python PostToolUse quality hooks'),
|
|
395
|
+
kleur.dim(' - Includes Serena matcher coverage for edit-equivalent tools'),
|
|
396
|
+
kleur.dim(' - Enforces linting/type-checking based on your repo config'),
|
|
362
397
|
'',
|
|
363
|
-
`${kleur.cyan('2)
|
|
364
|
-
kleur.dim('
|
|
365
|
-
kleur.dim(' -
|
|
366
|
-
kleur.dim('
|
|
367
|
-
kleur.dim(' -
|
|
398
|
+
`${kleur.cyan('2) Optional installs depending on workflow:')}`,
|
|
399
|
+
kleur.dim(' xtrm install project tdd-guard'),
|
|
400
|
+
kleur.dim(' - Strong test-first enforcement (PreToolUse + prompt/session checks)'),
|
|
401
|
+
kleur.dim(' xtrm install project service-skills-set'),
|
|
402
|
+
kleur.dim(' - Service-aware skill routing + drift checks + git hook reminders'),
|
|
368
403
|
'',
|
|
369
|
-
`${kleur.cyan('3)
|
|
370
|
-
kleur.dim(' -
|
|
371
|
-
kleur.dim(' -
|
|
372
|
-
kleur.dim(' -
|
|
404
|
+
`${kleur.cyan('3) Configure repo checks (hooks only enforce what your repo defines):')}`,
|
|
405
|
+
kleur.dim(' - Tests: should fail on regressions (required for TDD workflows)'),
|
|
406
|
+
kleur.dim(' - TS: eslint + prettier + tsc'),
|
|
407
|
+
kleur.dim(' - PY: ruff + mypy/pyright'),
|
|
408
|
+
'',
|
|
409
|
+
`${kleur.cyan('4) Beads workflow (required for gated edit/commit flow):')}`,
|
|
410
|
+
kleur.dim(' - Claim work: bd ready --json -> bd update <id> --claim --json'),
|
|
411
|
+
kleur.dim(' - During work: keep issue status current; create discovered follow-ups'),
|
|
412
|
+
kleur.dim(' - Finish work: bd close <id> --reason "Done" --json'),
|
|
413
|
+
'',
|
|
414
|
+
`${kleur.cyan('5) Git workflow (main-guard expected path):')}`,
|
|
415
|
+
kleur.dim(' - git checkout -b feature/<name>'),
|
|
416
|
+
kleur.dim(' - commit on feature branch only'),
|
|
417
|
+
kleur.dim(' - git push -u origin feature/<name>'),
|
|
418
|
+
kleur.dim(' - gh pr create --fill && gh pr merge --squash'),
|
|
419
|
+
kleur.dim(' - git checkout main && git pull --ff-only'),
|
|
420
|
+
'',
|
|
421
|
+
`${kleur.cyan('6) Hooks and startup automation:')}`,
|
|
422
|
+
kleur.dim(' - PreToolUse: safety gates (main/beads/TDD/type-safety/etc.)'),
|
|
423
|
+
kleur.dim(' - PostToolUse: quality checks + reminders'),
|
|
424
|
+
kleur.dim(' - Stop: beads stop-gate prevents unresolved session claims'),
|
|
425
|
+
kleur.dim(' - SessionStart: workflow context reminders'),
|
|
373
426
|
'',
|
|
374
427
|
kleur.bold('Quick start commands:'),
|
|
375
428
|
kleur.dim(' xtrm install project list'),
|
|
376
|
-
kleur.dim(' xtrm install project
|
|
429
|
+
kleur.dim(' xtrm install project quality-gates'),
|
|
430
|
+
kleur.dim(' xtrm install project tdd-guard'),
|
|
431
|
+
kleur.dim(' xtrm install project service-skills-set'),
|
|
377
432
|
'',
|
|
378
433
|
];
|
|
379
434
|
|
|
@@ -382,7 +437,7 @@ export function buildProjectInitGuide(): string {
|
|
|
382
437
|
|
|
383
438
|
async function printProjectInitGuide(): Promise<void> {
|
|
384
439
|
console.log(buildProjectInitGuide());
|
|
385
|
-
await
|
|
440
|
+
await bootstrapProjectInit();
|
|
386
441
|
}
|
|
387
442
|
|
|
388
443
|
async function installProjectByName(toolName: string): Promise<void> {
|
|
@@ -393,15 +448,22 @@ async function installProjectByName(toolName: string): Promise<void> {
|
|
|
393
448
|
await installProjectSkill(toolName);
|
|
394
449
|
}
|
|
395
450
|
|
|
396
|
-
async function
|
|
451
|
+
async function bootstrapProjectInit(): Promise<void> {
|
|
397
452
|
let projectRoot: string;
|
|
398
453
|
try {
|
|
399
454
|
projectRoot = getProjectRoot();
|
|
400
455
|
} catch (err: any) {
|
|
401
|
-
console.log(kleur.yellow(`\n ⚠ Skipping
|
|
456
|
+
console.log(kleur.yellow(`\n ⚠ Skipping project bootstrap: ${err.message}\n`));
|
|
402
457
|
return;
|
|
403
458
|
}
|
|
404
459
|
|
|
460
|
+
await runBdInitForProject(projectRoot);
|
|
461
|
+
await runGitNexusInitForProject(projectRoot);
|
|
462
|
+
await syncProjectMcpServers(projectRoot);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function runBdInitForProject(projectRoot: string): Promise<void> {
|
|
466
|
+
|
|
405
467
|
console.log(kleur.bold('Running beads initialization (bd init)...'));
|
|
406
468
|
|
|
407
469
|
const result = spawnSync('bd', ['init'], {
|
|
@@ -431,6 +493,55 @@ async function runBdInitForProject(): Promise<void> {
|
|
|
431
493
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
432
494
|
}
|
|
433
495
|
|
|
496
|
+
async function runGitNexusInitForProject(projectRoot: string): Promise<void> {
|
|
497
|
+
const gitnexusCheck = spawnSync('gitnexus', ['--version'], {
|
|
498
|
+
cwd: projectRoot,
|
|
499
|
+
encoding: 'utf8',
|
|
500
|
+
timeout: 5000,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
if (gitnexusCheck.status !== 0) {
|
|
504
|
+
console.log(kleur.yellow(' ⚠ gitnexus not found; skipping index bootstrap'));
|
|
505
|
+
console.log(kleur.dim(' Install with: npm install -g gitnexus'));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
console.log(kleur.bold('Checking GitNexus index status...'));
|
|
510
|
+
|
|
511
|
+
const status = spawnSync('gitnexus', ['status'], {
|
|
512
|
+
cwd: projectRoot,
|
|
513
|
+
encoding: 'utf8',
|
|
514
|
+
timeout: 10000,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
const statusText = `${status.stdout || ''}\n${status.stderr || ''}`.toLowerCase();
|
|
518
|
+
const needsAnalyze = status.status !== 0 ||
|
|
519
|
+
statusText.includes('stale') ||
|
|
520
|
+
statusText.includes('not indexed') ||
|
|
521
|
+
statusText.includes('missing');
|
|
522
|
+
|
|
523
|
+
if (!needsAnalyze) {
|
|
524
|
+
console.log(kleur.dim(' ✓ GitNexus index is ready'));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
console.log(kleur.bold('Running GitNexus indexing (gitnexus analyze)...'));
|
|
529
|
+
const analyze = spawnSync('gitnexus', ['analyze'], {
|
|
530
|
+
cwd: projectRoot,
|
|
531
|
+
encoding: 'utf8',
|
|
532
|
+
timeout: 120000,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
if (analyze.status === 0) {
|
|
536
|
+
console.log(kleur.green(' ✓ GitNexus index updated'));
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (analyze.stdout) process.stdout.write(analyze.stdout);
|
|
541
|
+
if (analyze.stderr) process.stderr.write(analyze.stderr);
|
|
542
|
+
console.log(kleur.yellow(` ⚠ gitnexus analyze exited with code ${analyze.status}`));
|
|
543
|
+
}
|
|
544
|
+
|
|
434
545
|
/**
|
|
435
546
|
* List available project skills.
|
|
436
547
|
*/
|
|
@@ -520,7 +631,7 @@ export function createInstallProjectCommand(): Command {
|
|
|
520
631
|
});
|
|
521
632
|
|
|
522
633
|
const initCmd = new Command('init')
|
|
523
|
-
.description('Show
|
|
634
|
+
.description('Show full onboarding guidance and bootstrap beads + GitNexus')
|
|
524
635
|
.action(async () => {
|
|
525
636
|
await printProjectInitGuide();
|
|
526
637
|
});
|
|
@@ -537,7 +648,7 @@ export function createProjectCommand(): Command {
|
|
|
537
648
|
|
|
538
649
|
projectCmd
|
|
539
650
|
.command('init')
|
|
540
|
-
.description('Show
|
|
651
|
+
.description('Show full onboarding guidance and bootstrap beads + GitNexus')
|
|
541
652
|
.action(async () => {
|
|
542
653
|
await printProjectInitGuide();
|
|
543
654
|
});
|
|
@@ -60,6 +60,7 @@ const SETTINGS_HOOKS: Record<string, unknown[]> = {
|
|
|
60
60
|
|
|
61
61
|
const MARKER_DOC = '# [jaggers] doc-reminder';
|
|
62
62
|
const MARKER_STALENESS = '# [jaggers] skill-staleness';
|
|
63
|
+
const MARKER_CHAIN = '# [jaggers] chain-githooks';
|
|
63
64
|
|
|
64
65
|
// ─── Pure functions (exported for testing) ───────────────────────────────────
|
|
65
66
|
|
|
@@ -139,7 +140,6 @@ export async function installGitHooks(projectRoot: string, skillsSrc: string = S
|
|
|
139
140
|
];
|
|
140
141
|
|
|
141
142
|
const hookFiles: { name: string; status: 'added' | 'already-present' }[] = [];
|
|
142
|
-
let anyAdded = false;
|
|
143
143
|
|
|
144
144
|
for (const [hookPath, marker, snippet] of snippets) {
|
|
145
145
|
const content = await fs.readFile(hookPath, 'utf8');
|
|
@@ -147,20 +147,47 @@ export async function installGitHooks(projectRoot: string, skillsSrc: string = S
|
|
|
147
147
|
if (!content.includes(marker)) {
|
|
148
148
|
await fs.writeFile(hookPath, content + snippet);
|
|
149
149
|
hookFiles.push({ name, status: 'added' });
|
|
150
|
-
anyAdded = true;
|
|
151
150
|
} else {
|
|
152
151
|
hookFiles.push({ name, status: 'already-present' });
|
|
153
152
|
}
|
|
154
153
|
}
|
|
155
154
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
155
|
+
const hooksPathResult = spawnSync('git', ['config', '--get', 'core.hooksPath'], {
|
|
156
|
+
cwd: projectRoot,
|
|
157
|
+
encoding: 'utf8',
|
|
158
|
+
timeout: 5000,
|
|
159
|
+
});
|
|
160
|
+
const configuredHooksPath = hooksPathResult.status === 0 ? hooksPathResult.stdout.trim() : '';
|
|
161
|
+
const activeHooksDir = configuredHooksPath
|
|
162
|
+
? (path.isAbsolute(configuredHooksPath)
|
|
163
|
+
? configuredHooksPath
|
|
164
|
+
: path.join(projectRoot, configuredHooksPath))
|
|
165
|
+
: path.join(projectRoot, '.git', 'hooks');
|
|
166
|
+
|
|
167
|
+
const activationTargets = new Set([path.join(projectRoot, '.git', 'hooks'), activeHooksDir]);
|
|
168
|
+
for (const hooksDir of activationTargets) {
|
|
169
|
+
await fs.mkdirp(hooksDir);
|
|
170
|
+
|
|
171
|
+
for (const [name, sourceHook] of [['pre-commit', preCommit], ['pre-push', prePush]] as const) {
|
|
172
|
+
const targetHook = path.join(hooksDir, name);
|
|
173
|
+
if (!await fs.pathExists(targetHook)) {
|
|
174
|
+
await fs.writeFile(targetHook, '#!/usr/bin/env bash\n', { mode: 0o755 });
|
|
175
|
+
} else {
|
|
176
|
+
await fs.chmod(targetHook, 0o755);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (path.resolve(targetHook) === path.resolve(sourceHook)) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const chainSnippet =
|
|
184
|
+
`\n${MARKER_CHAIN}\n` +
|
|
185
|
+
`if [ -x "${sourceHook}" ]; then\n` +
|
|
186
|
+
` "${sourceHook}" "$@"\n` +
|
|
187
|
+
'fi\n';
|
|
188
|
+
const targetContent = await fs.readFile(targetHook, 'utf8');
|
|
189
|
+
if (!targetContent.includes(MARKER_CHAIN)) {
|
|
190
|
+
await fs.writeFile(targetHook, targetContent + chainSnippet);
|
|
164
191
|
}
|
|
165
192
|
}
|
|
166
193
|
}
|
package/src/commands/install.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { findRepoRoot } from '../utils/repo-root.js';
|
|
|
10
10
|
import { t, sym } from '../utils/theme.js';
|
|
11
11
|
import path from 'path';
|
|
12
12
|
import { createInstallProjectCommand } from './install-project.js';
|
|
13
|
+
import { createInstallPiCommand } from './install-pi.js';
|
|
13
14
|
|
|
14
15
|
interface TargetChanges {
|
|
15
16
|
target: string;
|
|
@@ -529,6 +530,7 @@ export function createInstallCommand(): Command {
|
|
|
529
530
|
installCmd.addCommand(createInstallAllCommand());
|
|
530
531
|
installCmd.addCommand(createInstallBasicCommand());
|
|
531
532
|
installCmd.addCommand(createInstallProjectCommand());
|
|
533
|
+
installCmd.addCommand(createInstallPiCommand());
|
|
532
534
|
|
|
533
535
|
return installCmd;
|
|
534
536
|
}
|
|
@@ -41,6 +41,82 @@ export function isValueProtected(keyPath: string): boolean {
|
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function extractHookCommands(wrapper: any): string[] {
|
|
45
|
+
if (!wrapper || !Array.isArray(wrapper.hooks)) return [];
|
|
46
|
+
return wrapper.hooks
|
|
47
|
+
.map((h: any) => h?.command)
|
|
48
|
+
.filter((c: any): c is string => typeof c === 'string' && c.trim().length > 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function commandKey(command: string): string {
|
|
52
|
+
const m = command.match(/([A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
|
|
53
|
+
return m?.[1] || command.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mergeMatcher(existingMatcher: string, incomingMatcher: string): string {
|
|
57
|
+
const parts = [
|
|
58
|
+
...existingMatcher.split('|').map((s: string) => s.trim()),
|
|
59
|
+
...incomingMatcher.split('|').map((s: string) => s.trim()),
|
|
60
|
+
].filter(Boolean);
|
|
61
|
+
return Array.from(new Set(parts)).join('|');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mergeHookWrappers(existing: any[], incoming: any[]): any[] {
|
|
65
|
+
const merged = existing.map((w: any) => ({ ...w }));
|
|
66
|
+
|
|
67
|
+
for (const incomingWrapper of incoming) {
|
|
68
|
+
const incomingCommands = extractHookCommands(incomingWrapper);
|
|
69
|
+
if (incomingCommands.length === 0) {
|
|
70
|
+
merged.push(incomingWrapper);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const incomingKeys = new Set(incomingCommands.map(commandKey));
|
|
75
|
+
const existingIndex = merged.findIndex((existingWrapper: any) => {
|
|
76
|
+
const existingCommands = extractHookCommands(existingWrapper);
|
|
77
|
+
return existingCommands.some((c: string) => incomingKeys.has(commandKey(c)));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (existingIndex === -1) {
|
|
81
|
+
merged.push(incomingWrapper);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const existingWrapper = merged[existingIndex];
|
|
86
|
+
if (
|
|
87
|
+
typeof existingWrapper.matcher === 'string' &&
|
|
88
|
+
typeof incomingWrapper.matcher === 'string'
|
|
89
|
+
) {
|
|
90
|
+
existingWrapper.matcher = mergeMatcher(existingWrapper.matcher, incomingWrapper.matcher);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (Array.isArray(existingWrapper.hooks) && Array.isArray(incomingWrapper.hooks)) {
|
|
94
|
+
const existingByKey = new Set(existingWrapper.hooks
|
|
95
|
+
.map((h: any) => h?.command)
|
|
96
|
+
.filter((c: any): c is string => typeof c === 'string')
|
|
97
|
+
.map(commandKey));
|
|
98
|
+
for (const hook of incomingWrapper.hooks) {
|
|
99
|
+
const cmd = hook?.command;
|
|
100
|
+
if (typeof cmd !== 'string' || !existingByKey.has(commandKey(cmd))) {
|
|
101
|
+
existingWrapper.hooks.push(hook);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return merged;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function mergeHooksObject(existingHooks: any, incomingHooks: any): any {
|
|
111
|
+
const result = { ...(existingHooks || {}) };
|
|
112
|
+
for (const [event, incomingWrappers] of Object.entries(incomingHooks || {})) {
|
|
113
|
+
const existingWrappers = Array.isArray(result[event]) ? result[event] : [];
|
|
114
|
+
const incomingArray = Array.isArray(incomingWrappers) ? incomingWrappers : [];
|
|
115
|
+
result[event] = mergeHookWrappers(existingWrappers, incomingArray);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
44
120
|
/**
|
|
45
121
|
* Deep merge two objects, preserving protected values from the original
|
|
46
122
|
*/
|
|
@@ -50,6 +126,19 @@ export function deepMergeWithProtection(original: any, updates: any, currentPath
|
|
|
50
126
|
for (const [key, value] of Object.entries(updates)) {
|
|
51
127
|
const keyPath = currentPath ? `${currentPath}.${key}` : key;
|
|
52
128
|
|
|
129
|
+
// Hooks are canonical but should still preserve local custom entries.
|
|
130
|
+
// Merge by command identity and upgrade matchers for overlapping hooks.
|
|
131
|
+
if (
|
|
132
|
+
key === 'hooks' &&
|
|
133
|
+
typeof value === 'object' &&
|
|
134
|
+
value !== null &&
|
|
135
|
+
typeof original[key] === 'object' &&
|
|
136
|
+
original[key] !== null
|
|
137
|
+
) {
|
|
138
|
+
result[key] = mergeHooksObject(original[key], value);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
53
142
|
// If this specific value is protected and exists locally, skip it
|
|
54
143
|
if (isValueProtected(keyPath) && original.hasOwnProperty(key)) {
|
|
55
144
|
continue;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { deepMergeWithProtection } from '../src/utils/atomic-config.js';
|
|
3
|
+
|
|
4
|
+
describe('deepMergeWithProtection (hooks merge behavior)', () => {
|
|
5
|
+
it('upgrades matcher tokens for same hook command without duplicating hook', () => {
|
|
6
|
+
const local = {
|
|
7
|
+
hooks: {
|
|
8
|
+
PostToolUse: [{
|
|
9
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
10
|
+
hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/quality-check.py"' }],
|
|
11
|
+
}],
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const incoming = {
|
|
16
|
+
hooks: {
|
|
17
|
+
PostToolUse: [{
|
|
18
|
+
matcher: 'Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
|
|
19
|
+
hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/hooks/quality-check.py"' }],
|
|
20
|
+
}],
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const merged = deepMergeWithProtection(local, incoming);
|
|
25
|
+
const wrappers = merged.hooks.PostToolUse;
|
|
26
|
+
expect(wrappers).toHaveLength(1);
|
|
27
|
+
expect(wrappers[0].matcher).toContain('mcp__serena__insert_after_symbol');
|
|
28
|
+
expect(wrappers[0].matcher).toContain('mcp__serena__insert_before_symbol');
|
|
29
|
+
expect(wrappers[0].matcher).toContain('mcp__serena__rename_symbol');
|
|
30
|
+
expect(wrappers[0].matcher).toContain('mcp__serena__replace_symbol_body');
|
|
31
|
+
expect(wrappers[0].hooks).toHaveLength(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('preserves local custom hooks and still installs incoming wrappers', () => {
|
|
35
|
+
const local = {
|
|
36
|
+
hooks: {
|
|
37
|
+
PreToolUse: [{
|
|
38
|
+
matcher: 'Write',
|
|
39
|
+
hooks: [{ command: 'node /custom/local-hook.mjs' }],
|
|
40
|
+
}],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const incoming = {
|
|
45
|
+
hooks: {
|
|
46
|
+
PreToolUse: [{
|
|
47
|
+
matcher: 'Edit',
|
|
48
|
+
hooks: [{ command: 'node /repo/hooks/main-guard.mjs' }],
|
|
49
|
+
}],
|
|
50
|
+
PostToolUse: [{
|
|
51
|
+
matcher: 'Edit',
|
|
52
|
+
hooks: [{ command: 'node /repo/hooks/main-guard-post-push.mjs' }],
|
|
53
|
+
}],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const merged = deepMergeWithProtection(local, incoming);
|
|
58
|
+
expect(merged.hooks.PreToolUse).toHaveLength(2);
|
|
59
|
+
expect(merged.hooks.PreToolUse[0].hooks[0].command).toBe('node /custom/local-hook.mjs');
|
|
60
|
+
expect(merged.hooks.PreToolUse[1].hooks[0].command).toBe('node /repo/hooks/main-guard.mjs');
|
|
61
|
+
expect(merged.hooks.PostToolUse).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('keeps protected non-hook keys unchanged', () => {
|
|
65
|
+
const local = {
|
|
66
|
+
model: 'claude-sonnet',
|
|
67
|
+
hooks: {
|
|
68
|
+
SessionStart: [{ hooks: [{ command: 'echo start-local' }] }],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const incoming = {
|
|
73
|
+
model: 'claude-opus',
|
|
74
|
+
hooks: {
|
|
75
|
+
SessionStart: [{ hooks: [{ command: 'echo start-repo' }] }],
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const merged = deepMergeWithProtection(local, incoming);
|
|
80
|
+
expect(merged.model).toBe('claude-sonnet');
|
|
81
|
+
expect(merged.hooks.SessionStart).toHaveLength(2);
|
|
82
|
+
});
|
|
83
|
+
});
|