@vellumai/cli 0.8.6 → 0.8.7

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.
Files changed (43) hide show
  1. package/bun.lock +8 -0
  2. package/knip.json +5 -1
  3. package/node_modules/@vellumai/environments/bun.lock +24 -0
  4. package/node_modules/@vellumai/environments/package.json +18 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  6. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  7. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  8. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  9. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  10. package/node_modules/@vellumai/local-mode/package.json +21 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +93 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  13. package/node_modules/@vellumai/local-mode/src/config.ts +59 -0
  14. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +67 -0
  15. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  16. package/node_modules/@vellumai/local-mode/src/hatch.ts +74 -0
  17. package/node_modules/@vellumai/local-mode/src/index.ts +26 -0
  18. package/node_modules/@vellumai/local-mode/src/lockfile.ts +131 -0
  19. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  20. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  21. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  22. package/package.json +12 -1
  23. package/src/__tests__/env-drift.test.ts +32 -44
  24. package/src/__tests__/flags.test.ts +248 -0
  25. package/src/__tests__/multi-local.test.ts +1 -1
  26. package/src/__tests__/orphan-detection.test.ts +8 -6
  27. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  28. package/src/commands/client.ts +413 -2
  29. package/src/commands/env.ts +1 -1
  30. package/src/commands/flags.ts +89 -17
  31. package/src/components/DefaultMainScreen.tsx +16 -1
  32. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  33. package/src/lib/assistant-config.ts +3 -3
  34. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  35. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  36. package/src/lib/environments/paths.ts +1 -1
  37. package/src/lib/environments/resolve.ts +2 -5
  38. package/src/lib/guardian-token.ts +12 -5
  39. package/src/lib/hatch-local.ts +73 -33
  40. package/src/lib/lifecycle-reporter.ts +31 -0
  41. package/src/lib/retire-local.ts +28 -14
  42. package/src/lib/segments-to-plain-text.ts +35 -0
  43. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -0,0 +1,248 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ spyOn,
