@volter/world 2.0.0 → 2.0.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/dist/src/cli.js CHANGED
@@ -3,25 +3,49 @@
3
3
  // verb. `volter world …` acts on the world of the cwd the way git acts on the repository of the
4
4
  // cwd; `volter twin <vendor> …` runs one twin on its own; `volter remote …` is the hosted remote.
5
5
  // One client of `World`; it adds nothing the SDK does not have.
6
- import { formatInitReport, resolveCatalog, findInstalledPackage, packCli, serveConsoleFor } from '@volter/world-runtime';
6
+ import { detectRepoVendors, formatInitReport, resolveCatalog, findInstalledPackage, packCli, serveConsoleFor } from '@volter/world-runtime';
7
7
  import { readFileSync, realpathSync } from 'node:fs';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { spawn } from 'node:child_process';
9
+ import { spawn, spawnSync } from 'node:child_process';
10
+ import { createHash } from 'node:crypto';
11
+ import { hostname } from 'node:os';
10
12
  import { existsSync } from 'node:fs';
11
- import { relative, resolve } from 'node:path';
12
- import { platformOrigin, storePlatform, storeToken, tokenFor } from "./credentials.js";
13
+ import { join, relative, resolve } from 'node:path';
14
+ import { forgetPlatform, forgetToken, platformOrigin, storePlatform, storeToken, tokenFor } from "./credentials.js";
13
15
  import { parseOriginUrl, World } from "./world.js";
14
16
  import { waitForServeShutdown } from "./serve-shutdown.js";
