@dashclaw/cli 0.4.0 → 0.6.0

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.
@@ -3,11 +3,13 @@
3
3
  // `dashclaw install codex` — provisions DashClaw governance into Codex CLI.
4
4
  //
5
5
  // What it does, idempotently:
6
- // 1. Copies the Python governance hooks (pretool, posttool, stop) and the
7
- // vendored `dashclaw_agent_intel` module into ~/.codex/hooks/dashclaw/.
6
+ // 1. Copies the Python governance hooks (pretool, posttool, stop, session
7
+ // digest) and the vendored `dashclaw_agent_intel` module into
8
+ // ~/.codex/hooks/dashclaw/.
8
9
  // 2. Merges a managed block into ~/.codex/config.toml that registers:
9
10
  // - the DashClaw MCP server (stdio)
10
- // - PreToolUse / PostToolUse / Stop hooks pointing at the copied scripts
11
+ // - PreToolUse / PostToolUse / Stop / SessionStart hooks pointing at
12
+ // the copied scripts
11
13
  // - approval_policy = "on-request" so Codex surfaces require_approval
12
14
  // decisions from DashClaw guard
13
15
  // 3. Drops a managed block into <project>/AGENTS.md (or creates the file)
@@ -57,6 +59,12 @@ const HOOK_FILES = [
57
59
  'dashclaw_pretool.py',
58
60
  'dashclaw_posttool.py',
59
61
  'dashclaw_stop.py',
62
+ // dashclaw_stop.py imports this for Code Sessions ingest; without it the
63
+ // import fails inside a try/except and ingest silently no-ops (v2.7 fix).
64
+ 'dashclaw_code_session_reporter.py',
65
+ // SessionStart digest (v3.7 item 6) — wired once codex-cli 0.139.0's
66
+ // SessionStart hook event was confirmed to fire (see PLUGIN_PARITY.md).
67
+ 'dashclaw_session_digest.py',
60
68
  ];
61
69
  const HOOK_INTEL_DIR = 'dashclaw_agent_intel';
62
70
 
