@dashclaw/cli 0.3.1 → 0.4.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/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() {
@@ -64,10 +81,12 @@ ${bold('Usage:')}
64
81
  dashclaw approvals Interactive approval inbox
65
82
  dashclaw approve <actionId> [--reason] Approve an action
66
83
  dashclaw deny <actionId> [--reason] Deny an action
67
- dashclaw doctor Diagnose and auto-fix your DashClaw instance
84
+ dashclaw doctor Diagnose your instance + this machine (report-only)
85
+ --fix Apply safe auto-fixes, then re-check and report
68
86
  --json Output as JSON (for CI/scripts)
69
- --no-fix Diagnose only, skip auto-fixes
70
- --category <list> Filter checks (e.g., database,config)
87
+ --no-fix Accepted no-op alias (report-only is the default)
88
+ --category <list> Filter remote checks (e.g., database,config)
89
+ --repo <path> Treat <path> as the DashClaw checkout for repo checks
71
90
  dashclaw code ingest [--dry-run] Backfill Claude Code transcripts from ~/.claude/projects
72
91
  --projects-dir <path> Override the default scan directory
73
92
  dashclaw code ingest-codex [--dry-run] Backfill Codex transcripts from ~/.codex/sessions
@@ -81,6 +100,10 @@ ${bold('Usage:')}
81
100
  --yes Overwrite existing files when manifest says so
82
101
  --allow-redactions Write files that contain redacted secret patterns
83
102
  --overwrite Clobber existing .NEW side-by-side files
103
+ dashclaw install claude [--trial] Provision DashClaw governance into Claude Code
104
+ --endpoint <url> Your DashClaw instance URL (or DASHCLAW_BASE_URL)
105
+ --key <key> API key (or DASHCLAW_API_KEY; --trial prompts via browser signup)
106
+ --agent-id <id> Agent id for governed actions (default: claude-code)
84
107
  dashclaw install codex Provision DashClaw governance into Codex CLI
85
108
  --project <path> Project to receive AGENTS.md (default: cwd)
86
109
  --approval-policy <p> Codex approval_policy (default: on-request)
@@ -106,11 +129,17 @@ ${bold('Usage:')}
106
129
  dashclaw behavior status Behavior Learning sample status (local recorder)
107
130
  dashclaw behavior suggestions Evidence-backed policy suggestions per agent
108
131
  --agent-id <id> Filter to one agent
132
+ dashclaw env [--agent <id>] -- <cmd...> Run <cmd> with managed secrets injected (memory-only)
133
+ Without -- <cmd>: list secret NAMES + count (never values)
134
+ dashclaw cost Spend readback from /api/finops/spend
135
+ --lens fleet|claude-code Which rollup (default: claude-code)
136
+ --period 7d|30d|90d Window (default: 7d)
109
137
  dashclaw posture Governance posture score + remediation queue
110
138
  dashclaw posture resolve <key> Draft a fix (inactive) | --snooze | --accept-risk
111
139
  --note "..." Attach a note to the resolution
112
140
  dashclaw next The single top open governance gap + its fix
113
141
  dashclaw logout Remove saved config (~/.dashclaw/config.json)
142
+ dashclaw version Print the CLI version
114
143
  dashclaw help Show this help
115
144
 
116
145
  ${bold('Config:')}
@@ -120,6 +149,12 @@ ${bold('Config:')}
120
149
  `);
121
150
  }
122
151
 
152
+ async function cmdVersion() {
153
+ const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
154
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
155
+ console.log(pkg.version);
156
+ }
157
+
123
158
  async function cmdLogout() {
124
159
  const removed = clearConfigFile();
125
160
  if (removed) {
@@ -131,15 +166,23 @@ async function cmdLogout() {
131
166
 
132
167
  async function cmdDoctor() {
133
168
  const jsonFlag = args.includes('--json');
169
+ const fixFlag = args.includes('--fix');
134
170
  const noFixFlag = args.includes('--no-fix');
135
171
  const catIdx = args.indexOf('--category');
136
172
  const catValue = catIdx !== -1 ? args[catIdx + 1] : undefined;
173
+ const repoIdx = args.indexOf('--repo');
174
+ const repoValue = repoIdx !== -1 ? args[repoIdx + 1] : undefined;
175
+ const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
176
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
137
177
  await runDoctorCommand({
138
178
  baseUrl,
139
179
  apiKey,
140
180
  json: jsonFlag,
181
+ fix: fixFlag,
141
182
  noFix: noFixFlag,
142
183
  category: catValue,
184
+ repo: repoValue,
185
+ cliVersion: pkg.version,
143
186
  });
144
187
  }
145
188
 
@@ -181,211 +224,192 @@ async function cmdDeny() {
181
224
  }
182
225
  }
183
226
 
184
- async function cmdApprovals() {
185
- const claw = createClient();
227
+ async function fetchPendingApprovals(state) {
228
+ try {
229
+ const result = await state.claw.getPendingApprovals(50);
230
+ state.items = result.actions || [];
231
+ } catch (err) {
232
+ console.error(`Error fetching approvals: ${err.message}`);
233
+ process.exit(1);
234
+ }
235
+ }
186
236
 
187
- let items = [];
188
- let selected = 0;
237
+ function approvalId(action) {
238
+ return action.action_id || action.id;
239
+ }
189
240
 
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
- }
241
+ function renderApprovalRow(action, index, selected) {
242
+ const type = action.action_type || '-';
243
+ const agent = action.agent_id || '-';
244
+ const goal = (action.declared_goal || '-').slice(0, 60);
245
+ const risk = action.risk_score != null ? colorByRisk(action.risk_score) : dim('-');
246
+ const line = ` [${index + 1}] ${type} | ${agent} | ${goal} | risk: ${risk}`;
247
+ process.stdout.write((index === selected ? inverse(line) : line) + '\n');
248
+ }
199
249
 
200
- function render() {
201
- clearScreen();
202
- moveCursor(1, 1);
203
- process.stdout.write(bold('DashClaw Approval Inbox') + '\n\n');
250
+ function renderApprovals(state) {
251
+ clearScreen();
252
+ moveCursor(1, 1);
253
+ process.stdout.write(bold('DashClaw Approval Inbox') + '\n\n');
204
254
 
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
- }
255
+ if (state.items.length === 0) {
256
+ process.stdout.write(dim(' No pending approvals.\n'));
257
+ process.stdout.write(dim(' Press R to refresh, Q to quit.\n'));
258
+ } else {
259
+ state.items.forEach((action, index) => renderApprovalRow(action, index, state.selected));
260
+ }
221
261
 
222
- process.stdout.write('\n' + dim(' [A] Approve [D] Deny [R] Refresh [O] Open Replay [Q] Quit') + '\n');
262
+ process.stdout.write('\n' + dim(' [A] Approve [D] Deny [R] Refresh [O] Open Replay [Q] Quit') + '\n');
263
+ }
264
+
265
+ function replayUrlIsSafe(url) {
266
+ let parsed;
267
+ try {
268
+ parsed = new URL(url);
269
+ } catch {
270
+ return false;
223
271
  }
272
+ const protocolOk = parsed.protocol === 'http:' || parsed.protocol === 'https:';
273
+ return protocolOk && parsed.hostname && !/\s/.test(url) && !/[&|><^"'`]/.test(url);
274
+ }
224
275
 
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`);
276
+ function openReplay(actionId) {
277
+ const url = `${baseUrl}/replay/${actionId}`;
278
+ if (!replayUrlIsSafe(url)) {
279
+ process.stdout.write(`\n Invalid URL, cannot open browser.\n`);
280
+ return;
281
+ }
282
+ try {
283
+ const platform = process.platform;
284
+ if (platform === 'darwin') {
285
+ execFileSync('open', [url]);
286
+ } else if (platform === 'win32') {
287
+ // Use PowerShell Start-Process instead of relying on cmd.exe parsing
288
+ execFileSync('powershell', ['-NoProfile', '-Command', 'Start-Process', url]);
289
+ } else {
290
+ execFileSync('xdg-open', [url]);
264
291
  }
292
+ } catch (_) {
293
+ process.stdout.write(`\n Could not open browser. URL: ${url}\n`);
265
294
  }
295
+ }
266
296
 
267
- await fetchPending();
268
-
269
- // Open SSE stream for live push of new approval requests
270
- let stream = null;
297
+ function startApprovalStream(state) {
271
298
  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
- })
299
+ state.stream = state.claw.events()
300
+ .on('guard.decision.created', (data) => handleApprovalPush(state, data))
280
301
  .on('error', () => {
281
- moveCursor(items.length + 6, 1);
302
+ moveCursor(state.items.length + 6, 1);
282
303
  process.stdout.write(dim(' SSE stream error — live push unavailable, use R to refresh') + '\n');
283
304
  });
284
305
  } catch (_) {
285
306
  // SSE unavailable — inbox still works via manual refresh
286
307
  }
308
+ }
309
+
310
+ function handleApprovalPush(state, data) {
311
+ if (data.decision !== 'require_approval') return;
312
+ const exists = state.items.some((it) => approvalId(it) === data.action_id);
313
+ if (exists) return;
314
+ state.items.push(data);
315
+ renderApprovals(state);
316
+ }
287
317
 
288
- // Set up raw mode for interactive input
318
+ function ensureApprovalTty() {
289
319
  if (!process.stdin.isTTY) {
290
320
  console.error('Error: Interactive mode requires a TTY. Use dashclaw approve/deny for non-interactive use.');
291
321
  process.exit(1);
292
322
  }
323
+ }
324
+
325
+ function selectedWithinItems(state) {
326
+ state.selected = Math.min(state.selected, Math.max(0, state.items.length - 1));
327
+ }
293
328
 
329
+ function setupApprovalTerminal(state) {
330
+ ensureApprovalTty();
294
331
  process.stdin.setRawMode(true);
295
332
  process.stdin.resume();
296
333
  process.stdin.setEncoding('utf8');
297
334
  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);
335
+ process.on('exit', () => cleanupApprovalInbox(state));
307
336
  process.on('SIGINT', () => process.exit(0));
337
+ }
308
338
 
309
- render();
310
-
311
- let busy = false;
339
+ function cleanupApprovalInbox(state) {
340
+ if (state.stream) state.stream.close();
341
+ showCursor();
342
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
343
+ process.stdout.write('\n');
344
+ }
312
345
 
313
- process.stdin.on('data', async (key) => {
314
- if (busy) return;
346
+ async function withApprovalBusy(state, fn) {
347
+ state.busy = true;
348
+ try {
349
+ await fn();
350
+ } finally {
351
+ state.busy = false;
352
+ }
353
+ }
315
354
 
316
- // Ctrl+C
317
- if (key === '\x03') {
318
- process.exit(0);
319
- }
355
+ async function refreshApprovals(state) {
356
+ await fetchPendingApprovals(state);
357
+ selectedWithinItems(state);
358
+ renderApprovals(state);
359
+ }
320
360
 
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
- }
361
+ async function decideApproval(state, decision) {
362
+ const actionId = approvalId(state.items[state.selected]);
363
+ try {
364
+ await state.claw.approveAction(actionId, decision);
365
+ state.items.splice(state.selected, 1);
366
+ selectedWithinItems(state);
367
+ } catch (err) {
368
+ moveCursor(state.items.length + 5, 1);
369
+ process.stdout.write(red(` Error: ${err.message}`) + '\n');
370
+ }
371
+ renderApprovals(state);
372
+ }
334
373
 
335
- const ch = key.toLowerCase();
374
+ function moveApprovalSelection(state, delta) {
375
+ const next = state.selected + delta;
376
+ state.selected = Math.max(0, Math.min(next, state.items.length - 1));
377
+ renderApprovals(state);
378
+ }
336
379
 
337
- if (ch === 'q') {
338
- process.exit(0);
339
- }
380
+ const EMPTY_APPROVAL_KEY_ACTIONS = {
381
+ q: () => process.exit(0),
382
+ r: (state) => withApprovalBusy(state, () => refreshApprovals(state)),
383
+ };
340
384
 
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
- }
385
+ const APPROVAL_KEY_ACTIONS = {
386
+ ...EMPTY_APPROVAL_KEY_ACTIONS,
387
+ a: (state) => withApprovalBusy(state, () => decideApproval(state, 'allow')),
388
+ d: (state) => withApprovalBusy(state, () => decideApproval(state, 'deny')),
389
+ o: (state) => openReplay(approvalId(state.items[state.selected])),
390
+ };
349
391
 
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
- }
392
+ function approvalActionFor(state, key) {
393
+ if (key === '\x03') return () => process.exit(0);
394
+ if (key === '\x1b[A') return () => moveApprovalSelection(state, -1);
395
+ if (key === '\x1b[B') return () => moveApprovalSelection(state, 1);
396
+ const actions = state.items.length === 0 ? EMPTY_APPROVAL_KEY_ACTIONS : APPROVAL_KEY_ACTIONS;
397
+ return actions[key.toLowerCase()];
398
+ }
368
399
 
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
- }
400
+ async function handleApprovalKey(state, key) {
401
+ if (state.busy) return;
402
+ const action = approvalActionFor(state, key);
403
+ if (action) return action(state);
404
+ }
383
405
 
384
- if (ch === 'o') {
385
- openReplay(actionId);
386
- return;
387
- }
388
- });
406
+ async function cmdApprovals() {
407
+ const state = { claw: createClient(), items: [], selected: 0, busy: false, stream: null };
408
+ await fetchPendingApprovals(state);
409
+ startApprovalStream(state);
410
+ setupApprovalTerminal(state);
411
+ renderApprovals(state);
412
+ process.stdin.on('data', (key) => handleApprovalKey(state, key));
389
413
  }
390
414
 
391
415
  // -- install subcommand group ------------------------------------------------
@@ -423,15 +447,63 @@ async function cmdInstallCodex() {
423
447
  }
424
448
  }
425
449
 
450
+ async function cmdCost() {
451
+ const lens = getFlag('--lens') || 'claude-code';
452
+ const period = getFlag('--period') || '7d';
453
+
454
+ if (!baseUrl || !apiKey) {
455
+ console.error('Not configured. Run `dashclaw install claude` to set up,');
456
+ console.error('or set DASHCLAW_BASE_URL and DASHCLAW_API_KEY.');
457
+ process.exit(1);
458
+ }
459
+
460
+ try {
461
+ console.log(await runCost({ baseUrl, apiKey }, { lens, period }));
462
+ } catch (err) {
463
+ if (err.usage) {
464
+ console.error(err.message);
465
+ } else if (err.status === 401 || err.status === 403) {
466
+ console.error('API key was rejected (401/403). Re-run `dashclaw install claude` or check DASHCLAW_API_KEY.');
467
+ } else {
468
+ console.error(`Could not fetch spend from ${baseUrl}: ${err.message}`);
469
+ console.error('Check that the instance is running and reachable.');
470
+ }
471
+ // exitCode (not process.exit) so node drains the failed fetch socket —
472
+ // a hard exit here can trip a libuv teardown assert on Windows.
473
+ process.exitCode = 1;
474
+ }
475
+ }
476
+
477
+ async function cmdInstallClaude() {
478
+ try {
479
+ const result = await installClaude({
480
+ endpoint: getFlag('--endpoint') || baseUrl,
481
+ apiKey: getFlag('--key') || apiKey,
482
+ agentId: getFlag('--agent-id') || undefined,
483
+ trial: args.includes('--trial'),
484
+ prompt: ask,
485
+ promptSecret: askSecret,
486
+ logger: console,
487
+ });
488
+ console.log();
489
+ console.log(` ${green('Done.')} Claude Code governance installed (mode: ${result.hookMode}).`);
490
+ } catch (err) {
491
+ console.error(red(`Error: ${err.message}`));
492
+ process.exit(1);
493
+ }
494
+ }
495
+
426
496
  async function cmdInstall() {
427
497
  const target = args[1];
428
498
  switch (target) {
429
499
  case 'codex':
430
500
  return cmdInstallCodex();
501
+ case 'claude':
502
+ return cmdInstallClaude();
431
503
  default:
432
504
  console.error(`Unknown install target: dashclaw install ${target || '(missing)'}\n` +
433
- 'Try: dashclaw install codex [--project <path>] [--approval-policy <p>]');
434
- process.exit(1);
505
+ 'Try: dashclaw install claude [--trial] | dashclaw install codex [--project <path>]');
506
+ process.exitCode = 1;
435
507
  }
436
508
  }
437
509
 
@@ -445,26 +517,26 @@ async function cmdCodexNotify() {
445
517
  // Skip the leading 'codex' and 'notify' tokens — runCodexNotify reads the
446
518
  // JSON payload from the LAST argv slot (per Codex's notify contract).
447
519
  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
- });
520
+ try {
521
+ await runCodexNotify({
522
+ argv: notifyArgv,
523
+ baseUrl,
524
+ apiKey,
525
+ agentId: agentId || 'codex',
526
+ logger: console,
527
+ });
528
+ } catch {
529
+ // Best-effort by contract: Codex must never see a failure from its
530
+ // notify hook, so errors are swallowed and we still exit 0.
531
+ }
455
532
  process.exit(0);
456
533
  }
457
534
 
458
535
  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
- }
536
+ return runSubcommand({
537
+ notify: cmdCodexNotify,
538
+ }, args[1], (sub) => `Unknown subcommand: dashclaw codex ${sub || '(missing)'}\n` +
539
+ 'Try: dashclaw codex notify \'<json>\' (called by Codex notify config)');
468
540
  }
469
541
 
470
542
  // -- code subcommand group ---------------------------------------------------
@@ -487,24 +559,27 @@ async function cmdCodeIngestCodex() {
487
559
  console.log('No sessions to ingest.');
488
560
  return;
489
561
  }
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
- }
562
+ const counts = tallyCodexIngestResults(results);
501
563
  console.log();
502
- console.log(`Done. Written: ${written} Ingested: ${ingested} Dry-run: ${dryRunCount} Skipped: ${skipped} Errors: ${errors}`);
503
- if (!endpoint && written > 0) {
564
+ console.log(`Done. Written: ${counts.written_local} Ingested: ${counts.ingested} Dry-run: ${counts.dry_run} Skipped: ${counts.skipped} Errors: ${counts.error}`);
565
+ if (!endpoint && counts.written_local > 0) {
504
566
  console.log(dim(` Local sessions saved under ${outDir}.`));
505
567
  console.log(dim(` Server-side codex ingest will be wired in a follow-up phase.`));
506
568
  }
507
- if (errors > 0) process.exit(2);
569
+ if (counts.error > 0) process.exit(2);
570
+ }
571
+
572
+ function tallyCodexIngestResults(results) {
573
+ const counts = { written_local: 0, ingested: 0, dry_run: 0, skipped: 0, error: 0 };
574
+ for (const r of results) {
575
+ incrementCount(counts, r.status);
576
+ if (r.status === 'error') console.error(formatCodexIngestError(r));
577
+ }
578
+ return counts;
579
+ }
580
+
581
+ function formatCodexIngestError(result) {
582
+ return ` ${red('error')} ${result.file}: ${result.reason}${result.detail ? ' — ' + result.detail : ''}`;
508
583
  }
509
584
 
510
585
  async function cmdCodeIngest() {
@@ -518,17 +593,17 @@ async function cmdCodeIngest() {
518
593
  dryRun,
519
594
  });
520
595
  if (!results.length) return;
521
- let ingested = 0;
522
- let skipped = 0;
523
- let errors = 0;
596
+ const counts = { ingested: 0, skipped: 0, error: 0 };
524
597
  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++;
598
+ if (r.status === 'skipped_unchanged' || r.status === 'dry_run') {
599
+ incrementCount(counts, 'skipped');
600
+ } else {
601
+ incrementCount(counts, r.status);
602
+ }
528
603
  }
529
604
  console.log();
530
- console.log(`Done. Ingested: ${ingested} Skipped: ${skipped} Errors: ${errors}`);
531
- if (errors > 0) process.exit(2);
605
+ console.log(`Done. Ingested: ${counts.ingested} Skipped: ${counts.skipped} Errors: ${counts.error}`);
606
+ if (counts.error > 0) process.exit(2);
532
607
  }
533
608
 
534
609
  async function cmdCodeMemo() {
@@ -578,24 +653,16 @@ async function cmdCodeApply() {
578
653
  }
579
654
 
580
655
  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
- }
656
+ return runSubcommand({
657
+ ingest: cmdCodeIngest,
658
+ 'ingest-codex': cmdCodeIngestCodex,
659
+ memo: cmdCodeMemo,
660
+ apply: cmdCodeApply,
661
+ }, args[1], (sub) => `Unknown subcommand: dashclaw code ${sub || '(missing)'}\n` +
662
+ 'Try: dashclaw code ingest [--dry-run]\n' +
663
+ ' dashclaw code ingest-codex [--dry-run] [--out <dir>] [--endpoint <url>]\n' +
664
+ ' dashclaw code memo --project=<slug> [--save]\n' +
665
+ ' dashclaw code apply <manifestId> --dest=<dir> [--yes]');
599
666
  }
600
667
 
601
668
  // -- prompts subcommand group ------------------------------------------------
@@ -839,36 +906,42 @@ function inboxClient() {
839
906
  }
840
907
 
841
908
  async function cmdInboxList() {
842
- const unread = args.includes('--unread');
843
- const limitFlag = getFlag('--limit');
844
909
  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}`));