17
+ import { startUpdateCheck } from "./update-notice.js";
18
+ /** Open a URL in the person's browser; a machine without one only loses the convenience. */
19
+ function openInBrowser(url) {
20
+ // an http(s) URL only, and on Windows through the URL handler rather than cmd.exe, which would read `"`, `&` or `%VAR%`
21
+ // in it as commands
22
+ let target;
23
+ try {
24
+ const u = new URL(url);
25
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
26
+ return;
27
+ target = u.href;
28
+ }
29
+ catch {
30
+ return;
31
+ }
32
+ const [cmd, ...pre] = process.platform === 'darwin' ? ['open'] : process.platform === 'win32' ? ['rundll32', 'url.dll,FileProtocolHandler'] : ['xdg-open'];
33
+ try {
34
+ spawn(cmd, [...pre, target], { windowsHide: true, stdio: 'ignore', detached: true }).on('error', () => undefined).unref();
35
+ }
36
+ catch { /* no browser here */ }
37
+ }
15
38
  export const HELP = `volter — run your app against a world of twins
16
39
 
17
40
  world — the twins your app needs, running together (the world of the current directory)
18
- volter world init [--name <world>] [--allow-unknown] detect the app's vendors, write .volter/world.json
41
+ volter world init [--install] [--name <world>] [--allow-unknown] detect the app's vendors (installing their twins when none are), write .volter/world.json
19
42
  volter world up [--sandbox] [--no-seed] start the twins on this branch (loads the default data the first time)
20
43
  volter world run [--verbose] -- <command...> run the app or its tests inside the world
21
44
  volter world activate eval "$(volter world activate)": vendor CLIs and curl in this shell reach the twins
22
45
  volter world shell a subshell with the world active
23
46
  volter world init --bare <org>/<world> --twins a,b a world with no app: a shared world for a team, or one standing in for a vendor
24
- volter world serve [--port <p>] [--console-port <c>] serve this world on a URL under /<org>/<world>/; prints the token and the console (its own origin, port p+1 by default)
47
+ volter world serve [--port <p>] [--console-port <c>] serve this world on a URL under /<org>/<world>/ for apps; prints the token and the console (on loopback: view's front, no token in the browser)
48
+ volter world view [--port <p>] [--host <h>] [--no-open] [--no-origins] step into this world in a browser: its twins' own UIs, timeline, clock, branches (a non-loopback --host serves them on one origin)
25
49
  volter world console [<https://host/org/world>] [--port <p>] the console on this machine for a World served elsewhere (default: this world's origin); needs @volter/world-console
26
50
  volter world status world, branch, origin, unpushed changes, what is running
27
51
  volter world log [--receipts] [--json] every write the app made; --receipts adds each change's receipt
@@ -32,7 +56,7 @@ world — the twins your app needs, running together (the world of the current
32
56
  volter world branch [<name>] list branches, or make one from here and check it out
33
57
  volter world checkout <name> switch branches
34
58
  volter world replay <changeset> --into <branch> feed a changeset's changes into another branch's twins
35
- volter world clock show | set <iso> | advance <N s|m|h|d> the world's clock: set, not observed
59
+ volter world clock show | set <iso> | advance <N s|m|h|d> | shift <N s|m|h|d> | clear the world's clock: set, not observed
36
60
  volter world clone <url> --token <token> [--admin-token <t>] connect this world to a remote and fetch its history (the admin token copies the whole tree at once)
37
61
  volter world fetch what the remote observed since the last fetch
38
62
  volter world origin where this world clones from and pushes to
@@ -53,9 +77,17 @@ twin — one twin: in this world, or on its own
53
77
  volter twin <vendor> mirror [--port <p>] the twin's UI mirror
54
78
  volter twin <vendor> conformance check the twin against the vendor's spec
55
79
 
80
+ agents — your coding agent, driving the same verbs
81
+ volter agents install [--claude] [--cursor] [--vscode] [--codex] [--yes] put the volter-world skill and the MCP server into the agents found here
82
+ volter mcp the MCP server (stdio) an agent starts: the verbs above as tools
83
+
56
84
  remote — the worlds this one pushes to and fetches from, by name
57
- volter login <platform url> --token <personal token> sign the CLI into the hosted platform (a personal token from the console)
58
- volter remote add <name> <url|path|org/world> [--token <token>] name a remote; org/world resolves through the platform you logged into
85
+ volter login <platform url> [--token <personal token>] sign the CLI into the hosted platform: approve it in the browser (or a token made under Account, for CI)
86
+ volter whoami the platform the CLI is signed into, as whom, and its orgs
87
+ volter logout revoke the CLI's token at the platform and forget it here
88
+ volter remote add <name> <url|path|org/world> [--token <token>] [--create] name a remote; org/world (or world, in one org) resolves through the platform you logged into; --create makes it there from this world's twins
89
+ volter open [<org>/<world>] [--no-open] a World's dashboard: a hosted one through the platform, else this world's (as view)
90
+ volter completion bash|zsh|fish|powershell print a completion script for your shell
59
91
  volter remote remove <name> forget it
60
92
  volter remote list every remote, with its URL and whether a token is stored
61
93
  (the hosting product's serve answers here for one release: volter remote serve)
@@ -96,7 +128,7 @@ function json(value) { out(JSON.stringify(value, null, 2)); }
96
128
  function when(iso) { return iso.slice(11, 23); }
97
129
  /** Run another CLI in the foreground and adopt its exit code; a signal to us is a signal to it. */
98
130
  async function passthrough(cmd, cwd = process.cwd()) {
99
- const child = spawn(cmd[0], cmd.slice(1), { cwd, stdio: 'inherit' });
131
+ const child = spawn(cmd[0], cmd.slice(1), { windowsHide: true, cwd, stdio: 'inherit' });
100
132
  const forward = (signal) => () => { try {
101
133
  child.kill(signal);
102
134
  }
@@ -134,6 +166,7 @@ async function worldCommand(verb, args) {
134
166
  }
135
167
  if (verb === 'init') {
136
168
  const app = resolve(worldDir ?? process.cwd());
169
+ await installDetectedTwins(app, flag(args, '--install'));
137
170
  const { result } = World.init(app, { ...(value(args, '--name') ? { name: value(args, '--name') } : {}), force: flag(args, '--force'), allowUnknown: flag(args, '--allow-unknown') });
138
171
  if (asJson)
139
172
  json({ ok: result.ok, plan: result.plan, coverage: result.coverage, next: result.next });
@@ -141,6 +174,8 @@ async function worldCommand(verb, args) {
141
174
  const twins = result.plan.vendors.filter((v) => v.service !== null).map((v) => v.vendor).sort();
142
175
  out(`twins ${twins.join(', ') || '(none)'}\nwrote ${relative(process.cwd(), result.plan.configPath) || result.plan.configPath}`);
143
176
  out(formatInitReport(result));
177
+ if (result.ok)
178
+ await offerAgents();
144
179
  }
145
180
  if (!result.ok)
146
181
  process.exitCode = 1;
@@ -313,12 +348,22 @@ async function worldCommand(verb, args) {
313
348
  out(world.advanceClock(arg));
314
349
  return;
315
350
  }
351
+ if (action === 'shift') {
352
+ if (!arg)
353
+ throw new Error('volter world clock shift <N s|m|h|d>');
354
+ out(world.shiftClock(arg));
355
+ return;
356
+ }
357
+ if (action === 'clear') {
358
+ out(world.clearClock());
359
+ return;
360
+ }
316
361
  if (action === undefined || action === 'show') {
317
362
  const c = world.clock();
318
- out(`${c.at}${c.frozen ? '' : ' (wall clock — not set)'}`);
363
+ out(`${c.at}${c.frozen ? '' : c.running ? ' (running)' : ' (wall clock — not set)'}`);
319
364
  return;
320
365
  }
321
- throw new Error('volter world clock show | set <iso> | advance <N s|m|h|d>');
366
+ throw new Error('volter world clock show | set <iso> | advance <N s|m|h|d> | shift <N s|m|h|d> | clear');
322
367
  }
323
368
  case 'replay': {
324
369
  const name = pos[0];
@@ -388,10 +433,58 @@ async function worldCommand(verb, args) {
388
433
  out(`changeset ${cs.name} ${cs.actions.length} change${cs.actions.length === 1 ? '' : 's'}\n ${message}${cs.narration ? `\n${cs.narration.split('\n').map((l) => ` ${l}`).join('\n')}` : ''}`);
389
434
  return;
390
435
  }
436
+ case 'view':
391
437
  case 'serve': {
392
- const served = await world.serve({ ...(value(args, '--port') ? { port: Number(value(args, '--port')) } : {}), ...(value(args, '--console-port') ? { consolePort: Number(value(args, '--console-port')) } : {}), ...(value(args, '--host') ? { host: value(args, '--host') } : {}), announce: (info) => out(`serving ${info.name} ${info.base}\ntoken ${info.token}\nread ${info.readToken}${info.console ? `\nconsole ${info.console}` : ''}`) });
393
- await waitForServeShutdown(() => served.stop());
394
- process.exit(0); // success follows confirmed shutdown; failures reach main's error path
438
+ // `view` steps into the World in a browser; `serve` serves it for apps and scripts. Bound to this machine's
439
+ // loopback both are the same local front (the World's own origin, its console beside it) and the browser never
440
+ // needs a token (served-world.ts localSession); `serve` only leaves the browser closed. A non-loopback --host,
441
+ // or `serve --console-port`, is a World served for others: its console asks for a token.
442
+ const host = value(args, '--host');
443
+ const local = host === undefined || host === '127.0.0.1' || host === 'localhost' || host === '::1';
444
+ if (verb === 'serve' && (!local || value(args, '--console-port') !== undefined)) {
445
+ const served = await world.serve({ ...(value(args, '--port') ? { port: Number(value(args, '--port')) } : {}), ...(value(args, '--console-port') ? { consolePort: Number(value(args, '--console-port')) } : {}), ...(value(args, '--host') ? { host: value(args, '--host') } : {}), announce: (info) => out(`serving ${info.name} ${info.base}\ntoken ${info.token}\nread ${info.readToken}${info.console ? `\nconsole ${info.console}` : ''}`) });
446
+ await waitForServeShutdown(() => served.stop());
447
+ process.exit(0); // success follows confirmed shutdown; failures reach main's error path
448
+ return;
449
+ }
450
+ // the console mounts when it is installed beside the CLI; without it the World serves its doors and mirrors
451
+ const console = await (async () => {
452
+ try {
453
+ const mod = await import('@volter/world-console');
454
+ return mod.createConsole();
455
+ }
456
+ catch (error) {
457
+ // not installed is one thing; installed and broken is another, and says why
458
+ const missing = /ERR_MODULE_NOT_FOUND|Cannot find (package|module)/.test(`${error.code ?? ''} ${String(error)}`);
459
+ if (!missing)
460
+ process.stderr.write(`the console failed to load (the World is served without it): ${error instanceof Error ? error.message : String(error)}\n`);
461
+ return undefined;
462
+ }
463
+ })();
464
+ // `serve` says what it always said first (`serving <name> <url>`, then the tokens: scripts and the guides read
465
+ // those lines), and the local front's own lines after them; `view` says `viewing` as it goes
466
+ const later = [];
467
+ const view = await world.view({ ...(value(args, '--port') ? { port: Number(value(args, '--port')) } : {}), ...(host ? { host } : {}), ...(console ? { console } : {}), ...(args.includes('--no-origins') ? { origins: false } : {}), announce: (line) => { if (verb !== 'serve')
468
+ out(line);
469
+ else if (!line.startsWith('viewing'))
470
+ later.push(line); } });
471
+ if (verb === 'serve') {
472
+ out(`serving ${view.served} ${view.base}\ntoken ${view.token}\nread ${view.readToken}`);
473
+ for (const line of later)
474
+ out(line);
475
+ }
476
+ // a local World's page opens itself; a World served for others is handed its token in the URL's fragment
477
+ // (which the browser never sends), taken once
478
+ // (with --no-origins every World shares one origin, where no page is handed a session: the token again)
479
+ const open = view.console ? (local && view.origin ? view.console : `${view.console}#token=${encodeURIComponent(view.token)}`) : null;
480
+ // the read token's own one-line way in, for handing to someone who should look and not touch
481
+ // (not on a local World: any of its pages can open a write session there, so a read-only link restricts nothing)
482
+ const readOnly = view.console && !(local && view.origin) ? `${view.console}#token=${encodeURIComponent(view.readToken)}` : null;
483
+ out(`${local && view.origin ? `apps the World's tokens are in .volter/token and .volter/token.read (apps and scripts present them; the browser needs none)` : verb === 'serve' ? '' : `token ${view.token}\nread ${view.readToken}`}${readOnly ? `\nread-only view ${readOnly}` : ''}${view.console ? '' : '\n(no console installed: `npm install -D @volter/world-console` to look around in a browser)'}${view.origin ? '\n(the World opens at its own *.localhost origin; if your browser cannot reach *.localhost, run again with --no-origins)' : ''}`);
484
+ if (open && verb === 'view' && !args.includes('--no-open'))
485
+ openInBrowser(open);
486
+ await waitForServeShutdown(() => view.stop());
487
+ process.exit(0);
395
488
  return;
396
489
  }
