setup-agent 0.1.1 → 0.2.0

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 (47) hide show
  1. package/README.md +16 -4
  2. package/dist/agents.d.ts.map +1 -1
  3. package/dist/browser.d.ts.map +1 -1
  4. package/dist/cli.d.ts +0 -20
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +90 -84
  7. package/dist/cli.js.map +1 -1
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/guide.d.ts +45 -0
  10. package/dist/guide.d.ts.map +1 -0
  11. package/dist/guide.js +133 -0
  12. package/dist/guide.js.map +1 -0
  13. package/dist/host.d.ts.map +1 -1
  14. package/dist/host.js +1 -1
  15. package/dist/host.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -1
  19. package/dist/needs.d.ts +4 -2
  20. package/dist/needs.d.ts.map +1 -1
  21. package/dist/needs.js +20 -17
  22. package/dist/needs.js.map +1 -1
  23. package/dist/presets.d.ts.map +1 -1
  24. package/dist/protocol.d.ts +1 -1
  25. package/dist/protocol.d.ts.map +1 -1
  26. package/dist/sdk.d.ts.map +1 -1
  27. package/dist/sdk.js +19 -24
  28. package/dist/sdk.js.map +1 -1
  29. package/dist/stream.d.ts +8 -4
  30. package/dist/stream.d.ts.map +1 -1
  31. package/dist/stream.js +28 -17
  32. package/dist/stream.js.map +1 -1
  33. package/package.json +4 -4
  34. package/src/cli.test.ts +129 -116
  35. package/src/cli.ts +145 -132
  36. package/src/guide.test.ts +246 -0
  37. package/src/guide.ts +177 -0
  38. package/src/host.ts +1 -1
  39. package/src/index.ts +2 -0
  40. package/src/needs.test.ts +4 -5
  41. package/src/needs.ts +38 -23
  42. package/src/sdk.test.ts +8 -12
  43. package/src/sdk.ts +21 -31
  44. package/src/stream.test.ts +9 -7
  45. package/src/stream.ts +50 -23
  46. package/src/tests/guide-fixture.ts +39 -0
  47. package/src/tests/setup.ts +15 -0
