xtrm-cli 2.1.4
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 +55937 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/index.js +151 -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/lib/transform-gemini.js +119 -0
- package/package.json +43 -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/help.ts +171 -0
- package/src/commands/install-project.ts +566 -0
- package/src/commands/install-service-skills.ts +251 -0
- package/src/commands/install.ts +534 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +143 -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/sync-executor.ts +399 -0
- package/src/index.ts +69 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +222 -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 +467 -0
- package/src/utils/theme.ts +37 -0
- package/test/context.test.ts +33 -0
- package/test/hooks.test.ts +277 -0
- package/test/install-project.test.ts +235 -0
- package/test/install-service-skills.test.ts +111 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { spawnSync, execSync } from 'node:child_process';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, chmodSync, rmSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const HOOKS_DIR = path.join(__dirname, '../../hooks');
|
|
10
|
+
|
|
11
|
+
const CURRENT_BRANCH = (() => {
|
|
12
|
+
try {
|
|
13
|
+
return execSync('git branch --show-current', { encoding: 'utf8' }).trim();
|
|
14
|
+
} catch {
|
|
15
|
+
return 'main';
|
|
16
|
+
}
|
|
17
|
+
})();
|
|
18
|
+
|
|
19
|
+
function runHook(
|
|
20
|
+
hookFile: string,
|
|
21
|
+
input: Record<string, unknown>,
|
|
22
|
+
env: Record<string, string> = {},
|
|
23
|
+
) {
|
|
24
|
+
return spawnSync('node', [path.join(HOOKS_DIR, hookFile)], {
|
|
25
|
+
input: JSON.stringify(input),
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
env: { ...process.env, ...env },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseHookJson(stdout: string) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(stdout);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function withFakeBdDir(scriptBody: string) {
|
|
40
|
+
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fakebd-'));
|
|
41
|
+
const fakeBdPath = path.join(tempDir, 'bd');
|
|
42
|
+
writeFileSync(fakeBdPath, scriptBody, { encoding: 'utf8' });
|
|
43
|
+
chmodSync(fakeBdPath, 0o755);
|
|
44
|
+
return { tempDir, fakeBdPath };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES ──────────────────────────
|
|
48
|
+
|
|
49
|
+
describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
50
|
+
it('blocks Write when current branch is listed in MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
51
|
+
// Set env var to the actual current branch — without this env var the
|
|
52
|
+
// hook only checks hardcoded main/master, so a feature branch always exits 0.
|
|
53
|
+
const r = runHook(
|
|
54
|
+
'main-guard.mjs',
|
|
55
|
+
{ tool_name: 'Write', tool_input: { file_path: '/tmp/x' } },
|
|
56
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
57
|
+
);
|
|
58
|
+
expect(r.status).toBe(0);
|
|
59
|
+
const out = parseHookJson(r.stdout);
|
|
60
|
+
expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
61
|
+
expect(out?.systemMessage).toContain(CURRENT_BRANCH);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('allows Write when current branch is NOT in MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
65
|
+
const r = runHook(
|
|
66
|
+
'main-guard.mjs',
|
|
67
|
+
{ tool_name: 'Write', tool_input: { file_path: '/tmp/x' } },
|
|
68
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'nonexistent-branch-xyz' },
|
|
69
|
+
);
|
|
70
|
+
expect(r.status).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('blocks Bash by default on protected branch (default-deny)', () => {
|
|
74
|
+
const r = runHook(
|
|
75
|
+
'main-guard.mjs',
|
|
76
|
+
{ tool_name: 'Bash', tool_input: { command: 'cat > file.txt << EOF\nhello\nEOF' } },
|
|
77
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
78
|
+
);
|
|
79
|
+
expect(r.status).toBe(0);
|
|
80
|
+
const out = parseHookJson(r.stdout);
|
|
81
|
+
expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
82
|
+
expect(out?.systemMessage).toContain('Bash is restricted');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('allows safe Bash commands on protected branch', () => {
|
|
86
|
+
const safeCommands = [
|
|
87
|
+
'git status',
|
|
88
|
+
'git log --oneline -5',
|
|
89
|
+
'git diff HEAD',
|
|
90
|
+
'git checkout -b feature/x',
|
|
91
|
+
'git switch -c feature/y',
|
|
92
|
+
'git fetch origin',
|
|
93
|
+
'git pull',
|
|
94
|
+
'gh pr list',
|
|
95
|
+
'bd list',
|
|
96
|
+
];
|
|
97
|
+
for (const command of safeCommands) {
|
|
98
|
+
const r = runHook(
|
|
99
|
+
'main-guard.mjs',
|
|
100
|
+
{ tool_name: 'Bash', tool_input: { command } },
|
|
101
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
102
|
+
);
|
|
103
|
+
expect(r.status, `expected exit 0 for: ${command}`).toBe(0);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('blocks mutating checkout forms on protected branch', () => {
|
|
108
|
+
const blockedCommands = [
|
|
109
|
+
'git checkout -- README.md',
|
|
110
|
+
'git checkout HEAD -- README.md',
|
|
111
|
+
'git switch --detach HEAD',
|
|
112
|
+
];
|
|
113
|
+
for (const command of blockedCommands) {
|
|
114
|
+
const r = runHook(
|
|
115
|
+
'main-guard.mjs',
|
|
116
|
+
{ tool_name: 'Bash', tool_input: { command } },
|
|
117
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
118
|
+
);
|
|
119
|
+
expect(r.status, `expected structured deny for: ${command}`).toBe(0);
|
|
120
|
+
const out = parseHookJson(r.stdout);
|
|
121
|
+
expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
122
|
+
expect(out?.systemMessage).toContain('Bash is restricted');
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('allows Bash when MAIN_GUARD_ALLOW_BASH=1 is set', () => {
|
|
127
|
+
const r = runHook(
|
|
128
|
+
'main-guard.mjs',
|
|
129
|
+
{ tool_name: 'Bash', tool_input: { command: 'npm run build' } },
|
|
130
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH, MAIN_GUARD_ALLOW_BASH: '1' },
|
|
131
|
+
);
|
|
132
|
+
expect(r.status).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('blocks git commit in Bash with workflow guidance', () => {
|
|
136
|
+
const r = runHook(
|
|
137
|
+
'main-guard.mjs',
|
|
138
|
+
{ tool_name: 'Bash', tool_input: { command: 'git commit -m "oops"' } },
|
|
139
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
140
|
+
);
|
|
141
|
+
expect(r.status).toBe(0);
|
|
142
|
+
const out = parseHookJson(r.stdout);
|
|
143
|
+
expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
144
|
+
expect(out?.systemMessage).toContain('feature branch');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── beads-gate-utils.mjs ─────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
describe('beads-gate-utils.mjs — module integrity', () => {
|
|
151
|
+
it('exports all required symbols without crashing', () => {
|
|
152
|
+
const r = spawnSync('node', ['--input-type=module'], {
|
|
153
|
+
input: `
|
|
154
|
+
import {
|
|
155
|
+
resolveCwd, isBeadsProject, getSessionClaim,
|
|
156
|
+
getTotalWork, getInProgress, clearSessionClaim, withSafeBdContext
|
|
157
|
+
} from '${HOOKS_DIR}/beads-gate-utils.mjs';
|
|
158
|
+
const ok = [resolveCwd, isBeadsProject, getSessionClaim, getTotalWork,
|
|
159
|
+
getInProgress, clearSessionClaim, withSafeBdContext]
|
|
160
|
+
.every(fn => typeof fn === 'function');
|
|
161
|
+
process.exit(ok ? 0 : 1);
|
|
162
|
+
`,
|
|
163
|
+
encoding: 'utf8',
|
|
164
|
+
});
|
|
165
|
+
expect(r.status).toBe(0);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── beads-edit-gate.mjs ───────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
describe('beads-edit-gate.mjs', () => {
|
|
172
|
+
it('fails open (exit 0) when no .beads directory exists', () => {
|
|
173
|
+
const r = runHook('beads-edit-gate.mjs', {
|
|
174
|
+
session_id: 'test-session',
|
|
175
|
+
tool_name: 'Write',
|
|
176
|
+
tool_input: { file_path: '/tmp/x' },
|
|
177
|
+
cwd: '/tmp',
|
|
178
|
+
});
|
|
179
|
+
expect(r.status).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('imports from beads-gate-utils.mjs (no inline duplicate logic)', () => {
|
|
183
|
+
const content = readFileSync(path.join(HOOKS_DIR, 'beads-edit-gate.mjs'), 'utf8');
|
|
184
|
+
expect(content).toContain("from './beads-gate-utils.mjs'");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('blocks when session has no claim but open issues exist (regression guard)', () => {
|
|
188
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-beads-project-'));
|
|
189
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
190
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
191
|
+
set -euo pipefail
|
|
192
|
+
if [[ "$1" == "kv" && "$2" == "get" ]]; then
|
|
193
|
+
exit 1
|
|
194
|
+
fi
|
|
195
|
+
if [[ "$1" == "list" ]]; then
|
|
196
|
+
cat <<'EOF'
|
|
197
|
+
○ issue-1 P2 Open issue
|
|
198
|
+
|
|
199
|
+
--------------------------------------------------------------------------------
|
|
200
|
+
Total: 1 issues (1 open, 0 in progress)
|
|
201
|
+
EOF
|
|
202
|
+
exit 0
|
|
203
|
+
fi
|
|
204
|
+
exit 1
|
|
205
|
+
`);
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const r = runHook(
|
|
209
|
+
'beads-edit-gate.mjs',
|
|
210
|
+
{
|
|
211
|
+
session_id: 'session-regression-test',
|
|
212
|
+
tool_name: 'Write',
|
|
213
|
+
tool_input: { file_path: '/tmp/x' },
|
|
214
|
+
cwd: projectDir,
|
|
215
|
+
},
|
|
216
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
217
|
+
);
|
|
218
|
+
expect(r.status).toBe(2);
|
|
219
|
+
expect(r.stderr).toContain('no active claim');
|
|
220
|
+
} finally {
|
|
221
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
222
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── beads-stop-gate.mjs ───────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe('beads-stop-gate.mjs', () => {
|
|
230
|
+
it('fails open (exit 0) when no .beads directory exists', () => {
|
|
231
|
+
const r = runHook('beads-stop-gate.mjs', { session_id: 'test', cwd: '/tmp' });
|
|
232
|
+
expect(r.status).toBe(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('imports from beads-gate-utils.mjs', () => {
|
|
236
|
+
const content = readFileSync(path.join(HOOKS_DIR, 'beads-stop-gate.mjs'), 'utf8');
|
|
237
|
+
expect(content).toContain("from './beads-gate-utils.mjs'");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── beads-commit-gate.mjs ─────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
describe('beads-commit-gate.mjs', () => {
|
|
244
|
+
it('fails open (exit 0) when no .beads directory exists', () => {
|
|
245
|
+
const r = runHook('beads-commit-gate.mjs', {
|
|
246
|
+
session_id: 'test',
|
|
247
|
+
tool_name: 'Bash',
|
|
248
|
+
tool_input: { command: 'git commit -m test' },
|
|
249
|
+
cwd: '/tmp',
|
|
250
|
+
});
|
|
251
|
+
expect(r.status).toBe(0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('imports from beads-gate-utils.mjs', () => {
|
|
255
|
+
const content = readFileSync(path.join(HOOKS_DIR, 'beads-commit-gate.mjs'), 'utf8');
|
|
256
|
+
expect(content).toContain("from './beads-gate-utils.mjs'");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── beads-close-memory-prompt.mjs ────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
describe('beads-close-memory-prompt.mjs', () => {
|
|
263
|
+
it('exits 0 for non-bd-close bash commands', () => {
|
|
264
|
+
const r = runHook('beads-close-memory-prompt.mjs', {
|
|
265
|
+
session_id: 'test',
|
|
266
|
+
tool_name: 'Bash',
|
|
267
|
+
tool_input: { command: 'git status' },
|
|
268
|
+
cwd: '/tmp',
|
|
269
|
+
});
|
|
270
|
+
expect(r.status).toBe(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('imports from beads-gate-utils.mjs', () => {
|
|
274
|
+
const content = readFileSync(path.join(HOOKS_DIR, 'beads-close-memory-prompt.mjs'), 'utf8');
|
|
275
|
+
expect(content).toContain("from './beads-gate-utils.mjs'");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
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
|
+
extractReadmeDescription,
|
|
12
|
+
getAvailableProjectSkills,
|
|
13
|
+
installAllProjectSkills,
|
|
14
|
+
installProjectSkill,
|
|
15
|
+
} from '../src/commands/install-project.js';
|
|
16
|
+
|
|
17
|
+
describe('buildProjectInitGuide', () => {
|
|
18
|
+
it('includes recommended quality-gate skills and required config checks', () => {
|
|
19
|
+
const guide = buildProjectInitGuide();
|
|
20
|
+
expect(guide).toContain('ts-quality-gate');
|
|
21
|
+
expect(guide).toContain('py-quality-gate');
|
|
22
|
+
expect(guide).toContain('tdd-guard');
|
|
23
|
+
expect(guide.toLowerCase()).toContain('lint');
|
|
24
|
+
expect(guide.toLowerCase()).toContain('mypy');
|
|
25
|
+
expect(guide.toLowerCase()).toContain('service-skills-set');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('createProjectCommand', () => {
|
|
30
|
+
it('exposes init/list/install subcommands', () => {
|
|
31
|
+
const cmd = createProjectCommand();
|
|
32
|
+
const names = cmd.commands.map(c => c.name());
|
|
33
|
+
expect(names).toEqual(expect.arrayContaining(['init', 'list', 'install']));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('deepMergeHooks', () => {
|
|
38
|
+
it('appends new hook entries without overwriting existing events', () => {
|
|
39
|
+
const existing = {
|
|
40
|
+
hooks: {
|
|
41
|
+
PreToolUse: [{ command: 'echo existing-pre' }],
|
|
42
|
+
CustomEvent: [{ command: 'echo keep-me' }],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const incoming = {
|
|
47
|
+
hooks: {
|
|
48
|
+
PreToolUse: [{ command: 'echo new-pre' }],
|
|
49
|
+
PostToolUse: [{ command: 'echo new-post' }],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const merged = deepMergeHooks(existing, incoming);
|
|
54
|
+
|
|
55
|
+
expect(merged.hooks.PreToolUse).toEqual([
|
|
56
|
+
{ command: 'echo existing-pre' },
|
|
57
|
+
{ command: 'echo new-pre' },
|
|
58
|
+
]);
|
|
59
|
+
expect(merged.hooks.CustomEvent).toEqual([{ command: 'echo keep-me' }]);
|
|
60
|
+
expect(merged.hooks.PostToolUse).toEqual([{ command: 'echo new-post' }]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('upgrades existing same-command matcher to include incoming Serena tools', () => {
|
|
64
|
+
const existing = {
|
|
65
|
+
hooks: {
|
|
66
|
+
PostToolUse: [{
|
|
67
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
68
|
+
hooks: [{ command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/quality-check.cjs"' }],
|
|
69
|
+
}],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const incoming = {
|
|
74
|
+
hooks: {
|
|
75
|
+
PostToolUse: [{
|
|
76
|
+
matcher: 'Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
|
|
77
|
+
hooks: [{ command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/quality-check.cjs"' }],
|
|
78
|
+
}],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const merged = deepMergeHooks(existing, incoming);
|
|
83
|
+
const matcher = merged.hooks.PostToolUse[0].matcher as string;
|
|
84
|
+
expect(matcher).toContain('mcp__serena__rename_symbol');
|
|
85
|
+
expect(matcher).toContain('mcp__serena__replace_symbol_body');
|
|
86
|
+
expect(matcher).toContain('mcp__serena__insert_after_symbol');
|
|
87
|
+
expect(matcher).toContain('mcp__serena__insert_before_symbol');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('extractReadmeDescription', () => {
|
|
92
|
+
it('extracts the first prose line after the title', async () => {
|
|
93
|
+
const readme = await fsExtra.readFile(
|
|
94
|
+
path.join(__dirname, '../../project-skills/py-quality-gate/README.md'),
|
|
95
|
+
'utf8',
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(extractReadmeDescription(readme)).toBe(
|
|
99
|
+
'Python quality gate for Claude Code. Runs ruff (linting/formatting) and mypy (type checking) automatically on every file edit.',
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('skips badge blocks and finds the first actual description line', async () => {
|
|
104
|
+
const readme = await fsExtra.readFile(
|
|
105
|
+
path.join(__dirname, '../../project-skills/tdd-guard/README.md'),
|
|
106
|
+
'utf8',
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(extractReadmeDescription(readme)).toBe(
|
|
110
|
+
'Automated Test-Driven Development enforcement for Claude Code.',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('installProjectSkill', () => {
|
|
116
|
+
let tmpDir: string;
|
|
117
|
+
|
|
118
|
+
beforeEach(async () => {
|
|
119
|
+
tmpDir = await mkdtemp(path.join(tmpdir(), 'xtrm-project-skill-'));
|
|
120
|
+
await fsExtra.ensureDir(path.join(tmpDir, '.claude'));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(async () => {
|
|
124
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('copies hook assets required by the installed project skill', async () => {
|
|
128
|
+
await installProjectSkill('ts-quality-gate', tmpDir);
|
|
129
|
+
|
|
130
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'quality-check.cjs'))).toBe(true);
|
|
131
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'hook-config.json'))).toBe(true);
|
|
132
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-ts-quality-gate', 'SKILL.md'))).toBe(true);
|
|
133
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'ts-quality-gate-readme.md'))).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('merges settings without dropping existing project hooks', async () => {
|
|
137
|
+
await fsExtra.writeJson(
|
|
138
|
+
path.join(tmpDir, '.claude', 'settings.json'),
|
|
139
|
+
{
|
|
140
|
+
hooks: {
|
|
141
|
+
PreToolUse: [{ command: 'echo existing-pre' }],
|
|
142
|
+
CustomEvent: [{ command: 'echo keep-me' }],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{ spaces: 2 },
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await installProjectSkill('tdd-guard', tmpDir);
|
|
149
|
+
|
|
150
|
+
const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
|
|
151
|
+
expect(settings.hooks.PreToolUse).toHaveLength(2);
|
|
152
|
+
expect(settings.hooks.CustomEvent).toEqual([{ command: 'echo keep-me' }]);
|
|
153
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'tdd-guard-pretool-bridge.cjs'))).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('installs Node hook files that execute inside type-module projects', async () => {
|
|
157
|
+
await fsExtra.writeJson(path.join(tmpDir, 'package.json'), {
|
|
158
|
+
name: 'esm-target',
|
|
159
|
+
type: 'module',
|
|
160
|
+
}, { spaces: 2 });
|
|
161
|
+
|
|
162
|
+
await installProjectSkill('ts-quality-gate', tmpDir);
|
|
163
|
+
|
|
164
|
+
const tsRun = spawnSync(
|
|
165
|
+
'node',
|
|
166
|
+
[path.join(tmpDir, '.claude', 'hooks', 'quality-check.cjs')],
|
|
167
|
+
{
|
|
168
|
+
cwd: tmpDir,
|
|
169
|
+
input: '{"tool_name":"Edit","tool_input":{"file_path":"/tmp/does-not-exist.ts"}}',
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
expect(tsRun.status).toBe(0);
|
|
174
|
+
|
|
175
|
+
const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
|
|
176
|
+
const postToolUseCommand = settings.hooks.PostToolUse[0].hooks[0].command;
|
|
177
|
+
expect(postToolUseCommand).toContain('quality-check.cjs');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('installs service-skills git hooks when service-skills-set is installed', async () => {
|
|
181
|
+
await fsExtra.ensureDir(path.join(tmpDir, '.git', 'hooks'));
|
|
182
|
+
await installProjectSkill('service-skills-set', tmpDir);
|
|
183
|
+
|
|
184
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.githooks', 'pre-commit'))).toBe(true);
|
|
185
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.githooks', 'pre-push'))).toBe(true);
|
|
186
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.git', 'hooks', 'pre-commit'))).toBe(true);
|
|
187
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.git', 'hooks', 'pre-push'))).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('installAllProjectSkills', () => {
|
|
192
|
+
let tmpDir: string;
|
|
193
|
+
|
|
194
|
+
beforeEach(async () => {
|
|
195
|
+
tmpDir = await mkdtemp(path.join(tmpdir(), 'xtrm-project-skill-all-'));
|
|
196
|
+
await fsExtra.ensureDir(path.join(tmpDir, '.claude'));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
afterEach(async () => {
|
|
200
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('installs every available project skill with merged hooks and copied assets', async () => {
|
|
204
|
+
const availableSkills = await getAvailableProjectSkills();
|
|
205
|
+
expect(availableSkills).toEqual([
|
|
206
|
+
'py-quality-gate',
|
|
207
|
+
'service-skills-set',
|
|
208
|
+
'tdd-guard',
|
|
209
|
+
'ts-quality-gate',
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
await installAllProjectSkills(tmpDir);
|
|
213
|
+
|
|
214
|
+
const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
|
|
215
|
+
expect(settings.hooks.SessionStart).toHaveLength(2);
|
|
216
|
+
expect(settings.hooks.PreToolUse).toHaveLength(2);
|
|
217
|
+
expect(settings.hooks.PostToolUse).toHaveLength(3);
|
|
218
|
+
expect(settings.hooks.UserPromptSubmit).toHaveLength(1);
|
|
219
|
+
|
|
220
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'quality-check.cjs'))).toBe(true);
|
|
221
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'quality-check.py'))).toBe(true);
|
|
222
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'git-hooks', 'doc_reminder.py'))).toBe(true);
|
|
223
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'service-registry.json'))).toBe(true);
|
|
224
|
+
|
|
225
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-tdd-guard', 'SKILL.md'))).toBe(true);
|
|
226
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-ts-quality-gate', 'SKILL.md'))).toBe(true);
|
|
227
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-py-quality-gate', 'SKILL.md'))).toBe(true);
|
|
228
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-service-skills', 'SKILL.md'))).toBe(true);
|
|
229
|
+
|
|
230
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'tdd-guard-readme.md'))).toBe(true);
|
|
231
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'ts-quality-gate-readme.md'))).toBe(true);
|
|
232
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'py-quality-gate-readme.md'))).toBe(true);
|
|
233
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'service-skills-set-readme.md'))).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fsExtra from 'fs-extra';
|
|
6
|
+
import { mergeSettingsHooks, installSkills, installGitHooks } from '../src/commands/install-service-skills.js';
|
|
7
|
+
|
|
8
|
+
// __dirname in vitest context = cli/test/
|
|
9
|
+
const REPO_ROOT = path.resolve(__dirname, '../..');
|
|
10
|
+
const ACTUAL_SKILLS_SRC = path.join(REPO_ROOT, 'project-skills', 'service-skills-set', '.claude', 'skills');
|
|
11
|
+
const ACTUAL_CLAUDE_SRC = path.join(REPO_ROOT, 'project-skills', 'service-skills-set', '.claude');
|
|
12
|
+
|
|
13
|
+
describe('mergeSettingsHooks', () => {
|
|
14
|
+
it('adds all three hooks to empty settings', () => {
|
|
15
|
+
const { result, added, skipped } = mergeSettingsHooks({});
|
|
16
|
+
const hooks = result.hooks as Record<string, unknown>;
|
|
17
|
+
expect(added).toEqual(['SessionStart', 'PreToolUse', 'PostToolUse']);
|
|
18
|
+
expect(skipped).toEqual([]);
|
|
19
|
+
expect(hooks).toHaveProperty('SessionStart');
|
|
20
|
+
expect(hooks).toHaveProperty('PreToolUse');
|
|
21
|
+
expect(hooks).toHaveProperty('PostToolUse');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('preserves existing keys and skips them', () => {
|
|
25
|
+
const existing = { hooks: { SessionStart: [{ custom: true }] } };
|
|
26
|
+
const { result, added, skipped } = mergeSettingsHooks(existing);
|
|
27
|
+
const hooks = result.hooks as Record<string, unknown>;
|
|
28
|
+
expect(skipped).toEqual(['SessionStart']);
|
|
29
|
+
expect(added).toEqual(['PreToolUse', 'PostToolUse']);
|
|
30
|
+
expect(hooks.SessionStart).toEqual([{ custom: true }]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('preserves non-hook keys in settings', () => {
|
|
34
|
+
const existing = { apiKey: 'abc', permissions: { allow: [] } };
|
|
35
|
+
const { result } = mergeSettingsHooks(existing);
|
|
36
|
+
expect(result.apiKey).toBe('abc');
|
|
37
|
+
expect(result.permissions).toEqual({ allow: [] });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('installSkills', () => {
|
|
42
|
+
let tmpDir: string;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
tmpDir = await mkdtemp(path.join(tmpdir(), 'jaggers-test-'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('creates .claude/skills/<skill> directories', async () => {
|
|
53
|
+
await installSkills(tmpDir, ACTUAL_SKILLS_SRC);
|
|
54
|
+
for (const skill of ['creating-service-skills', 'using-service-skills', 'updating-service-skills', 'scoping-service-skills']) {
|
|
55
|
+
const dest = path.join(tmpDir, '.claude', 'skills', skill);
|
|
56
|
+
expect(await fsExtra.pathExists(dest)).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('is idempotent (safe to run twice)', async () => {
|
|
61
|
+
await installSkills(tmpDir, ACTUAL_SKILLS_SRC);
|
|
62
|
+
await expect(installSkills(tmpDir, ACTUAL_SKILLS_SRC)).resolves.not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('installGitHooks', () => {
|
|
67
|
+
let tmpDir: string;
|
|
68
|
+
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
tmpDir = await mkdtemp(path.join(tmpdir(), 'jaggers-test-'));
|
|
71
|
+
await fsExtra.mkdirp(path.join(tmpDir, '.git', 'hooks'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('creates .githooks/pre-commit with doc-reminder snippet', async () => {
|
|
79
|
+
await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
|
|
80
|
+
const content = await fsExtra.readFile(path.join(tmpDir, '.githooks', 'pre-commit'), 'utf8');
|
|
81
|
+
expect(content).toContain('# [jaggers] doc-reminder');
|
|
82
|
+
expect(content).toContain('.claude/git-hooks/doc_reminder.py');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('creates .githooks/pre-push with skill-staleness snippet', async () => {
|
|
86
|
+
await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
|
|
87
|
+
const content = await fsExtra.readFile(path.join(tmpDir, '.githooks', 'pre-push'), 'utf8');
|
|
88
|
+
expect(content).toContain('# [jaggers] skill-staleness');
|
|
89
|
+
expect(content).toContain('.claude/git-hooks/skill_staleness.py');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('copies hook scripts into .claude/git-hooks/', async () => {
|
|
93
|
+
await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
|
|
94
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'git-hooks', 'doc_reminder.py'))).toBe(true);
|
|
95
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'git-hooks', 'skill_staleness.py'))).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('activates hooks in .git/hooks/', async () => {
|
|
99
|
+
await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
|
|
100
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.git', 'hooks', 'pre-commit'))).toBe(true);
|
|
101
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.git', 'hooks', 'pre-push'))).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('is idempotent — does not duplicate snippets on re-run', async () => {
|
|
105
|
+
await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
|
|
106
|
+
await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
|
|
107
|
+
const content = await fsExtra.readFile(path.join(tmpDir, '.githooks', 'pre-commit'), 'utf8');
|
|
108
|
+
const count = (content.match(/# \[jaggers\] doc-reminder/g) ?? []).length;
|
|
109
|
+
expect(count).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"sourceMap": true
|
|
13
|
+
},
|
|
14
|
+
"include": [
|
|
15
|
+
"src/**/*"
|
|
16
|
+
],
|
|
17
|
+
"exclude": [
|
|
18
|
+
"node_modules",
|
|
19
|
+
"dist",
|
|
20
|
+
"test"
|
|
21
|
+
]
|
|
22
|
+
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ['src/index.ts'],
|
|
5
|
+
format: ['cjs'],
|
|
6
|
+
target: 'node18',
|
|
7
|
+
clean: true,
|
|
8
|
+
dts: true,
|
|
9
|
+
sourcemap: true,
|
|
10
|
+
outDir: 'dist',
|
|
11
|
+
banner: { js: '#!/usr/bin/env node' },
|
|
12
|
+
// Bundle ALL dependencies inline so dist/index.cjs is self-contained.
|
|
13
|
+
// Required for npx-from-git to work (cli/node_modules won't exist).
|
|
14
|
+
noExternal: [/.*/],
|
|
15
|
+
splitting: false,
|
|
16
|
+
});
|
|
17
|
+
|
package/vitest.config.ts
ADDED