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