@vellumai/cli 0.8.1 → 0.8.3

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
@@ -54,27 +54,25 @@ vellum hatch [species] [options]
54
54
 
55
55
  #### Options
56
56
 
57
- | Option | Description |
58
- | ------------------- | ---------------------------------------------------------------------------------------------- |
59
- | `-d` | Detached mode. Start the instance in the background without watching startup progress. |
60
- | `--name <name>` | Use a specific instance name instead of an auto-generated one. |
61
- | `--remote <target>` | Where to provision the instance. One of: `local`, `gcp`, `aws`, `custom`. Defaults to `local`. |
57
+ | Option | Description |
58
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
59
+ | `-d` | Detached mode. Start the instance in the background without watching startup progress. |
60
+ | `--name <name>` | Use a specific instance name instead of an auto-generated one. |
61
+ | `--remote <target>` | Where to provision the instance. One of: `local`, `docker`, `vellum`, `gcp`, `aws`, `custom`. Defaults to `local`. |
62
62
 
63
63
  #### Remote Targets
64
64
 
65
65
  - **`local`** -- Starts the local assistant and local gateway. Gateway source resolution order is: repo source tree, then installed `@vellumai/vellum-gateway` package.
66
- - **`gcp`** -- Creates a GCP Compute Engine VM (`e2-standard-4`: 4 vCPUs, 16 GB) with a startup script that bootstraps the assistant. Requires `gcloud` authentication and `GCP_PROJECT` / `GCP_DEFAULT_ZONE` environment variables.
67
- - **`aws`** -- Provisions an AWS instance.
68
- - **`custom`** -- Provisions on an arbitrary SSH host. Set `VELLUM_CUSTOM_HOST` (e.g. `user@hostname`) to specify the target.
66
+ - **`docker`** -- Starts the assistant, gateway, and credential service in Docker containers.
67
+ - **`vellum`** -- Hatches an assistant on the Vellum platform.
68
+ - **`gcp`** and **`aws`** -- Recognized but not supported as provisioning targets yet. The CLI exits before creating cloud resources. To self-host on AWS/GCP, SSH into the VM and run `vellum hatch` or `vellum hatch --remote docker` there.
69
+ - **`custom`** -- Recognized but not yet implemented.
69
70
 
70
71
  #### Environment Variables
71
72
 
72
- | Variable | Required For | Description |
73
- | -------------------- | ------------ | ---------------------------------------------------------- |
74
- | `ANTHROPIC_API_KEY` | All | Anthropic API key passed to the assistant runtime. |
75
- | `GCP_PROJECT` | `gcp` | GCP project ID. Falls back to the active `gcloud` project. |
76
- | `GCP_DEFAULT_ZONE` | `gcp` | GCP zone for the compute instance. |
77
- | `VELLUM_CUSTOM_HOST` | `custom` | SSH host in `user@hostname` format. |
73
+ | Variable | Required For | Description |
74
+ | ------------------- | ------------ | -------------------------------------------------------------------------- |
75
+ | `ANTHROPIC_API_KEY` | Optional | Used during setup when no Anthropic API key is already stored or prompted. |
78
76
 
79
77
  #### Examples
80
78
 
@@ -82,20 +80,14 @@ vellum hatch [species] [options]
82
80
  # Hatch a local assistant (default)
83
81
  vellum hatch
84
82
 
85
- # Hatch a vellum assistant on GCP
86
- vellum hatch vellum --remote gcp
87
-
88
- # Hatch an openclaw assistant on GCP in detached mode
89
- vellum hatch openclaw --remote gcp -d
83
+ # Hatch a Docker assistant
84
+ vellum hatch --remote docker
90
85
 
91
86
  # Hatch with a specific instance name
