@vellumai/cli 0.5.4 → 0.5.6

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/AGENTS.md CHANGED
@@ -40,3 +40,15 @@ Every command must have high-quality `--help` output. Follow the same standards
40
40
  3. **Write for machines**: Be precise about formats, constraints, and side effects.
41
41
  AI agents parse help text to decide which command to run and how. Avoid vague
42
42
  language — say exactly what the command does and where state is stored.
43
+
44
+ ## Docker Volume Management
45
+
46
+ The CLI creates and manages Docker volumes for containerized instances. See the root `AGENTS.md` § Docker Volume Architecture for the full volume layout.
47
+
48
+ **Volume creation** (`hatch`): Creates four volumes per instance — workspace, gateway-security, ces-security, and socket. The legacy data volume is no longer created.
49
+
50
+ **Volume migration** (`wake`/`hatch`): On startup, existing instances that still have a legacy data volume are migrated. `migrateGatewaySecurityFiles()` and `migrateCesSecurityFiles()` in `lib/docker.ts` copy security files from the data volume to their respective security volumes. Migrations are idempotent and non-fatal.
51
+
52
+ **Volume cleanup** (`retire`): All volumes (including the legacy data volume if it exists) are removed when an instance is retired.
53
+
54
+ **Volume mount rules**: Each service container receives only the volumes it needs. The assistant never mounts `gateway-security` or `ces-security`. The gateway never mounts `ces-security`. The CES mounts the workspace volume as read-only.
package/knip.json CHANGED
@@ -2,7 +2,8 @@
2
2
  "entry": [
3
3
  "src/**/*.test.ts",
4
4
  "src/**/__tests__/**/*.ts",
5
- "src/adapters/openclaw-http-server.ts"
5
+ "src/adapters/openclaw-http-server.ts",
6
+ "src/lib/version-compat.ts"
6
7
  ],
7
8
  "project": ["src/**/*.ts", "src/**/*.tsx"]
8
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -238,6 +238,7 @@ describe("migrateLegacyEntry", () => {
238
238
  expect(resources.daemonPort).toBe(7821);
239
239
  expect(resources.gatewayPort).toBe(7830);
240
240
  expect(resources.qdrantPort).toBe(6333);
241
+ expect(resources.cesPort).toBe(8090);
241
242
  expect(resources.pidFile).toContain("vellum.pid");
242
243
  });
243
244
 
@@ -310,6 +311,7 @@ describe("migrateLegacyEntry", () => {
310
311
  expect(resources.daemonPort).toBe(7821);
311
312
  expect(resources.gatewayPort).toBe(7830);
312
313
  expect(resources.qdrantPort).toBe(6333);
314
+ expect(resources.cesPort).toBe(8090);
313
315
  expect(resources.pidFile).toBe("/custom/path/.vellum/vellum.pid");
314
316
  });
315
317
 
@@ -328,6 +330,7 @@ describe("migrateLegacyEntry", () => {
328
330
  daemonPort: 8000,
329
331
  gatewayPort: 8001,
330
332
  qdrantPort: 8002,
333
+ cesPort: 8003,
331
334
  pidFile: "/my/path/.vellum/vellum.pid",
332
335
  },
333
336
  };
@@ -343,6 +346,7 @@ describe("migrateLegacyEntry", () => {
343
346
  expect(resources.daemonPort).toBe(8000);
344
347
  expect(resources.gatewayPort).toBe(8001);
345
348
  expect(resources.qdrantPort).toBe(8002);
349
+ expect(resources.cesPort).toBe(8003);
346
350
  });
347
351
 
348
352
  test("baseDataDir does not overwrite existing resources.instanceDir", () => {
@@ -377,6 +381,48 @@ describe("migrateLegacyEntry", () => {
377
381
  const resources = entry.resources as Record<string, unknown>;
378
382
  expect(resources.instanceDir).toBe("/new/path");
379
383
  });
384
+
385
+ test("backfills cesPort with default 8090", () => {
386
+ // GIVEN a local entry with resources that has no cesPort
387
+ const entry: Record<string, unknown> = {
388
+ assistantId: "no-ces-port",
389
+ runtimeUrl: "http://localhost:7830",
390
+ cloud: "local",
391
+ resources: {
392
+ instanceDir: "/custom/path",
393
+ daemonPort: 7821,
394
+ gatewayPort: 7830,
395
+ qdrantPort: 6333,
396
+ pidFile: "/custom/path/.vellum/vellum.pid",
397
+ },
398
+ };
399
+ // WHEN we migrate the entry
400
+ const changed = migrateLegacyEntry(entry);
401
+ // THEN cesPort should be backfilled
402
+ expect(changed).toBe(true);
403
+ const resources = entry.resources as Record<string, unknown>;
404
+ expect(resources.cesPort).toBe(8090);
405
+ });
406
+
407
+ test("does not overwrite existing cesPort", () => {
408
+ const entry: Record<string, unknown> = {
409
+ assistantId: "has-ces-port",
410
+ runtimeUrl: "http://localhost:7830",
411
+ cloud: "local",
412
+ resources: {
413
+ instanceDir: "/my/path",
414
+ daemonPort: 8000,
415
+ gatewayPort: 8001,
416
+ qdrantPort: 8002,
417
+ cesPort: 9090,
418
+ pidFile: "/my/path/.vellum/vellum.pid",
419
+ },
420
+ };
421
+ const changed = migrateLegacyEntry(entry);
422
+ expect(changed).toBe(false);
423
+ const resources = entry.resources as Record<string, unknown>;
424
+ expect(resources.cesPort).toBe(9090);
425
+ });
380
426
  });
