@vellumai/cli 0.3.11 → 0.3.13

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/README.md CHANGED
@@ -19,7 +19,7 @@ bun run ./src/index.ts <command> [options]
19
19
  Provision a new assistant instance and bootstrap the Vellum runtime on it.
20
20
 
21
21
  ```bash
22
- vellum-cli hatch [species] [options]
22
+ vellum hatch [species] [options]
23
23
  ```
24
24
 
25
25
  #### Species
@@ -59,19 +59,19 @@ vellum-cli hatch [species] [options]
59
59
 
60
60
  ```bash
61
61
  # Hatch a local assistant (default)
62
- vellum-cli hatch
62
+ vellum hatch
63
63
 
64
64
  # Hatch a vellum assistant on GCP
65
- vellum-cli hatch vellum --remote gcp
65
+ vellum hatch vellum --remote gcp
66
66
 
67
67
  # Hatch an openclaw assistant on GCP in detached mode
68
- vellum-cli hatch openclaw --remote gcp -d
68
+ vellum hatch openclaw --remote gcp -d
69
69
 
70
70
  # Hatch with a specific instance name
71
- vellum-cli hatch --name my-assistant --remote gcp
71
+ vellum hatch --name my-assistant --remote gcp
72
72
 
73
73
  # Hatch on a custom SSH host
74
- VELLUM_CUSTOM_HOST=user@10.0.0.1 vellum-cli hatch --remote custom
74
+ VELLUM_CUSTOM_HOST=user@10.0.0.1 vellum hatch --remote custom
75
75
  ```
76
76
 
77
77
  When hatching on GCP in interactive mode (without `-d`), the CLI displays an animated progress TUI that polls the instance's startup script output in real time. Press `Ctrl+C` to detach -- the instance will continue running in the background.
@@ -81,7 +81,7 @@ When hatching on GCP in interactive mode (without `-d`), the CLI displays an ani
81
81
  Delete a provisioned assistant instance. The cloud provider and connection details are automatically resolved from the saved assistant config (written during `hatch`).
82
82
 
83
83
  ```bash
84
- vellum-cli retire <name>
84
+ vellum retire <name>
85
85
  ```
86
86
 
87
87
  The CLI looks up the instance by name in `~/.vellum.lock.json` and determines how to retire it based on the saved `cloud` field:
@@ -95,5 +95,5 @@ The CLI looks up the instance by name in `~/.vellum.lock.json` and determines ho
95
95
 
96
96
  ```bash
97
97
  # Retire an instance (cloud type resolved from config)
98
- vellum-cli retire my-assistant
98
+ vellum retire my-assistant
99
99
  ```
package/bun.lock CHANGED
@@ -6,11 +6,13 @@
6
6
  "name": "@vellumai/cli",
7
7
  "dependencies": {
8
8
  "ink": "^6.7.0",
9
+ "qrcode-terminal": "^0.12.0",
9
10
  "react": "^19.2.4",
10
11
  "react-devtools-core": "^6.1.2",
11
12
  },
12
13
  "devDependencies": {
13
14
  "@types/bun": "^1.2.4",
15
+ "@types/qrcode-terminal": "^0.12.2",
14
16
  "@types/react": "^19.2.14",
15
17
  "eslint": "^10.0.0",
16
18
  "typescript": "^5.9.3",
@@ -53,6 +55,8 @@
53
55
 
54
56
  "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
55
57
 
58
+ "@types/qrcode-terminal": ["@types/qrcode-terminal@0.12.2", "", {}, "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q=="],
59
+
56
60
  "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
57
61
 
58
62
  "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/type-utils": "8.56.0", "@typescript-eslint/utils": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw=="],
@@ -217,6 +221,8 @@
217
221
 
218
222
  "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
219
223
 
224
+ "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
225
+
220
226
  "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
221
227
 
222
228
  "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,21 +11,24 @@
11
11
  "./src/commands/*": "./src/commands/*.ts"
12
12
  },
