@dashclaw/cli 0.7.6 → 0.8.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/README.md +0 -61
- package/bin/dashclaw.js +2 -770
- package/lib/claude/install.js +411 -412
- package/lib/codex/install.js +10 -9
- package/package.json +37 -37
- package/lib/code/apply.js +0 -164
- package/lib/code/codex-parser.vendored.js +0 -360
- package/lib/code/ingest-codex.js +0 -244
- package/lib/code/ingest.js +0 -261
- package/lib/code/memo.js +0 -57
- package/lib/code/vendored.js +0 -219
- package/lib/cost.js +0 -108
- package/lib/env.js +0 -69
- package/lib/posture.js +0 -45
package/lib/claude/install.js
CHANGED
|
@@ -1,412 +1,411 @@
|
|
|
1
|
-
// cli/lib/claude/install.js
|
|
2
|
-
//
|
|
3
|
-
// `dashclaw install claude [--trial]` — provisions DashClaw governance into
|
|
4
|
-
// Claude Code without cloning the repo.
|
|
5
|
-
//
|
|
6
|
-
// Flow (ordering matters — nothing is written until the preflight passes, so
|
|
7
|
-
// a failed install never leaves a half-written config):
|
|
8
|
-
// 1. Resolve endpoint + API key: flags → env → (--trial: open the hosted
|
|
9
|
-
// signup page in a browser and accept the pasted key — Turnstile cannot
|
|
10
|
-
// be driven headlessly) → interactive prompts.
|
|
11
|
-
// 2. Preflight: GET /api/health (reachability) and an authenticated read
|
|
12
|
-
// (key validity). Failure exits non-zero with an actionable message.
|
|
13
|
-
// 3. Acquire the hook scripts: copied from a repo checkout when the CLI is
|
|
14
|
-
// running inside one, otherwise downloaded from the instance's own
|
|
15
|
-
// /downloads/dashclaw-claude-code-hooks.zip bundle (version-matched).
|
|
16
|
-
// 4. Resolve the Python command (python3, then python) and write the hook
|
|
17
|
-
// entries into ~/.claude/settings.json (managed entries, replaced on
|
|
18
|
-
// re-install; backup created once).
|
|
19
|
-
// 5. Write hook credentials to <hooksDir>/.env (mode 600 — the hooks load
|
|
20
|
-
// .env beside the script, so no secret lands in ~/.claude/settings.json)
|
|
21
|
-
// with HOOK_MODE=observe by default, and save ~/.dashclaw/config.json
|
|
22
|
-
// for the CLI itself.
|
|
23
|
-
// 6. Print next steps (flip to enforce, dashboard URL).
|
|
24
|
-
|
|
25
|
-
import { spawnSync } from 'node:child_process';
|
|
26
|
-
import {
|
|
27
|
-
existsSync,
|
|
28
|
-
readFileSync,
|
|
29
|
-
writeFileSync,
|
|
30
|
-
mkdirSync,
|
|
31
|
-
copyFileSync,
|
|
32
|
-
readdirSync,
|
|
33
|
-
statSync,
|
|
34
|
-
rmSync,
|
|
35
|
-
cpSync,
|
|
36
|
-
} from 'node:fs';
|
|
37
|
-
import { dirname, join, resolve } from 'node:path';
|
|
38
|
-
import { homedir, tmpdir } from 'node:os';
|
|
39
|
-
import { fileURLToPath } from 'node:url';
|
|
40
|
-
import { readConfigFile, writeConfigFile } from '../config.js';
|
|
41
|
-
|
|
42
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
-
|
|
44
|
-
const HOOK_FILES = [
|
|
45
|
-
'dashclaw_pretool.py',
|
|
46
|
-
'dashclaw_posttool.py',
|
|
47
|
-
'dashclaw_stop.py',
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
if (entry.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
'
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const entries
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
*
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
'#
|
|
262
|
-
|
|
263
|
-
`
|
|
264
|
-
`
|
|
265
|
-
`
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
* @param {
|
|
277
|
-
* @param {string} [opts.
|
|
278
|
-
* @param {string} [opts.
|
|
279
|
-
* @param {
|
|
280
|
-
* @param {
|
|
281
|
-
* @param {
|
|
282
|
-
* @param {Function} [opts.
|
|
283
|
-
* @param {Function} [opts.
|
|
284
|
-
* @param {Function} [opts.
|
|
285
|
-
* @param {Function} [opts.
|
|
286
|
-
* @param {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
logger.log(
|
|
312
|
-
logger.log(
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
logger.log(
|
|
317
|
-
logger.log(
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
logger.log('');
|
|
361
|
-
logger.log(
|
|
362
|
-
logger.log(`
|
|
363
|
-
logger.log(`
|
|
364
|
-
logger.log(
|
|
365
|
-
logger.log('');
|
|
366
|
-
logger.log('
|
|
367
|
-
logger.log('
|
|
368
|
-
logger.log(
|
|
369
|
-
logger.log(`
|
|
370
|
-
logger.log(`
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (process.platform === '
|
|
388
|
-
else
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
1
|
+
// cli/lib/claude/install.js
|
|
2
|
+
//
|
|
3
|
+
// `dashclaw install claude [--trial]` — provisions DashClaw governance into
|
|
4
|
+
// Claude Code without cloning the repo.
|
|
5
|
+
//
|
|
6
|
+
// Flow (ordering matters — nothing is written until the preflight passes, so
|
|
7
|
+
// a failed install never leaves a half-written config):
|
|
8
|
+
// 1. Resolve endpoint + API key: flags → env → (--trial: open the hosted
|
|
9
|
+
// signup page in a browser and accept the pasted key — Turnstile cannot
|
|
10
|
+
// be driven headlessly) → interactive prompts.
|
|
11
|
+
// 2. Preflight: GET /api/health (reachability) and an authenticated read
|
|
12
|
+
// (key validity). Failure exits non-zero with an actionable message.
|
|
13
|
+
// 3. Acquire the hook scripts: copied from a repo checkout when the CLI is
|
|
14
|
+
// running inside one, otherwise downloaded from the instance's own
|
|
15
|
+
// /downloads/dashclaw-claude-code-hooks.zip bundle (version-matched).
|
|
16
|
+
// 4. Resolve the Python command (python3, then python) and write the hook
|
|
17
|
+
// entries into ~/.claude/settings.json (managed entries, replaced on
|
|
18
|
+
// re-install; backup created once).
|
|
19
|
+
// 5. Write hook credentials to <hooksDir>/.env (mode 600 — the hooks load
|
|
20
|
+
// .env beside the script, so no secret lands in ~/.claude/settings.json)
|
|
21
|
+
// with HOOK_MODE=observe by default, and save ~/.dashclaw/config.json
|
|
22
|
+
// for the CLI itself.
|
|
23
|
+
// 6. Print next steps (flip to enforce, dashboard URL).
|
|
24
|
+
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import {
|
|
27
|
+
existsSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
copyFileSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
statSync,
|
|
34
|
+
rmSync,
|
|
35
|
+
cpSync,
|
|
36
|
+
} from 'node:fs';
|
|
37
|
+
import { dirname, join, resolve } from 'node:path';
|
|
38
|
+
import { homedir, tmpdir } from 'node:os';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
import { readConfigFile, writeConfigFile } from '../config.js';
|
|
41
|
+
|
|
42
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
|
|
44
|
+
const HOOK_FILES = [
|
|
45
|
+
'dashclaw_pretool.py',
|
|
46
|
+
'dashclaw_posttool.py',
|
|
47
|
+
'dashclaw_stop.py',
|
|
48
|
+
];
|
|
49
|
+
// Shipped when present in the bundle, skipped otherwise — a newer CLI must
|
|
50
|
+
// keep installing against an older hosted bundle. enforcement_liveness_probe
|
|
51
|
+
// (v8.2) is not wired as a SessionStart hook by this CLI install path (only
|
|
52
|
+
// Pre/Post/Stop are wired below), but travels with the hooks so
|
|
53
|
+
// `python .claude/hooks/enforcement_liveness_probe.py` works out of the box.
|
|
54
|
+
const OPTIONAL_HOOK_FILES = ['enforcement_liveness_probe.py'];
|
|
55
|
+
const HOOK_INTEL_DIR = 'dashclaw_agent_intel';
|
|
56
|
+
const HOOKS_BUNDLE_PATH = '/downloads/dashclaw-claude-code-hooks.zip';
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_AGENT_ID = 'claude-code';
|
|
59
|
+
|
|
60
|
+
// The public hosted trial instance. `--trial` falls back to it when no
|
|
61
|
+
// --endpoint / DASHCLAW_HOSTED_URL is given — a cold outsider following
|
|
62
|
+
// QUICK-START has no way to answer a "which URL?" prompt (v5.4 outsider run).
|
|
63
|
+
export const DEFAULT_HOSTED_TRIAL_URL = 'https://hosted.dashclaw.io';
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Python resolution
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a working Python command: python3 first, then python. The Windows
|
|
71
|
+
* Store `python3` alias exits non-zero without running anything, so we
|
|
72
|
+
* require a successful `--version`, not just spawnability.
|
|
73
|
+
* @param {(cmd: string, args: string[]) => {error?: Error, status?: number}} probe
|
|
74
|
+
*/
|
|
75
|
+
export function resolvePythonCommand(probe = (cmd, args) => spawnSync(cmd, args, { stdio: 'ignore' })) {
|
|
76
|
+
for (const cmd of ['python3', 'python']) {
|
|
77
|
+
const result = probe(cmd, ['--version']);
|
|
78
|
+
if (!result.error && result.status === 0) return cmd;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Preflight
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export async function preflight(endpoint, apiKey, { fetchImpl = fetch } = {}) {
|
|
88
|
+
let health;
|
|
89
|
+
try {
|
|
90
|
+
health = await fetchImpl(`${endpoint}/api/health`, { signal: AbortSignal.timeout(8000) });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Could not reach ${endpoint}/api/health (${err.cause?.code || err.name}). ` +
|
|
94
|
+
'Check the URL, that the instance is running, and your network.',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (!health.ok) {
|
|
98
|
+
throw new Error(`${endpoint}/api/health returned HTTP ${health.status} — the instance is unhealthy.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const authed = await fetchImpl(`${endpoint}/api/actions?limit=1`, {
|
|
102
|
+
headers: { 'x-api-key': apiKey },
|
|
103
|
+
signal: AbortSignal.timeout(8000),
|
|
104
|
+
}).catch((err) => {
|
|
105
|
+
throw new Error(`Authenticated preflight to ${endpoint} failed (${err.name}).`);
|
|
106
|
+
});
|
|
107
|
+
if (authed.status === 401 || authed.status === 403) {
|
|
108
|
+
throw new Error('API key was rejected (401/403). Paste the key exactly as issued (oc_live_...).');
|
|
109
|
+
}
|
|
110
|
+
if (!authed.ok) {
|
|
111
|
+
throw new Error(`Authenticated preflight returned HTTP ${authed.status} — expected 200.`);
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Hook acquisition
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** Repo checkout containing hooks/, when the CLI runs from inside one. */
|
|
121
|
+
export function findRepoHooksDir() {
|
|
122
|
+
const candidate = resolve(__dirname, '..', '..', '..', 'hooks');
|
|
123
|
+
return existsSync(join(candidate, 'dashclaw_pretool.py')) ? candidate : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function copyDirRecursive(src, dst) {
|
|
127
|
+
mkdirSync(dst, { recursive: true });
|
|
128
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
129
|
+
if (entry.name === '__pycache__') continue;
|
|
130
|
+
const sp = join(src, entry.name);
|
|
131
|
+
const dp = join(dst, entry.name);
|
|
132
|
+
if (entry.isDirectory()) copyDirRecursive(sp, dp);
|
|
133
|
+
else if (entry.isFile()) copyFileSync(sp, dp);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function copyHooksFromRepo(hooksSrc, hooksDst) {
|
|
138
|
+
mkdirSync(hooksDst, { recursive: true });
|
|
139
|
+
for (const name of [...HOOK_FILES, 'run_hook.cjs']) {
|
|
140
|
+
const sp = join(hooksSrc, name);
|
|
141
|
+
if (!existsSync(sp)) throw new Error(`Required hook script missing: ${sp}`);
|
|
142
|
+
copyFileSync(sp, join(hooksDst, name));
|
|
143
|
+
}
|
|
144
|
+
for (const name of OPTIONAL_HOOK_FILES) {
|
|
145
|
+
const sp = join(hooksSrc, name);
|
|
146
|
+
if (existsSync(sp)) copyFileSync(sp, join(hooksDst, name));
|
|
147
|
+
}
|
|
148
|
+
const intelSrc = join(hooksSrc, HOOK_INTEL_DIR);
|
|
149
|
+
if (!existsSync(intelSrc) || !statSync(intelSrc).isDirectory()) {
|
|
150
|
+
throw new Error(`Required intel module missing: ${intelSrc}`);
|
|
151
|
+
}
|
|
152
|
+
copyDirRecursive(intelSrc, join(hooksDst, HOOK_INTEL_DIR));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function extractZip(zipPath, destDir) {
|
|
156
|
+
// tar on Windows 10+ and macOS is bsdtar (handles zip); GNU tar on most
|
|
157
|
+
// Linux does not, so try unzip there first. Whatever works first wins.
|
|
158
|
+
const attempts = process.platform === 'linux'
|
|
159
|
+
? [['unzip', ['-o', zipPath, '-d', destDir]], ['tar', ['-xf', zipPath, '-C', destDir]]]
|
|
160
|
+
: [['tar', ['-xf', zipPath, '-C', destDir]], ['unzip', ['-o', zipPath, '-d', destDir]]];
|
|
161
|
+
for (const [cmd, args] of attempts) {
|
|
162
|
+
const result = spawnSync(cmd, args, { stdio: 'ignore' });
|
|
163
|
+
if (!result.error && result.status === 0) return true;
|
|
164
|
+
}
|
|
165
|
+
throw new Error(
|
|
166
|
+
'Could not extract the hooks bundle (need `tar` with zip support or `unzip` on PATH). ' +
|
|
167
|
+
'Alternative: clone https://github.com/ucsandman/DashClaw and re-run from the checkout.',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function downloadHooksBundle(endpoint, hooksDst, { fetchImpl = fetch } = {}) {
|
|
172
|
+
const res = await fetchImpl(`${endpoint}${HOOKS_BUNDLE_PATH}`, { signal: AbortSignal.timeout(30_000) });
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
throw new Error(`Hook bundle download failed (HTTP ${res.status} from ${HOOKS_BUNDLE_PATH}).`);
|
|
175
|
+
}
|
|
176
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
177
|
+
const workDir = join(tmpdir(), `dashclaw-hooks-${Date.now()}`);
|
|
178
|
+
mkdirSync(workDir, { recursive: true });
|
|
179
|
+
try {
|
|
180
|
+
const zipPath = join(workDir, 'hooks.zip');
|
|
181
|
+
writeFileSync(zipPath, buf);
|
|
182
|
+
extractZip(zipPath, workDir);
|
|
183
|
+
const extracted = join(workDir, 'hooks');
|
|
184
|
+
if (!existsSync(join(extracted, 'dashclaw_pretool.py'))) {
|
|
185
|
+
throw new Error('Hook bundle had an unexpected layout (no hooks/dashclaw_pretool.py).');
|
|
186
|
+
}
|
|
187
|
+
mkdirSync(hooksDst, { recursive: true });
|
|
188
|
+
cpSync(extracted, hooksDst, { recursive: true });
|
|
189
|
+
} finally {
|
|
190
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Claude Code settings.json merge
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
const HOOK_EVENTS = {
|
|
199
|
+
// timeout is SECONDS (Claude Code hook schema). 3600000 was a ms value in a
|
|
200
|
+
// seconds field: seconds*1000 overflows the harness's 32-bit timer, the hook
|
|
201
|
+
// is cancelled instantly, and every block/approval wait FAILS OPEN.
|
|
202
|
+
PreToolUse: { matcher: 'Agent|Task|Workflow|Bash|Edit|Write|MultiEdit|Skill|mcp__.*', script: 'dashclaw_pretool.py', timeout: 3660 },
|
|
203
|
+
PostToolUse: { matcher: 'Agent|Task|Workflow|Bash|Edit|Write|MultiEdit|mcp__.*', script: 'dashclaw_posttool.py' },
|
|
204
|
+
Stop: { script: 'dashclaw_stop.py' },
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export function isManagedHookEntry(entry) {
|
|
208
|
+
return (entry?.hooks || []).some((h) => typeof h?.command === 'string' && h.command.includes('dashclaw_'));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Build the managed hook entries pointing at <hooksDir> via <python>. */
|
|
212
|
+
export function buildHookEntries(hooksDir, python, agentId = DEFAULT_AGENT_ID) {
|
|
213
|
+
const entries = {};
|
|
214
|
+
for (const [event, spec] of Object.entries(HOOK_EVENTS)) {
|
|
215
|
+
const hook = {
|
|
216
|
+
type: 'command',
|
|
217
|
+
// --agent-id is the per-harness identity declaration (roadmap v2.2):
|
|
218
|
+
// argv beats the machine-ambient DASHCLAW_AGENT_ID env var, so this
|
|
219
|
+
// install keeps its identity even when another harness exports one.
|
|
220
|
+
command: `${python} "${join(hooksDir, spec.script)}" --agent-id "${agentId}"`,
|
|
221
|
+
...(spec.timeout ? { timeout: spec.timeout } : {}),
|
|
222
|
+
};
|
|
223
|
+
entries[event] = [{ ...(spec.matcher ? { matcher: spec.matcher } : {}), hooks: [hook] }];
|
|
224
|
+
}
|
|
225
|
+
return entries;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Merge the managed hook entries into a Claude Code settings.json file:
|
|
230
|
+
* previous dashclaw-managed entries are replaced, everything else preserved.
|
|
231
|
+
*/
|
|
232
|
+
export function mergeClaudeSettings(settingsPath, hooksDir, python, agentId = DEFAULT_AGENT_ID) {
|
|
233
|
+
let settings = {};
|
|
234
|
+
if (existsSync(settingsPath)) {
|
|
235
|
+
try {
|
|
236
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
237
|
+
} catch {
|
|
238
|
+
throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run.`);
|
|
239
|
+
}
|
|
240
|
+
const bak = settingsPath + '.dashclaw-bak';
|
|
241
|
+
if (!existsSync(bak)) copyFileSync(settingsPath, bak);
|
|
242
|
+
}
|
|
243
|
+
settings.hooks = settings.hooks || {};
|
|
244
|
+
const managed = buildHookEntries(hooksDir, python, agentId);
|
|
245
|
+
for (const [event, entries] of Object.entries(managed)) {
|
|
246
|
+
const existing = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
|
|
247
|
+
settings.hooks[event] = [...existing.filter((e) => !isManagedHookEntry(e)), ...entries];
|
|
248
|
+
}
|
|
249
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
250
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
251
|
+
return settings;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Hook credentials (.env beside the scripts — never in settings.json)
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
export function buildHookEnv({ endpoint, apiKey, agentId, hookMode = 'observe' }) {
|
|
259
|
+
return [
|
|
260
|
+
'# Written by `dashclaw install claude` — credentials for the governance hooks.',
|
|
261
|
+
'# The hooks load this file from beside their scripts; env vars override it.',
|
|
262
|
+
`DASHCLAW_BASE_URL=${endpoint}`,
|
|
263
|
+
`DASHCLAW_API_KEY=${apiKey}`,
|
|
264
|
+
`DASHCLAW_AGENT_ID=${agentId}`,
|
|
265
|
+
`DASHCLAW_HOOK_MODE=${hookMode}`,
|
|
266
|
+
'',
|
|
267
|
+
].join('\n');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Top-level install
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {object} opts
|
|
276
|
+
* @param {string} [opts.endpoint] Instance URL (flag/env resolved by caller)
|
|
277
|
+
* @param {string} [opts.apiKey]
|
|
278
|
+
* @param {string} [opts.agentId]
|
|
279
|
+
* @param {boolean} [opts.trial]
|
|
280
|
+
* @param {string} [opts.homeDir] Injectable for tests
|
|
281
|
+
* @param {Function} [opts.fetchImpl]
|
|
282
|
+
* @param {Function} [opts.pythonProbe] Injected probe for resolvePythonCommand
|
|
283
|
+
* @param {Function} [opts.prompt] (question) => Promise<string>
|
|
284
|
+
* @param {Function} [opts.promptSecret] (question) => Promise<string>
|
|
285
|
+
* @param {Function} [opts.openUrl] Best-effort browser open
|
|
286
|
+
* @param {object} [opts.logger]
|
|
287
|
+
*/
|
|
288
|
+
export async function installClaude({
|
|
289
|
+
endpoint,
|
|
290
|
+
apiKey,
|
|
291
|
+
agentId = DEFAULT_AGENT_ID,
|
|
292
|
+
trial = false,
|
|
293
|
+
homeDir = homedir(),
|
|
294
|
+
env = process.env,
|
|
295
|
+
fetchImpl = fetch,
|
|
296
|
+
pythonProbe,
|
|
297
|
+
prompt,
|
|
298
|
+
promptSecret,
|
|
299
|
+
openUrl = defaultOpenUrl,
|
|
300
|
+
logger = console,
|
|
301
|
+
} = {}) {
|
|
302
|
+
// 1. Resolve endpoint + key -------------------------------------------------
|
|
303
|
+
endpoint = (endpoint || env.DASHCLAW_BASE_URL || '').replace(/\/+$/, '');
|
|
304
|
+
apiKey = apiKey || env.DASHCLAW_API_KEY || '';
|
|
305
|
+
|
|
306
|
+
if (trial && !apiKey) {
|
|
307
|
+
let hostedBase = (endpoint || env.DASHCLAW_HOSTED_URL || '').replace(/\/+$/, '');
|
|
308
|
+
if (!hostedBase) {
|
|
309
|
+
hostedBase = DEFAULT_HOSTED_TRIAL_URL;
|
|
310
|
+
logger.log('');
|
|
311
|
+
logger.log(` Using the public hosted trial: ${DEFAULT_HOSTED_TRIAL_URL}`);
|
|
312
|
+
logger.log(' (Trying your own instance instead? Re-run with --endpoint <url>.)');
|
|
313
|
+
}
|
|
314
|
+
const signupUrl = `${hostedBase.replace(/\/+$/, '')}/connect`;
|
|
315
|
+
logger.log('');
|
|
316
|
+
logger.log(` Opening the trial signup page: ${signupUrl}`);
|
|
317
|
+
logger.log(' Sign in there, copy your trial API key, and paste it below.');
|
|
318
|
+
openUrl(signupUrl);
|
|
319
|
+
endpoint = endpoint || hostedBase.replace(/\/+$/, '');
|
|
320
|
+
apiKey = await mustPrompt(promptSecret || prompt, 'Paste your trial API key (oc_live_...): ');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!endpoint) endpoint = await mustPrompt(prompt, 'DashClaw instance URL (e.g. https://your-dashclaw.vercel.app): ');
|
|
324
|
+
endpoint = endpoint.replace(/\/+$/, '');
|
|
325
|
+
if (!apiKey) apiKey = await mustPrompt(promptSecret || prompt, 'API key (oc_live_...): ');
|
|
326
|
+
|
|
327
|
+
// 2. Preflight (nothing is written before this passes) ----------------------
|
|
328
|
+
logger.log(` Preflight: ${endpoint}/api/health ...`);
|
|
329
|
+
await preflight(endpoint, apiKey, { fetchImpl });
|
|
330
|
+
logger.log(' Preflight OK — instance reachable, key accepted.');
|
|
331
|
+
|
|
332
|
+
// 3. Hooks ------------------------------------------------------------------
|
|
333
|
+
const hooksDir = join(homeDir, '.dashclaw', 'claude-hooks');
|
|
334
|
+
const repoHooks = findRepoHooksDir();
|
|
335
|
+
if (repoHooks) {
|
|
336
|
+
logger.log(` Installing hooks from repo checkout → ${hooksDir}`);
|
|
337
|
+
copyHooksFromRepo(repoHooks, hooksDir);
|
|
338
|
+
} else {
|
|
339
|
+
logger.log(` Downloading hooks bundle from ${endpoint} → ${hooksDir}`);
|
|
340
|
+
await downloadHooksBundle(endpoint, hooksDir, { fetchImpl });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 4. Python + Claude Code settings -------------------------------------------
|
|
344
|
+
const python = resolvePythonCommand(pythonProbe);
|
|
345
|
+
if (!python) {
|
|
346
|
+
throw new Error('No python3 or python found on PATH. Install Python 3.10+ and re-run.');
|
|
347
|
+
}
|
|
348
|
+
const settingsPath = join(homeDir, '.claude', 'settings.json');
|
|
349
|
+
logger.log(` Wiring hooks into ${settingsPath} (python: ${python}, agent: ${agentId})`);
|
|
350
|
+
mergeClaudeSettings(settingsPath, hooksDir, python, agentId);
|
|
351
|
+
|
|
352
|
+
// 5. Credentials -------------------------------------------------------------
|
|
353
|
+
const hookEnvPath = join(hooksDir, '.env');
|
|
354
|
+
writeFileSync(hookEnvPath, buildHookEnv({ endpoint, apiKey, agentId }), { mode: 0o600 });
|
|
355
|
+
const existing = readConfigForHome(homeDir);
|
|
356
|
+
writeConfigForHome(homeDir, { ...existing, baseUrl: endpoint, apiKey, agentId });
|
|
357
|
+
|
|
358
|
+
// 6. Next steps ---------------------------------------------------------------
|
|
359
|
+
logger.log('');
|
|
360
|
+
logger.log(' Done. Claude Code is governed by DashClaw (observe mode).');
|
|
361
|
+
logger.log(` Hooks: ${hooksDir}`);
|
|
362
|
+
logger.log(` Settings: ${settingsPath}`);
|
|
363
|
+
logger.log(` Config: ${join(homeDir, '.dashclaw', 'config.json')}`);
|
|
364
|
+
logger.log('');
|
|
365
|
+
logger.log(' Next steps:');
|
|
366
|
+
logger.log(' 1. Restart Claude Code (hooks load at session start).');
|
|
367
|
+
logger.log(' 2. Run any tool call and watch it appear in your dashboard:');
|
|
368
|
+
logger.log(` ${endpoint}/mission-control`);
|
|
369
|
+
logger.log(` 3. Observe mode logs decisions without blocking. To enforce, set`);
|
|
370
|
+
logger.log(` DASHCLAW_HOOK_MODE=enforce in ${hookEnvPath}`);
|
|
371
|
+
|
|
372
|
+
return { hooksDir, settingsPath, hookEnvPath, python, endpoint, agentId, hookMode: 'observe' };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function mustPrompt(promptFn, question) {
|
|
376
|
+
if (!promptFn) {
|
|
377
|
+
throw new Error(`Missing required value (${question.trim()}) and no interactive prompt available.`);
|
|
378
|
+
}
|
|
379
|
+
const answer = (await promptFn(question)).trim();
|
|
380
|
+
if (!answer) throw new Error('Aborted — a value is required.');
|
|
381
|
+
return answer;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function defaultOpenUrl(url) {
|
|
385
|
+
try {
|
|
386
|
+
if (process.platform === 'win32') spawnSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' });
|
|
387
|
+
else if (process.platform === 'darwin') spawnSync('open', [url], { stdio: 'ignore' });
|
|
388
|
+
else spawnSync('xdg-open', [url], { stdio: 'ignore' });
|
|
389
|
+
} catch {
|
|
390
|
+
// best-effort — the URL is printed either way
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// config.js hardcodes homedir(); these wrappers honor the injectable homeDir
|
|
395
|
+
// (tests) while production passes the real one and shares the same file.
|
|
396
|
+
function readConfigForHome(homeDir) {
|
|
397
|
+
const path = join(homeDir, '.dashclaw', 'config.json');
|
|
398
|
+
if (homeDir === homedir()) return readConfigFile();
|
|
399
|
+
if (!existsSync(path)) return {};
|
|
400
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function writeConfigForHome(homeDir, config) {
|
|
404
|
+
if (homeDir === homedir()) {
|
|
405
|
+
writeConfigFile(config);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const dir = join(homeDir, '.dashclaw');
|
|
409
|
+
mkdirSync(dir, { recursive: true });
|
|
410
|
+
writeFileSync(join(dir, 'config.json'), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
411
|
+
}
|