@davvidbaker/flambe-cli 0.1.0 → 0.1.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 ADDED
@@ -0,0 +1,63 @@
1
+ # @davvidbaker/flambe-cli
2
+
3
+ Zero-dependency Node 22 CLI that streams coding-agent work into a Flambe trace.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install -g @davvidbaker/flambe-cli
9
+ ```
10
+
11
+ That puts `flambe` on `PATH`. One-off:
12
+
13
+ ```sh
14
+ npx @davvidbaker/flambe-cli --help
15
+ ```
16
+
17
+ From this checkout (no npm):
18
+
19
+ ```sh
20
+ node cli/bin/flambe.mjs --help
21
+ # or
22
+ cd cli && npm link
23
+ ```
24
+
25
+ ## Cursor Cloud and other repos
26
+
27
+ 1. Install the CLI in the environment (or at the start of an agent session):
28
+
29
+ ```sh
30
+ npm install -g @davvidbaker/flambe-cli
31
+ ```
32
+
33
+ 2. Set Cloud secrets / env: `FLAMBE_URL`, `FLAMBE_API_TOKEN`, `FLAMBE_TRACE_ID`.
34
+ Optional: `FLAMBE_THREAD` (name, unique slug, or id) is sent on `start` when
35
+ `--thread` is omitted. Do not list threads and pick one by project name.
36
+ Copy the `flambe-cli` skill from this repo (`.codex/skills/flambe-cli`) so
37
+ agents know when to `start` / `end`.
38
+
39
+ 3. Set `FLAMBE_AGENT_PLATFORM` to the product (`Cursor Cloud`) as a Cloud
40
+ secret; it is shared by every agent there. Agents do not need a name: the
41
+ reducer assigns one on first contact and the CLI remembers it
42
+ (`flambe whoami`). Set `FLAMBE_AGENT_NAME` only to override. Do not put
43
+ `FLAMBE_AGENT_ID` or `FLAMBE_AGENT_NAME` in `.env`.
44
+
45
+ CI publishes this package from `cli/` via npm trusted publishing when
46
+ `cli/package.json` on `main` changes. Local `npm publish` needs access to
47
+ `@davvidbaker`.
48
+
49
+ ## Configure
50
+
51
+ Create a `.env` in the project working directory (or set the same names in the
52
+ shell; shell wins):
53
+
54
+ ```dotenv
55
+ FLAMBE_URL=https://your-app.fly.dev
56
+ FLAMBE_API_TOKEN=flb_...
57
+ FLAMBE_TRACE_ID=1
58
+ # FLAMBE_THREAD=flambe
59
+ ```
60
+
61
+ Mint a token in Flambe Settings → API tokens. See the
62
+ [repository README](https://github.com/davvidbaker/flambe#connect-coding-agents)
63
+ for commands (`start`, `end`, `message`, `observe`, local `serve`).
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "@davvidbaker/flambe-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "CLI for streaming coding-agent activity into Flambe",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "flambe": "./bin/flambe.mjs"
8
8
  },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
9
14
  "scripts": {
10
- "test": "node --test"
15
+ "test": "node --test",
16
+ "prepublishOnly": "npm test"
11
17
  },
12
18
  "engines": {
13
19
  "node": ">=22"
@@ -15,6 +21,14 @@
15
21
  "license": "MIT",
16
22
  "repository": {
17
23
  "type": "git",
18
- "url": "git+https://github.com/davvidbaker/flambe.git"
24
+ "url": "git+https://github.com/davvidbaker/flambe.git",
25
+ "directory": "cli"
26
+ },
27
+ "homepage": "https://github.com/davvidbaker/flambe/tree/main/cli",
28
+ "bugs": {
29
+ "url": "https://github.com/davvidbaker/flambe/issues"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
19
33
  }
20
34
  }
@@ -0,0 +1,35 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ // Names the reducer assigned to agents on this machine, keyed by agent id (ADR-012).
7
+ // Read synchronously because identity is resolved while the client is constructed.
8
+
9
+ export function defaultAgentNamesPath() {
10
+ return join(homedir(), '.flambe', 'agent-names.json');
11
+ }
12
+
13
+ export function readAgentNames(path = defaultAgentNamesPath()) {
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
16
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ export function rememberedAgentName(agentId, path = defaultAgentNamesPath()) {
23
+ if (!agentId) return undefined;
24
+ const name = readAgentNames(path)[agentId];
25
+ return typeof name === 'string' && name.trim() ? name : undefined;
26
+ }
27
+
28
+ export function rememberAgentName(agentId, name, path = defaultAgentNamesPath()) {
29
+ if (!agentId || !name) return;
30
+ const names = { ...readAgentNames(path), [agentId]: name };
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
33
+ writeFileSync(tmp, `${JSON.stringify(names, null, 2)}\n`);
34
+ renameSync(tmp, path);
35
+ }
package/src/cli.mjs CHANGED
@@ -1,8 +1,9 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join, resolve } from 'node:path';
4
4
 
5
5
  import { clientFromEnv, clientFromHostEnv } from './client.mjs';
6
+ import { clearQuestion, readOpenQuestion, rememberQuestion } from './questions.mjs';
6
7
 
7
8
  // `node:sqlite` still prints an ExperimentalWarning on import in some Node 22 releases.
8
9
  // Only serve/export need it, so load those modules on demand and keep every other
@@ -15,14 +16,16 @@ function defaultDbPath() {
15
16
  }
16
17
 
17
18
  const usage = `Usage:
