@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/README.md +179 -0
- package/bin/dashclaw.js +1143 -166
- package/lib/api.js +64 -0
- package/lib/claude/install.js +386 -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 +261 -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/cost.js +108 -0
- package/lib/doctor.js +270 -0
- package/lib/env.js +69 -0
- package/lib/posture.js +45 -0
- package/package.json +20 -4
package/lib/api.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny direct-API helper for the DashClaw CLI.
|
|
3
|
+
*
|
|
4
|
+
* The CLI imports `DashClaw` from the PUBLISHED npm package, whose installed
|
|
5
|
+
* version can LAG this repo and lack newly-added SDK methods. Commands that
|
|
6
|
+
* call brand-new endpoints therefore go through this helper instead — a clean
|
|
7
|
+
* `fetch` with the `x-api-key` header against the already-resolved
|
|
8
|
+
* baseUrl/apiKey. No dependency on unreleased SDK methods.
|
|
9
|
+
*
|
|
10
|
+
* Error semantics mirror sdk/dashclaw.js `_request`: parse JSON defensively
|
|
11
|
+
* (fall back to {} on a non-JSON gateway/error body so the real status isn't
|
|
12
|
+
* lost), and throw a status-bearing Error on a non-ok response.
|
|
13
|
+
*
|
|
14
|
+
* Node 20 global `fetch` — no imports needed.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ baseUrl: string, apiKey: string }} config
|
|
17
|
+
* @param {string} method
|
|
18
|
+
* @param {string} path
|
|
19
|
+
* @param {{ body?: any, query?: Record<string, any> }} [opts]
|
|
20
|
+
* @returns {Promise<any>}
|
|
21
|
+
*/
|
|
22
|
+
export async function apiRequest({ baseUrl, apiKey }, method, path, { body, query } = {}) {
|
|
23
|
+
let url = `${baseUrl}${path}`;
|
|
24
|
+
if (query) {
|
|
25
|
+
// Skip undefined/null values. Passing them straight into URLSearchParams
|
|
26
|
+
// serializes the literal strings "undefined"/"null", which routes treat as
|
|
27
|
+
// real filter values and match zero rows. Falsy-but-valid values (0, false,
|
|
28
|
+
// '') are preserved.
|
|
29
|
+
const qs = new URLSearchParams();
|
|
30
|
+
for (const [key, value] of Object.entries(query)) {
|
|
31
|
+
if (value !== undefined && value !== null) qs.append(key, String(value));
|
|
32
|
+
}
|
|
33
|
+
const s = qs.toString();
|
|
34
|
+
if (s) url += `?${s}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const res = await fetch(url, {
|
|
38
|
+
method,
|
|
39
|
+
headers: {
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
'x-api-key': apiKey,
|
|
42
|
+
},
|
|
43
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Parse the body defensively. A non-JSON error body (a Vercel 502/504/413
|
|
47
|
+
// gateway page, a 429 rate-limit page) makes res.json() reject with a
|
|
48
|
+
// SyntaxError, which would propagate instead of the status-bearing error
|
|
49
|
+
// below and lose res.status. Fall back to {} so the real status is thrown.
|
|
50
|
+
let data = {};
|
|
51
|
+
try {
|
|
52
|
+
data = await res.json();
|
|
53
|
+
} catch {
|
|
54
|
+
data = {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const err = new Error(data.error || `Request failed with status ${res.status}`);
|
|
59
|
+
err.status = res.status;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// `dashclaw code apply <manifestId>` — fetch a write plan from the server
|
|
2
|
+
// and apply it locally. The server emits the manifest (Phase 6) with a
|
|
3
|
+
// 24h TTL. This module is the only place the CLI writes to disk for the
|
|
4
|
+
// Optimal Files feature.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { _ensureInsideProject, applyMerge } from './vendored.js';
|
|
9
|
+
|
|
10
|
+
const SECRET_PATTERNS = [
|
|
11
|
+
{ name: 'stripe_test', re: /sk_test_[A-Za-z0-9]{8,}/g },
|
|
12
|
+
{ name: 'stripe_live', re: /sk_live_[A-Za-z0-9]{8,}/g },
|
|
13
|
+
{ name: 'stripe_webhook', re: /whsec_[A-Za-z0-9]{8,}/g },
|
|
14
|
+
{ name: 'openai_key', re: /sk-[A-Za-z0-9]{20,}/g },
|
|
15
|
+
{ name: 'anthropic_key', re: /sk-ant-[A-Za-z0-9_\-]{20,}/g },
|
|
16
|
+
{ name: 'github_pat', re: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g },
|
|
17
|
+
{ name: 'aws_access', re: /AKIA[0-9A-Z]{16}/g },
|
|
18
|
+
{ name: 'jwt', re: /eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}/g },
|
|
19
|
+
{ name: 'private_key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g },
|
|
20
|
+
{ name: 'env_assign', re: /\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CRED|AUTH))\s*=\s*['"]?[^\s'"\n]+/g },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function scanForSecrets(content) {
|
|
24
|
+
let redactions = 0;
|
|
25
|
+
let out = String(content == null ? '' : content);
|
|
26
|
+
for (const p of SECRET_PATTERNS) {
|
|
27
|
+
out = out.replace(p.re, (_match, captured) => {
|
|
28
|
+
redactions++;
|
|
29
|
+
if (p.name === 'env_assign' && captured) return `${captured}=<REDACTED:${p.name}>`;
|
|
30
|
+
return `<REDACTED:${p.name}>`;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return { status: redactions === 0 ? 'passed' : 'redacted', redactions, redacted: out };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function fetchManifest(baseUrl, apiKey, manifestId, { fetchImpl = fetch }) {
|
|
37
|
+
const url = baseUrl.replace(/\/+$/, '') + '/api/code-sessions/manifests/' + encodeURIComponent(manifestId);
|
|
38
|
+
const res = await fetchImpl(url, {
|
|
39
|
+
headers: { 'x-api-key': apiKey },
|
|
40
|
+
});
|
|
41
|
+
let body = null;
|
|
42
|
+
try { body = await res.json(); } catch { /* null */ }
|
|
43
|
+
return { status: res.status, ok: res.ok, body };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runApply({
|
|
47
|
+
baseUrl,
|
|
48
|
+
apiKey,
|
|
49
|
+
manifestId,
|
|
50
|
+
dest,
|
|
51
|
+
yes = false,
|
|
52
|
+
allowRedactions = false,
|
|
53
|
+
allowOverwriteSideBySide = false,
|
|
54
|
+
fetchImpl = fetch,
|
|
55
|
+
logger = console,
|
|
56
|
+
}) {
|
|
57
|
+
if (!manifestId) throw new Error('runApply: manifestId is required');
|
|
58
|
+
if (!dest) throw new Error('runApply: --dest=<dir> is required');
|
|
59
|
+
if (!baseUrl || !apiKey) throw new Error('runApply: baseUrl and apiKey are required');
|
|
60
|
+
|
|
61
|
+
const destAbs = path.resolve(dest);
|
|
62
|
+
try { fs.mkdirSync(destAbs, { recursive: true }); }
|
|
63
|
+
catch (err) { throw new Error('Could not create destination ' + destAbs + ': ' + err.message); }
|
|
64
|
+
|
|
65
|
+
const { status, ok, body } = await fetchManifest(baseUrl, apiKey, manifestId, { fetchImpl });
|
|
66
|
+
if (!ok) throw new Error('Failed to load manifest: HTTP ' + status + (body?.error ? ' — ' + body.error : ''));
|
|
67
|
+
const plan = Array.isArray(body?.plan) ? body.plan : Array.isArray(body?.plan?.results) ? body.plan.results : null;
|
|
68
|
+
if (!plan) throw new Error('Manifest payload missing plan');
|
|
69
|
+
|
|
70
|
+
const results = [];
|
|
71
|
+
for (const entry of plan) {
|
|
72
|
+
const target = entry.path;
|
|
73
|
+
if (!target) {
|
|
74
|
+
results.push({ path: '(unknown)', status: 'invalid_entry' });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const abs = _ensureInsideProject(destAbs, target);
|
|
78
|
+
if (!abs) {
|
|
79
|
+
results.push({ path: target, status: 'unsafe_path' });
|
|
80
|
+
logger.warn(` ${target} -> unsafe_path (refused)`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const content = entry.content;
|
|
84
|
+
if (typeof content !== 'string') {
|
|
85
|
+
results.push({ path: target, status: 'no_content' });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const scan = scanForSecrets(content);
|
|
89
|
+
if (scan.status === 'redacted' && !allowRedactions) {
|
|
90
|
+
results.push({ path: target, status: 'redacted', redactions: scan.redactions });
|
|
91
|
+
logger.warn(` ${target} -> ${scan.redactions} secret pattern(s) detected, refused (use --allow-redactions)`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const mode = entry.mode || 'create';
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
if (mode === 'merge') {
|
|
98
|
+
if (!fs.existsSync(abs)) {
|
|
99
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
100
|
+
fs.writeFileSync(abs, scan.redacted, 'utf8');
|
|
101
|
+
results.push({ path: target, status: 'created', bytes: Buffer.byteLength(scan.redacted, 'utf8') });
|
|
102
|
+
logger.info(` ${target} -> created`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const existing = fs.readFileSync(abs, 'utf8');
|
|
106
|
+
const merged = applyMerge(existing, scan.redacted, {
|
|
107
|
+
acceptedHeadings: entry.acceptedHeadings || [],
|
|
108
|
+
acceptedBullets: entry.acceptedBullets || [],
|
|
109
|
+
});
|
|
110
|
+
fs.writeFileSync(abs, merged.merged, 'utf8');
|
|
111
|
+
results.push({ path: target, status: 'merged', additions: merged.additions });
|
|
112
|
+
logger.info(` ${target} -> merged`);
|
|
113
|
+
} else if (mode === 'side_by_side') {
|
|
114
|
+
const sideAbs = entry.absolutePath
|
|
115
|
+
? path.resolve(entry.absolutePath)
|
|
116
|
+
: sideBySidePath(abs);
|
|
117
|
+
const guardedSide = _ensureInsideProject(destAbs, path.relative(destAbs, sideAbs));
|
|
118
|
+
if (!guardedSide) {
|
|
119
|
+
results.push({ path: target, status: 'unsafe_path' });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
fs.mkdirSync(path.dirname(guardedSide), { recursive: true });
|
|
123
|
+
if (fs.existsSync(guardedSide) && !allowOverwriteSideBySide) {
|
|
124
|
+
results.push({ path: target, status: 'side_by_side_conflict' });
|
|
125
|
+
logger.warn(` ${target} -> side_by_side_conflict (pass --overwrite to replace)`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
fs.writeFileSync(guardedSide, scan.redacted, 'utf8');
|
|
129
|
+
results.push({ path: target, status: 'side_by_side', bytes: Buffer.byteLength(scan.redacted, 'utf8') });
|
|
130
|
+
logger.info(` ${target} -> side_by_side`);
|
|
131
|
+
} else if (mode === 'skip') {
|
|
132
|
+
results.push({ path: target, status: 'skipped' });
|
|
133
|
+
} else {
|
|
134
|
+
// 'create' or 'overwrite'
|
|
135
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
136
|
+
const existed = fs.existsSync(abs);
|
|
137
|
+
if (existed && mode !== 'overwrite' && !yes) {
|
|
138
|
+
results.push({ path: target, status: 'refused_overwrite_without_yes' });
|
|
139
|
+
logger.warn(` ${target} -> exists; pass --yes or rebuild manifest with mode='overwrite'`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
fs.writeFileSync(abs, scan.redacted, 'utf8');
|
|
143
|
+
results.push({ path: target, status: existed ? 'overwritten' : 'created' });
|
|
144
|
+
logger.info(` ${target} -> ${existed ? 'overwritten' : 'created'}`);
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
results.push({ path: target, status: 'error', error: err.message });
|
|
148
|
+
logger.warn(` ${target} -> error: ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return results;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sideBySidePath(abs) {
|
|
155
|
+
const dir = path.dirname(abs);
|
|
156
|
+
const base = path.basename(abs);
|
|
157
|
+
const ext = path.extname(base);
|
|
158
|
+
if (!ext) return path.join(dir, base + '.NEW');
|
|
159
|
+
return path.join(dir, base.slice(0, -ext.length) + '.NEW' + ext);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Exported for the sync script + tests that want to assert the scan layer
|
|
163
|
+
// stays in lock-step with app/lib/claude-code/optimal-files/secret-scan.js.
|
|
164
|
+
export { scanForSecrets };
|