package/src/guide.ts ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The guide: every text the CLI shows the coding agent and the order of its
3
+ * intro, fetched from the setup server at `<setup-url>/guide` so a wording
4
+ * or flow fix ships with a server deploy. Setup is online-only, and the CLI
5
+ * keeps no copy of the text.
6
+ *
7
+ * The guide is data. The CLI prints its texts after stripping control
8
+ * characters, fills `{{name}}` placeholders with values it computes itself,
9
+ * and runs intro steps only from `LOCAL_ACTIONS`. Commands it runs, files it
10
+ * writes and URLs it opens never come from the guide.
11
+ */
12
+
13
+ /** The guide format the CLI reads. Bump it only for a change an older CLI cannot read. */
14
+ export const GUIDE_PROTOCOL = 1;
15
+
16
+ /** Every text the CLI prints from the guide. */
17
+ export const GUIDE_TEXT_KEYS = [
18
+ "briefing",
19
+ "intro.status-heading",
20
+ "stream.briefing",
21
+ "stream.briefing-footer",
22
+ "stream.stay-connected",
23
+ "stream.opened.input",
24
+ "stream.opened.plan",
25
+ "stream.opened.finish",
26
+ "stream.ask.recently-opened",
27
+ "stream.ask.browser-closed",
28
+ "stream.ask.no-browser",
29
+ "wait.waiting",
30
+ "done.next",
31
+ "need.plan.question",
32
+ "need.plan.approve",
33
+ "need.plan.revise",
34
+ "need.plan.format",
35
+ "need.finish.question",
36
+ "need.finish.question-preview",
37
+ "need.finish.close",
38
+ "need.finish.format",
39
+ "need.input.empty-prompt",
40
+ "need.input.optional",
41
+ "need.input.model",
42
+ "need.product.add",
43
+ "need.product.decline",
44
+ "need.format.text",
45
+ "need.format.product",
46
+ "need.format.model",
47
+ "need.format.entry-point",
48
+ "need.format.choice",
49
+ "need.format.multiple",
50
+ ] as const;
51
+ export type GuideTextKey = (typeof GUIDE_TEXT_KEYS)[number];
52
+
53
+ /** All a guide can make the CLI do: print one of its texts, print the setup's state, or announce the agent. */
54
+ const LOCAL_ACTIONS = ["print", "print-status", "announce-agent"] as const;
55
+
56
+ export type GuideStep =
57
+ | { action: "print"; text: GuideTextKey }
58
+ | { action: "print-status" }
59
+ | { action: "announce-agent" };
60
+
61
+ export type Guide = {
62
+ protocol: typeof GUIDE_PROTOCOL;
63
+ texts: Record<GuideTextKey, string>;
64
+ intro: GuideStep[];
65
+ };
66
+
67
+ /** Who started the setup (the user's browser, `assistant-ui setup`, or that command as the stream) and whether a browser opens. */
68
+ export type GuideContext = {
69
+ started: "user" | "agent" | "agent-stream";
70
+ opened: boolean;
71
+ };
72
+
73
+ const MAX_GUIDE_BYTES = 256 * 1024;
74
+ const TIMEOUT_MS = 5_000;
75
+
76
+ /**
77
+ * `text` without control characters other than tab and line feed (an escape
78
+ * sequence could rewrite the terminal) and bidirectional overrides.
79
+ */
80
+ const sanitizeTerminalText = (text: string) =>
81
+ // oxlint-disable-next-line no-control-regex
82
+ text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/g, "");
83
+
84
+ /** A guide text with its placeholders filled once; a value is never read for placeholders itself. */
85
+ export const renderText = (
86
+ guide: Pick<Guide, "texts">,
87
+ key: GuideTextKey,
88
+ values: Record<string, string | number> = {},
89
+ ) =>
90
+ guide.texts[key].replace(/\{\{([A-Za-z]+)\}\}/g, (_, name: string) =>
91
+ String(values[name] ?? ""),
92
+ );
93
+
94
+ const isTextKey = (value: unknown): value is GuideTextKey =>
95
+ (GUIDE_TEXT_KEYS as readonly unknown[]).includes(value);
96
+
97
+ /** The guide the server sent, or an error saying what this CLI cannot use. */
98
+ export const parseGuide = (value: unknown): Guide => {
99
+ const guide = value as Partial<Record<keyof Guide, unknown>> | null;
100
+ if (guide?.protocol !== GUIDE_PROTOCOL)
101
+ throw new Error(`the guide is not protocol ${GUIDE_PROTOCOL}`);
102
+ const texts = guide.texts as Record<string, unknown> | undefined;
103
+ const parsed = {} as Record<GuideTextKey, string>;
104
+ for (const key of GUIDE_TEXT_KEYS) {
105
+ const text = texts?.[key];
106
+ if (typeof text !== "string") throw new Error(`the guide has no "${key}"`);
107
+ parsed[key] = sanitizeTerminalText(text);
108
+ }
109
+ if (!Array.isArray(guide.intro)) throw new Error("the guide has no intro");
110
+ const intro = guide.intro.map((step: unknown): GuideStep => {
111
+ const { action, text, ...rest } = (step ?? {}) as Record<string, unknown>;
112
+ const allowed =
113
+ (LOCAL_ACTIONS as readonly unknown[]).includes(action) &&
114
+ Object.keys(rest).length === 0 &&
115
+ (action === "print" ? isTextKey(text) : text === undefined);
116
+ if (!allowed)
117
+ throw new Error(
118
+ `the guide asks for an action this CLI does not run: ${sanitizeTerminalText(JSON.stringify(step) ?? "").slice(0, 80)}`,
119
+ );
120
+ return action === "print"
121
+ ? { action, text: text as GuideTextKey }
122
+ : { action: action as "print-status" | "announce-agent" };
123
+ });
124
+ return { protocol: GUIDE_PROTOCOL, texts: parsed, intro };
125
+ };
126
+
127
+ /**
128
+ * The guide for this setup, from `<setupUrl>/guide`. Setup is online-only:
129
+ * without the guide the command fails. Redirects are refused, so the guide
130
+ * only ever comes from the setup URL `parseSetupUrl` accepted.
131
+ */
132
+ export const loadGuide = async (
133
+ setupUrl: string,
134
+ { started, opened }: GuideContext,
135
+ ): Promise<Guide> => {
136
+ const query = new URLSearchParams({
137
+ protocol: String(GUIDE_PROTOCOL),
138
+ started,
139
+ opened: opened ? "1" : "0",
140
+ });
141
+ const offline = `Setup needs internet access to ${setupUrl}`;
142
+ let response: Response;
143
+ try {
144
+ response = await fetch(`${setupUrl.replace(/\/$/, "")}/guide?${query}`, {
145
+ redirect: "error",
146
+ signal: AbortSignal.timeout(TIMEOUT_MS),
147
+ });
148
+ } catch {
149
+ throw new Error(offline);
150
+ }
151
+ if (response.status === 426) {
152
+ const { supported = [] } = (await response.json().catch(() => ({}))) as {
153
+ supported?: unknown[];
154
+ };
155
+ throw new Error(
156
+ supported.some((v) => typeof v === "number" && v > GUIDE_PROTOCOL)
157
+ ? "this setup-agent is older than the setup server supports; run the latest setup-agent"
158
+ : "this setup-agent is newer than the setup server; try again once the server updates",
159
+ );
160
+ }
161
+ if (!response.ok) throw new Error(`${offline} (HTTP ${response.status})`);
162
+ try {
163
+ let body = "";
164
+ let bytes = 0;
165
+ const decoder = new TextDecoder();
166
+ for await (const chunk of response.body ?? []) {
167
+ bytes += chunk.byteLength;
168
+ if (bytes > MAX_GUIDE_BYTES) throw new Error("it is too large");
169
+ body += decoder.decode(chunk, { stream: true });
170
+ }
171
+ return parseGuide(JSON.parse(body + decoder.decode()));
172
+ } catch (error) {
173
+ throw new Error(
174
+ `the setup server sent a guide this setup-agent cannot use (${error instanceof Error ? error.message : String(error)})`,
175
+ );
176
+ }
177
+ };
package/src/host.ts CHANGED
@@ -50,7 +50,7 @@ const useCheckoutHost = (restored: unknown) => {
50
50
  if (state.status === "waiting") {
51
51
  throw reject(
52
52
  "planning-not-started",
53
- 'ask the user to return to their browser and click "Begin plan"; wait with "wait start"',
53
+ 'the setup page starts planning on its own once the user opens it; wait with "wait start"',
54
54
  );
55
55
  }
56
56
  };