92
- vellum hatch --name my-assistant --remote gcp
93
-
94
- # Hatch on a custom SSH host
95
- VELLUM_CUSTOM_HOST=user@10.0.0.1 vellum hatch --remote custom
87
+ vellum hatch --name my-assistant --remote docker
96
88
  ```
97
89
 
98
- 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.
90
+ AWS and GCP hatch targets are recognized so users receive an explicit unsupported-target error instead of an unknown-option error. They currently exit without creating cloud resources; self-hosting on an AWS/GCP VM still works by running `vellum hatch` from inside that machine.
99
91
 
100
92
  ### `terminal`
101
93
 
@@ -111,18 +103,18 @@ Only available for managed assistants (those running in a Vellum Cloud container
111
103
 
112
104
  #### Subcommands
113
105
 
114
- | Subcommand | Description |
115
- | ------------------ | ------------------------------------------------------------------------ |
116
- | _(none)_ | Open an interactive shell session inside the container. |
117
- | `attach <session>` | Attach to an existing `tmux` session by name inside the container. |
118
- | `list` | List the `tmux` sessions currently running inside the container. |
106
+ | Subcommand | Description |
107
+ | ------------------ | ------------------------------------------------------------------ |
108
+ | _(none)_ | Open an interactive shell session inside the container. |
109
+ | `attach <session>` | Attach to an existing `tmux` session by name inside the container. |
110
+ | `list` | List the `tmux` sessions currently running inside the container. |
119
111
 
120
112
  #### Options
121
113
 
122
- | Option | Description |
123
- | -------------------- | -------------------------------------------------------------------------------------------- |
114
+ | Option | Description |
115
+ | -------------------- | --------------------------------------------------------------------------------------------------- |
124
116
  | `[name]` | Positional. Name of the assistant to target. Defaults to the active assistant set via `vellum use`. |
125
- | `--assistant <name>` | Explicit form of the assistant name. Equivalent to the positional argument. |
117
+ | `--assistant <name>` | Explicit form of the assistant name. Equivalent to the positional argument. |
126
118
 
127
119
  If no assistant is named and no active assistant is set, the CLI uses the only managed assistant in the lockfile -- or errors out if there's more than one. Use `vellum ps` to see your assistants and `vellum use <name>` to set the active one.
128
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,12 +81,20 @@ const localRuntimePollJobStatusMock = spyOn(
81
81
 
82
82
  // Mode 1 (runtime-direct local backup) uses guardian tokens. Don't exercise
83
83
  // it here, but the spies need to exist so the module under test can import
84
- // them without surprises.
85
- spyOn(guardianToken, "loadGuardianToken").mockReturnValue({
84
+ // them without surprises. Saved to variables so afterAll can restore them —
85
+ // otherwise the spied loadGuardianToken leaks into guardian-token.test.ts and
86
+ // setup.test.ts when they run later in the same `bun test` invocation.
87
+ const loadGuardianTokenSpy = spyOn(
88
+ guardianToken,
89
+ "loadGuardianToken",
90
+ ).mockReturnValue({
86
91
  accessToken: "local-token",
87
92
  accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
88
93
  } as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
89
- spyOn(guardianToken, "leaseGuardianToken").mockResolvedValue({
94
+ const leaseGuardianTokenSpy = spyOn(
95
+ guardianToken,
96
+ "leaseGuardianToken",
97
+ ).mockResolvedValue({
90
98
  accessToken: "leased-token",
91
99
  accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
92
100
  } as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
@@ -177,6 +185,8 @@ afterAll(() => {
177
185
  getBackupsDirMock.mockRestore();
178
186
  mkdirSyncMock.mockRestore();
179
187
  writeFileSyncMock.mockRestore();
188
+ loadGuardianTokenSpy.mockRestore();
189
+ leaseGuardianTokenSpy.mockRestore();
180
190
  rmSync(testDir, { recursive: true, force: true });
181
191
  });
182
192
 
@@ -1,7 +1,11 @@
1
1
  import { readFileSync, rmSync } from "fs";
2
2
  import { describe, expect, test } from "bun:test";
3
3
 
4
- import { buildNestedConfig, writeInitialConfig } from "../lib/config-utils.js";
4
+ import {
5
+ buildHatchConfigValues,
6
+ buildNestedConfig,
7
+ writeInitialConfig,
8
+ } from "../lib/config-utils.js";
5
9
 
6
10
  function readInitialConfig(
7
11
  configValues: Record<string, string>,
@@ -32,6 +36,32 @@ describe("config-utils", () => {
32
36
  });
33
37
  });
34
38
 
39
+ test("buildHatchConfigValues adds the default hatch provider when no config exists", () => {
40
+ expect(buildHatchConfigValues({}, "anthropic")).toEqual({
41
+ "llm.default.provider": "anthropic",
42
+ });
43
+ });
44
+
45
+ test("buildHatchConfigValues preserves explicit provider config", () => {
46
+ expect(
47
+ buildHatchConfigValues(
48
+ {
49
+ "llm.default.provider": "openai",
50
+ "llm.default.model": "gpt-5.4",
51
+ },
52
+ "anthropic",
53
+ ),
54
+ ).toEqual({
55
+ "llm.default.provider": "openai",
56
+ "llm.default.model": "gpt-5.4",
57
+ });
58
+ });
59
+
60
+ test("buildHatchConfigValues skips internal hatches without provider setup", () => {
61
+ expect(buildHatchConfigValues({}, undefined)).toEqual({});
62
+ expect(buildHatchConfigValues({}, null)).toEqual({});
63
+ });
64
+
35
65
  test("writeInitialConfig does not add a mainAgent callSite for Anthropic defaults", () => {
36
66
  expect(
37
67
  readInitialConfig({
@@ -0,0 +1,284 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ configureHatchProviderApiKey,
5
+ resolveHatchProvider,
6
+ type ProviderSecretFetch,
7
+ } from "../lib/provider-secrets.js";
8
+
9
+ interface RecordedFetchCall {
10
+ url: string;
11
+ init?: RequestInit;
12
+ body: unknown;
13
+ }
14
+
15
+ function jsonResponse(body: unknown, status = 200): Response {
16
+ return new Response(JSON.stringify(body), {
17
+ status,
18
+ headers: { "Content-Type": "application/json" },
19
+ });
20
+ }
21
+
22
+ function makeFetch(responses: Response[]): {
23
+ calls: RecordedFetchCall[];
24
+ fetchImpl: ProviderSecretFetch;
25
+ } {
26
+ const calls: RecordedFetchCall[] = [];
27
+ const fetchImpl: ProviderSecretFetch = async (input, init) => {
28
+ calls.push({
29
+ url: String(input),
30
+ init,
31
+ body: typeof init?.body === "string" ? JSON.parse(init.body) : init?.body,
32
+ });
33
+ const response = responses.shift();
34
+ if (!response) {
35
+ throw new Error("Unexpected fetch call.");
36
+ }
37
+ return response;
38
+ };
39
+
40
+ return { calls, fetchImpl };
41
+ }
42
+
43
+ describe("hatch provider secrets", () => {
44
+ test("defaults hatch provider setup to Anthropic", () => {
45
+ expect(resolveHatchProvider({})).toBe("anthropic");
46
+ });
47
+
48
+ test("uses llm.default.provider override for hatch provider setup", () => {
49
+ expect(resolveHatchProvider({ "llm.default.provider": "openai" })).toBe(
50
+ "openai",
51
+ );
52
+ });
53
+
54
+ test("uses active profile provider for hatch provider setup", () => {
55
+ expect(
56
+ resolveHatchProvider({
57
+ "llm.activeProfile": "work",
58
+ "llm.profiles.work.provider": "openai",
59
+ }),
60
+ ).toBe("openai");
61
+ });
62
+
63
+ test("infers active profile provider from model for hatch provider setup", () => {
64
+ expect(
65
+ resolveHatchProvider({
66
+ "llm.activeProfile": "work",
67
+ "llm.profiles.work.model": "gpt-5.4",
68
+ }),
69
+ ).toBe("openai");
70
+ });
71
+
72
+ test("active profile provider wins over main agent call-site provider", () => {
73
+ expect(
74
+ resolveHatchProvider({
75
+ "llm.activeProfile": "work",
76
+ "llm.profiles.work.provider": "openai",
77
+ "llm.callSites.mainAgent.provider": "gemini",
78
+ }),
79
+ ).toBe("openai");
80
+ });
81
+
82
+ test("uses default provider before static main agent call-site provider", () => {
83
+ expect(
84
+ resolveHatchProvider({
85
+ "llm.default.provider": "anthropic",
86
+ "llm.callSites.mainAgent.provider": "gemini",
87
+ }),
88
+ ).toBe("anthropic");
89
+ });
90
+
91
+ test("uses default provider before static main agent call-site profile", () => {
92
+ expect(
93
+ resolveHatchProvider({
94
+ "llm.default.provider": "anthropic",
95
+ "llm.callSites.mainAgent.profile": "work",
96
+ "llm.profiles.work.provider": "openai",
97
+ }),
98
+ ).toBe("anthropic");
99
+ });
100
+
101
+ test("uses main agent call-site provider when no hatch default exists", () => {
102
+ expect(
103
+ resolveHatchProvider({
104
+ "llm.callSites.mainAgent.provider": "gemini",
105
+ }),
106
+ ).toBe("gemini");
107
+ });
108
+
109
+ test("uses main agent call-site profile when no hatch default exists", () => {
110
+ expect(
111
+ resolveHatchProvider({
112
+ "llm.callSites.mainAgent.profile": "work",
113
+ "llm.profiles.work.provider": "openai",
114
+ }),
115
+ ).toBe("openai");
116
+ });
117
+
118
+ test("infers default provider from model before falling back to Anthropic", () => {
119
+ expect(
120
+ resolveHatchProvider({ "llm.default.model": "gemini-2.5-flash" }),
121
+ ).toBe("gemini");
122
+ });
123
+
124
+ test("skips hatch provider setup for ollama", () => {
125
+ expect(
126
+ resolveHatchProvider({ "llm.default.provider": "ollama" }),
127
+ ).toBeNull();
128
+ });
129
+
130
+ test("skips hatch provider setup for active Ollama profile", () => {
131
+ expect(
132
+ resolveHatchProvider({
133
+ "llm.activeProfile": "local",
134
+ "llm.profiles.local.model": "llama3.2",
135
+ }),
136
+ ).toBeNull();
137
+ });
138
+
139
+ test("rejects unsupported hatch providers before hatch starts", () => {
140
+ expect(() =>
141
+ resolveHatchProvider({ "llm.default.provider": "custom" }),
142
+ ).toThrow("supported API-key setup flow");
143
+ });
144
+
145
+ test("configures default Anthropic credentials from the environment", async () => {
146
+ const { calls, fetchImpl } = makeFetch([
147
+ jsonResponse({ found: false }),
148
+ jsonResponse({ success: true }),
149
+ ]);
150
+ const logs: string[] = [];
151
+
152
+ await configureHatchProviderApiKey({
153
+ gatewayUrl: "http://127.0.0.1:7830",
154
+ provider: resolveHatchProvider({}),
155
+ bearerToken: "guardian-token",
156
+ env: { ANTHROPIC_API_KEY: "test-anthropic-key" },
157
+ fetchImpl,
158
+ log: (message) => logs.push(message),
159
+ });
160
+
161
+ expect(calls).toHaveLength(2);
162
+ expect(calls[0].body).toEqual({
163
+ type: "api_key",
164
+ name: "anthropic",
165
+ reveal: false,
166
+ });
167
+ expect(calls[0].init?.headers).toMatchObject({
168
+ Authorization: "Bearer guardian-token",
169
+ });
170
+ expect(calls[1].body).toEqual({
171
+ type: "api_key",
172
+ name: "anthropic",
173
+ value: "test-anthropic-key",
174
+ });
175
+ expect(logs.join("\n")).toContain(
176
+ "Configured Anthropic credentials from ANTHROPIC_API_KEY.",
177
+ );
178
+ expect(logs.join("\n")).not.toContain("test-anthropic-key");
179
+ });
180
+
181
+ test("uses OpenAI override and skips prompt when credentials already exist", async () => {
182
+ const { calls, fetchImpl } = makeFetch([jsonResponse({ found: true })]);
183
+ const logs: string[] = [];
184
+ let prompted = false;
185
+
186
+ await configureHatchProviderApiKey({
187
+ gatewayUrl: "http://127.0.0.1:7830",
188
+ provider: resolveHatchProvider({ "llm.default.provider": "openai" }),
189
+ bearerToken: "guardian-token",
190
+ env: {},
191
+ fetchImpl,
192
+ prompt: async () => {
193
+ prompted = true;
194
+ return "unused";
195
+ },
196
+ log: (message) => logs.push(message),
197
+ });
198
+
199
+ expect(prompted).toBe(false);
200
+ expect(calls).toHaveLength(1);
201
+ expect(calls[0].body).toEqual({
202
+ type: "api_key",
203
+ name: "openai",
204
+ reveal: false,
205
+ });
206
+ expect(logs.join("\n")).toContain(
207
+ "Provider credentials already configured for OpenAI.",
208
+ );
209
+ });
210
+
211
+ test("uses active profile provider when selecting environment credential", async () => {
212
+ const { calls, fetchImpl } = makeFetch([
213
+ jsonResponse({ found: false }),
214
+ jsonResponse({ success: true }),
215
+ ]);
216
+
217
+ await configureHatchProviderApiKey({
218
+ gatewayUrl: "http://127.0.0.1:7830",
219
+ provider: resolveHatchProvider({
220
+ "llm.activeProfile": "work",
221
+ "llm.profiles.work.provider": "openai",
222
+ }),
223
+ bearerToken: "guardian-token",
224
+ env: { OPENAI_API_KEY: "test-openai-key" },
225
+ fetchImpl,
226
+ log: () => {},
227
+ });
228
+
229
+ expect(calls[0].body).toEqual({
230
+ type: "api_key",
231
+ name: "openai",
232
+ reveal: false,
233
+ });
234
+ expect(calls[1].body).toEqual({
235
+ type: "api_key",
236
+ name: "openai",
237
+ value: "test-openai-key",
238
+ });
239
+ });
240
+
241
+ test("keeps hatch recoverable when provider credentials are missing in a non-interactive shell", async () => {
242
+ const { fetchImpl } = makeFetch([jsonResponse({ found: false })]);
243
+ const logs: string[] = [];
244
+
245
+ await configureHatchProviderApiKey({
246
+ gatewayUrl: "http://127.0.0.1:7830",
247
+ provider: "anthropic",
248
+ env: {},
249
+ fetchImpl,
250
+ stdinIsTTY: false,
251
+ log: (message) => logs.push(message),
252
+ });
253
+
254
+ const output = logs.join("\n");
255
+ expect(output).toContain("Provider credential setup skipped");
256
+ expect(output).toContain("Missing ANTHROPIC_API_KEY");
257
+ expect(output).toContain("vellum setup --provider anthropic");
258
+ });
259
+
260
+ test("surfaces gateway validation failures without throwing or logging the key", async () => {
261
+ const { fetchImpl } = makeFetch([
262
+ jsonResponse({ found: false }),
263
+ jsonResponse(
264
+ { error: { message: "API key is invalid or expired." } },
265
+ 400,
266
+ ),
267
+ ]);
268
+ const logs: string[] = [];
269
+
270
+ await configureHatchProviderApiKey({
271
+ gatewayUrl: "http://127.0.0.1:7830",
272
+ provider: "anthropic",
273
+ env: { ANTHROPIC_API_KEY: "test-anthropic-key" },
274
+ fetchImpl,
275
+ log: (message) => logs.push(message),
276
+ });
277
+
278
+ const output = logs.join("\n");
279
+ expect(output).toContain("Provider credential setup failed");
280
+ expect(output).toContain("API key is invalid or expired.");
281
+ expect(output).toContain("vellum setup --provider anthropic");
282
+ expect(output).not.toContain("test-anthropic-key");
283
+ });
284
+ });
@@ -0,0 +1,102 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ } from "node:fs";
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import { getInputHistoryPath } from "../lib/environments/paths.js";
12
+ import { appendHistory, loadHistory } from "../lib/input-history.js";
13
+
14
+ describe("input-history XDG paths", () => {
15
+ let tempDir: string;
16
+ let savedState: string | undefined;
17
+
18
+ beforeEach(() => {
19
+ savedState = process.env.XDG_STATE_HOME;
20
+ tempDir = mkdtempSync(join(tmpdir(), "cli-input-history-test-"));
21
+ process.env.XDG_STATE_HOME = join(tempDir, ".local", "state");
22
+ });
23
+
24
+ afterEach(() => {
25
+ if (savedState === undefined) {
26
+ delete process.env.XDG_STATE_HOME;
27
+ } else {
28
+ process.env.XDG_STATE_HOME = savedState;
29
+ }
30
+ rmSync(tempDir, { recursive: true, force: true });
31
+ });
32
+
33
+ test("appendHistory writes to $XDG_STATE_HOME/vellum/input-history", () => {
34
+ appendHistory("hello world");
35
+
36
+ const canonical = getInputHistoryPath();
37
+ expect(canonical).toBe(
38
+ join(tempDir, ".local", "state", "vellum", "input-history"),
39
+ );
40
+ expect(existsSync(canonical)).toBe(true);
41
+ expect(readFileSync(canonical, "utf-8")).toBe("hello world\n");
42
+ });
43
+
44
+ test("appendHistory does NOT touch ~/.vellum/", () => {
45
+ // Crucially: the CLI must not create or write to ~/.vellum/ per the
46
+ // "No `.vellum/` directory access" boundary in cli/AGENTS.md. We snapshot
47
+ // the legacy path's existence before the call (some test machines already
48
+ // have a ~/.vellum/ for unrelated daemon state) and assert the file at
49
+ // that path is unchanged afterwards.
50
+ const legacyPath = join(homedir(), ".vellum", "input-history");
51
+ const existedBefore = existsSync(legacyPath);
52
+ const contentBefore: string = existedBefore
53
+ ? readFileSync(legacyPath, "utf-8")
54
+ : "";
55
+
56
+ appendHistory("hello");
57
+
58
+ expect(existsSync(legacyPath)).toBe(existedBefore);
59
+ if (existedBefore) {
60
+ expect(readFileSync(legacyPath, "utf-8")).toBe(contentBefore);
61
+ }
62
+ });
63
+
64
+ test("XDG_STATE_HOME default is ~/.local/state when unset", () => {
65
+ delete process.env.XDG_STATE_HOME;
66
+
67
+ // os.homedir() is cached at process start by Bun and ignores
68
+ // process.env.HOME mutations, so compute the expected path from the same
69
+ // source the production helper uses.
70
+ expect(getInputHistoryPath()).toBe(
71
+ join(homedir(), ".local", "state", "vellum", "input-history"),
72
+ );
73
+ });
74
+
75
+ test("appendHistory skips empty and slash-command entries", () => {
76
+ appendHistory("");
77
+ appendHistory(" ");
78
+ appendHistory("/help");
79
+ appendHistory("real entry");
80
+
81
+ expect(loadHistory()).toEqual(["real entry"]);
82
+ });
83
+
84
+ test("appendHistory deduplicates by moving to most recent", () => {
85
+ appendHistory("a");
86
+ appendHistory("b");
87
+ appendHistory("a");
88
+
89
+ expect(loadHistory()).toEqual(["b", "a"]);
90
+ });
91
+
92
+ test("appendHistory caps history at MAX_ENTRIES (1000)", () => {
93
+ for (let i = 0; i < 1100; i++) {
94
+ appendHistory(`entry-${i}`);
95
+ }
96
+
97
+ const history = loadHistory();
98
+ expect(history.length).toBe(1000);
99
+ expect(history[0]).toBe("entry-100");
100
+ expect(history[999]).toBe("entry-1099");
101
+ });
102
+ });
@@ -10,7 +10,7 @@
10
10
  import { mkdtempSync, realpathSync, rmSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
- import { afterAll } from "bun:test";
13
+ import { afterAll, mock } from "bun:test";
14
14
 
15
15
  const testDir = realpathSync(
16
16
  mkdtempSync(join(tmpdir(), "vellum-cli-test-workspace-")),
@@ -24,4 +24,8 @@ afterAll(() => {
24
24
  } catch {
25
25
  /* best-effort cleanup */
26
26
  }
27
+
28
+ // Reset all module mocks so mock.module() calls in one test file
29
+ // don't leak into the next file in the same bun test run.
30
+ mock.restore();
27
31
  });