@dashclaw/cli 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/doctor.js CHANGED
@@ -61,27 +61,36 @@ function nextStepFor(check, { noFix }) {
61
61
  return GUIDANCE[check.id] || null;
62
62
  }
63
63
 
64
- /**
65
- * Run doctor via the API and render results.
66
- * @param {{ baseUrl: string, apiKey: string, json?: boolean, noFix?: boolean, category?: string }} options
67
- */
68
- export async function runDoctor({ baseUrl, apiKey, json, noFix, category }) {
69
- const base = baseUrl.replace(/\/+$/, '');
70
- const headers = { 'Content-Type': 'application/json', 'x-api-key': apiKey };
71
-
64
+ function doctorUrl(base, category) {
72
65
  let url = `${base}/api/doctor?include_fixes=true`;
73
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
+ }
74
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);
75
83
  let res;
76
84
  try {
77
- res = await fetch(url, { headers });
85
+ res = await fetch(doctorUrl(base, category), { headers });
78
86
  } catch (err) {
79
- console.error(red(`\nError: Could not reach DashClaw at ${base}`));
80
- console.error(dim(` ${err.cause?.code || err.message}`));
81
- console.error(dim(` Check DASHCLAW_BASE_URL and confirm your instance is running.\n`));
82
- process.exit(1);
87
+ exitFetchError(base, err);
83
88
  }
89
+ await handleDoctorResponse(base, res);
90
+ return { result: await res.json(), headers };
91
+ }
84
92
 