package/src/index.ts CHANGED
@@ -28,3 +28,5 @@ export { INPUT_PRESETS, isPresetId, presetInput } from "./presets";
28
28
  export type { PresetId, PresetOverrides } from "./presets";
29
29
  export { connectCheckout } from "./client";
30
30
  export type { CheckoutClient } from "./client";
31
+ export { GUIDE_PROTOCOL, parseGuide } from "./guide";
32
+ export type { Guide, GuideContext, GuideStep, GuideTextKey } from "./guide";
package/src/needs.test.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { testGuide } from "./tests/guide-fixture";
1
2
  import { describe, expect, it } from "vitest";
2
3
  import {
3
4
  answerNeed,
@@ -70,7 +71,7 @@ describe("terminalQuestion", () => {
70
71
  presetInput("framework") as Checkout.Input,
71
72
  presetInput("llm-provider") as Checkout.Input,
72
73
  ),
73
- ).map(terminalQuestion);
74
+ ).map((need) => terminalQuestion(need, testGuide));
74
75
  expect(framework!.options?.map((option) => option.id)).toEqual([
75
76
  "ai-sdk:typescript",
76
77
  "mastra:typescript",
@@ -80,10 +81,8 @@ describe("terminalQuestion", () => {
80
81
  expect(formatTerminalQuestion(framework!)).toMatch(
81
82
  /^Which agent framework runs your backend\? Options: ai-sdk:typescript \(Vercel AI SDK \(TypeScript\): Shortest path/,
82
83
  );
83
- expect(model!.question).toContain("The API key is not typed here");
84
- expect(model!.answerFormat).toBe(
85
- "<provider>:<model>, such as openai:gpt-5",
86
- );
84
+ expect(model!.question).toContain("[need.input.model]");
85
+ expect(model!.answerFormat).toBe("[need.format.model]");
87
86
  });
88
87
  });
89
88
 
package/src/needs.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  parseMultipleAnswer,
12
12
  } from "./protocol";
13
13
  import type { Checkout } from "./protocol";
14
+ import { renderText } from "./guide";
15
+ import type { Guide, GuideTextKey } from "./guide";
14
16
 
