cawdev-cli 0.9.0 → 1.0.0-beta

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.
@@ -30,8 +30,9 @@ import { spawn } from 'node:child_process';
30
30
  import { access, chmod, mkdir, writeFile } from 'node:fs/promises';
31
31
  import { createInterface } from 'node:readline/promises';
32
32
  import { homedir, hostname } from 'node:os';
33
- import { dirname, join, resolve } from 'node:path';
33
+ import { dirname, join } from 'node:path';
34
34
  import { painter } from '../lib/ansi.mjs';
35
+ import { absolute } from './paths.mjs';
35
36
  import { Select, pickFromLine, pickManyFromLine, plainLines } from './select.mjs';
36
37
  import { signInThroughBrowser, storedSession } from './sign-in.mjs';
37
38
  import { saveToken, tokenFile } from './token-store.mjs';
@@ -106,7 +107,9 @@ export function checkoutFor(root, slug) {
106
107
  // the root the person named. Everything else is already inside it, because
107
108
  // the separator is the character the line above removes.
108
109
  .replace(/^\.+$/, '-');
109
- return join(resolve(root), safe);
110
+ // `absolute` and not `resolve`: `~/code` through resolve alone was
111
+ // `<cwd>/~/code`, which is the bug R289 started from.
112
+ return join(absolute(root), safe);
110
113
  }
111
114
 
