@vellumai/cli 0.8.0 → 0.8.2

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.
@@ -20,7 +20,19 @@ const testDir = mkdtempSync(join(tmpdir(), "cli-teleport-test-"));
20
20
  process.env.VELLUM_LOCKFILE_DIR = testDir;
21
21
 
22
22
  // ---------------------------------------------------------------------------
23
- // Mocks — must be set up before importing the module under test
23
+ // Mocks — must be set up before importing the module under test.
24
+ //
25
+ // We use Bun's `mock.module()` API (rather than `spyOn` on the imported
26
+ // module namespace). `spyOn` mutates the shared module namespace, and
27
+ // `mockRestore()` in `afterAll` was not reliably restoring on CI — the
28
+ // mutations leaked into sibling test files (notably `guardian-token.test.ts`)
29
+ // and caused flaky failures when the runtime function for that file returned
30
+ // teleport's mock value instead of the real implementation.
31
+ //
32
+ // `mock.module()` registers a per-module factory in Bun's loader. To prevent
33
+ // the mock from leaking across files in the same `bun test` run, we capture
34
+ // the real exports up front and re-register them in `afterAll` to restore
35
+ // the original module bindings.
24
36
  // ---------------------------------------------------------------------------
25
37
 
26
38
  import * as assistantConfig from "../lib/assistant-config.js";
@@ -28,83 +40,95 @@ import * as guardianToken from "../lib/guardian-token.js";
28
40
  import * as platformClient from "../lib/platform-client.js";
29
41
  import * as localRuntimeClient from "../lib/local-runtime-client.js";
30
42
 