910
+ const data = await apiRequest(inboxClient(), 'GET', '/api/messages', { query: inboxListQuery() });
911
+ renderInboxList(data);
866
912
  } catch (err) {
867
913
  console.error(`Error: ${err.message}`);
868
914
  process.exit(1);
869
915
  }
870
916
  }
871
917
 
918
+ function inboxListQuery() {
919
+ return {
920
+ agent_id: agentId,
921
+ direction: 'inbox',
922
+ unread: args.includes('--unread') ? 'true' : undefined,
923
+ limit: getFlag('--limit'),
924
+ };
925
+ }
926
+
927
+ function renderInboxList(data) {
928
+ const messages = data.messages || [];
929
+ if (messages.length === 0) {
930
+ console.log(dim(' No messages.'));
931
+ } else {
932
+ messages.forEach(renderInboxMessage);
933
+ }
934
+ console.log();
935
+ console.log(dim(` unread: ${data.unread_count ?? 0}`));
936
+ }
937
+
938
+ function renderInboxMessage(message) {
939
+ const readMark = message.is_read ? dim('read') : green('unread');
940
+ console.log(
941
+ ` ${bold(message.id)} ${dim('from')} ${message.from_agent_id || '-'} ${message.subject || dim('(no subject)')} [${readMark}]`,
942
+ );
943
+ }
944
+
872
945
  async function cmdInboxUpdate(action) {
873
946
  const ids = args.slice(2).filter((a) => !a.startsWith('--'));
874
947
  if (ids.length === 0) {
@@ -887,21 +960,14 @@ async function cmdInboxUpdate(action) {
887
960
  }
888
961
 
889
962
  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
- }
963
+ return runSubcommand({
964
+ list: cmdInboxList,
965
+ read: () => cmdInboxUpdate('read'),
966
+ archive: () => cmdInboxUpdate('archive'),
967
+ }, args[1], (sub) => `Unknown subcommand: dashclaw inbox ${sub || '(missing)'}\n` +
968
+ 'Try: dashclaw inbox list [--unread] [--limit N]\n' +
969
+ ' dashclaw inbox read <id> [<id> ...]\n' +
970
+ ' dashclaw inbox archive <id> [<id> ...]');
905
971
  }
