setup-agent 0.0.7 → 0.0.9

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
@@ -1,7 +1,13 @@
1
+ import {
2
+ CODING_AGENTS,
3
+ detectAgentKind,
4
+ isDirectInvocation,
5
+ runCodingAgent,
6
+ } from "./agents";
7
+ import type { CodingAgent } from "./agents";
1
8
  import { connectCheckout } from "./client";
2
9
  import type { CheckoutClient } from "./client";
3
10
  import { INPUT_PRESETS, isPresetId, presetInput } from "./presets";
4
- import { spawn } from "node:child_process";
5
11
  import { createHash, randomBytes } from "node:crypto";
6
12
  import { constants } from "node:fs";
7
13
  import {
@@ -23,6 +29,7 @@ import {
23
29
  currentPlan,
24
30
  isAgentPresent,
25
31
  isClosed,
32
+ isLoopbackHostname,
26
33
  isOptionIcon,
27
34
  isValidModelAnswer,
28
35
  parseEntryPointOptions,
@@ -65,9 +72,88 @@ Commands (the url always comes first):
65
72
  Mark every remaining step done and propose closing; the user closes it.
66
73
  `;
67
74
 
75
+ /** Characters a setup URL may hold: an origin and a path, nothing a shell or a reader treats as more than an address. */
76
+ const SETUP_URL_PATTERN =
77
+ /^https?:\/\/[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/[A-Za-z0-9._~%/-]*)?$|^http:\/\/\[::1\](?::[0-9]+)?(?:\/[A-Za-z0-9._~%/-]*)?$/;
78
+
79
+ /**
80
+ * The normalized setup URL, or an error saying why it is refused. A setup URL
81
+ * is printed into shell commands and the coding agent's prompt, and the API
82
+ * key travels over it, so it must be https (http only on this machine), carry
83
+ * no credentials, query or fragment, and hold only address characters.
84
+ */
85
+ export const parseSetupUrl = (value: string): string => {
86
+ let url: URL;
87
+ try {
88
+ url = new URL(value);
89
+ } catch {
90
+ throw new Error(`"${value}" is not a URL`);
91
+ }
92
+ if (
93
+ url.protocol !== "https:" &&
94
+ !(url.protocol === "http:" && isLoopbackHostname(url.hostname))
95
+ ) {
96
+ throw new Error("the setup URL must use https (http only on localhost)");
97
+ }
98
+ if (url.username !== "" || url.password !== "") {
99
+ throw new Error("the setup URL must not carry credentials");
100
+ }
101
+ if (
102
+ url.search !== "" ||
103
+ url.hash !== "" ||
104
+ value.includes("?") ||
105
+ value.includes("#")
106
+ ) {
107
+ throw new Error("the setup URL must not have a query or fragment");
108
+ }
109
+ const href = url.href.replace(/\/$/, "");
110
+ if (!SETUP_URL_PATTERN.test(href)) {
111
+ throw new Error("the setup URL has characters a setup URL does not use");
112
+ }
113
+ return href;
114
+ };
115
+
116
+ /** `value` as one shell word, quoted only when it holds more than plain URL characters. */
117
+ const shellWord = (value: string) =>
118
+ /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value)
119
+ ? value
120
+ : `'${value.replaceAll("'", `'\\''`)}'`;
121
+
122
+ export type AgentInstructionsOptions = {
123
+ /**
124
+ * The agent started this setup with `assistant-ui setup`, which already
125
+ * began planning and opened the setup page in the user's browser; there is
126
+ * no Begin plan click to wait for.
127
+ */
128
+ startedByAgent?: {
129
+ browserUrl: string;
130
+ /** False when the browser was not opened for the user; the agent then hands them the link. */
131
+ opened?: boolean;
132
+ };
133
+ };
134
+
135
+ const beginPlanHandoff = (url: string) =>
136
+ `Before planning, check the current setup status. If it is "waiting", tell the user in your own conversation: "I'm connected. Go back to your browser and click Begin plan. You can watch my progress, answer questions, and steer me there." Then run:
137
+ npx setup-agent ${url} wait start
138
+ Keep the stream running while you wait. Do not inspect the project, fetch guides, ask setup questions, or propose a plan until this command returns {"ok":true}. If it returns {"ok":false,"status":"cancelled"}, the user ended this setup before planning: do not plan, tell the user you are connected and waiting for their next setup, and run "wait start" again. Only the user in the browser should start planning; never invoke the browser's start command yourself. "planning.started" also arrives on the stream. If the status is already "planning" or "installing", resume that phase without asking the user to begin again. If it is "done" or "cancelled", that setup is over: tell the user you are connected and waiting for their next setup, and run "wait start", which returns once the next one begins.`;
139
+
140
+ const agentStartedHandoff = ({
141
+ browserUrl,
142
+ opened = true,
143
+ }: NonNullable<AgentInstructionsOptions["startedByAgent"]>) =>
144
+ `You started this setup, and it is already planning: there is no Begin plan click to wait for. The browser asks the user to connect to you first; it shows your working directory once your stream is running. Check "status" before planning: if it is "planning" or "installing", go on with that phase. Tell the user in your own conversation, once: ${
145
+ opened
146
+ ? `"I opened the assistant-ui setup in your browser. I'll ask my questions there, and you can watch my progress and steer me from that page. If it didn't open, go to ${browserUrl} ."`
147
+ : `"Open ${browserUrl} to follow the assistant-ui setup. I'll ask my questions there, and you can watch my progress and steer me from that page."`
148
+ } If the status is "done" or "cancelled", that setup is over: tell the user you are connected and waiting for their next setup, and run "wait start", which returns once the next one begins.`;
149
+
68
150
  export const agentInstructions = (
69
- url: string,
70
- ) => `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.
151
+ setupUrl: string,
152
+ options: AgentInstructionsOptions = {},
153
+ ) => {
154
+ const url = shellWord(setupUrl);
155
+ const started = options.startedByAgent;
156
+ return `You are the coding agent for a setup ${started ? "you opened in the user's browser" : "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.
71
157
 
72
158
  First, start a persistent Monitor on
73
159
  npx setup-agent ${url} stream
@@ -77,9 +163,7 @@ The user can message you at any time, including while a question is waiting. Rea
77
163
 
78
164
  "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.
79
165
 
80
- Before planning, check the current setup status. If it is "waiting", tell the user in your own conversation: "I'm connected. Go back to your browser and click Begin plan. You can watch my progress, answer questions, and steer me there." Then run:
81
- npx setup-agent ${url} wait start
82
- Keep the stream running while you wait. Do not inspect the project, fetch guides, ask setup questions, or propose a plan until this command returns {"ok":true}. Only the user in the browser should start planning; never invoke the browser's start command yourself. "planning.started" also arrives on the stream. If the status is already "planning" or "installing", resume that phase without asking the user to begin again. If it is "done" or "cancelled", that setup is over: tell the user you are connected and waiting for their next setup, and run "wait start", which returns once the next one begins.
166
+ ${started ? agentStartedHandoff(started) : beginPlanHandoff(url)}
83
167
 
84
168
  The remaining phases are plan, then install.
85
169
 
@@ -88,7 +172,7 @@ The remaining phases are plan, then install.
88
172
  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:
89
173
  npx setup-agent ${url} ask --preset framework
90
174
  npx setup-agent ${url} ask --preset llm-provider
91
- 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.
175
+ The setup's products are starting names and guide references. If the setup lists no products, first ask the user what to set up with a choice question whose options are slugs from https://www.assistant-ui.com/catalog.md, then fetch https://www.assistant-ui.com/install.md?items=<slugs> as the guide. 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.
92
176
  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:
93
177
  npx setup-agent ${url} ask "Where should users access the assistant?" --entry-points '<JSON array>' --wait
94
178
  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.
@@ -110,6 +194,7 @@ Before that, start the project's dev server in the background when the project h
110
194
  npx setup-agent ${url} wait start
111
195
  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.
112
196
  `;
197
+ };
113
198
 
114
199
  export const INSTRUCTIONS = agentInstructions;
115
200
 
@@ -136,6 +221,15 @@ const parseArgs = (argv: readonly string[]): Parsed => {
136
221
  return { positional, flags };
137
222
  };
138
223
 
224
+ export {
225
+ CODING_AGENTS,
226
+ detectAgentKind,
227
+ findCommand,
228
+ isDirectInvocation,
229
+ runCodingAgent,
230
+ } from "./agents";
231
+ export type { CodingAgent } from "./agents";
232
+
139
233
  const flagText = (flags: Parsed["flags"], name: string) => {
140
234
  const value = flags.get(name);
141
235
  return typeof value === "string" ? value : undefined;
@@ -150,66 +244,13 @@ const print = (value: unknown) => {
150
244
  process.stdout.write(`${JSON.stringify(value)}\n`);
151
245
  };
152
246
 
153
- const AGENT_ENV: readonly [kind: string, variable: string][] = [
154
- ["claude", "CLAUDECODE"],
155
- ["codex", "CODEX_SANDBOX"],
156
- ["codex", "CODEX_SANDBOX_NETWORK_DISABLED"],
157
- ["cursor", "CURSOR_AGENT"],
158
- ["gemini", "GEMINI_CLI"],
159
- ];
160
-
161
- /** The coding agent this process runs under, from the variables each one exports to its shell. */
162
- export const detectAgentKind = (env: NodeJS.ProcessEnv = process.env) =>
163
- AGENT_ENV.find(([, variable]) => env[variable])?.[0];
164
-
165
- type Launcher = {
166
- kind: string;
167
- name: string;
168
- command: string;
169
- args: (prompt: string) => string[];
170
- install: string;
171
- };
172
-
173
- const LAUNCHERS: Launcher[] = [
174
- {
175
- kind: "claude",
176
- name: "Claude Code",
177
- command: "claude",
178
- args: (prompt) => [prompt],
179
- install: "npm install -g @anthropic-ai/claude-code",
180
- },
181
- {
182
- kind: "codex",
183
- name: "Codex",
184
- command: "codex",
185
- args: (prompt) => [prompt],
186
- install: "npm install -g @openai/codex",
187
- },
188
- {
189
- kind: "cursor",
190
- name: "Cursor",
191
- command: "agent",
192
- args: (prompt) => [prompt],
193
- install: "curl https://cursor.com/install -fsS | bash",
194
- },
195
- {
196
- kind: "gemini",
197
- name: "Gemini CLI",
198
- command: "gemini",
199
- args: (prompt) => ["-i", prompt],
200
- install: "npm install -g @google/gemini-cli",
201
- },
202
- ];
203
-
204
- /** True when a person typed the command into a terminal rather than an agent running it from its shell. */
205
- export const isDirectInvocation = (
206
- env: NodeJS.ProcessEnv = process.env,
207
- stdin: { isTTY?: boolean | undefined } = process.stdin,
208
- stdout: { isTTY?: boolean | undefined } = process.stdout,
209
- ) =>
210
- detectAgentKind(env) === undefined &&
211
- stdin.isTTY === true &&
212
- stdout.isTTY === true;
247
+ /** The agents `setup-agent <url>` can start itself, for a person who ran it in a terminal. */
248
+ const LAUNCHERS = CODING_AGENTS.filter(
249
+ (
250
+ agent,
251
+ ): agent is CodingAgent & { launch: NonNullable<CodingAgent["launch"]> } =>
252
+ agent.launch !== undefined,
253
+ );
213
254
 
214
255
  const chooseLauncher = async () => {
215
256
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -242,25 +283,16 @@ const chooseLauncher = async () => {
242
283
 
243
284
  const launchAgent = async (url: string) => {
244
285
  const launcher = await chooseLauncher();
245
- const prompt = `Run \`npx setup-agent ${url}\` and follow the instructions it prints.`;
286
+ const prompt = `Run \`npx setup-agent ${shellWord(url)}\` and follow the instructions it prints.`;
246
287
  process.stdout.write(`\nStarting ${launcher.name}...\n\n`);
247
- const child = spawn(launcher.command, launcher.args(prompt), {
248
- stdio: "inherit",
249
- });
250
- const code = await new Promise<number>((resolve) => {
251
- child.on("error", (error: NodeJS.ErrnoException) => {
252
- if (error.code === "ENOENT") {
253
- process.stderr.write(
254
- `${launcher.name} is not installed. Install it with:\n ${launcher.install}\nthen run this command again.\n`,
255
- );
256
- } else {
257
- process.stderr.write(`${error.message}\n`);
258
- }
259
- resolve(1);
260
- });
261
- child.on("exit", (status) => resolve(status ?? 1));
262
- });
263
- process.exit(code);
288
+ const code = await runCodingAgent(launcher, prompt);
289
+ if (code === undefined) {
290
+ process.stderr.write(
291
+ `${launcher.name} is not installed. Install it with:\n ${launcher.launch.install}\nthen run this command again.\n`,
292
+ );
293
+ }
294
+ // Exit once stdout and stderr have drained.
295
+ process.exitCode = code ?? 1;
264
296
  };
265
297
 
266
298
  const requireState = (client: CheckoutClient): Checkout.State =>
@@ -654,6 +686,16 @@ const assertEnvKey = (key: string) => {
654
686
  /** Sets `key=value` in dotenv text, replacing an existing line for the key. */
655
687
  export const upsertEnvLine = (content: string, key: string, value: string) => {
656
688
  assertEnvKey(key);
689
+ // A line break or other control character would end the line and let the
690
+ // value write lines of its own.
691
+ const isLineBreaking = (code: number) =>
692
+ code < 0x20 ||
693
+ (code >= 0x7f && code <= 0x9f) ||
694
+ code === 0x2028 ||
695
+ code === 0x2029;
696
+ if ([...value].some((char) => isLineBreaking(char.charCodeAt(0)))) {
697
+ throw new Error(`the value for ${key} must be a single line`);
698
+ }
657
699
  const line = `${key}=${value}`;
658
700
  const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
659
701
  const index = lines.findIndex(
@@ -722,8 +764,10 @@ export const writeEnvSecret = async (
722
764
  await handle.chmod(0o600);
723
765
  const value = await take();
724
766
  const current = await handle.readFile("utf8");
767
+ // Built before truncating, so a refused value leaves the file intact.
768
+ const next = upsertEnvLine(current, key, value);
725
769
  await handle.truncate(0);
726
- await handle.writeFile(upsertEnvLine(current, key, value));
770
+ await handle.writeFile(next);
727
771
  } finally {
728
772
  await handle.close();
729
773
  }
@@ -855,7 +899,12 @@ export const main = async (argv: readonly string[]) => {
855
899
  process.stdout.write(HELP);
856
900
  return process.exit(first === undefined ? 1 : 0);
857
901
  }
858
- const url = first;
902
+ let url: string;
903
+ try {
904
+ url = parseSetupUrl(first);
905
+ } catch (error) {
906
+ return fail(error instanceof Error ? error.message : String(error));
907
+ }
859
908
  if (command === "intro" && isDirectInvocation()) return launchAgent(url);
860
909
 
861
910
  const client = await connectCheckout(url).catch((error: unknown) =>
@@ -1023,6 +1072,16 @@ export const main = async (argv: readonly string[]) => {
1023
1072
  }
1024
1073
  const file = resolve(flagText(flags, "file") ?? ".env.local");
1025
1074
  await writeEnvSecret(file, envKey, async () => {
1075
+ const secretUrl = new URL(url);
1076
+ if (
1077
+ secretUrl.protocol !== "https:" &&
1078
+ !(
1079
+ secretUrl.protocol === "http:" &&
1080
+ isLoopbackHostname(secretUrl.hostname)
1081
+ )
1082
+ ) {
1083
+ fail("the key is only fetched over https");
1084
+ }
1026
1085
  const response = await fetch(
1027
1086
  `${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}?setup=${encodeURIComponent(setupId)}`,
1028
1087
  storedKey === undefined
@@ -1038,7 +1097,14 @@ export const main = async (argv: readonly string[]) => {
1038
1097
  );
1039
1098
  }
1040
1099
  if (!response.ok) fail(`could not fetch the key: ${response.status}`);
1041
- return response.text();
1100
+ const key = await response.text();
1101
+ // An API key is one word of letters, digits and a few separators;
1102
+ // anything else could break out of the env line, be read differently
1103
+ // by dotenv, or run as shell syntax when the file is sourced.
1104
+ if (!/^[A-Za-z0-9._~+/=:@%,-]+$/.test(key)) {
1105
+ fail("the deposited key is not a valid API key");
1106
+ }
1107
+ return key;
1042
1108
  });
1043
1109
  if (storedKey !== undefined) {
1044
1110
  await unlink(storedKey.file).catch((error: NodeJS.ErrnoException) => {
package/src/protocol.ts CHANGED
@@ -339,11 +339,13 @@ export const parsePreviewUrl = (value: string | undefined) => {
339
339
  }
340
340
  if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
341
341
  if (url.username !== "" || url.password !== "") return undefined;
342
- const loopback =
343
- LOOPBACK_HOSTS.has(url.hostname) || url.hostname.endsWith(".localhost");
344
- return loopback ? url : undefined;
342
+ return isLoopbackHostname(url.hostname) ? url : undefined;
345
343
  };
346
344
 
345
+ /** True for a hostname that names this machine. */
346
+ export const isLoopbackHostname = (hostname: string) =>
347
+ LOOPBACK_HOSTS.has(hostname) || hostname.endsWith(".localhost");
348
+
347
349
  export const openInputs = (state: Checkout.State) =>
348
350
  state.inputs.filter((input) => input.status === "open");
349
351