@volter/world 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +427 -30
- 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 +22 -6
- package/dist/src/world.js +68 -20
- package/package.json +25 -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 +314 -28
- 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 +63 -19
|
@@ -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
|
+
}
|
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
|
+
}
|