setup-agent 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -41,6 +41,15 @@ export { diffEvents } from "./stream";
41
41
 
42
42
  const DEFAULT_COMMAND = "npx setup-agent";
43
43
 
44
+ /**
45
+ * How long "wait <input-id>", "wait plan" and "--wait" block before handing
46
+ * control back with a "waiting" result, in seconds. Agent shells kill a
47
+ * command after a limit of their own (two minutes by default in Claude
48
+ * Code); a wait that returns first lets the agent run it again instead of
49
+ * reading the kill as the end of the setup.
50
+ */
51
+ const DEFAULT_WAIT_SECONDS = 100;
52
+
44
53
  export const HELP = `setup-agent <url> [command]
45
54
 
46
55
  Commands (the url always comes first):
@@ -65,10 +74,13 @@ Commands (the url always comes first):
65
74
  ask <prompt> --entry-points '<JSON array>' [--default <id>] [--optional] [--step <step-id>] [--wait]
66
75
  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.
67
76
  wait start Block until the user clicks Begin plan in their browser; after a finished or cancelled setup, until the next one begins.
68
- wait <input-id> Block until the user answers or dismisses an input. A multi-select answer prints as an array.
69
- plan (<markdown> | --file <path>) [--wait]
77
+ wait <input-id> [--timeout <seconds>]
78
+ Block until the user answers or dismisses an input. A multi-select answer prints as an array.
79
+ plan (<markdown> | --file <path>) [--wait] [--timeout <seconds>]
70
80
  Propose the plan for the user to approve. --wait blocks until they decide.
71
- wait plan Block until the user approves the plan or asks for changes.
81
+ wait plan [--timeout <seconds>] Block until the user approves the plan or asks for changes.
82
+ wait <input-id>, wait plan and every --wait give up after ${DEFAULT_WAIT_SECONDS}s (--timeout, 0 for no limit) and print
83
+ {"ok":false,"status":"waiting","next":"<command>"}: run "next" again to keep waiting.
72
84
  env <input-id> <ENV_VAR> [--file <path>]
73
85
  Write the API key behind an answered llm-provider input to an env file (default .env.local) as ENV_VAR=<key>, without printing it.
74
86
  add-step <title> [--detail <text>] [--product <slug>] [--active]
@@ -166,9 +178,9 @@ const streamSection = (
166
178
  noOpen: boolean,
167
179
  ) =>
168
180
  streaming
169
- ? `The command that printed this is the stream. Keep it running in the background until the setup ends: the browser shows you as connected only while it runs, and it is how answers reach you. Each new line of its output is a JSON array of events: ${EVENT_LIST}, plus:
170
- - "browser.opened" ({id, message}): setup opened the setup page in the user's browser for a question. Tell the user once, in your own conversation, that it waits there.
171
- - "input.needed" ({id, message, question, options?, answer}): the user may not see the browser right now. Do what its message says: wait for "input.answered", or ask the user in your own conversation with that question and options, then run its "answer" command with their reply. Answered either way, the setup goes on.
181
+ ? `The command that printed this is the stream. Keep it running in the background until the setup ends: the browser shows you as connected only while it runs. New output does not wake you: read its new lines between your other commands. Each new line is a JSON array of events: ${EVENT_LIST}, plus:
182
+ - "browser.opened" ({id, message}): setup opened the setup page in the user's browser for a question. Tell the user once, in your own conversation, that it waits there, and keep working in the same turn.
183
+ - "input.needed" ({id, message, question, options?, answer}): the user may not see the browser right now. Do what its message says: run "wait <id>" for their answer in the browser, or ask the user in your own conversation with that question and options, then run its "answer" command with their reply. Answered either way, the setup goes on.
172
184
  If this command stops, start it again in the background with
173
185
  ${cmd} ${url} stream${noOpen ? " --no-open" : ""}
174
186
  The URL is the user's link to you from their browser, not one setup: it outlives this one, and their next setup arrives on the same stream.`
@@ -176,6 +188,12 @@ The URL is the user's link to you from their browser, not one setup: it outlives
176
188
  ${cmd} ${url} stream
