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.
- package/.gemini/settings.json +39 -0
- package/dist/index.cjs +57378 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/extensions/beads.ts +109 -0
- package/extensions/core/adapter.ts +45 -0
- package/extensions/core/lib.ts +3 -0
- package/extensions/core/logger.ts +45 -0
- package/extensions/core/runner.ts +71 -0
- package/extensions/custom-footer.ts +160 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/minimal-mode.ts +201 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +150 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/hooks/gitnexus-impact-reminder.py +13 -0
- package/lib/atomic-config.js +236 -0
- package/lib/config-adapter.js +231 -0
- package/lib/config-injector.js +80 -0
- package/lib/context.js +73 -0
- package/lib/diff.js +142 -0
- package/lib/env-manager.js +160 -0
- package/lib/sync-mcp-cli.js +345 -0
- package/lib/sync.js +227 -0
- package/package.json +47 -0
- package/src/adapters/base.ts +29 -0
- package/src/adapters/claude.ts +38 -0
- package/src/adapters/registry.ts +21 -0
- package/src/commands/claude.ts +122 -0
- package/src/commands/clean.ts +371 -0
- package/src/commands/end.ts +239 -0
- package/src/commands/finish.ts +25 -0
- package/src/commands/help.ts +180 -0
- package/src/commands/init.ts +959 -0
- package/src/commands/install-pi.ts +276 -0
- package/src/commands/install-service-skills.ts +281 -0
- package/src/commands/install.ts +427 -0
- package/src/commands/pi-install.ts +119 -0
- package/src/commands/pi.ts +128 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/commands/worktree.ts +193 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +174 -0
- package/src/core/interactive-plan.ts +165 -0
- package/src/core/manifest.ts +26 -0
- package/src/core/preflight.ts +142 -0
- package/src/core/rollback.ts +32 -0
- package/src/core/session-state.ts +139 -0
- package/src/core/sync-executor.ts +427 -0
- package/src/core/xtrm-finish.ts +267 -0
- package/src/index.ts +87 -0
- package/src/tests/policy-parity.test.ts +204 -0
- package/src/tests/session-flow-parity.test.ts +118 -0
- package/src/tests/session-state.test.ts +124 -0
- package/src/tests/xtrm-finish.test.ts +148 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +467 -0
- package/src/utils/banner.ts +194 -0
- package/src/utils/config-adapter.ts +90 -0
- package/src/utils/config-injector.ts +81 -0
- package/src/utils/env-manager.ts +193 -0
- package/src/utils/hash.ts +42 -0
- package/src/utils/repo-root.ts +39 -0
- package/src/utils/sync-mcp-cli.ts +395 -0
- package/src/utils/theme.ts +37 -0
- package/src/utils/worktree-session.ts +93 -0
- package/test/atomic-config-prune.test.ts +101 -0
- package/test/atomic-config.test.ts +138 -0
- package/test/clean.test.ts +172 -0
- package/test/config-schema.test.ts +52 -0
- package/test/context.test.ts +33 -0
- package/test/end-worktree.test.ts +168 -0
- package/test/extensions/beads.test.ts +166 -0
- package/test/extensions/extension-harness.ts +85 -0
- package/test/extensions/main-guard.test.ts +77 -0
- package/test/extensions/minimal-mode.test.ts +107 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/service-skills.test.ts +84 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
- package/test/hooks/quality-check-hooks.test.ts +45 -0
- package/test/hooks.test.ts +1075 -0
- package/test/install-pi.test.ts +185 -0
- package/test/install-project.test.ts +378 -0
- package/test/install-service-skills.test.ts +131 -0
- package/test/install-surface.test.ts +72 -0
- package/test/runtime-subcommands.test.ts +121 -0
- package/test/session-launcher.test.ts +139 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createInstallPiCommand } from '../src/commands/install-pi.js';
|
|
3
|
+
|
|
4
|
+
describe('createInstallPiCommand', () => {
|
|
5
|
+
it('exports a createInstallPiCommand function', () => {
|
|
6
|
+
expect(typeof createInstallPiCommand).toBe('function');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns a Command named "pi"', () => {
|
|
10
|
+
const cmd = createInstallPiCommand();
|
|
11
|
+
expect((cmd as any).name()).toBe('pi');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('fillTemplate replaces {{PLACEHOLDERS}} with values', async () => {
|
|
15
|
+
const { fillTemplate } = await import('../src/commands/install-pi.js');
|
|
16
|
+
expect(fillTemplate('{"k":"{{MY_KEY}}"}' , { MY_KEY: 'abc' })).toBe('{"k":"abc"}');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('fillTemplate leaves missing placeholders empty', async () => {
|
|
20
|
+
const { fillTemplate } = await import('../src/commands/install-pi.js');
|
|
21
|
+
expect(fillTemplate('{"k":"{{MISSING}}"}', {})).toBe('{"k":""}');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('models.json.template contains {{DASHSCOPE_API_KEY}}', () => {
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const p = require('node:path');
|
|
27
|
+
const content = fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'models.json.template'), 'utf8');
|
|
28
|
+
expect(content).toContain('{{DASHSCOPE_API_KEY}}');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('auth.json.template contains {{DASHSCOPE_API_KEY}} and {{ZAI_API_KEY}}', () => {
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const p = require('node:path');
|
|
34
|
+
const content = fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'auth.json.template'), 'utf8');
|
|
35
|
+
expect(content).toContain('{{DASHSCOPE_API_KEY}}');
|
|
36
|
+
expect(content).toContain('{{ZAI_API_KEY}}');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('auth.json.template contains no real API keys or tokens', () => {
|
|
40
|
+
const fs = require('node:fs');
|
|
41
|
+
const p = require('node:path');
|
|
42
|
+
const content = fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'auth.json.template'), 'utf8');
|
|
43
|
+
expect(content).not.toMatch(/sk-[a-zA-Z0-9]{20,}/);
|
|
44
|
+
expect(content).not.toMatch(/ya29\.[a-zA-Z0-9_-]{20,}/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('settings.json.template includes pi-serena-tools package', () => {
|
|
48
|
+
const fs = require('node:fs');
|
|
49
|
+
const p = require('node:path');
|
|
50
|
+
const settings = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'settings.json.template'), 'utf8'));
|
|
51
|
+
expect(settings.packages).toContain('npm:pi-serena-tools');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('settings.json.template includes @zenobius/pi-worktrees package', () => {
|
|
55
|
+
const fs = require('node:fs');
|
|
56
|
+
const p = require('node:path');
|
|
57
|
+
const settings = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'settings.json.template'), 'utf8'));
|
|
58
|
+
expect(settings.packages).toContain('npm:@zenobius/pi-worktrees');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('copyExtraConfigs copies missing files and skips existing ones', async () => {
|
|
62
|
+
const { copyExtraConfigs, EXTRA_PI_CONFIGS } = await import('../src/commands/install-pi.js?t=copy' + Date.now());
|
|
63
|
+
const os = require('node:os');
|
|
64
|
+
const nodePath = require('node:path');
|
|
65
|
+
const nodeFs = require('node:fs');
|
|
66
|
+
const srcDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-src-'));
|
|
67
|
+
const destDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-dest-'));
|
|
68
|
+
// Create src file
|
|
69
|
+
nodeFs.writeFileSync(nodePath.join(srcDir, 'pi-worktrees-settings.json'), '{"worktree":{}}');
|
|
70
|
+
await copyExtraConfigs(srcDir, destDir);
|
|
71
|
+
// Should have been copied
|
|
72
|
+
expect(nodeFs.existsSync(nodePath.join(destDir, 'pi-worktrees-settings.json'))).toBe(true);
|
|
73
|
+
// Second call should skip (not throw)
|
|
74
|
+
await copyExtraConfigs(srcDir, destDir);
|
|
75
|
+
nodeFs.rmSync(srcDir, { recursive: true });
|
|
76
|
+
nodeFs.rmSync(destDir, { recursive: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('EXTRA_PI_CONFIGS includes pi-worktrees-settings.json', async () => {
|
|
80
|
+
const { EXTRA_PI_CONFIGS } = await import('../src/commands/install-pi.js?t=extra' + Date.now());
|
|
81
|
+
expect(EXTRA_PI_CONFIGS).toContain('pi-worktrees-settings.json');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('pi-worktrees-settings.json exists in config/pi with worktree.parentDir defined', () => {
|
|
85
|
+
const fs = require('node:fs');
|
|
86
|
+
const p = require('node:path');
|
|
87
|
+
const cfg = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'pi-worktrees-settings.json'), 'utf8'));
|
|
88
|
+
expect(cfg.worktree).toBeDefined();
|
|
89
|
+
expect(cfg.worktree.parentDir).toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('install-schema.json defines DASHSCOPE_API_KEY and ZAI_API_KEY fields', () => {
|
|
93
|
+
const fs = require('node:fs');
|
|
94
|
+
const p = require('node:path');
|
|
95
|
+
const schema = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'install-schema.json'), 'utf8'));
|
|
96
|
+
const keys = schema.fields.map((f) => f.key);
|
|
97
|
+
expect(keys).toContain('DASHSCOPE_API_KEY');
|
|
98
|
+
expect(keys).toContain('ZAI_API_KEY');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('install-schema.json lists anthropic and qwen-cli as oauth_providers', () => {
|
|
102
|
+
const fs = require('node:fs');
|
|
103
|
+
const p = require('node:path');
|
|
104
|
+
const schema = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'install-schema.json'), 'utf8'));
|
|
105
|
+
const keys = schema.oauth_providers.map((o) => o.key);
|
|
106
|
+
expect(keys).toContain('anthropic');
|
|
107
|
+
expect(keys).toContain('qwen-cli');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('extensions directory contains all expected .ts files', () => {
|
|
111
|
+
const fs = require('node:fs');
|
|
112
|
+
const p = require('node:path');
|
|
113
|
+
const extDir = p.resolve(__dirname, '..', '..', 'config', 'pi', 'extensions');
|
|
114
|
+
const files = ['auto-session-name.ts','auto-update.ts','bg-process.ts','compact-header.ts','custom-footer.ts','git-checkpoint.ts','todo.ts'];
|
|
115
|
+
for (const f of files) expect(fs.existsSync(p.join(extDir, f))).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('custom-provider-qwen-cli extension has index.ts and package.json', () => {
|
|
119
|
+
const fs = require('node:fs');
|
|
120
|
+
const p = require('node:path');
|
|
121
|
+
const base = p.resolve(__dirname, '..', '..', 'config', 'pi', 'extensions', 'custom-provider-qwen-cli');
|
|
122
|
+
expect(fs.existsSync(p.join(base, 'index.ts'))).toBe(true);
|
|
123
|
+
expect(fs.existsSync(p.join(base, 'package.json'))).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('readExistingPiValues extracts DASHSCOPE_API_KEY from existing auth.json', async () => {
|
|
127
|
+
const { readExistingPiValues } = await import('../src/commands/install-pi.js?t=' + Date.now());
|
|
128
|
+
const tmpDir = require('node:fs').mkdtempSync(require('node:path').join(require('node:os').tmpdir(), 'pi-test-'));
|
|
129
|
+
require('node:fs').writeFileSync(require('node:path').join(tmpDir, 'auth.json'), JSON.stringify({ dashscope: { type: 'api_key', key: 'sk-existing-123' } }));
|
|
130
|
+
const result = readExistingPiValues(tmpDir);
|
|
131
|
+
require('node:fs').rmSync(tmpDir, { recursive: true });
|
|
132
|
+
expect(result['DASHSCOPE_API_KEY']).toBe('sk-existing-123');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('readExistingPiValues extracts ZAI_API_KEY from existing auth.json', async () => {
|
|
136
|
+
const { readExistingPiValues } = await import('../src/commands/install-pi.js?t=' + Date.now());
|
|
137
|
+
const tmpDir = require('node:fs').mkdtempSync(require('node:path').join(require('node:os').tmpdir(), 'pi-test-'));
|
|
138
|
+
require('node:fs').writeFileSync(require('node:path').join(tmpDir, 'auth.json'), JSON.stringify({ zai: { type: 'api_key', key: 'zai-existing-456' } }));
|
|
139
|
+
const result = readExistingPiValues(tmpDir);
|
|
140
|
+
require('node:fs').rmSync(tmpDir, { recursive: true });
|
|
141
|
+
expect(result['ZAI_API_KEY']).toBe('zai-existing-456');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('readExistingPiValues returns empty object when auth.json missing', async () => {
|
|
145
|
+
const { readExistingPiValues } = await import('../src/commands/install-pi.js?t=' + Date.now());
|
|
146
|
+
expect(readExistingPiValues('/nonexistent/path')).toEqual({});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('readExistingPiValues extracts DASHSCOPE_API_KEY from models.json when auth.json missing', async () => {
|
|
150
|
+
const { readExistingPiValues } = await import('../src/commands/install-pi.js?t=models' + Date.now());
|
|
151
|
+
const tmpDir = require('node:fs').mkdtempSync(require('node:path').join(require('node:os').tmpdir(), 'pi-test-'));
|
|
152
|
+
require('node:fs').writeFileSync(require('node:path').join(tmpDir, 'models.json'), JSON.stringify({ providers: { dashscope: { apiKey: 'sk-from-models-789' } } }));
|
|
153
|
+
const result = readExistingPiValues(tmpDir);
|
|
154
|
+
require('node:fs').rmSync(tmpDir, { recursive: true });
|
|
155
|
+
expect(result['DASHSCOPE_API_KEY']).toBe('sk-from-models-789');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('diffPiExtensions reports missing and stale files', async () => {
|
|
159
|
+
const { diffPiExtensions } = await import('../src/commands/install-pi.js?t=diff' + Date.now());
|
|
160
|
+
const nodeFs = require('node:fs');
|
|
161
|
+
const nodePath = require('node:path');
|
|
162
|
+
const os = require('node:os');
|
|
163
|
+
|
|
164
|
+
const srcDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-ext-src-'));
|
|
165
|
+
const dstDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-ext-dst-'));
|
|
166
|
+
|
|
167
|
+
nodeFs.writeFileSync(nodePath.join(srcDir, 'a.ts'), 'export const a = 1;');
|
|
168
|
+
nodeFs.writeFileSync(nodePath.join(srcDir, 'b.ts'), 'export const b = 1;');
|
|
169
|
+
nodeFs.writeFileSync(nodePath.join(dstDir, 'a.ts'), 'export const a = 2;');
|
|
170
|
+
|
|
171
|
+
const diff = await diffPiExtensions(srcDir, dstDir);
|
|
172
|
+
|
|
173
|
+
expect(diff.missing).toContain('b.ts');
|
|
174
|
+
expect(diff.stale).toContain('a.ts');
|
|
175
|
+
|
|
176
|
+
nodeFs.rmSync(srcDir, { recursive: true });
|
|
177
|
+
nodeFs.rmSync(dstDir, { recursive: true });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('createInstallPiCommand supports --check flag', () => {
|
|
181
|
+
const cmd = createInstallPiCommand();
|
|
182
|
+
const hasCheck = (cmd as any).options.some((opt: any) => opt.long === '--check');
|
|
183
|
+
expect(hasCheck).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fsExtra from 'fs-extra';
|
|
7
|
+
import {
|
|
8
|
+
buildProjectInitGuide,
|
|
9
|
+
createProjectCommand,
|
|
10
|
+
deepMergeHooks,
|
|
11
|
+
detectProjectFeatures,
|
|
12
|
+
ensureServiceRegistry,
|
|
13
|
+
extractReadmeDescription,
|
|
14
|
+
getAvailableProjectSkills,
|
|
15
|
+
installAllProjectSkills,
|
|
16
|
+
installProjectSkill,
|
|
17
|
+
upsertManagedBlock,
|
|
18
|
+
} from '../src/commands/install-project.js';
|
|
19
|
+
|
|
20
|
+
describe('buildProjectInitGuide', () => {
|
|
21
|
+
it('includes complete onboarding guidance (quality gates, beads workflow, and git workflow)', () => {
|
|
22
|
+
const guide = buildProjectInitGuide();
|
|
23
|
+
expect(guide).toContain('quality-gates');
|
|
24
|
+
expect(guide).toContain('tdd-guard');
|
|
25
|
+
expect(guide).toContain('service-skills-set');
|
|
26
|
+
expect(guide.toLowerCase()).toContain('beads workflow');
|
|
27
|
+
expect(guide).toContain('bd ready --json');
|
|
28
|
+
expect(guide).toContain('gh pr create --fill');
|
|
29
|
+
expect(guide.toLowerCase()).toContain('service-skills-set');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('createProjectCommand', () => {
|
|
34
|
+
it('exposes init/list/install subcommands', () => {
|
|
35
|
+
const cmd = createProjectCommand();
|
|
36
|
+
const names = cmd.commands.map(c => c.name());
|
|
37
|
+
expect(names).toEqual(expect.arrayContaining(['init', 'list', 'install']));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('detectProjectFeatures / ensureServiceRegistry', () => {
|
|
42
|
+
let tmpDir: string;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
tmpDir = await mkdtemp(path.join(tmpdir(), 'xtrm-init-detect-'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('detects TypeScript, Python, and Docker compose services', async () => {
|
|
53
|
+
await fsExtra.writeFile(path.join(tmpDir, 'tsconfig.json'), '{}');
|
|
54
|
+
await fsExtra.writeFile(path.join(tmpDir, 'pyproject.toml'), '[project]\nname = "demo"\n');
|
|
55
|
+
await fsExtra.writeFile(
|
|
56
|
+
path.join(tmpDir, 'docker-compose.yml'),
|
|
57
|
+
[
|
|
58
|
+
'services:',
|
|
59
|
+
' api:',
|
|
60
|
+
' image: api:latest',
|
|
61
|
+
' worker:',
|
|
62
|
+
' image: worker:latest',
|
|
63
|
+
].join('\n'),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const detected = await detectProjectFeatures(tmpDir);
|
|
67
|
+
|
|
68
|
+
expect(detected.hasTypeScript).toBe(true);
|
|
69
|
+
expect(detected.hasPython).toBe(true);
|
|
70
|
+
expect(detected.dockerServices.sort()).toEqual(['api', 'worker']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('scaffolds service-registry.json with detected service entries', async () => {
|
|
74
|
+
const { generated, registryPath } = await ensureServiceRegistry(tmpDir, ['api-service']);
|
|
75
|
+
|
|
76
|
+
expect(generated).toBe(true);
|
|
77
|
+
expect(await fsExtra.pathExists(registryPath)).toBe(true);
|
|
78
|
+
|
|
79
|
+
const registry = await fsExtra.readJson(registryPath);
|
|
80
|
+
expect(registry.services['api-service']).toBeDefined();
|
|
81
|
+
expect(registry.services['api-service'].name).toBe('api-service');
|
|
82
|
+
expect(registry.services['api-service'].skill_path).toContain('.claude/skills/api-service/SKILL.md');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not mutate registry when all detected services already exist', async () => {
|
|
86
|
+
const registryPath = path.join(tmpDir, 'service-registry.json');
|
|
87
|
+
await fsExtra.writeJson(registryPath, {
|
|
88
|
+
version: '1.0.0',
|
|
89
|
+
services: {
|
|
90
|
+
api: {
|
|
91
|
+
name: 'api',
|
|
92
|
+
description: 'existing',
|
|
93
|
+
territory: [],
|
|
94
|
+
skill_path: '.claude/skills/api/SKILL.md',
|
|
95
|
+
last_sync: '2026-01-01T00:00:00Z',
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}, { spaces: 2 });
|
|
99
|
+
|
|
100
|
+
const { generated } = await ensureServiceRegistry(tmpDir, ['api']);
|
|
101
|
+
|
|
102
|
+
expect(generated).toBe(false);
|
|
103
|
+
const registry = await fsExtra.readJson(registryPath);
|
|
104
|
+
expect(Object.keys(registry.services)).toEqual(['api']);
|
|
105
|
+
expect(registry.services.api.description).toBe('existing');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('deepMergeHooks', () => {
|
|
110
|
+
it('appends new hook entries without overwriting existing events', () => {
|
|
111
|
+
const existing = {
|
|
112
|
+
hooks: {
|
|
113
|
+
PreToolUse: [{ command: 'echo existing-pre' }],
|
|
114
|
+
CustomEvent: [{ command: 'echo keep-me' }],
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const incoming = {
|
|
119
|
+
hooks: {
|
|
120
|
+
PreToolUse: [{ command: 'echo new-pre' }],
|
|
121
|
+
PostToolUse: [{ command: 'echo new-post' }],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const merged = deepMergeHooks(existing, incoming);
|
|
126
|
+
|
|
127
|
+
expect(merged.hooks.PreToolUse).toEqual([
|
|
128
|
+
{ command: 'echo existing-pre' },
|
|
129
|
+
{ command: 'echo new-pre' },
|
|
130
|
+
]);
|
|
131
|
+
expect(merged.hooks.CustomEvent).toEqual([{ command: 'echo keep-me' }]);
|
|
132
|
+
expect(merged.hooks.PostToolUse).toEqual([{ command: 'echo new-post' }]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('upgrades existing same-command matcher to include incoming Serena tools', () => {
|
|
136
|
+
const existing = {
|
|
137
|
+
hooks: {
|
|
138
|
+
PostToolUse: [{
|
|
139
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
140
|
+
hooks: [{ command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/quality-check.cjs"' }],
|
|
141
|
+
}],
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const incoming = {
|
|
146
|
+
hooks: {
|
|
147
|
+
PostToolUse: [{
|
|
148
|
+
matcher: 'Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
|
|
149
|
+
hooks: [{ command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/quality-check.cjs"' }],
|
|
150
|
+
}],
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const merged = deepMergeHooks(existing, incoming);
|
|
155
|
+
const matcher = merged.hooks.PostToolUse[0].matcher as string;
|
|
156
|
+
expect(matcher).toContain('mcp__serena__rename_symbol');
|
|
157
|
+
expect(matcher).toContain('mcp__serena__replace_symbol_body');
|
|
158
|
+
expect(matcher).toContain('mcp__serena__insert_after_symbol');
|
|
159
|
+
expect(matcher).toContain('mcp__serena__insert_before_symbol');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('upgrades matcher when command path differs but hook script is the same', () => {
|
|
163
|
+
const existing = {
|
|
164
|
+
hooks: {
|
|
165
|
+
PostToolUse: [{
|
|
166
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
167
|
+
hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/hooks/quality-check.py"' }],
|
|
168
|
+
}],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const incoming = {
|
|
173
|
+
hooks: {
|
|
174
|
+
PostToolUse: [{
|
|
175
|
+
matcher: 'Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
|
|
176
|
+
hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/quality-check.py"' }],
|
|
177
|
+
}],
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const merged = deepMergeHooks(existing, incoming);
|
|
182
|
+
expect(merged.hooks.PostToolUse).toHaveLength(1);
|
|
183
|
+
const matcher = merged.hooks.PostToolUse[0].matcher as string;
|
|
184
|
+
expect(matcher).toContain('mcp__serena__rename_symbol');
|
|
185
|
+
expect(matcher).toContain('mcp__serena__replace_symbol_body');
|
|
186
|
+
expect(matcher).toContain('mcp__serena__insert_after_symbol');
|
|
187
|
+
expect(matcher).toContain('mcp__serena__insert_before_symbol');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
describe('upsertManagedBlock', () => {
|
|
193
|
+
it('prepends managed block when no block exists', () => {
|
|
194
|
+
const input = `# Existing
|
|
195
|
+
|
|
196
|
+
content`;
|
|
197
|
+
const result = upsertManagedBlock(input, '# Header');
|
|
198
|
+
|
|
199
|
+
expect(result).toContain('<!-- xtrm:start -->');
|
|
200
|
+
expect(result).toContain('# Header');
|
|
201
|
+
expect(result).toContain('<!-- xtrm:end -->');
|
|
202
|
+
expect(result.endsWith('content')).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('replaces existing managed block in place', () => {
|
|
206
|
+
const input = [
|
|
207
|
+
'<!-- xtrm:start -->',
|
|
208
|
+
'old',
|
|
209
|
+
'<!-- xtrm:end -->',
|
|
210
|
+
'',
|
|
211
|
+
'# Existing',
|
|
212
|
+
].join('\n');
|
|
213
|
+
|
|
214
|
+
const result = upsertManagedBlock(input, '# New Header');
|
|
215
|
+
|
|
216
|
+
expect(result).toContain('# New Header');
|
|
217
|
+
expect(result).not.toContain('\nold\n');
|
|
218
|
+
expect(result).toContain('# Existing');
|
|
219
|
+
expect((result.match(/<!-- xtrm:start -->/g) || []).length).toBe(1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('is idempotent for identical content', () => {
|
|
223
|
+
const input = '# Existing\n';
|
|
224
|
+
const first = upsertManagedBlock(input, '# Header');
|
|
225
|
+
const second = upsertManagedBlock(first, '# Header');
|
|
226
|
+
expect(second).toBe(first);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('extractReadmeDescription', () => {
|
|
231
|
+
it('extracts the first prose line after the title', async () => {
|
|
232
|
+
const readme = await fsExtra.readFile(
|
|
233
|
+
path.join(__dirname, '../../project-skills/quality-gates/README.md'),
|
|
234
|
+
'utf8',
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const description = extractReadmeDescription(readme);
|
|
238
|
+
expect(description).toBeTruthy();
|
|
239
|
+
expect(description).not.toBe('No description available');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('skips badge blocks and finds the first actual description line', async () => {
|
|
243
|
+
const readme = await fsExtra.readFile(
|
|
244
|
+
path.join(__dirname, '../../project-skills/tdd-guard/README.md'),
|
|
245
|
+
'utf8',
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
expect(extractReadmeDescription(readme)).toBe(
|
|
249
|
+
'Automated Test-Driven Development enforcement for Claude Code.',
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('installProjectSkill', () => {
|
|
255
|
+
let tmpDir: string;
|
|
256
|
+
|
|
257
|
+
beforeEach(async () => {
|
|
258
|
+
tmpDir = await mkdtemp(path.join(tmpdir(), 'xtrm-project-skill-'));
|
|
259
|
+
await fsExtra.ensureDir(path.join(tmpDir, '.claude'));
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
afterEach(async () => {
|
|
263
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('copies hook assets required by the installed project skill', async () => {
|
|
267
|
+
await installProjectSkill('tdd-guard', tmpDir);
|
|
268
|
+
|
|
269
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'tdd-guard-pretool-bridge.cjs'))).toBe(true);
|
|
270
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-tdd-guard', 'SKILL.md'))).toBe(true);
|
|
271
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'tdd-guard-readme.md'))).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('merges settings without dropping existing project hooks', async () => {
|
|
275
|
+
await fsExtra.writeJson(
|
|
276
|
+
path.join(tmpDir, '.claude', 'settings.json'),
|
|
277
|
+
{
|
|
278
|
+
hooks: {
|
|
279
|
+
PreToolUse: [{ command: 'echo existing-pre' }],
|
|
280
|
+
CustomEvent: [{ command: 'echo keep-me' }],
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
{ spaces: 2 },
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
await installProjectSkill('tdd-guard', tmpDir);
|
|
287
|
+
|
|
288
|
+
const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
|
|
289
|
+
expect(settings.hooks.PreToolUse).toHaveLength(2);
|
|
290
|
+
expect(settings.hooks.CustomEvent).toEqual([{ command: 'echo keep-me' }]);
|
|
291
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'tdd-guard-pretool-bridge.cjs'))).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('installs Node hook files that execute inside type-module projects', async () => {
|
|
295
|
+
await fsExtra.writeJson(path.join(tmpDir, 'package.json'), {
|
|
296
|
+
name: 'esm-target',
|
|
297
|
+
type: 'module',
|
|
298
|
+
}, { spaces: 2 });
|
|
299
|
+
|
|
300
|
+
await installProjectSkill('tdd-guard', tmpDir);
|
|
301
|
+
|
|
302
|
+
const guardRun = spawnSync(
|
|
303
|
+
'node',
|
|
304
|
+
[path.join(tmpDir, '.claude', 'hooks', 'tdd-guard-pretool-bridge.cjs')],
|
|
305
|
+
{
|
|
306
|
+
cwd: tmpDir,
|
|
307
|
+
input: '{"tool_name":"Edit","tool_input":{"file_path":"/tmp/does-not-exist.ts"}}',
|
|
308
|
+
encoding: 'utf8',
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
expect(guardRun.status).toBe(0);
|
|
312
|
+
|
|
313
|
+
const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
|
|
314
|
+
const preToolUseCommand = settings.hooks.PreToolUse[0].hooks[0].command;
|
|
315
|
+
expect(preToolUseCommand).toContain('tdd-guard-pretool-bridge.cjs');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('installs service-skills git hooks when service-skills-set is installed', async () => {
|
|
319
|
+
await fsExtra.ensureDir(path.join(tmpDir, '.git', 'hooks'));
|
|
320
|
+
await installProjectSkill('service-skills-set', tmpDir);
|
|
321
|
+
|
|
322
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.githooks', 'pre-commit'))).toBe(true);
|
|
323
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.githooks', 'pre-push'))).toBe(true);
|
|
324
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.git', 'hooks', 'pre-commit'))).toBe(true);
|
|
325
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.git', 'hooks', 'pre-push'))).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe('installAllProjectSkills', () => {
|
|
330
|
+
let tmpDir: string;
|
|
331
|
+
|
|
332
|
+
beforeEach(async () => {
|
|
333
|
+
tmpDir = await mkdtemp(path.join(tmpdir(), 'xtrm-project-skill-all-'));
|
|
334
|
+
await fsExtra.ensureDir(path.join(tmpDir, '.claude'));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
afterEach(async () => {
|
|
338
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('getAvailableProjectSkills only returns skills with a .claude directory', async () => {
|
|
342
|
+
const availableSkills = await getAvailableProjectSkills();
|
|
343
|
+
// All returned skills must have a .claude dir (eval-only dirs are excluded)
|
|
344
|
+
for (const skill of availableSkills) {
|
|
345
|
+
expect(availableSkills).toContain(skill);
|
|
346
|
+
}
|
|
347
|
+
expect(availableSkills).toContain('tdd-guard');
|
|
348
|
+
expect(availableSkills).toContain('service-skills-set');
|
|
349
|
+
expect(availableSkills).toContain('quality-gates');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('installs every available project skill with merged hooks and copied assets', async () => {
|
|
353
|
+
const availableSkills = await getAvailableProjectSkills();
|
|
354
|
+
expect(availableSkills).toEqual([
|
|
355
|
+
'quality-gates',
|
|
356
|
+
'service-skills-set',
|
|
357
|
+
'tdd-guard',
|
|
358
|
+
]);
|
|
359
|
+
|
|
360
|
+
await installAllProjectSkills(tmpDir);
|
|
361
|
+
|
|
362
|
+
const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
|
|
363
|
+
// Both tdd-guard and service-skills PreToolUse hooks should coexist.
|
|
364
|
+
expect(settings.hooks.PreToolUse).toHaveLength(2);
|
|
365
|
+
|
|
366
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'quality-check.cjs'))).toBe(true);
|
|
367
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'git-hooks', 'doc_reminder.py'))).toBe(true);
|
|
368
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'service-registry.json'))).toBe(true);
|
|
369
|
+
|
|
370
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-tdd-guard', 'SKILL.md'))).toBe(true);
|
|
371
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-service-skills', 'SKILL.md'))).toBe(true);
|
|
372
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-quality-gates', 'SKILL.md'))).toBe(true);
|
|
373
|
+
|
|
374
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'tdd-guard-readme.md'))).toBe(true);
|
|
375
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'service-skills-set-readme.md'))).toBe(true);
|
|
376
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'quality-gates-readme.md'))).toBe(true);
|
|
377
|
+
});
|
|
378
|
+
});
|