@dashclaw/cli 0.2.0 → 0.3.1
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/README.md +104 -0
- package/bin/dashclaw.js +903 -44
- package/lib/api.js +64 -0
- package/lib/code/apply.js +164 -0
- package/lib/code/codex-parser.vendored.js +360 -0
- package/lib/code/ingest-codex.js +244 -0
- package/lib/code/ingest.js +245 -0
- package/lib/code/memo.js +57 -0
- package/lib/code/vendored.js +219 -0
- package/lib/codex/install.js +405 -0
- package/lib/codex/notify.js +203 -0
- package/lib/config.js +160 -0
- package/lib/doctor.js +209 -0
- package/lib/posture.js +45 -0
- package/package.json +21 -18
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// cli/lib/codex/notify.js
|
|
2
|
+
//
|
|
3
|
+
// `dashclaw codex notify` — Codex legacy `notify` config target.
|
|
4
|
+
//
|
|
5
|
+
// Codex CLI supports a `notify` config that runs an external command after
|
|
6
|
+
// each agent turn completes. Codex appends a JSON payload as the FINAL argv
|
|
7
|
+
// argument:
|
|
8
|
+
//
|
|
9
|
+
// notify = ["node", "/path/to/dashclaw.js", "codex", "notify"]
|
|
10
|
+
//
|
|
11
|
+
// then on each turn-complete, Codex spawns:
|
|
12
|
+
//
|
|
13
|
+
// node /path/to/dashclaw.js codex notify '{"type":"agent-turn-complete", ...}'
|
|
14
|
+
//
|
|
15
|
+
// This module:
|
|
16
|
+
// 1. Extracts the JSON payload from the last argv arg.
|
|
17
|
+
// 2. Validates it's an `agent-turn-complete` event.
|
|
18
|
+
// 3. POSTs a record to /api/actions/by-tool-use-id (the same endpoint the
|
|
19
|
+
// PostToolUse Python hook uses for its terminal patch) with an action
|
|
20
|
+
// shape that represents the turn as a single observable unit.
|
|
21
|
+
//
|
|
22
|
+
// Failure mode: Codex spawns notify fire-and-forget with stdio nulled. We
|
|
23
|
+
// MUST exit 0 on any failure so we don't surface error output to the user.
|
|
24
|
+
// All errors are logged to stderr and swallowed by the spawn.
|
|
25
|
+
|
|
26
|
+
import { request } from 'node:http';
|
|
27
|
+
import { request as requestHttps } from 'node:https';
|
|
28
|
+
|
|
29
|
+
const TURN_COMPLETE_TYPE = 'agent-turn-complete';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse the Codex notify payload from argv. Codex appends the JSON as the
|
|
33
|
+
* final argv slot. We accept either:
|
|
34
|
+
* - argv last arg is a JSON string → parse it
|
|
35
|
+
* - argv last arg is empty + stdin has JSON → read stdin
|
|
36
|
+
*
|
|
37
|
+
* Returns the parsed object, or null if no valid payload was found.
|
|
38
|
+
*/
|
|
39
|
+
export function parseNotifyPayload(argv) {
|
|
40
|
+
if (!Array.isArray(argv) || argv.length === 0) return null;
|
|
41
|
+
const last = argv[argv.length - 1];
|
|
42
|
+
if (typeof last !== 'string' || last.length === 0) return null;
|
|
43
|
+
if (last[0] !== '{') return null;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(last);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Map a Codex turn-complete payload to a DashClaw action_record shape.
|
|
53
|
+
* We declare the turn as a single coarse action so it shows up in the
|
|
54
|
+
* decision ledger. Token/cost data is not in the notify payload — it will
|
|
55
|
+
* be back-filled by the Phase 3 JSONL ingest path.
|
|
56
|
+
*/
|
|
57
|
+
export function buildActionFromNotify(payload, { agentId = 'codex' } = {}) {
|
|
58
|
+
const lastMessage = payload.last_assistant_message || '';
|
|
59
|
+
const summary = lastMessage.length > 200
|
|
60
|
+
? lastMessage.slice(0, 197) + '...'
|
|
61
|
+
: lastMessage;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
agent_id: agentId,
|
|
65
|
+
action_type: 'agent_turn',
|
|
66
|
+
declared_goal: `Codex turn ${payload.turn_id || '?'} (${payload.thread_id || 'thread'})`,
|
|
67
|
+
outcome: 'success',
|
|
68
|
+
metadata: {
|
|
69
|
+
source: 'codex-notify',
|
|
70
|
+
thread_id: payload.thread_id,
|
|
71
|
+
turn_id: payload.turn_id,
|
|
72
|
+
cwd: payload.cwd,
|
|
73
|
+
client: payload.client || 'codex',
|
|
74
|
+
input_message_count: Array.isArray(payload.input_messages)
|
|
75
|
+
? payload.input_messages.length
|
|
76
|
+
: 0,
|
|
77
|
+
last_assistant_summary: summary,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Send an action record to DashClaw. Returns:
|
|
84
|
+
* - { status: 'sent', actionId } on 2xx
|
|
85
|
+
* - { status: 'skipped', reason } on local-validation skip
|
|
86
|
+
* - { status: 'error', reason } on network/4xx/5xx
|
|
87
|
+
*
|
|
88
|
+
* Never throws. All errors are returned in the result object so the caller
|
|
89
|
+
* can decide whether to swallow them.
|
|
90
|
+
*/
|
|
91
|
+
export async function postNotifyAction({ baseUrl, apiKey, action, timeoutMs = 5000 }) {
|
|
92
|
+
if (!baseUrl || !apiKey) {
|
|
93
|
+
return { status: 'skipped', reason: 'missing_config' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let url;
|
|
97
|
+
try {
|
|
98
|
+
url = new URL('/api/actions', baseUrl);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return { status: 'error', reason: 'invalid_base_url', detail: err.message };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const body = JSON.stringify(action);
|
|
104
|
+
const headers = {
|
|
105
|
+
'content-type': 'application/json',
|
|
106
|
+
'content-length': Buffer.byteLength(body).toString(),
|
|
107
|
+
'x-api-key': apiKey,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
const lib = url.protocol === 'https:' ? requestHttps : request;
|
|
112
|
+
const req = lib(
|
|
113
|
+
{
|
|
114
|
+
method: 'POST',
|
|
115
|
+
host: url.hostname,
|
|
116
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
117
|
+
path: url.pathname + url.search,
|
|
118
|
+
headers,
|
|
119
|
+
},
|
|
120
|
+
(res) => {
|
|
121
|
+
let chunks = '';
|
|
122
|
+
res.setEncoding('utf8');
|
|
123
|
+
res.on('data', (c) => { chunks += c; });
|
|
124
|
+
res.on('end', () => {
|
|
125
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
126
|
+
let parsed = null;
|
|
127
|
+
try { parsed = JSON.parse(chunks); } catch { /* keep null */ }
|
|
128
|
+
resolve({
|
|
129
|
+
status: 'sent',
|
|
130
|
+
actionId: parsed?.action_id || parsed?.id || null,
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
resolve({
|
|
134
|
+
status: 'error',
|
|
135
|
+
reason: `http_${res.statusCode}`,
|
|
136
|
+
detail: chunks.slice(0, 200),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
req.on('error', (err) => {
|
|
144
|
+
resolve({ status: 'error', reason: 'network', detail: err.message });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
req.setTimeout(timeoutMs, () => {
|
|
148
|
+
req.destroy();
|
|
149
|
+
resolve({ status: 'error', reason: 'timeout' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
req.write(body);
|
|
153
|
+
req.end();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run the notify command. This is the CLI entrypoint.
|
|
159
|
+
*
|
|
160
|
+
* IMPORTANT: must always exit 0 (the caller calls process.exit(0)).
|
|
161
|
+
* Codex spawns notify with stdio nulled and treats any failure as silent.
|
|
162
|
+
* We log to stderr (which Codex discards by design) for diagnostics.
|
|
163
|
+
*
|
|
164
|
+
* Options:
|
|
165
|
+
* argv — argv array (defaults to process.argv.slice(2))
|
|
166
|
+
* baseUrl — DashClaw instance URL
|
|
167
|
+
* apiKey — DashClaw API key
|
|
168
|
+
* agentId — agent identity (default: codex)
|
|
169
|
+
* logger — { warn, info } for diagnostic output
|
|
170
|
+
* skipPost — if true, do everything except the HTTP call (testing)
|
|
171
|
+
*/
|
|
172
|
+
export async function runCodexNotify({
|
|
173
|
+
argv = process.argv.slice(2),
|
|
174
|
+
baseUrl,
|
|
175
|
+
apiKey,
|
|
176
|
+
agentId = 'codex',
|
|
177
|
+
logger = console,
|
|
178
|
+
skipPost = false,
|
|
179
|
+
} = {}) {
|
|
180
|
+
const payload = parseNotifyPayload(argv);
|
|
181
|
+
if (!payload) {
|
|
182
|
+
logger.warn('codex-notify: no JSON payload in argv, exiting 0');
|
|
183
|
+
return { status: 'skipped', reason: 'no_payload' };
|
|
184
|
+
}
|
|
185
|
+
if (payload.type !== TURN_COMPLETE_TYPE) {
|
|
186
|
+
logger.warn(`codex-notify: unknown payload type "${payload.type}", exiting 0`);
|
|
187
|
+
return { status: 'skipped', reason: 'unknown_type', type: payload.type };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const action = buildActionFromNotify(payload, { agentId });
|
|
191
|
+
|
|
192
|
+
if (skipPost) {
|
|
193
|
+
return { status: 'dry_run', action };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = await postNotifyAction({ baseUrl, apiKey, action });
|
|
197
|
+
if (result.status === 'sent') {
|
|
198
|
+
logger.info?.(`codex-notify: recorded action ${result.actionId || '(unknown id)'}`);
|
|
199
|
+
} else if (result.status === 'error') {
|
|
200
|
+
logger.warn(`codex-notify: ${result.reason}${result.detail ? ' — ' + result.detail : ''}`);
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
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
|
+
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
|
+
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/doctor.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
/**
|
|
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
|
+
|
|
72
|
+
let url = `${base}/api/doctor?include_fixes=true`;
|
|
73
|
+
if (category) url += `&category=${encodeURIComponent(category)}`;
|
|
74
|
+
|
|
75
|
+
let res;
|
|
76
|
+
try {
|
|
77
|
+
res = await fetch(url, { headers });
|
|
78
|
+
} 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);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (res.status === 401 || res.status === 403) {
|
|
86
|
+
console.error(red(`\nError: API key rejected by ${base} (${res.status}).`));
|
|
87
|
+
console.error(dim(` Check DASHCLAW_API_KEY matches the key on your instance.\n`));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!res.ok && res.status !== 503) {
|
|
92
|
+
const errText = await res.text().catch(() => '');
|
|
93
|
+
console.error(red(`Doctor check failed (${res.status}): ${errText}`));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
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
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Header ----------------------------------------------------------------
|
|
105
|
+
const hostLabel = base.replace(/^https?:\/\//, '');
|
|
106
|
+
console.log();
|
|
107
|
+
console.log(` ${BRAND('[DashClaw]')} ${bold('Doctor')} ${dim(hostLabel)}`);
|
|
108
|
+
console.log();
|
|
109
|
+
|
|
110
|
+
// --- Grouped checks --------------------------------------------------------
|
|
111
|
+
const grouped = {};
|
|
112
|
+
for (const check of result.checks) {
|
|
113
|
+
if (!grouped[check.category]) grouped[check.category] = [];
|
|
114
|
+
grouped[check.category].push(check);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const cat of CATEGORY_ORDER) {
|
|
118
|
+
const checks = grouped[cat];
|
|
119
|
+
if (!checks || checks.length === 0) continue;
|
|
120
|
+
|
|
121
|
+
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
|
+
}
|
|
134
|
+
}
|
|
135
|
+
console.log();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Auto-fix (remote fixes only; local-only fixes blocked by API) --------
|
|
139
|
+
let fixCount = 0;
|
|
140
|
+
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
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- Summary ---------------------------------------------------------------
|
|
169
|
+
const reporting = latestRecheck || result;
|
|
170
|
+
const { pass, warn, fail } = reporting.summary;
|
|
171
|
+
|
|
172
|
+
console.log(hr());
|
|
173
|
+
console.log();
|
|
174
|
+
|
|
175
|
+
const segments = [
|
|
176
|
+
green(`${pass} passed`),
|
|
177
|
+
warn > 0 ? yellow(`${warn} warning${warn !== 1 ? 's' : ''}`) : dim('0 warnings'),
|
|
178
|
+
fail > 0 ? red(`${fail} failed`) : dim('0 failed'),
|
|
179
|
+
];
|
|
180
|
+
console.log(` ${segments.join(' ' + dim('\u00b7') + ' ')}`);
|
|
181
|
+
console.log();
|
|
182
|
+
|
|
183
|
+
// --- Contextual footer -----------------------------------------------------
|
|
184
|
+
const healthy = reporting.status === 'healthy';
|
|
185
|
+
|
|
186
|
+
if (fixCount > 0) {
|
|
187
|
+
console.log(` ${green('\u2713')} ${fixCount} issue${fixCount !== 1 ? 's' : ''} auto-fixed this run.`);
|
|
188
|
+
}
|
|
189
|
+
|
|
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
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!healthy) {
|
|
202
|
+
console.log(` ${dim('Docs:')} ${base}/setup`);
|
|
203
|
+
} else {
|
|
204
|
+
console.log(` ${green('\u2713')} ${bold('All systems healthy')} — ${dim('ready to govern actions')}`);
|
|
205
|
+
}
|
|
206
|
+
console.log();
|
|
207
|
+
|
|
208
|
+
process.exit(healthy ? 0 : 1);
|
|
209
|
+
}
|
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,18 +1,21 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@dashclaw/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DashClaw terminal
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"dashclaw": "./bin/dashclaw.js"
|
|
8
|
-
},
|
|
9
|
-
"files": ["bin/", "lib/"],
|
|
10
|
-
"engines": { "node": ">=18.0.0" },
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
},
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
}
|