397
490
  case 'rebase': {
@@ -556,21 +649,130 @@ function remoteCli() {
556
649
  return here;
557
650
  throw new Error('volter remote: the hosting product (@volter/world-host) is not installed — `bun add -d @volter/world-host`');
558
651
  }
559
- /** `volter login <platform url> --token <personal token>`: the CLI signed into the hosted platform as you — the
560
- * token is a personal access token from the console (shown once there), kept in your config directory. */
652
+ /** `volter login <platform url>`: the CLI signed into the hosted platform as you, through the browser (the device
653
+ * authorization grant, as `gh auth login`): the platform shows a code, you approve it there signed in as yourself,
654
+ * and the command collects a personal token named for this machine. `--token <personal token>` (one made in the
655
+ * platform under Account) signs in without a browser, for CI. Either way it is kept in your config directory. */
561
656
  async function loginCommand(args) {
562
657
  const url = positionals(args, VALUE_FLAGS)[0];
563
- const token = value(args, '--token');
564
- if (!url || !token)
565
- throw new Error('volter login <platform url> --token <personal token> (make one in the console under "You")');
566
- const origin = url.replace(/\/+$/, '');
658
+ if (!url)
659
+ throw new Error('volter login <platform url> [--token <personal token>] [--no-open]');
660
+ let origin;
661
+ try {
662
+ const u = new URL(url);
663
+ if (u.protocol !== 'https:' && u.protocol !== 'http:')
664
+ throw new Error();
665
+ origin = u.origin;
666
+ }
667
+ catch {
668
+ throw new Error(`${url} is not a platform address (https://…)`);
669
+ }
670
+ const token = value(args, '--token') ?? await deviceSignIn(origin, !flag(args, '--no-open'));
567
671
  const res = await fetch(`${origin}/-/orgs`, { headers: { authorization: `Bearer ${token}` } });
568
672
  const body = (await res.json().catch(() => ({})));
569
673
  if (!res.ok)
570
674
  throw new Error(`${origin} answered ${res.status}: ${body.error ?? 'not signed in'}`);
571
675
  storeToken(origin, token);
572
676
  storePlatform(origin);
573
- out(`signed in ${origin} as ${body.person?.email ?? body.person?.id ?? 'you'}\norgs ${(body.orgs ?? []).map((o) => o.slug ?? o.name).join(', ') || '(none yet)'}\nnext volter remote add origin <org>/<world>`);
677
+ out(`signed in ${origin} as ${body.person?.email ?? body.person?.id ?? 'you'}\norgs ${(body.orgs ?? []).map((o) => o.slug ?? o.name).join(', ') || '(none yet)'}\nnext in your app's folder: volter world init, then volter remote add origin ${(body.orgs ?? [])[0]?.slug ?? '<org>'}/<world> --create`);
678
+ }
679
+ /** `volter whoami`: which platform the command is signed into, as whom, and the orgs it reaches. */
680
+ async function whoamiCommand() {
681
+ const origin = platformOrigin();
682
+ const token = origin ? tokenFor(origin) : undefined;
683
+ if (!origin || !token) {
684
+ out('not signed in (volter login <platform url>)');
685
+ process.exitCode = 1;
686
+ return;
687
+ }
688
+ const res = await fetch(`${origin}/-/orgs`, { headers: { authorization: `Bearer ${token}` } }).catch(() => null);
689
+ if (!res)
690
+ throw new Error(`${origin} did not answer`);
691
+ const body = (await res.json().catch(() => ({})));
692
+ // a token the platform no longer takes is signed out; one it takes but that may not read orgs is still signed in
693
+ if (res.status === 403) {
694
+ out(`signed in ${origin} (this token may not read your orgs: ${body.error ?? 'refused'})`);
695
+ return;
696
+ }
697
+ if (!res.ok) {
698
+ out(`signed out ${origin}: ${body.error ?? res.status} — volter login ${origin}`);
699
+ process.exitCode = 1;
700
+ return;
701
+ }
702
+ out(`signed in ${origin} as ${body.person?.email ?? body.person?.id ?? 'you'}\norgs ${(body.orgs ?? []).map((o) => `${o.slug ?? o.name}${o.role === 'org:admin' ? ' (admin)' : ''}`).join(', ') || '(none yet)'}`);
703
+ }
704
+ /** `volter logout`: the command's token is revoked at the platform (a token may always revoke itself) and forgotten here. */
705
+ async function logoutCommand() {
706
+ const origin = platformOrigin();
707
+ const token = origin ? tokenFor(origin) : undefined;
708
+ if (!origin || !token) {
709
+ out('not signed in');
710
+ return;
711
+ }
712
+ const id = createHash('sha256').update(token).digest('hex');
713
+ const res = await fetch(`${origin}/-/tokens/${id}`, { method: 'DELETE', headers: { authorization: `Bearer ${token}` } }).catch(() => null);
714
+ forgetToken(origin);
715
+ forgetPlatform();
716
+ out(res?.ok ? `signed out ${origin} (its token is revoked)` : `signed out ${origin} (forgotten here; the platform did not revoke the token${res ? ` — ${res.status}` : ''}, so revoke it under Account)`);
717
+ }
718
+ /** The browser half of `volter login`: ask the platform for a code, send the person to approve it, and wait (at the
719
+ * platform's pace) for the personal token it hands over once they do. */
720
+ async function deviceSignIn(origin, openBrowser) {
721
+ const started = await fetch(`${origin}/-/cli/device`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: hostname() }) }).catch((e) => { throw new Error(`${origin} did not answer: ${e instanceof Error ? e.message : String(e)}`); });
722
+ const grant = (await started.json().catch(() => ({})));
723
+ if (!started.ok || !grant.device_code || !grant.user_code || !/^[A-Z]{4}-[A-Z]{4}$/.test(grant.user_code))
724
+ throw new Error(`${origin} cannot sign the command in through the browser (${started.status}${grant.error ? `: ${grant.error}` : ''}) — make a token on the platform under Account and use --token`);
725
+ // the page is this platform's own, built here from the address you gave and the code: nothing the platform answers is
726
+ // opened as it stands
727
+ const page = `${origin}/-/cli/approve?code=${grant.user_code}`;
728
+ process.stderr.write(`approve ${page}\ncode ${grant.user_code} (check the page shows the same)\n`);
729
+ if (openBrowser)
730
+ openInBrowser(page);
731
+ const until = Date.now() + (grant.expires_in ?? 600) * 1000;
732
+ const every = Math.max(1, grant.interval ?? 5) * 1000;
733
+ while (Date.now() < until) {
734
+ await new Promise((r) => setTimeout(r, every));
735
+ const res = await fetch(`${origin}/-/cli/token`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ device_code: grant.device_code }) }).catch(() => null);
736
+ if (!res)
737
+ continue;
738
+ const body = (await res.json().catch(() => ({})));
739
+ if (res.ok && body.token)
740
+ return body.token;
741
+ if (body.error === 'authorization_pending' || body.error === 'slow_down')
742
+ continue;
743
+ if (body.error === 'access_denied')
744
+ throw new Error('refused in the browser: the command was not signed in');
745
+ throw new Error(`the code expired or was already used (${body.error ?? res.status}) — run volter login again`);
746
+ }
747
+ throw new Error('the code expired before it was approved — run volter login again');
748
+ }
749
+ /** "a, b and c". */
750
+ const listed = (names) => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
751
+ /** A yes from the person at the terminal; no terminal is a no (a script says --create instead). */
752
+ async function confirm(question) {
753
+ if (!process.stdin.isTTY || !process.stderr.isTTY)
754
+ return false;
755
+ const { createInterface } = await import('node:readline/promises');
756
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
757
+ try {
758
+ return /^y(es)?$/i.test((await rl.question(question)).trim());
759
+ }
760
+ finally {
761
+ rl.close();
762
+ }
763
+ }
764
+ /** The org a bare world name belongs to: the one org the person is in, else a question they answer by naming it. */
765
+ async function onlyOrg(platform, personal, world) {
766
+ const res = await fetch(`${platform}/-/orgs`, { headers: { authorization: `Bearer ${personal}` } });
767
+ const body = (await res.json().catch(() => ({})));
768
+ if (!res.ok)
769
+ throw new Error(`${platform} answered ${res.status}: ${body.error ?? 'not signed in'} — volter login ${platform}`);
770
+ const orgs = (body.orgs ?? []).map((o) => o.slug ?? o.id ?? '').filter(Boolean);
771
+ if (orgs.length === 1)
772
+ return orgs[0];
773
+ if (orgs.length === 0)
774
+ throw new Error(`you are in no org on ${platform} yet — make one there, then volter remote add origin <org>/${world}`);
775
+ throw new Error(`you are in ${orgs.length} orgs (${listed(orgs)}): say which, volter remote add origin <org>/${world}`);
574
776
  }
