@vellumai/cli 0.4.36 → 0.4.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.4.36",
3
+ "version": "0.4.40",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,7 +11,7 @@
11
11
  "./src/commands/*": "./src/commands/*.ts"
12
12
  },
13
13
  "bin": {
14
- "vellum": "./src/index.ts"
14
+ "assistant": "./src/index.ts"
15
15
  },
16
16
  "scripts": {
17
17
  "format": "prettier --write .",
@@ -0,0 +1,275 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Create a temp directory that acts as a fake home, so allocateLocalResources()
7
+ // and defaultLocalResources() never touch the real ~/.vellum directory.
8
+ const testDir = mkdtempSync(join(tmpdir(), "cli-multi-local-test-"));
9
+ process.env.BASE_DATA_DIR = testDir;
10
+
11
+ // Mock homedir() to return testDir — this isolates allocateLocalResources()
12
+ // which uses homedir() directly for instance directory creation.
13
+ const realOs = await import("node:os");
14
+ mock.module("node:os", () => ({
15
+ ...realOs,
16
+ homedir: () => testDir,
17
+ }));
18
+ // Also mock the bare "os" specifier since assistant-config.ts uses `from "os"`
19
+ mock.module("os", () => ({
20
+ ...realOs,
21
+ homedir: () => testDir,
22
+ }));
23
+
24
+ // Mock probePort so we control which ports appear in-use without touching the network
25
+ const probePortMock = mock<(port: number, host?: string) => Promise<boolean>>(
26
+ () => Promise.resolve(false),
27
+ );
28
+ mock.module("../lib/port-probe.js", () => ({
29
+ probePort: probePortMock,
30
+ }));
31
+
32
+ import {
33
+ allocateLocalResources,
34
+ defaultLocalResources,
35
+ resolveTargetAssistant,
36
+ setActiveAssistant,
37
+ getActiveAssistant,
38
+ removeAssistantEntry,
39
+ saveAssistantEntry,
40
+ type AssistantEntry,
41
+ } from "../lib/assistant-config.js";
42
+ import { DEFAULT_DAEMON_PORT } from "../lib/constants.js";
43
+
44
+ afterAll(() => {
45
+ rmSync(testDir, { recursive: true, force: true });
46
+ delete process.env.BASE_DATA_DIR;
47
+ });
48
+
49
+ function writeLockfile(data: unknown): void {
50
+ writeFileSync(
51
+ join(testDir, ".vellum.lock.json"),
52
+ JSON.stringify(data, null, 2),
53
+ );
54
+ }
55
+
56
+ function readLockfileRaw(): Record<string, unknown> {
57
+ return JSON.parse(
58
+ readFileSync(join(testDir, ".vellum.lock.json"), "utf-8"),
59
+ ) as Record<string, unknown>;
60
+ }
61
+
62
+ const makeEntry = (
63
+ id: string,
64
+ cloud = "local",
65
+ extra?: Partial<AssistantEntry>,
66
+ ): AssistantEntry => ({
67
+ assistantId: id,
68
+ runtimeUrl: `http://localhost:${DEFAULT_DAEMON_PORT}`,
69
+ cloud,
70
+ ...extra,
71
+ });
72
+
73
+ function resetLockfile(): void {
74
+ try {
75
+ rmSync(join(testDir, ".vellum.lock.json"));
76
+ } catch {
77
+ // file may not exist
78
+ }
79
+ try {
80
+ rmSync(join(testDir, ".vellum.lockfile.json"));
81
+ } catch {
82
+ // file may not exist
83
+ }
84
+ }
85
+
86
+ describe("multi-local", () => {
87
+ beforeEach(() => {
88
+ resetLockfile();
89
+ probePortMock.mockReset();
90
+ probePortMock.mockImplementation(() => Promise.resolve(false));
91
+ });
92
+
93
+ describe("allocateLocalResources() produces non-conflicting ports", () => {
94
+ test("two instances get distinct ports and dirs when first instance ports are occupied", async () => {
95
+ // After the first allocation grabs its ports, simulate those ports
96
+ // being in-use so the second allocation must pick different ones.
97
+ const a = await allocateLocalResources("instance-a");
98
+ const occupiedPorts = new Set([
99
+ a.daemonPort,
100
+ a.gatewayPort,
101
+ a.qdrantPort,
102
+ ]);
103
+ probePortMock.mockImplementation((port: number) =>
104
+ Promise.resolve(occupiedPorts.has(port)),
105
+ );
106
+
107
+ const b = await allocateLocalResources("instance-b");
108
+
109
+ // All six ports must be unique across both instances
110
+ const allPorts = [
111
+ a.daemonPort,
112
+ a.gatewayPort,
113
+ a.qdrantPort,
114
+ b.daemonPort,
115
+ b.gatewayPort,
116
+ b.qdrantPort,
117
+ ];
118
+ expect(new Set(allPorts).size).toBe(6);
119
+
120
+ // Instance dirs must be distinct
121
+ expect(a.instanceDir).not.toBe(b.instanceDir);
122
+ expect(a.instanceDir).toContain("instance-a");
123
+ expect(b.instanceDir).toContain("instance-b");
124
+ });
125
+
126
+ test("skips ports that probePort reports as in-use", async () => {
127
+ // Simulate the default ports being occupied
128
+ const portsInUse = new Set([
129
+ DEFAULT_DAEMON_PORT,
130
+ DEFAULT_DAEMON_PORT + 1,
131
+ ]);
132
+ probePortMock.mockImplementation((port: number) =>
133
+ Promise.resolve(portsInUse.has(port)),
134
+ );
135
+
136
+ const res = await allocateLocalResources("probe-test");
137
+ expect(res.daemonPort).toBeGreaterThan(DEFAULT_DAEMON_PORT + 1);
138
+ expect(portsInUse.has(res.daemonPort)).toBe(false);
139
+ });
140
+ });
141
+
142
+ describe("defaultLocalResources() returns legacy paths", () => {
143
+ test("instanceDir is homedir", () => {
144
+ const res = defaultLocalResources();
145
+ expect(res.instanceDir).toBe(testDir);
146
+ });
147
+
148
+ test("daemonPort is DEFAULT_DAEMON_PORT", () => {
149
+ const res = defaultLocalResources();
150
+ expect(res.daemonPort).toBe(DEFAULT_DAEMON_PORT);
151
+ });
152
+ });
153
+
154
+ describe("resolveTargetAssistant() priority chain", () => {
155
+ test("explicit name returns that entry", () => {
156
+ writeLockfile({
157
+ assistants: [makeEntry("alpha"), makeEntry("beta")],
158
+ });
159
+ const result = resolveTargetAssistant("beta");
160
+ expect(result.assistantId).toBe("beta");
161
+ });
162
+
163
+ test("active assistant set returns the active entry", () => {
164
+ writeLockfile({
165
+ assistants: [makeEntry("alpha"), makeEntry("beta")],
166
+ activeAssistant: "alpha",
167
+ });
168
+ const result = resolveTargetAssistant();
169
+ expect(result.assistantId).toBe("alpha");
170
+ });
171
+
172
+ test("sole local assistant returns it", () => {
173
+ writeLockfile({
174
+ assistants: [makeEntry("only-one")],
175
+ });
176
+ const result = resolveTargetAssistant();
177
+ expect(result.assistantId).toBe("only-one");
178
+ });
179
+
180
+ test("multiple local assistants and no active throws with guidance", () => {
181
+ writeLockfile({
182
+ assistants: [makeEntry("x"), makeEntry("y")],
183
+ });
184
+ // resolveTargetAssistant calls process.exit(1) on ambiguity
185
+ const mockExit = mock(() => {
186
+ throw new Error("process.exit called");
187
+ });
188
+ const origExit = process.exit;
189
+ process.exit = mockExit as unknown as typeof process.exit;
190
+ try {
191
+ expect(() => resolveTargetAssistant()).toThrow("process.exit called");
192
+ expect(mockExit).toHaveBeenCalledWith(1);
193
+ } finally {
194
+ process.exit = origExit;
195
+ }
196
+ });
197
+
198
+ test("no local assistants throws", () => {
199
+ writeLockfile({ assistants: [] });
200
+ const mockExit = mock(() => {
201
+ throw new Error("process.exit called");
202
+ });
203
+ const origExit = process.exit;
204
+ process.exit = mockExit as unknown as typeof process.exit;
205
+ try {
206
+ expect(() => resolveTargetAssistant()).toThrow("process.exit called");
207
+ expect(mockExit).toHaveBeenCalledWith(1);
208
+ } finally {
209
+ process.exit = origExit;
210
+ }
211
+ });
212
+ });
213
+
214
+ describe("setActiveAssistant() / getActiveAssistant() round-trip", () => {
215
+ test("set active, read it back", () => {
216
+ writeLockfile({ assistants: [makeEntry("my-assistant")] });
217
+ setActiveAssistant("my-assistant");
218
+ expect(getActiveAssistant()).toBe("my-assistant");
219
+ });
220
+
221
+ test("lockfile is updated on disk", () => {
222
+ writeLockfile({ assistants: [makeEntry("disk-check")] });
223
+ setActiveAssistant("disk-check");
224
+ const raw = readLockfileRaw();
225
+ expect(raw.activeAssistant).toBe("disk-check");
226
+ });
227
+ });
228
+
229
+ describe("removeAssistantEntry() clears matching activeAssistant", () => {
230
+ test("set active to foo, remove foo, verify active is null", () => {
231
+ writeLockfile({
232
+ assistants: [makeEntry("foo"), makeEntry("bar")],
233
+ activeAssistant: "foo",
234
+ });
235
+ removeAssistantEntry("foo");
236
+ expect(getActiveAssistant()).toBeNull();
237
+ });
238
+
239
+ test("set active to foo, remove bar, verify active is still foo", () => {
240
+ writeLockfile({
241
+ assistants: [makeEntry("foo"), makeEntry("bar")],
242
+ activeAssistant: "foo",
243
+ });
244
+ removeAssistantEntry("bar");
245
+ expect(getActiveAssistant()).toBe("foo");
246
+ });
247
+ });
248
+
249
+ describe("remote non-regression", () => {
250
+ test("resolveTargetAssistant works with remote entries", () => {
251
+ writeLockfile({
252
+ assistants: [
253
+ makeEntry("my-remote", "gcp", {
254
+ runtimeUrl: "http://10.0.0.1:7821",
255
+ }),
256
+ ],
257
+ activeAssistant: "my-remote",
258
+ });
259
+ const result = resolveTargetAssistant();
260
+ expect(result.assistantId).toBe("my-remote");
261
+ expect(result.cloud).toBe("gcp");
262
+ });
263
+
264
+ test("remote entries don't get resources applied", () => {
265
+ const remoteEntry = makeEntry("cloud-box", "aws", {
266
+ runtimeUrl: "http://10.0.0.2:7821",
267
+ });
268
+ writeLockfile({ assistants: [remoteEntry] });
269
+ // Save and reload to verify resources are not injected
270
+ saveAssistantEntry(remoteEntry);
271
+ const result = resolveTargetAssistant("cloud-box");
272
+ expect(result.resources).toBeUndefined();
273
+ });
274
+ });
275
+ });
@@ -0,0 +1,203 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { skills } from "../commands/skills.js";
13
+
14
+ let tempDir: string;
15
+ let originalArgv: string[];
16
+ let originalBaseDataDir: string | undefined;
17
+ let originalExitCode: number | string | null | undefined;
18
+
19
+ function getSkillsDir(): string {
20
+ return join(tempDir, ".vellum", "workspace", "skills");
21
+ }
22
+
23
+ function getSkillsIndexPath(): string {
24
+ return join(getSkillsDir(), "SKILLS.md");
25
+ }
26
+
27
+ function installFakeSkill(skillId: string): void {
28
+ const skillDir = join(getSkillsDir(), skillId);
29
+ mkdirSync(skillDir, { recursive: true });
30
+ writeFileSync(join(skillDir, "SKILL.md"), `# ${skillId}\nA test skill.\n`);
31
+ }
32
+
33
+ function writeSkillsIndex(content: string): void {
34
+ mkdirSync(getSkillsDir(), { recursive: true });
35
+ writeFileSync(getSkillsIndexPath(), content);
36
+ }
37
+
38
+ beforeEach(() => {
39
+ tempDir = join(tmpdir(), `skills-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
40
+ mkdirSync(join(tempDir, ".vellum", "workspace", "skills"), {
41
+ recursive: true,
42
+ });
43
+ originalArgv = process.argv;
44
+ originalBaseDataDir = process.env.BASE_DATA_DIR;
45
+ originalExitCode = process.exitCode;
46
+ process.env.BASE_DATA_DIR = tempDir;
47
+ });
48
+
49
+ afterEach(() => {
50
+ process.argv = originalArgv;
51
+ process.env.BASE_DATA_DIR = originalBaseDataDir;
52
+ // Bun treats `process.exitCode = undefined` as a no-op, so explicitly
53
+ // reset to 0 when the original value was not set.
54
+ process.exitCode = originalExitCode ?? 0;
55
+ rmSync(tempDir, { recursive: true, force: true });
56
+ });
57
+
58
+ describe("vellum skills uninstall", () => {
59
+ test("removes skill directory and SKILLS.md entry", async () => {
60
+ /**
61
+ * Tests the happy path for uninstalling a skill.
62
+ */
63
+
64
+ // GIVEN a skill is installed locally
65
+ installFakeSkill("weather");
66
+ writeSkillsIndex("- weather\n- google-oauth-setup\n");
67
+
68
+ // WHEN we run `vellum skills uninstall weather`
69
+ process.argv = ["bun", "run", "skills", "uninstall", "weather"];
70
+ await skills();
71
+
72
+ // THEN the skill directory should be removed
73
+ expect(existsSync(join(getSkillsDir(), "weather"))).toBe(false);
74
+
75
+ // AND the SKILLS.md entry should be removed
76
+ const index = readFileSync(getSkillsIndexPath(), "utf-8");
77
+ expect(index).not.toContain("weather");
78
+
79
+ // AND other skills should remain in the index
80
+ expect(index).toContain("google-oauth-setup");
81
+ });
82
+
83
+ test("outputs JSON on success when --json flag is passed", async () => {
84
+ /**
85
+ * Tests that --json flag produces machine-readable output.
86
+ */
87
+
88
+ // GIVEN a skill is installed locally
89
+ installFakeSkill("weather");
90
+ writeSkillsIndex("- weather\n");
91
+
92
+ // WHEN we run `vellum skills uninstall weather --json`
93
+ process.argv = ["bun", "run", "skills", "uninstall", "weather", "--json"];
94
+ const logs: string[] = [];
95
+ const origLog = console.log;
96
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
97
+ try {
98
+ await skills();
99
+ } finally {
100
+ console.log = origLog;
101
+ }
102
+
103
+ // THEN JSON output should indicate success
104
+ const output = JSON.parse(logs[0]);
105
+ expect(output).toEqual({ ok: true, skillId: "weather" });
106
+ });
107
+
108
+ test("errors when skill is not installed", async () => {
109
+ /**
110
+ * Tests that uninstalling a non-existent skill produces an error.
111
+ */
112
+
113
+ // GIVEN no skills are installed
114
+ // WHEN we run `vellum skills uninstall nonexistent`
115
+ process.argv = ["bun", "run", "skills", "uninstall", "nonexistent"];
116
+ const errors: string[] = [];
117
+ const origError = console.error;
118
+ console.error = (...args: unknown[]) => errors.push(args.join(" "));
119
+ try {
120
+ await skills();
121
+ } finally {
122
+ console.error = origError;
123
+ }
124
+
125
+ // THEN an error message should be displayed
126
+ expect(errors[0]).toContain('Skill "nonexistent" is not installed.');
127
+ expect(process.exitCode).toBe(1);
128
+ });
129
+
130
+ test("errors with JSON output when skill is not installed and --json is passed", async () => {
131
+ /**
132
+ * Tests that --json flag produces machine-readable error output.
133
+ */
134
+
135
+ // GIVEN no skills are installed
136
+ // WHEN we run `vellum skills uninstall nonexistent --json`
137
+ process.argv = [
138
+ "bun",
139
+ "run",
140
+ "skills",
141
+ "uninstall",
142
+ "nonexistent",
143
+ "--json",
144
+ ];
145
+ const logs: string[] = [];
146
+ const origLog = console.log;
147
+ console.log = (...args: unknown[]) => logs.push(args.join(" "));
148
+ try {
149
+ await skills();
150
+ } finally {
151
+ console.log = origLog;
152
+ }
153
+
154
+ // THEN JSON output should indicate failure
155
+ const output = JSON.parse(logs[0]);
156
+ expect(output.ok).toBe(false);
157
+ expect(output.error).toContain('Skill "nonexistent" is not installed.');
158
+ });
159
+
160
+ test("works when SKILLS.md does not exist", async () => {
161
+ /**
162
+ * Tests that uninstall works even if the SKILLS.md index file is missing.
163
+ */
164
+
165
+ // GIVEN a skill directory exists but no SKILLS.md
166
+ installFakeSkill("weather");
167
+
168
+ // WHEN we run `vellum skills uninstall weather`
169
+ process.argv = ["bun", "run", "skills", "uninstall", "weather"];
170
+ await skills();
171
+
172
+ // THEN the skill directory should be removed
173
+ expect(existsSync(join(getSkillsDir(), "weather"))).toBe(false);
174
+
175
+ // AND no SKILLS.md should have been created
176
+ expect(existsSync(getSkillsIndexPath())).toBe(false);
177
+ });
178
+
179
+ test("removes skill with nested files", async () => {
180
+ /**
181
+ * Tests that uninstall recursively removes skills with nested directories.
182
+ */
183
+
184
+ // GIVEN a skill with nested files is installed
185
+ const skillDir = join(getSkillsDir(), "weather");
186
+ mkdirSync(join(skillDir, "scripts", "lib"), { recursive: true });
187
+ writeFileSync(join(skillDir, "SKILL.md"), "# weather\n");
188
+ writeFileSync(join(skillDir, "scripts", "fetch.sh"), "#!/bin/bash\n");
189
+ writeFileSync(join(skillDir, "scripts", "lib", "utils.sh"), "# utils\n");
190
+ writeSkillsIndex("- weather\n");
191
+
192
+ // WHEN we run `vellum skills uninstall weather`
193
+ process.argv = ["bun", "run", "skills", "uninstall", "weather"];
194
+ await skills();
195
+
196
+ // THEN the entire skill directory tree should be removed
197
+ expect(existsSync(skillDir)).toBe(false);
198
+
199
+ // AND the SKILLS.md entry should be removed
200
+ const index = readFileSync(getSkillsIndexPath(), "utf-8");
201
+ expect(index).not.toContain("weather");
202
+ });
203
+ });
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  findAssistantByName,
3
+ getActiveAssistant,
3
4
  loadLatestAssistant,
4
5
  } from "../lib/assistant-config";
5
6
  import { GATEWAY_PORT, type Species } from "../lib/constants";
@@ -45,16 +46,31 @@ function parseArgs(): ParsedArgs {
45
46
  }
46
47
  }
47
48
 
48
- const entry = positionalName
49
- ? findAssistantByName(positionalName)
50
- : loadLatestAssistant();
51
- if (positionalName && !entry) {
52
- console.error(`No assistant instance found with name '${positionalName}'.`);
53
- process.exit(1);
49
+ let entry: ReturnType<typeof findAssistantByName>;
50
+ if (positionalName) {
51
+ entry = findAssistantByName(positionalName);
52
+ if (!entry) {
53
+ console.error(
54
+ `No assistant instance found with name '${positionalName}'.`,
55
+ );
56
+ process.exit(1);
57
+ }
58
+ } else if (process.env.RUNTIME_URL) {
59
+ // Explicit env var — skip assistant resolution, will use env values below
60
+ entry = loadLatestAssistant();
61
+ } else {
62
+ // Respect active assistant when set, otherwise fall back to latest
63
+ // for backward compatibility with remote-only setups.
64
+ const active = getActiveAssistant();
65
+ const activeEntry = active ? findAssistantByName(active) : null;
66
+ entry = activeEntry ?? loadLatestAssistant();
54
67
  }
55
68
 
56
69
  let runtimeUrl =
57
- process.env.RUNTIME_URL || entry?.runtimeUrl || FALLBACK_RUNTIME_URL;
70
+ process.env.RUNTIME_URL ||
71
+ entry?.localUrl ||
72
+ entry?.runtimeUrl ||
73
+ FALLBACK_RUNTIME_URL;
58
74
  let assistantId =
59
75
  process.env.ASSISTANT_ID || entry?.assistantId || FALLBACK_ASSISTANT_ID;
60
76
  const bearerToken =