18
- flambe start <activity name> [--description <text>] [--thread <id>] [--parent <id> | --root] [--category <id>...] [--started-at <ISO-8601>]
19
+ flambe start <activity name> [--description <text>] [--thread <id|name>] [--parent <id> | --root] [--category <id>...] [--started-at <ISO-8601>]
19
20
  flambe end <activity-id> [message] [--force]
20
21
  flambe suspend <activity-id> [message]
21
22
  flambe resume <activity-id> [message]
22
- flambe status [--active | --suspended] [--json]
23
+ flambe plan <activity name> [--start <ISO-8601>] [--end <ISO-8601>] [--description <text>] [--weight <n>]
24
+ flambe status [--active | --suspended] [--unstarted] [--json]
23
25
  flambe threads [--json]
24
26
  flambe categories [--json]
25
27
  flambe ping
28
+ flambe whoami [--json]
26
29
  flambe message <text> [--activity <id>] [--json]
27
30
  flambe observe <kind> <value> [--unit <unit>] [--on <YYYY-MM-DD>] [--payload <json>] [--at <ISO-8601>]
28
31
  flambe serve [--port <n>] [--db <path>] [--static <dir>]
@@ -41,9 +44,17 @@ Required values:
41
44
  Optional:
42
45
  FLAMBE_AGENT_ID Stable ID for this running agent; enables named flame lanes.
43
46
  Derived from Codex, Cursor, or Claude Code session env when unset.
44
- FLAMBE_AGENT_NAME Display name for this agent's lane. Do not put this in .env.
47
+ FLAMBE_AGENT_NAME Override the lane name. Unset, the reducer names the agent and the
48
+ CLI remembers it in ~/.flambe/agent-names.json. Do not put this in .env.
49
+ FLAMBE_AGENT_PLATFORM Product the agent runs on, shared by many agents ("Cursor Cloud",
50
+ "Codex"). Derived from the session type when unset.
51
+ FLAMBE_AGENT_NAMES_PATH Where remembered agent names live.
52
+ FLAMBE_THREAD Thread name, unique slug, or id for start when --thread is omitted
53
+ (overridden by --thread). 'flambe' matches 'flambé🔥'. The reducer
54
+ still keeps a child on its parent's thread.
45
55
  FLAMBE_QUEUE_PATH Local offline queue path (default: ~/.flambe/event-queue.json)
46
56
  FLAMBE_LOCAL_DB SQLite path for flambe serve / export (default: ~/.flambe/local.sqlite)
57
+ FLAMBE_RUNTIME_MODE local_self_contained restricts CLI requests to numeric loopback; cloud preserves hosted access
47
58
 
