@dashclaw/cli 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/dashclaw.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync } from 'child_process';
4
+ import { readFileSync } from 'node:fs';
4
5
  import { dirname, resolve } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { DashClaw } from 'dashclaw';
@@ -10,7 +11,7 @@ import {
10
11
  green, red,
11
12
  } from '../lib/render.js';
12
13
  import { runDoctor as runDoctorCommand } from '../lib/doctor.js';
13
- import { resolveConfig, clearConfigFile, configPath } from '../lib/config.js';
14
+ import { resolveConfig, clearConfigFile, configPath, ask, askSecret } from '../lib/config.js';
14
15
  import { runIngest, defaultClaudeProjectsDir } from '../lib/code/ingest.js';
15
16
  import { runMemo } from '../lib/code/memo.js';
16
17
  import { runApply } from '../lib/code/apply.js';
@@ -20,13 +21,18 @@ import {
20
21
  defaultCodexOutDir,
21
22
  } from '../lib/code/ingest-codex.js';
22
23
  import { installCodex, codexConfigPath, codexHooksDir } from '../lib/codex/install.js';
24
+ import { installClaude } from '../lib/claude/install.js';
25
+ import { runCost } from '../lib/cost.js';
23
26
  import { runCodexNotify } from '../lib/codex/notify.js';
24
27
  import { apiRequest } from '../lib/api.js';
25
28
  import { fetchPosture, fetchFindings, fetchNext, resolveFinding } from '../lib/posture.js';
29
+ import { fetchAgentEnv, splitEnvArgv, formatEnvNames, runWithEnv } from '../lib/env.js';
26
30
 
27
31
  process.on('unhandledRejection', (reason) => {
28
32
  console.error('Unhandled Rejection:', reason);
29
- process.exit(1);
33
+ // exitCode (not process.exit) — a hard exit during in-flight I/O trips a
34
+ // libuv teardown assert on Windows.
35
+ process.exitCode = 1;
30
36
  });
31
37
 
32
38
  // -- Config -------------------------------------------------------------------
@@ -54,6 +60,17 @@ function getFlag(name) {
54
60
  return undefined;
55
61
  }
56
62
 
63
+ function incrementCount(counts, key) {
64
+ counts[key] = (counts[key] || 0) + 1;
65
+ }
66
+
67
+ function runSubcommand(table, sub, errorMessage) {
68
+ const handler = table[sub];
69
+ if (handler) return handler();
70
+ console.error(errorMessage(sub));
71
+ process.exitCode = 1;
72
+ }
73
+
57
74
  // -- Commands -----------------------------------------------------------------
58
75
 
59
76
  async function cmdHelp() {
@@ -81,6 +98,10 @@ ${bold('Usage:')}
81
98
  --yes Overwrite existing files when manifest says so
82
99
  --allow-redactions Write files that contain redacted secret patterns
83
100
  --overwrite Clobber existing .NEW side-by-side files
101
+ dashclaw install claude [--trial] Provision DashClaw governance into Claude Code
102
+ --endpoint <url> Your DashClaw instance URL (or DASHCLAW_BASE_URL)
103
+ --key <key> API key (or DASHCLAW_API_KEY; --trial prompts via browser signup)
104
+ --agent-id <id> Agent id for governed actions (default: claude-code)
84
105
  dashclaw install codex Provision DashClaw governance into Codex CLI
85
106
  --project <path> Project to receive AGENTS.md (default: cwd)
86
107
  --approval-policy <p> Codex approval_policy (default: on-request)
@@ -106,11 +127,17 @@ ${bold('Usage:')}
106
127
  dashclaw behavior status Behavior Learning sample status (local recorder)
107
128
  dashclaw behavior suggestions Evidence-backed policy suggestions per agent
108
129
  --agent-id <id> Filter to one agent
130
+ dashclaw env [--agent <id>] -- <cmd...> Run <cmd> with managed secrets injected (memory-only)
131
+ Without -- <cmd>: list secret NAMES + count (never values)
132
+ dashclaw cost Spend readback from /api/finops/spend
133
+ --lens fleet|claude-code Which rollup (default: claude-code)
134
+ --period 7d|30d|90d Window (default: 7d)
109
135
  dashclaw posture Governance posture score + remediation queue
110
136
  dashclaw posture resolve <key> Draft a fix (inactive) | --snooze | --accept-risk
111
137
  --note "..." Attach a note to the resolution
112
138
  dashclaw next The single top open governance gap + its fix
113
139
  dashclaw logout Remove saved config (~/.dashclaw/config.json)
140
+ dashclaw version Print the CLI version
114
141
  dashclaw help Show this help
115
142
 
116
143
  ${bold('Config:')}
@@ -120,6 +147,12 @@ ${bold('Config:')}
120
147
  `);
121
148
  }
122
149
 
150
+ async function cmdVersion() {
151
+ const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
152
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
153
+ console.log(pkg.version);
154
+ }
155
+
123
156
  async function cmdLogout() {
124
157
  const removed = clearConfigFile();
125
158
  if (removed) {
@@ -181,211 +214,192 @@ async function cmdDeny() {
181
214
  }
182
215
  }
183
216
 
184
- async function cmdApprovals() {
185
- const claw = createClient();
217
+ async function fetchPendingApprovals(state) {
218
+ try {
219
+ const result = await state.claw.getPendingApprovals(50);
220
+ state.items = result.actions || [];
221
+ } catch (err) {
222
+ console.error(`Error fetching approvals: ${err.message}`);
223
+ process.exit(1);
224
+ }
225
+ }
186
226
 
187
- let items = [];
188
- let selected = 0;
227
+ function approvalId(action) {
228
+ return action.action_id || action.id;
229
+ }
189
230
 
190
- async function fetchPending() {
191
- try {
192
- const result = await claw.getPendingApprovals(50);
193
- items = result.actions || [];
194
- } catch (err) {
195
- console.error(`Error fetching approvals: ${err.message}`);
196
- process.exit(1);
197
- }
198
- }
231
+ function renderApprovalRow(action, index, selected) {
232
+ const type = action.action_type || '-';
233
+ const agent = action.agent_id || '-';
234
+ const goal = (action.declared_goal || '-').slice(0, 60);
235
+ const risk = action.risk_score != null ? colorByRisk(action.risk_score) : dim('-');
236
+ const line = ` [${index + 1}] ${type} | ${agent} | ${goal} | risk: ${risk}`;
237
+ process.stdout.write((index === selected ? inverse(line) : line) + '\n');
238
+ }
199
239
 
200
- function render() {
201
- clearScreen();
202
- moveCursor(1, 1);
203
- process.stdout.write(bold('DashClaw Approval Inbox') + '\n\n');
240
+ function renderApprovals(state) {
241
+ clearScreen();
242
+ moveCursor(1, 1);
243
+ process.stdout.write(bold('DashClaw Approval Inbox') + '\n\n');
204
244
 
205
- if (items.length === 0) {
206
- process.stdout.write(dim(' No pending approvals.\n'));
207
- process.stdout.write(dim(' Press R to refresh, Q to quit.\n'));
208
- } else {
209
- for (let i = 0; i < items.length; i++) {
210
- const a = items[i];
211
- const id = a.action_id || a.id || '?';
212
- const type = a.action_type || '-';
213
- const agent = a.agent_id || '-';
214
- const goal = (a.declared_goal || '-').slice(0, 60);
215
- const risk = a.risk_score != null ? colorByRisk(a.risk_score) : dim('-');
216
-
217
- const line = ` [${i + 1}] ${type} | ${agent} | ${goal} | risk: ${risk}`;
218
- process.stdout.write((i === selected ? inverse(line) : line) + '\n');
219
- }
220
- }
245
+ if (state.items.length === 0) {
246
+ process.stdout.write(dim(' No pending approvals.\n'));
247
+ process.stdout.write(dim(' Press R to refresh, Q to quit.\n'));
248
+ } else {
249
+ state.items.forEach((action, index) => renderApprovalRow(action, index, state.selected));
250
+ }
221
251
 
222
- process.stdout.write('\n' + dim(' [A] Approve [D] Deny [R] Refresh [O] Open Replay [Q] Quit') + '\n');
252
+ process.stdout.write('\n' + dim(' [A] Approve [D] Deny [R] Refresh [O] Open Replay [Q] Quit') + '\n');
253
+ }
254
+
255
+ function replayUrlIsSafe(url) {
256
+ let parsed;
257
+ try {
258
+ parsed = new URL(url);
259
+ } catch {
260
+ return false;
223
261
  }
262
+ const protocolOk = parsed.protocol === 'http:' || parsed.protocol === 'https:';
263
+ return protocolOk && parsed.hostname && !/\s/.test(url) && !/[&|><^"'`]/.test(url);
264
+ }
224
265
 
