@vellumai/cli 0.8.12-staging.2 → 0.9.0-dev.202606162156.4bad3e5

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 (52) hide show
  1. package/README.md +1 -1
  2. package/bun.lock +49 -56
  3. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
  5. package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
  7. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
  8. package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
  9. package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
  10. package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
  11. package/package.json +3 -3
  12. package/src/__tests__/assistant-config.test.ts +1 -2
  13. package/src/__tests__/device-id.test.ts +6 -14
  14. package/src/__tests__/helpers/os-mock.ts +27 -0
  15. package/src/__tests__/login-loopback.test.ts +71 -0
  16. package/src/__tests__/multi-local.test.ts +2 -10
  17. package/src/__tests__/nginx-ingress-command.test.ts +69 -0
  18. package/src/__tests__/nginx-ingress.test.ts +403 -0
  19. package/src/__tests__/sleep.test.ts +4 -0
  20. package/src/__tests__/teleport.test.ts +6 -9
  21. package/src/__tests__/tunnel.test.ts +164 -0
  22. package/src/__tests__/wake.test.ts +15 -4
  23. package/src/__tests__/workos-pkce.test.ts +314 -0
  24. package/src/commands/flags.ts +1 -22
  25. package/src/commands/hatch.ts +90 -9
  26. package/src/commands/login.ts +123 -59
  27. package/src/commands/nginx-ingress.ts +291 -0
  28. package/src/commands/rollback.ts +0 -6
  29. package/src/commands/sleep.ts +17 -0
  30. package/src/commands/teleport.ts +23 -36
  31. package/src/commands/tunnel.ts +69 -11
  32. package/src/commands/upgrade.ts +0 -2
  33. package/src/commands/wake.ts +7 -5
  34. package/src/commands/workflows.ts +301 -0
  35. package/src/index.ts +8 -0
  36. package/src/lib/arg-utils.ts +48 -0
  37. package/src/lib/assistant-client.ts +2 -0
  38. package/src/lib/assistant-config.ts +0 -7
  39. package/src/lib/cloudflare-tunnel.ts +15 -2
  40. package/src/lib/docker.ts +103 -49
  41. package/src/lib/feature-flags.test.ts +157 -0
  42. package/src/lib/feature-flags.ts +38 -0
  43. package/src/lib/hatch-local.ts +0 -1
  44. package/src/lib/local.ts +5 -0
  45. package/src/lib/nginx-ingress.ts +576 -0
  46. package/src/lib/ngrok.ts +26 -4
  47. package/src/lib/platform-client.ts +0 -1
  48. package/src/lib/retire-local.ts +5 -0
  49. package/src/lib/statefulset.ts +73 -21
  50. package/src/lib/sync-cloud-assistants.ts +4 -17
  51. package/src/lib/upgrade-lifecycle.ts +1 -2
  52. package/src/lib/workos-pkce.ts +160 -0