906
972
 
907
973
  // -- behavior subcommand group -----------------------------------------------
@@ -918,20 +984,28 @@ function behaviorClient() {
918
984
  async function cmdBehaviorStatus() {
919
985
  try {
920
986
  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
- }
987
+ renderBehaviorStatus(data);
929
988
  } catch (err) {
930
989
  console.error(`Error: ${err.message}`);
931
990
  process.exit(1);
932
991
  }
933
992
  }
934
993
 
994
+ function behaviorReadyLabel(data) {
995
+ return data.ready ? green('yes') : dim('no — need ' + (data.min_samples ?? 8) + '+ samples for an agent');
996
+ }
997
+
998
+ function renderBehaviorStatus(data) {
999
+ console.log(` Recorder: ${data.recorder_enabled ? green('on') : dim('off')}`);
1000
+ console.log(` Directory: ${dim(data.dir || '-')}`);
1001
+ console.log(` Samples: ${bold(String(data.sample_count ?? 0))} ${dim('across')} ${data.agent_count ?? 0} ${dim('agent(s)')}`);
1002
+ console.log(` Window: ${dim((data.oldest_ts || '-') + ' → ' + (data.newest_ts || '-'))}`);
1003
+ console.log(` Ready: ${behaviorReadyLabel(data)}`);
1004
+ for (const a of data.agents || []) {
1005
+ console.log(` ${a.agent_id} ${dim(a.count + ' samples')}`);
1006
+ }
1007
+ }
1008
+
935
1009
  async function cmdBehaviorSuggestions() {
936
1010
  const agent = getFlag('--agent-id') || agentId;
937
1011
  try {
@@ -958,18 +1032,12 @@ async function cmdBehaviorSuggestions() {
958
1032
  }
959
1033
 
960
1034
  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
- }
1035
+ return runSubcommand({
1036
+ status: cmdBehaviorStatus,
1037
+ suggestions: cmdBehaviorSuggestions,
1038
+ }, args[1], (sub) => `Unknown subcommand: dashclaw behavior ${sub || '(missing)'}\n` +
1039
+ 'Try: dashclaw behavior status\n' +
1040
+ ' dashclaw behavior suggestions [--agent-id X]');
973
1041
  }