225
- function openReplay(actionId) {
226
- const url = `${baseUrl}/replay/${actionId}`;
227
- let parsed;
228
- try {
229
- parsed = new URL(url);
230
- } catch {
231
- process.stdout.write(`\n Invalid URL, cannot open browser.\n`);
232
- return;
233
- }
234
- const protocol = parsed.protocol;
235
- if (protocol !== 'http:' && protocol !== 'https:') {
236
- process.stdout.write(`\n Invalid URL, cannot open browser.\n`);
237
- return;
238
- }
239
- if (!parsed.hostname) {
240
- process.stdout.write(`\n Invalid URL, cannot open browser.\n`);
241
- return;
242
- }
243
- if (/\s/.test(url)) {
244
- process.stdout.write(`\n Invalid URL, cannot open browser.\n`);
245
- return;
246
- }
247
- // Disallow characters that are dangerous when passed through a shell
248
- if (/[&|><^"'`]/.test(url)) {
249
- process.stdout.write(`\n Invalid URL, cannot open browser.\n`);
250
- return;
251
- }
252
- try {
253
- const platform = process.platform;
254
- if (platform === 'darwin') {
255
- execFileSync('open', [url]);
256
- } else if (platform === 'win32') {
257
- // Use PowerShell Start-Process instead of relying on cmd.exe parsing
258
- execFileSync('powershell', ['-NoProfile', '-Command', 'Start-Process', url]);
259
- } else {
260
- execFileSync('xdg-open', [url]);
261
- }
262
- } catch (_) {
263
- process.stdout.write(`\n Could not open browser. URL: ${url}\n`);
266
+ function openReplay(actionId) {
267
+ const url = `${baseUrl}/replay/${actionId}`;
268
+ if (!replayUrlIsSafe(url)) {
269
+ process.stdout.write(`\n Invalid URL, cannot open browser.\n`);
270
+ return;
271
+ }
272
+ try {
273
+ const platform = process.platform;
274
+ if (platform === 'darwin') {
275
+ execFileSync('open', [url]);
276
+ } else if (platform === 'win32') {
277
+ // Use PowerShell Start-Process instead of relying on cmd.exe parsing
278
+ execFileSync('powershell', ['-NoProfile', '-Command', 'Start-Process', url]);
279
+ } else {
280
+ execFileSync('xdg-open', [url]);
264
281
  }
282
+ } catch (_) {
283
+ process.stdout.write(`\n Could not open browser. URL: ${url}\n`);
265
284
  }
285
+ }
266
286
 
267
- await fetchPending();
268
-
269
- // Open SSE stream for live push of new approval requests
270
- let stream = null;
287
+ function startApprovalStream(state) {
271
288
  try {
272
- stream = claw.events()
273
- .on('guard.decision.created', (data) => {
274
- if (data.decision !== 'require_approval') return;
275
- const exists = items.some((it) => (it.action_id || it.id) === data.action_id);
276
- if (exists) return;
277
- items.push(data);
278
- render();
279
- })
289
+ state.stream = state.claw.events()
290
+ .on('guard.decision.created', (data) => handleApprovalPush(state, data))
280
291
  .on('error', () => {
281
- moveCursor(items.length + 6, 1);
292
+ moveCursor(state.items.length + 6, 1);
282
293
  process.stdout.write(dim(' SSE stream error — live push unavailable, use R to refresh') + '\n');
283
294
  });
284
295
  } catch (_) {
285
296
  // SSE unavailable — inbox still works via manual refresh
286
297
  }
298
+ }
299
+
300
+ function handleApprovalPush(state, data) {
301
+ if (data.decision !== 'require_approval') return;
302
+ const exists = state.items.some((it) => approvalId(it) === data.action_id);
303
+ if (exists) return;
304
+ state.items.push(data);
305
+ renderApprovals(state);
306
+ }
287
307
 
288
- // Set up raw mode for interactive input
308
+ function ensureApprovalTty() {
289
309
  if (!process.stdin.isTTY) {
290
310
  console.error('Error: Interactive mode requires a TTY. Use dashclaw approve/deny for non-interactive use.');
291
311
  process.exit(1);
292
312
  }
313
+ }
314
+
315
+ function selectedWithinItems(state) {
316
+ state.selected = Math.min(state.selected, Math.max(0, state.items.length - 1));
317
+ }
293
318
 
319
+ function setupApprovalTerminal(state) {
320
+ ensureApprovalTty();
294
321
  process.stdin.setRawMode(true);
295
322
  process.stdin.resume();
296
323
  process.stdin.setEncoding('utf8');
297
324
  hideCursor();
298
-
299
- // Ensure cleanup on exit
300
- function cleanup() {
301
- if (stream) stream.close();
302
- showCursor();
303
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
304
- process.stdout.write('\n');
305
- }
306
- process.on('exit', cleanup);
325
+ process.on('exit', () => cleanupApprovalInbox(state));
307
326
  process.on('SIGINT', () => process.exit(0));
327
+ }
308
328
 
309
- render();
310
-
311
- let busy = false;
329
+ function cleanupApprovalInbox(state) {
330
+ if (state.stream) state.stream.close();
331
+ showCursor();
332
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
333
+ process.stdout.write('\n');
334
+ }
312
335
 
313
- process.stdin.on('data', async (key) => {
314
- if (busy) return;
336
+ async function withApprovalBusy(state, fn) {
337
+ state.busy = true;
338
+ try {
339
+ await fn();
340
+ } finally {
341
+ state.busy = false;
342
+ }
343
+ }
315
344
 
316
- // Ctrl+C
317
- if (key === '\x03') {
318
- process.exit(0);
319
- }
345
+ async function refreshApprovals(state) {
346
+ await fetchPendingApprovals(state);
347
+ selectedWithinItems(state);
348
+ renderApprovals(state);
349
+ }
320
350
 
321
- // Arrow keys: escape sequences
322
- if (key === '\x1b[A') {
323
- // Up
324
- if (selected > 0) selected--;
325
- render();
326
- return;
327
- }
328
- if (key === '\x1b[B') {
329
- // Down
330
- if (selected < items.length - 1) selected++;
331
- render();
332
- return;
333
- }
351
+ async function decideApproval(state, decision) {
352
+ const actionId = approvalId(state.items[state.selected]);
353
+ try {
354
+ await state.claw.approveAction(actionId, decision);
355
+ state.items.splice(state.selected, 1);
356
+ selectedWithinItems(state);
357
+ } catch (err) {
358
+ moveCursor(state.items.length + 5, 1);
359
+ process.stdout.write(red(` Error: ${err.message}`) + '\n');
360
+ }
361
+ renderApprovals(state);
362
+ }
334
363
 
335
- const ch = key.toLowerCase();
364
+ function moveApprovalSelection(state, delta) {
365
+ const next = state.selected + delta;
366
+ state.selected = Math.max(0, Math.min(next, state.items.length - 1));
367
+ renderApprovals(state);
368
+ }
336
369
 
337
- if (ch === 'q') {
338
- process.exit(0);
339
- }
370
+ const EMPTY_APPROVAL_KEY_ACTIONS = {
371
+ q: () => process.exit(0),
372
+ r: (state) => withApprovalBusy(state, () => refreshApprovals(state)),
373
+ };
340
374
 
341
- if (ch === 'r') {
342
- busy = true;
343
- await fetchPending();
344
- selected = Math.min(selected, Math.max(0, items.length - 1));
345
- render();
346
- busy = false;
347
- return;
348
- }
375
+ const APPROVAL_KEY_ACTIONS = {
376
+ ...EMPTY_APPROVAL_KEY_ACTIONS,
377
+ a: (state) => withApprovalBusy(state, () => decideApproval(state, 'allow')),
378
+ d: (state) => withApprovalBusy(state, () => decideApproval(state, 'deny')),
379
+ o: (state) => openReplay(approvalId(state.items[state.selected])),
380
+ };
349
381
 
350
- if (items.length === 0) return;
351
- const current = items[selected];
352
- const actionId = current.action_id || current.id;
353
-
354
- if (ch === 'a') {
355
- busy = true;
356
- try {
357
- await claw.approveAction(actionId, 'allow');
358
- items.splice(selected, 1);
359
- selected = Math.min(selected, Math.max(0, items.length - 1));
360
- } catch (err) {
361
- moveCursor(items.length + 5, 1);
362
- process.stdout.write(red(` Error: ${err.message}`) + '\n');
363
- }
364
- render();
365
- busy = false;
366
- return;
367
- }
382
+ function approvalActionFor(state, key) {
383
+ if (key === '\x03') return () => process.exit(0);
384
+ if (key === '\x1b[A') return () => moveApprovalSelection(state, -1);
385
+ if (key === '\x1b[B') return () => moveApprovalSelection(state, 1);
386
+ const actions = state.items.length === 0 ? EMPTY_APPROVAL_KEY_ACTIONS : APPROVAL_KEY_ACTIONS;
387
+ return actions[key.toLowerCase()];
388
+ }
368
389
 
369
- if (ch === 'd') {
370
- busy = true;
371
- try {
372
- await claw.approveAction(actionId, 'deny');
373
- items.splice(selected, 1);
374
- selected = Math.min(selected, Math.max(0, items.length - 1));
375
- } catch (err) {
376
- moveCursor(items.length + 5, 1);
377
- process.stdout.write(red(` Error: ${err.message}`) + '\n');
378
- }
379
- render();
380
- busy = false;
381
- return;
382
- }
390
+ async function handleApprovalKey(state, key) {
391
+ if (state.busy) return;
392
+ const action = approvalActionFor(state, key);
393
+ if (action) return action(state);
394
+ }
383
395
 
384
- if (ch === 'o') {
385
- openReplay(actionId);
386
- return;
387
- }
388
- });
396
+ async function cmdApprovals() {
397
+ const state = { claw: createClient(), items: [], selected: 0, busy: false, stream: null };
398
+ await fetchPendingApprovals(state);
399
+ startApprovalStream(state);
400
+ setupApprovalTerminal(state);
401
+ renderApprovals(state);
402
+ process.stdin.on('data', (key) => handleApprovalKey(state, key));
389
403
  }
390
404
 
391
405
  // -- install subcommand group ------------------------------------------------
@@ -423,15 +437,63 @@ async function cmdInstallCodex() {
423
437
  }
424
438
  }
425
439
 
440
+ async function cmdCost() {
441
+ const lens = getFlag('--lens') || 'claude-code';
442
+ const period = getFlag('--period') || '7d';
443
+
444
+ if (!baseUrl || !apiKey) {
445
+ console.error('Not configured. Run `dashclaw install claude` to set up,');
446
+ console.error('or set DASHCLAW_BASE_URL and DASHCLAW_API_KEY.');
447
+ process.exit(1);
448
+ }
449
+
450
+ try {
451
+ console.log(await runCost({ baseUrl, apiKey }, { lens, period }));
452
+ } catch (err) {
453
+ if (err.usage) {
454
+ console.error(err.message);
455
+ } else if (err.status === 401 || err.status === 403) {
456
+ console.error('API key was rejected (401/403). Re-run `dashclaw install claude` or check DASHCLAW_API_KEY.');
457
+ } else {
458
+ console.error(`Could not fetch spend from ${baseUrl}: ${err.message}`);
459
+ console.error('Check that the instance is running and reachable.');
460
+ }
461
+ // exitCode (not process.exit) so node drains the failed fetch socket —
462
+ // a hard exit here can trip a libuv teardown assert on Windows.
463
+ process.exitCode = 1;
464
+ }
465
+ }
466
+
467
+ async function cmdInstallClaude() {
468
+ try {
469
+ const result = await installClaude({
470
+ endpoint: getFlag('--endpoint') || baseUrl,
471
+ apiKey: getFlag('--key') || apiKey,
472
+ agentId: getFlag('--agent-id') || undefined,
473
+ trial: args.includes('--trial'),
474
+ prompt: ask,
475
+ promptSecret: askSecret,
476
+ logger: console,
477
+ });
478
+ console.log();
479
+ console.log(` ${green('Done.')} Claude Code governance installed (mode: ${result.hookMode}).`);
480
+ } catch (err) {
481
+ console.error(red(`Error: ${err.message}`));
482
+ process.exit(1);
483
+ }
484
+ }
485
+
426
486
  async function cmdInstall() {
427
487
  const target = args[1];
428
488
  switch (target) {
429
489
  case 'codex':
430
490
  return cmdInstallCodex();
491
+ case 'claude':
492
+ return cmdInstallClaude();
431
493
  default:
432
494
  console.error(`Unknown install target: dashclaw install ${target || '(missing)'}\n` +
433
- 'Try: dashclaw install codex [--project <path>] [--approval-policy <p>]');
434
- process.exit(1);
495
+ 'Try: dashclaw install claude [--trial] | dashclaw install codex [--project <path>]');
496
+ process.exitCode = 1;
435
497
  }
436
498
  }
437
499
 
@@ -445,26 +507,26 @@ async function cmdCodexNotify() {
445
507
  // Skip the leading 'codex' and 'notify' tokens — runCodexNotify reads the
446
508
  // JSON payload from the LAST argv slot (per Codex's notify contract).
447
509
  const notifyArgv = args.slice(1); // includes 'notify' and the payload
448
- await runCodexNotify({
449
- argv: notifyArgv,
450
- baseUrl,
451
- apiKey,
452
- agentId: agentId || 'codex',
453
- logger: console,
454
- });
510
+ try {
511
+ await runCodexNotify({
512
+ argv: notifyArgv,
513
+ baseUrl,
514
+ apiKey,
515
+ agentId: agentId || 'codex',
516
+ logger: console,
517
+ });
518
+ } catch {
519
+ // Best-effort by contract: Codex must never see a failure from its
520
+ // notify hook, so errors are swallowed and we still exit 0.
521
+ }
455
522
  process.exit(0);
456
523
  }
457
524
 
458
525
  async function cmdCodex() {
459
- const sub = args[1];
460
- switch (sub) {
461
- case 'notify':
462
- return cmdCodexNotify();
463
- default:
464
- console.error(`Unknown subcommand: dashclaw codex ${sub || '(missing)'}\n` +
465
- 'Try: dashclaw codex notify \'<json>\' (called by Codex notify config)');
466
- process.exit(1);
467
- }
526
+ return runSubcommand({
527
+ notify: cmdCodexNotify,
528
+ }, args[1], (sub) => `Unknown subcommand: dashclaw codex ${sub || '(missing)'}\n` +
529
+ 'Try: dashclaw codex notify \'<json>\' (called by Codex notify config)');
468
530
  }
469
531
 
470
532
  // -- code subcommand group ---------------------------------------------------
@@ -487,24 +549,27 @@ async function cmdCodeIngestCodex() {
487
549
  console.log('No sessions to ingest.');
488
550
  return;
489
551
  }
490
- let written = 0, ingested = 0, dryRunCount = 0, skipped = 0, errors = 0;
491
- for (const r of results) {
492
- if (r.status === 'written_local') written++;
493
- else if (r.status === 'ingested') ingested++;
494
- else if (r.status === 'dry_run') dryRunCount++;
495
- else if (r.status === 'skipped') skipped++;
496
- else if (r.status === 'error') {
497
- errors++;
498
- console.error(` ${red('error')} ${r.file}: ${r.reason}${r.detail ? ' — ' + r.detail : ''}`);
499
- }
500
- }
552
+ const counts = tallyCodexIngestResults(results);
501
553
  console.log();
502
- console.log(`Done. Written: ${written} Ingested: ${ingested} Dry-run: ${dryRunCount} Skipped: ${skipped} Errors: ${errors}`);
503
- if (!endpoint && written > 0) {
554
+ console.log(`Done. Written: ${counts.written_local} Ingested: ${counts.ingested} Dry-run: ${counts.dry_run} Skipped: ${counts.skipped} Errors: ${counts.error}`);
555
+ if (!endpoint && counts.written_local > 0) {
504
556
  console.log(dim(` Local sessions saved under ${outDir}.`));
505
557
  console.log(dim(` Server-side codex ingest will be wired in a follow-up phase.`));
506
558
  }
507
- if (errors > 0) process.exit(2);
559
+ if (counts.error > 0) process.exit(2);
560
+ }
561
+
562
+ function tallyCodexIngestResults(results) {
563
+ const counts = { written_local: 0, ingested: 0, dry_run: 0, skipped: 0, error: 0 };
564
+ for (const r of results) {
565
+ incrementCount(counts, r.status);
566
+ if (r.status === 'error') console.error(formatCodexIngestError(r));
567
+ }
568
+ return counts;
569
+ }
570
+
571
+ function formatCodexIngestError(result) {
572
+ return ` ${red('error')} ${result.file}: ${result.reason}${result.detail ? ' — ' + result.detail : ''}`;
508
573
  }
509
574
 
510
575
  async function cmdCodeIngest() {
@@ -518,17 +583,17 @@ async function cmdCodeIngest() {
518
583
  dryRun,
519
584
  });
520
585
  if (!results.length) return;
521
- let ingested = 0;
522
- let skipped = 0;
523
- let errors = 0;
586
+ const counts = { ingested: 0, skipped: 0, error: 0 };
524
587
  for (const r of results) {
525
- if (r.status === 'ingested') ingested++;
526
- else if (r.status === 'skipped_unchanged' || r.status === 'skipped' || r.status === 'dry_run') skipped++;
527
- else if (r.status === 'error') errors++;
588
+ if (r.status === 'skipped_unchanged' || r.status === 'dry_run') {
589
+ incrementCount(counts, 'skipped');
590
+ } else {
591
+ incrementCount(counts, r.status);
592
+ }
528
593
  }
529
594
  console.log();
530
- console.log(`Done. Ingested: ${ingested} Skipped: ${skipped} Errors: ${errors}`);
531
- if (errors > 0) process.exit(2);
595
+ console.log(`Done. Ingested: ${counts.ingested} Skipped: ${counts.skipped} Errors: ${counts.error}`);
596
+ if (counts.error > 0) process.exit(2);
532
597
  }
