@dashclaw/cli 0.2.0 → 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/lib/config.js ADDED
@@ -0,0 +1,160 @@
1
+ // cli/lib/config.js
2
+ // Config resolution: env vars -> ~/.dashclaw/config.json -> interactive prompt.
3
+ import readline from 'node:readline';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { bold, dim, green, red, yellow } from './render.js';
8
+
9
+ const CONFIG_DIR = resolve(homedir(), '.dashclaw');
10
+ const CONFIG_PATH = resolve(CONFIG_DIR, 'config.json');
11
+
12
+ export function configPath() {
13
+ return CONFIG_PATH;
14
+ }
15
+
16
+ export function readConfigFile() {
17
+ if (!existsSync(CONFIG_PATH)) return {};
18
+ try {
19
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ export function writeConfigFile(config) {
26
+ mkdirSync(CONFIG_DIR, { recursive: true });
27
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
28
+ }
29
+
30
+ export function clearConfigFile() {
31
+ if (existsSync(CONFIG_PATH)) {
32
+ unlinkSync(CONFIG_PATH);
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ export function ask(question) {
39
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
40
+ return new Promise((res) => {
41
+ rl.question(question, (answer) => {
42
+ rl.close();
43
+ res(answer.trim());
44
+ });
45
+ });
46
+ }
47
+
48
+ export function askSecret(question) {
49
+ return new Promise((res) => {
50
+ const stdin = process.stdin;
51
+ process.stdout.write(question);
52
+ const wasRaw = stdin.isRaw;
53
+ if (stdin.setRawMode) stdin.setRawMode(true);
54
+ stdin.resume();
55
+ stdin.setEncoding('utf8');
56
+
57
+ let input = '';
58
+ const onData = (char) => {
59
+ const str = String(char);
60
+ for (const c of str) {
61
+ if (c === '\n' || c === '\r' || c === '\u0004') {
62
+ if (stdin.setRawMode) stdin.setRawMode(wasRaw);
63
+ stdin.pause();
64
+ stdin.removeListener('data', onData);
65
+ process.stdout.write('\n');
66
+ res(input.trim());
67
+ return;
68
+ }
69
+ if (c === '\u0003') {
70
+ if (stdin.setRawMode) stdin.setRawMode(wasRaw);
71
+ stdin.pause();
72
+ process.stdout.write('\n');
73
+ process.exit(130);
74
+ }
75
+ if (c === '\u007f' || c === '\b') {
76
+ if (input.length > 0) {
77
+ input = input.slice(0, -1);
78
+ process.stdout.write('\b \b');
79
+ }
80
+ } else if (c >= ' ') {
81
+ input += c;
82
+ process.stdout.write('*');
83
+ }
84
+ }
85
+ };
86
+ stdin.on('data', onData);
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Resolve config from env, then file, then interactive prompt.
92
+ * @returns {Promise<{ baseUrl, apiKey, agentId, source } | null>}
93
+ * Returns null if missing and stdin is not a TTY (so caller can error out).
94
+ */
95
+ export async function resolveConfig({ env = process.env, interactive = true } = {}) {
96
+ let baseUrl = env.DASHCLAW_BASE_URL;
97
+ let apiKey = env.DASHCLAW_API_KEY;
98
+ let agentId = env.DASHCLAW_AGENT_ID;
99
+ let source = 'env';
100
+
101
+ if (!baseUrl || !apiKey) {
102
+ const file = readConfigFile();
103
+ if (!baseUrl && file.baseUrl) {
104
+ baseUrl = file.baseUrl;
105
+ source = 'file';
106
+ }
107
+ if (!apiKey && file.apiKey) {
108
+ apiKey = file.apiKey;
109
+ source = 'file';
110
+ }
111
+ if (!agentId && file.agentId) agentId = file.agentId;
112
+ }
113
+
114
+ if (baseUrl && apiKey) {
115
+ return { baseUrl, apiKey, agentId: agentId || 'cli-operator', source };
116
+ }
117
+
118
+ if (!interactive || !process.stdin.isTTY) {
119
+ return null;
120
+ }
121
+
122
+ console.log();
123
+ console.log(bold('DashClaw CLI — first-time setup'));
124
+ console.log(dim(`Saved values go to ${CONFIG_PATH} (mode 600). Env vars always override.`));
125
+ console.log();
126
+
127
+ if (!baseUrl) {
128
+ baseUrl = await ask('DashClaw instance URL (e.g. https://your-dashclaw.vercel.app): ');
129
+ if (!baseUrl) {
130
+ console.error(red('Aborted — baseUrl is required.'));
131
+ process.exit(1);
132
+ }
133
+ baseUrl = baseUrl.replace(/\/+$/, '');
134
+ }
135
+
136
+ if (!apiKey) {
137
+ apiKey = await askSecret('API key (oc_live_...): ');
138
+ if (!apiKey) {
139
+ console.error(red('Aborted — API key is required.'));
140
+ process.exit(1);
141
+ }
142
+ }
143
+
144
+ agentId = agentId || 'cli-operator';
145
+
146
+ const saveAnswer = await ask(`Save to ${CONFIG_PATH}? [Y/n] `);
147
+ if (saveAnswer.toLowerCase() !== 'n' && saveAnswer.toLowerCase() !== 'no') {
148
+ try {
149
+ writeConfigFile({ baseUrl, apiKey, agentId });
150
+ console.log(green(` \u2192 Saved. Remove with: dashclaw logout`));
151
+ } catch (err) {
152
+ console.error(yellow(` Warning: could not write config file: ${err.message}`));
153
+ }
154
+ } else {
155
+ console.log(dim(' Not saved. Values apply to this invocation only.'));
156
+ }
157
+ console.log();
158
+
159
+ return { baseUrl, apiKey, agentId, source: 'prompted' };
160
+ }
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
+ }
package/lib/doctor.js ADDED
@@ -0,0 +1,270 @@
1
+ // cli/lib/doctor.js
2
+ import { bold, dim, green, yellow, red } from './render.js';
3
+
4
+ // Brand orange (256-color ANSI). Used sparingly — header + key accents only.
5
+ const BRAND = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
6
+
7
+ const ICONS = {
8
+ pass: green('\u2713'),
9
+ warn: yellow('\u26a0'),
10
+ fail: red('\u2717'),
11
+ };
12
+
13
+ const CATEGORY_LABELS = {
14
+ database: 'Database',
15
+ config: 'Configuration',
16
+ auth: 'Authentication',
17
+ deployment: 'Deployment',
18
+ sdk: 'SDK Connectivity',
19
+ governance: 'Governance',
20
+ shape: 'Shape (generated)',
21
+ drift: 'Drift',
22
+ };
23
+
24
+ const CATEGORY_ORDER = ['database', 'config', 'auth', 'deployment', 'sdk', 'governance', 'shape', 'drift'];
25
+
26
+ // Next-step guidance by check ID, used when the check has no auto-fix.
27
+ // Keep each line short and actionable.
28
+ const GUIDANCE = {
29
+ db_connection: 'Check DATABASE_URL and that Postgres is reachable from your deployment.',
30
+ env_NEXTAUTH_URL: 'Set NEXTAUTH_URL to your deployment URL (e.g. https://your.vercel.app).',
31
+ env_CRON_SECRET: 'Set CRON_SECRET to protect /api/cron/* endpoints.',
32
+ auth_signin: 'Configure an OAuth provider (GitHub/Google) or set DASHCLAW_LOCAL_ADMIN_PASSWORD.',
33
+ deploy_nextauth_url: 'Set NEXTAUTH_URL to match your deployment domain.',
34
+ deploy_cors: 'Set ALLOWED_ORIGIN if your agents run from a different domain.',
35
+ sdk_reachable: 'Check DASHCLAW_BASE_URL and confirm your instance is deployed and awake.',
36
+ sdk_auth: 'API key rejected — verify DASHCLAW_API_KEY matches the key on your instance.',
37
+ gov_actions: 'Send your first governed action with claw.guard() via the SDK.',
38
+ gov_stale: "Agents haven't reported in 7 days — check your agent pairings.",
39
+ };
40
+
41
+ function hr(width = 72) {
42
+ return dim('\u2500'.repeat(width));
43
+ }
44
+
45
+ /**
46
+ * Return an actionable next-step string for a non-passing check, or null.
47
+ */
48
+ function nextStepFor(check, { noFix }) {
49
+ if (!check || check.status === 'pass') return null;
50
+
51
+ // Auto-fixable check: direct the user to the fix.
52
+ if (check.fix?.type === 'auto') {
53
+ if (noFix) {
54
+ return `${check.fix.description}. Re-run without ${bold('--no-fix')} to apply.`;
55
+ }
56
+ // Fix was attempted (or will run in this invocation); describe what it does.
57
+ return check.fix.description + '.';
58
+ }
59
+
60
+ // Fall back to the guidance table for warn/fail without an auto-fix.
61
+ return GUIDANCE[check.id] || null;
62
+ }
63
+
64
+ function doctorUrl(base, category) {
65
+ let url = `${base}/api/doctor?include_fixes=true`;
66
+ if (category) url += `&category=${encodeURIComponent(category)}`;
67
+ return url;
68
+ }
69
+
70
+ function doctorHeaders(apiKey) {
71
+ return { 'Content-Type': 'application/json', 'x-api-key': apiKey };
72
+ }
73
+
74
+ function exitFetchError(base, err) {
75
+ console.error(red(`\nError: Could not reach DashClaw at ${base}`));
76
+ console.error(dim(` ${err.cause?.code || err.message}`));
77
+ console.error(dim(` Check DASHCLAW_BASE_URL and confirm your instance is running.\n`));
78
+ process.exit(1);
79
+ }
80
+
81
+ async function fetchDoctorResult({ base, apiKey, category }) {
82
+ const headers = doctorHeaders(apiKey);
83
+ let res;
84
+ try {
85
+ res = await fetch(doctorUrl(base, category), { headers });
86
+ } catch (err) {
87
+ exitFetchError(base, err);
88
+ }
89
+ await handleDoctorResponse(base, res);
90
+ return { result: await res.json(), headers };
91
+ }
92
+
93
+ async function handleDoctorResponse(base, res) {
94
+ if (res.status === 401 || res.status === 403) {
95
+ console.error(red(`\nError: API key rejected by ${base} (${res.status}).`));
96
+ console.error(dim(` Check DASHCLAW_API_KEY matches the key on your instance.\n`));
97
+ process.exit(1);
98
+ }
99
+
100
+ if (!res.ok && res.status !== 503) {
101
+ const errText = await res.text().catch(() => '');
102
+ console.error(red(`Doctor check failed (${res.status}): ${errText}`));
103
+ process.exit(1);
104
+ }
105
+ }
106
+
107
+ function exitJson(result) {
108
+ console.log(JSON.stringify(result, null, 2));
109
+ process.exit(result.status === 'healthy' ? 0 : 1);
110
+ }
111
+
112
+ function renderHeader(base) {
113
+ const hostLabel = base.replace(/^https?:\/\//, '');
114
+ console.log();
115
+ console.log(` ${BRAND('[DashClaw]')} ${bold('Doctor')} ${dim(hostLabel)}`);
116
+ console.log();
117
+ }
118
+
119
+ function groupChecks(checks) {
120
+ const grouped = {};
121
+ for (const check of checks) {
122
+ if (!grouped[check.category]) grouped[check.category] = [];
123
+ grouped[check.category].push(check);
124
+ }
125
+ return grouped;
126
+ }
127
+
128
+ function renderCheck(check, noFix) {
129
+ const icon = ICONS[check.status] || '?';
130
+ const titleStyled = check.status === 'pass' ? check.title : bold(check.title);
131
+ console.log(` ${icon} ${titleStyled}`);
132
+
133
+ if (check.status !== 'pass') {
134
+ console.log(` ${dim(check.message)}`);
135
+ const tip = nextStepFor(check, { noFix });
136
+ if (tip) {
137
+ console.log(` ${dim('\u2192')} ${tip}`);
138
+ }
139
+ }
140
+ }
141
+
142
+ function renderGroupedChecks(checks, noFix) {
143
+ const grouped = groupChecks(checks);
144
+ for (const cat of CATEGORY_ORDER) {
145
+ const categoryChecks = grouped[cat];
146
+ if (!categoryChecks || categoryChecks.length === 0) continue;
147
+
148
+ console.log(` ${bold(CATEGORY_LABELS[cat] || cat)}`);
149
+ for (const check of categoryChecks) {
150
+ renderCheck(check, noFix);
151
+ }
152
+ console.log();
153
+ }
154
+ }
155
+
156
+ function autoFixableChecks(result) {
157
+ return result.checks.filter((c) => c.status === 'fail' && c.fix?.type === 'auto');
158
+ }
159
+
160
+ async function applyDoctorFix({ base, headers, check }) {
161
+ try {
162
+ const fixRes = await fetch(`${base}/api/doctor/fix`, {
163
+ method: 'POST',
164
+ headers,
165
+ body: JSON.stringify({ action: check.fix.action }),
166
+ });
167
+ const fixResult = await fixRes.json();
168
+ if (fixResult.applied) {
169
+ console.log(` ${green('\u2192')} Fixed: ${fixResult.description}`);
170
+ return { fixed: 1, recheck: fixResult.recheck || null };
171
+ }
172
+ console.log(` ${dim('\u2192')} Skipped: ${fixResult.description}`);
173
+ } catch (err) {
174
+ console.log(` ${red('\u2717')} Fix "${check.fix.action}" failed: ${err.cause?.code || err.message}`);
175
+ }
176
+ return { fixed: 0, recheck: null };
177
+ }
178
+
179
+ async function applyAutoFixes({ base, headers, result, noFix }) {
180
+ let fixCount = 0;
181
+ let latestRecheck = null;
182
+ if (noFix) return { fixCount, latestRecheck };
183
+
184
+ const fixable = autoFixableChecks(result);
185
+ if (fixable.length === 0) return { fixCount, latestRecheck };
186
+
187
+ console.log(` ${bold('Applying auto-fixes...')}`);
188
+ for (const check of fixable) {
189
+ const outcome = await applyDoctorFix({ base, headers, check });
190
+ fixCount += outcome.fixed;
191
+ latestRecheck = outcome.recheck || latestRecheck;
192
+ }
193
+ console.log();
194
+ return { fixCount, latestRecheck };
195
+ }
196
+
197
+ function renderSummary(reporting) {
198
+ const { pass, warn, fail } = reporting.summary;
199
+ console.log(hr());
200
+ console.log();
201
+
202
+ const segments = [
203
+ green(`${pass} passed`),
204
+ warn > 0 ? yellow(`${warn} warning${warn !== 1 ? 's' : ''}`) : dim('0 warnings'),
205
+ fail > 0 ? red(`${fail} failed`) : dim('0 failed'),
206
+ ];
207
+ console.log(` ${segments.join(' ' + dim('\u00b7') + ' ')}`);
208
+ console.log();
209
+ }
210
+
211
+ function renderFixCount(fixCount) {
212
+ if (fixCount > 0) {
213
+ console.log(` ${green('\u2713')} ${fixCount} issue${fixCount !== 1 ? 's' : ''} auto-fixed this run.`);
214
+ }
215
+ }
216
+
217
+ function renderNoFixHint(result, noFix) {
218
+ if (!noFix) return;
219
+ const autoFixable = result.checks.filter(
220
+ (c) => c.status !== 'pass' && c.fix?.type === 'auto',
221
+ ).length;
222
+ if (autoFixable > 0) {
223
+ console.log(
224
+ ` ${BRAND('\u2192')} ${autoFixable} issue${autoFixable !== 1 ? 's' : ''} can be auto-fixed. Run: ${bold('dashclaw doctor')}`,
225
+ );
226
+ }
227
+ }
228
+
229
+ function renderHealthFooter(base, healthy) {
230
+ if (!healthy) {
231
+ console.log(` ${dim('Docs:')} ${base}/setup`);
232
+ } else {
233
+ console.log(` ${green('\u2713')} ${bold('All systems healthy')} — ${dim('ready to govern actions')}`);
234
+ }
235
+ console.log();
236
+ }
237
+
238
+ /**
239
+ * Run doctor via the API and render results.
240
+ * @param {{ baseUrl: string, apiKey: string, json?: boolean, noFix?: boolean, category?: string }} options
241
+ */
242
+ export async function runDoctor({ baseUrl, apiKey, json, noFix, category }) {
243
+ const base = baseUrl.replace(/\/+$/, '');
244
+ const { result, headers } = await fetchDoctorResult({ base, apiKey, category });
245
+
246
+ if (json) {
247
+ exitJson(result);
248
+ }
249
+
250
+ // --- Header ----------------------------------------------------------------
251
+ renderHeader(base);
252
+
253
+ // --- Grouped checks --------------------------------------------------------
254
+ renderGroupedChecks(result.checks, noFix);
255
+
256
+ // --- Auto-fix (remote fixes only; local-only fixes blocked by API) --------
257
+ const { fixCount, latestRecheck } = await applyAutoFixes({ base, headers, result, noFix });
258
+
259
+ // --- Summary ---------------------------------------------------------------
260
+ const reporting = latestRecheck || result;
261
+ renderSummary(reporting);
262
+
263
+ // --- Contextual footer -----------------------------------------------------
264
+ const healthy = reporting.status === 'healthy';
265
+ renderFixCount(fixCount);
266
+ renderNoFixHint(result, noFix);
267
+ renderHealthFooter(base, healthy);
268
+
269
+ process.exit(healthy ? 0 : 1);
270
+ }
package/lib/env.js ADDED
@@ -0,0 +1,69 @@
1
+ // cli/lib/env.js
2
+ //
3
+ // `dashclaw env [--agent <id>] [-- <command...>]` — fetch the delivery-enabled
4
+ // managed-secret bundle from GET /api/secrets/env and inject it into a child
5
+ // process environment MEMORY-ONLY. Secret VALUES are never written to disk,
6
+ // never printed, and never echoed in error paths — only NAMES are ever shown.
7
+ // Fail-closed: if the bundle fetch fails, the child command is NOT run.
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { apiRequest } from './api.js';
11
+ import { dim } from './render.js';
12
+
13
+ /** GET /api/secrets/env for one agent. Returns { env, count, delivered }. */
14
+ export async function fetchAgentEnv(config, agentId) {
15
+ return apiRequest(config, 'GET', '/api/secrets/env', { query: { agent_id: agentId } });
16
+ }
17
+
18
+ /**
19
+ * Split the CLI argv at the `--` separator.
20
+ * Tokens before `--` are flags for `dashclaw env`; tokens after are the
21
+ * child command + its args (left untouched, including any `--print`).
22
+ */
23
+ export function splitEnvArgv(argv) {
24
+ const sep = argv.indexOf('--');
25
+ if (sep === -1) return { flags: argv, command: [] };
26
+ return { flags: argv.slice(0, sep), command: argv.slice(sep + 1) };
27
+ }
28
+
29
+ /** Names-only listing — never values. */
30
+ export function formatEnvNames(bundle) {
31
+ const names = Array.isArray(bundle.delivered) ? bundle.delivered : Object.keys(bundle.env || {});
32
+ const lines = [];
33
+ if (names.length === 0) {
34
+ lines.push(dim(' No delivery-enabled secrets for this agent.'));
35
+ } else {
36
+ for (const name of names) lines.push(` ${name}`);
37
+ }
38
+ lines.push('');
39
+ lines.push(dim(` ${names.length} secret(s). Values are never printed — run: dashclaw env -- <command>`));
40
+ return lines.join('\n');
41
+ }
42
+
43
+ /**
44
+ * Spawn the child command with the secret bundle merged into its environment.
45
+ * Memory-only: the merged env object lives only in this process and the
46
+ * child's process table — nothing is written anywhere. Resolves with the
47
+ * exit code to assign to process.exitCode (never calls process.exit — a hard
48
+ * exit can trip a libuv teardown assert on Windows).
49
+ */
50
+ export function runWithEnv(bundle, commandArgv) {
51
+ const [cmd, ...cmdArgs] = commandArgv;
52
+ // No shell: args pass through verbatim (shell:true concatenates them
53
+ // unescaped — mangles quoted args and is deprecated, DEP0190).
54
+ const child = spawn(cmd, cmdArgs, {
55
+ stdio: 'inherit',
56
+ env: { ...process.env, ...(bundle.env || {}) },
57
+ });
58
+ return new Promise((resolve) => {
59
+ child.on('error', (err) => {
60
+ // err.message is a spawn error (ENOENT/EINVAL etc.) — never contains values.
61
+ console.error(`Error: could not start "${cmd}": ${err.message}`);
62
+ if (process.platform === 'win32' && (err.code === 'EINVAL' || err.code === 'ENOENT')) {
63
+ console.error(`Hint: Windows .cmd shims (npm, npx) cannot be spawned directly — try: dashclaw env -- cmd /c ${cmd} ...`);
64
+ }
65
+ resolve(1);
66
+ });
67
+ child.on('exit', (code, signal) => resolve(signal ? 1 : code ?? 1));
68
+ });
69
+ }
package/lib/posture.js ADDED
@@ -0,0 +1,45 @@
1
+ // cli/lib/posture.js
2
+ //
3
+ // Direct-API helpers for the `dashclaw posture` / `dashclaw next` commands.
4
+ // Like the other CLI command groups, these call the live endpoints via
5
+ // apiRequest (fetch + x-api-key) rather than the published SDK, so the CLI never
6
+ // depends on a possibly-stale `dashclaw` package for newly-added routes.
7
+ //
8
+ // Resolve is DRAFT-ONLY: the CLI can create an inactive policy draft, snooze, or
9
+ // accept risk — it can NEVER activate enforcement. An operator (or agent) can
10
+ // prepare a fix; only a human activates it at /policies. Mirrors the API + MCP
11
+ // ceiling so agents can never self-escalate their own governance.
12
+
13
+ import { apiRequest } from './api.js';
14
+
15
+ /** GET /api/posture — score + dimensions + findings + summary + trend. */
16
+ export async function fetchPosture(config) {
17
+ return apiRequest(config, 'GET', '/api/posture');
18
+ }
19
+
20
+ /** GET /api/posture/findings — the prioritized queue (+ optional filters). */
21
+ export async function fetchFindings(config, { status, dimension } = {}) {
22
+ return apiRequest(config, 'GET', '/api/posture/findings', { query: { status, dimension } });
23
+ }
24
+
25
+ /** The single top open finding (the `next` gap), or null when the queue is clear. */
26
+ export async function fetchNext(config) {
27
+ const data = await fetchFindings(config);
28
+ return (data && Array.isArray(data.findings) && data.findings[0]) || null;
29
+ }
30
+
31
+ const RESOLVE_ACTIONS = new Set(['create_draft', 'snooze', 'accept_risk']);
32
+
33
+ /**
34
+ * POST /api/posture/findings/<key>/resolve — DRAFT-ONLY actions.
35
+ * `create_draft` (default) inserts an inactive policy draft; snooze/accept_risk
36
+ * record state. None of these activate enforcement.
37
+ */
38
+ export async function resolveFinding(config, key, action = 'create_draft', note) {
39
+ if (!RESOLVE_ACTIONS.has(action)) {
40
+ throw new Error(`Invalid resolve action "${action}". Draft-only: ${[...RESOLVE_ACTIONS].join(', ')}.`);
41
+ }
42
+ return apiRequest(config, 'POST', `/api/posture/findings/${encodeURIComponent(key)}/resolve`, {
43
+ body: { action, note },
44
+ });
45
+ }
package/package.json CHANGED
@@ -1,13 +1,29 @@
1
1
  {
2
2
  "name": "@dashclaw/cli",
3
- "version": "0.2.0",
4
- "description": "DashClaw terminal approval client",
3
+ "version": "0.3.2",
4
+ "description": "DashClaw terminal client — approve agent actions and diagnose your instance",
5
5
  "type": "module",
6
+ "keywords": [
7
+ "dashclaw",
8
+ "cli",
9
+ "claude-code",
10
+ "ai-governance",
11
+ "agent-governance"
12
+ ],
6
13
  "bin": {
7
14
  "dashclaw": "./bin/dashclaw.js"
8
15
  },
9
- "files": ["bin/", "lib/"],
10
- "engines": { "node": ">=18.0.0" },
16
+ "files": [
17
+ "bin/",
18
+ "lib/",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18.0.0"
23
+ },
24
+ "scripts": {
25
+ "test": "node --test test/**/*.test.js"
26
+ },
11
27
  "dependencies": {
12
28
  "dashclaw": "^2.2.1"
13
29
  },