@@ -0,0 +1,164 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ mock,
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 * as cloudflareTunnel from "../lib/cloudflare-tunnel.js";
15
+ import * as ngrok from "../lib/ngrok.js";
16
+ import type { AssistantEntry } from "../lib/assistant-config.js";
17
+
18
+ const realCloudflareTunnel = { ...cloudflareTunnel };
19
+ const realNgrok = { ...ngrok };
20
+
21
+ const runCloudflareTunnelMock = mock<
22
+ typeof cloudflareTunnel.runCloudflareTunnel
23
+ >(async () => {});
24
+ mock.module("../lib/cloudflare-tunnel.js", () => ({
25
+ ...realCloudflareTunnel,
26
+ runCloudflareTunnel: runCloudflareTunnelMock,
27
+ }));
28
+
29
+ const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () => {});
30
+ mock.module("../lib/ngrok", () => ({
31
+ ...realNgrok,
32
+ runNgrokTunnel: runNgrokTunnelMock,
33
+ }));
34
+
35
+ const { tunnel } = await import("../commands/tunnel.js");
36
+
37
+ const originalArgv = [...process.argv];
38
+ const originalFetch = globalThis.fetch;
39
+ const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
40
+ const tempDirs: string[] = [];
41
+
42
+ function makeLocalEntry(): AssistantEntry {
43
+ const instanceDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-test-"));
44
+ tempDirs.push(instanceDir);
45
+ return {
46
+ assistantId: "assistant-1",
47
+ runtimeUrl: "http://127.0.0.1:7830",
48
+ cloud: "local",
49
+ resources: {
50
+ instanceDir,
51
+ daemonPort: 7821,
52
+ gatewayPort: 7830,
53
+ qdrantPort: 6333,
54
+ cesPort: 7822,
55
+ },
56
+ };
57
+ }
58
+
59
+ function writeLockfile(entry: AssistantEntry): void {
60
+ const lockfileDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-lockfile-"));
61
+ tempDirs.push(lockfileDir);
62
+ process.env.VELLUM_LOCKFILE_DIR = lockfileDir;
63
+ mkdirSync(lockfileDir, { recursive: true });
64
+ writeFileSync(
65
+ join(lockfileDir, ".vellum.lock.json"),
66
+ JSON.stringify(
67
+ {
68
+ activeAssistant: entry.assistantId,
69
+ assistants: [entry],
70
+ },
71
+ null,
72
+ 2,
73
+ ),
74
+ );
75
+ }
76
+
77
+ function mockEnabledFlagFetch() {
78
+ const fetchMock = mock(async (_input: string, _init?: RequestInit) => {
79
+ return new Response(
80
+ JSON.stringify({
81
+ flags: [{ key: "web-remote-ingress", enabled: true }],
82
+ }),
83
+ { status: 200, headers: { "Content-Type": "application/json" } },
84
+ );
85
+ });
86
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
87
+ return fetchMock;
88
+ }
89
+
90
+ describe("tunnel nginx ingress feature flag", () => {
91
+ beforeEach(() => {
92
+ process.argv = ["bun", "vellum", "tunnel"];
93
+ writeLockfile(makeLocalEntry());
94
+ globalThis.fetch = (async () => {
95
+ throw new Error("gateway unavailable");
96
+ }) as unknown as typeof globalThis.fetch;
97
+ runCloudflareTunnelMock.mockReset();
98
+ runCloudflareTunnelMock.mockResolvedValue(undefined);
99
+ runNgrokTunnelMock.mockReset();
100
+ runNgrokTunnelMock.mockResolvedValue(undefined);
101
+ });
102
+
103
+ afterEach(() => {
104
+ process.argv = originalArgv;
105
+ globalThis.fetch = originalFetch;
106
+ if (originalLockfileDir === undefined) {
107
+ delete process.env.VELLUM_LOCKFILE_DIR;
108
+ } else {
109
+ process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
110
+ }
111
+ for (const dir of tempDirs.splice(0)) {
112
+ rmSync(dir, { recursive: true, force: true });
113
+ }
114
+ });
115
+
116
+ afterAll(() => {
117
+ mock.module("../lib/cloudflare-tunnel.js", () => realCloudflareTunnel);
118
+ mock.module("../lib/ngrok", () => realNgrok);
119
+ });
120
+
121
+ test("does not start ngrok when the flag lookup fails", async () => {
122
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
123
+
124
+ await expect(tunnel()).rejects.toThrow(
125
+ "Could not verify the `web-remote-ingress` feature flag",
126
+ );
127
+
128
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
129
+ expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
130
+ });
131
+
132
+ test("checks the nginx flag through the local gateway for ngrok", async () => {
133
+ const entry = makeLocalEntry();
134
+ entry.runtimeUrl = "https://stale-tunnel.ngrok-free.dev";
135
+ writeLockfile(entry);
136
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
137
+ const fetchMock = mockEnabledFlagFetch();
138
+
139
+ await tunnel();
140
+
141
+ const [url, init] = fetchMock.mock.calls[0];
142
+ expect(url).toBe(
143
+ "http://127.0.0.1:7830/v1/assistants/assistant-1/feature-flags",
144
+ );
145
+ expect(init?.method).toBe("GET");
146
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
147
+ port: 7830,
148
+ workspaceDir: join(entry.resources!.instanceDir, ".vellum", "workspace"),
149
+ preferNginxIngress: true,
150
+ });
151
+ expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
152
+ });
153
+
154
+ test("does not start cloudflared when the flag lookup fails", async () => {
155
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "cloudflare"];
156
+
157
+ await expect(tunnel()).rejects.toThrow(
158
+ "Could not verify the `web-remote-ingress` feature flag",
159
+ );
160
+
161
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
162
+ expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
163
+ });
164
+ });
@@ -272,12 +272,23 @@ describe("vellum wake", () => {
272
272
  expect(leaseGuardianTokenMock).not.toHaveBeenCalled();
273
273
  });
