setup-agent 0.0.4 → 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.
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  isClosed,
15
15
  isOptionIcon,
16
16
  isValidModelAnswer,
17
+ parseEntryPointOptions,
17
18
  parseMultipleAnswer,
18
19
  stepProgress,
19
20
  } from "./protocol";
@@ -28,17 +29,16 @@ Commands (the url always comes first):
28
29
  ack <message-id> Acknowledge a user message.
29
30
  ask <prompt> [--placeholder <text>] [--optional] [--step <step-id>] [--wait]
30
31
  Ask the user for a line of text.
31
- ask [prompt] --preset <framework|llm-provider|project> [--only <id,id>] [--choices <id=description,...>] [--default <id>] [--optional] [--step <step-id>] [--wait]
32
+ ask [prompt] --preset <framework|llm-provider> [--only <id,id>] [--choices <id=description,...>] [--default <id>] [--optional] [--step <step-id>] [--wait]
32
33
  Ask a standard question. --only restricts the options and leaves out --choices (one id locks it in); --default preselects one.
33
34
  "framework" answers "<framework>:<language>"; "llm-provider" answers JSON with provider, model and reasoningEffort; the key itself arrives through "env".
34
- "project" lists the React projects you found as --choices "<path>=<what it is>,..." ahead of "New project"; it answers a path, or "new:<next|vite|react-router|tanstack-start|expo>".
35
35
  ask <prompt> --choices <id,id,...> --icons <id=icon,...> [--single] [--default <id>] [--optional] [--step <step-id>] [--wait]
36
36
  Ask the user to pick from your own options; they may also type their own answer.
37
37
  The user checks any number of options and the answer is a JSON array of option ids (their own text is one entry).
38
38
  --single is for options that exclude each other (one framework, one model, yes or no); the answer is then one id.
39
39
  --icons is required and names an icon for every option, from: ${OPTION_ICONS.join(", ")}.
40
- ask <prompt> --product <slug> [--wait]
41
- Propose adding a product to this setup, such as one another product needs first. Answers "added"; the product and its guide then appear in "status".
40
+ ask <prompt> --entry-points '<JSON array>' [--default <id>] [--optional] [--step <step-id>] [--wait]
41
+ Ask where users should access the assistant. Each of 1–3 options has id, label, description and entryPoint {formFactor: modal|sidebar|full-page, placement, trigger, recommended?}. Answers one option id.
42
42
  wait start Block until the user clicks Begin plan in their browser; after a finished or cancelled setup, until the next one begins.
43
43
  wait <input-id> Block until the user answers or dismisses an input. A multi-select answer prints as an array.
44
44
  plan (<markdown> | --file <path>) [--wait]
@@ -54,7 +54,7 @@ Commands (the url always comes first):
54
54
  Mark every remaining step done and propose closing; the user closes it.
55
55
  `;
56
56
 
57
- export const INSTRUCTIONS = (
57
+ export const agentInstructions = (
58
58
  url: string,
59
59
  ) => `You are the coding agent for a setup the user opened in their browser. The browser shows this setup live; every command below updates it. The user watches the browser, not this terminal: once setup begins, send questions through "ask" so the user can answer in their browser.
60
60
 
@@ -73,13 +73,14 @@ Keep the stream running while you wait. Do not inspect the project, fetch guides
73
73
  The remaining phases are plan, then install.
74
74
 
75
75
  1. Plan. Investigate only; change nothing yet. If the current directory is not an app to install into (a monorepo root, an empty folder, a library), ask where to install first:
76
- npx setup-agent ${url} ask "Which project should I install into?" --placeholder "An absolute path, or a path for a new Next.js app" --wait
76
+ npx setup-agent ${url} ask "Which project should I install into?" --placeholder "An absolute path to the app" --wait
77
77
  Read the project: its app framework and package manager, the agent framework and model provider it already uses, where components live, any existing chat route. Fetch each product's "guide" URL from the setup below; it is the install reference. Ask for what the project does not decide, using the standard questions so the browser can render them well:
78
78
  npx setup-agent ${url} ask --preset framework
79
79
  npx setup-agent ${url} ask --preset llm-provider