15
17
  /**
16
18
  * Something the setup waits on the user for: an open question, a proposed
@@ -86,49 +88,62 @@ const optionList = (input: Checkout.Input): NeedOption[] =>
86
88
  : [entry(option.id, option.label)];
87
89
  });
88
90
 
89
- const ANSWER_FORMATS: Record<Checkout.InputKind, string> = {
90
- text: "the user's text, as one shell word",
91
- product: "add or decline",
92
- model: "<provider>:<model>, such as openai:gpt-5",
93
- "entry-point": "one option id",
94
- choice: "one option id, or the user's own text",
95
- };
91
+ const ANSWER_FORMATS = {
92
+ text: "need.format.text",
93
+ product: "need.format.product",
94
+ model: "need.format.model",
95
+ "entry-point": "need.format.entry-point",
96
+ choice: "need.format.choice",
97
+ } as const satisfies Record<Checkout.InputKind, GuideTextKey>;
96
98
 
97
- export const terminalQuestion = (need: SetupNeed): TerminalQuestion => {
99
+ /** A need as a question for the agent's own conversation, worded by the setup server's `guide`. */
100
+ export const terminalQuestion = (
101
+ need: SetupNeed,
102
+ guide: Pick<Guide, "texts">,
103
+ ): TerminalQuestion => {
104
+ const text = (key: GuideTextKey, values?: Record<string, string>) =>
105
+ renderText(guide, key, values);
98
106
  if (need.kind === "plan")
99
107
  return {
100
- question:
101
- "Approve the setup plan? Show the user the plan you proposed and ask whether to go ahead or what to change.",
108
+ question: text("need.plan.question"),
102
109
  options: [
103
- { id: "approve", label: "Approve the plan" },
104
- { id: "revise", label: "Ask for changes (pass --feedback)" },
110
+ { id: "approve", label: text("need.plan.approve") },
111
+ { id: "revise", label: text("need.plan.revise") },
105
112
  ],
106
- answerFormat: 'approve, or revise --feedback "<what to change>"',
113
+ answerFormat: text("need.plan.format"),
107
114
  };
108
115
  if (need.kind === "finish")
109
116
  return {
110
- question: `Everything is installed${need.completion.preview ? ` and running at ${need.completion.preview}` : ""}. Close the setup, or keep going?`,
111
- options: [{ id: "close", label: "Close the setup" }],
112
- answerFormat:
113
- "close; if the user wants more, do it and run done again instead",
117
+ question: need.completion.preview
118
+ ? text("need.finish.question-preview", {
119
+ preview: need.completion.preview,
120
+ })
121
+ : text("need.finish.question"),
122
+ options: [{ id: "close", label: text("need.finish.close") }],
123
+ answerFormat: text("need.finish.format"),
114
124
  };
115
125
  const { input } = need;
116
126
  const options =
117
127
  input.kind === "product"
118
128
  ? [
119
- { id: "add", label: `Add ${input.product ?? "the product"}` },
120
- { id: "decline", label: "Leave it out" },
129
+ {
130
+ id: "add",
131
+ label: text("need.product.add", {
132
+ product: input.product ?? "the product",
133
+ }),
134
+ },
135
+ { id: "decline", label: text("need.product.decline") },
121
136
  ]
122
137
  : input.kind === "text"
123
138
  ? undefined
124
139
  : optionList(input);
125
140
  return {
126
- question: `${input.prompt.trim() || "Your agent needs an answer."}${input.optional ? " (optional; --dismiss skips it)" : ""}${input.kind === "model" ? " The API key is not typed here: the user adds it to the project's env file themselves." : ""}`,
141
+ question: `${input.prompt.trim() || text("need.input.empty-prompt")}${input.optional ? text("need.input.optional") : ""}${input.kind === "model" ? text("need.input.model") : ""}`,
127
142
  ...(options && { options }),
128
143
  ...(input.multiple && { multiple: true as const }),
129
- answerFormat: input.multiple
130
- ? "option ids separated by commas, or the user's own text"
131
- : ANSWER_FORMATS[input.kind],
144
+ answerFormat: text(
145
+ input.multiple ? "need.format.multiple" : ANSWER_FORMATS[input.kind],
146
+ ),
132
147
  };
133
148
  };
134
149
 
package/src/sdk.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import * as sdk from "./sdk";
3
- import { initialCheckoutState } from "./protocol";
3
+ import { OPTION_ICONS, initialCheckoutState } from "./protocol";
4
4
  import type { Checkout } from "./protocol";