13
13
  "bin": {
14
- "vellum-cli": "./src/index.ts"
14
+ "vellum": "./src/index.ts"
15
15
  },
16
16
  "scripts": {
17
17
  "lint": "eslint",
18
+ "test": "bun test",
18
19
  "typecheck": "bunx tsc --noEmit"
19
20
  },
20
21
  "author": "Vellum AI",
21
22
  "license": "MIT",
22
23
  "dependencies": {
23
24
  "ink": "^6.7.0",
25
+ "qrcode-terminal": "^0.12.0",
24
26
  "react": "^19.2.4",
25
27
  "react-devtools-core": "^6.1.2"
26
28
  },
27
29
  "devDependencies": {
28
30
  "@types/bun": "^1.2.4",
31
+ "@types/qrcode-terminal": "^0.12.2",
29
32
  "@types/react": "^19.2.14",
30
33
  "eslint": "^10.0.0",
31
34
  "typescript": "^5.9.3",
@@ -0,0 +1,139 @@
1
+ import { describe, test, expect, beforeEach, afterAll } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Point lockfile operations at a temp directory
7
+ const testDir = mkdtempSync(join(tmpdir(), "cli-assistant-config-test-"));
8
+ process.env.BASE_DATA_DIR = testDir;
9
+
10
+ import {
11
+ loadLatestAssistant,
12
+ findAssistantByName,
13
+ removeAssistantEntry,
14
+ loadAllAssistants,
15
+ saveAssistantEntry,
16
+ type AssistantEntry,
17
+ } from "../lib/assistant-config.js";
18
+
19
+ afterAll(() => {
20
+ rmSync(testDir, { recursive: true, force: true });
21
+ delete process.env.BASE_DATA_DIR;
22
+ });
23
+
24
+ function writeLockfile(data: unknown): void {
25
+ writeFileSync(
26
+ join(testDir, ".vellum.lock.json"),
27
+ JSON.stringify(data, null, 2),
28
+ );
29
+ }
30
+
31
+ const makeEntry = (id: string, runtimeUrl = "http://localhost:7821", extra?: Partial<AssistantEntry>): AssistantEntry => ({
32
+ assistantId: id,
33
+ runtimeUrl,
34
+ cloud: "local",
35
+ ...extra,
36
+ });
37
+
38
+ describe("assistant-config", () => {
39
+ beforeEach(() => {
40
+ // Reset lockfile between tests
41
+ try {
42
+ rmSync(join(testDir, ".vellum.lock.json"));
43
+ } catch {
44
+ // file may not exist
45
+ }
46
+ try {
47
+ rmSync(join(testDir, ".vellum.lockfile.json"));
48
+ } catch {
49
+ // file may not exist
50
+ }
51
+ });
52
+
53
+ test("loadAllAssistants returns empty array when no lockfile exists", () => {
54
+ expect(loadAllAssistants()).toEqual([]);
55
+ });
56
+
57
+ test("loadAllAssistants returns empty array for malformed lockfile", () => {
58
+ writeFileSync(join(testDir, ".vellum.lock.json"), "not json");
59
+ expect(loadAllAssistants()).toEqual([]);
60
+ });
61
+
62
+ test("loadAllAssistants returns empty array when assistants key is missing", () => {
63
+ writeLockfile({ someOtherKey: true });
64
+ expect(loadAllAssistants()).toEqual([]);
65
+ });
66
+
67
+ test("saveAssistantEntry and loadAllAssistants round-trip", () => {
68
+ const entry = makeEntry("test-1");
69
+ saveAssistantEntry(entry);
70
+ const all = loadAllAssistants();
71
+ expect(all).toHaveLength(1);
72
+ expect(all[0].assistantId).toBe("test-1");
73
+ });
74
+
75
+ test("findAssistantByName returns matching entry", () => {
76
+ writeLockfile({
77
+ assistants: [makeEntry("alpha"), makeEntry("beta")],
78
+ });
79
+ const result = findAssistantByName("beta");
80
+ expect(result).not.toBeNull();
81
+ expect(result!.assistantId).toBe("beta");
82
+ });
83
+
84
+ test("findAssistantByName returns null for non-existent name", () => {
85
+ writeLockfile({ assistants: [makeEntry("alpha")] });
86
+ expect(findAssistantByName("missing")).toBeNull();
87
+ });
88
+
89
+ test("removeAssistantEntry removes matching entry", () => {
90
+ writeLockfile({
91
+ assistants: [makeEntry("a"), makeEntry("b"), makeEntry("c")],
92
+ });
93
+ removeAssistantEntry("b");
94
+ const all = loadAllAssistants();
95
+ expect(all).toHaveLength(2);
96
+ expect(all.map((e) => e.assistantId)).toEqual(["a", "c"]);
97
+ });
98
+
99
+ test("loadLatestAssistant returns null when empty", () => {
100
+ expect(loadLatestAssistant()).toBeNull();
101
+ });
102
+
103
+ test("loadLatestAssistant returns most recently hatched entry", () => {
104
+ writeLockfile({
105
+ assistants: [
106
+ makeEntry("old", "http://localhost:7821", { hatchedAt: "2024-01-01T00:00:00Z" }),
107
+ makeEntry("new", "http://localhost:7822", { hatchedAt: "2025-06-15T00:00:00Z" }),
108
+ makeEntry("mid", "http://localhost:7823", { hatchedAt: "2024-06-15T00:00:00Z" }),
109
+ ],
110
+ });
111
+ const latest = loadLatestAssistant();
112
+ expect(latest).not.toBeNull();
113
+ expect(latest!.assistantId).toBe("new");
114
+ });
115
+
116
+ test("loadLatestAssistant handles entries without hatchedAt", () => {
117
+ writeLockfile({
118
+ assistants: [
119
+ makeEntry("no-date"),
120
+ makeEntry("with-date", "http://localhost:7822", { hatchedAt: "2025-01-01T00:00:00Z" }),
121
+ ],
122
+ });
123
+ const latest = loadLatestAssistant();
124
+ expect(latest!.assistantId).toBe("with-date");
125
+ });
126
+
127
+ test("loadAllAssistants filters out entries missing required fields", () => {
128
+ writeLockfile({
129
+ assistants: [
130
+ makeEntry("valid"),
131
+ { cloud: "local" }, // missing assistantId and runtimeUrl
132
+ { assistantId: "no-url", cloud: "local" }, // missing runtimeUrl
133
+ ],
134
+ });
135
+ const all = loadAllAssistants();
136
+ expect(all).toHaveLength(1);
137
+ expect(all[0].assistantId).toBe("valid");
138
+ });
139
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ FIREWALL_TAG,
4
+ GATEWAY_PORT,
5
+ VALID_REMOTE_HOSTS,
6
+ VALID_SPECIES,
7
+ SPECIES_CONFIG,
8
+ } from "../lib/constants.js";
9
+
10
+ describe("constants", () => {
11
+ test("FIREWALL_TAG is a non-empty string", () => {
12
+ expect(typeof FIREWALL_TAG).toBe("string");
13
+ expect(FIREWALL_TAG.length).toBeGreaterThan(0);
14
+ });
15
+
16
+ test("GATEWAY_PORT is a valid port number", () => {
17
+ expect(typeof GATEWAY_PORT).toBe("number");
18
+ expect(GATEWAY_PORT).toBeGreaterThan(0);
19
+ expect(GATEWAY_PORT).toBeLessThan(65536);
20
+ });
21
+
22
+ test("VALID_REMOTE_HOSTS includes expected hosts", () => {
23
+ expect(VALID_REMOTE_HOSTS).toContain("local");
24
+ expect(VALID_REMOTE_HOSTS).toContain("gcp");
25
+ expect(VALID_REMOTE_HOSTS).toContain("aws");
26
+ expect(VALID_REMOTE_HOSTS).toContain("custom");
27
+ });
28
+
29
+ test("VALID_SPECIES includes expected species", () => {
30
+ expect(VALID_SPECIES).toContain("openclaw");
31
+ expect(VALID_SPECIES).toContain("vellum");
32
+ });
33
+
34
+ test("SPECIES_CONFIG has entries for all valid species", () => {
35
+ for (const species of VALID_SPECIES) {
36
+ const config = SPECIES_CONFIG[species];
37
+ expect(config).toBeDefined();
38
+ expect(typeof config.color).toBe("string");
39
+ expect(Array.isArray(config.art)).toBe(true);
40
+ expect(config.art.length).toBeGreaterThan(0);
41
+ expect(typeof config.hatchedEmoji).toBe("string");
42
+ expect(Array.isArray(config.waitingMessages)).toBe(true);
43
+ expect(config.waitingMessages.length).toBeGreaterThan(0);
44
+ expect(Array.isArray(config.runningMessages)).toBe(true);
45
+ expect(config.runningMessages.length).toBeGreaterThan(0);
46
+ }
47
+ });
48
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { checkHealth, HEALTH_CHECK_TIMEOUT_MS } from "../lib/health-check.js";
3
+
4
+ describe("checkHealth", () => {
5
+ test("HEALTH_CHECK_TIMEOUT_MS is a positive number", () => {
6
+ expect(typeof HEALTH_CHECK_TIMEOUT_MS).toBe("number");
7
+ expect(HEALTH_CHECK_TIMEOUT_MS).toBeGreaterThan(0);
8
+ });
9
+
10
+ test("returns unreachable for non-existent host", async () => {
11
+ const result = await checkHealth("http://127.0.0.1:1");
12
+ expect(["unreachable", "timeout"]).toContain(result.status);
13
+ });
14
+
15
+ test("returns healthy for a mock healthy endpoint", async () => {
16
+ const server = Bun.serve({
17
+ port: 0,
18
+ fetch() {
19
+ return Response.json({ status: "healthy" });
20
+ },
21
+ });
22
+
23
+ try {
24
+ const result = await checkHealth(`http://localhost:${server.port}`);
25
+ expect(result.status).toBe("healthy");
26
+ expect(result.detail).toBeNull();
27
+ } finally {
28
+ server.stop(true);
29
+ }
30
+ });
31
+
32
+ test("returns status with detail for non-healthy response", async () => {
33
+ const server = Bun.serve({
34
+ port: 0,
35
+ fetch() {
36
+ return Response.json({ status: "degraded", message: "high latency" });
37
+ },
38
+ });
39
+
40
+ try {
41
+ const result = await checkHealth(`http://localhost:${server.port}`);
42
+ expect(result.status).toBe("degraded");
43
+ expect(result.detail).toBe("high latency");
44
+ } finally {
45
+ server.stop(true);
46
+ }
47
+ });
48
+
49
+ test("returns error status for non-ok HTTP response", async () => {
50
+ const server = Bun.serve({
51
+ port: 0,
52
+ fetch() {
53
+ return new Response("Internal Server Error", { status: 500 });
54
+ },
55
+ });
56
+
57
+ try {
58
+ const result = await checkHealth(`http://localhost:${server.port}`);
59
+ expect(result.status).toBe("error (500)");
60
+ } finally {
61
+ server.stop(true);
62
+ }
63
+ });
64
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { generateRandomSuffix } from "../lib/random-name.js";
3
+
4
+ describe("generateRandomSuffix", () => {
5
+ test("returns a string in adjective-noun format", () => {
6
+ const result = generateRandomSuffix();
7
+ expect(result).toMatch(/^[a-z]+-[a-z]+$/);
8
+ });
9
+
10
+ test("produces varying results across multiple calls", () => {
11
+ const results = new Set<string>();
12
+ for (let i = 0; i < 20; i++) {
13
+ results.add(generateRandomSuffix());
14
+ }
15
+ // With 62 adjectives * 62 nouns = 3844 combos, 20 calls should produce
16
+ // at least a few unique values
17
+ expect(results.size).toBeGreaterThan(1);
18
+ });
19
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { validateAssistantName } from "../lib/retire-archive.js";
3
+
4
+ describe("validateAssistantName", () => {
5
+ test("accepts valid names", () => {
6
+ expect(() => validateAssistantName("my-assistant")).not.toThrow();
7
+ expect(() => validateAssistantName("test123")).not.toThrow();
8
+ expect(() => validateAssistantName("a")).not.toThrow();
9
+ });
10
+
11
+ test("rejects empty string", () => {
12
+ expect(() => validateAssistantName("")).toThrow("Invalid assistant name");
13
+ });
14
+
15
+ test("rejects names with forward slashes", () => {
16
+ expect(() => validateAssistantName("foo/bar")).toThrow("Invalid assistant name");
17
+ });
18
+
19
+ test("rejects names with backslashes", () => {
20
+ expect(() => validateAssistantName("foo\\bar")).toThrow("Invalid assistant name");
21
+ });
22
+
23
+ test("rejects dot-dot traversal", () => {
24
+ expect(() => validateAssistantName("..")).toThrow("Invalid assistant name");
25
+ });
26
+
27
+ test("rejects single dot", () => {
28
+ expect(() => validateAssistantName(".")).toThrow("Invalid assistant name");
29
+ });
30
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { statusEmoji, withStatusEmoji } from "../lib/status-emoji.js";
3
+
4
+ describe("statusEmoji", () => {
5
+ test("returns green for running/healthy/ok statuses", () => {
6
+ expect(statusEmoji("running")).toBe("\u{1F7E2}");
7
+ expect(statusEmoji("healthy")).toBe("\u{1F7E2}");
8
+ expect(statusEmoji("ok")).toBe("\u{1F7E2}");
9
+ });
10
+
11
+ test("returns green for statuses starting with 'up '", () => {
12
+ expect(statusEmoji("up 5 minutes")).toBe("\u{1F7E2}");
13
+ expect(statusEmoji("up 1d")).toBe("\u{1F7E2}");
14
+ });
15
+
16
+ test("returns red for error/unreachable/exited statuses", () => {
17
+ expect(statusEmoji("error")).toBe("\u{1F534}");
18
+ expect(statusEmoji("error (500)")).toBe("\u{1F534}");
19
+ expect(statusEmoji("unreachable")).toBe("\u{1F534}");
20
+ expect(statusEmoji("exited")).toBe("\u{1F534}");
21
+ expect(statusEmoji("exited (1)")).toBe("\u{1F534}");
22
+ });
23
+
24
+ test("returns yellow for unknown statuses", () => {
25
+ expect(statusEmoji("starting")).toBe("\u{1F7E1}");
26
+ expect(statusEmoji("pending")).toBe("\u{1F7E1}");
27
+ expect(statusEmoji("unknown")).toBe("\u{1F7E1}");
28
+ });
29
+
30
+ test("is case-insensitive", () => {
31
+ expect(statusEmoji("Running")).toBe("\u{1F7E2}");
32
+ expect(statusEmoji("HEALTHY")).toBe("\u{1F7E2}");
33
+ expect(statusEmoji("ERROR")).toBe("\u{1F534}");
34
+ expect(statusEmoji("Unreachable")).toBe("\u{1F534}");
35
+ });
36
+ });
37
+
38
+ describe("withStatusEmoji", () => {
39
+ test("prepends emoji to status string", () => {
40
+ expect(withStatusEmoji("running")).toBe("\u{1F7E2} running");
41
+ expect(withStatusEmoji("error")).toBe("\u{1F534} error");
42
+ expect(withStatusEmoji("pending")).toBe("\u{1F7E1} pending");
43
+ });
44
+ });