112
115
  async function exists(path) {
@@ -160,7 +163,11 @@ export async function mintRunnerToken(session, slugs, name) {
160
163
  if (!minted?.secret) {
161
164
  throw new Error('The platform minted a token but did not return it.');
162
165
  }
163
- return minted.secret;
166
+ // R283: the id travels alongside the secret from here on, so a later
167
+ // `cawdev config add-project` can widen THIS token's grants
168
+ // (`PUT /api/tokens/{id}/grants`) instead of minting a new one and leaving
169
+ // the old one live on the Tokens page for somebody to notice and revoke.
170
+ return { secret: minted.secret, id: minted.token?.id ?? null };
164
171
  }
165
172
 
166
173
  /**
@@ -185,19 +192,26 @@ export function cloneInto(gitUrl, path) {
185
192
  });
186
193
  }
187
194
 
188
- /** Whether `claude` is on the PATH at all — a cheaper question than "is it signed in". */
189
- export function findAgent(command = 'claude') {
190
- return new Promise((done) => {
191
- const child = spawn(process.platform === 'win32' ? 'where' : 'which', [command], {
192
- stdio: ['ignore', 'pipe', 'ignore'],
195
+ /** Whether the agent is on the PATH at all — a cheaper question than "is it signed in". */
196
+ export async function findAgents(commands = ['agy', 'claude']) {
197
+ const found = [];
198
+ for (const command of Array.isArray(commands) ? commands : [commands]) {
199
+ const where = await new Promise((done) => {
200
+ const child = spawn(process.platform === 'win32' ? 'where' : 'which', [command], {
201
+ stdio: ['ignore', 'pipe', 'ignore'],
202
+ });
203
+ let out = '';
204
+ child.stdout.on('data', (chunk) => {
205
+ out += chunk;
206
+ });
207
+ child.on('error', () => done(null));
208
+ child.on('close', (code) => done(code === 0 && out.trim() ? out.trim().split('\n')[0] : null));
193
209
  });
194
- let out = '';
195
- child.stdout.on('data', (chunk) => {
196
- out += chunk;
197
- });
198
- child.on('error', () => done(null));
199
- child.on('close', (code) => done(code === 0 && out.trim() ? out.trim().split('\n')[0] : null));
200
- });
210
+ if (where) {
211
+ found.push({ command, where });
212
+ }
213
+ }
214
+ return found;
201
215
  }
202
216
 
203
217
  /**
@@ -285,7 +299,7 @@ export async function setUpThisMachine({
285
299
  configPath = runnerConfigPath(),
286
300
  signIn = signInThroughBrowser,
287
301
  session: given = null,
288
- agent = findAgent,
302
+ agent = findAgents,
289
303
  } = {}) {
290
304
  const session = given ?? (await storedSession(url));
291
305
 
@@ -360,7 +374,7 @@ export async function setUpThisMachine({
360
374
  say(` ${ink.muted(`Skipping ${project.slug}.`)}`);
361
375
  continue;
362
376
  }
363
- entries.push({ slug: project.slug, path: resolve(typed) });
377
+ entries.push({ slug: project.slug, path: absolute(typed) });
364
378
  continue;
365
379
  }
366
380
  say('');
@@ -377,25 +391,46 @@ export async function setUpThisMachine({
377
391
  // R93's one question this cannot answer for itself. Asked BEFORE the token is
378
392
  // minted, so somebody who has to go and log in elsewhere has not left a
379
393
  // credential behind them.
380
- const where = await agent();
381
- say('');
382
- if (where) {
383
- say(` ${ink.muted('This machine spawns')} ${ink.text('claude')} ${ink.muted(`(${where}) for every run.`)}`);
384
- } else {
385
- say(` ${ink.warn('!')} ${ink.muted('No')} ${ink.text('claude')} ${ink.muted('on this PATH. Install Claude Code before a run can start.')}`);
394
+ const agentInfos = await agent();
395
+ if (agentInfos.length === 0) {
396
+ say(` ${ink.warn('!')} ${ink.muted('Neither Claude Code nor Antigravity CLI found on PATH. Install at least one before a run can start.')}`);
397
+ throw new Error('No supported coding agent found.');
386
398
  }
387
- say(` ${ink.muted('It cannot sign in for you: a runner whose Claude Code is logged out')}`);
388
- say(` ${ink.muted('boots fine and then fails every run.')}`);
389
- if (!await confirm(ask, 'Is Claude Code signed in on this machine?')) {
390
- throw new Error(
391
- 'Sign in first run `claude` once in a terminal and follow it — then run cawdev again.\n'
392
- + ' Nothing has been created, so there is nothing to undo.',
393
- );
399
+
400
+ const chosenAgents = agentInfos.length === 1
401
+ ? agentInfos
402
+ : (await askMany(ask, say, new Select({
403
+ title: 'Which coding agents do you want to use on this machine?',
404
+ rows: agentInfos.map((a) => ({
405
+ id: a.command,
406
+ label: a.command === 'agy' ? 'Antigravity CLI' : 'Claude Code',
407
+ hint: a.where,
408
+ })),
409
+ }))).map((row) => agentInfos.find((a) => a.command === row.id));
410
+
411
+ if (!chosenAgents.length) {
412
+ throw new Error('You must select at least one coding agent to run.');
413
+ }
414
+
415
+ say('');
416
+ for (const a of chosenAgents) {
417
+ const agentDisplayName = a.command === 'agy' ? 'Antigravity CLI' : 'Claude Code';
418
+ say(` ${ink.muted('This machine spawns')} ${ink.text(a.command)} ${ink.muted(`(${a.where}) for every run.`)}`);
419
+ if (!await confirm(ask, `Is ${agentDisplayName} signed in on this machine?`)) {
420
+ throw new Error(
421
+ `Sign in first — run \`${a.command}\` once in a terminal and follow it — then run cawdev again.\n`
422
+ + ' Nothing has been created, so there is nothing to undo.',
423
+ );
424
+ }
394
425
  }
395
426
 
396
427
  const name = defaultRunnerName();
397
- const token = await mintRunnerToken(session, entries.map((entry) => entry.slug), name);
398
- const config = configFor({ url, token, name, entries });
428
+ const minted = await mintRunnerToken(session, entries.map((entry) => entry.slug), name);
429
+ const config = configFor({ url, token: minted.secret, name, entries });
430
+ config.agentCommands = chosenAgents.map(a => a.command);
431
+ // R283: kept beside the secret so a later `cawdev config add-project` can
432
+ // widen this token in place rather than minting a second one.
433
+ config.tokenId = minted.id;
399
434
  await write(configPath, config);
400
435
 
401
436
  say('');
@@ -477,13 +512,13 @@ export async function mintForThisMachine({
477
512
  say(` ${ink.success('✓')} ${ink.muted('Signed in as')} ${ink.text(session.email)}`);
478
513
 
479
514
  const name = config.name ?? defaultRunnerName();
480
- const token = await mintRunnerToken(session, slugs, name);
515
+ const minted = await mintRunnerToken(session, slugs, name);
481
516
 
482
517
  // Filed under the name the DAEMON will look for, which is not always the door
483
518
  // a person came through — in development the console is on `:4200` and the
484
519
  // API it proxies to is on `:8091`, and both are this one cawdev. Storing it
485
520
  // under the sign-in URL puts a working credential somewhere nothing reads.
486
- await store(storeUrl, token, { name });
521
+ await store(storeUrl, minted.secret, { name, id: minted.id });
487
522
 
488
523
  say(` ${ink.success('✓')} ${ink.muted('Minted a')} ${ink.text('runner:operate')} `
489
524
  + `${ink.muted(`token for ${slugs.length} project${slugs.length === 1 ? '' : 's'}`)}`);
@@ -497,5 +532,5 @@ export async function mintForThisMachine({
497
532
  }
498
533
  say('');
499
534
 
500
- return { token, slugs, name };
535
+ return { token: minted.secret, tokenId: minted.id, slugs, name };
501
536
  }
package/runner/cawdev.mjs CHANGED
@@ -26,11 +26,18 @@ import { homedir } from 'node:os';
26
26
  import { dirname, join, resolve } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { attach, urlFrom, valueOf } from './attach.mjs';
29
- import { asker, mintForThisMachine, setUpThisMachine } from './bootstrap.mjs';
29
+ import { asker, askOne, mintForThisMachine, setUpThisMachine } from './bootstrap.mjs';
30
30
  import { liveSockets, probeSocket, socketPathFor } from './control.mjs';
31
+ import {
32
+ addProject, addWorkspace, readConfigFile, reloadIfRunning, setAcceptsRulesFromConsole,
33
+ setAgentEnabled,
34
+ } from './configure.mjs';
35
+ import { Select } from './select.mjs';
36
+ import { storedSession } from './sign-in.mjs';
31
37
  import { loadToken } from './token-store.mjs';
32
38
  import { painter } from '../lib/ansi.mjs';
33
39
  import { mark } from './brand.mjs';
40
+ import { cliVersion, isBelow } from '../lib/version.mjs';
34
41
 
35
42
  const HERE = dirname(new URL(import.meta.url).pathname);
36
43
  const RUNNER = join(HERE, 'runner.mjs');
@@ -46,14 +53,26 @@ const USAGE = `
46
53
  cawdev --no-start attach only; never launch a daemon
47
54
  cawdev --watch-only do not sign in; watch without being able to act
48
55
  cawdev --leave-running leave the daemon running when you quit
56
+ cawdev --version this CLI's version, and the platform's
49
57
  cawdev --help
50
58
 
59
+ cawdev config a menu: add a project, add a
60
+ workspace, agents, console rules
61
+ cawdev config add-project serve one more project you can write to
62
+ cawdev config add-workspace another checkout for a project you
63
+ already serve — R47's concurrency
64
+ cawdev config agent enable|disable <claude|agy>
65
+ cawdev config rules on|off accept permission rules from the console
66
+ cawdev config show this machine's config, token redacted
67
+
51
68
  On a machine with no config, cawdev sets one up: it signs you in through the
52
69
  browser, asks which projects this machine should run agents for, clones them,
53
- and mints its own runner token. No token is ever typed.
70
+ and mints its own runner token. No token is ever typed. \`cawdev config\` is
71
+ the same walk, one change at a time, on a machine already set up.
54
72
 
55
73
  Inside: enter prompts the session you are watching, / takes a command
56
- (/help lists them), L lists the runs, and q stops the machine and leaves.
74
+ (/help lists them), L lists the runs, c configures this machine (the same
75
+ menu as \`cawdev config\`, on screen), and q stops the machine and leaves.
57
76
  It asks twice while sessions are running. --leave-running keeps it up.
58
77
  `;
59
78
 
@@ -280,22 +299,10 @@ export function daemonUrl(file, env = process.env) {
280
299
  return String(env.CAWDEV_URL ?? file?.url ?? DEFAULT_URL).replace(/\/+$/, '');
281
300
  }
282
301
 
283
- /**
284
- * The config as an object, or null if it will not parse.
285
- *
286
- * Null rather than a throw: an unreadable config is the daemon's complaint to
287
- * make — it names the file and the parse error — and swallowing it here to
288
- * offer a setup walk would replace a precise message with a wrong guess about
289
- * what somebody wants.
290
- */
291
- export async function readConfigFile(path) {
292
- try {
293
- const parsed = JSON.parse(await readFile(path, 'utf8'));
294
- return parsed && typeof parsed === 'object' ? parsed : null;
295
- } catch {
296
- return null;
297
- }
298
- }
302
+ // `readConfigFile` lives in configure.mjs since R288 — the attached UI reads
303
+ // the file too, and this module imports attach.mjs. Re-exported so nothing
304
+ // that imported it from here has to move.
305
+ export { readConfigFile };
299
306
 
300
307
  /**
301
308
  * The setup walk, with the terminal handed to it and taken back.
@@ -337,6 +344,181 @@ async function runMint(configPath, file, argv, ink) {
337
344
  });
338
345
  }
339
346
 
347
+ /**
348
+ * This CLI's version against the platform's — R283.
349
+ *
350
+ * Best-effort and silent on the happy path, matching `acceptsConsoleRules`'
351
+ * own philosophy (R126's comment on the daemon side): a mismatch is worth a
352
+ * line, agreement is worth nothing. Never thrown from here — a network hiccup
353
+ * is not evidence of a stale CLI, and `cawdev` has an attach to get on with.
354
+ */
355
+ async function warnIfVersionMismatched(url, ink) {
356
+ try {
357
+ const mine = await cliVersion();
358
+ const response = await fetch(`${url}/api/health`);
359
+ if (!response.ok) return;
360
+ const health = await response.json();
361
+ if (health.version && health.version !== mine && health.version !== 'unknown') {
362
+ console.log(` ${ink.warn('!')} ${ink.muted(`This CLI is ${mine}; ${url} is on ${health.version}.`)}`);
363
+ console.log(` ${ink.muted('Update this machine\'s cawdev if things look wrong.')}`);
364
+ }
365
+ if (isBelow(mine, health.minimumCliVersion)) {
366
+ console.log(` ${ink.danger('!')} ${ink.text(`This CLI (${mine}) is below what ${url} requires `
367
+ + `(${health.minimumCliVersion}) — the daemon will refuse to start until it is updated.`)}`);
368
+ }
369
+ } catch {
370
+ // Best-effort. readConfig's own health check, before registering, is
371
+ // where a genuinely unreachable platform actually matters.
372
+ }
373
+ }
374
+
375
+ /**
376
+ * `cawdev --version`: this CLI's own version, and the platform's.
377
+ *
378
+ * Works with no config and no sign-in — `/api/health` needs neither — so it
379
+ * answers on a machine that has not been set up at all yet.
380
+ */
381
+ async function runVersion(argv, ink) {
382
+ const mine = await cliVersion();
383
+ console.log(` ${ink.muted('cawdev')} ${ink.text(mine)}`);
384
+ const url = urlFrom(argv);
385
+ try {
386
+ const response = await fetch(`${url}/api/health`);
387
+ const health = response.ok ? await response.json() : null;
388
+ if (health) {
389
+ console.log(` ${ink.muted(url)} ${ink.text(health.version)}`);
390
+ if (isBelow(mine, health.minimumCliVersion)) {
391
+ console.log(` ${ink.danger('This CLI is below the minimum that platform requires')} `
392
+ + `(${health.minimumCliVersion}). Update it.`);
393
+ } else if (health.version !== mine && health.version !== 'unknown') {
394
+ console.log(` ${ink.muted('These differ — update whichever is behind.')}`);
395
+ }
396
+ } else {
397
+ console.log(` ${ink.muted(url)} ${ink.warn('did not answer')}`);
398
+ }
399
+ } catch (failure) {
400
+ console.log(` ${ink.muted(url)} ${ink.warn(`unreachable (${failure.message})`)}`);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * `cawdev config` — R283's granular half of the setup walk, on a machine
406
+ * already configured. A direct subcommand (`add-project`, `add-workspace`,
407
+ * `agent enable|disable <command>`, `rules on|off`, `show`) or, with none, a
408
+ * menu — the same `Select` widget everything that offers a choice uses.
409
+ */
410
+ async function runConfig(args, ink) {
411
+ const configPath = await findConfig(args);
412
+ if (!configPath) {
413
+ throw new Error('No config on this machine yet. Run `cawdev --setup` first.');
414
+ }
415
+ const file = await readConfigFile(configPath);
416
+ if (!file) {
417
+ throw new Error(`Could not read ${configPath}.`);
418
+ }
419
+
420
+ if (args[0] === 'show') {
421
+ printConfig(file, ink, (line) => console.log(line));
422
+ return;
423
+ }
424
+
425
+ const url = daemonUrl(file);
426
+ const session = await storedSession(url);
427
+ if (!session.signedIn) {
428
+ throw new Error('Not signed in on this machine. Run `cawdev --setup` first.');
429
+ }
430
+
431
+ const ask = asker();
432
+ const say = (line) => console.log(line);
433
+ try {
434
+ const action = args.length ? args : await pickAction(ask, say);
435
+ await runAction(action, { configPath, file, url, session, ask, say, ink });
436
+ } finally {
437
+ ask.close();
438
+ }
439
+ }
440
+
441
+ export async function pickAction(ask, say, pick = askOne) {
442
+ const picked = await pick(ask, say, new Select({
443
+ title: 'What do you want to change on this machine?',
444
+ rows: [
445
+ { id: 'add-project', label: 'Add a project' },
446
+ { id: 'add-workspace', label: 'Add a workspace to a project' },
447
+ { id: 'agent-enable', label: 'Enable an agent (claude / agy)' },
448
+ { id: 'agent-disable', label: 'Disable an agent' },
449
+ { id: 'rules-on', label: 'Accept permission rules from the console' },
450
+ { id: 'rules-off', label: 'Stop accepting rules from the console' },
451
+ { id: 'show', label: 'Show this machine\'s config' },
452
+ ],
453
+ }));
454
+ const id = picked.row.id;
455
+ if (id === 'agent-enable' || id === 'agent-disable') {
456
+ const command = (await ask.line(' Which agent — claude or agy? ')).trim();
457
+ return ['agent', id === 'agent-enable' ? 'enable' : 'disable', command];
458
+ }
459
+ if (id === 'rules-on') return ['rules', 'on'];
460
+ if (id === 'rules-off') return ['rules', 'off'];
461
+ return [id];
462
+ }
463
+
464
+ export async function runAction([verb, ...rest], { configPath, file, url, session, ask, say, ink }) {
465
+ if (verb === 'show') {
466
+ printConfig(file, ink, say);
467
+ return;
468
+ }
469
+ if (verb === 'add-project') {
470
+ await addProject({ configPath, file, url, session, ask, say, ink });
471
+ await afterProjectsChanged(file, say, ink);
472
+ return;
473
+ }
474
+ if (verb === 'add-workspace') {
475
+ await addWorkspace({ configPath, file, url, session, ask, say, ink });
476
+ await afterProjectsChanged(file, say, ink);
477
+ return;
478
+ }
479
+ if (verb === 'agent') {
480
+ const [sub, command] = rest;
481
+ if (!['claude', 'agy'].includes(command)) {
482
+ throw new Error('Name an agent: cawdev config agent enable|disable claude|agy');
483
+ }
484
+ await setAgentEnabled({
485
+ file, configPath, ask, say, ink, command, enabled: sub === 'enable',
486
+ });
487
+ await reloadIfRunning(file.name, say, ink);
488
+ return;
489
+ }
490
+ if (verb === 'rules') {
491
+ const [sub] = rest;
492
+ await setAcceptsRulesFromConsole({ file, configPath, ask, say, ink, enabled: sub === 'on' });
493
+ await reloadIfRunning(file.name, say, ink);
494
+ return;
495
+ }
496
+ throw new Error(`Not a cawdev config command: ${verb}. See cawdev --help.`);
497
+ }
498
+
499
+ /**
500
+ * Adding a project or a workspace is NOT hot-swappable — `config.projects` is
501
+ * read once at boot and threaded everywhere by value, unlike the three
502
+ * settings runner.mjs's SIGHUP handler re-reads. So this only says why
503
+ * nothing happened yet, on the daemon this config would actually restart as.
504
+ */
505
+ export async function afterProjectsChanged(file, say, ink, { probe = probeSocket } = {}) {
506
+ if (await probe(socketPathFor(file.name))) {
507
+ say(` ${ink.muted('A runner is already running here, on the config it booted with.')}`);
508
+ say(` ${ink.muted('Attach and quit to stop it, and the next cawdev starts one on what you just set up.')}`);
509
+ }
510
+ }
511
+
512
+ /** The config as somebody would want to read it back — never the secret. */
513
+ export function printConfig(file, ink, say) {
514
+ const shown = { ...file };
515
+ if (shown.token) shown.token = `${String(shown.token).slice(0, 10)}…`;
516
+ say(JSON.stringify(shown, null, 2)
517
+ .split('\n')
518
+ .map((line) => ` ${ink.text(line)}`)
519
+ .join('\n'));
520
+ }
521
+
340
522
  async function main() {
341
523
  const argv = process.argv.slice(2);
342
524
  const ink = painter();
@@ -346,6 +528,16 @@ async function main() {
346
528
  return;
347
529
  }
348
530
 
531
+ if (argv[0] === 'config') {
532
+ await runConfig(argv.slice(1), ink);
533
+ return;
534
+ }
535
+
536
+ if (argv.includes('--version')) {
537
+ await runVersion(argv, ink);
538
+ return;
539
+ }
540
+
349
541
  // The mark, once, before anything else happens. It is the only decoration in
350
542
  // the program and it is here because this is the moment somebody is waiting:
351
543
  // a browser about to open, or a daemon about to boot.
@@ -353,6 +545,10 @@ async function main() {
353
545
  console.log(`\n${line}\n`);
354
546
  }
355
547
 
548
+ // R283. Best-effort, silent when the two agree — see the function's own
549
+ // comment for why this never throws.
550
+ await warnIfVersionMismatched(urlFrom(argv), ink);
551
+
356
552
  // Asked for by name, the walk runs even where one has been done before —
357
553
  // that is what "again" means, and adding a project to this machine is the
358
554
  // ordinary reason. A daemon already running keeps the config it booted with,