974
1042
 
975
1043
  // -- posture subcommand group ------------------------------------------------
@@ -993,31 +1061,47 @@ function printFinding(f, indent = ' ') {
993
1061
  console.log(dim(`${indent} ${f.key}`));
994
1062
  }
995
1063
 
1064
+ function postureStatusLabel(status) {
1065
+ if (status === 'healthy') return green(status);
1066
+ if (status === 'at_risk') return red(status);
1067
+ return status;
1068
+ }
1069
+
1070
+ function renderPostureHeader(data) {
1071
+ console.log();
1072
+ console.log(` ${bold('Governance posture')} ${bold(String(data.score))}${dim('/100')} ${postureStatusLabel(data.status)}` +
1073
+ `${data.cappedBy ? ' ' + red('[capped: incident]') : ''}`);
1074
+ console.log(dim(` ${data.summary?.openFindings ?? 0} open · +${Math.round(data.summary?.pointsRecoverable ?? 0)} points recoverable`));
1075
+ console.log();
1076
+ }
1077
+
1078
+ function renderPostureDimensions(dimensions) {
1079
+ for (const d of dimensions || []) {
1080
+ const label = POSTURE_DIM_LABEL[d.dimension] || d.dimension;
1081
+ const mark = d.score < 70 ? red('!') : ' ';
1082
+ console.log(` ${mark} ${label.padEnd(12)} ${String(d.score).padStart(3)}`);
1083
+ }
1084
+ console.log();
1085
+ }
1086
+
1087
+ function renderPostureQueue(findings) {
1088
+ console.log(dim(` Next — ${findings.length} open`));
1089
+ if (findings.length === 0) {
1090
+ console.log(green(' Queue is clear — no open coverage gaps.'));
1091
+ return;
1092
+ }
1093
+ for (const f of findings.slice(0, 5)) printFinding(f);
1094
+ if (findings.length > 5) console.log(dim(` … ${findings.length - 5} more`));
1095
+ }
1096
+
996
1097
  async function cmdPostureShow() {
997
1098
  try {
998
1099
  const client = postureClient();
999
1100
  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
1101
  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
- }
1102
+ renderPostureHeader(data);
1103
+ renderPostureDimensions(data.dimensions);
1104
+ renderPostureQueue(findings);
1021
1105
  console.log();
1022
1106
  } catch (err) {
1023
1107
  console.error(`Error: ${err.message}`);
@@ -1077,18 +1161,79 @@ async function cmdNext() {
1077
1161
  }
1078
1162
  }