48
59
  serve / export do not need FLAMBE_URL. import needs FLAMBE_URL and FLAMBE_API_TOKEN (the production instance).`;
49
60
 
@@ -117,20 +128,55 @@ function parseEnd(args) {
117
128
  };
118
129
  }
119
130
 
131
+ function parsePlan(args) {
132
+ const nameParts = [];
133
+ let description;
134
+ let weight;
135
+ let start;
136
+ let end;
137
+ for (let index = 0; index < args.length; index += 1) {
138
+ const arg = args[index];
139
+ if (arg === '--description') {
140
+ description = args[index + 1];
141
+ if (description === undefined) throw new Error('--description requires a value');
142
+ index += 1;
143
+ } else if (arg === '--weight') {
144
+ weight = Number(args[index + 1]);
145
+ if (!Number.isInteger(weight)) throw new Error('--weight requires an integer');
146
+ index += 1;
147
+ } else if (arg === '--start' || arg === '--end') {
148
+ const raw = args[index + 1];
149
+ if (raw === undefined) throw new Error(`${arg} requires an ISO-8601 timestamp`);
150
+ const parsed = Date.parse(raw);
151
+ if (!Number.isFinite(parsed)) throw new Error(`${arg} must be a valid ISO-8601 timestamp`);
152
+ if (arg === '--start') start = parsed;
153
+ else end = parsed;
154
+ index += 1;
155
+ } else if (arg.startsWith('--')) {
156
+ throw new Error(`Unknown option: ${arg}`);
157
+ } else nameParts.push(arg);
158
+ }
159
+ const name = nameParts.join(' ').trim();
160
+ if (!name) throw new Error('Usage: flambe plan <activity name> [--start <ISO-8601>] [--end <ISO-8601>]');
161
+ return { name, description, weight, scheduledStart: start, scheduledEnd: end };
162
+ }
163
+
120
164
  function parseStatus(args) {
121
165
  let activeOnly = false;
122
166
  let suspendedOnly = false;
167
+ let includeUnstarted = false;
123
168
  let json = false;
124
169
 
125
170
  for (const arg of args) {
126
171
  if (arg === '--active') activeOnly = true;
127
172
  else if (arg === '--suspended') suspendedOnly = true;
173
+ else if (arg === '--unstarted') includeUnstarted = true;
128
174
  else if (arg === '--json') json = true;
129
175
  else throw new Error(`Unknown option: ${arg}`);
130
176
  }
131
177
 
132
178
  if (activeOnly && suspendedOnly) throw new Error('--active and --suspended cannot be used together');
133
- return { activeOnly, suspendedOnly, json };
179
+ return { activeOnly, suspendedOnly, includeUnstarted, json };
134
180
  }
135
181
 
136
182
  function parseObserve(args) {
@@ -267,6 +313,14 @@ function parseImport(args) {
267
313
  function formatAction(action) {
268
314
  if (action.type === 'create_child') return `created child ${action.activity_id} under ${action.parent_activity_id}`;
269
315
  if (action.type === 'update_activity') return `updated activity ${action.activity_id}`;
316
+ if (action.type === 'reparent') return `reparented ${action.activity_id} under ${action.parent_activity_id}`;
317
+ if (action.type === 'rename') return `renamed ${action.activity_id} to ${action.name}`;
318
+ if (action.type === 'ask') return `asked about ${action.activity_id}`;
319
+ if (action.type === 'resume_existing') return `resumed existing ${action.activity_id}`;
320
+ if (action.type === 'resume_ancestor') return `resumed ancestor ${action.activity_id}`;
321
+ if (action.type === 'no_op') return `left ${action.activity_id} as it was`;
322
+ if (action.type === 'keep') return 'kept the recorded structure';
323
+ if (action.type === 'skipped') return 'recorded the proposal; model review skipped';
270
324
  return JSON.stringify(action);
271
325
  }
272
326
 
@@ -275,10 +329,31 @@ function formatReducerNote(note) {
275
329
  const list = note.closedDescendants.map(item => `${item.activityId} (${item.activityName})`).join(', ');
276
330
  return `reducer closed open descendants of ${note.activityId}: ${list}\n`;
277
331
  }
332
+ if (note.type === 'agent_named') {
333
+ return `reducer named this agent "${note.name}"; the CLI will use it from now on\n`;
334
+ }
335
+ if (note.type === 'command_result') {
336
+ const lines = [];
337
+ if (note.direction) lines.push(`direction\t${note.direction}\n`);
338
+ if (note.reply) lines.push(`reply\t${note.reply}\n`);
339
+ if (note.actionsApplied?.length) {
340
+ lines.push(`actions\t${note.actionsApplied.map(formatAction).join('; ')}\n`);
341
+ }
342
+ return lines.join('');
343
+ }
344
+ if (note.type === 'start_reduced') {
345
+ const parts = [];
346
+ if (note.parentSource === 'inferred') parts.push(`nested ${note.activityId} under ${note.parentId}`);
347
+ if (note.threadSource === 'parent' && note.requestedThreadId !== undefined && note.requestedThreadId !== note.threadId) {
348
+ parts.push(`kept it on the parent's thread ${note.threadId}, not ${note.requestedThreadId}`);
349
+ }
350
+ if (note.categoriesSource === 'parent') parts.push('inherited the parent\'s categories');
351
+ return parts.length > 0 ? `reducer ${parts.join('; ')}\n` : '';
352
+ }
278
353
  return `reducer: ${JSON.stringify(note)}\n`;
279
354
  }
280
355
 
