@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.
@@ -0,0 +1,386 @@
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
+ const HOOK_INTEL_DIR = 'dashclaw_agent_intel';
51
+ const HOOKS_BUNDLE_PATH = '/downloads/dashclaw-claude-code-hooks.zip';
52
+
53
+ export const DEFAULT_AGENT_ID = 'claude-code';
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Python resolution
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Resolve a working Python command: python3 first, then python. The Windows
61
+ * Store `python3` alias exits non-zero without running anything, so we
62
+ * require a successful `--version`, not just spawnability.
63
+ * @param {(cmd: string, args: string[]) => {error?: Error, status?: number}} probe
64
+ */
65
+ export function resolvePythonCommand(probe = (cmd, args) => spawnSync(cmd, args, { stdio: 'ignore' })) {
66
+ for (const cmd of ['python3', 'python']) {
67
+ const result = probe(cmd, ['--version']);
68
+ if (!result.error && result.status === 0) return cmd;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Preflight
75
+ // ---------------------------------------------------------------------------
76
+
77
+ export async function preflight(endpoint, apiKey, { fetchImpl = fetch } = {}) {
78
+ let health;
79
+ try {
80
+ health = await fetchImpl(`${endpoint}/api/health`, { signal: AbortSignal.timeout(8000) });
81
+ } catch (err) {
82
+ throw new Error(
83
+ `Could not reach ${endpoint}/api/health (${err.cause?.code || err.name}). ` +
84
+ 'Check the URL, that the instance is running, and your network.',
85
+ );
86
+ }
87
+ if (!health.ok) {
88
+ throw new Error(`${endpoint}/api/health returned HTTP ${health.status} — the instance is unhealthy.`);
89
+ }
90
+
91
+ const authed = await fetchImpl(`${endpoint}/api/actions?limit=1`, {
92
+ headers: { 'x-api-key': apiKey },
93
+ signal: AbortSignal.timeout(8000),
94
+ }).catch((err) => {
95
+ throw new Error(`Authenticated preflight to ${endpoint} failed (${err.name}).`);
96
+ });
97
+ if (authed.status === 401 || authed.status === 403) {
98
+ throw new Error('API key was rejected (401/403). Paste the key exactly as issued (oc_live_...).');
99
+ }
100
+ if (!authed.ok) {
101
+ throw new Error(`Authenticated preflight returned HTTP ${authed.status} — expected 200.`);
102
+ }
103
+ return true;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Hook acquisition
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /** Repo checkout containing hooks/, when the CLI runs from inside one. */
111
+ export function findRepoHooksDir() {
112
+ const candidate = resolve(__dirname, '..', '..', '..', 'hooks');
113
+ return existsSync(join(candidate, 'dashclaw_pretool.py')) ? candidate : null;
114
+ }
115
+
116
+ function copyDirRecursive(src, dst) {
117
+ mkdirSync(dst, { recursive: true });
118
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
119
+ if (entry.name === '__pycache__') continue;
120
+ const sp = join(src, entry.name);
121
+ const dp = join(dst, entry.name);
122
+ if (entry.isDirectory()) copyDirRecursive(sp, dp);
123
+ else if (entry.isFile()) copyFileSync(sp, dp);
124
+ }
125
+ }
126
+
127
+ function copyHooksFromRepo(hooksSrc, hooksDst) {
128
+ mkdirSync(hooksDst, { recursive: true });
129
+ for (const name of [...HOOK_FILES, 'run_hook.cjs']) {
130
+ const sp = join(hooksSrc, name);
131
+ if (!existsSync(sp)) throw new Error(`Required hook script missing: ${sp}`);
132
+ copyFileSync(sp, join(hooksDst, name));
133
+ }
134
+ const intelSrc = join(hooksSrc, HOOK_INTEL_DIR);
135
+ if (!existsSync(intelSrc) || !statSync(intelSrc).isDirectory()) {
136
+ throw new Error(`Required intel module missing: ${intelSrc}`);
137
+ }
138
+ copyDirRecursive(intelSrc, join(hooksDst, HOOK_INTEL_DIR));
139
+ }
140
+
141
+ function extractZip(zipPath, destDir) {
142
+ // tar on Windows 10+ and macOS is bsdtar (handles zip); GNU tar on most
143
+ // Linux does not, so try unzip there first. Whatever works first wins.
144
+ const attempts = process.platform === 'linux'
145
+ ? [['unzip', ['-o', zipPath, '-d', destDir]], ['tar', ['-xf', zipPath, '-C', destDir]]]
146
+ : [['tar', ['-xf', zipPath, '-C', destDir]], ['unzip', ['-o', zipPath, '-d', destDir]]];
147
+ for (const [cmd, args] of attempts) {
148
+ const result = spawnSync(cmd, args, { stdio: 'ignore' });
149
+ if (!result.error && result.status === 0) return true;
150
+ }
151
+ throw new Error(
152
+ 'Could not extract the hooks bundle (need `tar` with zip support or `unzip` on PATH). ' +
153
+ 'Alternative: clone https://github.com/ucsandman/DashClaw and re-run from the checkout.',
154
+ );
155
+ }
156
+
157
+ async function downloadHooksBundle(endpoint, hooksDst, { fetchImpl = fetch } = {}) {
158
+ const res = await fetchImpl(`${endpoint}${HOOKS_BUNDLE_PATH}`, { signal: AbortSignal.timeout(30_000) });
159
+ if (!res.ok) {
160
+ throw new Error(`Hook bundle download failed (HTTP ${res.status} from ${HOOKS_BUNDLE_PATH}).`);
161
+ }
162
+ const buf = Buffer.from(await res.arrayBuffer());
163
+ const workDir = join(tmpdir(), `dashclaw-hooks-${Date.now()}`);
164
+ mkdirSync(workDir, { recursive: true });
165
+ try {
166
+ const zipPath = join(workDir, 'hooks.zip');
167
+ writeFileSync(zipPath, buf);
168
+ extractZip(zipPath, workDir);
169
+ const extracted = join(workDir, 'hooks');
170
+ if (!existsSync(join(extracted, 'dashclaw_pretool.py'))) {
171
+ throw new Error('Hook bundle had an unexpected layout (no hooks/dashclaw_pretool.py).');
172
+ }
173
+ mkdirSync(hooksDst, { recursive: true });
174
+ cpSync(extracted, hooksDst, { recursive: true });
175
+ } finally {
176
+ rmSync(workDir, { recursive: true, force: true });
177
+ }
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Claude Code settings.json merge
182
+ // ---------------------------------------------------------------------------
183
+
184
+ const HOOK_EVENTS = {
185
+ PreToolUse: { matcher: 'Agent|Task|Bash|Edit|Write|MultiEdit|Skill|mcp__.*', script: 'dashclaw_pretool.py', timeout: 3600000 },
186
+ PostToolUse: { matcher: 'Agent|Task|Bash|Edit|Write|MultiEdit|mcp__.*', script: 'dashclaw_posttool.py' },
187
+ Stop: { script: 'dashclaw_stop.py' },
188
+ };
189
+
190
+ export function isManagedHookEntry(entry) {
191
+ return (entry?.hooks || []).some((h) => typeof h?.command === 'string' && h.command.includes('dashclaw_'));
192
+ }
193
+
194
+ /** Build the managed hook entries pointing at <hooksDir> via <python>. */
195
+ export function buildHookEntries(hooksDir, python) {
196
+ const entries = {};
197
+ for (const [event, spec] of Object.entries(HOOK_EVENTS)) {
198
+ const hook = {
199
+ type: 'command',
200
+ command: `${python} "${join(hooksDir, spec.script)}"`,
201
+ ...(spec.timeout ? { timeout: spec.timeout } : {}),
202
+ };
203
+ entries[event] = [{ ...(spec.matcher ? { matcher: spec.matcher } : {}), hooks: [hook] }];
204
+ }
205
+ return entries;
206
+ }
207
+
208
+ /**
209
+ * Merge the managed hook entries into a Claude Code settings.json file:
210
+ * previous dashclaw-managed entries are replaced, everything else preserved.
211
+ */
212
+ export function mergeClaudeSettings(settingsPath, hooksDir, python) {
213
+ let settings = {};
214
+ if (existsSync(settingsPath)) {
215
+ try {
216
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
217
+ } catch {
218
+ throw new Error(`${settingsPath} is not valid JSON — fix or remove it, then re-run.`);
219
+ }
220
+ const bak = settingsPath + '.dashclaw-bak';
221
+ if (!existsSync(bak)) copyFileSync(settingsPath, bak);
222
+ }
223
+ settings.hooks = settings.hooks || {};
224
+ const managed = buildHookEntries(hooksDir, python);
225
+ for (const [event, entries] of Object.entries(managed)) {
226
+ const existing = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
227
+ settings.hooks[event] = [...existing.filter((e) => !isManagedHookEntry(e)), ...entries];
228
+ }
229
+ mkdirSync(dirname(settingsPath), { recursive: true });
230
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
231
+ return settings;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Hook credentials (.env beside the scripts — never in settings.json)
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export function buildHookEnv({ endpoint, apiKey, agentId, hookMode = 'observe' }) {
239
+ return [
240
+ '# Written by `dashclaw install claude` — credentials for the governance hooks.',
241
+ '# The hooks load this file from beside their scripts; env vars override it.',
242
+ `DASHCLAW_BASE_URL=${endpoint}`,
243
+ `DASHCLAW_API_KEY=${apiKey}`,
244
+ `DASHCLAW_AGENT_ID=${agentId}`,
245
+ `DASHCLAW_HOOK_MODE=${hookMode}`,
246
+ '',
247
+ ].join('\n');
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Top-level install
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * @param {object} opts
256
+ * @param {string} [opts.endpoint] Instance URL (flag/env resolved by caller)
257
+ * @param {string} [opts.apiKey]
258
+ * @param {string} [opts.agentId]
259
+ * @param {boolean} [opts.trial]
260
+ * @param {string} [opts.homeDir] Injectable for tests
261
+ * @param {Function} [opts.fetchImpl]
262
+ * @param {Function} [opts.pythonProbe] Injected probe for resolvePythonCommand
263
+ * @param {Function} [opts.prompt] (question) => Promise<string>
264
+ * @param {Function} [opts.promptSecret] (question) => Promise<string>
265
+ * @param {Function} [opts.openUrl] Best-effort browser open
266
+ * @param {object} [opts.logger]
267
+ */
268
+ export async function installClaude({
269
+ endpoint,
270
+ apiKey,
271
+ agentId = DEFAULT_AGENT_ID,
272
+ trial = false,
273
+ homeDir = homedir(),
274
+ env = process.env,
275
+ fetchImpl = fetch,
276
+ pythonProbe,
277
+ prompt,
278
+ promptSecret,
279
+ openUrl = defaultOpenUrl,
280
+ logger = console,
281
+ } = {}) {
282
+ // 1. Resolve endpoint + key -------------------------------------------------
283
+ endpoint = (endpoint || env.DASHCLAW_BASE_URL || '').replace(/\/+$/, '');
284
+ apiKey = apiKey || env.DASHCLAW_API_KEY || '';
285
+
286
+ if (trial && !apiKey) {
287
+ const hostedBase = (endpoint || env.DASHCLAW_HOSTED_URL || '').replace(/\/+$/, '')
288
+ || (await mustPrompt(prompt, 'Hosted DashClaw URL (where you signed up / will sign up): '));
289
+ const signupUrl = `${hostedBase.replace(/\/+$/, '')}/connect`;
290
+ logger.log('');
291
+ logger.log(` Opening the trial signup page: ${signupUrl}`);
292
+ logger.log(' Sign in there, copy your trial API key, and paste it below.');
293
+ openUrl(signupUrl);
294
+ endpoint = endpoint || hostedBase.replace(/\/+$/, '');
295
+ apiKey = await mustPrompt(promptSecret || prompt, 'Paste your trial API key (oc_live_...): ');
296
+ }
297
+
298
+ if (!endpoint) endpoint = await mustPrompt(prompt, 'DashClaw instance URL (e.g. https://your-dashclaw.vercel.app): ');
299
+ endpoint = endpoint.replace(/\/+$/, '');
300
+ if (!apiKey) apiKey = await mustPrompt(promptSecret || prompt, 'API key (oc_live_...): ');
301
+
302
+ // 2. Preflight (nothing is written before this passes) ----------------------
303
+ logger.log(` Preflight: ${endpoint}/api/health ...`);
304
+ await preflight(endpoint, apiKey, { fetchImpl });
305
+ logger.log(' Preflight OK — instance reachable, key accepted.');
306
+
307
+ // 3. Hooks ------------------------------------------------------------------
308
+ const hooksDir = join(homeDir, '.dashclaw', 'claude-hooks');
309
+ const repoHooks = findRepoHooksDir();
310
+ if (repoHooks) {
311
+ logger.log(` Installing hooks from repo checkout → ${hooksDir}`);
312
+ copyHooksFromRepo(repoHooks, hooksDir);
313
+ } else {
314
+ logger.log(` Downloading hooks bundle from ${endpoint} → ${hooksDir}`);
315
+ await downloadHooksBundle(endpoint, hooksDir, { fetchImpl });
316
+ }
317
+
318
+ // 4. Python + Claude Code settings -------------------------------------------
319
+ const python = resolvePythonCommand(pythonProbe);
320
+ if (!python) {
321
+ throw new Error('No python3 or python found on PATH. Install Python 3.10+ and re-run.');
322
+ }
323
+ const settingsPath = join(homeDir, '.claude', 'settings.json');
324
+ logger.log(` Wiring hooks into ${settingsPath} (python: ${python})`);
325
+ mergeClaudeSettings(settingsPath, hooksDir, python);
326
+
327
+ // 5. Credentials -------------------------------------------------------------
328
+ const hookEnvPath = join(hooksDir, '.env');
329
+ writeFileSync(hookEnvPath, buildHookEnv({ endpoint, apiKey, agentId }), { mode: 0o600 });
330
+ const existing = readConfigForHome(homeDir);
331
+ writeConfigForHome(homeDir, { ...existing, baseUrl: endpoint, apiKey, agentId });
332
+
333
+ // 6. Next steps ---------------------------------------------------------------
334
+ logger.log('');
335
+ logger.log(' Done. Claude Code is governed by DashClaw (observe mode).');
336
+ logger.log(` Hooks: ${hooksDir}`);
337
+ logger.log(` Settings: ${settingsPath}`);
338
+ logger.log(` Config: ${join(homeDir, '.dashclaw', 'config.json')}`);
339
+ logger.log('');
340
+ logger.log(' Next steps:');
341
+ logger.log(' 1. Restart Claude Code (hooks load at session start).');
342
+ logger.log(' 2. Run any tool call and watch it appear in your dashboard:');
343
+ logger.log(` ${endpoint}/mission-control`);
344
+ logger.log(` 3. Observe mode logs decisions without blocking. To enforce, set`);
345
+ logger.log(` DASHCLAW_HOOK_MODE=enforce in ${hookEnvPath}`);
346
+
347
+ return { hooksDir, settingsPath, hookEnvPath, python, endpoint, agentId, hookMode: 'observe' };
348
+ }
349
+
350
+ async function mustPrompt(promptFn, question) {
351
+ if (!promptFn) {
352
+ throw new Error(`Missing required value (${question.trim()}) and no interactive prompt available.`);
353
+ }
354
+ const answer = (await promptFn(question)).trim();
355
+ if (!answer) throw new Error('Aborted — a value is required.');
356
+ return answer;
357
+ }
358
+
359
+ function defaultOpenUrl(url) {
360
+ try {
361
+ if (process.platform === 'win32') spawnSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' });
362
+ else if (process.platform === 'darwin') spawnSync('open', [url], { stdio: 'ignore' });
363
+ else spawnSync('xdg-open', [url], { stdio: 'ignore' });
364
+ } catch {
365
+ // best-effort — the URL is printed either way
366
+ }
367
+ }
368
+
369
+ // config.js hardcodes homedir(); these wrappers honor the injectable homeDir
370
+ // (tests) while production passes the real one and shares the same file.
371
+ function readConfigForHome(homeDir) {
372
+ const path = join(homeDir, '.dashclaw', 'config.json');
373
+ if (homeDir === homedir()) return readConfigFile();
374
+ if (!existsSync(path)) return {};
375
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; }
376
+ }
377
+
378
+ function writeConfigForHome(homeDir, config) {
379
+ if (homeDir === homedir()) {
380
+ writeConfigFile(config);
381
+ return;
382
+ }
383
+ const dir = join(homeDir, '.dashclaw');
384
+ mkdirSync(dir, { recursive: true });
385
+ writeFileSync(join(dir, 'config.json'), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
386
+ }
@@ -77,6 +77,22 @@ async function readLines(filePath) {
77
77
  return lines;
78
78
  }
79
79
 
80
+ // Recover the project's real working directory from the transcript itself.
81
+ // Claude Code stamps a `cwd` field on JSONL records; the encoded directory
82
+ // slug (c--projects-dashclaw) is NOT reversible, so this is the only
83
+ // client-side source of a copy-pasteable path. Bounded scan; null when absent.
84
+ export function deriveCwdFromLines(lines, maxScan = 50) {
85
+ for (const line of lines.slice(0, maxScan)) {
86
+ try {
87
+ const rec = JSON.parse(line);
88
+ if (rec && typeof rec.cwd === 'string' && rec.cwd.trim()) return rec.cwd;
89
+ } catch {
90
+ // Non-JSON line — keep scanning; the parser tolerates these too.
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
80
96
  export async function buildIngestPayload(filePath, { cwdOverride = null } = {}) {
81
97
  const stat = fs.statSync(filePath);
82
98
  if (stat.size > MAX_FILE_BYTES) {
@@ -93,7 +109,7 @@ export async function buildIngestPayload(filePath, { cwdOverride = null } = {})
93
109
  const body = {
94
110
  project: {
95
111
  slug,
96
- cwd: cwdOverride,
112
+ cwd: cwdOverride || deriveCwdFromLines(lines),
97
113
  source_host: 'jsonl',
98
114
  },
99
115
  session_uuid: null,
package/lib/config.js CHANGED
@@ -35,7 +35,7 @@ export function clearConfigFile() {
35
35
  return false;
36
36
  }
37
37
 
38
- function ask(question) {
38
+ export function ask(question) {
39
39
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
40
40
  return new Promise((res) => {
41
41
  rl.question(question, (answer) => {
@@ -45,7 +45,7 @@ function ask(question) {
45
45
  });
46
46
  }
47
47
 
48
- function askSecret(question) {
48
+ export function askSecret(question) {
49
49
  return new Promise((res) => {
50
50
  const stdin = process.stdin;
51
51
  process.stdout.write(question);
package/lib/cost.js ADDED
@@ -0,0 +1,108 @@
1
+ // cli/lib/cost.js
2
+ //
3
+ // `dashclaw cost [--lens fleet|claude-code] [--period 7d|30d|90d]` — terminal
4
+ // readback over GET /api/finops/spend (the finops rollups). Defaults:
5
+ // lens=claude-code, period=7d — the wedge user's own Claude Code spend.
6
+
7
+ import { apiRequest } from './api.js';
8
+
9
+ export const VALID_LENSES = ['fleet', 'claude-code'];
10
+ export const VALID_PERIODS = ['7d', '30d', '90d'];
11
+ export const USAGE =
12
+ 'Usage: dashclaw cost [--lens fleet|claude-code] [--period 7d|30d|90d]\n' +
13
+ ' Defaults: --lens claude-code --period 7d';
14
+
15
+ /** Validate flag values. Returns null when fine, or a usage-bearing message. */
16
+ export function validateCostFlags({ lens, period }) {
17
+ if (!VALID_LENSES.includes(lens)) {
18
+ return `Invalid --lens "${lens}". Valid: ${VALID_LENSES.join(', ')}.\n${USAGE}`;
19
+ }
20
+ if (!VALID_PERIODS.includes(period)) {
21
+ return `Invalid --period "${period}". Valid: ${VALID_PERIODS.join(', ')}.\n${USAGE}`;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ export async function fetchSpend(config, { lens, period }) {
27
+ return apiRequest(config, 'GET', '/api/finops/spend', { query: { lens, period } });
28
+ }
29
+
30
+ const money = (n) => '$' + Number(n || 0).toFixed(2);
31
+
32
+ function table(rows) {
33
+ // rows: [label, value][] — right-pad labels so values align.
34
+ const width = Math.max(...rows.map(([label]) => label.length));
35
+ return rows.map(([label, value]) => ` ${label.padEnd(width + 2)}${value}`).join('\n');
36
+ }
37
+
38
+ function formatClaudeCode(data, period) {
39
+ const cs = data.code_sessions || {};
40
+ const total = Number(data.code_total_usd || 0);
41
+ const sessions = Number(cs.session_count || 0);
42
+ if (total === 0 && sessions === 0) {
43
+ return (
44
+ `No Claude Code spend recorded yet for ${period}.\n` +
45
+ 'Sessions are captured by the Stop hook (on by default, metadata-only;\n' +
46
+ 'opt-out: DASHCLAW_CODE_SESSIONS_ENABLED=0). Run a Claude Code session and check back.'
47
+ );
48
+ }
49
+ const lines = [
50
+ `Claude Code spend — last ${period}`,
51
+ '',
52
+ table([
53
+ ['Total', money(total)],
54
+ ['Sessions', String(sessions)],
55
+ ['Cache saved', money(cs.total_cache_savings_usd)],
56
+ ]),
57
+ ];
58
+ const projects = Array.isArray(cs.by_project) ? cs.by_project.filter((p) => Number(p.cost_usd || 0) > 0) : [];
59
+ if (projects.length > 0) {
60
+ lines.push('', ' By project:');
61
+ lines.push(table(projects.map((p) => [` ${p.project_name || p.project_id || 'unknown'}`, money(p.cost_usd)])));
62
+ }
63
+ lines.push('', `Summary: ${money(total)} across ${sessions} session(s) in the last ${period}.`);
64
+ return lines.join('\n');
65
+ }
66
+
67
+ function formatFleet(data, period) {
68
+ const agent = Number(data.agent?.total_cost_usd || 0);
69
+ const x402 = Number(data.x402?.total_spend_usd || 0);
70
+ const total = Number(data.fleet_total_usd || 0);
71
+ if (total === 0) {
72
+ return (
73
+ `No fleet spend recorded yet for ${period}.\n` +
74
+ 'Agent spend lands when governed actions report tokens_in/tokens_out + model.'
75
+ );
76
+ }
77
+ const lines = [
78
+ `Fleet spend — last ${period}`,
79
+ '',
80
+ table([
81
+ ['Agent LLM', money(agent)],
82
+ ['x402 purchases', money(x402)],
83
+ ['Total', money(total)],
84
+ ]),
85
+ '',
86
+ `Summary: ${money(total)} fleet spend in the last ${period} (LLM ${money(agent)} + x402 ${money(x402)}).`,
87
+ ];
88
+ return lines.join('\n');
89
+ }
90
+
91
+ export function formatSpend(data, { lens, period }) {
92
+ return lens === 'claude-code' ? formatClaudeCode(data, period) : formatFleet(data, period);
93
+ }
94
+
95
+ /**
96
+ * Validate, fetch, format. Throws a usage-tagged Error on bad flags so the
97
+ * caller prints usage and exits non-zero without a stack trace.
98
+ */
99
+ export async function runCost(config, { lens = 'claude-code', period = '7d' } = {}, { fetcher = fetchSpend } = {}) {
100
+ const invalid = validateCostFlags({ lens, period });
101
+ if (invalid) {
102
+ const err = new Error(invalid);
103
+ err.usage = true;
104
+ throw err;
105
+ }
106
+ const data = await fetcher(config, { lens, period });
107
+ return formatSpend(data, { lens, period });
108
+ }