1079
1163
 
1164
+ // -- env command ---------------------------------------------------------------
1165
+ //
1166
+ // `dashclaw env [--agent <id>] -- <command...>` — managed-secrets delivery.
1167
+ // Fetches the delivery-enabled bundle (GET /api/secrets/env) and spawns the
1168
+ // child command with the values injected into its environment MEMORY-ONLY.
1169
+ // Secret VALUES are never printed, never written to disk, never echoed in
1170
+ // error paths; only NAMES are ever shown. Fail-closed: if the fetch fails,
1171
+ // the child is NOT run. Uses process.exitCode (not process.exit) so node
1172
+ // drains sockets — a hard exit can trip a libuv teardown assert on Windows.
1173
+
1174
+ async function cmdEnv() {
1175
+ const { flags, command: childArgv } = splitEnvArgv(args.slice(1));
1176
+
1177
+ if (flags.includes('--print')) {
1178
+ console.error('Error: --print is not supported. Secret values are memory-only by design —');
1179
+ console.error('they are injected into a child process environment, never printed or written to disk.');
1180
+ console.error('Use `dashclaw env` to list secret NAMES, or `dashclaw env -- <command>` to run with them.');
1181
+ process.exitCode = 1;
1182
+ return;
1183
+ }
1184
+
1185
+ let agent = agentId;
1186
+ for (let i = 0; i < flags.length; i++) {
1187
+ if (flags[i] === '--agent') agent = flags[i + 1];
1188
+ else if (flags[i].startsWith('--agent=')) agent = flags[i].slice('--agent='.length);
1189
+ }
1190
+ if (!agent) {
1191
+ console.error('Error: no agent id. Pass --agent <id> or set DASHCLAW_AGENT_ID.');
1192
+ process.exitCode = 1;
1193
+ return;
1194
+ }
1195
+
1196
+ let bundle;
1197
+ try {
1198
+ bundle = await fetchAgentEnv({ baseUrl, apiKey }, agent);
1199
+ } catch (err) {
1200
+ console.error(`Error: could not fetch the managed-secrets bundle: ${err.message}`);
1201
+ if (childArgv.length > 0) {
1202
+ console.error('Fail-closed: the command was NOT run without the requested env.');
1203
+ }
1204
+ process.exitCode = 1;
1205
+ return;
1206
+ }
1207
+
1208
+ if (childArgv.length === 0) {
1209
+ console.log(formatEnvNames(bundle));
1210
+ return;
1211
+ }
1212
+
1213
+ process.exitCode = await runWithEnv(bundle, childArgv);
1214
+ }
1215
+
1080
1216
  // -- Router -------------------------------------------------------------------
