@volter/world 2.0.1 → 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/skills/volter-world/AGENTS.md +155 -0
- package/dist/skills/volter-world/SKILL.md +14 -0
- package/dist/src/agent-prompt.d.ts +10 -0
- package/dist/src/agent-prompt.js +23 -0
- package/dist/src/agents.d.ts +15 -0
- package/dist/src/agents.js +206 -0
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +414 -27
- package/dist/src/credentials.d.ts +4 -0
- package/dist/src/credentials.js +13 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +203 -0
- package/dist/src/update-notice.d.ts +7 -0
- package/dist/src/update-notice.js +60 -0
- package/dist/src/world.d.ts +11 -1
- package/dist/src/world.js +9 -2
- package/package.json +20 -6
- package/skills/volter-world/AGENTS.md +155 -0
- package/skills/volter-world/SKILL.md +14 -0
- package/src/agent-prompt.ts +35 -0
- package/src/agents.ts +166 -0
- package/src/cli.ts +309 -25
- package/src/credentials.ts +11 -0
- package/src/journeys/tutorial.ts +86 -8
- package/src/journeys/tutorials.test.ts +18 -5
- package/src/mcp.ts +181 -0
- package/src/update-notice.ts +44 -0
- package/src/world.ts +9 -2
package/src/agents.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// `volter agents install` (docs/contributing/architecture.md, "The way in is the app's folder"): the skill
|
|
2
|
+
// (skills/volter-world) and the MCP server (`volter mcp`) put into the coding agents on this machine. Each change is
|
|
3
|
+
// idempotent and said; an entry another tool wrote is never touched: only our own `volter` entry and our own skill
|
|
4
|
+
// folder or marked block are written.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
export type Agent = 'claude' | 'cursor' | 'vscode' | 'codex';
|
|
12
|
+
export const AGENTS: readonly Agent[] = ['claude', 'cursor', 'vscode', 'codex'];
|
|
13
|
+
const NAMES: Record<Agent, string> = { claude: 'Claude Code', cursor: 'Cursor', vscode: 'VS Code', codex: 'Codex' };
|
|
14
|
+
|
|
15
|
+
/** A command on PATH (with Windows' extensions), or null. */
|
|
16
|
+
function which(cmd: string): string | null {
|
|
17
|
+
const exts = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';').map((e) => e.toLowerCase()) : [''];
|
|
18
|
+
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
|
|
19
|
+
if (!dir) continue;
|
|
20
|
+
for (const ext of ['', ...exts]) { const p = join(dir, `${cmd}${ext}`); if (existsSync(p)) return p; }
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The skill's folder: the one shipped in the package (skills/, copied in at pack time), else this checkout's. */
|
|
26
|
+
export function skillSource(): string {
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
for (const candidate of [resolve(here, '..', 'skills', 'volter-world'), resolve(here, '..', '..', 'skills', 'volter-world'), resolve(here, '..', '..', '..', 'skills', 'volter-world'), resolve(here, '..', '..', '..', '..', 'skills', 'volter-world')]) {
|
|
29
|
+
if (existsSync(join(candidate, 'SKILL.md'))) return candidate;
|
|
30
|
+
}
|
|
31
|
+
throw new Error('the volter-world skill is not in this installation (skills/volter-world)');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How an agent starts the server: `volter mcp` when the command is on PATH, else this installation by its path. */
|
|
35
|
+
export function mcpCommand(): { command: string; args: string[] } {
|
|
36
|
+
if (which('volter')) return { command: 'volter', args: ['mcp'] };
|
|
37
|
+
const cli = fileURLToPath(new URL(`./cli${import.meta.url.endsWith('.ts') ? '.ts' : '.js'}`, import.meta.url));
|
|
38
|
+
return { command: process.execPath, args: [cli, 'mcp'] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The agents this machine (and this project) shows signs of. */
|
|
42
|
+
export function detectAgents(project: string): Agent[] {
|
|
43
|
+
const home = homedir();
|
|
44
|
+
const found: Agent[] = [];
|
|
45
|
+
if (existsSync(join(home, '.claude')) || which('claude')) found.push('claude');
|
|
46
|
+
if (existsSync(join(home, '.cursor')) || which('cursor')) found.push('cursor');
|
|
47
|
+
if (existsSync(join(project, '.vscode')) || which('code')) found.push('vscode');
|
|
48
|
+
if (existsSync(join(home, '.codex')) || which('codex')) found.push('codex');
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A path as a person reads it: inside the project relative to it, inside the home folder from ~, with forward slashes. */
|
|
53
|
+
function shown(path: string): string {
|
|
54
|
+
const within = (base: string): string | null => { const r = relative(base, path); return r && !r.startsWith('..') && !isAbsolute(r) ? r.replace(/\\/g, '/') : null; };
|
|
55
|
+
const inProject = within(process.cwd()); if (inProject) return inProject;
|
|
56
|
+
const inHome = within(homedir()); if (inHome) return `~/${inHome}`;
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const readJson = (path: string): Record<string, unknown> => {
|
|
61
|
+
if (!existsSync(path)) return {};
|
|
62
|
+
const raw = readFileSync(path, 'utf8').trim();
|
|
63
|
+
if (!raw) return {};
|
|
64
|
+
try { const v = JSON.parse(raw) as unknown; return v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}; } catch { throw new Error(`${path} is not JSON; not changing it (add the volter server by hand)`); }
|
|
65
|
+
};
|
|
66
|
+
/** A config file replaced whole in one step (written beside it, then renamed over it): a reader never sees half of it. */
|
|
67
|
+
const writeAtomic = (path: string, text: string): void => { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.volter-${process.pid}.tmp`; writeFileSync(tmp, text); renameSync(tmp, path); };
|
|
68
|
+
const writeJson = (path: string, value: unknown): void => writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
69
|
+
/** A command run as itself; on Windows, where `claude` is a .cmd that runs only in cmd, one line to cmd with each argument
|
|
70
|
+
* quoted (a path with a space stays one argument), as cross-spawn does. */
|
|
71
|
+
const runTool = (bin: string, args: string[]): ReturnType<typeof spawnSync> => process.platform === 'win32'
|
|
72
|
+
? spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `"${[bin, ...args].map((a) => `"${a.replace(/"/g, '""')}"`).join(' ')}"`], { windowsHide: true, encoding: 'utf8', windowsVerbatimArguments: true })
|
|
73
|
+
: spawnSync(bin, args, { windowsHide: true, encoding: 'utf8' });
|
|
74
|
+
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
|
75
|
+
|
|
76
|
+
/** Our entry under `key` in a JSON config's server map: added, or already there; someone else's `volter` is left alone. */
|
|
77
|
+
function mergeServer(path: string, key: 'mcpServers' | 'servers', entry: Record<string, unknown>): string {
|
|
78
|
+
const doc = readJson(path);
|
|
79
|
+
const servers = (doc[key] && typeof doc[key] === 'object' ? doc[key] : {}) as Record<string, unknown>;
|
|
80
|
+
const mine = servers.volter as Record<string, unknown> | undefined;
|
|
81
|
+
if (mine && same(mine, entry)) return `${shown(path)}: the volter server is already there`;
|
|
82
|
+
if (mine && !(typeof mine.command === 'string' && /volter|cli\.[tj]s/.test(`${mine.command} ${JSON.stringify(mine.args ?? [])}`))) return `${shown(path)}: a "volter" server someone else configured is there; left alone`;
|
|
83
|
+
doc[key] = { ...servers, volter: entry };
|
|
84
|
+
writeJson(path, doc);
|
|
85
|
+
return `${shown(path)}: ${mine ? 'updated' : 'added'} the volter server`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The skill copied into an agent's skills folder: our own folder, replaced whole. */
|
|
89
|
+
function copySkill(target: string): string {
|
|
90
|
+
const dest = join(target, 'volter-world');
|
|
91
|
+
rmSync(dest, { recursive: true, force: true });
|
|
92
|
+
mkdirSync(target, { recursive: true });
|
|
93
|
+
cpSync(skillSource(), dest, { recursive: true });
|
|
94
|
+
return `${shown(dest)}: the volter-world skill`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const BEGIN = '<!-- volter-world:begin -->';
|
|
98
|
+
const END = '<!-- volter-world:end -->';
|
|
99
|
+
/** The skill's instructions as a marked block in a Markdown instructions file; the rest of the file untouched. */
|
|
100
|
+
function markedBlock(path: string): string {
|
|
101
|
+
const body = readFileSync(join(skillSource(), 'AGENTS.md'), 'utf8').trimEnd();
|
|
102
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
103
|
+
const start = existing.indexOf(BEGIN); const end = existing.indexOf(END);
|
|
104
|
+
const outside = start >= 0 && end > start ? `${existing.slice(0, start)}${existing.slice(end + END.length)}`.replace(/\n{3,}$/, '\n\n') : existing;
|
|
105
|
+
const next = `${outside.trimEnd()}${outside.trim() ? '\n\n' : ''}${BEGIN}\n${body}\n${END}\n`;
|
|
106
|
+
if (next === existing) return `${shown(path)}: the volter-world instructions are already there`;
|
|
107
|
+
writeAtomic(path, next);
|
|
108
|
+
return `${shown(path)}: ${start >= 0 ? 'updated' : 'added'} the volter-world instructions`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Codex's config.toml: a [mcp_servers.volter] table appended when there is none; an existing one is left alone. */
|
|
112
|
+
function codexServer(path: string, entry: { command: string; args: string[] }): string {
|
|
113
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
114
|
+
if (/^\s*\[mcp_servers\.(?:volter|"volter"|'volter')\]\s*$/m.test(existing)) return `${shown(path)}: a [mcp_servers.volter] table is already there; left alone`;
|
|
115
|
+
const q = (s: string): string => JSON.stringify(s);
|
|
116
|
+
const table = `[mcp_servers.volter]\ncommand = ${q(entry.command)}\nargs = [${entry.args.map(q).join(', ')}]\n`;
|
|
117
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
118
|
+
writeAtomic(path, `${existing.trimEnd()}${existing.trim() ? '\n\n' : ''}${table}`);
|
|
119
|
+
return `${shown(path)}: added the volter server`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Claude Code: `claude mcp add` at user scope when the command is here (it owns its config file), else ~/.claude.json. */
|
|
123
|
+
function claudeServer(entry: { command: string; args: string[] }): string {
|
|
124
|
+
const claude = which('claude');
|
|
125
|
+
if (claude) {
|
|
126
|
+
const has = runTool(claude, ['mcp', 'get', 'volter']);
|
|
127
|
+
if (has.status === 0) return 'Claude Code: the volter server is already registered (claude mcp get volter)';
|
|
128
|
+
const add = runTool(claude, ['mcp', 'add', '--scope', 'user', 'volter', '--', entry.command, ...entry.args]);
|
|
129
|
+
if (add.status === 0) return 'Claude Code: registered the volter server (claude mcp add --scope user volter)';
|
|
130
|
+
}
|
|
131
|
+
return mergeServer(join(homedir(), '.claude.json'), 'mcpServers', { type: 'stdio', command: entry.command, args: entry.args });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Install into each agent: what changed, one line each. */
|
|
135
|
+
export function installAgents(agents: Agent[], project: string): string[] {
|
|
136
|
+
const entry = mcpCommand(); const home = homedir(); const lines: string[] = [];
|
|
137
|
+
for (const a of agents) {
|
|
138
|
+
try {
|
|
139
|
+
if (a === 'claude') { lines.push(copySkill(join(home, '.claude', 'skills'))); lines.push(claudeServer(entry)); }
|
|
140
|
+
if (a === 'cursor') lines.push(mergeServer(join(home, '.cursor', 'mcp.json'), 'mcpServers', { command: entry.command, args: entry.args }));
|
|
141
|
+
if (a === 'vscode') lines.push(mergeServer(join(project, '.vscode', 'mcp.json'), 'servers', { type: 'stdio', command: entry.command, args: entry.args }));
|
|
142
|
+
if (a === 'codex') { lines.push(markedBlock(join(home, '.codex', 'AGENTS.md'))); lines.push(codexServer(join(home, '.codex', 'config.toml'), entry)); }
|
|
143
|
+
} catch (e) { lines.push(`${NAMES[a]}: not changed: ${e instanceof Error ? e.message : String(e)}`); }
|
|
144
|
+
}
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `volter agents install [--claude] [--cursor] [--vscode] [--codex] [--yes]`. */
|
|
149
|
+
export async function agentsCommand(verb: string | undefined, args: string[], out: (s: string) => void): Promise<void> {
|
|
150
|
+
if (verb !== 'install') throw new Error('volter agents install [--claude] [--cursor] [--vscode] [--codex] [--yes]');
|
|
151
|
+
const project = process.cwd();
|
|
152
|
+
const named = AGENTS.filter((a) => args.includes(`--${a}`));
|
|
153
|
+
const agents = named.length ? named : detectAgents(project);
|
|
154
|
+
if (agents.length === 0) { out('no coding agent found here (Claude Code, Cursor, VS Code, Codex); name one: volter agents install --claude'); process.exitCode = 1; return; }
|
|
155
|
+
const entry = mcpCommand();
|
|
156
|
+
out(`for ${agents.map((a) => NAMES[a]).join(', ')}\nskill volter-world\nserver ${[entry.command, ...entry.args].join(' ')}`);
|
|
157
|
+
if (!args.includes('--yes')) {
|
|
158
|
+
if (!process.stdin.isTTY) { out('\nnothing changed: run again with --yes to install'); return; }
|
|
159
|
+
process.stdout.write('\nInstall? [y/N] ');
|
|
160
|
+
const answer = await new Promise<string>((done) => { process.stdin.once('data', (d) => done(String(d).trim().toLowerCase())); });
|
|
161
|
+
process.stdin.pause();
|
|
162
|
+
if (answer !== 'y' && answer !== 'yes') { out('nothing changed'); return; }
|
|
163
|
+
}
|
|
164
|
+
for (const line of installAgents(agents, project)) out(` ${line}`);
|
|
165
|
+
out('\nRestart the agent, then ask it to "set up Volter World for this app".');
|
|
166
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -3,27 +3,41 @@
|
|
|
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 { formatLedgerDelta } from '@volter/world-core';
|
|
11
13
|
import { existsSync } from 'node:fs';
|
|
12
14
|
import { join, relative, resolve } from 'node:path';
|
|
13
|
-
import { platformOrigin, requireToken, storePlatform, storeToken, tokenFor } from './credentials.ts';
|
|
15
|
+
import { forgetPlatform, forgetToken, platformOrigin, requireToken, storePlatform, storeToken, tokenFor } from './credentials.ts';
|
|
14
16
|
import { parseOriginUrl, World } from './world.ts';
|
|
15
17
|
import { waitForServeShutdown } from './serve-shutdown.ts';
|
|
18
|
+
import { startUpdateCheck } from './update-notice.ts';
|
|
19
|
+
import type { ViewConsole } from '@volter/world-runtime';
|
|
20
|
+
|
|
21
|
+
/** Open a URL in the person's browser; a machine without one only loses the convenience. */
|
|
22
|
+
function openInBrowser(url: string): void {
|
|
23
|
+
// an http(s) URL only, and on Windows through the URL handler rather than cmd.exe, which would read `"`, `&` or `%VAR%`
|
|
24
|
+
// in it as commands
|
|
25
|
+
let target: string; try { const u = new URL(url); if (u.protocol !== 'http:' && u.protocol !== 'https:') return; target = u.href; } catch { return; }
|
|
26
|
+
const [cmd, ...pre] = process.platform === 'darwin' ? ['open'] : process.platform === 'win32' ? ['rundll32', 'url.dll,FileProtocolHandler'] : ['xdg-open'];
|
|
27
|
+
try { spawn(cmd!, [...pre, target], { windowsHide: true, stdio: 'ignore', detached: true }).on('error', () => undefined).unref(); } catch { /* no browser here */ }
|
|
28
|
+
}
|
|
16
29
|
|
|
17
30
|
export const HELP = `volter — run your app against a world of twins
|
|
18
31
|
|
|
19
32
|
world — the twins your app needs, running together (the world of the current directory)
|
|
20
|
-
volter world init [--name <world>] [--allow-unknown] detect the app's vendors, write .volter/world.json
|
|
33
|
+
volter world init [--install] [--name <world>] [--allow-unknown] detect the app's vendors (installing their twins when none are), write .volter/world.json
|
|
21
34
|
volter world up [--sandbox] [--no-seed] start the twins on this branch (loads the default data the first time)
|
|
22
35
|
volter world run [--verbose] -- <command...> run the app or its tests inside the world
|
|
23
36
|
volter world activate eval "$(volter world activate)": vendor CLIs and curl in this shell reach the twins
|
|
24
37
|
volter world shell a subshell with the world active
|
|
25
38
|
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
|
|
26
|
-
volter world serve [--port <p>] [--console-port <c>] serve this world on a URL under /<org>/<world
|
|
39
|
+
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)
|
|
40
|
+
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)
|
|
27
41
|
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
|
|
28
42
|
volter world status world, branch, origin, unpushed changes, what is running
|
|
29
43
|
volter world log [--receipts] [--json] every write the app made; --receipts adds each change's receipt
|
|
@@ -55,9 +69,17 @@ twin — one twin: in this world, or on its own
|
|
|
55
69
|
volter twin <vendor> mirror [--port <p>] the twin's UI mirror
|
|
56
70
|
volter twin <vendor> conformance check the twin against the vendor's spec
|
|
57
71
|
|
|
72
|
+
agents — your coding agent, driving the same verbs
|
|
73
|
+
volter agents install [--claude] [--cursor] [--vscode] [--codex] [--yes] put the volter-world skill and the MCP server into the agents found here
|
|
74
|
+
volter mcp the MCP server (stdio) an agent starts: the verbs above as tools
|
|
75
|
+
|
|
58
76
|
remote — the worlds this one pushes to and fetches from, by name
|
|
59
|
-
volter login <platform url> --token <personal token> sign the CLI into the hosted platform (a
|
|
60
|
-
volter
|
|
77
|
+
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)
|
|
78
|
+
volter whoami the platform the CLI is signed into, as whom, and its orgs
|
|
79
|
+
volter logout revoke the CLI's token at the platform and forget it here
|
|
80
|
+
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
|
|
81
|
+
volter open [<org>/<world>] [--no-open] a World's dashboard: a hosted one through the platform, else this world's (as view)
|
|
82
|
+
volter completion bash|zsh|fish|powershell print a completion script for your shell
|
|
61
83
|
volter remote remove <name> forget it
|
|
62
84
|
volter remote list every remote, with its URL and whether a token is stored
|
|
63
85
|
(the hosting product's serve answers here for one release: volter remote serve)
|
|
@@ -96,7 +118,7 @@ function when(iso: string): string { return iso.slice(11, 23); }
|
|
|
96
118
|
|
|
97
119
|
/** Run another CLI in the foreground and adopt its exit code; a signal to us is a signal to it. */
|
|
98
120
|
async function passthrough(cmd: string[], cwd = process.cwd()): Promise<number> {
|
|
99
|
-
const child = spawn(cmd[0]!, cmd.slice(1), { cwd, stdio: 'inherit' });
|
|
121
|
+
const child = spawn(cmd[0]!, cmd.slice(1), { windowsHide: true, cwd, stdio: 'inherit' });
|
|
100
122
|
const forward = (signal: NodeJS.Signals) => () => { try { child.kill(signal); } catch { /* already gone */ } };
|
|
101
123
|
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) process.on(signal, forward(signal));
|
|
102
124
|
return await new Promise<number>((resolve, reject) => { child.once('error', reject); child.once('exit', (code, signal) => resolve(code ?? (signal ? 128 : 1))); });
|
|
@@ -123,12 +145,14 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
|
|
|
123
145
|
}
|
|
124
146
|
if (verb === 'init') {
|
|
125
147
|
const app = resolve(worldDir ?? process.cwd());
|
|
148
|
+
await installDetectedTwins(app, flag(args, '--install'));
|
|
126
149
|
const { result } = World.init(app, { ...(value(args, '--name') ? { name: value(args, '--name')! } : {}), force: flag(args, '--force'), allowUnknown: flag(args, '--allow-unknown') });
|
|
127
150
|
if (asJson) json({ ok: result.ok, plan: result.plan, coverage: result.coverage, next: result.next });
|
|
128
151
|
else {
|
|
129
152
|
const twins = result.plan.vendors.filter((v) => v.service !== null).map((v) => v.vendor).sort();
|
|
130
153
|
out(`twins ${twins.join(', ') || '(none)'}\nwrote ${relative(process.cwd(), result.plan.configPath) || result.plan.configPath}`);
|
|
131
154
|
out(formatInitReport(result));
|
|
155
|
+
if (result.ok) await offerAgents();
|
|
132
156
|
}
|
|
133
157
|
if (!result.ok) process.exitCode = 1;
|
|
134
158
|
return;
|
|
@@ -299,10 +323,45 @@ async function worldCommand(verb: string, args: string[]): Promise<void> {
|
|
|
299
323
|
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')}` : ''}`);
|
|
300
324
|
return;
|
|
301
325
|
}
|
|
326
|
+
case 'view':
|
|
302
327
|
case 'serve': {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
328
|
+
// `view` steps into the World in a browser; `serve` serves it for apps and scripts. Bound to this machine's
|
|
329
|
+
// loopback both are the same local front (the World's own origin, its console beside it) and the browser never
|
|
330
|
+
// needs a token (served-world.ts localSession); `serve` only leaves the browser closed. A non-loopback --host,
|
|
331
|
+
// or `serve --console-port`, is a World served for others: its console asks for a token.
|
|
332
|
+
const host = value(args, '--host');
|
|
333
|
+
const local = host === undefined || host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
334
|
+
if (verb === 'serve' && (!local || value(args, '--console-port') !== undefined)) {
|
|
335
|
+
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}` : ''}`) });
|
|
336
|
+
await waitForServeShutdown(() => served.stop());
|
|
337
|
+
process.exit(0); // success follows confirmed shutdown; failures reach main's error path
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// the console mounts when it is installed beside the CLI; without it the World serves its doors and mirrors
|
|
341
|
+
const console = await (async (): Promise<ViewConsole | undefined> => {
|
|
342
|
+
try { const mod = await import('@volter/world-console') as { createConsole: () => ViewConsole }; return mod.createConsole(); } catch (error) {
|
|
343
|
+
// not installed is one thing; installed and broken is another, and says why
|
|
344
|
+
const missing = /ERR_MODULE_NOT_FOUND|Cannot find (package|module)/.test(`${(error as { code?: string }).code ?? ''} ${String(error)}`);
|
|
345
|
+
if (!missing) process.stderr.write(`the console failed to load (the World is served without it): ${error instanceof Error ? error.message : String(error)}\n`);
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
// `serve` says what it always said first (`serving <name> <url>`, then the tokens: scripts and the guides read
|
|
350
|
+
// those lines), and the local front's own lines after them; `view` says `viewing` as it goes
|
|
351
|
+
const later: string[] = [];
|
|
352
|
+
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') out(line); else if (!line.startsWith('viewing')) later.push(line); } });
|
|
353
|
+
if (verb === 'serve') { out(`serving ${view.served} ${view.base}\ntoken ${view.token}\nread ${view.readToken}`); for (const line of later) out(line); }
|
|
354
|
+
// a local World's page opens itself; a World served for others is handed its token in the URL's fragment
|
|
355
|
+
// (which the browser never sends), taken once
|
|
356
|
+
// (with --no-origins every World shares one origin, where no page is handed a session: the token again)
|
|
357
|
+
const open = view.console ? (local && view.origin ? view.console : `${view.console}#token=${encodeURIComponent(view.token)}`) : null;
|
|
358
|
+
// the read token's own one-line way in, for handing to someone who should look and not touch
|
|
359
|
+
// (not on a local World: any of its pages can open a write session there, so a read-only link restricts nothing)
|
|
360
|
+
const readOnly = view.console && !(local && view.origin) ? `${view.console}#token=${encodeURIComponent(view.readToken)}` : null;
|
|
361
|
+
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)' : ''}`);
|
|
362
|
+
if (open && verb === 'view' && !args.includes('--no-open')) openInBrowser(open);
|
|
363
|
+
await waitForServeShutdown(() => view.stop());
|
|
364
|
+
process.exit(0);
|
|
306
365
|
return;
|
|
307
366
|
}
|
|
308
367
|
case 'rebase': {
|
|
@@ -410,17 +469,90 @@ function remoteCli(): string {
|
|
|
410
469
|
throw new Error('volter remote: the hosting product (@volter/world-host) is not installed — `bun add -d @volter/world-host`');
|
|
411
470
|
}
|
|
412
471
|
|
|
413
|
-
/** `volter login <platform url
|
|
414
|
-
*
|
|
472
|
+
/** `volter login <platform url>`: the CLI signed into the hosted platform as you, through the browser (the device
|
|
473
|
+
* authorization grant, as `gh auth login`): the platform shows a code, you approve it there signed in as yourself,
|
|
474
|
+
* and the command collects a personal token named for this machine. `--token <personal token>` (one made in the
|
|
475
|
+
* platform under Account) signs in without a browser, for CI. Either way it is kept in your config directory. */
|
|
415
476
|
async function loginCommand(args: string[]): Promise<void> {
|
|
416
|
-
const url = positionals(args, VALUE_FLAGS)[0];
|
|
417
|
-
if (!url
|
|
418
|
-
|
|
477
|
+
const url = positionals(args, VALUE_FLAGS)[0];
|
|
478
|
+
if (!url) throw new Error('volter login <platform url> [--token <personal token>] [--no-open]');
|
|
479
|
+
let origin: string; try { const u = new URL(url); if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error(); origin = u.origin; } catch { throw new Error(`${url} is not a platform address (https://…)`); }
|
|
480
|
+
const token = value(args, '--token') ?? await deviceSignIn(origin, !flag(args, '--no-open'));
|
|
419
481
|
const res = await fetch(`${origin}/-/orgs`, { headers: { authorization: `Bearer ${token}` } });
|
|
420
482
|
const body = (await res.json().catch(() => ({}))) as { person?: { email?: string; id?: string }; orgs?: Array<{ slug: string | null; name: string }>; error?: string };
|
|
421
483
|
if (!res.ok) throw new Error(`${origin} answered ${res.status}: ${body.error ?? 'not signed in'}`);
|
|
422
484
|
storeToken(origin, token); storePlatform(origin);
|
|
423
|
-
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
|
|
485
|
+
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`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** `volter whoami`: which platform the command is signed into, as whom, and the orgs it reaches. */
|
|
489
|
+
async function whoamiCommand(): Promise<void> {
|
|
490
|
+
const origin = platformOrigin(); const token = origin ? tokenFor(origin) : undefined;
|
|
491
|
+
if (!origin || !token) { out('not signed in (volter login <platform url>)'); process.exitCode = 1; return; }
|
|
492
|
+
const res = await fetch(`${origin}/-/orgs`, { headers: { authorization: `Bearer ${token}` } }).catch(() => null);
|
|
493
|
+
if (!res) throw new Error(`${origin} did not answer`);
|
|
494
|
+
const body = (await res.json().catch(() => ({}))) as { person?: { email?: string; id?: string }; orgs?: Array<{ slug: string | null; name: string; role?: string }>; error?: string };
|
|
495
|
+
// a token the platform no longer takes is signed out; one it takes but that may not read orgs is still signed in
|
|
496
|
+
if (res.status === 403) { out(`signed in ${origin} (this token may not read your orgs: ${body.error ?? 'refused'})`); return; }
|
|
497
|
+
if (!res.ok) { out(`signed out ${origin}: ${body.error ?? res.status} — volter login ${origin}`); process.exitCode = 1; return; }
|
|
498
|
+
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)'}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `volter logout`: the command's token is revoked at the platform (a token may always revoke itself) and forgotten here. */
|
|
502
|
+
async function logoutCommand(): Promise<void> {
|
|
503
|
+
const origin = platformOrigin(); const token = origin ? tokenFor(origin) : undefined;
|
|
504
|
+
if (!origin || !token) { out('not signed in'); return; }
|
|
505
|
+
const id = createHash('sha256').update(token).digest('hex');
|
|
506
|
+
const res = await fetch(`${origin}/-/tokens/${id}`, { method: 'DELETE', headers: { authorization: `Bearer ${token}` } }).catch(() => null);
|
|
507
|
+
forgetToken(origin); forgetPlatform();
|
|
508
|
+
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)`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The browser half of `volter login`: ask the platform for a code, send the person to approve it, and wait (at the
|
|
512
|
+
* platform's pace) for the personal token it hands over once they do. */
|
|
513
|
+
async function deviceSignIn(origin: string, openBrowser: boolean): Promise<string> {
|
|
514
|
+
const started = await fetch(`${origin}/-/cli/device`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: hostname() }) }).catch((e: unknown) => { throw new Error(`${origin} did not answer: ${e instanceof Error ? e.message : String(e)}`); });
|
|
515
|
+
const grant = (await started.json().catch(() => ({}))) as { device_code?: string; user_code?: string; verification_uri_complete?: string; expires_in?: number; interval?: number; error?: string };
|
|
516
|
+
if (!started.ok || !grant.device_code || !grant.user_code || !/^[A-Z]{4}-[A-Z]{4}$/.test(grant.user_code)) 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`);
|
|
517
|
+
// the page is this platform's own, built here from the address you gave and the code: nothing the platform answers is
|
|
518
|
+
// opened as it stands
|
|
519
|
+
const page = `${origin}/-/cli/approve?code=${grant.user_code}`;
|
|
520
|
+
process.stderr.write(`approve ${page}\ncode ${grant.user_code} (check the page shows the same)\n`);
|
|
521
|
+
if (openBrowser) openInBrowser(page);
|
|
522
|
+
const until = Date.now() + (grant.expires_in ?? 600) * 1000; const every = Math.max(1, grant.interval ?? 5) * 1000;
|
|
523
|
+
while (Date.now() < until) {
|
|
524
|
+
await new Promise((r) => setTimeout(r, every));
|
|
525
|
+
const res = await fetch(`${origin}/-/cli/token`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ device_code: grant.device_code }) }).catch(() => null);
|
|
526
|
+
if (!res) continue;
|
|
527
|
+
const body = (await res.json().catch(() => ({}))) as { token?: string; error?: string };
|
|
528
|
+
if (res.ok && body.token) return body.token;
|
|
529
|
+
if (body.error === 'authorization_pending' || body.error === 'slow_down') continue;
|
|
530
|
+
if (body.error === 'access_denied') throw new Error('refused in the browser: the command was not signed in');
|
|
531
|
+
throw new Error(`the code expired or was already used (${body.error ?? res.status}) — run volter login again`);
|
|
532
|
+
}
|
|
533
|
+
throw new Error('the code expired before it was approved — run volter login again');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** "a, b and c". */
|
|
537
|
+
const listed = (names: string[]): string => (names.length <= 1 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);
|
|
538
|
+
|
|
539
|
+
/** A yes from the person at the terminal; no terminal is a no (a script says --create instead). */
|
|
540
|
+
async function confirm(question: string): Promise<boolean> {
|
|
541
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) return false;
|
|
542
|
+
const { createInterface } = await import('node:readline/promises');
|
|
543
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
544
|
+
try { return /^y(es)?$/i.test((await rl.question(question)).trim()); } finally { rl.close(); }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** The org a bare world name belongs to: the one org the person is in, else a question they answer by naming it. */
|
|
548
|
+
async function onlyOrg(platform: string, personal: string, world: string): Promise<string> {
|
|
549
|
+
const res = await fetch(`${platform}/-/orgs`, { headers: { authorization: `Bearer ${personal}` } });
|
|
550
|
+
const body = (await res.json().catch(() => ({}))) as { orgs?: Array<{ slug: string | null; id?: string }>; error?: string };
|
|
551
|
+
if (!res.ok) throw new Error(`${platform} answered ${res.status}: ${body.error ?? 'not signed in'} — volter login ${platform}`);
|
|
552
|
+
const orgs = (body.orgs ?? []).map((o) => o.slug ?? o.id ?? '').filter(Boolean);
|
|
553
|
+
if (orgs.length === 1) return orgs[0]!;
|
|
554
|
+
if (orgs.length === 0) throw new Error(`you are in no org on ${platform} yet — make one there, then volter remote add origin <org>/${world}`);
|
|
555
|
+
throw new Error(`you are in ${orgs.length} orgs (${listed(orgs)}): say which, volter remote add origin <org>/${world}`);
|
|
424
556
|
}
|
|
425
557
|
|
|
426
558
|
async function remoteCommand(verb: string, args: string[]): Promise<void> {
|
|
@@ -429,16 +561,35 @@ async function remoteCommand(verb: string, args: string[]): Promise<void> {
|
|
|
429
561
|
const pos = positionals(args, VALUE_FLAGS);
|
|
430
562
|
if (verb === 'add') {
|
|
431
563
|
const [name, target] = pos;
|
|
432
|
-
if (!name || !target) throw new Error('volter remote add <name> <url|path|org/world> [--token <token>]');
|
|
433
|
-
// `org/world
|
|
434
|
-
|
|
435
|
-
|
|
564
|
+
if (!name || !target) throw new Error('volter remote add <name> <url|path|org/world> [--token <token>] [--create]');
|
|
565
|
+
// `org/world` (or `world`, when you are in one org): a world the hosted platform serves for your org — its address
|
|
566
|
+
// and token come through the platform, never by hand; one that is not there yet is made there from this world's
|
|
567
|
+
// vendors, asked first, or at once with --create (a script's or an agent's way)
|
|
568
|
+
// a bare word is a hosted world only when this command is signed into a platform; otherwise it is a folder, as ever
|
|
569
|
+
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())));
|
|
570
|
+
if (hosted) {
|
|
571
|
+
const platform = platformOrigin(); if (!platform) throw new Error(`${target} looks like a hosted world (org/world) — \`volter login <platform url>\` first`);
|
|
436
572
|
const personal = tokenFor(platform); if (!personal) throw new Error(`no personal token stored for ${platform} — \`volter login\` again`);
|
|
437
|
-
const
|
|
438
|
-
const
|
|
573
|
+
const served = target.includes('/') ? target : `${await onlyOrg(platform, personal, target)}/${target}`;
|
|
574
|
+
const key = (): Promise<Response> => fetch(`${platform}/-/worlds/${served}/token`, { headers: { authorization: `Bearer ${personal}` } });
|
|
575
|
+
let res = await key();
|
|
576
|
+
let body = (await res.clone().json().catch(() => ({}))) as { base?: string; token?: string; scope?: string; error?: string };
|
|
577
|
+
if (res.status === 404 && /^no world /.test(body.error ?? '')) {
|
|
578
|
+
const vendors = world.vendors();
|
|
579
|
+
if (vendors.length === 0) throw new Error(`${served} is not on ${platform} yet, and this world names no twins to make it with — \`volter world init\` first`);
|
|
580
|
+
if (!flag(args, '--create') && !(await confirm(`${served} is not on ${platform}. Make it there with ${listed(vendors)}? [y/N] `))) {
|
|
581
|
+
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})`}`);
|
|
582
|
+
}
|
|
583
|
+
const [org, worldName] = served.split('/') as [string, string];
|
|
584
|
+
const made = await fetch(`${platform}/-/worlds`, { method: 'POST', headers: { authorization: `Bearer ${personal}`, 'content-type': 'application/json' }, body: JSON.stringify({ org, world: worldName, vendors }) });
|
|
585
|
+
const madeBody = (await made.json().catch(() => ({}))) as { name?: string; error?: string };
|
|
586
|
+
if (!made.ok) throw new Error(`the platform did not make ${served} (${made.status}): ${madeBody.error ?? 'refused'}`);
|
|
587
|
+
out(`made ${madeBody.name ?? served} on ${platform} (${listed(vendors)})`);
|
|
588
|
+
res = await key(); body = (await res.json().catch(() => ({}))) as typeof body;
|
|
589
|
+
}
|
|
439
590
|
if (!res.ok || !body.base || !body.token) throw new Error(`the platform answered ${res.status}: ${body.error ?? 'no such world in your orgs'}`);
|
|
440
591
|
world.addRemote(name, body.base, { token: body.token });
|
|
441
|
-
out(`remote ${name} ${body.base} (through ${platform})`);
|
|
592
|
+
out(`remote ${name} ${body.base} (through ${platform}${body.scope === 'read' ? ', read only: this token may not write Worlds' : ''})`);
|
|
442
593
|
return;
|
|
443
594
|
}
|
|
444
595
|
world.addRemote(name, target, { ...(value(args, '--token') ? { token: value(args, '--token') } : {}) });
|
|
@@ -456,8 +607,133 @@ async function remoteCommand(verb: string, args: string[]): Promise<void> {
|
|
|
456
607
|
throw new Error(`volter remote: unknown verb "${verb}"\n\n${HELP}`);
|
|
457
608
|
}
|
|
458
609
|
|
|
610
|
+
/** `volter open [<org>/<world>] [--no-open]`: a World's dashboard. A hosted World (named, or this world's origin on the
|
|
611
|
+
* platform the command is signed into) opens through the platform, which signs the person in with a pass; otherwise
|
|
612
|
+
* it is this world's own, as `volter world view` serves it. */
|
|
613
|
+
async function openCommand(args: string[]): Promise<void> {
|
|
614
|
+
const named = positionals(args, VALUE_FLAGS)[0];
|
|
615
|
+
const platform = platformOrigin();
|
|
616
|
+
let served = named && /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/.test(named) ? named : null;
|
|
617
|
+
if (!served && !named && platform) {
|
|
618
|
+
// this world's origin, when it is a World the platform serves: its namespace is <org>/<world>
|
|
619
|
+
try { const origin = World.open({ ...(value(args, '--world') ? { root: value(args, '--world') } : {}) }).remote(); if (origin && tokenFor(origin.url) && /^[a-z0-9-]+\/[a-z0-9-]+$/.test(origin.namespace)) served = origin.namespace; } catch { /* no world here */ }
|
|
620
|
+
}
|
|
621
|
+
if (named && !served) throw new Error(`volter open ${named}: name a hosted World as <org>/<world>, or run it in the app's folder for this world`);
|
|
622
|
+
if (served) {
|
|
623
|
+
if (!platform) throw new Error(`${served} is a hosted World — \`volter login <platform url>\` first`);
|
|
624
|
+
const url = `${platform}/-/worlds/${served}/open`;
|
|
625
|
+
out(url);
|
|
626
|
+
if (!flag(args, '--no-open')) openInBrowser(url);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
await worldCommand('view', args);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** The command tree for completion, read from HELP (the one list of commands): noun → its verbs. */
|
|
633
|
+
function commandTree(): Record<string, string[]> {
|
|
634
|
+
const tree: Record<string, string[]> = { open: [], completion: ['bash', 'zsh', 'fish', 'powershell'] };
|
|
635
|
+
for (const m of HELP.matchAll(/^\s+volter ([a-z]+)(?: ([a-z][a-z-]*))?/gm)) {
|
|
636
|
+
const [, noun, verb] = m; (tree[noun!] ??= []);
|
|
637
|
+
if (verb && !tree[noun!]!.includes(verb)) tree[noun!]!.push(verb);
|
|
638
|
+
}
|
|
639
|
+
return tree;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** `volter completion bash|zsh|fish|powershell`: a script that completes volter's nouns and verbs in that shell. */
|
|
643
|
+
function completionScript(shell: string | undefined): string {
|
|
644
|
+
const tree = commandTree(); const nouns = Object.keys(tree).join(' ');
|
|
645
|
+
const cases = (fmt: (noun: string, verbs: string) => string): string => Object.entries(tree).filter(([, v]) => v.length).map(([n, v]) => fmt(n, v.join(' '))).join('\n');
|
|
646
|
+
switch (shell) {
|
|
647
|
+
case 'bash': case 'zsh':
|
|
648
|
+
return `${shell === 'zsh' ? 'autoload -U +X bashcompinit && bashcompinit\n' : ''}# volter completion (${shell}): eval "$(volter completion ${shell})"
|
|
649
|
+
_volter() {
|
|
650
|
+
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
651
|
+
if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W "${nouns}" -- "$cur")); return; fi
|
|
652
|
+
if [ "$COMP_CWORD" -eq 2 ]; then
|
|
653
|
+
case "\${COMP_WORDS[1]}" in
|
|
654
|
+
${cases((n, v) => ` ${n}) COMPREPLY=($(compgen -W "${v}" -- "$cur")) ;;`)}
|
|
655
|
+
esac
|
|
656
|
+
fi
|
|
657
|
+
}
|
|
658
|
+
complete -o default -F _volter volter
|
|
659
|
+
`;
|
|
660
|
+
case 'fish':
|
|
661
|
+
return `# volter completion (fish): volter completion fish | source
|
|
662
|
+
complete -c volter -f -n '__fish_use_subcommand' -a '${nouns}'
|
|
663
|
+
${cases((n, v) => `complete -c volter -f -n '__fish_seen_subcommand_from ${n}; and test (count (commandline -opc)) -eq 2' -a '${v}'`)}
|
|
664
|
+
`;
|
|
665
|
+
case 'powershell':
|
|
666
|
+
return `# volter completion (PowerShell): volter completion powershell | Out-String | Invoke-Expression
|
|
667
|
+
Register-ArgumentCompleter -Native -CommandName volter -ScriptBlock {
|
|
668
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
669
|
+
$words = @($commandAst.CommandElements | ForEach-Object { $_.ToString() })
|
|
670
|
+
$tree = @{
|
|
671
|
+
${Object.entries(tree).map(([n, v]) => ` '${n}' = @(${v.map((x) => `'${x}'`).join(', ')})`).join('\n')}
|
|
672
|
+
}
|
|
673
|
+
$choices = if ($words.Count -le 1 -or ($words.Count -eq 2 -and $wordToComplete)) { $tree.Keys } elseif ($tree.ContainsKey($words[1])) { $tree[$words[1]] } else { @() }
|
|
674
|
+
$choices | Where-Object { $_ -like "$wordToComplete*" } | Sort-Object | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
|
|
675
|
+
}
|
|
676
|
+
`;
|
|
677
|
+
default:
|
|
678
|
+
throw new Error('volter completion bash|zsh|fish|powershell');
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** A fresh app has no twins installed, so `init` has nothing to plan with: its vendors are found from what the app
|
|
683
|
+
* depends on and names in its env (the signatures ship with the command), and the twins for them are installed with
|
|
684
|
+
* the app's own package manager: asked at a terminal, or at once with `--install` (a script's or an agent's way). */
|
|
685
|
+
async function installDetectedTwins(app: string, install: boolean): Promise<void> {
|
|
686
|
+
try { if (resolveCatalog(app).vendors().length > 0) return; } catch { /* no twins here yet */ }
|
|
687
|
+
const found = detectRepoVendors(app).detected;
|
|
688
|
+
const vendors = [...found.keys()].filter((v) => /^[a-z0-9][a-z0-9-]*$/.test(v)).sort();
|
|
689
|
+
if (vendors.length === 0) return; // init says what it found and how to add a twin by hand
|
|
690
|
+
const pkgs = vendors.map((v) => `@volter/twin-${v}`);
|
|
691
|
+
const has = (file: string): boolean => existsSync(join(app, file));
|
|
692
|
+
// the app's own package manager, by its lockfile; npm where there is none
|
|
693
|
+
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(' ')}`;
|
|
694
|
+
out(`detected ${vendors.map((v) => `${v} (${(found.get(v) ?? []).slice(0, 2).join(', ')})`).join('; ')}`);
|
|
695
|
+
out(`twins ${pkgs.join(' ')}`);
|
|
696
|
+
let yes = install;
|
|
697
|
+
if (!yes && process.stdin.isTTY && process.stdout.isTTY) {
|
|
698
|
+
process.stdout.write(`Install them now (${command})? [Y/n] `);
|
|
699
|
+
const answer = await new Promise<string>((done) => { process.stdin.once('data', (d) => done(String(d).trim().toLowerCase())); });
|
|
700
|
+
process.stdin.pause();
|
|
701
|
+
yes = answer === '' || answer === 'y' || answer === 'yes';
|
|
702
|
+
}
|
|
703
|
+
if (!yes) throw new Error(`install the twins first: ${command}
|
|
704
|
+
then run volter world init again (or volter world init --install does both)`);
|
|
705
|
+
// one command line built from the checked names above: nothing of anyone's input reaches the shell
|
|
706
|
+
const ran = spawnSync(command, { cwd: app, stdio: 'inherit', shell: true, windowsHide: true });
|
|
707
|
+
if (ran.status !== 0) throw new Error(`${command} failed (exit ${ran.status ?? 'none'})`);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** After `volter world init`: a person at a terminal is asked whether to set up their coding agents; anyone else is
|
|
711
|
+
* told the command. */
|
|
712
|
+
async function offerAgents(): Promise<void> {
|
|
713
|
+
const { detectAgents, installAgents } = await import('./agents.ts');
|
|
714
|
+
const found = detectAgents(process.cwd());
|
|
715
|
+
if (found.length === 0) return;
|
|
716
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) { out('\nagents volter agents install --yes (the volter-world skill and MCP server for your coding agents)'); return; }
|
|
717
|
+
process.stdout.write(`\nSet up your coding agent${found.length === 1 ? '' : 's'} (${found.join(', ')}) to drive this World? [y/N] `);
|
|
718
|
+
const answer = await new Promise<string>((done) => { process.stdin.once('data', (d) => done(String(d).trim().toLowerCase())); });
|
|
719
|
+
process.stdin.pause();
|
|
720
|
+
if (answer === 'y' || answer === 'yes') for (const line of installAgents(found, process.cwd())) out(` ${line}`);
|
|
721
|
+
else out('later: volter agents install');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** This package's version, for the MCP server's handshake. */
|
|
725
|
+
function ownVersion(): string {
|
|
726
|
+
try { return (JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')) as { version?: string }).version ?? '0.0.0'; } catch { return '0.0.0'; }
|
|
727
|
+
}
|
|
728
|
+
|
|
459
729
|
async function main(): Promise<void> {
|
|
460
730
|
const [noun, verb, ...args] = process.argv.slice(2);
|
|
731
|
+
const notice = startUpdateCheck(process.argv.slice(2));
|
|
732
|
+
await dispatch(noun, verb, args);
|
|
733
|
+
await notice();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function dispatch(noun: string | undefined, verb: string | undefined, args: string[]): Promise<void> {
|
|
461
737
|
if (!noun || noun === '--help' || noun === '-h' || noun === 'help') { out(HELP); return; }
|
|
462
738
|
if (verb === '--help' || verb === '-h') { out(HELP); return; }
|
|
463
739
|
switch (noun) {
|
|
@@ -467,14 +743,22 @@ async function main(): Promise<void> {
|
|
|
467
743
|
return;
|
|
468
744
|
}
|
|
469
745
|
case 'login': await loginCommand([verb, ...args].filter((a): a is string => a !== undefined)); return;
|
|
746
|
+
case 'logout': await logoutCommand(); return;
|
|
747
|
+
case 'whoami': await whoamiCommand(); return;
|
|
470
748
|
case 'twin': await twinCommand([verb, ...args].filter((a): a is string => a !== undefined)); return;
|
|
749
|
+
case 'mcp': { const { serveMcp } = await import('./mcp.ts'); await serveMcp(ownVersion()); return; }
|
|
750
|
+
case 'agents': { const { agentsCommand } = await import('./agents.ts'); await agentsCommand(verb, args, out); return; }
|
|
471
751
|
case 'remote': {
|
|
472
752
|
if (!verb) throw new Error('volter remote: which verb? add | remove | list | serve');
|
|
473
753
|
await remoteCommand(verb, args);
|
|
474
754
|
return;
|
|
475
755
|
}
|
|
756
|
+
case 'open': await openCommand([verb, ...args].filter((a): a is string => a !== undefined)); return;
|
|
757
|
+
case 'completion': process.stdout.write(completionScript(verb)); return;
|
|
476
758
|
default:
|
|
477
|
-
throw new Error(`volter: unknown noun "${noun}" — world, twin or
|
|
759
|
+
throw new Error(`volter: unknown noun "${noun}" — world, twin, remote, open, completion, agents or mcp
|
|
760
|
+
|
|
761
|
+
${HELP}`);
|
|
478
762
|
}
|
|
479
763
|
}
|
|
480
764
|
|