533
598
 
534
599
  async function cmdCodeMemo() {
@@ -578,24 +643,16 @@ async function cmdCodeApply() {
578
643
  }
579
644
 
580
645
  async function cmdCode() {
581
- const sub = args[1];
582
- switch (sub) {
583
- case 'ingest':
584
- return cmdCodeIngest();
585
- case 'ingest-codex':
586
- return cmdCodeIngestCodex();
587
- case 'memo':
588
- return cmdCodeMemo();
589
- case 'apply':
590
- return cmdCodeApply();
591
- default:
592
- console.error(`Unknown subcommand: dashclaw code ${sub || '(missing)'}\n` +
593
- 'Try: dashclaw code ingest [--dry-run]\n' +
594
- ' dashclaw code ingest-codex [--dry-run] [--out <dir>] [--endpoint <url>]\n' +
595
- ' dashclaw code memo --project=<slug> [--save]\n' +
596
- ' dashclaw code apply <manifestId> --dest=<dir> [--yes]');
597
- process.exit(1);
598
- }
646
+ return runSubcommand({
647
+ ingest: cmdCodeIngest,
648
+ 'ingest-codex': cmdCodeIngestCodex,
649
+ memo: cmdCodeMemo,
650
+ apply: cmdCodeApply,
651
+ }, args[1], (sub) => `Unknown subcommand: dashclaw code ${sub || '(missing)'}\n` +
652
+ 'Try: dashclaw code ingest [--dry-run]\n' +
653
+ ' dashclaw code ingest-codex [--dry-run] [--out <dir>] [--endpoint <url>]\n' +
654
+ ' dashclaw code memo --project=<slug> [--save]\n' +
655
+ ' dashclaw code apply <manifestId> --dest=<dir> [--yes]');
599
656
  }
600
657
 
601
658
  // -- prompts subcommand group ------------------------------------------------
@@ -839,36 +896,42 @@ function inboxClient() {
839
896
  }
840
897
 
841
898
  async function cmdInboxList() {
842
- const unread = args.includes('--unread');
843
- const limitFlag = getFlag('--limit');
844
899
  try {
845
- const data = await apiRequest(inboxClient(), 'GET', '/api/messages', {
846
- query: {
847
- agent_id: agentId,
848
- direction: 'inbox',
849
- unread: unread ? 'true' : undefined,
850
- limit: limitFlag,
851
- },
852
- });
853
- const messages = data.messages || [];
854
- if (messages.length === 0) {
855
- console.log(dim(' No messages.'));
856
- } else {
857
- for (const m of messages) {
858
- const readMark = m.is_read ? dim('read') : green('unread');
859
- console.log(
860
- ` ${bold(m.id)} ${dim('from')} ${m.from_agent_id || '-'} ${m.subject || dim('(no subject)')} [${readMark}]`,
861
- );
862
- }
863
- }
864
- console.log();
865
- console.log(dim(` unread: ${data.unread_count ?? 0}`));
900
+ const data = await apiRequest(inboxClient(), 'GET', '/api/messages', { query: inboxListQuery() });
901
+ renderInboxList(data);
866
902
  } catch (err) {
867
903
  console.error(`Error: ${err.message}`);
868
904
  process.exit(1);
869
905
  }
870
906
  }
871
907
 
908
+ function inboxListQuery() {
909
+ return {
910
+ agent_id: agentId,
911
+ direction: 'inbox',
912
+ unread: args.includes('--unread') ? 'true' : undefined,
913
+ limit: getFlag('--limit'),
914
+ };
915
+ }
916
+
917
+ function renderInboxList(data) {
918
+ const messages = data.messages || [];
919
+ if (messages.length === 0) {
920
+ console.log(dim(' No messages.'));
921
+ } else {
922
+ messages.forEach(renderInboxMessage);
923
+ }
924
+ console.log();
925
+ console.log(dim(` unread: ${data.unread_count ?? 0}`));
926
+ }
927
+
928
+ function renderInboxMessage(message) {
929
+ const readMark = message.is_read ? dim('read') : green('unread');
930
+ console.log(
931
+ ` ${bold(message.id)} ${dim('from')} ${message.from_agent_id || '-'} ${message.subject || dim('(no subject)')} [${readMark}]`,
932
+ );
933
+ }
934
+
872
935
  async function cmdInboxUpdate(action) {
873
936
  const ids = args.slice(2).filter((a) => !a.startsWith('--'));
874
937
  if (ids.length === 0) {
@@ -887,21 +950,14 @@ async function cmdInboxUpdate(action) {
887
950
  }
888
951
 
889
952
  async function cmdInbox() {
890
- const sub = args[1];
891
- switch (sub) {
892
- case 'list':
893
- return cmdInboxList();
894
- case 'read':
895
- return cmdInboxUpdate('read');
896
- case 'archive':
897
- return cmdInboxUpdate('archive');
898
- default:
899
- console.error(`Unknown subcommand: dashclaw inbox ${sub || '(missing)'}\n` +
900
- 'Try: dashclaw inbox list [--unread] [--limit N]\n' +
901
- ' dashclaw inbox read <id> [<id> ...]\n' +
902
- ' dashclaw inbox archive <id> [<id> ...]');
903
- process.exit(1);
904
- }
953
+ return runSubcommand({
954
+ list: cmdInboxList,
955
+ read: () => cmdInboxUpdate('read'),
956
+ archive: () => cmdInboxUpdate('archive'),
957
+ }, args[1], (sub) => `Unknown subcommand: dashclaw inbox ${sub || '(missing)'}\n` +
958
+ 'Try: dashclaw inbox list [--unread] [--limit N]\n' +
959
+ ' dashclaw inbox read <id> [<id> ...]\n' +
960
+ ' dashclaw inbox archive <id> [<id> ...]');
905
961
  }
906
962
 
907
963
  // -- behavior subcommand group -----------------------------------------------
@@ -918,20 +974,28 @@ function behaviorClient() {
918
974
  async function cmdBehaviorStatus() {
919
975
  try {
920
976
  const data = await apiRequest(behaviorClient(), 'GET', '/api/behavior/samples');
921
- console.log(` Recorder: ${data.recorder_enabled ? green('on') : dim('off')}`);
922
- console.log(` Directory: ${dim(data.dir || '-')}`);
923
- console.log(` Samples: ${bold(String(data.sample_count ?? 0))} ${dim('across')} ${data.agent_count ?? 0} ${dim('agent(s)')}`);
924
- console.log(` Window: ${dim((data.oldest_ts || '-') + ' → ' + (data.newest_ts || '-'))}`);
925
- console.log(` Ready: ${data.ready ? green('yes') : dim('no — need ' + (data.min_samples ?? 8) + '+ samples for an agent')}`);
926
- for (const a of data.agents || []) {
927
- console.log(` ${a.agent_id} ${dim(a.count + ' samples')}`);
928
- }
977
+ renderBehaviorStatus(data);
929
978
  } catch (err) {
930
979
  console.error(`Error: ${err.message}`);
931
980
  process.exit(1);
932
981
  }
933
982
  }
934
983
 
984
+ function behaviorReadyLabel(data) {
985
+ return data.ready ? green('yes') : dim('no — need ' + (data.min_samples ?? 8) + '+ samples for an agent');
986
+ }
987
+
988
+ function renderBehaviorStatus(data) {
989
+ console.log(` Recorder: ${data.recorder_enabled ? green('on') : dim('off')}`);
990
+ console.log(` Directory: ${dim(data.dir || '-')}`);
991
+ console.log(` Samples: ${bold(String(data.sample_count ?? 0))} ${dim('across')} ${data.agent_count ?? 0} ${dim('agent(s)')}`);
992
+ console.log(` Window: ${dim((data.oldest_ts || '-') + ' → ' + (data.newest_ts || '-'))}`);
993
+ console.log(` Ready: ${behaviorReadyLabel(data)}`);
994
+ for (const a of data.agents || []) {
995
+ console.log(` ${a.agent_id} ${dim(a.count + ' samples')}`);
996
+ }
997
+ }
998
+
935
999
  async function cmdBehaviorSuggestions() {
936
1000
  const agent = getFlag('--agent-id') || agentId;
937
1001
  try {
@@ -958,18 +1022,12 @@ async function cmdBehaviorSuggestions() {
958
1022
  }
959
1023
 
960
1024
  async function cmdBehavior() {
961
- const sub = args[1];
962
- switch (sub) {
963
- case 'status':
964
- return cmdBehaviorStatus();
965
- case 'suggestions':
966
- return cmdBehaviorSuggestions();
967
- default:
968
- console.error(`Unknown subcommand: dashclaw behavior ${sub || '(missing)'}\n` +
969
- 'Try: dashclaw behavior status\n' +
970
- ' dashclaw behavior suggestions [--agent-id X]');
971
- process.exit(1);
972
- }
1025
+ return runSubcommand({
1026
+ status: cmdBehaviorStatus,
1027
+ suggestions: cmdBehaviorSuggestions,
1028
+ }, args[1], (sub) => `Unknown subcommand: dashclaw behavior ${sub || '(missing)'}\n` +
1029
+ 'Try: dashclaw behavior status\n' +
1030
+ ' dashclaw behavior suggestions [--agent-id X]');
973
1031
  }
974
1032
 
975
1033
  // -- posture subcommand group ------------------------------------------------
@@ -993,31 +1051,47 @@ function printFinding(f, indent = ' ') {
993
1051
  console.log(dim(`${indent} ${f.key}`));
994
1052
  }
995
1053
 
1054
+ function postureStatusLabel(status) {
1055
+ if (status === 'healthy') return green(status);
1056
+ if (status === 'at_risk') return red(status);
1057
+ return status;
1058
+ }
1059
+
1060
+ function renderPostureHeader(data) {
1061
+ console.log();
1062
+ console.log(` ${bold('Governance posture')} ${bold(String(data.score))}${dim('/100')} ${postureStatusLabel(data.status)}` +
1063
+ `${data.cappedBy ? ' ' + red('[capped: incident]') : ''}`);
1064
+ console.log(dim(` ${data.summary?.openFindings ?? 0} open · +${Math.round(data.summary?.pointsRecoverable ?? 0)} points recoverable`));
1065
+ console.log();
1066
+ }
1067
+
1068
+ function renderPostureDimensions(dimensions) {
1069
+ for (const d of dimensions || []) {
1070
+ const label = POSTURE_DIM_LABEL[d.dimension] || d.dimension;
1071
+ const mark = d.score < 70 ? red('!') : ' ';
1072
+ console.log(` ${mark} ${label.padEnd(12)} ${String(d.score).padStart(3)}`);
1073
+ }
1074
+ console.log();
1075
+ }
1076
+
1077
+ function renderPostureQueue(findings) {
1078
+ console.log(dim(` Next — ${findings.length} open`));
1079
+ if (findings.length === 0) {
1080
+ console.log(green(' Queue is clear — no open coverage gaps.'));
1081
+ return;
1082
+ }
1083
+ for (const f of findings.slice(0, 5)) printFinding(f);
1084
+ if (findings.length > 5) console.log(dim(` … ${findings.length - 5} more`));
1085
+ }
1086
+
996
1087
  async function cmdPostureShow() {
997
1088
  try {
998
1089
  const client = postureClient();
999
1090
  const [data, queue] = await Promise.all([fetchPosture(client), fetchFindings(client)]);
1000
- const status = data.status === 'healthy' ? green(data.status)
1001
- : data.status === 'at_risk' ? red(data.status) : data.status;
1002
- console.log();
1003
- console.log(` ${bold('Governance posture')} ${bold(String(data.score))}${dim('/100')} ${status}` +
1004
- `${data.cappedBy ? ' ' + red('[capped: incident]') : ''}`);
1005
- console.log(dim(` ${data.summary?.openFindings ?? 0} open · +${Math.round(data.summary?.pointsRecoverable ?? 0)} points recoverable`));
1006
- console.log();
1007
- for (const d of data.dimensions || []) {
1008
- const label = POSTURE_DIM_LABEL[d.dimension] || d.dimension;
1009
- const mark = d.score < 70 ? red('!') : ' ';
1010
- console.log(` ${mark} ${label.padEnd(12)} ${String(d.score).padStart(3)}`);
1011
- }
1012
- console.log();
1013
1091
  const findings = queue.findings || [];
1014
- console.log(dim(` Next — ${findings.length} open`));
1015
- if (findings.length === 0) {
1016
- console.log(green(' Queue is clear — no open coverage gaps.'));
1017
- } else {
1018
- for (const f of findings.slice(0, 5)) printFinding(f);
1019
- if (findings.length > 5) console.log(dim(` … ${findings.length - 5} more`));
1020
- }
1092
+ renderPostureHeader(data);
1093
+ renderPostureDimensions(data.dimensions);
1094
+ renderPostureQueue(findings);
1021
1095
  console.log();
1022
1096
  } catch (err) {
1023
1097
  console.error(`Error: ${err.message}`);
@@ -1077,18 +1151,79 @@ async function cmdNext() {
1077
1151
  }
1078
1152
  }
1079
1153
 
1154
+ // -- env command ---------------------------------------------------------------
1155
+ //
1156
+ // `dashclaw env [--agent <id>] -- <command...>` — managed-secrets delivery.
1157
+ // Fetches the delivery-enabled bundle (GET /api/secrets/env) and spawns the
1158
+ // child command with the values injected into its environment MEMORY-ONLY.
1159
+ // Secret VALUES are never printed, never written to disk, never echoed in
1160
+ // error paths; only NAMES are ever shown. Fail-closed: if the fetch fails,
1161
+ // the child is NOT run. Uses process.exitCode (not process.exit) so node
1162
+ // drains sockets — a hard exit can trip a libuv teardown assert on Windows.
1163
+
1164
+ async function cmdEnv() {
1165
+ const { flags, command: childArgv } = splitEnvArgv(args.slice(1));
1166
+
1167
+ if (flags.includes('--print')) {
1168
+ console.error('Error: --print is not supported. Secret values are memory-only by design —');
1169
+ console.error('they are injected into a child process environment, never printed or written to disk.');
1170
+ console.error('Use `dashclaw env` to list secret NAMES, or `dashclaw env -- <command>` to run with them.');
1171
+ process.exitCode = 1;
1172
+ return;
1173
+ }
1174
+
1175
+ let agent = agentId;
1176
+ for (let i = 0; i < flags.length; i++) {
1177
+ if (flags[i] === '--agent') agent = flags[i + 1];
1178
+ else if (flags[i].startsWith('--agent=')) agent = flags[i].slice('--agent='.length);
1179
+ }
1180
+ if (!agent) {
1181
+ console.error('Error: no agent id. Pass --agent <id> or set DASHCLAW_AGENT_ID.');
1182
+ process.exitCode = 1;
1183
+ return;
1184
+ }
1185
+
1186
+ let bundle;
1187
+ try {
1188
+ bundle = await fetchAgentEnv({ baseUrl, apiKey }, agent);
1189
+ } catch (err) {
1190
+ console.error(`Error: could not fetch the managed-secrets bundle: ${err.message}`);
1191
+ if (childArgv.length > 0) {
1192
+ console.error('Fail-closed: the command was NOT run without the requested env.');
1193
+ }
1194
+ process.exitCode = 1;
1195
+ return;
1196
+ }
1197
+
1198
+ if (childArgv.length === 0) {
1199
+ console.log(formatEnvNames(bundle));
1200
+ return;
1201
+ }
1202
+
1203
+ process.exitCode = await runWithEnv(bundle, childArgv);
1204
+ }
1205
+
1080
1206
  // -- Router -------------------------------------------------------------------
1081
1207
 
1082
- const COMMANDS_NEEDING_CONFIG = new Set(['approvals', 'approve', 'deny', 'doctor', 'code', 'prompts', 'inbox', 'behavior', 'posture', 'next']);
1208
+ const COMMANDS_NEEDING_CONFIG = new Set(['approvals', 'approve', 'deny', 'doctor', 'code', 'prompts', 'inbox', 'behavior', 'posture', 'next', 'env']);
1083
1209
  // `install` deliberately omitted: provisioning hooks and AGENTS.md shouldn't
1084
1210
  // require the user to have already configured API keys. If config happens to
1085
1211
  // be present, install will pick up baseUrl for the AGENTS.md instance link.
1086
1212
  // `codex notify` also opt-in: if no config, the notify fail-softs to skipped
1087
1213
  // rather than erroring (Codex never sees the error anyway — it spawns with
1088
1214
  // stdio nulled).
1089
- const COMMANDS_OPTIONAL_CONFIG = new Set(['install', 'codex']);
1215
+ // `cost` resolves config non-interactively and prints its own setup hint when
1216
+ // missing (the generic interactive prompt would be the wrong UX for a
1217
+ // read-only readback command).
1218
+ const COMMANDS_OPTIONAL_CONFIG = new Set(['install', 'codex', 'cost']);
1219
+
1220
+ function applyConfig(config) {
1221
+ baseUrl = config.baseUrl;
1222
+ apiKey = config.apiKey;
1223
+ agentId = config.agentId;
1224
+ }
1090
1225
 
1091
- async function main() {
1226
+ async function loadCommandConfig() {
1092
1227
  if (COMMANDS_NEEDING_CONFIG.has(command)) {
1093
1228
  const config = await resolveConfig();
1094
1229
  if (!config) {
@@ -1096,68 +1231,51 @@ async function main() {
1096
1231
  console.error('Set them as env vars, save with an interactive first run, or use a .env file.');
1097
1232
  process.exit(1);
1098
1233
  }
1099
- baseUrl = config.baseUrl;
1100
- apiKey = config.apiKey;
1101
- agentId = config.agentId;
1102
- } else if (COMMANDS_OPTIONAL_CONFIG.has(command)) {
1234
+ applyConfig(config);
1235
+ return;
1236
+ }
1237
+ if (COMMANDS_OPTIONAL_CONFIG.has(command)) {
1103
1238
  const config = await resolveConfig({ interactive: false }).catch(() => null);
1104
1239
  if (config) {
1105
- baseUrl = config.baseUrl;
1106
- apiKey = config.apiKey;
1107
- agentId = config.agentId;
1240
+ applyConfig(config);
1108
1241
  }
1109
1242
  }
1243
+ }
1110
1244
 
1111
- switch (command) {
1112
- case 'approvals':
1113
- await cmdApprovals();
1114
- break;
1115
- case 'approve':
1116
- await cmdApprove();
1117
- break;
1118
- case 'deny':
1119
- await cmdDeny();
1120
- break;
1121
- case 'doctor':
1122
- await cmdDoctor();
1123
- break;
1124
- case 'logout':
1125
- await cmdLogout();
1126
- break;
1127
- case 'code':
1128
- await cmdCode();
1129
- break;
1130
- case 'install':
1131
- await cmdInstall();
1132
- break;
1133
- case 'codex':
1134
- await cmdCodex();
1135
- break;
1136
- case 'prompts':
1137
- await cmdPrompts();
1138
- break;
1139
- case 'inbox':
1140
- await cmdInbox();
1141
- break;
1142
- case 'behavior':
1143
- await cmdBehavior();
1144
- break;
1145
- case 'posture':
1146
- await cmdPosture();
1147
- break;
1148
- case 'next':
1149
- await cmdNext();
1150
- break;
1151
- case 'help':
1152
- case '--help':
1153
- case '-h':
1154
- await cmdHelp();
1155
- break;
1156
- default:
1157
- console.error(`Unknown command: ${command}`);
1158
- await cmdHelp();
1159
- process.exit(1);
1245
+ const COMMAND_HANDLERS = {
1246
+ approvals: cmdApprovals,
1247
+ approve: cmdApprove,
1248
+ deny: cmdDeny,
1249
+ doctor: cmdDoctor,
1250
+ logout: cmdLogout,
1251
+ code: cmdCode,
1252
+ install: cmdInstall,
1253
+ codex: cmdCodex,
1254
+ prompts: cmdPrompts,
1255
+ inbox: cmdInbox,
1256
+ behavior: cmdBehavior,
1257
+ posture: cmdPosture,
1258
+ cost: cmdCost,
1259
+ next: cmdNext,
1260
+ env: cmdEnv,
1261
+ help: cmdHelp,
1262
+ '--help': cmdHelp,
1263
+ '-h': cmdHelp,
1264
+ version: cmdVersion,
1265
+ '--version': cmdVersion,
1266
+ '-v': cmdVersion,
1267
+ };
1268
+
1269
+ async function main() {
1270
+ await loadCommandConfig();
1271
+ const handler = COMMAND_HANDLERS[command];
1272
+ if (handler) {
1273
+ await handler();
1274
+ return;
1160
1275
  }
1276
+ console.error(`Unknown command: ${command}`);
1277
+ await cmdHelp();
1278
+ process.exitCode = 1;
1161
1279
  }
1162
1280
 
1163
1281
  main();