177
189
  and keep it running: the browser shows you as connected only while it runs, and it is how answers reach you. The URL is the user's link to you from their browser, not one setup: it outlives this one, and their next setup arrives on the same stream. Each line is a JSON array of events: ${EVENT_LIST}.`;
178
190
 
191
+ /** Why the agent must not end its turn mid-setup, and how it waits instead. */
192
+ const keepGoing = (cmd: string, url: string) =>
193
+ `Run the setup to the end in one go. Do not count on the stream or the browser to wake you: if you end your turn before the install is done, the setup can stay paused until the user types to you, even after they answer in the browser. So never end your turn to wait for an answer, and do not stop after telling the user something. To get an answer, block on it:
194
+ ${cmd} ${url} wait <input-id>
195
+ ("plan --wait" and "ask --wait" block the same way). A wait that prints {"ok":false,"status":"waiting"} has not failed and the setup is not stuck: run its "next" command, right away. "next" only waits; never repeat an "ask" or "plan" to keep waiting, since that asks or submits again. End your turn only to ask the user in your own conversation (an "input.needed" you relay, or a value only they can give), and go on as soon as they reply; or once the install is done and proposed with "done".`;
196
+
179
197
  export const agentInstructions = (
180
198
  setupUrl: string,
181
199
  options: AgentInstructionsOptions = {},
@@ -188,6 +206,8 @@ export const agentInstructions = (
188
206
 
189
207
  ${streamSection(cmd, url, streaming, started?.opened === false)}
190
208
 
209
+ ${keepGoing(cmd, url)}
210
+
191
211
  The user can message you at any time, including while a question is waiting. Read "message.created" events, acknowledge each with "${cmd} ${url} ack <message-id>", and reply using "log" with what you will do. Pending messages are replayed when the stream starts and listed in "status". A message is not an answer or plan approval; keep those decisions explicit.
192
212
 
193
213
  "log" is a message to the user, and the user reads every one: send it only to reply to their message, to tell them something no question or step conveys (a blocker, a deviation from the plan, a decision you made for them), or once as a wrap-up when the install is finished. Findings go in the plan, progress in step notes, questions in "ask"; do not narrate.
@@ -205,10 +225,10 @@ The setup's products are starting names and guide references. If the setup lists
205
225
  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:
206
226
  ${cmd} ${url} ask "Where should users access the assistant?" --entry-points '<JSON array>' --wait
207
227
  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.
208
- 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.
228
+ 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 then run "wait <input-id>" for each open question before you write the plan; do not end your turn while they are open. 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.
209
229
  Then write the plan as markdown and submit it:
210
230
  ${cmd} ${url} plan --file <path> --wait
211
- Use these headings, each with bullets: "## What I found" (facts as \`- **Label:** value\`: app framework, package manager, agent framework, model provider and model, where components live), "## What I will install" (packages and files, one per bullet), "## Steps" (a numbered list, one line each), and "## Open questions" only if any remain. Keep it under 30 lines; no preamble. "plan --wait" prints {"decision":"approve"} or {"decision":"revise","feedback":"..."}. On revise, address the feedback (ask again if you need to) and submit a new plan; repeat until approved. Do not change the project before approval: the setup refuses steps until then.
231
+ Use these headings, each with bullets: "## What I found" (facts as \`- **Label:** value\`: app framework, package manager, agent framework, model provider and model, where components live), "## What I will install" (packages and files, one per bullet), "## Steps" (a numbered list, one line each), and "## Open questions" only if any remain. Keep it under 30 lines; no preamble. "plan --wait" prints {"decision":"approve"} or {"decision":"revise","feedback":"..."}; when it prints {"ok":false,"status":"waiting"}, the plan is submitted and still with the user, so keep waiting with "wait plan" and do not submit it again. On revise, address the feedback (ask again if you need to) and submit a new plan; repeat until approved. Do not change the project before approval: the setup refuses steps until then.
212
232
 
213
233
  2. Install. Declare the steps from your plan up front, in order:
214
234
  ${cmd} ${url} add-step "<title>" --detail "<one line>"
@@ -416,6 +436,47 @@ const waitForPlan = (client: CheckoutClient, revision: number) =>
416
436
  check();
417
437
  });
418
438
 
439
+ /** The seconds `--timeout` asks a wait to block for; 0 is no limit. */
440
+ const waitSeconds = (flags: Parsed["flags"], fallback: number) => {
441
+ const value = flagText(flags, "timeout");
442
+ if (value === undefined) return fallback;
443
+ const seconds = Number(value);
444
+ if (!/^\d+(?:\.\d+)?$/.test(value) || !Number.isFinite(seconds)) {
445
+ return fail("--timeout takes a number of seconds, 0 for no limit");
446
+ }
447
+ return seconds;
448
+ };
449
+
450
+ const TIMED_OUT = Symbol("timed out");
451
+
452
+ /** `promise`, or `TIMED_OUT` once `seconds` pass first; 0 waits as long as it takes. */
453
+ const within = async <T>(
454
+ promise: Promise<T>,
455
+ seconds: number,
456
+ ): Promise<T | typeof TIMED_OUT> => {
457
+ if (seconds === 0) return promise;
458
+ let timer: ReturnType<typeof setTimeout> | undefined;
459
+ try {
460
+ return await Promise.race([
461
+ promise,
462
+ new Promise<typeof TIMED_OUT>((resolve) => {
463
+ timer = setTimeout(() => resolve(TIMED_OUT), seconds * 1000);
464
+ }),
465
+ ]);
466
+ } finally {
467
+ clearTimeout(timer);
468
+ }
469
+ };
470
+
471
+ /** What a wait prints when the user has not decided yet: the command to run again, now. */
472
+ const stillWaiting = (target: string, next: string) => ({
473
+ ok: false,
474
+ status: "waiting",
475
+ waitingFor: target,
476
+ next,
477
+ message: `The user has not answered yet. Run "next" now to keep waiting (it only waits; do not repeat the command you ran, which would ask or submit again), or go on with other work and run it when you need the answer. Do not end your turn: nothing resumes you until you run it.`,
478
+ });
479
+
419
480
  const decided = (plan: Checkout.Plan | undefined, state: Checkout.State) => {
420
481
  if (state.status === "cancelled") return { ok: false, status: "cancelled" };
421
482
  if (plan === undefined) return fail("the plan disappeared");
@@ -840,6 +901,8 @@ export const runSetupCommand = async (
840
901
  fail(error instanceof Error ? error.message : String(error)),
841
902
  );
842
903
  const commands = client.commands;
904
+ const waitCommand = (target: string) =>
905
+ `${options.command ?? DEFAULT_COMMAND} ${shellWord(url)} wait ${target}`;
843
906
 
844
907
  try {
845
908
  switch (command) {
@@ -921,8 +984,15 @@ export const runSetupCommand = async (
921
984
  print({ ok: true, revision });
922
985
  break;
923
986
  }
924
- const plan = await waitForPlan(client, revision);
925
- print(decided(plan, requireState(client)));
987
+ const plan = await within(
988
+ waitForPlan(client, revision),
989
+ waitSeconds(flags, DEFAULT_WAIT_SECONDS),
990
+ );
991
+ print(
992
+ plan === TIMED_OUT
993
+ ? stillWaiting("plan", waitCommand("plan"))
994
+ : decided(plan, requireState(client)),
995
+ );
926
996
  break;
927
997
  }
928
998
  case "ask": {
@@ -956,7 +1026,15 @@ export const runSetupCommand = async (
956
1026
  print({ ok: true, inputId });
957
1027
  break;
958
1028
  }
959
- print(await waitForInput(client, inputId).then(answered));
1029
+ const input = await within(
1030
+ waitForInput(client, inputId),
1031
+ waitSeconds(flags, DEFAULT_WAIT_SECONDS),
1032
+ );
1033
+ print(
1034
+ input === TIMED_OUT
1035
+ ? stillWaiting(inputId, waitCommand(inputId))
1036
+ : answered(input),
1037
+ );
960
1038
  break;
961
1039
  }
962
1040
  case "wait": {
@@ -964,7 +1042,15 @@ export const runSetupCommand = async (
964
1042
  rest[0] ?? fail("usage: wait start | wait <input-id> | wait plan");
965
1043
  const state = requireState(client);
966
1044
  if (target === "start") {
967
- const status = await waitForStart(client);
1045
+ // Waiting for the user's next setup is idle time, so it has no limit by default.
1046
+ const status = await within(
1047
+ waitForStart(client),
1048
+ waitSeconds(flags, 0),
1049
+ );
1050
+ if (status === TIMED_OUT) {
1051
+ print(stillWaiting("start", waitCommand("start")));
1052
+ break;
1053
+ }
968
1054
  print({
969
1055
  ok: status === "planning" || status === "installing",
970
1056
  status,
@@ -973,18 +1059,29 @@ export const runSetupCommand = async (
973
1059
  }
974
1060
  if (target === "plan") {
975
1061
  const plan = currentPlan(state) ?? fail("no plan has been proposed");
1062
+ const decision = await within(
1063
+ waitForPlan(client, plan.revision),
1064
+ waitSeconds(flags, DEFAULT_WAIT_SECONDS),
1065
+ );
976
1066
  print(
977
- decided(
978
- await waitForPlan(client, plan.revision),
979
- requireState(client),
980
- ),
1067
+ decision === TIMED_OUT
1068
+ ? stillWaiting("plan", waitCommand("plan"))
1069
+ : decided(decision, requireState(client)),
981
1070
  );
982
1071
  break;
983
1072
  }
984
1073
  if (!state.inputs.some((input) => input.id === target)) {
985
1074
  fail(`no input "${target}"`);
986
1075
  }
987
- print(await waitForInput(client, target).then(answered));
1076
+ const input = await within(
1077
+ waitForInput(client, target),
1078
+ waitSeconds(flags, DEFAULT_WAIT_SECONDS),
1079
+ );
1080
+ print(
1081
+ input === TIMED_OUT
1082
+ ? stillWaiting(target, waitCommand(target))
1083
+ : answered(input),
1084
+ );
988
1085
  break;
989
1086
  }
990
1087
  case "env": {
package/src/sdk.test.ts CHANGED
@@ -67,6 +67,8 @@ describe("setup-agent/sdk", () => {
67
67
  `npx my-tool setup https://checkout.test/${session.linkId} stream`,
68
68
  );
69
69
  expect(out).toContain("run_in_background");
70
+ expect(out).toContain("never end your turn to wait for it");
71
+ expect(out).not.toContain("Read its new output as it arrives");
70
72
  expect(out).toContain('"input.needed"');
71
73
  expect(out).toContain('"browser.opened"');
72
74
  expect(out).not.toContain("start a persistent Monitor");
package/src/sdk.ts CHANGED
@@ -68,7 +68,7 @@ const setupBriefing = (
68
68
 
69
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
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.
71
+ 2. Its new output does not wake you, so never end your turn to wait for it. Read the new lines between your other commands; each line after this briefing is a JSON array of events. Answers reach you through the blocking "wait" commands below.
72
72
  3. Run every other command in another shell: ${command} ${url} <command>.
73
73
  4. ${
74
74
  canOpen
@@ -7,6 +7,7 @@ import type { StreamEvent } from "./stream";
7
7
 
8
8
  const PAGE = "https://setup.test/#link=abc&setup=s1";
9
9
  const ANSWER = "npx assistant-ui@latest setup https://checkout.test/abc answer";
10
+ const WAIT = "npx assistant-ui@latest setup https://checkout.test/abc wait";
10
11
 
11
12
  /** A client whose state the test changes, notifying like a statewire client. */
12
13
  const fakeClient = () => {
@@ -129,8 +130,7 @@ describe("followSetup", () => {
129
130
  event: "browser.opened",
130
131
  id: "q1",
131
132
  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.',
133
+ message: `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. Then keep working in the same turn; when you need the answer, block on it with "${WAIT} q1". Do not end your turn to wait: nothing on this stream wakes you.`,
134
134
  },
135
135
  ]);
136
136
 
@@ -160,8 +160,7 @@ describe("followSetup", () => {
160
160
  event: "input.needed",
161
161
  id: "q3",
162
162
  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).",
163
+ message: `A browser tab for setup was opened 10s ago. Wait for the user there with "${WAIT} q3", or ask the user directly in the terminal: Which registry? Options: pnpm (pnpm), npm (npm).`,
165
164
  question: "Which registry?",
166
165
  options: [
167
166
  { id: "pnpm", label: "pnpm" },
package/src/stream.ts CHANGED
@@ -167,29 +167,44 @@ export const shellWord = (value: string) =>
167
167
 
168
168
  const ASK_MESSAGES: Record<
169
169
  AskReason,
170
- (question: string, context: { agoSeconds: number; page: string }) => string
170
+ (
171
+ question: string,
172
+ context: { agoSeconds: number; page: string; wait: string },
173
+ ) => string
171
174
  > = {
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}`,
175
+ "recently-opened": (question, { agoSeconds, wait }) =>
176
+ `A browser tab for setup was opened ${agoSeconds}s ago. Wait for the user there${wait}, or ask the user directly in the terminal: ${question}`,
174
177
  "browser-closed": (question) =>
175
178
  `The user closed the setup tab. Ask the user directly in the terminal: ${question}`,
176
179
  "no-browser": (question, { page }) =>
177
180
  `No browser tab is open for this setup. Ask the user directly in the terminal, or give them ${page}: ${question}`,
178
181
  };
179
182
 
183
+ /** The command that blocks on a need, beside the one that answers it: `<cmd> <url> wait`. */
184
+ const waitCommandFor = (answerCommand: string) =>
185
+ answerCommand.endsWith(" answer")
186
+ ? `${answerCommand.slice(0, -" answer".length)} wait`
187
+ : undefined;
188
+
180
189
  /** What the agent reads when setup opened the browser, or when it should ask the user itself. */
181
190
  const gateEvent = (
182
191
  action: BrowserGateAction,
183
192
  answerCommand: string,
184
193
  page: string,
194
+ waitCommand?: string,
185
195
  ): StreamEvent => {
186
196
  const { need } = action;
197
+ // Closing the setup is the user's call after "done"; there is nothing to block on.
198
+ const wait =
199
+ waitCommand !== undefined && need.kind !== "finish"
200
+ ? ` with "${waitCommand} ${need.id}"`
201
+ : "";
187
202
  if (action.type === "open")
188
203
  return {
189
204
  event: "browser.opened",
190
205
  id: need.id,
191
206
  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.`,
207
+ 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.${need.kind === "finish" ? " The answer arrives on this stream." : ` Then keep working in the same turn; when you need the answer, block on it${wait || ' with "wait"'}. Do not end your turn to wait: nothing on this stream wakes you.`}`,
193
208
  };
194
209
  const question = terminalQuestion(need);
195
210
  return {
@@ -199,6 +214,7 @@ const gateEvent = (
199
214
  message: ASK_MESSAGES[action.reason](formatTerminalQuestion(question), {
200
215
  agoSeconds: Math.round((action.openedAgoMs ?? 0) / 1000),
201
216
  page,
217
+ wait,
202
218
  }),
203
219
  ...question,
204
220
  answer: `${answerCommand} ${need.id} <${question.answerFormat}>`,
@@ -263,7 +279,12 @@ export const followSetup = async (
263
279
  for (const action of gate?.update(state) ?? []) {
264
280
  if (action.type === "open") (browser!.open ?? openUrl)(action.url);
265
281
  pending.push(
266
- gateEvent(action, browser!.answerCommand, page(state) ?? ""),
282
+ gateEvent(
283
+ action,
284
+ browser!.answerCommand,
285
+ page(state) ?? "",
286
+ waitCommandFor(browser!.answerCommand),
287
+ ),
267
288
  );
268
289
  }
269
290
  };