381
427
 
382
428
  describe("legacy migration via loadAllAssistants", () => {
@@ -10,6 +10,7 @@ describe("checkHealth", () => {
10
10
  test("returns unreachable for non-existent host", async () => {
11
11
  const result = await checkHealth("http://127.0.0.1:1");
12
12
  expect(["unreachable", "timeout"]).toContain(result.status);
13
+ expect(result.version).toBeUndefined();
13
14
  });
14
15
 
15
16
  test("returns healthy for a mock healthy endpoint", async () => {
@@ -24,6 +25,24 @@ describe("checkHealth", () => {
24
25
  const result = await checkHealth(`http://localhost:${server.port}`);
25
26
  expect(result.status).toBe("healthy");
26
27
  expect(result.detail).toBeNull();
28
+ expect(result.version).toBeUndefined();
29
+ } finally {
30
+ server.stop(true);
31
+ }
32
+ });
33
+
34
+ test("returns version when present in response", async () => {
35
+ const server = Bun.serve({
36
+ port: 0,
37
+ fetch() {
38
+ return Response.json({ status: "healthy", version: "1.2.3" });
39
+ },
40
+ });
41
+
42
+ try {
43
+ const result = await checkHealth(`http://localhost:${server.port}`);
44
+ expect(result.status).toBe("healthy");
45
+ expect(result.version).toBe("1.2.3");
27
46
  } finally {
28
47
  server.stop(true);
29
48
  }
@@ -33,7 +52,11 @@ describe("checkHealth", () => {
33
52
  const server = Bun.serve({
34
53
  port: 0,
35
54
  fetch() {
36
- return Response.json({ status: "degraded", message: "high latency" });
55
+ return Response.json({
56
+ status: "degraded",
57
+ message: "high latency",
58
+ version: "0.9.0",
59
+ });
37
60
  },
38
61
  });
39
62
 
@@ -41,6 +64,7 @@ describe("checkHealth", () => {
41
64
  const result = await checkHealth(`http://localhost:${server.port}`);
42
65
  expect(result.status).toBe("degraded");
43
66
  expect(result.detail).toBe("high latency");
67
+ expect(result.version).toBe("0.9.0");
44
68
  } finally {
45
69
  server.stop(true);
46
70
  }
@@ -57,6 +81,7 @@ describe("checkHealth", () => {
57
81
  try {
58
82
  const result = await checkHealth(`http://localhost:${server.port}`);
59
83
  expect(result.status).toBe("error (500)");
84
+ expect(result.version).toBeUndefined();
60
85
  } finally {
61
86
  server.stop(true);
62
87
  }
@@ -3,7 +3,7 @@ import { buildOpenclawRuntimeServer } from "../lib/openclaw-runtime-server";
3
3
 
4
4
  export async function buildOpenclawStartupScript(
5
5
  sshUser: string,
6
- anthropicApiKey: string,
6
+ providerApiKeys: Record<string, string>,
7
7
  timestampRedirect: string,
8
8
  userSetup: string,
9
9
  ownershipFixup: string,
@@ -110,7 +110,9 @@ echo -n "\$OPENCLAW_GW_TOKEN" > /tmp/openclaw-gateway-token
110
110
  chmod 600 /tmp/openclaw-gateway-token
111
111
 
112
112
  mkdir -p /root/.openclaw
113
- openclaw config set env.ANTHROPIC_API_KEY "${anthropicApiKey}"
113
+ ${Object.entries(providerApiKeys)
114
+ .map(([envVar, value]) => `openclaw config set env.${envVar} "${value}"`)
115
+ .join("\n")}
114
116
  openclaw config set agents.defaults.model.primary "anthropic/claude-opus-4-6"
115
117
  openclaw config set gateway.auth.token "\$OPENCLAW_GW_TOKEN"
116
118
 
@@ -0,0 +1,151 @@
1
+ import { mkdirSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { dirname, join } from "path";
4
+
5
+ import { findAssistantByName } from "../lib/assistant-config";
6
+ import { loadGuardianToken, leaseGuardianToken } from "../lib/guardian-token";
7
+
8
+ function getBackupsDir(): string {
9
+ const dataHome =
10
+ process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
11
+ return join(dataHome, "vellum", "backups");
12
+ }
13
+
14
+ function formatSize(bytes: number): string {
15
+ if (bytes < 1024) return `${bytes} B`;
16
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
17
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
18
+ }
19
+
20
+ export async function backup(): Promise<void> {
21
+ const args = process.argv.slice(3);
22
+
23
+ if (args.includes("--help") || args.includes("-h")) {
24
+ console.log("Usage: vellum backup <name> [--output <path>]");
25
+ console.log("");
26
+ console.log(
27
+ "Export a backup of a running assistant as a .vbundle archive.",
28
+ );
29
+ console.log("");
30
+ console.log("Arguments:");
31
+ console.log(" <name> Name of the assistant to back up");
32
+ console.log("");
33
+ console.log("Options:");
34
+ console.log(" --output <path> Path to save the .vbundle file");
35
+ console.log(
36
+ " (default: ~/.local/share/vellum/backups/<name>-<timestamp>.vbundle)",
37
+ );
38
+ console.log("");
39
+ console.log("Examples:");
40
+ console.log(" vellum backup my-assistant");
41
+ console.log(
42
+ " vellum backup my-assistant --output ~/Desktop/backup.vbundle",
43
+ );
44
+ process.exit(0);
45
+ }
46
+
47
+ const name = args[0];
48
+ if (!name || name.startsWith("-")) {
49
+ console.error("Usage: vellum backup <name> [--output <path>]");
50
+ process.exit(1);
51
+ }
52
+
53
+ // Parse --output flag
54
+ let outputArg: string | undefined;
55
+ for (let i = 1; i < args.length; i++) {
56
+ if (args[i] === "--output" && args[i + 1]) {
57
+ outputArg = args[i + 1];
58
+ break;
59
+ }
60
+ }
61
+
62
+ // Look up the instance
63
+ const entry = findAssistantByName(name);
64
+ if (!entry) {
65
+ console.error(`No assistant found with name '${name}'.`);
66
+ console.error("Run 'vellum hatch' first, or check the instance name.");
67
+ process.exit(1);
68
+ }
69
+
70
+ // Obtain an auth token
71
+ let accessToken: string;
72
+ const tokenData = loadGuardianToken(entry.assistantId);
73
+ if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
74
+ accessToken = tokenData.accessToken;
75
+ } else {
76
+ try {
77
+ const freshToken = await leaseGuardianToken(
78
+ entry.runtimeUrl,
79
+ entry.assistantId,
80
+ );
81
+ accessToken = freshToken.accessToken;
82
+ } catch (err) {
83
+ const msg = err instanceof Error ? err.message : String(err);
84
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
85
+ console.error(
86
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
87
+ );
88
+ console.error(`Try: vellum wake ${name}`);
89
+ process.exit(1);
90
+ }
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ // Call the export endpoint
96
+ let response: Response;
97
+ try {
98
+ response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
99
+ method: "POST",
100
+ headers: {
101
+ Authorization: `Bearer ${accessToken}`,
102
+ "Content-Type": "application/json",
103
+ },
104
+ body: JSON.stringify({ description: "CLI backup" }),
105
+ signal: AbortSignal.timeout(120_000),
106
+ });
107
+ } catch (err) {
108
+ if (err instanceof Error && err.name === "TimeoutError") {
109
+ console.error("Error: Export request timed out after 2 minutes.");
110
+ process.exit(1);
111
+ }
112
+ const msg = err instanceof Error ? err.message : String(err);
113
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
114
+ console.error(
115
+ `Error: Could not connect to assistant '${name}'. Is it running?`,
116
+ );
117
+ console.error(`Try: vellum wake ${name}`);
118
+ process.exit(1);
119
+ }
120
+ throw err;
121
+ }
122
+
123
+ if (!response.ok) {
124
+ const body = await response.text();
125
+ console.error(`Error: Export failed (${response.status}): ${body}`);
126
+ process.exit(1);
127
+ }
128
+
129
+ // Read the response body
130
+ const arrayBuffer = await response.arrayBuffer();
131
+ const data = new Uint8Array(arrayBuffer);
132
+
133
+ // Determine output path
134
+ const isoTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
135
+ const outputPath =
136
+ outputArg || join(getBackupsDir(), `${name}-${isoTimestamp}.vbundle`);
137
+
138
+ // Ensure parent directory exists
139
+ mkdirSync(dirname(outputPath), { recursive: true });
140
+
141
+ // Write the archive to disk
142
+ writeFileSync(outputPath, data);
143
+
144
+ // Print success
145
+ const manifestSha = response.headers.get("X-Vbundle-Manifest-Sha256");
146
+ console.log(`Backup saved to ${outputPath}`);
147
+ console.log(`Size: ${formatSize(data.byteLength)}`);
148
+ if (manifestSha) {
149
+ console.log(`Manifest SHA-256: ${manifestSha}`);
150
+ }
151
+ }
@@ -96,7 +96,7 @@ chown -R "$SSH_USER:$SSH_USER" "$SSH_USER_HOME" 2>/dev/null || true
96
96
  export async function buildStartupScript(
97
97
  species: Species,
98
98
  sshUser: string,
99
- anthropicApiKey: string,
99
+ providerApiKeys: Record<string, string>,
100
100
  instanceName: string,
101
101
  cloud: RemoteHost,
102
102
  ): Promise<string> {
@@ -114,13 +114,22 @@ export async function buildStartupScript(
114
114
  if (species === "openclaw") {
115
115
  return await buildOpenclawStartupScript(
116
116
  sshUser,
117
- anthropicApiKey,
117
+ providerApiKeys,
118
118
  timestampRedirect,
119
119
  userSetup,
120
120
  ownershipFixup,
121
121
  );
122
122
  }
123
123
 
124
+ // Build bash lines that set each provider API key as a shell variable
125
+ // and corresponding dotenv lines for the env file.
126
+ const envSetLines = Object.entries(providerApiKeys)
127
+ .map(([envVar, value]) => `${envVar}=${value}`)
128
+ .join("\n");
129
+ const dotenvLines = Object.keys(providerApiKeys)
130
+ .map((envVar) => `${envVar}=\$${envVar}`)
131
+ .join("\n");
132
+
124
133
  return `#!/bin/bash
125
134
  set -e
126
135
 
@@ -128,11 +137,11 @@ ${timestampRedirect}
128
137
 
129
138
  trap 'EXIT_CODE=\$?; if [ \$EXIT_CODE -ne 0 ]; then echo "Startup script failed with exit code \$EXIT_CODE at line \$LINENO" > ${errorPath}; echo "Last 20 log lines:" >> ${errorPath}; tail -20 ${logPath} >> ${errorPath} 2>/dev/null || true; fi' EXIT
130
139
  ${userSetup}
131
- ANTHROPIC_API_KEY=${anthropicApiKey}
140
+ ${envSetLines}
132
141
  VELLUM_ASSISTANT_NAME=${instanceName}
133
142
  mkdir -p "\$HOME/.config/vellum"
134
143
  cat > "\$HOME/.config/vellum/env" << DOTENV_EOF
135
- ANTHROPIC_API_KEY=\$ANTHROPIC_API_KEY
144
+ ${dotenvLines}
136
145
  RUNTIME_HTTP_PORT=7821
137
146
  DOTENV_EOF
138
147
 
@@ -749,6 +758,7 @@ async function hatchLocal(
749
758
  cloud: "local",
750
759
  species,
751
760
  hatchedAt: new Date().toISOString(),
761
+ serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
752
762
  resources,
753
763
  };
754
764
  if (!daemonOnly && !restart) {
@@ -4,6 +4,7 @@ import {
4
4
  findAssistantByName,
5
5
  getActiveAssistant,
6
6
  loadAllAssistants,
7
+ updateServiceGroupVersion,
7
8
  type AssistantEntry,
8
9
  } from "../lib/assistant-config";
9
10
  import { loadGuardianToken } from "../lib/guardian-token";
@@ -424,7 +425,7 @@ async function listAllAssistants(): Promise<void> {
424
425
  // hitting the health endpoint. If the PID file is missing or the
425
426
  // process isn't running, the assistant is sleeping — skip the
426
427
  // network health check to avoid a misleading "unreachable" status.
427
- let health: { status: string; detail: string | null };
428
+ let health: { status: string; detail: string | null; version?: string };
428
429
  const resources = a.resources;
429
430
  if (a.cloud === "local" && resources) {
430
431
  const pid = readPidFile(resources.pidFile);
@@ -451,6 +452,10 @@ async function listAllAssistants(): Promise<void> {
451
452
  health = await checkHealth(a.localUrl ?? a.runtimeUrl, token);
452
453
  }
453
454
 
455
+ if (health.status === "healthy" && health.version) {
456
+ updateServiceGroupVersion(a.assistantId, health.version);
457
+ }
458
+
454
459
  const infoParts = [a.runtimeUrl];
455
460
  if (a.cloud) infoParts.push(`cloud: ${a.cloud}`);
456
461
  if (a.species) infoParts.push(`species: ${a.species}`);