575
777
  async function remoteCommand(verb, args) {
576
778
  if (verb === 'add' || verb === 'remove' || verb === 'list') {
@@ -579,21 +781,43 @@ async function remoteCommand(verb, args) {
579
781
  if (verb === 'add') {
580
782
  const [name, target] = pos;
581
783
  if (!name || !target)
582
- throw new Error('volter remote add <name> <url|path|org/world> [--token <token>]');
583
- // `org/world`: a world the hosted platform serves for your org — its address and token come through the platform, never by hand
584
- if (/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(target) && !existsSync(target)) {
784
+ throw new Error('volter remote add <name> <url|path|org/world> [--token <token>] [--create]');
785
+ // `org/world` (or `world`, when you are in one org): a world the hosted platform serves for your org — its address
786
+ // and token come through the platform, never by hand; one that is not there yet is made there from this world's
787
+ // vendors, asked first, or at once with --create (a script's or an agent's way)
788
+ // a bare word is a hosted world only when this command is signed into a platform; otherwise it is a folder, as ever
789
+ const hosted = !existsSync(target) && (/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(target) || (/^[a-z0-9][a-z0-9-]*$/.test(target) && Boolean(platformOrigin())));
790
+ if (hosted) {
585
791
  const platform = platformOrigin();
586
792
  if (!platform)
587
- throw new Error(`${target} looks like a hosted world (org/world) — \`volter login <platform url> --token <personal token>\` first`);
793
+ throw new Error(`${target} looks like a hosted world (org/world) — \`volter login <platform url>\` first`);
588
794
  const personal = tokenFor(platform);
589
795
  if (!personal)
590
796
  throw new Error(`no personal token stored for ${platform} — \`volter login\` again`);
591
- const res = await fetch(`${platform}/-/worlds/${target}/token`, { headers: { authorization: `Bearer ${personal}` } });
592
- const body = (await res.json().catch(() => ({})));
797
+ const served = target.includes('/') ? target : `${await onlyOrg(platform, personal, target)}/${target}`;
798
+ const key = () => fetch(`${platform}/-/worlds/${served}/token`, { headers: { authorization: `Bearer ${personal}` } });
799
+ let res = await key();
800
+ let body = (await res.clone().json().catch(() => ({})));
801
+ if (res.status === 404 && /^no world /.test(body.error ?? '')) {
802
+ const vendors = world.vendors();
803
+ if (vendors.length === 0)
804
+ throw new Error(`${served} is not on ${platform} yet, and this world names no twins to make it with — \`volter world init\` first`);
805
+ if (!flag(args, '--create') && !(await confirm(`${served} is not on ${platform}. Make it there with ${listed(vendors)}? [y/N] `))) {
806
+ throw new Error(`${served} is not on ${platform}${process.stdin.isTTY ? '' : ' — add --create to make it with this world\'s twins'}${target.includes('/') ? '' : ` (a folder? name it ./${target})`}`);
807
+ }
808
+ const [org, worldName] = served.split('/');
809
+ const made = await fetch(`${platform}/-/worlds`, { method: 'POST', headers: { authorization: `Bearer ${personal}`, 'content-type': 'application/json' }, body: JSON.stringify({ org, world: worldName, vendors }) });
810
+ const madeBody = (await made.json().catch(() => ({})));
811
+ if (!made.ok)
812
+ throw new Error(`the platform did not make ${served} (${made.status}): ${madeBody.error ?? 'refused'}`);
813
+ out(`made ${madeBody.name ?? served} on ${platform} (${listed(vendors)})`);
814
+ res = await key();
815
+ body = (await res.json().catch(() => ({})));
816
+ }
593
817
  if (!res.ok || !body.base || !body.token)
594
818
  throw new Error(`the platform answered ${res.status}: ${body.error ?? 'no such world in your orgs'}`);
595
819
  world.addRemote(name, body.base, { token: body.token });
596
- out(`remote ${name} ${body.base} (through ${platform})`);
820
+ out(`remote ${name} ${body.base} (through ${platform}${body.scope === 'read' ? ', read only: this token may not write Worlds' : ''})`);
597
821
  return;
598
822
  }
599
823
  world.addRemote(name, target, { ...(value(args, '--token') ? { token: value(args, '--token') } : {}) });
@@ -626,8 +850,157 @@ async function remoteCommand(verb, args) {
626
850
  }
627
851
  throw new Error(`volter remote: unknown verb "${verb}"\n\n${HELP}`);
628
852
  }
853
+ /** `volter open [<org>/<world>] [--no-open]`: a World's dashboard. A hosted World (named, or this world's origin on the
854
+ * platform the command is signed into) opens through the platform, which signs the person in with a pass; otherwise
855
+ * it is this world's own, as `volter world view` serves it. */
856
+ async function openCommand(args) {
857
+ const named = positionals(args, VALUE_FLAGS)[0];
858
+ const platform = platformOrigin();
859
+ let served = named && /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(named) ? named : null;
860
+ if (!served && !named && platform) {
861
+ // this world's origin, when it is a World the platform serves: its namespace is <org>/<world>
862
+ try {
863
+ const origin = World.open({ ...(value(args, '--world') ? { root: value(args, '--world') } : {}) }).remote();
864
+ if (origin && tokenFor(origin.url) && /^[a-z0-9-]+\/[a-z0-9-]+$/.test(origin.namespace))
865
+ served = origin.namespace;
866
+ }
867
+ catch { /* no world here */ }
868
+ }
869
+ if (named && !served)
870
+ throw new Error(`volter open ${named}: name a hosted World as <org>/<world>, or run it in the app's folder for this world`);
871
+ if (served) {
872
+ if (!platform)
873
+ throw new Error(`${served} is a hosted World — \`volter login <platform url>\` first`);
874
+ const url = `${platform}/-/worlds/${served}/open`;
875
+ out(url);
876
+ if (!flag(args, '--no-open'))
877
+ openInBrowser(url);
878
+ return;
879
+ }
880
+ await worldCommand('view', args);
881
+ }
882
+ /** The command tree for completion, read from HELP (the one list of commands): noun → its verbs. */
883
+ function commandTree() {
884
+ const tree = { open: [], completion: ['bash', 'zsh', 'fish', 'powershell'] };
885
+ for (const m of HELP.matchAll(/^\s+volter ([a-z]+)(?: ([a-z][a-z-]*))?/gm)) {
886
+ const [, noun, verb] = m;
887
+ (tree[noun] ??= []);
888
+ if (verb && !tree[noun].includes(verb))
889
+ tree[noun].push(verb);
890
+ }
891
+ return tree;
892
+ }
893
+ /** `volter completion bash|zsh|fish|powershell`: a script that completes volter's nouns and verbs in that shell. */
894
+ function completionScript(shell) {
895
+ const tree = commandTree();
896
+ const nouns = Object.keys(tree).join(' ');
897
+ const cases = (fmt) => Object.entries(tree).filter(([, v]) => v.length).map(([n, v]) => fmt(n, v.join(' '))).join('\n');
898
+ switch (shell) {
899
+ case 'bash':
900
+ case 'zsh':
901
+ return `${shell === 'zsh' ? 'autoload -U +X bashcompinit && bashcompinit\n' : ''}# volter completion (${shell}): eval "$(volter completion ${shell})"
902
+ _volter() {
903
+ local cur=\${COMP_WORDS[COMP_CWORD]}
904
+ if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W "${nouns}" -- "$cur")); return; fi
905
+ if [ "$COMP_CWORD" -eq 2 ]; then
906
+ case "\${COMP_WORDS[1]}" in
907
+ ${cases((n, v) => ` ${n}) COMPREPLY=($(compgen -W "${v}" -- "$cur")) ;;`)}
908
+ esac
909
+ fi
910
+ }
911
+ complete -o default -F _volter volter
912
+ `;
913
+ case 'fish':
914
+ return `# volter completion (fish): volter completion fish | source
915
+ complete -c volter -f -n '__fish_use_subcommand' -a '${nouns}'
916
+ ${cases((n, v) => `complete -c volter -f -n '__fish_seen_subcommand_from ${n}; and test (count (commandline -opc)) -eq 2' -a '${v}'`)}
917
+ `;
918
+ case 'powershell':
919
+ return `# volter completion (PowerShell): volter completion powershell | Out-String | Invoke-Expression
920
+ Register-ArgumentCompleter -Native -CommandName volter -ScriptBlock {
921
+ param($wordToComplete, $commandAst, $cursorPosition)
922
+ $words = @($commandAst.CommandElements | ForEach-Object { $_.ToString() })
923
+ $tree = @{
924
+ ${Object.entries(tree).map(([n, v]) => ` '${n}' = @(${v.map((x) => `'${x}'`).join(', ')})`).join('\n')}
925
+ }
926
+ $choices = if ($words.Count -le 1 -or ($words.Count -eq 2 -and $wordToComplete)) { $tree.Keys } elseif ($tree.ContainsKey($words[1])) { $tree[$words[1]] } else { @() }
927
+ $choices | Where-Object { $_ -like "$wordToComplete*" } | Sort-Object | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
928
+ }
929
+ `;
930
+ default:
931
+ throw new Error('volter completion bash|zsh|fish|powershell');
932
+ }
933
+ }
934
+ /** A fresh app has no twins installed, so `init` has nothing to plan with: its vendors are found from what the app
935
+ * depends on and names in its env (the signatures ship with the command), and the twins for them are installed with
936
+ * the app's own package manager: asked at a terminal, or at once with `--install` (a script's or an agent's way). */
937
+ async function installDetectedTwins(app, install) {
938
+ try {
939
+ if (resolveCatalog(app).vendors().length > 0)
940
+ return;
941
+ }
942
+ catch { /* no twins here yet */ }
943
+ const found = detectRepoVendors(app).detected;
944
+ const vendors = [...found.keys()].filter((v) => /^[a-z0-9][a-z0-9-]*$/.test(v)).sort();
945
+ if (vendors.length === 0)
946
+ return; // init says what it found and how to add a twin by hand
947
+ const pkgs = vendors.map((v) => `@volter/twin-${v}`);
948
+ const has = (file) => existsSync(join(app, file));
949
+ // the app's own package manager, by its lockfile; npm where there is none
950
+ const command = has('bun.lock') || has('bun.lockb') ? `bun add -d ${pkgs.join(' ')}` : has('pnpm-lock.yaml') ? `pnpm add -D ${pkgs.join(' ')}` : has('yarn.lock') ? `yarn add -D ${pkgs.join(' ')}` : `npm install -D ${pkgs.join(' ')}`;
951
+ out(`detected ${vendors.map((v) => `${v} (${(found.get(v) ?? []).slice(0, 2).join(', ')})`).join('; ')}`);
952
+ out(`twins ${pkgs.join(' ')}`);
953
+ let yes = install;
954
+ if (!yes && process.stdin.isTTY && process.stdout.isTTY) {
955
+ process.stdout.write(`Install them now (${command})? [Y/n] `);
956
+ const answer = await new Promise((done) => { process.stdin.once('data', (d) => done(String(d).trim().toLowerCase())); });
957
+ process.stdin.pause();
958
+ yes = answer === '' || answer === 'y' || answer === 'yes';
959
+ }
960
+ if (!yes)
961
+ throw new Error(`install the twins first: ${command}
962
+ then run volter world init again (or volter world init --install does both)`);
963
+ // one command line built from the checked names above: nothing of anyone's input reaches the shell
964
+ const ran = spawnSync(command, { cwd: app, stdio: 'inherit', shell: true, windowsHide: true });
965
+ if (ran.status !== 0)
966
+ throw new Error(`${command} failed (exit ${ran.status ?? 'none'})`);
967
+ }
968
+ /** After `volter world init`: a person at a terminal is asked whether to set up their coding agents; anyone else is
969
+ * told the command. */
970
+ async function offerAgents() {
971
+ const { detectAgents, installAgents } = await import("./agents.js");
972
+ const found = detectAgents(process.cwd());
973
+ if (found.length === 0)
974
+ return;
975
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
976
+ out('\nagents volter agents install --yes (the volter-world skill and MCP server for your coding agents)');
977
+ return;
978
+ }
979
+ process.stdout.write(`\nSet up your coding agent${found.length === 1 ? '' : 's'} (${found.join(', ')}) to drive this World? [y/N] `);
980
+ const answer = await new Promise((done) => { process.stdin.once('data', (d) => done(String(d).trim().toLowerCase())); });
981
+ process.stdin.pause();
982
+ if (answer === 'y' || answer === 'yes')
983
+ for (const line of installAgents(found, process.cwd()))
984
+ out(` ${line}`);
985
+ else
986
+ out('later: volter agents install');
987
+ }
988
+ /** This package's version, for the MCP server's handshake. */
989
+ function ownVersion() {
990
+ try {
991
+ return JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version ?? '0.0.0';
992
+ }
993
+ catch {
994
+ return '0.0.0';
995
+ }
996
+ }
629
997
  async function main() {
630
998
  const [noun, verb, ...args] = process.argv.slice(2);
999
+ const notice = startUpdateCheck(process.argv.slice(2));
1000
+ await dispatch(noun, verb, args);
1001
+ await notice();
1002
+ }
1003
+ async function dispatch(noun, verb, args) {
631
1004
  if (!noun || noun === '--help' || noun === '-h' || noun === 'help') {
632
1005
  out(HELP);
633
1006
  return;
@@ -646,17 +1019,41 @@ async function main() {
646
1019
  case 'login':
647
1020
  await loginCommand([verb, ...args].filter((a) => a !== undefined));
648
1021
  return;
1022
+ case 'logout':
1023
+ await logoutCommand();
1024
+ return;
1025
+ case 'whoami':
1026
+ await whoamiCommand();
1027
+ return;
649
1028
  case 'twin':
650
1029
  await twinCommand([verb, ...args].filter((a) => a !== undefined));
651
1030
  return;
1031
+ case 'mcp': {
1032
+ const { serveMcp } = await import("./mcp.js");
1033
+ await serveMcp(ownVersion());
1034
+ return;
1035
+ }
1036
+ case 'agents': {
1037
+ const { agentsCommand } = await import("./agents.js");
1038
+ await agentsCommand(verb, args, out);
1039
+ return;
1040
+ }
652
1041
  case 'remote': {
653
1042
  if (!verb)
654
1043
  throw new Error('volter remote: which verb? add | remove | list | serve');
655
1044
  await remoteCommand(verb, args);
656
1045
  return;
657
1046
  }
1047
+ case 'open':
1048
+ await openCommand([verb, ...args].filter((a) => a !== undefined));
1049
+ return;
1050
+ case 'completion':
1051
+ process.stdout.write(completionScript(verb));
1052
+ return;
658
1053
  default:
659
- throw new Error(`volter: unknown noun "${noun}" — world, twin or remote\n\n${HELP}`);
1054
+ throw new Error(`volter: unknown noun "${noun}" — world, twin, remote, open, completion, agents or mcp
1055
+
1056
+ ${HELP}`);
660
1057
  }
661
1058
  }
662
1059
  const isEntryPoint = import.meta.main ?? (process.argv[1] !== undefined && existsSync(process.argv[1])
@@ -4,9 +4,13 @@ export declare function credentialsPath(): string;
4
4
  export declare function tokenFor(origin: string, path?: string): string | undefined;
5
5
  /** Store a token for an origin (0600). */
6
6
  export declare function storeToken(origin: string, token: string, path?: string): void;
7
+ /** Forget the token stored for an origin; whether one was stored. */
8
+ export declare function forgetToken(origin: string, path?: string): boolean;
7
9
  /** `volter login`: the hosted platform a person signed the CLI into — its origin, beside the credentials. */
8
10
  export declare function platformPath(): string;
9
11
  export declare function storePlatform(origin: string, path?: string): void;
12
+ /** Forget which platform the CLI is signed into. */
13
+ export declare function forgetPlatform(path?: string): void;
10
14
  export declare function platformOrigin(path?: string): string | undefined;
11
15
  /** The token a verb uses: an explicit one for this call (CI), else the stored one, else a clear error. */
12
16
  export declare function requireToken(origin: string, explicit?: string, path?: string): string;
@@ -35,12 +35,25 @@ export function storeToken(origin, token, path = credentialsPath()) {
35
35
  writeFileSync(path, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
36
36
  chmodSync(path, 0o600);
37
37
  }
38
+ /** Forget the token stored for an origin; whether one was stored. */
39
+ export function forgetToken(origin, path = credentialsPath()) {
40
+ const store = readStore(path);
41
+ const key = normalizeOrigin(origin);
42
+ if (!(key in store))
43
+ return false;
44
+ delete store[key];
45
+ writeFileSync(path, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
46
+ return true;
47
+ }
38
48
  /** `volter login`: the hosted platform a person signed the CLI into — its origin, beside the credentials. */
39
49
  export function platformPath() { return join(dirname(credentialsPath()), 'platform.json'); }
40
50
  export function storePlatform(origin, path = platformPath()) {
41
51
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
42
52
  writeFileSync(path, `${JSON.stringify({ url: normalizeOrigin(origin), savedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
43
53
  }
54
+ /** Forget which platform the CLI is signed into. */
55
+ export function forgetPlatform(path = platformPath()) { if (existsSync(path))
56
+ writeFileSync(path, '{}\n', { mode: 0o600 }); }
44
57
  export function platformOrigin(path = platformPath()) {
45
58
  if (!existsSync(path))
46
59
  return undefined;
@@ -0,0 +1 @@
1
+ export declare function serveMcp(version: string): Promise<void>;