@@ -184,6 +192,7 @@ export function buildConfigTomlBlock({
184
192
  const pre = join(hooksDir, 'dashclaw_pretool.py');
185
193
  const post = join(hooksDir, 'dashclaw_posttool.py');
186
194
  const stop = join(hooksDir, 'dashclaw_stop.py');
195
+ const sessionStart = join(hooksDir, 'dashclaw_session_digest.py');
187
196
 
188
197
  const lines = [
189
198
  MANAGED_START,
@@ -215,19 +224,31 @@ export function buildConfigTomlBlock({
215
224
  'matcher = "Bash|Edit|Write|MultiEdit"',
216
225
  '[[hooks.PreToolUse.hooks]]',
217
226
  'type = "command"',
218
- `command = ${tomlString(`${py} ${pre}`)}`,
227
+ // --agent-id on the hook command line mirrors the MCP server args above:
228
+ // the hooks' argv identity beats the machine-ambient DASHCLAW_AGENT_ID
229
+ // env var, so Codex tool calls are never mis-attributed to another
230
+ // harness (roadmap v2.2).
231
+ `command = ${tomlString(`${py} ${pre} --agent-id codex`)}`,
219
232
  'timeoutSec = 3600',
220
233
  '',
221
234
  '[[hooks.PostToolUse]]',
222
235
  'matcher = "Bash|Edit|Write|MultiEdit"',
223
236
  '[[hooks.PostToolUse.hooks]]',
224
237
  'type = "command"',
225
- `command = ${tomlString(`${py} ${post}`)}`,
238
+ `command = ${tomlString(`${py} ${post} --agent-id codex`)}`,
226
239
  '',
227
240
  '[[hooks.Stop]]',
228
241
  '[[hooks.Stop.hooks]]',
229
242
  'type = "command"',
230
- `command = ${tomlString(`${py} ${stop}`)}`,
243
+ `command = ${tomlString(`${py} ${stop} --agent-id codex`)}`,
244
+ '',
245
+ // Codex CLI 0.139.0's hook-event enum contains SessionStart alongside
246
+ // Pre/Post/Stop; wired once that lifecycle was verified to fire
247
+ // (roadmap v3.7 item 6 — see PLUGIN_PARITY.md).
248
+ '[[hooks.SessionStart]]',
249
+ '[[hooks.SessionStart.hooks]]',
250
+ 'type = "command"',
251
+ `command = ${tomlString(`${py} ${sessionStart} --agent-id codex`)}`,
231
252
  MANAGED_END,
232
253
  );
233
254
 
package/lib/config.js CHANGED
@@ -37,17 +37,29 @@ export function clearConfigFile() {
37
37
 
38
38
  export function ask(question) {
39
39
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
40
- return new Promise((res) => {
40
+ return new Promise((res, rej) => {
41
+ let answered = false;
41
42
  rl.question(question, (answer) => {
43
+ answered = true;
42
44
  rl.close();
43
45
  res(answer.trim());
44
46
  });
47
+ // stdin ending (piped input exhausted, Ctrl+D) used to leave the promise
48
+ // pending forever — node then drained the event loop and exited 0 as if
49
+ // the install had succeeded (v5.4 outsider run). Fail loudly instead.
50
+ rl.on('close', () => {
51
+ if (!answered) rej(new Error('stdin closed before the prompt was answered.'));
52
+ });
45
53
  });
46
54
  }
47
55
 
48
56
  export function askSecret(question) {
49
- return new Promise((res) => {
57
+ return new Promise((res, rej) => {
50
58
  const stdin = process.stdin;
59
+ if (stdin.readableEnded || stdin.destroyed) {
60
+ rej(new Error('stdin closed before the prompt was answered.'));
61
+ return;
62
+ }
51
63
  process.stdout.write(question);
52
64
  const wasRaw = stdin.isRaw;
53
65
  if (stdin.setRawMode) stdin.setRawMode(true);
@@ -55,6 +67,13 @@ export function askSecret(question) {
55
67
  stdin.setEncoding('utf8');
56
68
 
57
69
  let input = '';
70
+ // Same silent-exit-0 hazard as ask(): if stdin ends before a newline,
71
+ // reject instead of leaving the promise pending while node exits clean.
72
+ const onEnd = () => {
73
+ stdin.removeListener('data', onData);
74
+ rej(new Error('stdin closed before the prompt was answered.'));
75
+ };
76
+ stdin.once('end', onEnd);
58
77
  const onData = (char) => {
59
78
  const str = String(char);
60
79
  for (const c of str) {
@@ -62,6 +81,7 @@ export function askSecret(question) {
62
81
  if (stdin.setRawMode) stdin.setRawMode(wasRaw);
63
82
  stdin.pause();
64
83
  stdin.removeListener('data', onData);
84
+ stdin.removeListener('end', onEnd);
65
85
  process.stdout.write('\n');
66
86
  res(input.trim());
67
87
  return;
@@ -87,6 +107,31 @@ export function askSecret(question) {
87
107
  });
88
108
  }
89
109
 
110
+ // The CLI sends x-api-key over whatever scheme baseUrl has. Plaintext http to
111
+ // a non-local host exposes the key to the network path — warn once per
112
+ // process (don't refuse: LAN self-hosting over http is a supported setup).
113
+ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1', '0.0.0.0']);
114
+ let warnedInsecureUrl = false;
115
+
116
+ export function __resetInsecureUrlWarning() {
117
+ warnedInsecureUrl = false;
118
+ }
119
+
120
+ function warnIfInsecureBaseUrl(baseUrl) {
121
+ if (warnedInsecureUrl) return;
122
+ try {
123
+ const url = new URL(baseUrl);
124
+ if (url.protocol === 'http:' && !LOCAL_HOSTNAMES.has(url.hostname)) {
125
+ warnedInsecureUrl = true;
126
+ console.error(yellow(
127
+ `Warning: DashClaw base URL ${url.origin} uses plaintext http — your API key is sent unencrypted. Use https for non-local instances.`
128
+ ));
129
+ }
130
+ } catch {
131
+ // Unparseable URL — the request itself will surface the real error.
132
+ }
133
+ }
134
+
90
135
  /**
91
136
  * Resolve config from env, then file, then interactive prompt.
92
137
  * @returns {Promise<{ baseUrl, apiKey, agentId, source } | null>}
@@ -112,6 +157,7 @@ export async function resolveConfig({ env = process.env, interactive = true } =
112
157
  }
113
158
 
114
159
  if (baseUrl && apiKey) {
160
+ warnIfInsecureBaseUrl(baseUrl);
115
161
  return { baseUrl, apiKey, agentId: agentId || 'cli-operator', source };
116
162
  }
117
163
 
@@ -156,5 +202,6 @@ export async function resolveConfig({ env = process.env, interactive = true } =
156
202
  }
157
203
  console.log();
158
204
 
205
+ warnIfInsecureBaseUrl(baseUrl);
159
206
  return { baseUrl, apiKey, agentId, source: 'prompted' };
160
207
  }
package/lib/up/args.js ADDED
@@ -0,0 +1,25 @@
1
+ const DB_MODES = ['docker', 'embedded', 'url'];
2
+
3
+ export function parseUpArgs(argv) {
4
+ const out = { update: false, yes: false, noBrowser: false, db: null, dir: null, port: null, sourceDir: null };
5
+ for (let i = 0; i < argv.length; i++) {
6
+ const a = argv[i];
7
+ if (a === '--update') out.update = true;
8
+ else if (a === '--yes') out.yes = true;
9
+ else if (a === '--no-browser') out.noBrowser = true;
10
+ else if (a === '--db') {
11
+ const v = argv[++i];
12
+ if (!DB_MODES.includes(v)) throw new Error(`--db must be one of: ${DB_MODES.join(', ')}`);
13
+ out.db = v;
14
+ } else if (a === '--dir') out.dir = argv[++i] ?? null;
15
+ else if (a === '--source-dir') out.sourceDir = argv[++i] ?? null;
16
+ else if (a === '--port') {
17
+ const v = Number(argv[++i]);
18
+ if (!Number.isInteger(v) || v < 1 || v > 65535) throw new Error('--port must be an integer 1-65535');
19
+ out.port = v;
20
+ } else if (a.startsWith('--')) {
21
+ throw new Error(`Unknown flag: ${a}. Run 'dashclaw help' for usage.`);
22
+ }
23
+ }
24
+ return out;
25
+ }
package/lib/up/db.js ADDED
@@ -0,0 +1,163 @@
1
+ // cli/lib/up/db.js
2
+ //
3
+ // Database mode selection and provisioning for `dashclaw up`.
4
+ //
5
+ // Three modes:
6
+ // docker — starts/reuses a `dashclaw-pg` Docker container (port 5433)
7
+ // embedded — downloads and runs embedded Postgres via embedded-postgres (~40 MB, first run)
8
+ // url — prompts for a postgresql:// connection string from the user
9
+
10
+ import { join } from 'node:path';
11
+ import { existsSync, rmSync } from 'node:fs';
12
+ import { execFileSync, spawnSync } from 'node:child_process';
13
+
14
+ // Keep this in sync with the "embedded-postgres" version in cli/package.json.
15
+ const EMBEDDED_PG_VERSION = 'embedded-postgres@18.4.0-beta.17';
16
+
17
+ export const LOCAL_DB_URL = 'postgresql://dashclaw:dashclaw@localhost:5433/dashclaw';
18
+
19
+ const CONTAINER = 'dashclaw-pg';
20
+
21
+ /** Returns true when the `docker` binary is available and responsive. */
22
+ export function dockerAvailableSync() {
23
+ return spawnSync('docker', ['--version'], { stdio: 'ignore' }).status === 0;
24
+ }
25
+
26
+ /**
27
+ * Determines which database mode to use.
28
+ *
29
+ * Priority:
30
+ * 1. Explicit --db flag
31
+ * 2. Non-interactive (--yes): docker if available, otherwise embedded
32
+ * 3. Interactive prompt
33
+ *
34
+ * @param {object} opts
35
+ * @param {string|null} opts.flagDb - explicit --db value or null
36
+ * @param {boolean} opts.dockerAvailable
37
+ * @param {boolean} [opts.yes] - non-interactive mode
38
+ * @param {Function} [opts.promptFn] - async (message) => string
39
+ * @returns {Promise<'docker'|'embedded'|'url'>}
40
+ */
41
+ export async function chooseDbMode({ flagDb, dockerAvailable, yes = false, promptFn }) {
42
+ if (flagDb) return flagDb;
43
+ if (yes) return dockerAvailable ? 'docker' : 'embedded';
44
+
45
+ const lines = [
46
+ 'Database — pick one:',
47
+ dockerAvailable
48
+ ? ' 1. Docker Postgres (Docker detected) [default]'
49
+ : ' 1. Docker Postgres (Docker NOT detected)',
50
+ ` 2. Embedded Postgres (no Docker needed, ~40 MB download)${dockerAvailable ? '' : ' [default]'}`,
51
+ ' 3. I have a postgresql:// URL',
52
+ ];
53
+ const def = dockerAvailable ? '1' : '2';
54
+ const answer = (await promptFn(`${lines.join('\n')}\nChoice [${def}]: `)).trim() || def;
55
+ return { 1: 'docker', 2: 'embedded', 3: 'url' }[answer] ?? (dockerAvailable ? 'docker' : 'embedded');
56
+ }
57
+
58
+ /**
59
+ * Returns the docker run command that starts a local Postgres container.
60
+ * Mirrors the repo's docker-compose.yml: postgres:16-alpine, host port 5433,
61
+ * dashclaw/dashclaw/dashclaw credentials, named volume dashclaw_pgdata.
62
+ */
63
+ export function dockerCommandFor() {
64
+ return {
65
+ cmd: 'docker',
66
+ args: [
67
+ 'run', '-d', '--name', CONTAINER,
68
+ '-e', 'POSTGRES_USER=dashclaw',
69
+ '-e', 'POSTGRES_PASSWORD=dashclaw',
70
+ '-e', 'POSTGRES_DB=dashclaw',
71
+ '-p', '5433:5432',
72
+ '-v', 'dashclaw_pgdata:/var/lib/postgresql/data',
73
+ 'postgres:16-alpine',
74
+ ],
75
+ };
76
+ }
77
+
78
+ /** Starts the dashclaw-pg container, reusing it if it already exists. */
79
+ function dockerStartOrRun(logger) {
80
+ const ps = spawnSync(
81
+ 'docker', ['ps', '-aq', '--filter', `name=^${CONTAINER}$`],
82
+ { encoding: 'utf8' },
83
+ );
84
+ if ((ps.stdout || '').trim()) {
85
+ execFileSync('docker', ['start', CONTAINER], { stdio: 'ignore' });
86
+ } else {
87
+ const { cmd, args } = dockerCommandFor();
88
+ execFileSync(cmd, args, { stdio: 'ignore' });
89
+ }
90
+ logger.error('[ok] Docker Postgres running (container dashclaw-pg, port 5433)');
91
+ }
92
+
93
+ /**
94
+ * Provisions the database according to `mode` and returns { databaseUrl, stop }.
95
+ *
96
+ * @param {object} opts
97
+ * @param {'docker'|'embedded'|'url'} opts.mode
98
+ * @param {string} opts.baseDir - base dir for embedded data (e.g. ~/.dashclaw)
99
+ * @param {Function} [opts.promptFn] - async (message) => string (required for mode=url)
100
+ * @param {object} [opts.logger] - object with .error(); defaults to console
101
+ * @returns {Promise<{ databaseUrl: string, stop: () => Promise<void> }>}
102
+ */
103
+ export async function provisionDatabase({ mode, baseDir, promptFn, logger = console }) {
104
+ if (mode === 'url') {
105
+ const url = (await promptFn('postgresql:// connection string: ')).trim();
106
+ if (!url.startsWith('postgresql://')) throw new Error('That is not a postgresql:// URL.');
107
+ return { databaseUrl: url, stop: async () => {} };
108
+ }
109
+
110
+ if (mode === 'docker') {
111
+ dockerStartOrRun(logger);
112
+ await waitForPort(5433, 30_000);
113
+ return { databaseUrl: LOCAL_DB_URL, stop: async () => {} };
114
+ }
115
+
116
+ // mode === 'embedded'
117
+ const { default: EmbeddedPostgres } = await import('embedded-postgres');
118
+ const pgDir = join(baseDir, 'pg');
119
+ // Record whether the data dir existed BEFORE this run.
120
+ // Only clean up on failure when WE created it (a pre-existing dir means
121
+ // a working prior install whose data we must not delete).
122
+ const dirPreExisted = existsSync(pgDir);
123
+ // Derive credentials from LOCAL_DB_URL so the local dev creds live in
124
+ // exactly one place rather than being duplicated as bare literals here.
125
+ const localDb = new URL(LOCAL_DB_URL);
126
+ const pg = new EmbeddedPostgres({
127
+ databaseDir: pgDir,
128
+ user: localDb.username,
129
+ password: localDb.password,
130
+ port: Number(localDb.port),
131
+ persistent: true,
132
+ });
133
+ try {
134
+ await pg.initialise();
135
+ await pg.start();
136
+ } catch (e) {
137
+ if (!dirPreExisted) {
138
+ rmSync(pgDir, { recursive: true, force: true });
139
+ }
140
+ throw new Error(
141
+ `Embedded Postgres (${EMBEDDED_PG_VERSION}) failed: ${e.message}. Retry with --db docker or --db url.`,
142
+ );
143
+ }
144
+ try { await pg.createDatabase('dashclaw'); } catch { /* already exists on resume — fine */ }
145
+ logger.error('[ok] Embedded Postgres running (port 5433, data in ~/.dashclaw/pg)');
146
+ return { databaseUrl: LOCAL_DB_URL, stop: () => pg.stop() };
147
+ }
148
+
149
+ /** Polls :port until it accepts TCP connections, or throws on timeout. */
150
+ async function waitForPort(port, timeoutMs) {
151
+ const net = await import('node:net');
152
+ const deadline = Date.now() + timeoutMs;
153
+ while (Date.now() < deadline) {
154
+ const ok = await new Promise((resolve) => {
155
+ const s = net.connect(port, '127.0.0.1');
156
+ s.once('connect', () => { s.destroy(); resolve(true); });
157
+ s.once('error', () => resolve(false));
158
+ });
159
+ if (ok) return;
160
+ await new Promise((r) => setTimeout(r, 500));
161
+ }
162
+ throw new Error(`Postgres did not accept connections on :${port} within ${timeoutMs / 1000}s.`);
163
+ }
@@ -0,0 +1,82 @@
1
+ // cli/lib/up/fetch-app.js
2
+ //
3
+ // Version resolve + tarball fetch/extract for `dashclaw up`.
4
+ // Fetches the latest platform version from npm and downloads the corresponding
5
+ // GitHub release tarball, extracting it into ${baseDir}/app/${version}/.
6
+
7
+ import { mkdirSync, existsSync, rmSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { Readable } from 'node:stream';
11
+ import * as tar from 'tar';
12
+
13
+ const REPO = 'ucsandman/DashClaw';
14
+
15
+ /**
16
+ * Fetch the latest published platform version from the npm registry.
17
+ * The `dashclaw` npm package version mirrors the platform version (unified versioning).
18
+ *
19
+ * @param {typeof fetch} fetchImpl - injectable for tests
20
+ * @returns {Promise<string>} semver string e.g. '4.21.0'
21
+ */
22
+ export async function resolveAppVersion(fetchImpl = fetch) {
23
+ const res = await fetchImpl('https://registry.npmjs.org/dashclaw/latest');
24
+ if (!res.ok) {
25
+ throw new Error(`npm registry lookup failed (${res.status}) — check your network and retry.`);
26
+ }
27
+ const { version } = await res.json();
28
+ if (!version) throw new Error('npm registry returned no version for dashclaw.');
29
+ return version;
30
+ }
31
+
32
+ /**
33
+ * Build the GitHub codeload tarball URL for a given version tag.
34
+ *
35
+ * @param {string} version - semver string e.g. '4.21.0'
36
+ * @returns {string}
37
+ */
38
+ export function tarballUrl(version) {
39
+ return `https://codeload.github.com/${REPO}/tar.gz/refs/tags/v${version}`;
40
+ }
41
+
42
+ /**
43
+ * Download and extract the app tarball for `version` into `${baseDir}/app/${version}`.
44
+ * The GitHub tarball wraps everything in a `DashClaw-<version>/` folder —
45
+ * strip 1 level so the app root lands directly in the target dir.
46
+ * Skips cleanly if the target already exists (resume case).
47
+ *
48
+ * @param {object} opts
49
+ * @param {string} opts.version
50
+ * @param {string} opts.baseDir
51
+ * @param {typeof fetch} opts.fetchImpl
52
+ * @param {{ error: (...args: any[]) => void }} opts.logger
53
+ * @returns {Promise<string>} absolute path to the extracted app dir
54
+ */
55
+ export async function downloadAndExtract({ version, baseDir, fetchImpl = fetch, logger = console }) {
56
+ const target = join(baseDir, 'app', version);
57
+ if (existsSync(join(target, 'package.json'))) {
58
+ // Resume-skip: a pre-existing package.json means a prior successful extract.
59
+ logger.error(`[ok] App ${version} already present at ${target}`);
60
+ return target;
61
+ }
62
+ mkdirSync(target, { recursive: true });
63
+ const res = await fetchImpl(tarballUrl(version));
64
+ if (!res.ok || !res.body) {
65
+ rmSync(target, { recursive: true, force: true });
66
+ throw new Error(
67
+ `Download failed (${res.status}) for ${tarballUrl(version)} — does tag v${version} exist?`,
68
+ );
69
+ }
70
+ try {
71
+ await pipeline(Readable.fromWeb(res.body), tar.x({ cwd: target, strip: 1 }));
72
+ if (!existsSync(join(target, 'package.json'))) {
73
+ throw new Error('Extracted tarball did not contain package.json — aborting.');
74
+ }
75
+ } catch (e) {
76
+ rmSync(target, { recursive: true, force: true });
77
+ throw e;
78
+ }
79
+ // Invariant: a target dir with package.json present can only result from a
80
+ // successful prior extract because every failure path above removes the dir.
81
+ return target;
82
+ }