281
- export async function run(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, client } = {}) {
356
+ export async function run(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr, client, questionsPath } = {}) {
282
357
  const [command, ...args] = argv;
283
358
 
284
359
  if (!command || command === 'help' || command === '--help' || command === '-h') {
@@ -300,6 +375,9 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
300
375
 
301
376
  if (command === 'export') {
302
377
  const { file, dbPath } = parseExport(args, env);
378
+ if (!existsSync(dbPath)) {
379
+ throw new Error(`Local database does not exist: ${dbPath}`);
380
+ }
303
381
  const { LocalStore } = await loadStore();
304
382
  const store = new LocalStore(dbPath);
305
383
  try {
@@ -327,11 +405,32 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
327
405
  }
328
406
 
329
407
  // Reducer notes go to stderr so stdout stays a machine-friendly id.
330
- const onReducerNote = note => stderr.write(formatReducerNote(note));
408
+ const questionFile = questionsPath ?? (client ? undefined : env.FLAMBE_QUESTIONS_PATH);
409
+ const persistQuestions = Boolean(questionsPath) || !client;
410
+ const traceId = env.FLAMBE_TRACE_ID;
411
+ const pendingQuestion = !persistQuestions || command === 'message'
412
+ ? undefined
413
+ : readOpenQuestion(traceId, questionFile);
414
+ if (pendingQuestion) {
415
+ stderr.write(`reducer still waiting on activity ${pendingQuestion.activityId}: ${pendingQuestion.reply}\n`);
416
+ }
417
+ const onReducerNote = note => {
418
+ const text = formatReducerNote(note);
419
+ if (text) stderr.write(text);
420
+ if (persistQuestions && note.type === 'command_result' && note.asked) {
421
+ rememberQuestion(traceId, { activityId: note.activityId, reply: note.reply }, questionFile);
422
+ }
423
+ };
331
424
  const flambe = client ?? clientFromEnv(env, { onReducerNote });
332
425
  flambe.onReducerNote ??= onReducerNote;
333
426
  await flambe.flushQueue?.();
334
427
 
428
+ if (command === 'plan') {
429
+ const activityId = await flambe.plan(parsePlan(args));
430
+ stdout.write(`${activityId}\n`);
431
+ return;
432
+ }
433
+
335
434
  if (command === 'start') {
336
435
  const activityId = await flambe.start(parseStart(args));
337
436
  stdout.write(`${activityId}\n`);
@@ -355,14 +454,20 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
355
454
  }
356
455
 
357
456
  if (command === 'status') {
358
- const { activeOnly, suspendedOnly, json } = parseStatus(args);
359
- const status = await flambe.status({ activeOnly, suspendedOnly });
457
+ const { activeOnly, suspendedOnly, includeUnstarted, json } = parseStatus(args);
458
+ const status = await flambe.status({
459
+ activeOnly,
460
+ suspendedOnly,
461
+ ...(includeUnstarted ? { includeUnstarted } : {}),
462
+ });
360
463
 
361
464
  if (json) {
362
465
  stdout.write(`${JSON.stringify(status)}\n`);
363
466
  } else {
364
467
  for (const activity of status.activities) {
365
- stdout.write(`${activity.id}\t${activity.path.join(' > ')}\t${activity.threadId}\t${activity.threadName ?? ''}\t${activity.categoryIds.join(',')}\t${activity.latestEvent.phase}\t${activity.latestEvent.timestamp}\n`);
468
+ const phase = activity.latestEvent?.phase ?? activity.status;
469
+ const when = activity.latestEvent?.timestamp ?? '';
470
+ stdout.write(`${activity.id}\t${activity.path.join(' > ')}\t${activity.threadId}\t${activity.threadName ?? ''}\t${activity.categoryIds.join(',')}\t${phase}\t${when}\n`);
366
471
  }
367
472
  }
368
473
 
@@ -399,6 +504,14 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
399
504
  return;
400
505
  }
401
506
 
507
+ if (command === 'whoami') {
508
+ const json = parseJson(args);
509
+ const me = await flambe.whoami();
510
+ if (json) stdout.write(`${JSON.stringify(me)}\n`);
511
+ else stdout.write(`${me.agentId}\t${me.name}\t${me.platform ?? ''}\n`);
512
+ return;
513
+ }
514
+
402
515
  if (command === 'observe') {
403
516
  const observationId = await flambe.observe(parseObserve(args));
404
517
  stdout.write(`${observationId}\n`);
@@ -408,6 +521,7 @@ export async function run(argv, { env = process.env, stdout = process.stdout, st
408
521
  if (command === 'message') {
409
522
  const { text, activityId, json } = parseMessage(args);
410
523
  const decision = await flambe.message({ activityId, text });
524
+ if (persistQuestions) clearQuestion(traceId, questionFile);
411
525
  if (json) {
412
526
  stdout.write(`${JSON.stringify(decision)}\n`);
413
527
  } else {