93
+ async function handleDoctorResponse(base, res) {
85
94
  if (res.status === 401 || res.status === 403) {
86
95
  console.error(red(`\nError: API key rejected by ${base} (${res.status}).`));
87
96
  console.error(dim(` Check DASHCLAW_API_KEY matches the key on your instance.\n`));
@@ -93,82 +102,100 @@ export async function runDoctor({ baseUrl, apiKey, json, noFix, category }) {
93
102
  console.error(red(`Doctor check failed (${res.status}): ${errText}`));
94
103
  process.exit(1);
95
104
  }
105
+ }
96
106
 
97
- const result = await res.json();
98
-
99
- if (json) {
100
- console.log(JSON.stringify(result, null, 2));
101
- process.exit(result.status === 'healthy' ? 0 : 1);
102
- }
107
+ function exitJson(result) {
108
+ console.log(JSON.stringify(result, null, 2));
109
+ process.exit(result.status === 'healthy' ? 0 : 1);
110
+ }
103
111
 
104
- // --- Header ----------------------------------------------------------------
112
+ function renderHeader(base) {
105
113
  const hostLabel = base.replace(/^https?:\/\//, '');
106
114
  console.log();
107
115
  console.log(` ${BRAND('[DashClaw]')} ${bold('Doctor')} ${dim(hostLabel)}`);
108
116
  console.log();
117
+ }
109
118
 
110
- // --- Grouped checks --------------------------------------------------------
119
+ function groupChecks(checks) {
111
120
  const grouped = {};
112
- for (const check of result.checks) {
121
+ for (const check of checks) {
113
122
  if (!grouped[check.category]) grouped[check.category] = [];
114
123
  grouped[check.category].push(check);
115
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
+ }
116
141
 
142
+ function renderGroupedChecks(checks, noFix) {
143
+ const grouped = groupChecks(checks);
117
144
  for (const cat of CATEGORY_ORDER) {
118
- const checks = grouped[cat];
119
- if (!checks || checks.length === 0) continue;
145
+ const categoryChecks = grouped[cat];
146
+ if (!categoryChecks || categoryChecks.length === 0) continue;
120
147
 
121
148
  console.log(` ${bold(CATEGORY_LABELS[cat] || cat)}`);
122
- for (const check of checks) {
123
- const icon = ICONS[check.status] || '?';
124
- const titleStyled = check.status === 'pass' ? check.title : bold(check.title);
125
- console.log(` ${icon} ${titleStyled}`);
126
-
127
- if (check.status !== 'pass') {
128
- console.log(` ${dim(check.message)}`);
129
- const tip = nextStepFor(check, { noFix });
130
- if (tip) {
131
- console.log(` ${dim('\u2192')} ${tip}`);
132
- }
133
- }
149
+ for (const check of categoryChecks) {
150
+ renderCheck(check, noFix);
134
151
  }
135
152
  console.log();
136
153
  }
154
+ }
137
155
 
138
- // --- Auto-fix (remote fixes only; local-only fixes blocked by API) --------
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 }) {
139
180
  let fixCount = 0;
140
181
  let latestRecheck = null;
141
- if (!noFix) {
142
- const fixable = result.checks.filter((c) => c.status === 'fail' && c.fix?.type === 'auto');
143
- if (fixable.length > 0) {
144
- console.log(` ${bold('Applying auto-fixes...')}`);
145
- for (const check of fixable) {
146
- try {
147
- const fixRes = await fetch(`${base}/api/doctor/fix`, {
148
- method: 'POST',
149
- headers,
150
- body: JSON.stringify({ action: check.fix.action }),
151
- });
152
- const fixResult = await fixRes.json();
153
- if (fixResult.applied) {
154
- console.log(` ${green('\u2192')} Fixed: ${fixResult.description}`);
155
- fixCount++;
156
- if (fixResult.recheck) latestRecheck = fixResult.recheck;
157
- } else {
158
- console.log(` ${dim('\u2192')} Skipped: ${fixResult.description}`);
159
- }
160
- } catch (err) {
161
- console.log(` ${red('\u2717')} Fix "${check.fix.action}" failed: ${err.cause?.code || err.message}`);
162
- }
163
- }
164
- console.log();
165
- }
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;
166
192
  }
193
+ console.log();
194
+ return { fixCount, latestRecheck };
195
+ }
167
196
 
168
- // --- Summary ---------------------------------------------------------------
169
- const reporting = latestRecheck || result;
197
+ function renderSummary(reporting) {
170
198
  const { pass, warn, fail } = reporting.summary;
171
-
172
199
  console.log(hr());
173
200
  console.log();
174
201
 
@@ -179,31 +206,65 @@ export async function runDoctor({ baseUrl, apiKey, json, noFix, category }) {
179
206
  ];
180
207
  console.log(` ${segments.join(' ' + dim('\u00b7') + ' ')}`);
181
208
  console.log();
209
+ }
182
210
 
183
- // --- Contextual footer -----------------------------------------------------
184
- const healthy = reporting.status === 'healthy';
185
-
211
+ function renderFixCount(fixCount) {
186
212
  if (fixCount > 0) {
187
213
  console.log(` ${green('\u2713')} ${fixCount} issue${fixCount !== 1 ? 's' : ''} auto-fixed this run.`);
188
214
  }
215
+ }
189
216
 
190
- if (noFix) {
191
- const autoFixable = result.checks.filter(
192
- (c) => c.status !== 'pass' && c.fix?.type === 'auto',
193
- ).length;
194
- if (autoFixable > 0) {
195
- console.log(
196
- ` ${BRAND('\u2192')} ${autoFixable} issue${autoFixable !== 1 ? 's' : ''} can be auto-fixed. Run: ${bold('dashclaw doctor')}`,
197
- );
198
- }
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
+ );
199
226
  }
227
+ }
200
228
 
229
+ function renderHealthFooter(base, healthy) {
201
230
  if (!healthy) {
202
231
  console.log(` ${dim('Docs:')} ${base}/setup`);
203
232
  } else {
204
233
  console.log(` ${green('\u2713')} ${bold('All systems healthy')} — ${dim('ready to govern actions')}`);
205
234
  }
206
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);
207
268
 
208
269
  process.exit(healthy ? 0 : 1);
209
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/package.json CHANGED
@@ -1,21 +1,34 @@
1
- {
2
- "name": "@dashclaw/cli",
3
- "version": "0.3.1",
4
- "description": "DashClaw terminal client — approve agent actions and diagnose your instance",
5
- "type": "module",
6
- "bin": {
7
- "dashclaw": "./bin/dashclaw.js"
8
- },
9
- "files": ["bin/", "lib/", "README.md"],
10
- "engines": { "node": ">=18.0.0" },
11
- "scripts": {
12
- "test": "node --test test/**/*.test.js"
13
- },
14
- "dependencies": {
15
- "dashclaw": "^2.2.1"
16
- },
17
- "license": "MIT",
18
- "publishConfig": {
19
- "access": "public"
20
- }
21
- }
1
+ {
2
+ "name": "@dashclaw/cli",
3
+ "version": "0.3.2",
4
+ "description": "DashClaw terminal client — approve agent actions and diagnose your instance",
5
+ "type": "module",
6
+ "keywords": [
7
+ "dashclaw",
8
+ "cli",
9
+ "claude-code",
10
+ "ai-governance",
11
+ "agent-governance"
12
+ ],
13
+ "bin": {
14
+ "dashclaw": "./bin/dashclaw.js"
15
+ },
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
+ },
27
+ "dependencies": {
28
+ "dashclaw": "^2.2.1"
29
+ },
30
+ "license": "MIT",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }