setup-agent 0.0.3 → 0.0.5

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.
@@ -0,0 +1,615 @@
1
+ import { initialCheckoutState, type Checkout } from "./protocol";
2
+ import { describe, expect, it, onTestFinished, vi } from "vitest";
3
+ import {
4
+ chmod,
5
+ mkdtemp,
6
+ readFile,
7
+ rm,
8
+ stat,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ HELP,
15
+ INSTRUCTIONS,
16
+ askSeed,
17
+ agentInstructions,
18
+ diffEvents,
19
+ detectAgentKind,
20
+ isDirectInvocation,
21
+ upsertEnvLine,
22
+ waitForStart,
23
+ writeEnvSecret,
24
+ } from "./cli";
25
+
26
+ describe("the agent's briefing", () => {
27
+ it("preserves the published instructions export and setup-created event wording", () => {
28
+ expect(INSTRUCTIONS).toBe(agentInstructions);
29
+ expect(typeof HELP).toBe("string");
30
+ expect(INSTRUCTIONS("https://example.test/session")).toContain(
31
+ '"setup.created"',
32
+ );
33
+ expect(INSTRUCTIONS("https://example.test/session")).not.toContain(
34
+ '"checkout.created"',
35
+ );
36
+ });
37
+
38
+ it("describes a setup without the wording of a purchase", () => {
39
+ const briefing = HELP + INSTRUCTIONS("https://example.test/session");
40
+ expect(briefing).not.toMatch(/\b(?:cart|shop|checkout|purchase)\b/i);
41
+ });
42
+ });
43
+
44
+ describe("askSeed", () => {
45
+ const parse = (argv: string[]) => {
46
+ const flags = new Map<string, string | true>();
47
+ const rest: string[] = [];
48
+ for (let i = 0; i < argv.length; i++) {
49
+ const arg = argv[i]!;
50
+ if (!arg.startsWith("--")) rest.push(arg);
51
+ else if (argv[i + 1]?.startsWith("--") === false)
52
+ flags.set(arg.slice(2), argv[++i]!);
53
+ else flags.set(arg.slice(2), true);
54
+ }
55
+ return askSeed(rest, flags);
56
+ };
57
+
58
+ const withExit = (run: () => void) => {
59
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
60
+ throw new Error("exit");
61
+ });
62
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
63
+ try {
64
+ expect(run).toThrow("exit");
65
+ return stderr.mock.lastCall?.[0];
66
+ } finally {
67
+ exit.mockRestore();
68
+ stderr.mockRestore();
69
+ }
70
+ };
71
+
72
+ const entryPoints = [
73
+ {
74
+ id: "ticket-modal",
75
+ label: "Ticket reply modal",
76
+ description: "Draft a response without leaving the support ticket.",
77
+ entryPoint: {
78
+ formFactor: "modal",
79
+ placement: "Above the ticket detail page",
80
+ trigger: "Draft reply button in the ticket toolbar",
81
+ recommended: true,
82
+ },
83
+ },
84
+ ];
85
+
86
+ it("asks an entry-point question preserving contextual choices and exact default", () => {
87
+ expect(
88
+ parse([
89
+ "Where should support agents draft replies?",
90
+ "--entry-points",
91
+ JSON.stringify(entryPoints),
92
+ "--default",
93
+ "ticket-modal",
94
+ "--step",
95
+ "s1",
96
+ "--wait",
97
+ ]),
98
+ ).toEqual({
99
+ kind: "entry-point",
100
+ prompt: "Where should support agents draft replies?",
101
+ options: entryPoints,
102
+ default: "ticket-modal",
103
+ stepId: "s1",
104
+ optional: false,
105
+ });
106
+ });
107
+
108
+ it("refuses malformed JSON, defaults and conflicting input flags", () => {
109
+ expect(withExit(() => parse(["Where?", "--entry-points", "{"]))).toContain(
110
+ "JSON array",
111
+ );
112
+ expect(withExit(() => parse(["Where?", "--entry-points"]))).toContain(
113
+ "JSON array",
114
+ );
115
+ expect(withExit(() => parse(["Where?", "--entry-points", "[]"]))).toContain(
116
+ "unique options",
117
+ );
118
+ expect(
119
+ withExit(() =>
120
+ parse([
121
+ "Where?",
122
+ "--entry-points",
123
+ JSON.stringify(entryPoints),
124
+ "--default",
125
+ "unknown",
126
+ ]),
127
+ ),
128
+ ).toContain('no option "unknown"');
129
+ for (const flag of [
130
+ "--preset",
131
+ "--choices",
132
+ "--product",
133
+ "--multiple",
134
+ "--icons",
135
+ "--placeholder",
136
+ "--single",
137
+ "--only",
138
+ ]) {
139
+ expect(
140
+ withExit(() =>
141
+ parse([
142
+ "Where?",
143
+ "--entry-points",
144
+ JSON.stringify(entryPoints),
145
+ flag,
146
+ "x",
147
+ ]),
148
+ ),
149
+ ).toContain("cannot be combined");
150
+ }
151
+ });
152
+
153
+ it("keeps the legacy product command decoder", () => {
154
+ expect(parse(["Add it?", "--product", "assistant-ui"])).toEqual({
155
+ kind: "product",
156
+ prompt: "Add it?",
157
+ product: "assistant-ui",
158
+ optional: false,
159
+ });
160
+ });
161
+
162
+ it("asks a multi-select choice with an icon on every option by default", () => {
163
+ expect(
164
+ parse([
165
+ "Which integrations?",
166
+ "--choices",
167
+ "slack,email",
168
+ "--icons",
169
+ "slack=message,email=mail",
170
+ ]),
171
+ ).toEqual({
172
+ kind: "choice",
173
+ prompt: "Which integrations?",
174
+ options: [
175
+ { id: "slack", label: "slack", icon: "message" },
176
+ { id: "email", label: "email", icon: "mail" },
177
+ ],
178
+ multiple: true,
179
+ optional: false,
180
+ });
181
+ expect(
182
+ parse([
183
+ "Pick",
184
+ "--choices",
185
+ "a,b",
186
+ "--icons",
187
+ "a=code,b=zap",
188
+ "--multiple",
189
+ ]),
190
+ ).toHaveProperty("multiple", true);
191
+ });
192
+
193
+ it("asks a single choice only with --single", () => {
194
+ const seed = parse([
195
+ "Which package manager?",
196
+ "--choices",
197
+ "pnpm,npm",
198
+ "--icons",
199
+ "pnpm=package,npm=package",
200
+ "--single",
201
+ "--default",
202
+ "pnpm",
203
+ ]);
204
+ expect(seed).not.toHaveProperty("multiple");
205
+ expect(seed).toHaveProperty("default", "pnpm");
206
+ expect(
207
+ withExit(() =>
208
+ parse([
209
+ "Pick",
210
+ "--choices",
211
+ "a",
212
+ "--icons",
213
+ "a=code",
214
+ "--single",
215
+ "--multiple",
216
+ ]),
217
+ ),
218
+ ).toContain("--single and --multiple exclude each other");
219
+ });
220
+
221
+ it("refuses a choice missing an icon, naming the pack and the flag to pass", () => {
222
+ const message = withExit(() =>
223
+ parse(["Pick", "--choices", "a,b,c", "--icons", "b=mail"]),
224
+ );
225
+ expect(message).toContain('"a", "c" have none');
226
+ expect(message).toContain('--icons "a=<icon>,b=<icon>,c=<icon>"');
227
+ expect(message).toContain("code, terminal, braces");
228
+ expect(message).toContain("question");
229
+ });
230
+
231
+ it("refuses an icon outside the pack or on an unknown choice", () => {
232
+ expect(
233
+ withExit(() => parse(["Pick", "--choices", "a", "--icons", "a=rocket"])),
234
+ ).toContain('unknown icon "rocket"');
235
+ expect(
236
+ withExit(() => parse(["Pick", "--choices", "a", "--icons", "b=mail"])),
237
+ ).toContain('no choice "b"');
238
+ });
239
+
240
+ it("leaves presets single-select with their own brand marks", () => {
241
+ const framework = parse(["--preset", "framework"]);
242
+ expect(framework).not.toHaveProperty("multiple");
243
+ expect(framework.options?.[0]).toHaveProperty("icon", "vercel");
244
+ const project = parse([
245
+ "--preset",
246
+ "project",
247
+ "--choices",
248
+ "apps/web=Next.js",
249
+ ]);
250
+ expect(project).not.toHaveProperty("multiple");
251
+ expect(project.options?.[0]).toEqual({
252
+ id: "apps/web",
253
+ label: "apps/web",
254
+ description: "Next.js",
255
+ });
256
+ });
257
+ });
258
+
259
+ describe("upsertEnvLine", () => {
260
+ it.each(["", "1KEY", "KEY.*", "(A+)+$", "KEY=VALUE", "KEY\nOTHER", "KEY "])(
261
+ "rejects invalid environment keys in the exported helper: %j",
262
+ (key) => {
263
+ expect(() => upsertEnvLine("KEY=old\n", key, "new")).toThrow(
264
+ "not an environment variable name",
265
+ );
266
+ },
267
+ );
268
+
269
+ it("matches assignment keys literally, including export and whitespace", () => {
270
+ expect(
271
+ upsertEnvLine("KEY_LONG=keep\nexport KEY \t=old\n", "KEY", "new"),
272
+ ).toBe("KEY_LONG=keep\nKEY=new\n");
273
+ expect(upsertEnvLine("KEY_LONG=keep\n", "KEY", "new")).toBe(
274
+ "KEY_LONG=keep\nKEY=new\n",
275
+ );
276
+ expect(upsertEnvLine("export =old\n", "export", "new")).toBe(
277
+ "export=new\n",
278
+ );
279
+ });
280
+ it("appends, replaces, and keeps the rest of the file", () => {
281
+ expect(upsertEnvLine("", "OPENAI_API_KEY", "sk-1")).toBe(
282
+ "OPENAI_API_KEY=sk-1\n",
283
+ );
284
+ expect(
285
+ upsertEnvLine(
286
+ "A=1\nexport OPENAI_API_KEY = old\nB=2",
287
+ "OPENAI_API_KEY",
288
+ "sk-2",
289
+ ),
290
+ ).toBe("A=1\nOPENAI_API_KEY=sk-2\nB=2\n");
291
+ expect(upsertEnvLine("A=1\n", "B", "2")).toBe("A=1\nB=2\n");
292
+ });
293
+ });
294
+
295
+ describe("detectAgentKind", () => {
296
+ it("names the agent from the variables it exports", () => {
297
+ expect(detectAgentKind({ CLAUDECODE: "1" })).toBe("claude");
298
+ expect(detectAgentKind({ CODEX_SANDBOX_NETWORK_DISABLED: "1" })).toBe(
299
+ "codex",
300
+ );
301
+ expect(detectAgentKind({ CURSOR_AGENT: "1" })).toBe("cursor");
302
+ expect(detectAgentKind({ GEMINI_CLI: "1" })).toBe("gemini");
303
+ expect(detectAgentKind({ CLAUDECODE: "" })).toBeUndefined();
304
+ expect(detectAgentKind({})).toBeUndefined();
305
+ });
306
+ });
307
+
308
+ describe("isDirectInvocation", () => {
309
+ it("is true only in a terminal with no agent in the environment", () => {
310
+ expect(isDirectInvocation({}, { isTTY: true }, { isTTY: true })).toBe(true);
311
+ expect(
312
+ isDirectInvocation({ CLAUDECODE: "1" }, { isTTY: true }, { isTTY: true }),
313
+ ).toBe(false);
314
+ expect(isDirectInvocation({}, { isTTY: undefined }, { isTTY: true })).toBe(
315
+ false,
316
+ );
317
+ expect(isDirectInvocation({}, { isTTY: true }, { isTTY: false })).toBe(
318
+ false,
319
+ );
320
+ });
321
+ });
322
+
323
+ describe("chat stream events", () => {
324
+ it("delivers new user messages once and replays only unacknowledged messages", () => {
325
+ const before = initialCheckoutState();
326
+ const after: Checkout.State = {
327
+ ...before,
328
+ log: [
329
+ { id: "l1", role: "agent", phase: "planning", at: 1, text: "Ready" },
330
+ { id: "l2", role: "user", phase: "planning", at: 2, text: "Use pnpm" },
331
+ {
332
+ id: "l3",
333
+ role: "user",
334
+ phase: "planning",
335
+ at: 3,
336
+ text: "Keep the theme",
337
+ acknowledgedAt: 4,
338
+ },
339
+ ],
340
+ };
341
+ expect(diffEvents(before, after)).toEqual([
342
+ { event: "message.created", messageId: "l2", text: "Use pnpm" },
343
+ ]);
344
+ expect(diffEvents(after, after)).toEqual([]);
345
+ });
346
+ });
347
+
348
+ describe("begin planning", () => {
349
+ it("surfaces a multiple choice answer as an array and flags the user's own text", () => {
350
+ const before = initialCheckoutState();
351
+ const asked: Checkout.Input = {
352
+ phase: "planning",
353
+ id: "q1",
354
+ kind: "choice",
355
+ prompt: "Which integrations?",
356
+ options: [
357
+ { id: "slack", label: "slack" },
358
+ { id: "email", label: "email" },
359
+ ],
360
+ multiple: true,
361
+ optional: false,
362
+ status: "open",
363
+ createdAt: 1,
364
+ };
365
+ const answered = (answer: string): Checkout.State => ({
366
+ ...before,
367
+ inputs: [{ ...asked, status: "answered", answer, answeredAt: 2 }],
368
+ });
369
+ expect(diffEvents(before, answered('["slack","email"]'))).toEqual([
370
+ {
371
+ event: "input.answered",
372
+ inputId: "q1",
373
+ prompt: "Which integrations?",
374
+ answer: ["slack", "email"],
375
+ },
376
+ ]);
377
+ expect(diffEvents(before, answered('["email","Discord"]'))).toEqual([
378
+ {
379
+ event: "input.answered",
380
+ inputId: "q1",
381
+ prompt: "Which integrations?",
382
+ answer: ["email", "Discord"],
383
+ custom: true,
384
+ },
385
+ ]);
386
+ });
387
+
388
+ it("emits a start event only when the browser starts planning", () => {
389
+ const waiting = initialCheckoutState();
390
+ const connected = {
391
+ ...waiting,
392
+ agent: { ...waiting.agent, connected: true, lastSeenAt: 1 },
393
+ };
394
+ expect(diffEvents(waiting, connected)).toEqual([]);
395
+ const planning: Checkout.State = { ...connected, status: "planning" };
396
+ expect(diffEvents(connected, planning)).toEqual([
397
+ { event: "planning.started" },
398
+ ]);
399
+ expect(diffEvents(planning, planning)).toEqual([]);
400
+ expect(diffEvents(waiting, { ...waiting, status: "done" })).toEqual([
401
+ { event: "finished", next: expect.stringContaining("Keep the stream") },
402
+ ]);
403
+ expect(diffEvents(waiting, { ...waiting, status: "cancelled" })).toEqual([
404
+ { event: "cancelled", next: expect.stringContaining("Keep the stream") },
405
+ ]);
406
+ });
407
+
408
+ it("announces the next setup the browser creates on the same link", () => {
409
+ const done: Checkout.State = {
410
+ ...initialCheckoutState(),
411
+ id: "c1",
412
+ status: "done",
413
+ log: [{ id: "l1", role: "user", phase: "installing", at: 1, text: "x" }],
414
+ };
415
+ const next: Checkout.State = {
416
+ ...initialCheckoutState(),
417
+ id: "c2",
418
+ products: [{ slug: "cloud", name: "Cloud" }],
419
+ };
420
+ expect(diffEvents(done, next)).toEqual([
421
+ { event: "setup.created", products: ["cloud"] },
422
+ ]);
423
+ expect(diffEvents(next, next)).toEqual([]);
424
+ expect(diffEvents(initialCheckoutState(), initialCheckoutState())).toEqual(
425
+ [],
426
+ );
427
+ });
428
+
429
+ it.each(["planning", "cancelled"] as const)(
430
+ "blocks while waiting, then releases on %s",
431
+ async (status) => {
432
+ let state: Checkout.State | undefined;
433
+ let notify = () => {};
434
+ const unsubscribe = vi.fn();
435
+ const settled = vi.fn();
436
+ const result = waitForStart({
437
+ get state() {
438
+ return state;
439
+ },
440
+ subscribe: (listener) => {
441
+ notify = listener;
442
+ return unsubscribe;
443
+ },
444
+ }).then(settled);
445
+ state = initialCheckoutState();
446
+ notify();
447
+ await Promise.resolve();
448
+ expect(settled).not.toHaveBeenCalled();
449
+ expect(unsubscribe).not.toHaveBeenCalled();
450
+ state = { ...state, status };
451
+ notify();
452
+ await result;
453
+ expect(settled).toHaveBeenCalledWith(status);
454
+ expect(unsubscribe).toHaveBeenCalledOnce();
455
+ },
456
+ );
457
+
458
+ it.each(["planning", "installing"] as const)(
459
+ "resolves immediately when rejoining a %s session",
460
+ async (status) => {
461
+ const unsubscribe = vi.fn();
462
+ await expect(
463
+ waitForStart({
464
+ state: { ...initialCheckoutState(), status },
465
+ subscribe: () => unsubscribe,
466
+ }),
467
+ ).resolves.toBe(status);
468
+ expect(unsubscribe).toHaveBeenCalledOnce();
469
+ },
470
+ );
471
+
472
+ it.each(["done", "cancelled"] as const)(
473
+ "waits past a %s setup for the next one to begin",
474
+ async (status) => {
475
+ let state: Checkout.State = {
476
+ ...initialCheckoutState(),
477
+ id: "c1",
478
+ status,
479
+ };
480
+ let notify = () => {};
481
+ const settled = vi.fn();
482
+ const result = waitForStart({
483
+ get state() {
484
+ return state;
485
+ },
486
+ subscribe: (listener) => {
487
+ notify = listener;
488
+ return () => {};
489
+ },
490
+ }).then(settled);
491
+ await Promise.resolve();
492
+ expect(settled).not.toHaveBeenCalled();
493
+ state = { ...initialCheckoutState(), id: "c2" };
494
+ notify();
495
+ await Promise.resolve();
496
+ expect(settled).not.toHaveBeenCalled();
497
+ state = { ...state, status: "cancelled" };
498
+ notify();
499
+ await result;
500
+ expect(settled).toHaveBeenCalledWith("cancelled");
501
+ },
502
+ );
503
+ });
504
+
505
+ describe("agent instructions", () => {
506
+ it("discovers related products and asks contextual entry points before the plan", () => {
507
+ const instructions = agentInstructions(
508
+ "http://localhost:18892/checkout/session",
509
+ );
510
+ expect(instructions).toContain(
511
+ "products are starting names and guide references",
512
+ );
513
+ expect(instructions).toContain(
514
+ "The starting list does not restrict the implementation",
515
+ );
516
+ expect(instructions).toContain("only 1–3 sensible alternatives");
517
+ expect(instructions).toContain(
518
+ "two options may use the same form factor in different places",
519
+ );
520
+ expect(instructions).toContain("--entry-points '<JSON array>'");
521
+ expect(instructions).not.toContain("--product assistant-ui");
522
+ expect(instructions.indexOf("--entry-points")).toBeLessThan(
523
+ instructions.indexOf("plan --file"),
524
+ );
525
+ });
526
+
527
+ it("waits for the entry-point answer before the plan is submitted", () => {
528
+ const ask = agentInstructions("http://localhost:18892/checkout/session")
529
+ .split("\n")
530
+ .find((line) => line.includes("--entry-points '<JSON array>'"));
531
+ expect(ask?.trim().endsWith("--wait")).toBe(true);
532
+ expect(ask).not.toContain("--default");
533
+ });
534
+
535
+ it("skips the entry-point question for a setup that puts no assistant in the app", () => {
536
+ const instructions = agentInstructions(
537
+ "http://localhost:18892/checkout/session",
538
+ );
539
+ expect(instructions).toContain(
540
+ "When the setup puts an assistant in the app, ask how users should access it",
541
+ );
542
+ expect(instructions).toContain(
543
+ "Skip this question when no product's guide installs an assistant",
544
+ );
545
+ expect(instructions).toContain(
546
+ "When you ask it, do not submit the plan before the user picks an entry point.",
547
+ );
548
+ expect(instructions).not.toContain(
549
+ "recommendation. Do not submit the plan before the user picks an entry point.",
550
+ );
551
+ });
552
+ });
553
+
554
+ describe("writeEnvSecret", () => {
555
+ const tempDir = async () => {
556
+ const dir = await mkdtemp(join(tmpdir(), "setup-agent-"));
557
+ onTestFinished(() => rm(dir, { recursive: true, force: true }));
558
+ return dir;
559
+ };
560
+
561
+ it("leaves the secret deposited when the file cannot be opened", async () => {
562
+ const dir = await tempDir();
563
+ const take = vi.fn(async () => "sk-test");
564
+ await expect(
565
+ writeEnvSecret(
566
+ join(dir, "missing", ".env.local"),
567
+ "OPENAI_API_KEY",
568
+ take,
569
+ ),
570
+ ).rejects.toThrow();
571
+ expect(take).not.toHaveBeenCalled();
572
+ });
573
+
574
+ it.skipIf(process.platform === "win32")(
575
+ "creates the file readable only by its owner",
576
+ async () => {
577
+ const file = join(await tempDir(), ".env.local");
578
+ await writeEnvSecret(file, "OPENAI_API_KEY", async () => "sk-test");
579
+ expect((await stat(file)).mode & 0o777).toBe(0o600);
580
+ expect(await readFile(file, "utf8")).toBe("OPENAI_API_KEY=sk-test\n");
581
+ },
582
+ );
583
+
584
+ it("keeps the rest of an existing file and restricts it to its owner", async () => {
585
+ const file = join(await tempDir(), ".env.local");
586
+ await writeFile(file, "A=1\nOPENAI_API_KEY=old\nB=2\n");
587
+ await chmod(file, 0o644);
588
+ await writeEnvSecret(file, "OPENAI_API_KEY", async () => "sk-test");
589
+ expect(await readFile(file, "utf8")).toBe(
590
+ "A=1\nOPENAI_API_KEY=sk-test\nB=2\n",
591
+ );
592
+ if (process.platform !== "win32") {
593
+ expect((await stat(file)).mode & 0o777).toBe(0o600);
594
+ }
595
+ });
596
+
597
+ it("refuses an invalid key before taking the secret", async () => {
598
+ const take = vi.fn(async () => "sk-test");
599
+ await expect(
600
+ writeEnvSecret(join(await tempDir(), ".env.local"), "BAD KEY", take),
601
+ ).rejects.toThrow("not an environment variable name");
602
+ expect(take).not.toHaveBeenCalled();
603
+ });
604
+
605
+ it("keeps lines another writer added while the secret was being taken", async () => {
606
+ const file = join(await tempDir(), ".env.local");
607
+ await writeEnvSecret(file, "OPENAI_API_KEY", async () => {
608
+ await writeFile(file, "OTHER_KEY=1\n");
609
+ return "sk-test";
610
+ });
611
+ expect(await readFile(file, "utf8")).toBe(
612
+ "OTHER_KEY=1\nOPENAI_API_KEY=sk-test\n",
613
+ );
614
+ });
615
+ });