@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/dist/src/mcp.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// `volter mcp` (docs/contributing/architecture.md, "The way in is the app's folder"): an MCP server over stdio whose tools
|
|
2
|
+
// are the `volter` command's own verbs. A tool that reads (status, log, diff, branches) asks the SDK; a tool that acts runs
|
|
3
|
+
// the command itself, as a child, so there is one implementation of each verb and an agent gets what a person would.
|
|
4
|
+
// Locally a World is reached with its own token (the command finds it); the platform with the person's token from
|
|
5
|
+
// `volter login`, which only the person can approve, in their browser.
|
|
6
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { World } from "./world.js";
|
|
13
|
+
import { agentPrompt } from "./agent-prompt.js";
|
|
14
|
+
/** This command's own entry, beside this module (src/cli.ts in a checkout, dist/src/cli.js when published). */
|
|
15
|
+
const CLI = fileURLToPath(new URL(`./cli${import.meta.url.endsWith('.ts') ? '.ts' : '.js'}`, import.meta.url));
|
|
16
|
+
const MAX_OUTPUT = 20_000;
|
|
17
|
+
/** Run `volter <args>` in `dir` to its end (or its timeout), its stdout and stderr together, the tail kept. */
|
|
18
|
+
function volter(args, dir, timeoutSeconds = 600) {
|
|
19
|
+
return new Promise((done) => {
|
|
20
|
+
const child = spawn(process.execPath, [CLI, ...args], { cwd: dir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
21
|
+
let output = '';
|
|
22
|
+
const take = (d) => { output += d.toString(); if (output.length > MAX_OUTPUT * 2)
|
|
23
|
+
output = output.slice(-MAX_OUTPUT); };
|
|
24
|
+
child.stdout.on('data', take);
|
|
25
|
+
child.stderr.on('data', take);
|
|
26
|
+
const timer = setTimeout(() => { output += `\n(stopped after ${timeoutSeconds}s)`; child.kill(); }, timeoutSeconds * 1000);
|
|
27
|
+
child.on('error', (e) => { clearTimeout(timer); done({ code: 1, output: `${output}${e.message}` }); });
|
|
28
|
+
child.on('close', (code) => { clearTimeout(timer); done({ code: code ?? 1, output: output.length > MAX_OUTPUT ? `…${output.slice(-MAX_OUTPUT)}` : output }); });
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** A command that keeps running (the World's view, a sign-in waiting for approval): started once, answered as soon as
|
|
32
|
+
* its first lines say what the agent needs, left running for as long as this server runs. */
|
|
33
|
+
const background = new Map();
|
|
34
|
+
function startBackground(key, args, dir, ready, seconds = 60) {
|
|
35
|
+
background.get(key)?.kill();
|
|
36
|
+
background.delete(key);
|
|
37
|
+
return new Promise((done) => {
|
|
38
|
+
const child = spawn(process.execPath, [CLI, ...args], { cwd: dir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
39
|
+
background.set(key, child);
|
|
40
|
+
let output = '';
|
|
41
|
+
let answered = false;
|
|
42
|
+
const answer = (code) => { if (!answered) {
|
|
43
|
+
answered = true;
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
done({ code, output });
|
|
46
|
+
} };
|
|
47
|
+
const take = (d) => { output += d.toString(); if (ready.test(output))
|
|
48
|
+
answer(0); };
|
|
49
|
+
child.stdout.on('data', take);
|
|
50
|
+
child.stderr.on('data', take);
|
|
51
|
+
child.on('error', (e) => { output += e.message; answer(1); });
|
|
52
|
+
child.on('close', (code) => { background.delete(key); answer(code ?? 1); });
|
|
53
|
+
const timer = setTimeout(() => answer(0), seconds * 1000);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Everything this server left running, ended: a World under a dashboard is brought down through its own command first
|
|
57
|
+
* (its state kept, its teardown recorded), then the command itself. */
|
|
58
|
+
function stopAll() {
|
|
59
|
+
for (const [key, child] of background) {
|
|
60
|
+
if (key.startsWith('view:'))
|
|
61
|
+
spawnSync(process.execPath, [CLI, 'world', 'down'], { cwd: key.slice('view:'.length), windowsHide: true, stdio: 'ignore', timeout: 60_000 });
|
|
62
|
+
child.kill();
|
|
63
|
+
}
|
|
64
|
+
background.clear();
|
|
65
|
+
}
|
|
66
|
+
for (const signal of ['SIGINT', 'SIGTERM'])
|
|
67
|
+
process.on(signal, () => { stopAll(); process.exit(0); });
|
|
68
|
+
process.on('exit', stopAll);
|
|
69
|
+
const text = (s, isError = false) => ({ content: [{ type: 'text', text: s.trim() || '(no output)' }], ...(isError ? { isError: true } : {}) });
|
|
70
|
+
const ran = (r) => text(r.output, r.code !== 0);
|
|
71
|
+
const asJson = (v) => text(JSON.stringify(v, null, 2));
|
|
72
|
+
const fail = (e) => text(e instanceof Error ? e.message : String(e), true);
|
|
73
|
+
const INSTRUCTIONS = `Volter World runs an app against twins of the SaaS APIs it calls (Stripe, Slack, GitHub, OpenAI and about a hundred more): the app keeps its real SDKs and code, and every write lands in a World that can be branched, reset and looked at on each vendor's own screens.
|
|
74
|
+
|
|
75
|
+
Work in the app's folder (each tool takes "dir", defaulting to where this server was started).
|
|
76
|
+
- Set up: world_init detects the vendors from the app's manifests and env names; show the person the list before keeping it. Then world_up.
|
|
77
|
+
- Run the app or its tests inside the World with world_run (never bare against the real vendors).
|
|
78
|
+
- Look: world_log and world_diff say what the app changed at each vendor; world_view starts the dashboard and returns its link for the person.
|
|
79
|
+
- Branch and reset like a database: world_branch, world_checkout, world_reset.
|
|
80
|
+
- Share with a team: platform_login returns a link and a code the PERSON must approve in their browser (you cannot approve it); after they do, remote_link links this World to <org>/<world> on the platform (create: true makes it there from this World's vendors); world_changeset cuts the changes with a message, and world_push sends them.
|
|
81
|
+
The guide: https://github.com/volter-ai/twin/blob/main/docs/guides/use-with-a-coding-agent.md`;
|
|
82
|
+
export async function serveMcp(version) {
|
|
83
|
+
const server = new McpServer({ name: 'volter', title: 'Volter World', version }, { instructions: INSTRUCTIONS });
|
|
84
|
+
const here = (dir) => resolve(dir ?? process.cwd());
|
|
85
|
+
const dirArg = { dir: z.string().optional().describe("The app's folder (the World's root). Defaults to where this server was started.") };
|
|
86
|
+
server.registerTool('world_status', {
|
|
87
|
+
title: 'World status', description: 'The World of the app folder: its branch, whether it is running, each twin\'s URL, its origin (the remote it pushes to) and unpushed changes.',
|
|
88
|
+
inputSchema: dirArg, annotations: { readOnlyHint: true },
|
|
89
|
+
}, async ({ dir }) => { try {
|
|
90
|
+
return asJson(World.open({ root: here(dir) }).status());
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
return fail(e);
|
|
94
|
+
} });
|
|
95
|
+
server.registerTool('world_init', {
|
|
96
|
+
title: 'Set up a World for the app', description: "Detect the vendors the app calls (from its package manifests and env var names), install their twins when none are installed yet, and write .volter/world.json beside the code. Show the person the detected vendors; pass allowUnknown to keep going when some have no twin yet.",
|
|
97
|
+
inputSchema: { ...dirArg, name: z.string().optional().describe('The World\'s name (defaults to the folder\'s).'), allowUnknown: z.boolean().optional(), force: z.boolean().optional().describe('Overwrite an existing .volter/world.json.') },
|
|
98
|
+
}, async ({ dir, name, allowUnknown, force }) => ran(await volter(['world', 'init', '--install', ...(name ? ['--name', name] : []), ...(allowUnknown ? ['--allow-unknown'] : []), ...(force ? ['--force'] : [])], here(dir))));
|
|
99
|
+
server.registerTool('world_up', {
|
|
100
|
+
title: 'Start the World', description: 'Start the twins on the current branch. A fresh branch loads its default data (the story); a stopped one resumes where it was.',
|
|
101
|
+
inputSchema: { ...dirArg, seed: z.boolean().optional().describe('Load the default data on a fresh branch (default true).') },
|
|
102
|
+
}, async ({ dir, seed }) => ran(await volter(['world', 'up', ...(seed === false ? ['--no-seed'] : [])], here(dir))));
|
|
103
|
+
server.registerTool('world_run', {
|
|
104
|
+
title: 'Run a command in the World', description: "Run the app or its tests inside the World: the vendors' hosts go to their twins, so the real SDKs work unchanged. Returns the command's exit code and the tail of its output.",
|
|
105
|
+
inputSchema: { ...dirArg, command: z.array(z.string()).min(1).describe('The command and its arguments, e.g. ["npm", "test"].'), timeoutSeconds: z.number().int().positive().max(3600).optional() },
|
|
106
|
+
}, async ({ dir, command, timeoutSeconds }) => { const r = await volter(['world', 'run', '--', ...command], here(dir), timeoutSeconds ?? 600); return text(`exit ${r.code}\n${r.output}`, r.code !== 0); });
|
|
107
|
+
server.registerTool('world_down', {
|
|
108
|
+
title: 'Stop the World', description: 'Stop the twins. Their state is kept for the next world_up unless purge is true.',
|
|
109
|
+
inputSchema: { ...dirArg, purge: z.boolean().optional() }, annotations: { destructiveHint: true },
|
|
110
|
+
}, async ({ dir, purge }) => ran(await volter(['world', 'down', ...(purge ? ['--purge'] : [])], here(dir))));
|
|
111
|
+
server.registerTool('world_view', {
|
|
112
|
+
title: 'Open the dashboard', description: "Run the World with its dashboard on this machine (each vendor's own screens, the calls, the changes, the branches) and return its link for the person. The dashboard runs the World itself: a World already started with world_up is stopped first (its state is kept) and comes back under the dashboard. It keeps running while this server does; world_run works against it meanwhile.",
|
|
113
|
+
inputSchema: dirArg,
|
|
114
|
+
}, async ({ dir }) => {
|
|
115
|
+
const root = here(dir);
|
|
116
|
+
let before = '';
|
|
117
|
+
try {
|
|
118
|
+
const s = World.open({ root }).status();
|
|
119
|
+
if (s.running && !s.served) {
|
|
120
|
+
const down = await volter(['world', 'down'], root);
|
|
121
|
+
if (down.code !== 0)
|
|
122
|
+
return ran(down);
|
|
123
|
+
before = `${down.output.trim()} (state kept; the dashboard runs it now)\n`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
return fail(e);
|
|
128
|
+
}
|
|
129
|
+
const r = await startBackground(`view:${root}`, ['world', 'view', '--no-open'], root, /console\s+\S+/);
|
|
130
|
+
return text(`${before}${r.output}`, r.code !== 0);
|
|
131
|
+
});
|
|
132
|
+
server.registerTool('world_log', {
|
|
133
|
+
title: 'What the app changed', description: 'Every write the app made in this World, newest last: vendor, operation and subject.',
|
|
134
|
+
inputSchema: { ...dirArg, limit: z.number().int().positive().max(1000).optional().describe('The newest this many (default 50).') }, annotations: { readOnlyHint: true },
|
|
135
|
+
}, async ({ dir, limit }) => { try {
|
|
136
|
+
const rows = World.open({ root: here(dir) }).log();
|
|
137
|
+
return asJson(rows.slice(-(limit ?? 50)).map((r) => ({ vendor: r.service, operation: r.operation ?? r.op, subject: r.subject, ...(r.receipt ? { receipt: r.receipt } : {}) })));
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
return fail(e);
|
|
141
|
+
} });
|
|
142
|
+
server.registerTool('world_diff', {
|
|
143
|
+
title: 'Changes since the default data', description: 'How many changes each vendor has since the default data (or the last mark).',
|
|
144
|
+
inputSchema: dirArg, annotations: { readOnlyHint: true },
|
|
145
|
+
}, async ({ dir }) => { try {
|
|
146
|
+
const d = World.open({ root: here(dir) }).diff();
|
|
147
|
+
return asJson({ base: d.base, vendors: d.vendors });
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
return fail(e);
|
|
151
|
+
} });
|
|
152
|
+
server.registerTool('world_branch', {
|
|
153
|
+
title: 'List or make branches', description: 'Without a name, the branches (the checked-out one first). With a name, make a branch from here and check it out, like a database branch: try something, then check out the old one or reset.',
|
|
154
|
+
inputSchema: { ...dirArg, name: z.string().optional() },
|
|
155
|
+
}, async ({ dir, name }) => {
|
|
156
|
+
if (name)
|
|
157
|
+
return ran(await volter(['world', 'branch', name], here(dir)));
|
|
158
|
+
try {
|
|
159
|
+
const w = World.open({ root: here(dir) });
|
|
160
|
+
return asJson({ current: w.name, branches: w.branches() });
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
return fail(e);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
server.registerTool('world_checkout', {
|
|
167
|
+
title: 'Switch branch', description: 'Check out another branch of the World.',
|
|
168
|
+
inputSchema: { ...dirArg, name: z.string() },
|
|
169
|
+
}, async ({ dir, name }) => ran(await volter(['world', 'checkout', name], here(dir))));
|
|
170
|
+
server.registerTool('world_reset', {
|
|
171
|
+
title: 'Back to the default data', description: "Discard the current branch's changes and load its default data again.",
|
|
172
|
+
inputSchema: dirArg, annotations: { destructiveHint: true },
|
|
173
|
+
}, async ({ dir }) => ran(await volter(['world', 'reset'], here(dir))));
|
|
174
|
+
server.registerTool('platform_login', {
|
|
175
|
+
title: 'Sign in to a platform', description: 'Start signing this machine\'s `volter` command in to a Volter World platform. Returns a link and a code: give both to the PERSON, who approves it in their browser (you cannot). The sign-in completes by itself once they do; check with platform_whoami.',
|
|
176
|
+
inputSchema: { url: z.string().url().describe("The platform's address (https://…), as the person gave it") },
|
|
177
|
+
}, async ({ url }) => ran(await startBackground('login', ['login', url, '--no-open'], process.cwd(), /code\s+[A-Z]{4}-[A-Z]{4}/, 30)));
|
|
178
|
+
server.registerTool('platform_whoami', {
|
|
179
|
+
title: 'Who the command is signed in as', description: 'The platform this machine\'s `volter` command is signed in to, as whom, and their orgs.',
|
|
180
|
+
inputSchema: {}, annotations: { readOnlyHint: true },
|
|
181
|
+
}, async () => ran(await volter(['whoami'], process.cwd())));
|
|
182
|
+
server.registerTool('remote_link', {
|
|
183
|
+
title: 'Link the World to the platform', description: "Link this World to <org>/<world> on the platform the command is signed in to, as the remote it pushes to. create: true makes that World on the platform from this World's vendors when it does not exist.",
|
|
184
|
+
inputSchema: { ...dirArg, target: z.string().regex(/^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/).describe('<org>/<world>'), name: z.string().optional().describe('The remote\'s name (default origin).'), create: z.boolean().optional() },
|
|
185
|
+
}, async ({ dir, target, name, create }) => ran(await volter(['remote', 'add', name ?? 'origin', target, ...(create ? ['--create'] : [])], here(dir))));
|
|
186
|
+
server.registerTool('world_changeset', {
|
|
187
|
+
title: 'Cut a changeset', description: "Cut the World's unpushed changes into a reviewable changeset, with a message saying what and why; world_push sends it.",
|
|
188
|
+
inputSchema: { ...dirArg, message: z.string().min(1).describe('What the changes are and why.'), name: z.string().optional() },
|
|
189
|
+
}, async ({ dir, message, name }) => ran(await volter(['world', 'changeset', ...(name ? [name] : []), '-m', message], here(dir))));
|
|
190
|
+
server.registerTool('world_push', {
|
|
191
|
+
title: 'Push to the remote', description: "Send this World's unpushed changes to its remote (the platform's World after remote_link); the remote answers with a receipt for each change.",
|
|
192
|
+
inputSchema: { ...dirArg, changeset: z.string().optional().describe('Push one changeset by name; default all unpushed.') },
|
|
193
|
+
}, async ({ dir, changeset }) => ran(await volter(['world', 'push', ...(changeset ? [changeset] : [])], here(dir))));
|
|
194
|
+
server.registerPrompt('get-started', {
|
|
195
|
+
title: 'Set up Volter World for this app', description: 'Walks through setting up a World for the app in this folder, running its tests in it, and optionally sharing it on a platform.',
|
|
196
|
+
argsSchema: { platform: z.string().optional().describe("A platform to share the World on: its address"), world: z.string().optional().describe('<org>/<world> on that platform') },
|
|
197
|
+
}, ({ platform, world }) => ({ messages: [{ role: 'user', content: { type: 'text', text: agentPrompt({ ...(platform ? { platform } : {}), ...(world ? { world } : {}), tools: true }) } }] }));
|
|
198
|
+
// the client closing the connection (its stdin ends) ends this server, and the commands it left running with it
|
|
199
|
+
const stop = () => { stopAll(); process.exit(0); };
|
|
200
|
+
server.server.onclose = stop;
|
|
201
|
+
process.stdin.on('end', stop);
|
|
202
|
+
await server.connect(new StdioServerTransport());
|
|
203
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** This package's version, read from its own package.json. */
|
|
2
|
+
export declare function currentVersion(): string;
|
|
3
|
+
/** Whether `a` is a newer release than `b` (major.minor.patch; a prerelease is not offered). */
|
|
4
|
+
export declare function newer(a: string, b: string): boolean;
|
|
5
|
+
/** Start the day's check if one is due, while the command runs; the returned function, called when the command is
|
|
6
|
+
* done, waits a moment at most for it and prints the notice when there is one. Off, it does nothing and asks nothing. */
|
|
7
|
+
export declare function startUpdateCheck(args: string[], env?: NodeJS.ProcessEnv): () => Promise<void>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The update notice (docs/contributing/architecture.md, "The way in is the app's folder"): at most once a day the command
|
|
2
|
+
// asks the npm registry for @volter/world's latest version, while it does its own work, and says in one line on stderr
|
|
3
|
+
// when a newer one exists. Never in CI, never for --json, never when the person opted out (VOLTER_NO_UPDATE_NOTIFIER=1):
|
|
4
|
+
// then nothing is asked at all. The answer is kept beside the credentials, so a day's commands ask once.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { credentialsPath } from "./credentials.js";
|
|
8
|
+
const DAY = 86_400_000;
|
|
9
|
+
const REGISTRY = 'https://registry.npmjs.org/@volter/world/latest';
|
|
10
|
+
/** This package's version, read from its own package.json. */
|
|
11
|
+
export function currentVersion() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version ?? '0.0.0';
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return '0.0.0';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Whether `a` is a newer release than `b` (major.minor.patch; a prerelease is not offered). */
|
|
20
|
+
export function newer(a, b) {
|
|
21
|
+
const parse = (v) => (/^\d+\.\d+\.\d+$/.test(v) ? v.split('.').map(Number) : null);
|
|
22
|
+
const x = parse(a);
|
|
23
|
+
const y = parse(b);
|
|
24
|
+
if (!x || !y)
|
|
25
|
+
return false;
|
|
26
|
+
for (let i = 0; i < 3; i++)
|
|
27
|
+
if (x[i] !== y[i])
|
|
28
|
+
return x[i] > y[i];
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
/** Start the day's check if one is due, while the command runs; the returned function, called when the command is
|
|
32
|
+
* done, waits a moment at most for it and prints the notice when there is one. Off, it does nothing and asks nothing. */
|
|
33
|
+
export function startUpdateCheck(args, env = process.env) {
|
|
34
|
+
if (env.VOLTER_NO_UPDATE_NOTIFIER || env.CI || args.includes('--json') || !process.stderr.isTTY)
|
|
35
|
+
return async () => undefined;
|
|
36
|
+
const path = join(dirname(credentialsPath()), 'update-check.json');
|
|
37
|
+
let kept = null;
|
|
38
|
+
try {
|
|
39
|
+
if (existsSync(path))
|
|
40
|
+
kept = JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
kept = null;
|
|
44
|
+
}
|
|
45
|
+
const due = !kept || Date.now() - Date.parse(kept.checkedAt) > DAY;
|
|
46
|
+
const check = due
|
|
47
|
+
? fetch(REGISTRY, { signal: AbortSignal.timeout(1500) }).then(async (r) => (r.ok ? (await r.json()).version ?? null : null)).catch(() => null)
|
|
48
|
+
.then((latest) => { try {
|
|
49
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
50
|
+
writeFileSync(path, `${JSON.stringify({ checkedAt: new Date().toISOString(), latest })}\n`);
|
|
51
|
+
}
|
|
52
|
+
catch { /* a read-only home only loses the cache */ } return latest; })
|
|
53
|
+
: Promise.resolve(kept?.latest ?? null);
|
|
54
|
+
return async () => {
|
|
55
|
+
const latest = await Promise.race([check, new Promise((r) => setTimeout(() => r(null), 800))]);
|
|
56
|
+
const here = currentVersion();
|
|
57
|
+
if (latest && newer(latest, here))
|
|
58
|
+
process.stderr.write(`\nvolter ${latest} is out (you have ${here}): npm install -g @volter/world\n`);
|
|
59
|
+
};
|
|
60
|
+
}
|
package/dist/src/world.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { rebaseChangeset, worldBootMarker, type Changeset, type LedgerDelta, type TwinAction, type WorldMarker, type ApplyReceipt, applyTwinWrite, type TwinResource } from '@volter/world-core';
|
|
2
|
-
import { approveWorldChangeset, createWorldChangeset, downWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, seedWorld, statusWorldChangeset, verifyWorldChangeset, type ServedWorld, type DeployTwinOutcome, type ChangesetLocation, type InitOptions, type InitResult, type WorldInstance } from '@volter/world-runtime';
|
|
2
|
+
import { approveWorldChangeset, createWorldChangeset, downWorld, fetchFromOrigin, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, seedWorld, statusWorldChangeset, verifyWorldChangeset, type ServedWorld, type ViewConsole, type WorldView, type DeployTwinOutcome, type ChangesetLocation, type InitOptions, type InitResult, type WorldInstance } from '@volter/world-runtime';
|
|
3
3
|
import { rebaseBranch, type Receipt, type RootConfig } from '@volter/world-core';
|
|
4
4
|
export type WorldRef = {
|
|
5
5
|
name?: string;
|
|
@@ -198,6 +198,16 @@ export declare class World {
|
|
|
198
198
|
console: string | null;
|
|
199
199
|
}) => void;
|
|
200
200
|
}): Promise<ServedWorld>;
|
|
201
|
+
/** `volter world view`: this World, its mirrors, its branches and the console on one origin (docs/contributing/architecture.md, "Viewing a World"). */
|
|
202
|
+
view(opts?: {
|
|
203
|
+
port?: number;
|
|
204
|
+
host?: string;
|
|
205
|
+
console?: ViewConsole;
|
|
206
|
+
announce?: (line: string) => void;
|
|
207
|
+
origins?: boolean;
|
|
208
|
+
}): Promise<WorldView>;
|
|
209
|
+
/** The vendors this world has a twin of, as world.json names them (a twin service's package, else its id). */
|
|
210
|
+
vendors(): string[];
|
|
201
211
|
/** The remotes named in world.json (`volter remote add`), name → url or path. */
|
|
202
212
|
remotes(): Record<string, string>;
|
|
203
213
|
/** Name a remote; `origin` is the one fetch and push use by default. A token given is remembered for it. */
|
package/dist/src/world.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { rebaseChangeset, worldBootMarker, listActions, isTwinBookkeeping, pushablePendingActions, pendingActions, applyTwinWrite, twinResources } from '@volter/world-core';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import clockForm from '@volter/world-core/world-clock';
|
|
8
|
-
import { activateScript, approveWorldChangeset, branchWorld, clockFile, checkoutWorld, createWorldChangeset, diffWorld, downWorld, fetchFromOrigin, findWorldChangeset, initWorld, listWorldChangesets, listWorldMarks, listWorlds, markWorld, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, runWithWorldEnv, seedWorld, shellWorld, statusWorld, statusWorldChangeset, upWorld, verifyWorldChangeset, worldLedgers, worldOrigin, deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, findInstalledPackage, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom, materializeRoots } from '@volter/world-runtime';
|
|
8
|
+
import { activateScript, approveWorldChangeset, branchWorld, clockFile, checkoutWorld, createWorldChangeset, diffWorld, downWorld, fetchFromOrigin, findWorldChangeset, initWorld, listWorldChangesets, listWorldMarks, listWorlds, markWorld, pushWorldChangeset, rebaseWorldChangeset, replayWorldChangeset, resetWorld, runWithWorldEnv, seedWorld, shellWorld, statusWorld, statusWorldChangeset, upWorld, verifyWorldChangeset, worldLedgers, worldOrigin, deployWorld, loadWorldConfig, writeWorldConfig, readServeRecord, refreshTwin, serveWorld, serveWorldView, findInstalledPackage, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, credentialPayloadFrom, materializeRoots } from '@volter/world-runtime';
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { dirname, join, resolve } from 'node:path';
|
|
11
11
|
import { requireToken, storeToken } from "./credentials.js";
|
|
@@ -94,7 +94,7 @@ export class World {
|
|
|
94
94
|
if (opts.install !== false && !twins.every((t) => findInstalledPackage(dir, `@volter/twin-${t}`))) {
|
|
95
95
|
// the runtime's own installer: bun under Bun, npm under Node — never assumed
|
|
96
96
|
const cmd = typeof Bun !== 'undefined' ? ['bun', 'add', '-d'] : ['npm', 'install', '--save-dev'];
|
|
97
|
-
const r = spawnSync(cmd[0], [...cmd.slice(1), ...twins.map((t) => `@volter/twin-${t}`)], { cwd: dir, stdio: 'inherit' });
|
|
97
|
+
const r = spawnSync(cmd[0], [...cmd.slice(1), ...twins.map((t) => `@volter/twin-${t}`)], { windowsHide: true, cwd: dir, stdio: 'inherit' });
|
|
98
98
|
if (r.status !== 0)
|
|
99
99
|
throw new Error(`installing the twins failed (${cmd.join(' ')} exited ${r.status})`);
|
|
100
100
|
}
|
|
@@ -357,7 +357,14 @@ export class World {
|
|
|
357
357
|
// ── serving ─────────────────────────────────────────────────────────────────────────────
|
|
358
358
|
/** Serve this world on a port under `/<org>/<world>/`; returns when listening. */
|
|
359
359
|
serve(opts = {}) { return serveWorld(this.name, { root: this.root, ...opts }); }
|
|
360
|
+
/** `volter world view`: this World, its mirrors, its branches and the console on one origin (docs/contributing/architecture.md, "Viewing a World"). */
|
|
361
|
+
view(opts = {}) { return serveWorldView(this.name, { root: this.root, ...opts }); }
|
|
360
362
|
// ── remotes ─────────────────────────────────────────────────────────────────────────────
|
|
363
|
+
/** The vendors this world has a twin of, as world.json names them (a twin service's package, else its id). */
|
|
364
|
+
vendors() {
|
|
365
|
+
const { config } = loadWorldConfig(this.configRef(), this.root);
|
|
366
|
+
return [...new Set(config.services.filter((s) => (s.type ?? 'twin') === 'twin').map((s) => /^@volter\/twin-(.+)$/.exec(s.package ?? '')?.[1] ?? s.id))].sort();
|
|
367
|
+
}
|
|
361
368
|
/** The remotes named in world.json (`volter remote add`), name → url or path. */
|
|
362
369
|
remotes() { return { ...(loadWorldConfig(this.configRef(), this.root).config.remotes ?? {}) }; }
|
|
363
370
|
/** Name a remote; `origin` is the one fetch and push use by default. A token given is remembered for it. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Run your app against a world of twins. `World` and `Repo` — one method per verb: init, up, run, log, diff, reset, branch, checkout, clone, fetch, changeset, push — and the `volter` command, one client of them.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"volter",
|
|
@@ -25,18 +25,32 @@
|
|
|
25
25
|
"main": "dist/src/index.js",
|
|
26
26
|
"files": [
|
|
27
27
|
"src",
|
|
28
|
-
"dist"
|
|
28
|
+
"dist",
|
|
29
|
+
"skills"
|
|
29
30
|
],
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"@
|
|
32
|
-
"@volter/world-
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
33
|
+
"@volter/world-core": "2.0.2",
|
|
34
|
+
"@volter/world-runtime": "2.0.2",
|
|
35
|
+
"zod": "^4.6.5"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@volter/world-console": "2.0.2"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@volter/world-console": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@volter/world-console": "2.0.2"
|
|
33
47
|
},
|
|
34
48
|
"bin": {
|
|
35
49
|
"volter": "dist/src/cli.js"
|
|
36
50
|
},
|
|
37
51
|
"scripts": {
|
|
38
52
|
"build": "node ../../scripts/publish/build.mjs",
|
|
39
|
-
"prepack": "node ../../scripts/publish/prepare-publish.mjs prepack",
|
|
40
|
-
"postpack": "node ../../scripts/publish/prepare-publish.mjs postpack"
|
|
53
|
+
"prepack": "node ./scripts/ship-skill.mjs in && node ../../scripts/publish/prepare-publish.mjs prepack",
|
|
54
|
+
"postpack": "node ../../scripts/publish/prepare-publish.mjs postpack && node ./scripts/ship-skill.mjs out"
|
|
41
55
|
}
|
|
42
56
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# volter-world — operating a World
|
|
2
|
+
|
|
3
|
+
A World contains only the vendors deliberately selected for substitution in the current workflow.
|
|
4
|
+
Review detected vendors before including them. The app uses its real SDKs unchanged;
|
|
5
|
+
environment injection, a Node preload and a scoped HTTPS proxy route configured vendors to twins.
|
|
6
|
+
|
|
7
|
+
The state model is **Neon for every SaaS**: a log, checkpoints and branches over a parent position.
|
|
8
|
+
Git-inspired verbs describe operations; there is no staging or commit step. Push moves entries
|
|
9
|
+
between Worlds. Deploy performs them against a vendor at a real-system root under its policy.
|
|
10
|
+
The canonical [model](https://github.com/volter-ai/twin/blob/main/docs/concepts/the-model.md),
|
|
11
|
+
[lifecycle](https://github.com/volter-ai/twin/blob/main/docs/concepts/worlds.md) and
|
|
12
|
+
[CLI reference](https://github.com/volter-ai/twin/blob/main/docs/reference/cli.md) own the details.
|
|
13
|
+
|
|
14
|
+
## Set up a World for an app
|
|
15
|
+
|
|
16
|
+
In the app's folder (the [guide](https://github.com/volter-ai/twin/blob/main/docs/guides/use-with-a-coding-agent.md)):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g @volter/world # once; `volter agents install` then adds this skill and the MCP server to your agents
|
|
20
|
+
volter world init # detects the vendors from the app's manifests and env names; writes .volter/world.json
|
|
21
|
+
volter world up
|
|
22
|
+
volter world run -- npm test # the app's own test command
|
|
23
|
+
volter world log # what the app did at each vendor
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Show the person the vendors `init` detected, and why, before keeping them. Keys the app reads
|
|
27
|
+
(named in `.env.example`) get throwaway values in the World's env; never put real credentials in
|
|
28
|
+
a World to get a test past validation. `volter world view --no-open` runs the World with its
|
|
29
|
+
dashboard and prints its link for the person; it keeps running, so start it in the background,
|
|
30
|
+
and stop a World you started with `up` first (`down` keeps its state).
|
|
31
|
+
|
|
32
|
+
## Share it on a platform
|
|
33
|
+
|
|
34
|
+
A team shares Worlds on a Volter World platform (Volter's cloud, or one the team runs):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
volter login <platform url> # prints a link and a code: the PERSON approves it in their browser
|
|
38
|
+
volter remote add origin <org>/<world> --create # links this World to that one, making it from world.json's vendors
|
|
39
|
+
volter world changeset -m "<what and why>" # the unpushed changes, as one reviewable changeset
|
|
40
|
+
volter world push # sends it; the platform answers with receipts
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
You cannot approve a sign-in; hand the link and code to the person and wait. Ask the person for
|
|
44
|
+
`<org>/<world>` rather than guessing it. Keys for apps and CI are made on the platform.
|
|
45
|
+
|
|
46
|
+
## The MCP tools
|
|
47
|
+
|
|
48
|
+
`volter mcp` serves the same verbs to an agent: `world_init`, `world_up`, `world_run`,
|
|
49
|
+
`world_status`, `world_log`, `world_diff`, `world_view`, `world_branch`, `world_checkout`,
|
|
50
|
+
`world_reset`, `world_down`, `platform_login`, `platform_whoami`, `remote_link`, `world_changeset` and
|
|
51
|
+
`world_push`,
|
|
52
|
+
each taking the app's folder as `dir`. They run the `volter` command itself, so their answers and
|
|
53
|
+
refusals are the command's: act on a refusal's reason rather than working around it.
|
|
54
|
+
|
|
55
|
+
## Run through the World
|
|
56
|
+
|
|
57
|
+
Run apps and tests inside a World, never bare. For a repo with `.volter/world.json`:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
volter world up
|
|
61
|
+
volter world run -- npm test
|
|
62
|
+
volter world down
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`volter world up` resumes a stopped branch's state and seeds a fresh branch.
|
|
66
|
+
`volter world run` runs a command inside that World; it does not own teardown.
|
|
67
|
+
`reset` discards the current branch's state and reloads default data. `down` retains state;
|
|
68
|
+
`down --purge` removes it after verified teardown, unless another local branch references it.
|
|
69
|
+
Fresh boot, reset and prune also refuse to remove a referenced parent; stop compute with
|
|
70
|
+
ordinary `down`, or remove dependent branches first.
|
|
71
|
+
|
|
72
|
+
For existing operator configs, use the explicit name and root:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
volter-world up <config> --root <twins-checkout> --env-file <live-env> --owner <task>
|
|
76
|
+
# From the app repo so the seed's SDK dependencies resolve:
|
|
77
|
+
volter-world attach <world> --root <twins-checkout> -- bun <seed-entry>
|
|
78
|
+
volter-world attach <world> --root <twins-checkout> -- <command...>
|
|
79
|
+
volter-world down <world> --root <twins-checkout>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The lower-level `volter-world up` starts a fresh instance: reseed after each successful boot.
|
|
83
|
+
Do not confuse this with the resumable `volter world up` API. The env file passed to `up` is the
|
|
84
|
+
live env; an init-generated preview is not. Use `url` and `app-url` to discover endpoints rather
|
|
85
|
+
than parsing env files. Registered commands carry `VOLTER_WORLD`.
|
|
86
|
+
|
|
87
|
+
For a task that owns the entire lifecycle, use:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
volter-world run <config> --root <twins-checkout> --env-file <live-env> --owner <task> -- <command...>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
This operator command owns boot, its command and teardown. `--keep` makes it persistent.
|
|
94
|
+
Its cleanup process handles caller loss if it survives; uncertain teardown retains diagnostics
|
|
95
|
+
and lifecycle evidence. Stop registered consumers before explicitly bringing their World down.
|
|
96
|
+
|
|
97
|
+
## State and behavior
|
|
98
|
+
|
|
99
|
+
Create stored data through the vendor's own API using the real SDK pointed at the twin.
|
|
100
|
+
Identity comes from credentials in the vendor's manner; inspect `GET /twin` for the twin's
|
|
101
|
+
identity and time rules. Use `clock set` and `clock advance` for backdated history. Without a
|
|
102
|
+
frozen World clock, the runtime uses wall time.
|
|
103
|
+
|
|
104
|
+
Author judgment, stateless lookups and faults in `handlers/<vendor>.json` in the World config
|
|
105
|
+
directory (typically `.volter/handlers/`). These are ordered, first-match scenario rules.
|
|
106
|
+
Edit the file and restart to load changes; a handler must never fake success for a stored
|
|
107
|
+
mutation. `GET /twin/scenario` shows handler matches and misses; `tail` records misses to author.
|
|
108
|
+
Model twins serve deterministic scripts or labeled stubs, so verify calls and state changes,
|
|
109
|
+
not the quality of prose from an unconfigured model.
|
|
110
|
+
|
|
111
|
+
Local default data is synthetic. A World cloned or refreshed from a real root may contain real
|
|
112
|
+
records; treat its files accordingly. Fake opaque keys are intentional. If an SDK parses a key
|
|
113
|
+
locally, use `fake-env` to produce a structurally valid throwaway credential; never substitute
|
|
114
|
+
real credentials to get a local test past validation.
|
|
115
|
+
|
|
116
|
+
## Capacity and ownership
|
|
117
|
+
|
|
118
|
+
Use `resources` to inspect actual filesystem capacity and registered lifecycle owners/consumers.
|
|
119
|
+
Runtime v3 does not reserve speculative per-World memory or storage. Configure supported limits
|
|
120
|
+
in the execution backend; deprecated `resources` declarations are not enforced limits.
|
|
121
|
+
On a storage failure, inspect the actual destination and backend error. Preview `prune` for
|
|
122
|
+
eligible stopped instance files; it never stops a running World. Inspect ownership before apply.
|
|
123
|
+
Unknown use remains unknown; age never proves safety. Preserve cleanup evidence and parent
|
|
124
|
+
history. Never delete another actor's World, worktree, package cache or rate-budget ledger.
|
|
125
|
+
Migrate an old installation only after verified teardown with its pinned runtime, then pin all
|
|
126
|
+
entrypoints to the new version and resume retained state. Never run two runtime versions over
|
|
127
|
+
one instance or erase legacy ownership records to make startup pass.
|
|
128
|
+
|
|
129
|
+
Worlds own their declared infrastructure. Start, inspect and stop it through World commands;
|
|
130
|
+
never operate its underlying process, container or database separately. Install locked repository
|
|
131
|
+
dependencies from the normal registry/cache; use a registry twin only when registry behavior
|
|
132
|
+
needs substitution. Build workspace packages when needed. Do not delete `node_modules` as a World repair.
|
|
133
|
+
|
|
134
|
+
When booting an app, register its URL as the last step with `app-url --set <url>` (or `--detect`).
|
|
135
|
+
An app declared as a World service supplies its own endpoint record. Consumers read `app-url`.
|
|
136
|
+
An app that answers as its own production hostnames (`app.example.com`) records them with
|
|
137
|
+
`app-url --host <name>`: inside the World they reach the app with their Host, `x-forwarded-host` and
|
|
138
|
+
`x-forwarded-proto` (https when the caller used TLS).
|
|
139
|
+
|
|
140
|
+
## Diagnose before changing the app
|
|
141
|
+
|
|
142
|
+
1. Check whether the failing vendor belongs in this World before seeding or repairing its twin.
|
|
143
|
+
For app/test execution, check `VOLTER_WORLD` and the intended World environment.
|
|
144
|
+
2. Run `doctor`, then `tail --no-follow`, and inspect the twin's `GET /twin` manifest.
|
|
145
|
+
3. Run `covers <world> --repo <app>` for missing vendor coverage.
|
|
146
|
+
4. Determine whether the failure belongs to the app, twin fidelity, scenario, routing or runtime.
|
|
147
|
+
|
|
148
|
+
Sandbox mode refuses untwinned destinations in cooperating clients, including Node sockets a
|
|
149
|
+
client opens around the injector. It does not constrain binaries or non-Node processes that
|
|
150
|
+
ignore the injector/proxy; network isolation needs an enforced boundary.
|
|
151
|
+
Do not disable routing to hide a coverage gap.
|
|
152
|
+
|
|
153
|
+
Current packages are `@volter/world`, `@volter/world-core`, `@volter/world-runtime`,
|
|
154
|
+
`@volter/world-access`, `@volter/world-attach` and `@volter/twin-<vendor>`. Use the CLI reference for commands instead of
|
|
155
|
+
retired `volter-twin`, `plan` or `review` examples.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: volter-world
|
|
3
|
+
description: Set up, run and diagnose an app inside a Volter World (twins of Stripe, Slack, GitHub, OpenAI and the other SaaS APIs it calls), share it on a Volter World platform, with explicit ownership and cleanup. Use when asked to set up Volter World, run an app or its tests against twins, look at what an app did at a vendor, branch or reset that state, or link a World to a team's platform.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Operating a World
|
|
7
|
+
|
|
8
|
+
Read [AGENTS.md](./AGENTS.md) before setting up, running or changing a World. It is the single
|
|
9
|
+
operator instruction source shipped with this skill: setting a World up for an app, linking it
|
|
10
|
+
to a platform, the `volter mcp` tools, and the lifecycle distinction between the product command
|
|
11
|
+
`volter world` and the explicit operator command `volter-world`.
|
|
12
|
+
|
|
13
|
+
The [documentation map](https://github.com/volter-ai/twin/blob/main/docs/README.md) links the
|
|
14
|
+
canonical model, task guides and command reference. Do not maintain a second copy here.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The prompt a person hands their coding agent to set Volter World up for their app (docs/contributing/architecture.md,
|
|
2
|
+
// "The way in is the app's folder"): one text for every place it is offered (the landing page, the platform's Get
|
|
3
|
+
// started, the dashboard's Connect, `volter mcp`'s get-started prompt). Pure, with no imports: browser bundles use it.
|
|
4
|
+
|
|
5
|
+
export type AgentPromptOptions = {
|
|
6
|
+
/** A platform to share the World on: its address. */
|
|
7
|
+
platform?: string;
|
|
8
|
+
/** The World on that platform, `<org>/<world>`; without it, the agent asks. */
|
|
9
|
+
world?: string;
|
|
10
|
+
/** Phrase the steps for an agent that has `volter mcp`'s tools rather than a shell. */
|
|
11
|
+
tools?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** The prompt, as plain text a person copies. */
|
|
15
|
+
export function agentPrompt(o: AgentPromptOptions = {}): string {
|
|
16
|
+
const cmd = (shell: string, tool: string): string => (o.tools ? `${tool} (\`${shell}\`)` : `\`${shell}\``);
|
|
17
|
+
const steps = [
|
|
18
|
+
'Set up Volter World for this app, so its calls to Stripe, Slack, GitHub, OpenAI and the other services it uses go to local twins instead of the real APIs. The app keeps its real SDKs and code.',
|
|
19
|
+
'',
|
|
20
|
+
o.tools
|
|
21
|
+
? '1. You have the Volter World tools (volter mcp). If the `volter` command is missing, install it: `npm install -g @volter/world`.'
|
|
22
|
+
: '1. Install the command and your agent tooling: `npm install -g @volter/world`, then `volter agents install --yes`.',
|
|
23
|
+
`2. In the app's folder, ${cmd('volter world init --install', 'world_init')}: it detects the vendors the app calls and installs their twins. Show me the vendors it detected and why, before keeping them.`,
|
|
24
|
+
`3. ${o.tools ? 'Start it with world_up' : 'Start it: `volter world up`'}, then run the tests inside it: ${cmd('volter world run -- <the test command>', 'world_run')}. Fix anything that fails because a call went to the real service.`,
|
|
25
|
+
`4. Tell me what the app did at each vendor (${cmd('volter world log', 'world_log')}), and give me the dashboard link (${o.tools ? 'world_view' : '`volter world view --no-open`, started in the background: it keeps running'}).`,
|
|
26
|
+
];
|
|
27
|
+
if (o.platform) {
|
|
28
|
+
const target = o.world ?? '<org>/<world>';
|
|
29
|
+
steps.push(
|
|
30
|
+
`5. Share it with my team on ${o.platform}: ${cmd(`volter login ${o.platform}`, 'platform_login')} gives a link and a code for me to approve in my browser. Once I have, link it with ${cmd(`volter remote add origin ${target} --create`, 'remote_link with create: true')}${o.world ? '' : ' (ask me for <org>/<world>)'}, then send what the app did: ${cmd('volter world changeset -m "<what and why>"', 'world_changeset')} and ${cmd('volter world push', 'world_push')}.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
steps.push('', 'The guide: https://github.com/volter-ai/twin/blob/main/docs/guides/use-with-a-coding-agent.md');
|
|
34
|
+
return steps.join('\n');
|
|
35
|
+
}
|