80
- If a product's guide needs another product the project does not have yet (most need assistant-ui installed and rendering a thread), do not stop and do not install it on the side. Propose adding it to this setup:
81
- npx setup-agent ${url} ask "assistant-ui is not installed in this project yet. Add it to this setup?" --product assistant-ui --wait
82
- On "added", run "status", fetch the new product's guide, and install it first in your plan. If the user dismisses it, follow what the guide says about the missing product.
80
+ The setup's products are starting names and guide references. Explore each product's setup instructions, then discover and plan any other relevant products or dependencies the app needs. Read /catalog.md on the guide URL’s origin to find related products and fetch their setup guides. The starting list does not restrict the implementation or require adding another product to the list. Include relevant additions in the plan for approval.
81
+ When the setup puts an assistant in the app, ask how users should access it after discovering the actual app and before proposing the plan. Skip this question when no product's guide installs an assistant (a standalone library or service setup, for example). Use an entry-point question with only 1–3 sensible alternatives. Describe the actual app's pages, workflow and component placement, not generic placeholder copy. Recommend one clear fit when there is one; two options may use the same form factor in different places. Each option needs a stable id, label, contextual description and entryPoint {"formFactor":"modal"|"sidebar"|"full-page","placement":"where it appears","trigger":"how users open it or navigate to it","recommended":true?}. Write the JSON array for this app and send it with:
82
+ npx setup-agent ${url} ask "Where should users access the assistant?" --entry-points '<JSON array>' --wait
83
+ Mark a clear fit with "recommended":true rather than --default, so the user makes the pick. When you ask it, do not submit the plan before the user picks an entry point. Respect the selected placement and trigger in the plan and implementation. Do not ask for a new-versus-existing app setting; discover the app from its files and ask only for an install path when needed.
83
84
  Skip a preset the code already answers (a chat route on the AI SDK, a provider key in .env.local) and log why. Ask everything at once, keep investigating, and block with "wait <input-id>" only when nothing else can proceed. A framework answer is "<framework>:<language>", for example "ai-sdk:typescript". A model answer is JSON {"provider","model","reasoningEffort"?}. An answer may carry a "note" from the user; follow it. A choice answer that matches none of the options is the user's own text.
84
85
  Then write the plan as markdown and submit it:
85
86
  npx setup-agent ${url} plan --file <path> --wait
@@ -99,6 +100,8 @@ Before that, start the project's dev server in the background when the project h
99
100
  which returns once that setup begins, then run "status" for its products, guides and instructions and go through plan and install again. A "setup.created" event while you are still working means the user replaced this setup: drop what you were doing and start over the same way.