31
- const findAssistantByNameMock = spyOn(
32
- assistantConfig,
33
- "findAssistantByName",
34
- ).mockReturnValue(null);
35
-
36
- const saveAssistantEntryMock = spyOn(
37
- assistantConfig,
38
- "saveAssistantEntry",
39
- ).mockImplementation(() => {});
40
-
41
- const loadAllAssistantsMock = spyOn(
42
- assistantConfig,
43
- "loadAllAssistants",
44
- ).mockReturnValue([]);
45
-
46
- const removeAssistantEntryMock = spyOn(
47
- assistantConfig,
48
- "removeAssistantEntry",
49
- ).mockImplementation(() => {});
50
-
51
- const loadGuardianTokenMock = spyOn(
52
- guardianToken,
53
- "loadGuardianToken",
54
- ).mockReturnValue({
55
- accessToken: "local-token",
56
- accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
57
- } as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
58
-
59
- const leaseGuardianTokenMock = spyOn(
60
- guardianToken,
61
- "leaseGuardianToken",
62
- ).mockResolvedValue({
63
- accessToken: "leased-token",
64
- accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
65
- } as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
66
-
67
- const computeDeviceIdMock = spyOn(
68
- guardianToken,
69
- "computeDeviceId",
70
- ).mockReturnValue("device-id-123");
71
-
72
- const readPlatformTokenMock = spyOn(
73
- platformClient,
74
- "readPlatformToken",
75
- ).mockReturnValue("platform-token");
76
-
77
- const getPlatformUrlMock = spyOn(
78
- platformClient,
79
- "getPlatformUrl",
80
- ).mockReturnValue("https://platform.vellum.ai");
81
-
82
- const hatchAssistantMock = spyOn(
83
- platformClient,
84
- "hatchAssistant",
85
- ).mockResolvedValue({
86
- assistant: {
87
- id: "platform-new-id",
88
- name: "platform-new",
89
- status: "active",
90
- },
91
- reusedExisting: false,
92
- });
43
+ // Snapshot the real exports before any `mock.module()` call so we can
44
+ // reliably restore them after this file's tests complete, regardless of
45
+ // how Bun's loader treats namespace bindings post-mock.
46
+ const realAssistantConfig = { ...assistantConfig };
47
+ const realGuardianToken = { ...guardianToken };
48
+ const realPlatformClient = { ...platformClient };
49
+ const realLocalRuntimeClient = { ...localRuntimeClient };
50
+
51
+ const findAssistantByNameMock = mock<
52
+ typeof assistantConfig.findAssistantByName
53
+ >(() => null);
54
+ const saveAssistantEntryMock = mock<typeof assistantConfig.saveAssistantEntry>(
55
+ () => {},
56
+ );
57
+ const loadAllAssistantsMock = mock<typeof assistantConfig.loadAllAssistants>(
58
+ () => [],
59
+ );
60
+ const removeAssistantEntryMock = mock<
61
+ typeof assistantConfig.removeAssistantEntry
62
+ >(() => {});
63
+
64
+ mock.module("../lib/assistant-config.js", () => ({
65
+ ...realAssistantConfig,
66
+ findAssistantByName: findAssistantByNameMock,
67
+ saveAssistantEntry: saveAssistantEntryMock,
68
+ loadAllAssistants: loadAllAssistantsMock,
69
+ removeAssistantEntry: removeAssistantEntryMock,
70
+ }));
71
+
72
+ const loadGuardianTokenMock = mock<typeof guardianToken.loadGuardianToken>(
73
+ () =>
74
+ ({
75
+ accessToken: "local-token",
76
+ accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
77
+ }) as unknown as ReturnType<typeof guardianToken.loadGuardianToken>,
78
+ );
79
+
80
+ const leaseGuardianTokenMock = mock<typeof guardianToken.leaseGuardianToken>(
81
+ async () =>
82
+ ({
83
+ accessToken: "leased-token",
84
+ accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
85
+ }) as unknown as Awaited<
86
+ ReturnType<typeof guardianToken.leaseGuardianToken>
87
+ >,
88
+ );
89
+
90
+ const computeDeviceIdMock = mock<typeof guardianToken.computeDeviceId>(
91
+ () => "device-id-123",
92
+ );
93
+
94
+ mock.module("../lib/guardian-token.js", () => ({
95
+ ...realGuardianToken,
96
+ loadGuardianToken: loadGuardianTokenMock,
97
+ leaseGuardianToken: leaseGuardianTokenMock,
98
+ computeDeviceId: computeDeviceIdMock,
99
+ }));
100
+
101
+ const readPlatformTokenMock = mock<typeof platformClient.readPlatformToken>(
102
+ () => "platform-token",
103
+ );
104
+
105
+ const getPlatformUrlMock = mock<typeof platformClient.getPlatformUrl>(
106
+ () => "https://platform.vellum.ai",
107
+ );
108
+
109
+ const hatchAssistantMock = mock<typeof platformClient.hatchAssistant>(
110
+ async () => ({
111
+ assistant: {
112
+ id: "platform-new-id",
113
+ name: "platform-new",
114
+ status: "active",
115
+ },
116
+ reusedExisting: false,
117
+ }),
118
+ );
93
119
 
94
- const platformPollJobStatusMock = spyOn(
95
- platformClient,
96
- "platformPollJobStatus",
97
- ).mockResolvedValue({
120
+ const platformPollJobStatusMock = mock<
121
+ typeof platformClient.platformPollJobStatus
122
+ >(async () => ({
98
123
  jobId: "platform-job-1",
99
124
  type: "export",
100
125
  status: "complete",
101
126
  bundleKey: "platform-bundle-key-abc",
102
- });
127
+ }));
103
128
 
104
- const platformRequestSignedUrlMock = spyOn(
105
- platformClient,
106
- "platformRequestSignedUrl",
107
- ).mockImplementation(async (params) => ({
129
+ const platformRequestSignedUrlMock = mock<
130
+ typeof platformClient.platformRequestSignedUrl
131
+ >(async (params) => ({
108
132
  url:
109
133
  params.operation === "upload"
110
134
  ? "https://storage.googleapis.com/bucket/signed-upload"
@@ -113,10 +137,9 @@ const platformRequestSignedUrlMock = spyOn(
113
137
  expiresAt: new Date(Date.now() + 3600_000).toISOString(),
114
138
  }));
115
139
 
116
- const platformImportBundleFromGcsMock = spyOn(
117
- platformClient,
118
- "platformImportBundleFromGcs",
119
- ).mockResolvedValue({
140
+ const platformImportBundleFromGcsMock = mock<
141
+ typeof platformClient.platformImportBundleFromGcs
142
+ >(async () => ({
120
143
  statusCode: 200,
121
144
  body: {
122
145
  success: true,
@@ -128,12 +151,11 @@ const platformImportBundleFromGcsMock = spyOn(
128
151
  backups_created: 1,
129
152
  },
130
153
  } as Record<string, unknown>,
131
- });
154
+ }));
132
155
 
133
- const platformImportPreflightFromGcsMock = spyOn(
134
- platformClient,
135
- "platformImportPreflightFromGcs",
136
- ).mockResolvedValue({
156
+ const platformImportPreflightFromGcsMock = mock<
157
+ typeof platformClient.platformImportPreflightFromGcs
158
+ >(async () => ({
137
159
  statusCode: 200,
138
160
  body: {
139
161
  can_import: true,
@@ -144,71 +166,84 @@ const platformImportPreflightFromGcsMock = spyOn(
144
166
  total_files: 3,
145
167
  },
146
168
  } as Record<string, unknown>,
147
- });
169
+ }));
148
170
 
149
- const checkExistingPlatformAssistantMock = spyOn(
150
- platformClient,
151
- "checkExistingPlatformAssistant",
152
- ).mockResolvedValue(null);
153
-
154
- const ensureSelfHostedLocalRegistrationMock = spyOn(
155
- platformClient,
156
- "ensureSelfHostedLocalRegistration",
157
- ).mockResolvedValue({
158
- assistant: { id: "platform-assistant-1", name: "my-assistant" },
159
- registration: {
160
- client_installation_id: "device-id-123",
161
- runtime_assistant_id: "target-local",
162
- client_platform: "cli",
163
- },
164
- assistant_api_key: "api-key-123",
165
- webhook_secret: "webhook-secret-123",
166
- } as unknown as Awaited<
167
- ReturnType<typeof platformClient.ensureSelfHostedLocalRegistration>
168
- >);
169
-
170
- const injectCredentialsIntoAssistantMock = spyOn(
171
- platformClient,
172
- "injectCredentialsIntoAssistant",
173
- ).mockResolvedValue(true);
174
-
175
- const fetchCurrentUserMock = spyOn(
176
- platformClient,
177
- "fetchCurrentUser",
178
- ).mockResolvedValue({
179
- id: "user-1",
180
- email: "test@example.com",
181
- display: "Test",
182
- } as unknown as Awaited<ReturnType<typeof platformClient.fetchCurrentUser>>);
183
-
184
- const fetchOrganizationIdMock = spyOn(
185
- platformClient,
186
- "fetchOrganizationId",
187
- ).mockResolvedValue("org-1");
188
-
189
- const localRuntimeExportToGcsMock = spyOn(
190
- localRuntimeClient,
191
- "localRuntimeExportToGcs",
192
- ).mockResolvedValue({ jobId: "local-export-job-1" });
193
-
194
- const localRuntimeImportFromGcsMock = spyOn(
195
- localRuntimeClient,
196
- "localRuntimeImportFromGcs",
197
- ).mockResolvedValue({ jobId: "local-import-job-1" });
171
+ const checkExistingPlatformAssistantMock = mock<
172
+ typeof platformClient.checkExistingPlatformAssistant
173
+ >(async () => null);
174
+
175
+ const ensureSelfHostedLocalRegistrationMock = mock<
176
+ typeof platformClient.ensureSelfHostedLocalRegistration
177
+ >(
178
+ async () =>
179
+ ({
180
+ assistant: { id: "platform-assistant-1", name: "my-assistant" },
181
+ registration: {
182
+ client_installation_id: "device-id-123",
183
+ runtime_assistant_id: "target-local",
184
+ client_platform: "cli",
185
+ },
186
+ assistant_api_key: "api-key-123",
187
+ webhook_secret: "webhook-secret-123",
188
+ }) as unknown as Awaited<
189
+ ReturnType<typeof platformClient.ensureSelfHostedLocalRegistration>
190
+ >,
191
+ );
192
+
193
+ const injectCredentialsIntoAssistantMock = mock<
194
+ typeof platformClient.injectCredentialsIntoAssistant
195
+ >(async () => true);
196
+
197
+ const fetchCurrentUserMock = mock<typeof platformClient.fetchCurrentUser>(
198
+ async () =>
199
+ ({
200
+ id: "user-1",
201
+ email: "test@example.com",
202
+ display: "Test",
203
+ }) as unknown as Awaited<
204
+ ReturnType<typeof platformClient.fetchCurrentUser>
205
+ >,
206
+ );
207
+
208
+ const fetchOrganizationIdMock = mock<typeof platformClient.fetchOrganizationId>(
209
+ async () => "org-1",
210
+ );
211
+
212
+ mock.module("../lib/platform-client.js", () => ({
213
+ ...realPlatformClient,
214
+ readPlatformToken: readPlatformTokenMock,
215
+ getPlatformUrl: getPlatformUrlMock,
216
+ hatchAssistant: hatchAssistantMock,
217
+ platformPollJobStatus: platformPollJobStatusMock,
218
+ platformRequestSignedUrl: platformRequestSignedUrlMock,
219
+ platformImportBundleFromGcs: platformImportBundleFromGcsMock,
220
+ platformImportPreflightFromGcs: platformImportPreflightFromGcsMock,
221
+ checkExistingPlatformAssistant: checkExistingPlatformAssistantMock,
222
+ ensureSelfHostedLocalRegistration: ensureSelfHostedLocalRegistrationMock,
223
+ injectCredentialsIntoAssistant: injectCredentialsIntoAssistantMock,
224
+ fetchCurrentUser: fetchCurrentUserMock,
225
+ fetchOrganizationId: fetchOrganizationIdMock,
226
+ }));
227
+
228
+ const localRuntimeExportToGcsMock = mock<
229
+ typeof localRuntimeClient.localRuntimeExportToGcs
230
+ >(async () => ({ jobId: "local-export-job-1" }));
231
+
232
+ const localRuntimeImportFromGcsMock = mock<
233
+ typeof localRuntimeClient.localRuntimeImportFromGcs
234
+ >(async () => ({ jobId: "local-import-job-1" }));
198
235
 
199
236
  // Default to a fixed version string. Tests that exercise the version-gate
200
237
  // surface override this mock per-case to assert the value flows from the
201
238
  // target runtime's `/v1/identity` (NOT from `cliPkg.version`) into the
202
239
  // download signed-URL request.
203
- const localRuntimeIdentityMock = spyOn(
204
- localRuntimeClient,
205
- "localRuntimeIdentity",
206
- ).mockResolvedValue({ version: "0.6.5" });
207
-
208
- const localRuntimePollJobStatusMock = spyOn(
209
- localRuntimeClient,
210
- "localRuntimePollJobStatus",
211
- ).mockImplementation(async (_runtimeUrl, _token, jobId) => ({
240
+ const localRuntimeIdentityMock = mock<
241
+ typeof localRuntimeClient.localRuntimeIdentity
242
+ >(async () => ({ version: "0.6.5" }));
243
+
244
+ const localRuntimePollJobStatusMock = mock<
245
+ typeof localRuntimeClient.localRuntimePollJobStatus
246
+ >(async (_runtimeUrl, _token, jobId) => ({
212
247
  jobId,
213
248
  type: jobId.includes("import") ? "import" : "export",
214
249
  status: "complete",
@@ -224,6 +259,14 @@ const localRuntimePollJobStatusMock = spyOn(
224
259
  },
225
260
  }));
226
261
 
262
+ mock.module("../lib/local-runtime-client.js", () => ({
263
+ ...realLocalRuntimeClient,
264
+ localRuntimeExportToGcs: localRuntimeExportToGcsMock,
265
+ localRuntimeImportFromGcs: localRuntimeImportFromGcsMock,
266
+ localRuntimeIdentity: localRuntimeIdentityMock,
267
+ localRuntimePollJobStatus: localRuntimePollJobStatusMock,
268
+ }));
269
+
227
270
  const hatchLocalMock = mock(async () => {});
228
271
 
229
272
  mock.module("../lib/hatch-local.js", () => ({
@@ -285,29 +328,13 @@ import type { AssistantEntry } from "../lib/assistant-config.js";
285
328
  // ---------------------------------------------------------------------------
286
329
 
287
330
  afterAll(() => {
288
- findAssistantByNameMock.mockRestore();
289
- saveAssistantEntryMock.mockRestore();
290
- loadAllAssistantsMock.mockRestore();
291
- removeAssistantEntryMock.mockRestore();
292
- loadGuardianTokenMock.mockRestore();
293
- leaseGuardianTokenMock.mockRestore();
294
- readPlatformTokenMock.mockRestore();
295
- getPlatformUrlMock.mockRestore();
296
- hatchAssistantMock.mockRestore();
297
- checkExistingPlatformAssistantMock.mockRestore();
298
- platformPollJobStatusMock.mockRestore();
299
- platformRequestSignedUrlMock.mockRestore();
300
- platformImportBundleFromGcsMock.mockRestore();
301
- platformImportPreflightFromGcsMock.mockRestore();
302
- ensureSelfHostedLocalRegistrationMock.mockRestore();
303
- injectCredentialsIntoAssistantMock.mockRestore();
304
- fetchCurrentUserMock.mockRestore();
305
- fetchOrganizationIdMock.mockRestore();
306
- computeDeviceIdMock.mockRestore();
307
- localRuntimeExportToGcsMock.mockRestore();
308
- localRuntimeImportFromGcsMock.mockRestore();
309
- localRuntimeIdentityMock.mockRestore();
310
- localRuntimePollJobStatusMock.mockRestore();
331
+ // Restore the real module exports so the mocks do not leak into sibling
332
+ // test files (e.g. guardian-token.test.ts, platform-client tests) that
333
+ // run in the same `bun test` invocation.
334
+ mock.module("../lib/assistant-config.js", () => realAssistantConfig);
335
+ mock.module("../lib/guardian-token.js", () => realGuardianToken);
336
+ mock.module("../lib/platform-client.js", () => realPlatformClient);
337
+ mock.module("../lib/local-runtime-client.js", () => realLocalRuntimeClient);
311
338
  rmSync(testDir, { recursive: true, force: true });
312
339
  delete process.env.VELLUM_LOCKFILE_DIR;
313
340
  });
@@ -1,9 +1,12 @@
1
- import { hostname } from "os";
1
+ import { existsSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import path from "node:path";
2
4
 
3
5
  import {
4
6
  findAssistantByName,
5
7
  getActiveAssistant,
6
8
  resolveAssistant,
9
+ saveAssistantEntry,
7
10
  } from "../lib/assistant-config";
8
11
  import {
9
12
  DAEMON_INTERNAL_ASSISTANT_ID,
@@ -14,15 +17,17 @@ import { loadGuardianToken } from "../lib/guardian-token";
14
17
  import { getLocalLanIPv4 } from "../lib/local";
15
18
  import {
16
19
  CLI_INTERFACE_ID,
20
+ WEB_INTERFACE_ID,
17
21
  getClientRegistrationHeaders,
18
22
  } from "../lib/client-identity";
19
23
  import {
20
24
  fetchOrganizationId,
25
+ fetchPlatformAssistants,
21
26
  readPlatformToken,
22
27
  } from "../lib/platform-client";
23
28
  import { tuiLog } from "../lib/tui-log";
24
29
 
25
- const SUPPORTED_INTERFACES = ["cli"] as const;
30
+ const SUPPORTED_INTERFACES = ["cli", "web"] as const;
26
31
  type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
27
32
 
28
33
  const ANSI = {
@@ -36,6 +41,7 @@ const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
36
41
  interface ParsedArgs {
37
42
  runtimeUrl: string;
38
43
  assistantId: string;
44
+ assistantName?: string;
39
45
  species: Species;
40
46
  /** "vellum" for platform-hosted assistants, undefined for local. */
41
47
  cloud?: string;
@@ -49,6 +55,15 @@ interface ParsedArgs {
49
55
  zone?: string;
50
56
  }
51
57
 
58
+ function readAssistantName(
59
+ entry: ReturnType<typeof findAssistantByName>,
60
+ ): string | undefined {
61
+ const rawName = entry?.name ?? entry?.assistantName;
62
+ return typeof rawName === "string" && rawName.trim()
63
+ ? rawName.trim()
64
+ : undefined;
65
+ }
66
+
52
67
  function parseArgs(): ParsedArgs {
53
68
  const args = process.argv.slice(3);
54
69
 
@@ -109,6 +124,7 @@ function parseArgs(): ParsedArgs {
109
124
 
110
125
  let runtimeUrl = entry?.localUrl || entry?.runtimeUrl || FALLBACK_RUNTIME_URL;
111
126
  let assistantId = entry?.assistantId || DAEMON_INTERNAL_ASSISTANT_ID;
127
+ let assistantName = readAssistantName(entry);
112
128
  const cloud = entry?.cloud;
113
129
  const species: Species = (entry?.species as Species) ?? "vellum";
114
130
 
@@ -131,14 +147,9 @@ function parseArgs(): ParsedArgs {
131
147
  flagArgs[i + 1]
132
148
  ) {
133
149
  assistantId = flagArgs[++i];
150
+ assistantName = undefined;
134
151
  } else if ((flag === "--interface" || flag === "-i") && flagArgs[i + 1]) {
135
152
  const value = flagArgs[++i];
136
- if (value === "web") {
137
- console.error(
138
- `--interface web is not yet supported. Coming soon.`,
139
- );
140
- process.exit(1);
141
- }
142
153
  if (!(SUPPORTED_INTERFACES as readonly string[]).includes(value)) {
143
154
  console.error(
144
155
  `Unknown interface '${value}'. Supported: ${SUPPORTED_INTERFACES.join(", ")}.`,
@@ -152,6 +163,7 @@ function parseArgs(): ParsedArgs {
152
163
  return {
153
164
  runtimeUrl: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
154
165
  assistantId,
166
+ assistantName,
155
167
  species,
156
168
  cloud,
157
169
  platformToken,
@@ -213,7 +225,7 @@ ${ANSI.bold}ARGUMENTS:${ANSI.reset}
213
225
  ${ANSI.bold}OPTIONS:${ANSI.reset}
214
226
  -u, --url <url> Runtime URL
215
227
  -a, --assistant-id <id> Assistant ID
216
- -i, --interface <id> Interface identifier (default: cli)
228
+ -i, --interface <id> Interface identifier: cli (default) or web
217
229
  -h, --help Show this help message
218
230
 
219
231
  ${ANSI.bold}DEFAULTS:${ANSI.reset}
@@ -228,10 +240,104 @@ ${ANSI.bold}EXAMPLES:${ANSI.reset}
228
240
  `);
229
241
  }
230
242
 
243
+ async function maybeHydratePlatformAssistantName(
244
+ assistantId: string,
245
+ assistantName: string | undefined,
246
+ cloud: string | undefined,
247
+ platformToken: string | undefined,
248
+ ): Promise<string | undefined> {
249
+ if (cloud !== "vellum" || assistantName || !platformToken) {
250
+ return assistantName;
251
+ }
252
+
253
+ try {
254
+ const matchedAssistant = (
255
+ await fetchPlatformAssistants(platformToken)
256
+ ).find((assistant) => assistant.id === assistantId);
257
+ const hydratedName = matchedAssistant?.name.trim();
258
+ if (!hydratedName) {
259
+ return assistantName;
260
+ }
261
+
262
+ const entry = findAssistantByName(assistantId);
263
+ if (entry && entry.name !== hydratedName) {
264
+ saveAssistantEntry({
265
+ ...entry,
266
+ name: hydratedName,
267
+ });
268
+ }
269
+
270
+ return hydratedName;
271
+ } catch {
272
+ return assistantName;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Walk up from this file's location to find a sibling `clients/web` package.
278
+ *
279
+ * Returns the absolute path to its directory, or null when not found —
280
+ * e.g. when the CLI is installed via npm/bunx, where the `clients/web`
281
+ * source isn't shipped alongside `@vellumai/cli`. For now we treat the
282
+ * `--interface web` path as source-checkout-only.
283
+ */
284
+ function findClientsWebDir(): string | null {
285
+ let dir = import.meta.dir;
286
+ for (let depth = 0; depth < 8; depth++) {
287
+ const candidate = path.join(dir, "clients", "web", "package.json");
288
+ if (existsSync(candidate)) {
289
+ return path.dirname(candidate);
290
+ }
291
+ const parent = path.dirname(dir);
292
+ if (parent === dir) break;
293
+ dir = parent;
294
+ }
295
+ return null;
296
+ }
297
+
298
+ /**
299
+ * Spawn the `clients/web` package's `local` script and proxy its lifecycle.
300
+ *
301
+ * The web client is deliberately not declared as a dependency of `@vellumai/cli`:
302
+ * the CLI is published, the web package is not. Locating it on disk and
303
+ * shelling out keeps the two packages independent.
304
+ */
305
+ async function runWebInterface(): Promise<void> {
306
+ const webDir = findClientsWebDir();
307
+ if (!webDir) {
308
+ console.error(
309
+ `${ANSI.bold}--interface web${ANSI.reset}: unable to locate ` +
310
+ `clients/web. This interface currently requires running ` +
311
+ `vellum from a source checkout of vellum-assistant.`,
312
+ );
313
+ process.exit(1);
314
+ }
315
+
316
+ const child = Bun.spawn({
317
+ cmd: ["bun", "run", "local"],
318
+ cwd: webDir,
319
+ stdio: ["inherit", "inherit", "inherit"],
320
+ });
321
+
322
+ const forward = (signal: "SIGINT" | "SIGTERM"): void => {
323
+ try {
324
+ child.kill(signal);
325
+ } catch {
326
+ // Child already exited; nothing to forward.
327
+ }
328
+ };
329
+ process.on("SIGINT", () => forward("SIGINT"));
330
+ process.on("SIGTERM", () => forward("SIGTERM"));
331
+
332
+ const exitCode = await child.exited;
333
+ process.exit(typeof exitCode === "number" ? exitCode : 0);
334
+ }
335
+
231
336
  export async function client(): Promise<void> {
232
337
  const {
233
338
  runtimeUrl,
234
339
  assistantId,
340
+ assistantName: parsedAssistantName,
235
341
  species,
236
342
  cloud,
237
343
  platformToken,
@@ -241,6 +347,11 @@ export async function client(): Promise<void> {
241
347
  zone,
242
348
  } = parseArgs();
243
349
 
350
+ if (interfaceId === WEB_INTERFACE_ID) {
351
+ await runWebInterface();
352
+ return;
353
+ }
354
+
244
355
  tuiLog.init();
245
356
  tuiLog.info("session start", {
246
357
  runtimeUrl,
@@ -250,6 +361,13 @@ export async function client(): Promise<void> {
250
361
  interfaceId,
251
362
  });
252
363
 
364
+ const assistantName = await maybeHydratePlatformAssistantName(
365
+ assistantId,
366
+ parsedAssistantName,
367
+ cloud,
368
+ platformToken,
369
+ );
370
+
253
371
  // Build pre-constructed request headers merged from auth + client registration.
254
372
  // Spreading into every fetch site ensures consistency across REST and SSE endpoints.
255
373
  let auth: Record<string, string> | undefined;
@@ -286,6 +404,6 @@ export async function client(): Promise<void> {
286
404
  console.log(`${ANSI.dim}Disconnected.${ANSI.reset}`);
287
405
  process.exit(0);
288
406
  },
289
- { auth, project, zone },
407
+ { auth, project, zone, assistantName },
290
408
  );
291
409
  }
@@ -49,13 +49,14 @@ interface AssistantEvent {
49
49
  content?: string;
50
50
  message?: string;
51
51
  chunk?: string;
52
+ tags?: unknown;
52
53
  conversationId?: string;
53
54
  [key: string]: unknown;
54
55
  };
55
56
  }
56
57
 
57
58
  /** Render an event as human-readable markdown to stdout. */
58
- function renderMarkdown(event: AssistantEvent): void {
59
+ export function renderMarkdown(event: AssistantEvent): void {
59
60
  const msg = event.message;
60
61
  switch (msg.type) {
61
62
  case "assistant_text_delta":
@@ -94,6 +95,17 @@ function renderMarkdown(event: AssistantEvent): void {
94
95
  case "user_message_echo":
95
96
  console.log(`\n**You:** ${msg.text}`);
96
97
  break;
98
+ case "sync_changed": {
99
+ const tags = Array.isArray(msg.tags)
100
+ ? msg.tags.filter((tag): tag is string => typeof tag === "string")
101
+ : [];
102
+ const renderedTags =
103
+ tags.length > 0
104
+ ? tags.map((tag) => `\`${tag}\``).join(", ")
105
+ : "(no tags)";
106
+ console.log(`\n> **Sync changed:** ${renderedTags}`);
107
+ break;
108
+ }
97
109
  default:
98
110
  // Silently skip events that don't have a markdown representation
99
111
  // (e.g. heartbeat comments, activity states, etc.)