1081
1217
 
1082
- const COMMANDS_NEEDING_CONFIG = new Set(['approvals', 'approve', 'deny', 'doctor', 'code', 'prompts', 'inbox', 'behavior', 'posture', 'next']);
1218
+ const COMMANDS_NEEDING_CONFIG = new Set(['approvals', 'approve', 'deny', 'doctor', 'code', 'prompts', 'inbox', 'behavior', 'posture', 'next', 'env']);
1083
1219
  // `install` deliberately omitted: provisioning hooks and AGENTS.md shouldn't
1084
1220
  // require the user to have already configured API keys. If config happens to
1085
1221
  // be present, install will pick up baseUrl for the AGENTS.md instance link.
1086
1222
  // `codex notify` also opt-in: if no config, the notify fail-softs to skipped
1087
1223
  // rather than erroring (Codex never sees the error anyway — it spawns with
1088
1224
  // stdio nulled).
1089
- const COMMANDS_OPTIONAL_CONFIG = new Set(['install', 'codex']);
1225
+ // `cost` resolves config non-interactively and prints its own setup hint when
1226
+ // missing (the generic interactive prompt would be the wrong UX for a
1227
+ // read-only readback command).
1228
+ const COMMANDS_OPTIONAL_CONFIG = new Set(['install', 'codex', 'cost']);
1229
+
1230
+ function applyConfig(config) {
1231
+ baseUrl = config.baseUrl;
1232
+ apiKey = config.apiKey;
1233
+ agentId = config.agentId;
1234
+ }
1090
1235
 