100
101
  `;
101
102
 
103
+ export const INSTRUCTIONS = agentInstructions;
104
+
102
105
  type Parsed = { positional: string[]; flags: Map<string, string | true> };
103
106
 
104
107
  const parseArgs = (argv: readonly string[]): Parsed => {
@@ -506,6 +509,52 @@ export const askSeed = (
506
509
  const stepId = flagText(flags, "step");
507
510
  const optional = flags.has("optional");
508
511
  const fallback = flagText(flags, "default");
512
+ if (flags.has("entry-points")) {
513
+ if (
514
+ [
515
+ "preset",
516
+ "product",
517
+ "choices",
518
+ "icons",
519
+ "multiple",
520
+ "single",
521
+ "only",
522
+ "placeholder",
523
+ ].some((flag) => flags.has(flag))
524
+ )
525
+ return fail(
526
+ "--entry-points cannot be combined with other input kinds, --multiple, --single or --only",
527
+ );
528
+ const json = flagText(flags, "entry-points");
529
+ let value: unknown;
530
+ try {
531
+ value = JSON.parse(json ?? "");
532
+ } catch {
533
+ return fail("--entry-points needs a JSON array of options");
534
+ }
535
+ const options = parseEntryPointOptions(value);
536
+ if (!options)
537
+ return fail(
538
+ "--entry-points needs 1–3 unique options with id, label, description and entryPoint {formFactor, placement, trigger, recommended?}; at most one is recommended",
539
+ );
540
+ if (
541
+ fallback !== undefined &&
542
+ !options.some((option) => option.id === fallback)
543
+ )
544
+ return fail(`no option "${fallback}" to default to`);
545
+ const prompt =
546
+ rest[0] ?? fail("usage: ask <prompt> --entry-points '<JSON array>'");
547
+ if (prompt.trim() === "")
548
+ return fail("an entry-point prompt cannot be empty");
549
+ return {
550
+ kind: "entry-point",
551
+ prompt,
552
+ options,
553
+ ...(fallback !== undefined && { default: fallback }),
554
+ optional,
555
+ ...(stepId !== undefined && { stepId }),
556
+ };
557
+ }
509
558
  const preset = flagText(flags, "preset");
510
559
  if (preset !== undefined) {
511
560
  if (!isPresetId(preset)) {
package/src/host.test.ts CHANGED
@@ -310,12 +310,6 @@ describe("steps", () => {
310
310
  host.commands["agent/step"]({ stepId: "nope", status: "done" }),
311
311
  ),
312
312
  ).toEqual({ reason: "unknown-step" });
313
- expect(
314
- await reason(
315
- host.commands["agent/add-step"]({ title: "x", product: "nope" }),
316
- ),
317
- ).toEqual({ reason: "unknown-product" });
318
-
319
313
  await host.commands["agent/ask"]({ prompt: "Port?", stepId: "s2" });
320
314
  expect(await reason(host.commands["checkout/finish"]())).toEqual({
321
315
  reason: "finish-not-proposed",
@@ -776,6 +770,138 @@ describe("setup chat", () => {
776
770
  });
777
771
  });
778
772
 
773
+ describe("entry-point questions", () => {
774
+ const options = [
775
+ {
776
+ id: "ticket-sidebar",
777
+ label: "Ticket sidebar",
778
+ description: "Keep the support ticket visible while drafting a reply.",
779
+ entryPoint: {
780
+ formFactor: "sidebar" as const,
781
+ placement: "Right side of the ticket detail page",
782
+ trigger: "Draft reply button in the ticket toolbar",
783
+ recommended: true,
784
+ },
785
+ },
786
+ {
787
+ id: "inbox-sidebar",
788
+ label: "Inbox sidebar",
789
+ description: "Draft replies from the selected ticket in the inbox.",
790
+ entryPoint: {
791
+ formFactor: "sidebar" as const,
792
+ placement: "Right side of the inbox",
793
+ trigger: "Assist button in the inbox header",
794
+ },
795
+ },
796
+ ];
797
+
798
+ it("persists agent-generated placements and answers by stable id across restore", async () => {
799
+ const host = mount();
800
+ await host.commands["checkout/create"](seed);
801
+ await host.commands["checkout/begin-plan"]();
802
+ const { inputId } = await host.commands["agent/ask"]({
803
+ kind: "entry-point",
804
+ prompt: "Where should support agents draft replies?",
805
+ options,
806
+ default: "ticket-sidebar",
807
+ });
808
+ expect(host.state.inputs[0]?.options).toEqual(options);
809
+ for (const answer of [
810
+ "sidebar",
811
+ "ticket-sidebar:variant",
812
+ '["ticket-sidebar"]',
813
+ "",
814
+ ]) {
815
+ expect(
816
+ await reason(host.commands["checkout/answer"]({ inputId, answer })),
817
+ ).toEqual({ reason: "invalid-answer" });
818
+ }
819
+ await host.commands["checkout/answer"]({
820
+ inputId,
821
+ answer: "inbox-sidebar",
822
+ note: "Keep the ticket list visible",
823
+ });
824
+ const restored = mount(host.snapshot());
825
+ expect(restored.state.inputs[0]).toMatchObject({
826
+ kind: "entry-point",
827
+ options,
828
+ status: "answered",
829
+ answer: "inbox-sidebar",
830
+ note: "Keep the ticket list visible",
831
+ });
832
+ });
833
+
834
+ it("rejects invalid structured inputs before they enter persisted state", async () => {
835
+ const host = mount();
836
+ await host.commands["checkout/create"](seed);
837
+ await host.commands["checkout/begin-plan"]();
838
+ for (const invalid of [
839
+ { options: [] },
840
+ { options: [options[0]!, options[0]!] },
841
+ { options, multiple: true as const },
842
+ { options, default: "unknown" },
843
+ { options: [{ ...options[0]!, description: "" }] },
844
+ {
845
+ options: [
846
+ {
847
+ ...options[0]!,
848
+ entryPoint: { ...options[0]!.entryPoint, trigger: "" },
849
+ },
850
+ ],
851
+ },
852
+ ]) {
853
+ expect(
854
+ await reason(
855
+ host.commands["agent/ask"]({
856
+ kind: "entry-point",
857
+ prompt: "Where?",
858
+ ...invalid,
859
+ }),
860
+ ),
861
+ ).toEqual({ reason: "invalid-input" });
862
+ }
863
+ expect(host.state.inputs).toEqual([]);
864
+ });
865
+ });
866
+
867
+ describe("product discovery", () => {
868
+ it("rejects an empty related-product identifier", async () => {
869
+ const host = await approvedHost();
870
+ expect(
871
+ await reason(
872
+ host.commands["agent/add-step"]({
873
+ title: "Install integration",
874
+ product: " ",
875
+ }),
876
+ ),
877
+ ).toEqual({ reason: "invalid-input" });
878
+ expect(host.state.steps).toEqual([]);
879
+ });
880
+
881
+ it("tracks relevant products outside the starting list without changing that list", async () => {
882
+ const host = await approvedHost();
883
+ const discovered = await host.commands["agent/add-step"]({
884
+ title: "Install discovered integration",
885
+ product: "discovered-integration",
886
+ });
887
+ expect(
888
+ host.state.steps.find((step) => step.id === discovered.stepId),
889
+ ).toMatchObject({
890
+ product: "discovered-integration",
891
+ });
892
+ await host.commands["agent/step"]({
893
+ stepId: discovered.stepId,
894
+ status: "done",
895
+ });
896
+ const restored = mount(host.snapshot());
897
+ expect(restored.state.steps[0]).toMatchObject({
898
+ product: "discovered-integration",
899
+ status: "done",
900
+ });
901
+ expect(restored.state.products).toEqual(seed.products);
902
+ });
903
+ });
904
+
779
905
  describe("closing", () => {
780
906
  it("keeps a finished setup done when a late cancel arrives", async () => {
781
907
  const host = await approvedHost();
@@ -785,6 +911,27 @@ describe("closing", () => {
785
911
  expect(host.state.status).toBe("done");
786
912
  });
787
913
 
914
+ it("ignores a cancel meant for another checkout", async () => {
915
+ const host = mount();
916
+ await host.commands["checkout/create"](seed);
917
+ await host.commands["checkout/cancel"]({ id: "c0" });
918
+ expect(host.state.status).toBe("waiting");
919
+ });
920
+
921
+ it("cancels the checkout a cancel names", async () => {
922
+ const host = mount();
923
+ await host.commands["checkout/create"](seed);
924
+ await host.commands["checkout/cancel"]({ id: seed.id });
925
+ expect(host.state.status).toBe("cancelled");
926
+ });
927
+
928
+ it("cancels the current checkout when a cancel names none", async () => {
929
+ const host = mount();
930
+ await host.commands["checkout/create"](seed);
931
+ await host.commands["checkout/cancel"]({});
932
+ expect(host.state.status).toBe("cancelled");
933
+ });
934
+
788
935
  it("ignores a cancel before the setup exists", async () => {
789
936
  const host = mount();
790
937
  await host.commands["checkout/cancel"]();
package/src/host.ts CHANGED
@@ -12,6 +12,9 @@ import {
12
12
  isAgentPresent,
13
13
  isClosed,
14
14
  isValidModelAnswer,
15
+ isValidEntryPointAnswer,
16
+ isValidEntryPointInput,
17
+ parseEntryPointOptions,
15
18
  parsePreviewUrl,
16
19
  } from "./protocol";
17
20
  import type { Checkout } from "./protocol";
@@ -83,6 +86,16 @@ const useCheckoutHost = (restored: unknown) => {
83
86
 
84
87
  const ask = (seed: Checkout.InputSeed & { stepId?: string }) => {
85
88
  const kind = seed.kind ?? "text";
89
+ const options =
90
+ kind === "entry-point"
91
+ ? parseEntryPointOptions(seed.options)
92
+ : seed.options;
93
+ if (kind === "entry-point" && !isValidEntryPointInput(seed)) {
94
+ throw reject(
95
+ "invalid-input",
96
+ "an entry-point input needs a prompt and 1–3 distinct options with descriptions, form factors, placements and triggers; at most one is recommended",
97
+ );
98
+ }
86
99
  if (kind === "product") {
87
100
  if (!seed.product) {
88
101
  throw reject("invalid-input", "a product input names a product");
@@ -148,7 +161,7 @@ const useCheckoutHost = (restored: unknown) => {
148
161
  ...(seed.preset !== undefined && { preset: seed.preset }),
149
162
  prompt: seed.prompt,
150
163
  ...(seed.placeholder !== undefined && { placeholder: seed.placeholder }),
151
- ...(seed.options !== undefined && { options: seed.options }),
164
+ ...(options !== undefined && { options }),
152
165
  ...(seed.multiple && { multiple: true }),
153
166
  ...(kind === "product" && { product: seed.product }),
154
167
  ...(seed.default !== undefined && { default: seed.default }),
@@ -237,6 +250,12 @@ const useCheckoutHost = (restored: unknown) => {
237
250
  : `"${answer}" names an option without a valid variant`,
238
251
  );
239
252
  }
253
+ if (
254
+ input.kind === "entry-point" &&
255
+ !isValidEntryPointAnswer(input, answer)
256
+ ) {
257
+ throw reject("invalid-answer", "an entry-point answer is an option id");
258
+ }
240
259
  if (input.kind === "model" && !isValidModelAnswer(input, answer)) {
241
260
  throw reject(
242
261
  "invalid-answer",
@@ -328,8 +347,9 @@ const useCheckoutHost = (restored: unknown) => {
328
347
  plan.decidedAt = Date.now();
329
348
  plan.feedback = params.feedback.trim();
330
349
  },
331
- "checkout/cancel": () => {
350
+ "checkout/cancel": ({ id } = {}) => {
332
351
  if (state.createdAt === null || isClosed(state)) return;
352
+ if (id !== undefined && id !== state.id) return;
333
353
  state.status = "cancelled";
334
354
  dismissOpenInputs();
335
355
  },
@@ -383,11 +403,11 @@ const useCheckoutHost = (restored: unknown) => {
383
403
  },
384
404
  "agent/add-step": ({ title, detail, product, active }) => {
385
405
  requireInstalling();
386
- if (
387
- product !== undefined &&
388
- !state.products.some((candidate) => candidate.slug === product)
389
- ) {
390
- throw reject("unknown-product", `no product "${product}"`);
406
+ if (product !== undefined && product.trim() === "") {
407
+ throw reject(
408
+ "invalid-input",
409
+ "a step's product identifier cannot be empty",
410
+ );
391
411
  }
392
412
  const id = `s${state.steps.length + 1}`;
393
413
  state.steps.push({
package/src/index.ts CHANGED
@@ -12,8 +12,11 @@ export {
12
12
  isClosed,
13
13
  isOptionIcon,
14
14
  isValidModelAnswer,
15
+ isValidEntryPointAnswer,
16
+ isValidEntryPointInput,
15
17
  openInputs,
16
18
  parseChoiceAnswer,
19
+ parseEntryPointOptions,
17
20
  parseModelAnswer,
18
21
  parseMultipleAnswer,
19
22
  parsePreviewUrl,
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ isValidEntryPointAnswer,
4
+ isValidEntryPointInput,
5
+ parseEntryPointOptions,
6
+ type Checkout,
7
+ } from "./protocol";
8
+
9
+ const option: Checkout.EntryPointOption = {
10
+ id: "ticket-sidebar",
11
+ label: "Ticket sidebar",
12
+ description: "Draft replies beside the selected support ticket.",
13
+ entryPoint: {
14
+ formFactor: "sidebar",
15
+ placement: "The right side of the ticket detail page",
16
+ trigger: "Open with the Draft reply button in the ticket toolbar",
17
+ recommended: true,
18
+ },
19
+ };
20
+
21
+ const input: Checkout.Input = {
22
+ id: "q1",
23
+ kind: "entry-point",
24
+ phase: "planning",
25
+ prompt: "Where should agents draft replies?",
26
+ options: [option],
27
+ default: option.id,
28
+ optional: false,
29
+ status: "open",
30
+ createdAt: 1,
31
+ };
32
+
33
+ describe("entry-point options", () => {
34
+ it("keeps contextual metadata and permits different placements of the same form factor", () => {
35
+ const second = {
36
+ ...option,
37
+ id: "inbox-sidebar",
38
+ entryPoint: {
39
+ ...option.entryPoint,
40
+ placement: "The inbox list",
41
+ trigger: "Open with the inbox Assist button",
42
+ recommended: false,
43
+ },
44
+ };
45
+ expect(parseEntryPointOptions([option, second])).toEqual([option, second]);
46
+ expect(isValidEntryPointInput(input)).toBe(true);
47
+ expect(isValidEntryPointAnswer(input, "ticket-sidebar")).toBe(true);
48
+ expect(isValidEntryPointAnswer(input, "sidebar")).toBe(false);
49
+ expect(isValidEntryPointAnswer(input, "ticket-sidebar:variant")).toBe(
50
+ false,
51
+ );
52
+ expect(isValidEntryPointAnswer(input, '["ticket-sidebar"]')).toBe(false);
53
+ });
54
+
55
+ it.each(
56
+ [
57
+ null,
58
+ {},
59
+ [],
60
+ [null],
61
+ [option, option],
62
+ [option, { ...option, id: "another" }],
63
+ [
64
+ option,
65
+ { ...option, id: "b" },
66
+ { ...option, id: "c" },
67
+ { ...option, id: "d" },
68
+ ],
69
+ [{ ...option, id: "with:variant" }],
70
+ [{ ...option, id: "x".repeat(65) }],
71
+ [{ ...option, label: " " }],
72
+ [{ ...option, description: "" }],
73
+ [{ ...option, description: "x".repeat(1001) }],
74
+ [{ ...option, entryPoint: null }],
75
+ [
76
+ {
77
+ ...option,
78
+ entryPoint: { ...option.entryPoint, formFactor: "drawer" },
79
+ },
80
+ ],
81
+ [{ ...option, entryPoint: { ...option.entryPoint, placement: "" } }],
82
+ [{ ...option, entryPoint: { ...option.entryPoint, trigger: " " } }],
83
+ [{ ...option, entryPoint: { ...option.entryPoint, recommended: "yes" } }],
84
+ [{ ...option, variants: [] }],
85
+ ].map((value) => ({ value })),
86
+ )("rejects malformed or ambiguous choices %#", ({ value }) => {
87
+ expect(parseEntryPointOptions(value)).toBeUndefined();
88
+ });
89
+
90
+ it("types an option with only the fields the parser keeps", () => {
91
+ const withIcon: Checkout.EntryPointOption = {
92
+ ...option,
93
+ // @ts-expect-error an entry-point option carries no icon
94
+ icon: "sidebar",
95
+ };
96
+ const withVariants: Checkout.EntryPointOption = {
97
+ ...option,
98
+ // @ts-expect-error an entry-point option carries no variants
99
+ variants: [],
100
+ };
101
+ expect(parseEntryPointOptions([withIcon])).toEqual([option]);
102
+ expect(parseEntryPointOptions([withVariants])).toBeUndefined();
103
+ });
104
+
105
+ it("strips undeclared payload fields", () => {
106
+ expect(
107
+ parseEntryPointOptions([{ ...option, icon: "untrusted", extra: 1 }]),
108
+ ).toEqual([option]);
109
+ });
110
+
111
+ it("requires an exact default and a single selection", () => {
112
+ expect(isValidEntryPointInput({ ...input, multiple: true })).toBe(false);
113
+ expect(isValidEntryPointInput({ ...input, default: "unknown" })).toBe(
114
+ false,
115
+ );
116
+ expect(isValidEntryPointInput({ ...input, prompt: " " })).toBe(false);
117
+ expect(isValidEntryPointInput({ ...input, kind: "choice" })).toBe(false);
118
+ });
119
+
120
+ it("refuses a help note, which the entry-point card has no place for", () => {
121
+ expect(
122
+ isValidEntryPointInput({
123
+ ...input,
124
+ help: { summary: "Pick one" },
125
+ }),
126
+ ).toBe(false);
127
+ });
128
+ });
package/src/protocol.ts CHANGED
@@ -32,7 +32,26 @@ export namespace Checkout {
32
32
  * another product depends on. The browser owns the catalog, so it answers
33
33
  * with `checkout/add-product`; dismissing the input declines.
34
34
  */
35
- export type InputKind = "text" | "choice" | "model" | "product";
35
+ export type InputKind =
36
+ | "text"
37
+ | "choice"
38
+ | "model"
39
+ | "product"
40
+ | "entry-point";
41
+
42
+ export type EntryPoint = {
43
+ formFactor: "modal" | "sidebar" | "full-page";
44
+ placement: string;
45
+ trigger: string;
46
+ recommended?: boolean;
47
+ };
48
+
49
+ export type EntryPointOption = {
50
+ id: string;
51
+ label: string;
52
+ description: string;
53
+ entryPoint: EntryPoint;
54
+ };
36
55
 
37
56
  export type ChoiceVariant = { id: string; label: string };
38
57
 
@@ -44,6 +63,7 @@ export namespace Checkout {
44
63
  icon?: string;
45
64
  /** A second pick under this option, such as the language of a framework. */
46
65
  variants?: ChoiceVariant[];
66
+ entryPoint?: EntryPoint;
47
67
  };
48
68
 
49
69
  /** A short nudge for users who need help choosing, with a longer guide to link to. */
@@ -199,7 +219,8 @@ export namespace Checkout {
199
219
  "agent/ack": (params: { messageId: string }) => void;
200
220
  "checkout/dismiss": (params: { inputId: string }) => void;
201
221
  "checkout/plan": (params: PlanDecision) => void;
202
- "checkout/cancel": () => void;
222
+ /** A cancel that names another checkout than the current one is a no-op. */
223
+ "checkout/cancel": (params?: { id?: string }) => void;
203
224
  "checkout/finish": () => void;
204
225
  "agent/intro": (params: { kind?: string }) => void;
205
226
  "agent/hello": (params: { cwd?: string; kind?: string }) => void;
@@ -437,6 +458,76 @@ export const classifyChoiceAnswer = (
437
458
  return kinds.includes("custom") ? "custom" : "option";
438
459
  };
439
460
 
461
+ const isEntryPointText = (value: unknown, max: number): value is string =>
462
+ typeof value === "string" && value.trim() !== "" && value.length <= max;
463
+
464
+ export const parseEntryPointOptions = (
465
+ value: unknown,
466
+ ): Checkout.EntryPointOption[] | undefined => {
467
+ if (!Array.isArray(value) || value.length < 1 || value.length > 3)
468
+ return undefined;
469
+ const options: Checkout.EntryPointOption[] = [];
470
+ for (const candidate of value) {
471
+ if (typeof candidate !== "object" || candidate === null) return undefined;
472
+ const { id, label, description, entryPoint, variants } =
473
+ candidate as Record<string, unknown>;
474
+ if (
475
+ typeof id !== "string" ||
476
+ !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(id) ||
477
+ options.some((option) => option.id === id) ||
478
+ !isEntryPointText(label, 160) ||
479
+ !isEntryPointText(description, 1000) ||
480
+ variants !== undefined ||
481
+ typeof entryPoint !== "object" ||
482
+ entryPoint === null
483
+ )
484
+ return undefined;
485
+ const { formFactor, placement, trigger, recommended } =
486
+ entryPoint as Record<string, unknown>;
487
+ if (
488
+ (formFactor !== "modal" &&
489
+ formFactor !== "sidebar" &&
490
+ formFactor !== "full-page") ||
491
+ !isEntryPointText(placement, 400) ||
492
+ !isEntryPointText(trigger, 400) ||
493
+ (recommended !== undefined && typeof recommended !== "boolean")
494
+ )
495
+ return undefined;
496
+ options.push({
497
+ id,
498
+ label,
499
+ description,
500
+ entryPoint: {
501
+ formFactor,
502
+ placement,
503
+ trigger,
504
+ ...(recommended !== undefined && { recommended }),
505
+ },
506
+ });
507
+ }
508
+ return options.filter((option) => option.entryPoint.recommended).length <= 1
509
+ ? options
510
+ : undefined;
511
+ };
512
+
513
+ export const isValidEntryPointInput = (input: Checkout.InputSeed) => {
514
+ const options = parseEntryPointOptions(input.options);
515
+ return (
516
+ input.kind === "entry-point" &&
517
+ isEntryPointText(input.prompt, 1000) &&
518
+ options !== undefined &&
519
+ input.multiple === undefined &&
520
+ input.help === undefined &&
521
+ (input.default === undefined ||
522
+ options.some((option) => option.id === input.default))
523
+ );
524
+ };
525
+
526
+ export const isValidEntryPointAnswer = (
527
+ input: Checkout.Input,
528
+ answer: string,
529
+ ) => input.options?.some((option) => option.id === answer) ?? false;
530
+
440
531
  const REASONING_EFFORTS = new Set(["low", "medium", "high"]);
441
532
 
442
533
  const isReasoningEffort = (