274
274
 
275
- test("skips re-provision when a guardian token already exists", async () => {
275
+ test("re-provisions even when a guardian token already exists", async () => {
276
+ // A connect can 401 off a token whose local state looks healthy
277
+ // (revoked, mis-seeded, wrong principal). The user explicitly confirmed
278
+ // the destructive repair, so the flag forces a re-lease instead of
279
+ // guessing from local token state and recreating the no-op loop.
276
280
  process.argv = ["bun", "vellum", "wake", "--repair-guardian", "local-assistant"];
277
- // loadGuardianToken returns a token by default — recovery must not run.
281
+ // loadGuardianToken returns a healthy-looking token by default.
278
282
  await wake();
279
283
 
280
- expect(resetGuardianBootstrapMock).not.toHaveBeenCalled();
281
- expect(leaseGuardianTokenMock).not.toHaveBeenCalled();
284
+ expect(resetGuardianBootstrapMock).toHaveBeenCalledWith(
285
+ "http://127.0.0.1:7830",
286
+ "generated-bootstrap-secret",
287
+ );
288
+ expect(leaseGuardianTokenMock).toHaveBeenCalledWith(
289
+ "http://127.0.0.1:7830",
290
+ "local-assistant",
291
+ "generated-bootstrap-secret",
292
+ );
282
293
  });
283
294
  });