8
+ test,
9
+ } from "bun:test";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import { type AssistantEntry } from "../lib/assistant-config.js";
15
+ import { flags } from "../commands/flags.js";
16
+
17
+ const testDir = mkdtempSync(join(tmpdir(), "cli-flags-test-"));
18
+ const originalArgv = [...process.argv];
19
+ const originalExit = process.exit;
20
+ const originalFetch = globalThis.fetch;
21
+ const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
22
+
23
+ let consoleLogSpy: ReturnType<typeof spyOn>;
24
+ let consoleErrorSpy: ReturnType<typeof spyOn>;
25
+ let fetchCalls: Array<{ url: string; method: string }>;
26
+
27
+ function makeEntry(
28
+ assistantId: string,
29
+ extra: Partial<AssistantEntry> = {},
30
+ ): AssistantEntry {
31
+ return {
32
+ assistantId,
33
+ runtimeUrl: `http://127.0.0.1:${7800 + assistantId.length}`,
34
+ cloud: "local",
35
+ ...extra,
36
+ };
37
+ }
38
+
39
+ function writeLockfile(
40
+ entries: AssistantEntry[],
41
+ activeAssistant?: string,
42
+ ): void {
43
+ mkdirSync(testDir, { recursive: true });
44
+ writeFileSync(
45
+ join(testDir, ".vellum.lock.json"),
46
+ JSON.stringify(
47
+ {
48
+ assistants: entries,
49
+ ...(activeAssistant ? { activeAssistant } : {}),
50
+ },
51
+ null,
52
+ 2,
53
+ ),
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Build a Response stub that callers shape per subcommand. `setFlag` needs
59
+ * a 200 OK with the gateway's updated flag payload; `getFlag`/`listFlags`
60
+ * need a flag list. Body content is the minimal valid shape — the tests
61
+ * exercise URL routing, not response parsing.
62
+ */
63
+ function jsonResponse(body: unknown, status = 200): Response {
64
+ return new Response(JSON.stringify(body), {
65
+ status,
66
+ headers: { "content-type": "application/json" },
67
+ });
68
+ }
69
+
70
+ describe("vellum flags --assistant routing", () => {
71
+ beforeEach(() => {
72
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
73
+ rmSync(join(testDir, ".vellum.lock.json"), { force: true });
74
+ fetchCalls = [];
75
+ // Capture every outgoing fetch and respond with a stub matching the
76
+ // subcommand's expected shape. The URL is what the test asserts on.
77
+ globalThis.fetch = (async (
78
+ input: RequestInfo | URL,
79
+ init?: RequestInit,
80
+ ) => {
81
+ const url = typeof input === "string" ? input : input.toString();
82
+ const method = init?.method ?? "GET";
83
+ fetchCalls.push({ url, method });
84
+ if (method === "PATCH") {
85
+ return jsonResponse({
86
+ key: "external-plugins",
87
+ enabled: true,
88
+ defaultEnabled: false,
89
+ label: "External Plugins",
90
+ description: "test",
91
+ });
92
+ }
93
+ return jsonResponse({ flags: [] });
94
+ }) as typeof globalThis.fetch;
95
+ process.exit = ((code?: number) => {
96
+ throw new Error(`process.exit:${code}`);
97
+ }) as typeof process.exit;
98
+ consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
99
+ consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
100
+ });
101
+
102
+ afterEach(() => {
103
+ process.argv = originalArgv;
104
+ process.exit = originalExit;
105
+ globalThis.fetch = originalFetch;
106
+ consoleLogSpy.mockRestore();
107
+ consoleErrorSpy.mockRestore();
108
+ });
109
+
110
+ afterAll(() => {
111
+ if (originalLockfileDir === undefined) {
112
+ delete process.env.VELLUM_LOCKFILE_DIR;
113
+ } else {
114
+ process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
115
+ }
116
+ rmSync(testDir, { recursive: true, force: true });
117
+ });
118
+
119
+ test("set --assistant <id> routes to the explicit instance's runtime URL, not the active one", async () => {
120
+ // Two assistants on different ports. The active one is "alice"; the
121
+ // explicit --assistant target is "bob". A correct routing impl hits
122
+ // bob's URL — a regression that silently uses the active assistant
123
+ // would hit alice's URL.
124
+ writeLockfile(
125
+ [
126
+ makeEntry("alice-1", { name: "Alice" }),
127
+ makeEntry("bob-2", { name: "Bob" }),
128
+ ],
129
+ "alice-1",
130
+ );
131
+ process.argv = [
132
+ "bun",
133
+ "vellum",
134
+ "flags",
135
+ "set",
136
+ "external-plugins",
137
+ "true",
138
+ "--assistant",
139
+ "Bob",
140
+ ];
141
+
142
+ await flags();
143
+
144
+ expect(fetchCalls.length).toBe(1);
145
+ expect(fetchCalls[0].method).toBe("PATCH");
146
+ // bob-2 has assistantId.length === 5, so port = 7800 + 5 = 7805.
147
+ expect(fetchCalls[0].url).toContain("http://127.0.0.1:7805");
148
+ expect(fetchCalls[0].url).toContain(
149
+ "/v1/assistants/bob-2/feature-flags/external-plugins",
150
+ );
151
+ });
152
+
153
+ test("set --assistant <id> placed BEFORE positional args still parses correctly", async () => {
154
+ // Eval harness composes `vellum flags set <key> <value> --assistant <id>`
155
+ // but human users might write `--assistant <id> set <key> <value>`.
156
+ // The extractor strips --assistant from anywhere in argv so positional
157
+ // parsing downstream sees the same shape either way.
158
+ writeLockfile([
159
+ makeEntry("alice-1", { name: "Alice" }),
160
+ makeEntry("bob-2", { name: "Bob" }),
161
+ ]);
162
+ process.argv = [
163
+ "bun",
164
+ "vellum",
165
+ "flags",
166
+ "--assistant",
167
+ "Bob",
168
+ "set",
169
+ "external-plugins",
170
+ "true",
171
+ ];
172
+
173
+ await flags();
174
+
175
+ expect(fetchCalls.length).toBe(1);
176
+ expect(fetchCalls[0].url).toContain(
177
+ "/v1/assistants/bob-2/feature-flags/external-plugins",
178
+ );
179
+ });
180
+
181
+ test("set without --assistant uses the active assistant", async () => {
182
+ // Backwards-compat: behavior unchanged for invocations that don't
183
+ // pass --assistant. The active assistant ("alice-1") wins.
184
+ writeLockfile(
185
+ [
186
+ makeEntry("alice-1", { name: "Alice" }),
187
+ makeEntry("bob-2", { name: "Bob" }),
188
+ ],
189
+ "alice-1",
190
+ );
191
+ process.argv = [
192
+ "bun",
193
+ "vellum",
194
+ "flags",
195
+ "set",
196
+ "external-plugins",
197
+ "true",
198
+ ];
199
+
200
+ await flags();
201
+
202
+ expect(fetchCalls.length).toBe(1);
203
+ // alice-1 has assistantId.length === 7, so port = 7800 + 7 = 7807.
204
+ expect(fetchCalls[0].url).toContain("http://127.0.0.1:7807");
205
+ expect(fetchCalls[0].url).toContain(
206
+ "/v1/assistants/alice-1/feature-flags/external-plugins",
207
+ );
208
+ });
209
+
210
+ test("set --assistant <name> exits with a lookup error when no assistant matches", async () => {
211
+ writeLockfile([makeEntry("alice-1", { name: "Alice" })]);
212
+ process.argv = [
213
+ "bun",
214
+ "vellum",
215
+ "flags",
216
+ "set",
217
+ "external-plugins",
218
+ "true",
219
+ "--assistant",
220
+ "Ghost",
221
+ ];
222
+
223
+ // The Error thrown by createClient propagates out of flags().
224
+ // No fetch should ever fire because lookup fails before the
225
+ // AssistantClient is constructed.
226
+ await expect(flags()).rejects.toThrow(/Ghost/);
227
+ expect(fetchCalls.length).toBe(0);
228
+ });
229
+
230
+ test("--assistant without a value exits via the explicit missing-value branch", async () => {
231
+ writeLockfile([makeEntry("alice-1", { name: "Alice" })]);
232
+ process.argv = [
233
+ "bun",
234
+ "vellum",
235
+ "flags",
236
+ "set",
237
+ "external-plugins",
238
+ "true",
239
+ "--assistant",
240
+ ];
241
+
242
+ await expect(flags()).rejects.toThrow(/process\.exit:1/);
243
+ expect(consoleErrorSpy.mock.calls.flat().join("\n")).toContain(
244
+ "Missing value for --assistant <name>",
245
+ );
246
+ expect(fetchCalls.length).toBe(0);
247
+ });
248
+ });
@@ -156,7 +156,7 @@ describe("multi-local", () => {
156
156
 
157
157
  test("allocation picks env-specific port bases for non-prod envs", async () => {
158
158
  // Each non-prod env sits in its own 1000-port window (see
159
- // environments/seeds.ts). Hatching under VELLUM_ENVIRONMENT=dev should
159
+ // @vellumai/environments seeds). Hatching under VELLUM_ENVIRONMENT=dev should
160
160
  // produce ports in the dev block (18000+), not the production defaults.
161
161
  const prevEnv = process.env.VELLUM_ENVIRONMENT;
162
162
  const prevXdg = process.env.XDG_DATA_HOME;
@@ -3,6 +3,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
+ import type { EnvironmentDefinition } from "@vellumai/environments";
7
+
6
8
  // Point lockfile operations at a temp directory before importing anything that
7
9
  // would otherwise resolve real on-host paths.
8
10
  const testDir = mkdtempSync(join(tmpdir(), "cli-orphan-detection-test-"));
@@ -16,7 +18,6 @@ import {
16
18
  loadAllAssistantsAcrossEnvs,
17
19
  type AssistantEntry,
18
20
  } from "../lib/assistant-config.js";
19
- import type { EnvironmentDefinition } from "../lib/environments/types.js";
20
21
 
21
22
  afterAll(() => {
22
23
  rmSync(testDir, { recursive: true, force: true });
@@ -74,11 +75,12 @@ describe("getKnownPidsFromAssistants", () => {
74
75
  });
75
76
 
76
77
  test("collects daemon, gateway, qdrant, and embed-worker PIDs", () => {
77
- const entry = makeLocalEntry(
78
- "alpha",
79
- join(perTestDir, "alpha"),
80
- { daemon: "100", gateway: "200", qdrant: "300", embed: "400" },
81
- );
78
+ const entry = makeLocalEntry("alpha", join(perTestDir, "alpha"), {
79
+ daemon: "100",
80
+ gateway: "200",
81
+ qdrant: "300",
82
+ embed: "400",
83
+ });
82
84
  const pids = getKnownPidsFromAssistants([entry]);
83
85
  expect(pids).toEqual(new Set(["100", "200", "300", "400"]));
84
86
  });
@@ -0,0 +1,37 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { segmentsToPlainText } from "../lib/segments-to-plain-text.js";
3
+
4
+ describe("segmentsToPlainText", () => {
5
+ test("returns empty string for missing or empty segments", () => {
6
+ // GIVEN a message whose history payload carries no text segments
7
+ // WHEN deriving its flat body
8
+ // THEN it is the empty string (matching the daemon's old empty `content`)
9
+ expect(segmentsToPlainText(undefined)).toBe("");
10
+ expect(segmentsToPlainText([])).toBe("");
11
+ });
12
+
13
+ test("returns a single segment unchanged", () => {
14
+ // GIVEN a plain-text message with one segment
15
+ // WHEN deriving its flat body
16
+ // THEN the segment is returned verbatim
17
+ expect(segmentsToPlainText(["Real reply."])).toBe("Real reply.");
18
+ });
19
+
20
+ test("joins adjacent segments with a single inserted space", () => {
21
+ // GIVEN segments split at a tool_use boundary with no surrounding whitespace
22
+ // WHEN deriving the flat body
23
+ // THEN a single space is inserted between them
24
+ expect(segmentsToPlainText(["before", "after"])).toBe("before after");
25
+ });
26
+
27
+ test("does not double-space when either side already has whitespace", () => {
28
+ // GIVEN segments where one side already ends/starts with whitespace
29
+ // WHEN deriving the flat body
30
+ // THEN no extra space is inserted (mirrors daemon joinWithSpacing)
31
+ expect(segmentsToPlainText(["before ", "after"])).toBe("before after");
32
+ expect(segmentsToPlainText(["before", " after"])).toBe("before after");
33
+ expect(segmentsToPlainText(["line one\n", "line two"])).toBe(
34
+ "line one\nline two",
35
+ );
36
+ });
37
+ });