1091
- async function main() {
1236
+ async function loadCommandConfig() {
1092
1237
  if (COMMANDS_NEEDING_CONFIG.has(command)) {
1093
1238
  const config = await resolveConfig();
1094
1239
  if (!config) {
@@ -1096,68 +1241,51 @@ async function main() {
1096
1241
  console.error('Set them as env vars, save with an interactive first run, or use a .env file.');
1097
1242
  process.exit(1);
1098
1243
  }
1099
- baseUrl = config.baseUrl;
1100
- apiKey = config.apiKey;
1101
- agentId = config.agentId;
1102
- } else if (COMMANDS_OPTIONAL_CONFIG.has(command)) {
1244
+ applyConfig(config);
1245
+ return;
1246
+ }
1247
+ if (COMMANDS_OPTIONAL_CONFIG.has(command)) {
1103
1248
  const config = await resolveConfig({ interactive: false }).catch(() => null);
1104
1249
  if (config) {
1105
- baseUrl = config.baseUrl;
1106
- apiKey = config.apiKey;
1107
- agentId = config.agentId;
1250
+ applyConfig(config);
1108
1251
  }
1109
1252
  }
1253
+ }
1110
1254
 
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);
1255
+ const COMMAND_HANDLERS = {
1256
+ approvals: cmdApprovals,
1257
+ approve: cmdApprove,
1258
+ deny: cmdDeny,
1259
+ doctor: cmdDoctor,
1260
+ logout: cmdLogout,
1261
+ code: cmdCode,
1262
+ install: cmdInstall,
1263
+ codex: cmdCodex,
1264
+ prompts: cmdPrompts,
1265
+ inbox: cmdInbox,
1266
+ behavior: cmdBehavior,
1267
+ posture: cmdPosture,
1268
+ cost: cmdCost,
1269
+ next: cmdNext,
1270
+ env: cmdEnv,
1271
+ help: cmdHelp,
1272
+ '--help': cmdHelp,
1273
+ '-h': cmdHelp,
1274
+ version: cmdVersion,
1275
+ '--version': cmdVersion,
1276
+ '-v': cmdVersion,
1277
+ };
1278
+
1279
+ async function main() {
1280
+ await loadCommandConfig();
1281
+ const handler = COMMAND_HANDLERS[command];
1282
+ if (handler) {
1283
+ await handler();
1284
+ return;
1160
1285
  }
1286
+ console.error(`Unknown command: ${command}`);
1287
+ await cmdHelp();
1288
+ process.exitCode = 1;
1161
1289
  }
1162
1290
 
1163
1291
  main();