dont-hallucinate 0.1.3
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/README.md +154 -0
- package/bin/dont-hallucinate.js +5 -0
- package/dont_hallucinate/hooks/hook.ps1 +272 -0
- package/dont_hallucinate/hooks/hook.sh +150 -0
- package/dont_hallucinate/hooks/hook_noninteractive.sh +151 -0
- package/dont_hallucinate/snark.json +198 -0
- package/package.json +33 -0
- package/src/burn.js +48 -0
- package/src/cli.js +599 -0
- package/src/parser.js +123 -0
- package/src/runtime.js +47 -0
- package/src/ui.js +240 -0
- package/src/verifier.js +156 -0
- package/src/watcher.js +123 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
// cli.js — main CLI (port of cli.py, using commander instead of click)
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { spawnSync, execFileSync, execSync } from 'child_process';
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'fs';
|
|
6
|
+
import { resolve, join, dirname } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import { platform } from 'process';
|
|
10
|
+
|
|
11
|
+
import { classifyFailure, extractCmdName } from './parser.js';
|
|
12
|
+
import { detectAgent, loadSnarkMap, pickRoast } from './burn.js';
|
|
13
|
+
import { verifyCommand, VerificationResult } from './verifier.js';
|
|
14
|
+
import { appendJsonl, defaultStreamFile, ensureRuntimeDir, utcTimestamp, workspaceRoot } from './runtime.js';
|
|
15
|
+
import { runUi } from './ui.js';
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const PACKAGE_ROOT = join(__dirname, '..');
|
|
19
|
+
|
|
20
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function hookPath(shell) {
|
|
23
|
+
const hooksDir = join(PACKAGE_ROOT, 'dont_hallucinate', 'hooks');
|
|
24
|
+
const map = {
|
|
25
|
+
powershell: join(hooksDir, 'hook.ps1'),
|
|
26
|
+
bash: join(hooksDir, 'hook.sh'),
|
|
27
|
+
'agent-bash': join(hooksDir, 'hook_noninteractive.sh'),
|
|
28
|
+
};
|
|
29
|
+
return map[shell] ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function quotePowershell(value) {
|
|
33
|
+
return "'" + value.replace(/'/g, "''") + "'";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function quoteBash(value) {
|
|
37
|
+
return "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toBashPath(p) {
|
|
41
|
+
const abs = resolve(p);
|
|
42
|
+
if (abs.length >= 3 && abs[1] === ':' && abs[2] === '\\') {
|
|
43
|
+
const drive = abs[0].toLowerCase();
|
|
44
|
+
const tail = abs.slice(2).replace(/\\/g, '/');
|
|
45
|
+
return `/${drive}${tail}`;
|
|
46
|
+
}
|
|
47
|
+
return abs.replace(/\\/g, '/');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function commandDisplay(args) {
|
|
51
|
+
if (platform === 'win32') {
|
|
52
|
+
return args.map(a => (a.includes(' ') ? `"${a}"` : a)).join(' ');
|
|
53
|
+
}
|
|
54
|
+
return args.join(' ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function which(cmd) {
|
|
58
|
+
try {
|
|
59
|
+
const result = execFileSync(platform === 'win32' ? 'where' : 'which', [cmd], {
|
|
60
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
61
|
+
});
|
|
62
|
+
return result.trim().split('\n')[0] || null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function realBashPath() {
|
|
69
|
+
return process.env.HALLUCINATE_REAL_BASH || which('bash');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function prepareEnv(workspace, stream, actor) {
|
|
73
|
+
return {
|
|
74
|
+
...process.env,
|
|
75
|
+
HALLUCINATE_WORKSPACE_ROOT: workspace,
|
|
76
|
+
HALLUCINATE_STREAM_FILE: stream,
|
|
77
|
+
HALLUCINATE_ACTOR: actor,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function shimDir(ws) {
|
|
82
|
+
return join(ws, '.hallucinate', 'bin');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readJsonFile(path) {
|
|
86
|
+
if (!existsSync(path)) return {};
|
|
87
|
+
try {
|
|
88
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
89
|
+
return (data && typeof data === 'object' && !Array.isArray(data)) ? data : {};
|
|
90
|
+
} catch {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function writeJsonFile(path, payload) {
|
|
96
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
97
|
+
writeFileSync(path, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readTextFile(path) {
|
|
101
|
+
if (!existsSync(path)) return '';
|
|
102
|
+
return readFileSync(path, 'utf8');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeTextFile(path, content) {
|
|
106
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
107
|
+
writeFileSync(path, content, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function appendBlockOnce(filePath, beginMarker, endMarker, block) {
|
|
111
|
+
const current = readTextFile(filePath);
|
|
112
|
+
if (current.includes(beginMarker) && current.includes(endMarker)) {
|
|
113
|
+
const start = current.indexOf(beginMarker);
|
|
114
|
+
const end = current.indexOf(endMarker, start) + endMarker.length;
|
|
115
|
+
const replacement = block.trimEnd();
|
|
116
|
+
const before = current.slice(0, start).trimEnd();
|
|
117
|
+
const after = current.slice(end).replace(/^\r?\n/, '');
|
|
118
|
+
const updated = (before ? before + '\n\n' : '') + replacement + '\n' + after;
|
|
119
|
+
writeTextFile(filePath, updated);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const normalized = current.trimEnd();
|
|
123
|
+
const updated = (normalized ? normalized + '\n\n' : '') + block.trimEnd() + '\n';
|
|
124
|
+
writeTextFile(filePath, updated);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── TOML helpers (for Codex config) ──────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function findTomlTable(lines, tableName) {
|
|
131
|
+
const header = `[${tableName}]`;
|
|
132
|
+
let start = null;
|
|
133
|
+
for (let i = 0; i < lines.length; i++) {
|
|
134
|
+
if (lines[i].trim() === header) { start = i; break; }
|
|
135
|
+
}
|
|
136
|
+
if (start === null) return [null, null];
|
|
137
|
+
let end = lines.length;
|
|
138
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
139
|
+
const s = lines[i].trim();
|
|
140
|
+
if (s.startsWith('[') && s.endsWith(']')) { end = i; break; }
|
|
141
|
+
}
|
|
142
|
+
return [start, end];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function ensureTomlTable(lines, tableName) {
|
|
146
|
+
let [start, end] = findTomlTable(lines, tableName);
|
|
147
|
+
if (start !== null) return [start, end];
|
|
148
|
+
if (lines.length && lines[lines.length - 1].trim()) lines.push('');
|
|
149
|
+
lines.push(`[${tableName}]`);
|
|
150
|
+
return [lines.length - 1, lines.length];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function upsertTomlAssignment(lines, tableName, key, value) {
|
|
154
|
+
const [start, end] = ensureTomlTable(lines, tableName);
|
|
155
|
+
const assignment = `${key} = ${value}`;
|
|
156
|
+
for (let i = start + 1; i < end; i++) {
|
|
157
|
+
if (lines[i].trim().startsWith(`${key} =`)) { lines[i] = assignment; return lines; }
|
|
158
|
+
}
|
|
159
|
+
lines.splice(end, 0, assignment);
|
|
160
|
+
return lines;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function codexConfigWithHook(existing, streamFile, hookFile) {
|
|
164
|
+
const lines = existing.split('\n');
|
|
165
|
+
const [shellStart, shellEnd] = ensureTomlTable(lines, 'shell_environment_policy');
|
|
166
|
+
for (let i = shellStart + 1; i < shellEnd; i++) {
|
|
167
|
+
if (lines[i].trim().startsWith('set =')) {
|
|
168
|
+
throw new Error('Existing [shell_environment_policy] uses inline `set = {...}`. Please migrate it to [shell_environment_policy.set] before installing the Codex hook.');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
upsertTomlAssignment(lines, 'shell_environment_policy', 'inherit', '"all"');
|
|
172
|
+
upsertTomlAssignment(lines, 'shell_environment_policy.set', 'BASH_ENV', JSON.stringify(toBashPath(hookFile)));
|
|
173
|
+
upsertTomlAssignment(lines, 'shell_environment_policy.set', 'HALLUCINATE_STREAM_FILE', JSON.stringify(streamFile));
|
|
174
|
+
upsertTomlAssignment(lines, 'shell_environment_policy.set', 'HALLUCINATE_ACTOR', JSON.stringify('agent'));
|
|
175
|
+
return lines.join('\n').trimEnd() + '\n';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Bash shim ────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
function installBashShim(ws, actor) {
|
|
181
|
+
const sDir = shimDir(ws);
|
|
182
|
+
mkdirSync(sDir, { recursive: true });
|
|
183
|
+
|
|
184
|
+
const shimSh = join(sDir, 'bash');
|
|
185
|
+
const shimCmd = join(sDir, 'bash.cmd');
|
|
186
|
+
|
|
187
|
+
writeFileSync(shimSh, `#!/usr/bin/env sh\nexec dont-hallucinate bash --workspace ${quoteBash(ws)} --actor ${quoteBash(actor)} -- "$@"\n`, 'utf8');
|
|
188
|
+
try { chmodSync(shimSh, 0o755); } catch { /* skip on Windows */ }
|
|
189
|
+
|
|
190
|
+
writeFileSync(shimCmd, `@echo off\r\ndont-hallucinate bash --workspace "${ws}" --actor "${actor}" -- %*\r\n`, 'utf8');
|
|
191
|
+
|
|
192
|
+
return sDir;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Core execution ────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
function buildFailureReport(commandText, actor, stderrText, exitCode) {
|
|
198
|
+
const parsed = classifyFailure(commandText, stderrText, exitCode);
|
|
199
|
+
|
|
200
|
+
let verification;
|
|
201
|
+
if (parsed.errorType === 'invalid_flag') {
|
|
202
|
+
verification = verifyCommand(commandText, parsed.hallucinatedFlag);
|
|
203
|
+
} else {
|
|
204
|
+
verification = new VerificationResult({ availableFlags: [], suggestedFlag: null, correctedCommand: null, source: 'none' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const agent = detectAgent();
|
|
208
|
+
const cmdName = extractCmdName(commandText);
|
|
209
|
+
const roast = pickRoast(loadSnarkMap(), parsed.errorType, {
|
|
210
|
+
agent,
|
|
211
|
+
cmdName,
|
|
212
|
+
flag: parsed.hallucinatedFlag || 'that flag',
|
|
213
|
+
missing: parsed.missingCommand || cmdName || 'that',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const lines = [];
|
|
217
|
+
if (stderrText) lines.push(stderrText.trimEnd());
|
|
218
|
+
lines.push('');
|
|
219
|
+
lines.push(`[dont-hallucinate/${actor}] caught: ${commandText}`);
|
|
220
|
+
lines.push(`[dont-hallucinate/${actor}] type: ${parsed.errorType}`);
|
|
221
|
+
if (verification.correctedCommand) {
|
|
222
|
+
lines.push(`[dont-hallucinate/${actor}] try: ${verification.correctedCommand}`);
|
|
223
|
+
} else if (verification.suggestedFlag) {
|
|
224
|
+
lines.push(`[dont-hallucinate/${actor}] maybe: ${verification.suggestedFlag}`);
|
|
225
|
+
}
|
|
226
|
+
lines.push(`[dont-hallucinate/${actor}] roast: ${roast}`);
|
|
227
|
+
return lines.join('\n').trimEnd() + '\n';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function logExecEvent(stream, ws, actor, commandText, exitCode, stderrText) {
|
|
231
|
+
appendJsonl(stream, {
|
|
232
|
+
ts: utcTimestamp(),
|
|
233
|
+
shell: 'exec',
|
|
234
|
+
actor,
|
|
235
|
+
session: process.pid,
|
|
236
|
+
pid: process.pid,
|
|
237
|
+
cwd: ws,
|
|
238
|
+
workspace_root: ws,
|
|
239
|
+
exit_code: exitCode,
|
|
240
|
+
command: commandText,
|
|
241
|
+
stderr: stderrText,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function runWrappedCommand(ws, actor, args, useShell = false) {
|
|
246
|
+
ensureRuntimeDir(ws);
|
|
247
|
+
const stream = defaultStreamFile(ws);
|
|
248
|
+
const commandText = useShell ? args[0] : commandDisplay(args);
|
|
249
|
+
|
|
250
|
+
const result = spawnSync(useShell ? args[0] : args[0], useShell ? [] : args.slice(1), {
|
|
251
|
+
cwd: ws,
|
|
252
|
+
shell: useShell,
|
|
253
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
254
|
+
encoding: 'utf8',
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
258
|
+
|
|
259
|
+
const stderrText = result.stderr || '';
|
|
260
|
+
const exitCode = result.status ?? 1;
|
|
261
|
+
|
|
262
|
+
if (exitCode !== 0) {
|
|
263
|
+
const decorated = buildFailureReport(commandText, actor, stderrText, exitCode);
|
|
264
|
+
process.stderr.write(decorated);
|
|
265
|
+
logExecEvent(stream, ws, actor, commandText, exitCode, stderrText);
|
|
266
|
+
process.exit(exitCode);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (stderrText) process.stderr.write(stderrText);
|
|
270
|
+
logExecEvent(stream, ws, actor, commandText, 0, stderrText);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Claude hook script (Node.js) ─────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
const HOOK_SCRIPT = `#!/usr/bin/env node
|
|
276
|
+
// PreToolUse hook: wraps every Bash tool call through dont-hallucinate exec.
|
|
277
|
+
let input = '';
|
|
278
|
+
process.stdin.on('data', c => input += c);
|
|
279
|
+
process.stdin.on('end', () => {
|
|
280
|
+
const data = JSON.parse(input);
|
|
281
|
+
const cmd = (data?.tool_input?.command) || '';
|
|
282
|
+
if (!cmd || cmd.trimStart().startsWith('hallucinate')) {
|
|
283
|
+
process.stdout.write(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow'}}) + '\\n');
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
286
|
+
const quoted = cmd.replace(/'/g, "'\\"'\\''");
|
|
287
|
+
const wrapped = \`dont-hallucinate exec --shell --actor agent -- '\${quoted}'\`;
|
|
288
|
+
process.stdout.write(JSON.stringify({
|
|
289
|
+
hookSpecificOutput: {
|
|
290
|
+
hookEventName: 'PreToolUse',
|
|
291
|
+
permissionDecision: 'allow',
|
|
292
|
+
updatedInput: { command: wrapped },
|
|
293
|
+
}
|
|
294
|
+
}) + '\\n');
|
|
295
|
+
});
|
|
296
|
+
`;
|
|
297
|
+
|
|
298
|
+
function mergeClaudeHook(settings, scriptPath) {
|
|
299
|
+
const hooks = settings.hooks ?? (settings.hooks = {});
|
|
300
|
+
const pre = hooks.PreToolUse ?? (hooks.PreToolUse = []);
|
|
301
|
+
for (const entry of pre) {
|
|
302
|
+
if (entry.matcher === 'Bash' && (entry.hooks || []).some(h => h.command?.includes('hallucinate_hook'))) {
|
|
303
|
+
return settings;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
pre.push({ matcher: 'Bash', hooks: [{ type: 'command', command: `node ${JSON.stringify(scriptPath)}` }] });
|
|
307
|
+
return settings;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function installClaudeHook(globalInstall) {
|
|
311
|
+
const claudeDir = globalInstall
|
|
312
|
+
? join(homedir(), '.claude')
|
|
313
|
+
: join(process.cwd(), '.claude');
|
|
314
|
+
const scriptPathInSettings = globalInstall
|
|
315
|
+
? join(claudeDir, 'hallucinate_hook.js')
|
|
316
|
+
: '.claude/hallucinate_hook.js';
|
|
317
|
+
|
|
318
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
319
|
+
const settingsFile = join(claudeDir, 'settings.json');
|
|
320
|
+
const hookScript = join(claudeDir, 'hallucinate_hook.js');
|
|
321
|
+
writeFileSync(hookScript, HOOK_SCRIPT, 'utf8');
|
|
322
|
+
|
|
323
|
+
const settings = readJsonFile(settingsFile);
|
|
324
|
+
mergeClaudeHook(settings, scriptPathInSettings);
|
|
325
|
+
writeJsonFile(settingsFile, settings);
|
|
326
|
+
return [hookScript, settingsFile];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function codexConfigPath(globalInstall) {
|
|
330
|
+
return globalInstall
|
|
331
|
+
? join(homedir(), '.codex', 'config.toml')
|
|
332
|
+
: join(process.cwd(), '.codex', 'config.toml');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function agentStreamFile(agentName) {
|
|
336
|
+
return join(homedir(), '.dont-hallucinate', agentName, 'stream.jsonl');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function installCodexHook(globalInstall) {
|
|
340
|
+
const configPath = codexConfigPath(globalInstall);
|
|
341
|
+
const streamFile = agentStreamFile('codex');
|
|
342
|
+
const hookFile = hookPath('agent-bash');
|
|
343
|
+
|
|
344
|
+
mkdirSync(dirname(streamFile), { recursive: true });
|
|
345
|
+
const updated = codexConfigWithHook(readTextFile(configPath), streamFile, hookFile);
|
|
346
|
+
writeTextFile(configPath, updated);
|
|
347
|
+
return [configPath, streamFile];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function cursorBashBlock(streamFile, hookFile) {
|
|
351
|
+
return [
|
|
352
|
+
'# >>> dont-hallucinate cursor >>>',
|
|
353
|
+
'if [ -n "${CURSOR_AGENT:-}" ]; then',
|
|
354
|
+
` export HALLUCINATE_ACTOR=${quoteBash('agent')}`,
|
|
355
|
+
` export HALLUCINATE_STREAM_FILE=${quoteBash(streamFile)}`,
|
|
356
|
+
` source ${quoteBash(toBashPath(hookFile))}`,
|
|
357
|
+
'fi',
|
|
358
|
+
'# <<< dont-hallucinate cursor <<<',
|
|
359
|
+
].join('\n');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function cursorPowershellBlock(streamFile, hookFile) {
|
|
363
|
+
return [
|
|
364
|
+
'# >>> dont-hallucinate cursor >>>',
|
|
365
|
+
'if ($env:CURSOR_AGENT) {',
|
|
366
|
+
" $env:HALLUCINATE_ACTOR = 'agent'",
|
|
367
|
+
` $env:HALLUCINATE_STREAM_FILE = ${quotePowershell(streamFile)}`,
|
|
368
|
+
` . ${quotePowershell(hookFile)}`,
|
|
369
|
+
'}',
|
|
370
|
+
'# <<< dont-hallucinate cursor <<<',
|
|
371
|
+
].join('\n');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function installCursorHook() {
|
|
375
|
+
const streamFile = agentStreamFile('cursor');
|
|
376
|
+
const bashHook = hookPath('bash');
|
|
377
|
+
const psHook = hookPath('powershell');
|
|
378
|
+
|
|
379
|
+
mkdirSync(dirname(streamFile), { recursive: true });
|
|
380
|
+
const touched = [];
|
|
381
|
+
|
|
382
|
+
const psProfile = join(homedir(), 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1');
|
|
383
|
+
appendBlockOnce(psProfile, '# >>> dont-hallucinate cursor >>>', '# <<< dont-hallucinate cursor <<<', cursorPowershellBlock(streamFile, psHook));
|
|
384
|
+
touched.push(psProfile);
|
|
385
|
+
|
|
386
|
+
for (const profile of [join(homedir(), '.bashrc'), join(homedir(), '.zshrc')]) {
|
|
387
|
+
appendBlockOnce(profile, '# >>> dont-hallucinate cursor >>>', '# <<< dont-hallucinate cursor <<<', cursorBashBlock(streamFile, bashHook));
|
|
388
|
+
touched.push(profile);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return [touched, streamFile];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── CLI definition ────────────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
export function createCli() {
|
|
397
|
+
const program = new Command();
|
|
398
|
+
|
|
399
|
+
program
|
|
400
|
+
.name('dont-hallucinate')
|
|
401
|
+
.description('mocks your coding agent when they get it wrong')
|
|
402
|
+
.action(() => {
|
|
403
|
+
const ws = workspaceRoot();
|
|
404
|
+
const stream = defaultStreamFile(ws);
|
|
405
|
+
ensureRuntimeDir(ws);
|
|
406
|
+
runUi({ streamFile: stream, pollInterval: 200, fromStart: true, workspace: ws });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// watch
|
|
410
|
+
program
|
|
411
|
+
.command('watch')
|
|
412
|
+
.description('run the terminal watcher UI')
|
|
413
|
+
.option('--stream-file <path>', 'JSONL stream file path')
|
|
414
|
+
.option('--poll-interval <ms>', 'file polling interval in ms', v => Number(v), 200)
|
|
415
|
+
.option('--from-start', 'read existing events first instead of seeking to EOF', false)
|
|
416
|
+
.option('--workspace <dir>', 'workspace root to watch', process.cwd())
|
|
417
|
+
.action(opts => {
|
|
418
|
+
const ws = workspaceRoot(opts.workspace);
|
|
419
|
+
ensureRuntimeDir(ws);
|
|
420
|
+
const stream = opts.streamFile ? resolve(opts.streamFile) : defaultStreamFile(ws);
|
|
421
|
+
runUi({ streamFile: stream, pollInterval: opts.pollInterval, fromStart: opts.fromStart, workspace: ws });
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// activate
|
|
425
|
+
program
|
|
426
|
+
.command('activate')
|
|
427
|
+
.description('print shell commands to activate the hook for the current workspace')
|
|
428
|
+
.requiredOption('--shell <type>', 'shell type: powershell | bash | agent-bash')
|
|
429
|
+
.option('--workspace <dir>', 'workspace root', process.cwd())
|
|
430
|
+
.option('--stream-file <path>', 'override the JSONL stream path')
|
|
431
|
+
.option('--actor <type>', 'tag events: human | agent')
|
|
432
|
+
.action(opts => {
|
|
433
|
+
const ws = workspaceRoot(opts.workspace);
|
|
434
|
+
ensureRuntimeDir(ws);
|
|
435
|
+
const stream = opts.streamFile ? resolve(opts.streamFile) : defaultStreamFile(ws);
|
|
436
|
+
const hook = resolve(hookPath(opts.shell));
|
|
437
|
+
const actor = opts.actor ?? (opts.shell === 'agent-bash' ? 'agent' : 'human');
|
|
438
|
+
const sDir = installBashShim(ws, actor);
|
|
439
|
+
const realBash = realBashPath();
|
|
440
|
+
|
|
441
|
+
let snippet;
|
|
442
|
+
if (opts.shell === 'powershell') {
|
|
443
|
+
const parts = [
|
|
444
|
+
`$env:HALLUCINATE_WORKSPACE_ROOT = ${quotePowershell(ws)}`,
|
|
445
|
+
`$env:HALLUCINATE_STREAM_FILE = ${quotePowershell(stream)}`,
|
|
446
|
+
`$env:HALLUCINATE_ACTOR = ${quotePowershell(actor)}`,
|
|
447
|
+
`$env:PATH = ${quotePowershell(sDir + ';')} + $env:PATH`,
|
|
448
|
+
...(realBash ? [`$env:HALLUCINATE_REAL_BASH = ${quotePowershell(realBash)}`] : []),
|
|
449
|
+
`. ${quotePowershell(hook)}`,
|
|
450
|
+
];
|
|
451
|
+
snippet = parts.join('; ');
|
|
452
|
+
} else if (opts.shell === 'bash') {
|
|
453
|
+
const parts = [
|
|
454
|
+
`export HALLUCINATE_WORKSPACE_ROOT=${quoteBash(toBashPath(ws))}`,
|
|
455
|
+
`export HALLUCINATE_STREAM_FILE=${quoteBash(toBashPath(stream))}`,
|
|
456
|
+
`export HALLUCINATE_ACTOR=${quoteBash(actor)}`,
|
|
457
|
+
`export PATH=${quoteBash(toBashPath(sDir))}:$PATH`,
|
|
458
|
+
...(realBash ? [`export HALLUCINATE_REAL_BASH=${quoteBash(toBashPath(realBash))}`] : []),
|
|
459
|
+
`source ${quoteBash(toBashPath(hook))}`,
|
|
460
|
+
];
|
|
461
|
+
snippet = parts.join('\n');
|
|
462
|
+
} else {
|
|
463
|
+
const parts = [
|
|
464
|
+
`export HALLUCINATE_WORKSPACE_ROOT=${quoteBash(toBashPath(ws))}`,
|
|
465
|
+
`export HALLUCINATE_STREAM_FILE=${quoteBash(toBashPath(stream))}`,
|
|
466
|
+
`export HALLUCINATE_ACTOR=${quoteBash(actor)}`,
|
|
467
|
+
`export PATH=${quoteBash(toBashPath(sDir))}:$PATH`,
|
|
468
|
+
...(realBash ? [`export HALLUCINATE_REAL_BASH=${quoteBash(toBashPath(realBash))}`] : []),
|
|
469
|
+
`export BASH_ENV=${quoteBash(toBashPath(hook))}`,
|
|
470
|
+
];
|
|
471
|
+
snippet = parts.join('\n');
|
|
472
|
+
}
|
|
473
|
+
process.stdout.write(snippet + '\n');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// shell
|
|
477
|
+
program
|
|
478
|
+
.command('shell')
|
|
479
|
+
.description('spawn a shell with hallucinate already activated')
|
|
480
|
+
.option('--shell <type>', 'powershell | bash', platform === 'win32' ? 'powershell' : 'bash')
|
|
481
|
+
.option('--workspace <dir>', 'workspace root', process.cwd())
|
|
482
|
+
.option('--actor <type>', 'human | agent', 'human')
|
|
483
|
+
.action(opts => {
|
|
484
|
+
const ws = workspaceRoot(opts.workspace);
|
|
485
|
+
ensureRuntimeDir(ws);
|
|
486
|
+
const stream = defaultStreamFile(ws);
|
|
487
|
+
const env = prepareEnv(ws, stream, opts.actor);
|
|
488
|
+
const sDir = installBashShim(ws, opts.actor);
|
|
489
|
+
env.PATH = sDir + (platform === 'win32' ? ';' : ':') + (env.PATH || '');
|
|
490
|
+
const realBash = realBashPath();
|
|
491
|
+
if (realBash) env.HALLUCINATE_REAL_BASH = realBash;
|
|
492
|
+
|
|
493
|
+
if (opts.shell === 'powershell') {
|
|
494
|
+
const ps = which('powershell') || which('pwsh');
|
|
495
|
+
if (!ps) { console.error('PowerShell is not available on PATH.'); process.exit(1); }
|
|
496
|
+
const hook = resolve(hookPath('powershell'));
|
|
497
|
+
const cmd = `. ${quotePowershell(hook)}; Set-Location ${quotePowershell(ws)}`;
|
|
498
|
+
const result = spawnSync(ps, ['-NoExit', '-ExecutionPolicy', 'Bypass', '-Command', cmd], { stdio: 'inherit', env });
|
|
499
|
+
process.exit(result.status ?? 1);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const bash = which('bash');
|
|
503
|
+
if (!bash) { console.error('bash is not available on PATH.'); process.exit(1); }
|
|
504
|
+
const hook = resolve(hookPath('bash'));
|
|
505
|
+
env.HALLUCINATE_WORKSPACE_ROOT = toBashPath(ws);
|
|
506
|
+
env.HALLUCINATE_STREAM_FILE = toBashPath(stream);
|
|
507
|
+
const result = spawnSync(bash, ['--noprofile', '--rcfile', toBashPath(hook), '-i'], { cwd: ws, stdio: 'inherit', env });
|
|
508
|
+
process.exit(result.status ?? 1);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// bash
|
|
512
|
+
program
|
|
513
|
+
.command('bash [args...]')
|
|
514
|
+
.description('run the real bash through dont-hallucinate')
|
|
515
|
+
.option('--workspace <dir>', 'workspace root', process.cwd())
|
|
516
|
+
.option('--actor <type>', 'human | agent', 'agent')
|
|
517
|
+
.allowUnknownOption()
|
|
518
|
+
.action((args, opts) => {
|
|
519
|
+
const realBash = realBashPath();
|
|
520
|
+
if (!realBash) { console.error('A real bash binary could not be found. Set HALLUCINATE_REAL_BASH or install bash.'); process.exit(1); }
|
|
521
|
+
const ws = workspaceRoot(opts.workspace);
|
|
522
|
+
runWrappedCommand(ws, opts.actor, [realBash, ...args]);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// exec
|
|
526
|
+
program
|
|
527
|
+
.command('exec <command...>')
|
|
528
|
+
.description('run a command through dont-hallucinate and decorate failures')
|
|
529
|
+
.option('--workspace <dir>', 'workspace root', process.cwd())
|
|
530
|
+
.option('--actor <type>', 'human | agent', 'agent')
|
|
531
|
+
.option('--shell', 'run via system shell instead of direct exec', false)
|
|
532
|
+
.allowUnknownOption()
|
|
533
|
+
.action((command, opts) => {
|
|
534
|
+
const ws = workspaceRoot(opts.workspace);
|
|
535
|
+
runWrappedCommand(ws, opts.actor, command, opts.shell);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// explain
|
|
539
|
+
program
|
|
540
|
+
.command('explain')
|
|
541
|
+
.description('render the dont-hallucinate failure report for a command')
|
|
542
|
+
.option('--actor <name>', 'actor label', 'agent')
|
|
543
|
+
.requiredOption('--command <text>', 'command text that failed')
|
|
544
|
+
.requiredOption('--exit-code <n>', 'exit code from the failed command', v => Number(v))
|
|
545
|
+
.option('--stderr-file <path>', 'path to a stderr capture file')
|
|
546
|
+
.option('--stderr-text <text>', 'fallback stderr text', '')
|
|
547
|
+
.action(opts => {
|
|
548
|
+
const stderrValue = opts.stderrFile
|
|
549
|
+
? readFileSync(opts.stderrFile, { encoding: 'utf8', flag: 'r' })
|
|
550
|
+
: (opts.stderrText || '');
|
|
551
|
+
process.stdout.write(buildFailureReport(opts.command, opts.actor, stderrValue, opts.exitCode));
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// install-hook-claude (+ alias install-hook)
|
|
555
|
+
for (const cmdName of ['install-hook-claude', 'install-hook']) {
|
|
556
|
+
program
|
|
557
|
+
.command(cmdName)
|
|
558
|
+
.description('install the Claude Code PreToolUse hook for automatic bash interception')
|
|
559
|
+
.option('--global', 'install into ~/.claude/settings.json', false)
|
|
560
|
+
.action(opts => {
|
|
561
|
+
const [hookScript, settingsFile] = installClaudeHook(opts.global);
|
|
562
|
+
console.log(`Wrote ${hookScript}`);
|
|
563
|
+
console.log(`Updated ${settingsFile}`);
|
|
564
|
+
const scope = opts.global ? 'all projects (global)' : 'this project';
|
|
565
|
+
console.log(`Done. Hallucinate will intercept bash commands for ${scope}.`);
|
|
566
|
+
console.log('Restart Claude Code to activate.');
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// install-hook-codex
|
|
571
|
+
program
|
|
572
|
+
.command('install-hook-codex')
|
|
573
|
+
.description('install a Codex CLI shell hook via shell_environment_policy')
|
|
574
|
+
.option('--global', 'install into ~/.codex/config.toml', true)
|
|
575
|
+
.option('--project', 'install project-locally', false)
|
|
576
|
+
.action(opts => {
|
|
577
|
+
const globalInstall = !opts.project;
|
|
578
|
+
const [configPath, streamFile] = installCodexHook(globalInstall);
|
|
579
|
+
const scope = globalInstall ? 'global' : 'project-local';
|
|
580
|
+
console.log(`Updated ${configPath}`);
|
|
581
|
+
console.log(`Stream file: ${streamFile}`);
|
|
582
|
+
console.log(`Done. Hallucinate will intercept Codex bash subprocess failures (${scope}).`);
|
|
583
|
+
console.log('Restart Codex sessions to activate.');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// install-hook-cursor
|
|
587
|
+
program
|
|
588
|
+
.command('install-hook-cursor')
|
|
589
|
+
.description('install shell-profile hooks that activate only inside Cursor agent terminals')
|
|
590
|
+
.action(() => {
|
|
591
|
+
const [touched, streamFile] = installCursorHook();
|
|
592
|
+
for (const p of touched) console.log(`Updated ${p}`);
|
|
593
|
+
console.log(`Stream file: ${streamFile}`);
|
|
594
|
+
console.log('Done. Hallucinate will activate when Cursor sets CURSOR_AGENT in the terminal.');
|
|
595
|
+
console.log('Restart Cursor terminals to activate.');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
return program;
|
|
599
|
+
}
|