@rigour-labs/cli 3.0.0 → 3.0.2
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/cli.js +34 -2
- package/dist/commands/demo.d.ts +15 -8
- package/dist/commands/demo.js +470 -158
- package/dist/commands/demo.test.d.ts +1 -0
- package/dist/commands/demo.test.js +59 -0
- package/dist/commands/hooks.d.ts +22 -0
- package/dist/commands/hooks.js +274 -0
- package/dist/commands/hooks.test.d.ts +1 -0
- package/dist/commands/hooks.test.js +77 -0
- package/dist/commands/init.js +25 -1
- package/dist/commands/init.test.d.ts +1 -0
- package/dist/commands/init.test.js +97 -0
- package/package.json +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the demo command — all modes: default, --hooks, --cinematic.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { demoCommand } from './demo.js';
|
|
6
|
+
describe('demoCommand', () => {
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
let consoleSpy;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
11
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
12
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
it('should run default demo without errors', async () => {
|
|
18
|
+
await expect(demoCommand({})).resolves.not.toThrow();
|
|
19
|
+
}, 30_000);
|
|
20
|
+
it('should run with --hooks flag', async () => {
|
|
21
|
+
await expect(demoCommand({ hooks: true, speed: 'fast' })).resolves.not.toThrow();
|
|
22
|
+
// Should show hooks simulation
|
|
23
|
+
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
24
|
+
expect(allOutput).toContain('hooks');
|
|
25
|
+
}, 30_000);
|
|
26
|
+
it('should run with --cinematic flag at fast speed', async () => {
|
|
27
|
+
await expect(demoCommand({ cinematic: true, speed: 'fast' })).resolves.not.toThrow();
|
|
28
|
+
// Cinematic mode should show the before/after flow
|
|
29
|
+
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
30
|
+
expect(allOutput).toContain('fix');
|
|
31
|
+
}, 60_000);
|
|
32
|
+
it('should produce FAIL status on demo project', async () => {
|
|
33
|
+
await demoCommand({ speed: 'fast' });
|
|
34
|
+
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
35
|
+
expect(allOutput).toContain('FAIL');
|
|
36
|
+
}, 30_000);
|
|
37
|
+
it('should generate fix packet and audit report', async () => {
|
|
38
|
+
await demoCommand({ speed: 'fast' });
|
|
39
|
+
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
40
|
+
expect(allOutput).toContain('Fix packet generated');
|
|
41
|
+
expect(allOutput).toContain('Audit report exported');
|
|
42
|
+
}, 30_000);
|
|
43
|
+
it('should show score bars in output', async () => {
|
|
44
|
+
await demoCommand({ speed: 'fast' });
|
|
45
|
+
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
46
|
+
// Score bars use block characters
|
|
47
|
+
expect(allOutput).toContain('/100');
|
|
48
|
+
}, 30_000);
|
|
49
|
+
it('should show whitepaper link in cinematic closing', async () => {
|
|
50
|
+
await demoCommand({ cinematic: true, speed: 'fast' });
|
|
51
|
+
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
52
|
+
expect(allOutput).toContain('zenodo.org');
|
|
53
|
+
}, 60_000);
|
|
54
|
+
it('should show hooks init command in closing', async () => {
|
|
55
|
+
await demoCommand({ speed: 'fast' });
|
|
56
|
+
const allOutput = consoleSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
57
|
+
expect(allOutput).toContain('hooks init');
|
|
58
|
+
}, 30_000);
|
|
59
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rigour hooks init` — Generate tool-specific hook configurations.
|
|
3
|
+
*
|
|
4
|
+
* Detects which AI coding tools are present (or accepts --tool flag)
|
|
5
|
+
* and generates the appropriate hook files so that Rigour runs
|
|
6
|
+
* quality checks after every file write/edit.
|
|
7
|
+
*
|
|
8
|
+
* Supported tools:
|
|
9
|
+
* - Claude Code (.claude/settings.json PostToolUse)
|
|
10
|
+
* - Cursor (.cursor/hooks.json afterFileEdit)
|
|
11
|
+
* - Cline (.clinerules/hooks/PostToolUse)
|
|
12
|
+
* - Windsurf (.windsurf/hooks.json post_write_code)
|
|
13
|
+
*
|
|
14
|
+
* @since v3.0.0
|
|
15
|
+
*/
|
|
16
|
+
export interface HooksOptions {
|
|
17
|
+
tool?: string;
|
|
18
|
+
dryRun?: boolean;
|
|
19
|
+
force?: boolean;
|
|
20
|
+
block?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function hooksInitCommand(cwd: string, options?: HooksOptions): Promise<void>;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rigour hooks init` — Generate tool-specific hook configurations.
|
|
3
|
+
*
|
|
4
|
+
* Detects which AI coding tools are present (or accepts --tool flag)
|
|
5
|
+
* and generates the appropriate hook files so that Rigour runs
|
|
6
|
+
* quality checks after every file write/edit.
|
|
7
|
+
*
|
|
8
|
+
* Supported tools:
|
|
9
|
+
* - Claude Code (.claude/settings.json PostToolUse)
|
|
10
|
+
* - Cursor (.cursor/hooks.json afterFileEdit)
|
|
11
|
+
* - Cline (.clinerules/hooks/PostToolUse)
|
|
12
|
+
* - Windsurf (.windsurf/hooks.json post_write_code)
|
|
13
|
+
*
|
|
14
|
+
* @since v3.0.0
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'fs-extra';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import chalk from 'chalk';
|
|
19
|
+
import { randomUUID } from 'crypto';
|
|
20
|
+
// ── Studio event logging ─────────────────────────────────────────────
|
|
21
|
+
async function logStudioEvent(cwd, event) {
|
|
22
|
+
try {
|
|
23
|
+
const rigourDir = path.join(cwd, '.rigour');
|
|
24
|
+
await fs.ensureDir(rigourDir);
|
|
25
|
+
const eventsPath = path.join(rigourDir, 'events.jsonl');
|
|
26
|
+
const logEntry = JSON.stringify({
|
|
27
|
+
id: randomUUID(),
|
|
28
|
+
timestamp: new Date().toISOString(),
|
|
29
|
+
...event,
|
|
30
|
+
}) + '\n';
|
|
31
|
+
await fs.appendFile(eventsPath, logEntry);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Silent fail
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// ── Tool detection ───────────────────────────────────────────────────
|
|
38
|
+
const TOOL_MARKERS = {
|
|
39
|
+
claude: ['CLAUDE.md', '.claude'],
|
|
40
|
+
cursor: ['.cursor', '.cursorrules'],
|
|
41
|
+
cline: ['.clinerules'],
|
|
42
|
+
windsurf: ['.windsurfrules', '.windsurf'],
|
|
43
|
+
};
|
|
44
|
+
function detectTools(cwd) {
|
|
45
|
+
const detected = [];
|
|
46
|
+
for (const [tool, markers] of Object.entries(TOOL_MARKERS)) {
|
|
47
|
+
for (const marker of markers) {
|
|
48
|
+
if (fs.existsSync(path.join(cwd, marker))) {
|
|
49
|
+
detected.push(tool);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return detected;
|
|
55
|
+
}
|
|
56
|
+
function resolveCheckerPath(cwd) {
|
|
57
|
+
const localPath = path.join(cwd, 'node_modules', '@rigour-labs', 'core', 'dist', 'hooks', 'standalone-checker.js');
|
|
58
|
+
if (fs.existsSync(localPath)) {
|
|
59
|
+
return localPath;
|
|
60
|
+
}
|
|
61
|
+
return 'npx rigour-hook-check';
|
|
62
|
+
}
|
|
63
|
+
// ── Tool resolution (from --tool flag or auto-detect) ────────────────
|
|
64
|
+
const ALL_TOOLS = ['claude', 'cursor', 'cline', 'windsurf'];
|
|
65
|
+
function resolveTools(cwd, toolFlag) {
|
|
66
|
+
if (toolFlag === 'all') {
|
|
67
|
+
return ALL_TOOLS;
|
|
68
|
+
}
|
|
69
|
+
if (toolFlag) {
|
|
70
|
+
const requested = toolFlag.split(',').map(t => t.trim().toLowerCase());
|
|
71
|
+
const valid = requested.filter(t => ALL_TOOLS.includes(t));
|
|
72
|
+
if (valid.length === 0) {
|
|
73
|
+
console.error(chalk.red(`Unknown tool: ${toolFlag}. Valid: claude, cursor, cline, windsurf, all`));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
return valid;
|
|
77
|
+
}
|
|
78
|
+
// Auto-detect
|
|
79
|
+
const detected = detectTools(cwd);
|
|
80
|
+
if (detected.length === 0) {
|
|
81
|
+
console.log(chalk.yellow('No AI coding tools detected. Defaulting to Claude Code.'));
|
|
82
|
+
console.log(chalk.dim(' Use --tool <name> to specify: claude, cursor, cline, windsurf, all\n'));
|
|
83
|
+
return ['claude'];
|
|
84
|
+
}
|
|
85
|
+
console.log(chalk.green(`Detected tools: ${detected.join(', ')}`));
|
|
86
|
+
return detected;
|
|
87
|
+
}
|
|
88
|
+
// ── Per-tool hook generators ─────────────────────────────────────────
|
|
89
|
+
function generateClaudeHooks(checkerPath, block) {
|
|
90
|
+
const blockFlag = block ? ' --block' : '';
|
|
91
|
+
const settings = {
|
|
92
|
+
hooks: {
|
|
93
|
+
PostToolUse: [{
|
|
94
|
+
matcher: "Write|Edit|MultiEdit",
|
|
95
|
+
hooks: [{
|
|
96
|
+
type: "command",
|
|
97
|
+
command: `node ${checkerPath} --files "$TOOL_INPUT_file_path"${blockFlag}`,
|
|
98
|
+
}]
|
|
99
|
+
}]
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
return [{
|
|
103
|
+
path: '.claude/settings.json',
|
|
104
|
+
content: JSON.stringify(settings, null, 4),
|
|
105
|
+
description: 'Claude Code PostToolUse hook',
|
|
106
|
+
}];
|
|
107
|
+
}
|
|
108
|
+
function generateCursorHooks(checkerPath, _block) {
|
|
109
|
+
const hooks = {
|
|
110
|
+
version: 1,
|
|
111
|
+
hooks: { afterFileEdit: [{ command: `node ${checkerPath} --stdin` }] }
|
|
112
|
+
};
|
|
113
|
+
return [{
|
|
114
|
+
path: '.cursor/hooks.json',
|
|
115
|
+
content: JSON.stringify(hooks, null, 4),
|
|
116
|
+
description: 'Cursor afterFileEdit hook config',
|
|
117
|
+
}];
|
|
118
|
+
}
|
|
119
|
+
function generateClineHooks(checkerPath, _block) {
|
|
120
|
+
const script = buildClineScript(checkerPath);
|
|
121
|
+
return [{
|
|
122
|
+
path: '.clinerules/hooks/PostToolUse',
|
|
123
|
+
content: script,
|
|
124
|
+
executable: true,
|
|
125
|
+
description: 'Cline PostToolUse executable hook',
|
|
126
|
+
}];
|
|
127
|
+
}
|
|
128
|
+
function buildClineScript(checkerPath) {
|
|
129
|
+
return `#!/usr/bin/env node
|
|
130
|
+
/**
|
|
131
|
+
* Cline PostToolUse hook for Rigour.
|
|
132
|
+
* Receives JSON on stdin with { toolName, toolInput }.
|
|
133
|
+
*/
|
|
134
|
+
const WRITE_TOOLS = ['write_to_file', 'replace_in_file'];
|
|
135
|
+
|
|
136
|
+
let data = '';
|
|
137
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
138
|
+
process.stdin.on('end', async () => {
|
|
139
|
+
try {
|
|
140
|
+
const payload = JSON.parse(data);
|
|
141
|
+
if (!WRITE_TOOLS.includes(payload.toolName)) {
|
|
142
|
+
process.stdout.write(JSON.stringify({}));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const filePath = payload.toolInput?.path || payload.toolInput?.file_path;
|
|
146
|
+
if (!filePath) {
|
|
147
|
+
process.stdout.write(JSON.stringify({}));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { execSync } = require('child_process');
|
|
152
|
+
const raw = execSync(
|
|
153
|
+
\`node ${checkerPath} --files "\${filePath}"\`,
|
|
154
|
+
{ encoding: 'utf-8', timeout: 5000 }
|
|
155
|
+
);
|
|
156
|
+
const result = JSON.parse(raw);
|
|
157
|
+
if (result.status === 'fail') {
|
|
158
|
+
const msgs = result.failures
|
|
159
|
+
.map(f => \`[rigour/\${f.gate}] \${f.file}: \${f.message}\`)
|
|
160
|
+
.join('\\n');
|
|
161
|
+
process.stdout.write(JSON.stringify({
|
|
162
|
+
contextModification: \`\\n[Rigour] \${result.failures.length} issue(s):\\n\${msgs}\\nPlease fix before continuing.\`,
|
|
163
|
+
}));
|
|
164
|
+
} else {
|
|
165
|
+
process.stdout.write(JSON.stringify({}));
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
process.stderr.write(\`Rigour hook error: \${err.message}\\n\`);
|
|
169
|
+
process.stdout.write(JSON.stringify({}));
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
function generateWindsurfHooks(checkerPath, _block) {
|
|
175
|
+
const hooks = {
|
|
176
|
+
version: 1,
|
|
177
|
+
hooks: { post_write_code: [{ command: `node ${checkerPath} --stdin` }] }
|
|
178
|
+
};
|
|
179
|
+
return [{
|
|
180
|
+
path: '.windsurf/hooks.json',
|
|
181
|
+
content: JSON.stringify(hooks, null, 4),
|
|
182
|
+
description: 'Windsurf post_write_code hook config',
|
|
183
|
+
}];
|
|
184
|
+
}
|
|
185
|
+
const GENERATORS = {
|
|
186
|
+
claude: generateClaudeHooks,
|
|
187
|
+
cursor: generateCursorHooks,
|
|
188
|
+
cline: generateClineHooks,
|
|
189
|
+
windsurf: generateWindsurfHooks,
|
|
190
|
+
};
|
|
191
|
+
// ── File writing ─────────────────────────────────────────────────────
|
|
192
|
+
function printDryRun(files) {
|
|
193
|
+
console.log(chalk.cyan('\nDry run — files that would be created:\n'));
|
|
194
|
+
for (const file of files) {
|
|
195
|
+
console.log(chalk.bold(` ${file.path}`));
|
|
196
|
+
console.log(chalk.dim(` ${file.description}`));
|
|
197
|
+
if (file.executable) {
|
|
198
|
+
console.log(chalk.dim(' (executable)'));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
console.log('');
|
|
202
|
+
}
|
|
203
|
+
async function writeHookFiles(cwd, files, force) {
|
|
204
|
+
let written = 0;
|
|
205
|
+
let skipped = 0;
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
const fullPath = path.join(cwd, file.path);
|
|
208
|
+
const exists = await fs.pathExists(fullPath);
|
|
209
|
+
if (exists && !force) {
|
|
210
|
+
console.log(chalk.yellow(` SKIP ${file.path} (already exists, use --force to overwrite)`));
|
|
211
|
+
skipped++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
await fs.ensureDir(path.dirname(fullPath));
|
|
215
|
+
await fs.writeFile(fullPath, file.content, 'utf-8');
|
|
216
|
+
if (file.executable) {
|
|
217
|
+
await fs.chmod(fullPath, 0o755);
|
|
218
|
+
}
|
|
219
|
+
console.log(chalk.green(` CREATE ${file.path}`));
|
|
220
|
+
console.log(chalk.dim(` ${file.description}`));
|
|
221
|
+
written++;
|
|
222
|
+
}
|
|
223
|
+
return { written, skipped };
|
|
224
|
+
}
|
|
225
|
+
// ── Next-steps guidance ──────────────────────────────────────────────
|
|
226
|
+
const NEXT_STEPS = {
|
|
227
|
+
claude: 'Claude Code: Hooks are active immediately. Rigour runs after every Write/Edit.',
|
|
228
|
+
cursor: 'Cursor: Reload window (Cmd+Shift+P > Reload). Check Output > Hooks panel for logs.',
|
|
229
|
+
cline: 'Cline: Hook is active. Quality feedback appears in agent context on violations.',
|
|
230
|
+
windsurf: 'Windsurf: Reload editor. Check terminal for Rigour output after Cascade writes.',
|
|
231
|
+
};
|
|
232
|
+
function printNextSteps(tools) {
|
|
233
|
+
console.log(chalk.cyan('\nNext steps:'));
|
|
234
|
+
for (const tool of tools) {
|
|
235
|
+
console.log(chalk.dim(` ${NEXT_STEPS[tool]}`));
|
|
236
|
+
}
|
|
237
|
+
console.log('');
|
|
238
|
+
}
|
|
239
|
+
// ── Main command entry point ─────────────────────────────────────────
|
|
240
|
+
export async function hooksInitCommand(cwd, options = {}) {
|
|
241
|
+
console.log(chalk.blue('\nRigour Hooks Setup\n'));
|
|
242
|
+
await logStudioEvent(cwd, {
|
|
243
|
+
type: 'tool_call',
|
|
244
|
+
tool: 'rigour_hooks_init',
|
|
245
|
+
arguments: { tool: options.tool, dryRun: options.dryRun },
|
|
246
|
+
});
|
|
247
|
+
const tools = resolveTools(cwd, options.tool);
|
|
248
|
+
const checkerPath = resolveCheckerPath(cwd);
|
|
249
|
+
const block = !!options.block;
|
|
250
|
+
// Collect generated files from all tools
|
|
251
|
+
const allFiles = [];
|
|
252
|
+
for (const tool of tools) {
|
|
253
|
+
allFiles.push(...GENERATORS[tool](checkerPath, block));
|
|
254
|
+
}
|
|
255
|
+
if (options.dryRun) {
|
|
256
|
+
printDryRun(allFiles);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const { written, skipped } = await writeHookFiles(cwd, allFiles, !!options.force);
|
|
260
|
+
console.log('');
|
|
261
|
+
if (written > 0) {
|
|
262
|
+
console.log(chalk.green.bold(`Created ${written} hook file(s).`));
|
|
263
|
+
}
|
|
264
|
+
if (skipped > 0) {
|
|
265
|
+
console.log(chalk.yellow(`Skipped ${skipped} existing file(s).`));
|
|
266
|
+
}
|
|
267
|
+
printNextSteps(tools);
|
|
268
|
+
await logStudioEvent(cwd, {
|
|
269
|
+
type: 'tool_response',
|
|
270
|
+
tool: 'rigour_hooks_init',
|
|
271
|
+
status: 'success',
|
|
272
|
+
content: [{ type: 'text', text: `Generated hooks for: ${tools.join(', ')}` }],
|
|
273
|
+
});
|
|
274
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hooks init command.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { hooksInitCommand } from './hooks.js';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import yaml from 'yaml';
|
|
10
|
+
describe('hooksInitCommand', () => {
|
|
11
|
+
let testDir;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-test-'));
|
|
14
|
+
// Write minimal rigour.yml
|
|
15
|
+
fs.writeFileSync(path.join(testDir, 'rigour.yml'), yaml.stringify({
|
|
16
|
+
version: 1,
|
|
17
|
+
gates: { max_file_lines: 500 },
|
|
18
|
+
}));
|
|
19
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
20
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
it('should generate Claude hooks', async () => {
|
|
27
|
+
await hooksInitCommand(testDir, { tool: 'claude' });
|
|
28
|
+
const settingsPath = path.join(testDir, '.claude', 'settings.json');
|
|
29
|
+
expect(fs.existsSync(settingsPath)).toBe(true);
|
|
30
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
31
|
+
expect(settings.hooks).toBeDefined();
|
|
32
|
+
expect(settings.hooks.PostToolUse).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
it('should generate Cursor hooks', async () => {
|
|
35
|
+
await hooksInitCommand(testDir, { tool: 'cursor' });
|
|
36
|
+
const hooksPath = path.join(testDir, '.cursor', 'hooks.json');
|
|
37
|
+
expect(fs.existsSync(hooksPath)).toBe(true);
|
|
38
|
+
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
|
|
39
|
+
expect(hooks.hooks).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
it('should generate Cline hooks', async () => {
|
|
42
|
+
await hooksInitCommand(testDir, { tool: 'cline' });
|
|
43
|
+
const hookPath = path.join(testDir, '.clinerules', 'hooks', 'PostToolUse');
|
|
44
|
+
expect(fs.existsSync(hookPath)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('should generate Windsurf hooks', async () => {
|
|
47
|
+
await hooksInitCommand(testDir, { tool: 'windsurf' });
|
|
48
|
+
const hooksPath = path.join(testDir, '.windsurf', 'hooks.json');
|
|
49
|
+
expect(fs.existsSync(hooksPath)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it('should support dry-run mode', async () => {
|
|
52
|
+
await hooksInitCommand(testDir, { tool: 'claude', dryRun: true });
|
|
53
|
+
// Dry run should NOT create files
|
|
54
|
+
const settingsPath = path.join(testDir, '.claude', 'settings.json');
|
|
55
|
+
expect(fs.existsSync(settingsPath)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
it('should not overwrite without --force', async () => {
|
|
58
|
+
// Create existing file
|
|
59
|
+
const claudeDir = path.join(testDir, '.claude');
|
|
60
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
61
|
+
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{"existing": true}');
|
|
62
|
+
await hooksInitCommand(testDir, { tool: 'claude' });
|
|
63
|
+
// Should keep existing content
|
|
64
|
+
const content = fs.readFileSync(path.join(claudeDir, 'settings.json'), 'utf-8');
|
|
65
|
+
expect(content).toContain('existing');
|
|
66
|
+
});
|
|
67
|
+
it('should overwrite with --force', async () => {
|
|
68
|
+
// Create existing file
|
|
69
|
+
const claudeDir = path.join(testDir, '.claude');
|
|
70
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
71
|
+
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{"existing": true}');
|
|
72
|
+
await hooksInitCommand(testDir, { tool: 'claude', force: true });
|
|
73
|
+
// Should have new hooks content
|
|
74
|
+
const content = fs.readFileSync(path.join(claudeDir, 'settings.json'), 'utf-8');
|
|
75
|
+
expect(content).toContain('PostToolUse');
|
|
76
|
+
});
|
|
77
|
+
});
|
package/dist/commands/init.js
CHANGED
|
@@ -4,6 +4,7 @@ import chalk from 'chalk';
|
|
|
4
4
|
import yaml from 'yaml';
|
|
5
5
|
import { DiscoveryService } from '@rigour-labs/core';
|
|
6
6
|
import { CODE_QUALITY_RULES, DEBUGGING_RULES, COLLABORATION_RULES, AGNOSTIC_AI_INSTRUCTIONS } from './constants.js';
|
|
7
|
+
import { hooksInitCommand } from './hooks.js';
|
|
7
8
|
import { randomUUID } from 'crypto';
|
|
8
9
|
// Helper to log events for Rigour Studio
|
|
9
10
|
async function logStudioEvent(cwd, event) {
|
|
@@ -322,7 +323,9 @@ ${ruleContent}`;
|
|
|
322
323
|
console.log(chalk.green('✔ Initialized Windsurf Handshake (.windsurfrules)'));
|
|
323
324
|
}
|
|
324
325
|
}
|
|
325
|
-
// 3.
|
|
326
|
+
// 3. Auto-initialize hooks for detected AI coding tools
|
|
327
|
+
await initHooksForDetectedTools(cwd, detectedIDE);
|
|
328
|
+
// 4. Update .gitignore
|
|
326
329
|
const gitignorePath = path.join(cwd, '.gitignore');
|
|
327
330
|
const ignorePatterns = ['rigour-report.json', 'rigour-fix-packet.json', '.rigour/'];
|
|
328
331
|
try {
|
|
@@ -368,3 +371,24 @@ ${ruleContent}`;
|
|
|
368
371
|
content: [{ type: "text", text: `Rigour Governance Initialized` }]
|
|
369
372
|
});
|
|
370
373
|
}
|
|
374
|
+
// Maps detected IDE to hook tool name
|
|
375
|
+
const IDE_TO_HOOK_TOOL = {
|
|
376
|
+
claude: 'claude',
|
|
377
|
+
cursor: 'cursor',
|
|
378
|
+
cline: 'cline',
|
|
379
|
+
windsurf: 'windsurf',
|
|
380
|
+
};
|
|
381
|
+
async function initHooksForDetectedTools(cwd, detectedIDE) {
|
|
382
|
+
const hookTool = IDE_TO_HOOK_TOOL[detectedIDE];
|
|
383
|
+
if (!hookTool) {
|
|
384
|
+
return; // Unknown IDE or no hook support (vscode, gemini, codex)
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
console.log(chalk.dim(`\n Setting up real-time hooks for ${detectedIDE}...`));
|
|
388
|
+
await hooksInitCommand(cwd, { tool: hookTool });
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// Non-fatal — hooks are a bonus, not a requirement
|
|
392
|
+
console.log(chalk.dim(` (Hooks setup skipped — run 'rigour hooks init' manually)`));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for init command — IDE detection, config generation, auto-hook integration.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { initCommand } from './init.js';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
describe('initCommand', () => {
|
|
10
|
+
let testDir;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'init-test-'));
|
|
13
|
+
// Minimal package.json for discovery
|
|
14
|
+
fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({
|
|
15
|
+
name: 'test-project',
|
|
16
|
+
dependencies: { express: '^4.0.0' },
|
|
17
|
+
}));
|
|
18
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
19
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
it('should create rigour.yml', async () => {
|
|
26
|
+
await initCommand(testDir);
|
|
27
|
+
const configPath = path.join(testDir, 'rigour.yml');
|
|
28
|
+
expect(fs.existsSync(configPath)).toBe(true);
|
|
29
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
30
|
+
expect(content).toContain('version');
|
|
31
|
+
});
|
|
32
|
+
it('should create docs/AGENT_INSTRUCTIONS.md', async () => {
|
|
33
|
+
await initCommand(testDir);
|
|
34
|
+
const docsPath = path.join(testDir, 'docs', 'AGENT_INSTRUCTIONS.md');
|
|
35
|
+
expect(fs.existsSync(docsPath)).toBe(true);
|
|
36
|
+
const content = fs.readFileSync(docsPath, 'utf-8');
|
|
37
|
+
expect(content).toContain('Rigour');
|
|
38
|
+
});
|
|
39
|
+
it('should support dry-run mode', async () => {
|
|
40
|
+
await initCommand(testDir, { dryRun: true });
|
|
41
|
+
// Dry run should NOT create rigour.yml
|
|
42
|
+
expect(fs.existsSync(path.join(testDir, 'rigour.yml'))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
it('should not overwrite without --force', async () => {
|
|
45
|
+
// Create existing rigour.yml
|
|
46
|
+
fs.writeFileSync(path.join(testDir, 'rigour.yml'), 'version: 1\nexisting: true\n');
|
|
47
|
+
await initCommand(testDir);
|
|
48
|
+
const content = fs.readFileSync(path.join(testDir, 'rigour.yml'), 'utf-8');
|
|
49
|
+
expect(content).toContain('existing');
|
|
50
|
+
});
|
|
51
|
+
it('should overwrite with --force', async () => {
|
|
52
|
+
// Create existing rigour.yml
|
|
53
|
+
fs.writeFileSync(path.join(testDir, 'rigour.yml'), 'version: 1\nexisting: true\n');
|
|
54
|
+
await initCommand(testDir, { force: true });
|
|
55
|
+
// Should create backup
|
|
56
|
+
expect(fs.existsSync(path.join(testDir, 'rigour.yml.bak'))).toBe(true);
|
|
57
|
+
// New config should not contain 'existing'
|
|
58
|
+
const content = fs.readFileSync(path.join(testDir, 'rigour.yml'), 'utf-8');
|
|
59
|
+
expect(content).not.toContain('existing: true');
|
|
60
|
+
});
|
|
61
|
+
it('should detect Claude IDE and create hooks', async () => {
|
|
62
|
+
// Create Claude marker
|
|
63
|
+
fs.mkdirSync(path.join(testDir, '.claude'), { recursive: true });
|
|
64
|
+
await initCommand(testDir);
|
|
65
|
+
// Should have created .claude/settings.json (hooks)
|
|
66
|
+
const settingsPath = path.join(testDir, '.claude', 'settings.json');
|
|
67
|
+
expect(fs.existsSync(settingsPath)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
it('should detect Cursor IDE and create hooks', async () => {
|
|
70
|
+
// Create Cursor marker
|
|
71
|
+
fs.mkdirSync(path.join(testDir, '.cursor'), { recursive: true });
|
|
72
|
+
await initCommand(testDir);
|
|
73
|
+
// Should have created .cursor/hooks.json
|
|
74
|
+
const hooksPath = path.join(testDir, '.cursor', 'hooks.json');
|
|
75
|
+
expect(fs.existsSync(hooksPath)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
it('should update .gitignore with rigour patterns', async () => {
|
|
78
|
+
await initCommand(testDir);
|
|
79
|
+
const gitignorePath = path.join(testDir, '.gitignore');
|
|
80
|
+
expect(fs.existsSync(gitignorePath)).toBe(true);
|
|
81
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
82
|
+
expect(content).toContain('rigour-report.json');
|
|
83
|
+
expect(content).toContain('.rigour/');
|
|
84
|
+
});
|
|
85
|
+
it('should create .rigour/memory.json for Studio', async () => {
|
|
86
|
+
await initCommand(testDir);
|
|
87
|
+
const memPath = path.join(testDir, '.rigour', 'memory.json');
|
|
88
|
+
expect(fs.existsSync(memPath)).toBe(true);
|
|
89
|
+
const mem = JSON.parse(fs.readFileSync(memPath, 'utf-8'));
|
|
90
|
+
expect(mem.memories.project_boot).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
it('should support --ide flag to target specific IDE', async () => {
|
|
93
|
+
await initCommand(testDir, { ide: 'windsurf' });
|
|
94
|
+
// Should create windsurf rules
|
|
95
|
+
expect(fs.existsSync(path.join(testDir, '.windsurfrules'))).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/cli",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "CLI quality gates for AI-generated code. Forces AI agents (Claude, Cursor, Copilot) to meet strict engineering standards with PASS/FAIL enforcement.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://rigour.run",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"inquirer": "9.2.16",
|
|
45
45
|
"ora": "^8.0.1",
|
|
46
46
|
"yaml": "^2.8.2",
|
|
47
|
-
"@rigour-labs/core": "3.0.
|
|
47
|
+
"@rigour-labs/core": "3.0.2"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/fs-extra": "^11.0.4",
|