@@ -0,0 +1,314 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import {
4
+ buildAuthorizeUrl,
5
+ exchangeAccessTokenForSession,
6
+ exchangeCodeWithWorkos,
7
+ fetchWorkosClientId,
8
+ generatePkcePair,
9
+ selectWorkosClientId,
10
+ } from "../lib/workos-pkce.js";
11
+
12
+ const originalFetch = globalThis.fetch;
13
+
14
+ afterEach(() => {
15
+ globalThis.fetch = originalFetch;
16
+ });
17
+
18
+ /**
19
+ * Route fetch responses by URL substring so each WorkOS/platform call can be
20
+ * inspected. The module talks to remote hosts via loopbackSafeFetch, which
21
+ * delegates to globalThis.fetch.
22
+ */
23
+ function mockFetchByUrl(responses: Record<string, () => Response>) {
24
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
25
+ globalThis.fetch = mock(
26
+ async (input: RequestInfo | URL, init?: RequestInit) => {
27
+ const url = String(input);
28
+ calls.push({ url, init });
29
+ const match = Object.entries(responses).find(([prefix]) =>
30
+ url.includes(prefix),
31
+ );
32
+ if (!match) throw new Error(`Unexpected fetch: ${url}`);
33
+ return match[1]();
34
+ },
35
+ ) as unknown as typeof globalThis.fetch;
36
+ return calls;
37
+ }
38
+
39
+ describe("generatePkcePair", () => {
40
+ test("challenge is the base64url sha256 of the verifier", async () => {
41
+ const { verifier, challenge } = generatePkcePair();
42
+ const digest = await crypto.subtle.digest(
43
+ "SHA-256",
44
+ new TextEncoder().encode(verifier),
45
+ );
46
+ const expected = Buffer.from(digest).toString("base64url");
47
+ expect(challenge).toBe(expected);
48
+ });
49
+
50
+ test("verifiers are unique", () => {
51
+ expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier);
52
+ });
53
+ });
54
+
55
+ describe("buildAuthorizeUrl", () => {
56
+ const base = {
57
+ clientId: "client_123",
58
+ redirectUri: "http://127.0.0.1:4242/auth/callback",
59
+ challenge: "chal",
60
+ state: "st",
61
+ };
62
+
63
+ test("targets user_management/authorize with PKCE and authkit defaults", () => {
64
+ const url = new URL(buildAuthorizeUrl(base));
65
+ expect(url.origin).toBe("https://api.workos.com");
66
+ expect(url.pathname).toBe("/user_management/authorize");
67
+ expect(url.searchParams.get("client_id")).toBe("client_123");
68
+ expect(url.searchParams.get("redirect_uri")).toBe(base.redirectUri);
69
+ expect(url.searchParams.get("response_type")).toBe("code");
70
+ expect(url.searchParams.get("scope")).toBe("openid profile email");
71
+ expect(url.searchParams.get("code_challenge")).toBe("chal");
72
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
73
+ expect(url.searchParams.get("state")).toBe("st");
74
+ expect(url.searchParams.get("provider")).toBe("authkit");
75
+ // Session reuse: never force a fresh IdP login.
76
+ expect(url.searchParams.has("prompt")).toBe(false);
77
+ });
78
+
79
+ test("provider hint replaces authkit", () => {
80
+ const url = new URL(
81
+ buildAuthorizeUrl({ ...base, providerHint: "GoogleOAuth" }),
82
+ );
83
+ expect(url.searchParams.get("provider")).toBe("GoogleOAuth");
84
+ });
85
+
86
+ test("login hint is forwarded", () => {
87
+ const url = new URL(
88
+ buildAuthorizeUrl({ ...base, loginHint: "user@example.com" }),
89
+ );
90
+ expect(url.searchParams.get("login_hint")).toBe("user@example.com");
91
+
92
+ const noHint = new URL(buildAuthorizeUrl(base));
93
+ expect(noHint.searchParams.has("login_hint")).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("selectWorkosClientId", () => {
98
+ // During coexistence the platform lists two providers that share the
99
+ // "workos-oidc" id; only the OAuth2 one (no discovery URL) is usable.
100
+ const legacy = {
101
+ id: "workos-oidc",
102
+ name: "WorkOS OIDC",
103
+ client_id: "client_connect",
104
+ flows: ["provider_redirect", "provider_token"],
105
+ openid_configuration_url:
106
+ "https://x.authkit.app/.well-known/openid-configuration",
107
+ };
108
+ const modern = {
109
+ id: "workos-oidc",
110
+ name: "WorkOS",
111
+ client_id: "client_um",
112
+ flows: ["provider_redirect", "provider_token"],
113
+ };
114
+
115
+ test("picks the OAuth2 entry during coexistence", () => {
116
+ expect(selectWorkosClientId([legacy, modern])).toBe("client_um");
117
+ expect(selectWorkosClientId([modern, legacy])).toBe("client_um");
118
+ });
119
+
120
+ test("returns null when the platform lacks token auth", () => {
121
+ const preTokenAuth = { ...modern, flows: ["provider_redirect"] };
122
+ expect(selectWorkosClientId([legacy, preTokenAuth])).toBeNull();
123
+ expect(selectWorkosClientId([])).toBeNull();
124
+ });
125
+
126
+ test("ignores an entry missing client_id", () => {
127
+ const noClientId = { id: "workos-oidc", flows: ["provider_token"] };
128
+ expect(selectWorkosClientId([noClientId])).toBeNull();
129
+ });
130
+ });
131
+
132
+ describe("fetchWorkosClientId", () => {
133
+ test("resolves the client id from the headless config", async () => {
134
+ const calls = mockFetchByUrl({
135
+ "/_allauth/app/v1/config": () =>
136
+ new Response(
137
+ JSON.stringify({
138
+ data: {
139
+ socialaccount: {
140
+ providers: [
141
+ {
142
+ id: "workos-oidc",
143
+ client_id: "client_um",
144
+ flows: ["provider_token"],
145
+ },
146
+ ],
147
+ },
148
+ },
149
+ }),
150
+ { status: 200 },
151
+ ),
152
+ });
153
+
154
+ expect(await fetchWorkosClientId("https://platform.example")).toBe(
155
+ "client_um",
156
+ );
157
+ expect(calls[0]!.url).toBe(
158
+ "https://platform.example/_allauth/app/v1/config",
159
+ );
160
+ });
161
+
162
+ test("derives the config URL from the origin, ignoring any path", async () => {
163
+ const calls = mockFetchByUrl({
164
+ "/_allauth/app/v1/config": () =>
165
+ new Response(
166
+ JSON.stringify({
167
+ data: {
168
+ socialaccount: {
169
+ providers: [
170
+ { client_id: "client_um", flows: ["provider_token"] },
171
+ ],
172
+ },
173
+ },
174
+ }),
175
+ { status: 200 },
176
+ ),
177
+ });
178
+
179
+ await fetchWorkosClientId("https://platform.example/some/path");
180
+ expect(calls[0]!.url).toBe(
181
+ "https://platform.example/_allauth/app/v1/config",
182
+ );
183
+ });
184
+
185
+ test("throws a clear error when no token-auth provider is advertised", async () => {
186
+ mockFetchByUrl({
187
+ "/_allauth/app/v1/config": () =>
188
+ new Response(
189
+ JSON.stringify({ data: { socialaccount: { providers: [] } } }),
190
+ { status: 200 },
191
+ ),
192
+ });
193
+
194
+ await expect(
195
+ fetchWorkosClientId("https://platform.example"),
196
+ ).rejects.toThrow(/does not advertise/);
197
+ });
198
+
199
+ test("throws on a non-OK config response", async () => {
200
+ mockFetchByUrl({
201
+ "/_allauth/app/v1/config": () => new Response("nope", { status: 500 }),
202
+ });
203
+
204
+ await expect(
205
+ fetchWorkosClientId("https://platform.example"),
206
+ ).rejects.toThrow(/500/);
207
+ });
208
+ });
209
+
210
+ describe("exchangeCodeWithWorkos", () => {
211
+ test("posts a public-client PKCE exchange and returns the access token", async () => {
212
+ const calls = mockFetchByUrl({
213
+ "/user_management/authenticate": () =>
214
+ new Response(JSON.stringify({ access_token: "at_1", user: {} }), {
215
+ status: 200,
216
+ }),
217
+ });
218
+
219
+ const token = await exchangeCodeWithWorkos({
220
+ clientId: "client_um",
221
+ code: "c",
222
+ verifier: "v",
223
+ });
224
+
225
+ expect(token).toBe("at_1");
226
+ expect(calls[0]!.url).toBe(
227
+ "https://api.workos.com/user_management/authenticate",
228
+ );
229
+ const body = JSON.parse(String(calls[0]!.init?.body));
230
+ // Public client: no secret, no API key.
231
+ expect(body).toEqual({
232
+ client_id: "client_um",
233
+ grant_type: "authorization_code",
234
+ code: "c",
235
+ code_verifier: "v",
236
+ });
237
+ });
238
+
239
+ test("throws with upstream detail on failure", async () => {
240
+ mockFetchByUrl({
241
+ "/user_management/authenticate": () =>
242
+ new Response(JSON.stringify({ error: "invalid_grant" }), {
243
+ status: 400,
244
+ }),
245
+ });
246
+
247
+ await expect(
248
+ exchangeCodeWithWorkos({ clientId: "c", code: "x", verifier: "v" }),
249
+ ).rejects.toThrow(/400/);
250
+ });
251
+
252
+ test("throws when the exchange returns no access token", async () => {
253
+ mockFetchByUrl({
254
+ "/user_management/authenticate": () =>
255
+ new Response(JSON.stringify({}), { status: 200 }),
256
+ });
257
+
258
+ await expect(
259
+ exchangeCodeWithWorkos({ clientId: "c", code: "x", verifier: "v" }),
260
+ ).rejects.toThrow(/no access token/);
261
+ });
262
+ });
263
+
264
+ describe("exchangeAccessTokenForSession", () => {
265
+ test("posts the headless token payload and returns the session token", async () => {
266
+ const calls = mockFetchByUrl({
267
+ "/_allauth/app/v1/auth/provider/token": () =>
268
+ new Response(JSON.stringify({ meta: { session_token: "sess_1" } }), {
269
+ status: 200,
270
+ }),
271
+ });
272
+
273
+ const token = await exchangeAccessTokenForSession(
274
+ "https://platform.example",
275
+ "client_um",
276
+ "at_1",
277
+ );
278
+
279
+ expect(token).toBe("sess_1");
280
+ expect(calls[0]!.url).toBe(
281
+ "https://platform.example/_allauth/app/v1/auth/provider/token",
282
+ );
283
+ const body = JSON.parse(String(calls[0]!.init?.body));
284
+ expect(body).toEqual({
285
+ provider: "workos",
286
+ process: "login",
287
+ token: { client_id: "client_um", access_token: "at_1" },
288
+ });
289
+ });
290
+
291
+ test("throws on a rejected token", async () => {
292
+ mockFetchByUrl({
293
+ "/_allauth/app/v1/auth/provider/token": () =>
294
+ new Response(JSON.stringify({ errors: [{ code: "invalid_token" }] }), {
295
+ status: 400,
296
+ }),
297
+ });
298
+
299
+ await expect(
300
+ exchangeAccessTokenForSession("https://platform.example", "c", "bad"),
301
+ ).rejects.toThrow(/400/);
302
+ });
303
+
304
+ test("throws when the session exchange returns no session token", async () => {
305
+ mockFetchByUrl({
306
+ "/_allauth/app/v1/auth/provider/token": () =>
307
+ new Response(JSON.stringify({ meta: {} }), { status: 200 }),
308
+ });
309
+
310
+ await expect(
311
+ exchangeAccessTokenForSession("https://platform.example", "c", "at"),
312
+ ).rejects.toThrow(/no session token/);
313
+ });
314
+ });
@@ -1,3 +1,4 @@
1
+ import { extractAssistantFlag } from "../lib/arg-utils.js";
1
2
  import { AssistantClient } from "../lib/assistant-client.js";
2
3
  import {
3
4
  formatAssistantLookupError,
@@ -199,28 +200,6 @@ async function setFlag(
199
200
  console.log(`Flag "${key}" set to ${value}`);
200
201
  }
201
202
 
202
- /**
203
- * Strip `--assistant <name>` from argv and return the captured value.
204
- *
205
- * Mutates the input array so positional parsing downstream sees a clean
206
- * shape (subcommand + key + value). Returns `undefined` if the flag is
207
- * absent. Error-reports a missing value so the user gets a clear message
208
- * rather than the flag being silently swallowed as a positional.
209
- */
210
- function extractAssistantFlag(args: string[]): string | undefined {
211
- for (let i = 0; i < args.length; i++) {
212
- if (args[i] !== "--assistant") continue;
213
- const value = args[i + 1];
214
- if (!value || value.startsWith("-")) {
215
- console.error("Missing value for --assistant <name>");
216
- process.exit(1);
217
- }
218
- args.splice(i, 2);
219
- return value;
220
- }
221
- return undefined;
222
- }
223
-
224
203
  export async function flags(): Promise<void> {
225
204
  const args = process.argv.slice(3);
226
205