5
5
  import type { CheckoutClient } from "./client";
6
6
 
@@ -62,18 +62,14 @@ describe("setup-agent/sdk", () => {
62
62
  agent: { cwd: "/app" },
63
63
  },
64
64
  });
65
- expect(out).toMatch(/^my-tool setup is running\./);
66
- expect(out).toContain(
67
- `npx my-tool setup https://checkout.test/${session.linkId} stream`,
65
+ // The briefing is the server's guide, with this command's values filled in.
66
+ const url = `https://checkout.test/${session.linkId}`;
67
+ expect(out).toBe(
68
+ `[stream.briefing] my-tool setup npx my-tool setup ${url} ${session.browserUrl}[briefing] npx my-tool setup ${url} ${OPTION_ICONS.join(", ")}\n[stream.briefing-footer] ${session.browserUrl}${JSON.stringify({ status: "planning", products: [{ slug: "assistant-ui", name: "assistant-ui" }] })}\n`,
69
+ );
70
+ expect(String(vi.mocked(fetch).mock.calls[0]?.[0])).toBe(
71
+ `${url}/guide?protocol=1&started=agent-stream&opened=1`,
68
72
  );
69
- expect(out).toContain("run_in_background");
70
- expect(out).toContain('"input.needed"');
71
- expect(out).toContain('"browser.opened"');
72
- expect(out).not.toContain("start a persistent Monitor");
73
- expect(out).toContain("opens the user's browser by itself");
74
- expect(out).not.toContain("npx setup-agent");
75
- expect(out).not.toContain("click Begin plan");
76
- expect(out).toContain(session.browserUrl);
77
73
  expect(open).not.toHaveBeenCalled();
78
74
  expect(calls.map(([name]) => name)).toEqual([
79
75
  "checkout/create",
package/src/sdk.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  */
6
6
  import { randomInt } from "node:crypto";
7
7
  import { detectAgentKind } from "./agents";
8
- import { agentInstructions } from "./cli";
8
+ import { OPTION_ICONS } from "./protocol";
9
+ import { loadGuide, renderText } from "./guide";
10
+ import type { Guide } from "./guide";
9
11
  import { connectCheckout } from "./client";
10
12
  import type { CheckoutClient } from "./client";
11
13
  import type { Checkout } from "./protocol";
@@ -57,37 +59,19 @@ export type RunAgentSetupOptions = {
57
59
  /** What the agent reads first in the output of a setup command that keeps running. */
58
60
  const setupBriefing = (
59
61
  session: SetupSession,
60
- {
61
- command,
62
- name = "Setup",
63
- canOpen = true,
64
- }: Pick<RunAgentSetupOptions, "command" | "name" | "canOpen">,
62
+ guide: Guide,
63
+ { command, name = "Setup" }: Pick<RunAgentSetupOptions, "command" | "name">,
65
64
  ) => {
66
- const url = shellWord(session.agentUrl);
67
- return `${name} is running. This command keeps running: it is the setup's stream.
68
-
69
- 1. Keep it running in the background until the setup ends. If you started it in the foreground, stop it and resume it in the background (Claude Code: Bash with run_in_background; Codex and other agents: add > "\${TMPDIR:-/tmp}/setup-${session.linkId}.log" 2>&1 & and read that file):
70
- ${command} ${url} stream${canOpen ? "" : " --no-open"}
71
- 2. Read its new output as it arrives. Each line after this briefing is a JSON array of events.
72
- 3. Run every other command in another shell: ${command} ${url} <command>.
73
- 4. ${
74
- canOpen
75
- ? "Setup opens the user's browser by itself when you ask the user something; do not open it yourself."
76
- : `Setup will not open a browser (--no-open). Give the user ${session.browserUrl} once, or ask in your own conversation.`
77
- } On an "input.needed" event, do what its message says; to answer from here, run its "answer" command with the user's reply.
78
-
79
- ${agentInstructions(session.agentUrl, {
80
- command,
81
- startedByAgent: {
65
+ const values = {
66
+ name,
67
+ cmd: command,
68
+ url: shellWord(session.agentUrl),
69
+ linkId: session.linkId,
82
70
  browserUrl: session.browserUrl,
83
- streaming: true,
84
- opened: canOpen,
85
- },
86
- })}
87
- Setup page, if the user asks for it: ${session.browserUrl}
88
-
89
- Current setup:
90
- ${JSON.stringify({ status: "planning", products: session.products })}
71
+ icons: OPTION_ICONS.join(", "),
72
+ };
73
+ return `${renderText(guide, "stream.briefing", values)}${renderText(guide, "briefing", values)}
74
+ ${renderText(guide, "stream.briefing-footer", values)}${JSON.stringify({ status: "planning", products: session.products })}
91
75
  `;
92
76
  };
93
77
 
@@ -103,6 +87,11 @@ export const runAgentSetup = async (options: RunAgentSetupOptions) => {
103
87
  const linkId = createSetupId();
104
88
  const setupId = createSetupId();
105
89
  const agentUrl = `${options.server.replace(/\/$/, "")}/${linkId}`;
90
+ // Online-only: the briefing and the stream's wording come from the setup server.
91
+ const guide = await loadGuide(agentUrl, {
92
+ started: "agent-stream",
93
+ opened: options.canOpen !== false,
94
+ });
106
95
  const client = await connect(agentUrl);
107
96
  try {
108
97
  const kind = detectAgentKind(options.env);
@@ -124,9 +113,10 @@ export const runAgentSetup = async (options: RunAgentSetupOptions) => {
124
113
  };
125
114
  options.onSession?.(session);
126
115
  (options.write ?? ((text) => process.stdout.write(text)))(
127
- setupBriefing(session, options),
116
+ setupBriefing(session, guide, options),
128
117
  );
129
118
  const stream = await followSetup(client, {
119
+ guide,
130
120
  ...options.stream,
131
121
  agent: { ...(kind !== undefined && { kind }), ...options.stream?.agent },
132
122
  browser: {
@@ -1,3 +1,4 @@
1
+ import { testGuide } from "./tests/guide-fixture";
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3
  import { answerNeed } from "./needs";
3
4
  import { initialCheckoutState } from "./protocol";
@@ -7,6 +8,7 @@ import type { StreamEvent } from "./stream";
7
8
 
8
9
  const PAGE = "https://setup.test/#link=abc&setup=s1";
9
10
  const ANSWER = "npx assistant-ui@latest setup https://checkout.test/abc answer";
11
+ const WAIT = "npx assistant-ui@latest setup https://checkout.test/abc wait";
10
12
 
11
13
  /** A client whose state the test changes, notifying like a statewire client. */
12
14
  const fakeClient = () => {
@@ -91,6 +93,7 @@ describe("followSetup", () => {
91
93
  const lines: StreamEvent[][] = [];
92
94
  const open = vi.fn();
93
95
  const stream = await followSetup(fake.client, {
96
+ guide: testGuide,
94
97
  print: (events) => lines.push(events),
95
98
  agent: { cwd: "/app", kind: "claude" },
96
99
  browser: {
@@ -129,8 +132,7 @@ describe("followSetup", () => {
129
132
  event: "browser.opened",
130
133
  id: "q1",
131
134
  url: PAGE,
132
- message:
133
- 'Opened the setup in the user\'s browser for "Which package manager?". Tell the user once that it is waiting there, and give them https://setup.test/#link=abc&setup=s1 in case it did not open. The answer arrives on this stream.',
135
+ message: `[stream.opened.input] Which package manager? ${PAGE} with "${WAIT} q1"`,
134
136
  },
135
137
  ]);
136
138
 
@@ -160,15 +162,14 @@ describe("followSetup", () => {
160
162
  event: "input.needed",
161
163
  id: "q3",
162
164
  reason: "recently-opened",
163
- message:
164
- "A browser tab for setup was opened 10s ago. Wait for the user there, or ask the user directly in the terminal: Which registry? Options: pnpm (pnpm), npm (npm).",
165
+ message: `[stream.ask.recently-opened] 10 with "${WAIT} q3" Which registry? Options: pnpm (pnpm), npm (npm).`,
165
166
  question: "Which registry?",
166
167
  options: [
167
168
  { id: "pnpm", label: "pnpm" },
168
169
  { id: "npm", label: "npm" },
169
170
  ],
170
- answerFormat: "one option id, or the user's own text",
171
- answer: `${ANSWER} q3 <one option id, or the user's own text>`,
171
+ answerFormat: "[need.format.choice]",
172
+ answer: `${ANSWER} q3 <[need.format.choice]>`,
172
173
  });
173
174
 
174
175
  // The agent asked in its own conversation and answers for the user.
@@ -196,7 +197,7 @@ describe("followSetup", () => {
196
197
  id: "q1",
197
198
  reason: "browser-closed",
198
199
  message:
199
- "The user closed the setup tab. Ask the user directly in the terminal: Which package manager? Options: pnpm (pnpm), npm (npm).",
200
+ "[stream.ask.browser-closed] Which package manager? Options: pnpm (pnpm), npm (npm).",
200
201
  });
201
202
  // A question right after: inside the 30s window, and the tab is known to be gone.
202
203
  t.ask("Which registry?");
@@ -212,6 +213,7 @@ describe("followSetup", () => {
212
213
  const fake = fakeClient();
213
214
  const lines: StreamEvent[][] = [];
214
215
  const stream = await followSetup(fake.client, {
216
+ guide: testGuide,
215
217
  print: (events) => lines.push(events),
216
218
  agent: { cwd: "/app" },
217
219
  });
package/src/stream.ts CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  parseMultipleAnswer,
12
12
  } from "./protocol";
13
13
  import type { Checkout } from "./protocol";
14
+ import { renderText } from "./guide";
15
+ import type { Guide } from "./guide";
14
16
 
15
17
  export type StreamEvent =
16
18
  | { event: "planning.started" }
@@ -47,14 +49,14 @@ export type StreamEvent =
47
49
  answer: string;
48
50
  } & TerminalQuestion);
49
51
 
50
- const STAY_CONNECTED =
51
- "This setup is over, not your connection. Keep the stream running, tell the user you are connected and waiting for their next setup, and run wait start.";
52
-
52
+ /** The events between two states, with hints worded by the setup server's `guide`. */
53
53
  export const diffEvents = (
54
54
  before: Checkout.State,
55
55
  after: Checkout.State,
56
+ guide: Pick<Guide, "texts">,
56
57
  ): StreamEvent[] => {
57
58
  const events: StreamEvent[] = [];
59
+ const stayConnected = renderText(guide, "stream.stay-connected");
58
60
  if (before.status === "waiting" && after.status === "planning") {
59
61
  events.push({ event: "planning.started" });
60
62
  }
@@ -110,10 +112,10 @@ export const diffEvents = (
110
112
  }
111
113
  }
112
114
  if (before.status !== "done" && after.status === "done") {
113
- events.push({ event: "finished", next: STAY_CONNECTED });
115
+ events.push({ event: "finished", next: stayConnected });
114
116
  }
115
117
  if (before.status !== "cancelled" && after.status === "cancelled") {
116
- events.push({ event: "cancelled", next: STAY_CONNECTED });
118
+ events.push({ event: "cancelled", next: stayConnected });
117
119
  }
118
120
  if (after.id !== null && after.id !== before.id) {
119
121
  events.push({
@@ -165,40 +167,57 @@ export const shellWord = (value: string) =>
165
167
  ? value
166
168
  : `'${value.replaceAll("'", `'\\''`)}'`;
167
169
 
168
- const ASK_MESSAGES: Record<
169
- AskReason,
170
- (question: string, context: { agoSeconds: number; page: string }) => string
171
- > = {
172
- "recently-opened": (question, { agoSeconds }) =>
173
- `A browser tab for setup was opened ${agoSeconds}s ago. Wait for the user there, or ask the user directly in the terminal: ${question}`,
174
- "browser-closed": (question) =>
175
- `The user closed the setup tab. Ask the user directly in the terminal: ${question}`,
176
- "no-browser": (question, { page }) =>
177
- `No browser tab is open for this setup. Ask the user directly in the terminal, or give them ${page}: ${question}`,
178
- };
170
+ const ASK_TEXTS = {
171
+ "recently-opened": "stream.ask.recently-opened",
172
+ "browser-closed": "stream.ask.browser-closed",
173
+ "no-browser": "stream.ask.no-browser",
174
+ } as const satisfies Record<AskReason, string>;
175
+
176
+ /** The command that blocks on a need, beside the one that answers it: `<cmd> <url> wait`. */
177
+ const waitCommandFor = (answerCommand: string) =>
178
+ answerCommand.endsWith(" answer")
179
+ ? `${answerCommand.slice(0, -" answer".length)} wait`
180
+ : undefined;
179
181
 
180
182
  /** What the agent reads when setup opened the browser, or when it should ask the user itself. */
181
183
  const gateEvent = (
182
184
  action: BrowserGateAction,
183
185
  answerCommand: string,
184
186
  page: string,
187
+ waitCommand: string | undefined,
188
+ guide: Pick<Guide, "texts">,
185
189
  ): StreamEvent => {
186
190
  const { need } = action;
191
+ // Closing the setup is the user's call after "done"; there is nothing to block on.
192
+ const wait =
193
+ waitCommand !== undefined && need.kind !== "finish"
194
+ ? ` with "${waitCommand} ${need.id}"`
195
+ : "";
187
196
  if (action.type === "open")
188
197
  return {
189
198
  event: "browser.opened",
190
199
  id: need.id,
191
200
  url: action.url,
192
- message: `Opened the setup in the user's browser for ${need.kind === "input" ? `"${need.input.prompt.trim() || "Your agent needs an answer."}"` : need.kind === "plan" ? "the plan review" : "closing the setup"}. Tell the user once that it is waiting there, and give them ${action.url} in case it did not open. The answer arrives on this stream.`,
201
+ message: renderText(guide, `stream.opened.${need.kind}`, {
202
+ url: action.url,
203
+ wait: wait || ' with "wait"',
204
+ prompt:
205
+ need.kind === "input"
206
+ ? need.input.prompt.trim() ||
207
+ renderText(guide, "need.input.empty-prompt")
208
+ : "",
209
+ }),
193
210
  };
194
- const question = terminalQuestion(need);
211
+ const question = terminalQuestion(need, guide);
195
212
  return {
196
213
  event: "input.needed",
197
214
  id: need.id,
198
215
  reason: action.reason,
199
- message: ASK_MESSAGES[action.reason](formatTerminalQuestion(question), {
216
+ message: renderText(guide, ASK_TEXTS[action.reason], {
217
+ question: formatTerminalQuestion(question),
200
218
  agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1000),
201
219
  page,
220
+ wait,
202
221
  }),
203
222
  ...question,
204
223
  answer: `${answerCommand} ${need.id} <${question.answerFormat}>`,
@@ -206,6 +225,8 @@ const gateEvent = (
206
225
  };
207
226
 
208
227
  export type FollowSetupOptions = {
228
+ /** The setup server's wording for the stream's messages. */
229
+ guide: Pick<Guide, "texts">;
209
230
  /** Writes one line of events; the default prints a JSON array to stdout. */
210
231
  print?: (events: StreamEvent[]) => void;
211
232
  /** The agent the stream reports in its hello; detected by default. */
@@ -231,7 +252,7 @@ export type FollowSetupOptions = {
231
252
  */
232
253
  export const followSetup = async (
233
254
  client: Pick<CheckoutClient, "state" | "subscribe" | "commands">,
234
- { print, agent, browser }: FollowSetupOptions = {},
255
+ { print, agent, browser, guide }: FollowSetupOptions,
235
256
  ) => {
236
257
  const write =
237
258
  print ??
@@ -251,7 +272,7 @@ export const followSetup = async (
251
272
  });
252
273
  let previous = client.state;
253
274
  if (previous === undefined) throw new Error("the setup has no state");
254
- let pending = diffEvents({ ...previous, log: [] }, previous);
275
+ let pending = diffEvents({ ...previous, log: [] }, previous, guide);
255
276
  let timer: ReturnType<typeof setTimeout> | undefined;
256
277
  const flush = () => {
257
278
  clearTimeout(timer);
@@ -263,14 +284,20 @@ export const followSetup = async (
263
284
  for (const action of gate?.update(state) ?? []) {
264
285
  if (action.type === "open") (browser!.open ?? openUrl)(action.url);
265
286
  pending.push(
266
- gateEvent(action, browser!.answerCommand, page(state) ?? ""),
287
+ gateEvent(
288
+ action,
289
+ browser!.answerCommand,
290
+ page(state) ?? "",
291
+ waitCommandFor(browser!.answerCommand),
292
+ guide,
293
+ ),
267
294
  );
268
295
  }
269
296
  };
270
297
  const unsubscribe = client.subscribe(() => {
271
298
  const next = client.state;
272
299
  if (next === undefined) return;
273
- pending.push(...diffEvents(previous!, next));
300
+ pending.push(...diffEvents(previous!, next, guide));
274
301
  previous = next;
275
302
  runGate(next);
276
303
  if (pending.length > 0 && timer === undefined)