setup-agent 0.0.2 → 0.0.4

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,9 +1,8 @@
1
- /// <reference types="node" />
2
1
  import { connectCheckout } from "./client";
3
2
  import type { CheckoutClient } from "./client";
4
3
  import { INPUT_PRESETS, isPresetId, presetInput } from "./presets";
5
4
  import { spawn } from "node:child_process";
6
- import { readFile, writeFile } from "node:fs/promises";
5
+ import { open, readFile } from "node:fs/promises";
7
6
  import { createInterface } from "node:readline/promises";
8
7
  import { resolve } from "node:path";
9
8
  import {
@@ -14,23 +13,23 @@ import {
14
13
  isAgentPresent,
15
14
  isClosed,
16
15
  isOptionIcon,
17
- parseModelAnswer,
16
+ isValidModelAnswer,
18
17
  parseMultipleAnswer,
19
18
  stepProgress,
20
19
  } from "./protocol";
21
20
  import type { Checkout } from "./protocol";
22
21
 
23
- const HELP = `setup-agent <url> [command]
22
+ export const HELP = `setup-agent <url> [command]
24
23
 
25
24
  Commands (the url always comes first):
26
- status Print the checkout as JSON.
27
- stream Follow the checkout: one JSON array of events per line.
25
+ status Print the setup as JSON.
26
+ stream Follow the setup: one JSON array of events per line.
28
27
  log <text> Send a message to the user: a reply, something no question or step conveys, or one wrap-up when finished.
29
28
  ack <message-id> Acknowledge a user message.
30
29
  ask <prompt> [--placeholder <text>] [--optional] [--step <step-id>] [--wait]
31
30
  Ask the user for a line of text.
32
31
  ask [prompt] --preset <framework|llm-provider|project> [--only <id,id>] [--choices <id=description,...>] [--default <id>] [--optional] [--step <step-id>] [--wait]
33
- Ask a standard question. --only restricts the options (one id locks it in); --default preselects one.
32
+ Ask a standard question. --only restricts the options and leaves out --choices (one id locks it in); --default preselects one.
34
33
  "framework" answers "<framework>:<language>"; "llm-provider" answers JSON with provider, model and reasoningEffort; the key itself arrives through "env".
35
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>".
36
35
  ask <prompt> --choices <id,id,...> --icons <id=icon,...> [--single] [--default <id>] [--optional] [--step <step-id>] [--wait]
@@ -39,8 +38,8 @@ Commands (the url always comes first):
39
38
  --single is for options that exclude each other (one framework, one model, yes or no); the answer is then one id.
40
39
  --icons is required and names an icon for every option, from: ${OPTION_ICONS.join(", ")}.
41
40
  ask <prompt> --product <slug> [--wait]
42
- Propose adding a product to this checkout, such as one another product needs first. Answers "added"; the product and its guide then appear in "status".
43
- wait start Block until the user clicks Begin plan in their browser; after a finished or cancelled checkout, until the next one begins.
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".
42
+ wait start Block until the user clicks Begin plan in their browser; after a finished or cancelled setup, until the next one begins.
44
43
  wait <input-id> Block until the user answers or dismisses an input. A multi-select answer prints as an array.
45
44
  plan (<markdown> | --file <path>) [--wait]
46
45
  Propose the plan for the user to approve. --wait blocks until they decide.
@@ -55,19 +54,19 @@ Commands (the url always comes first):
55
54
  Mark every remaining step done and propose closing; the user closes it.
56
55
  `;
57
56
 
58
- const INSTRUCTIONS = (
57
+ export const INSTRUCTIONS = (
59
58
  url: string,
60
- ) => `You are the coding agent for a checkout the user opened in their browser. The browser shows this checkout 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.
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.
61
60
 
62
61
  First, start a persistent Monitor on
63
62
  npx setup-agent ${url} stream
64
- 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 checkout: it outlives this setup, and their next setup arrives on the same stream. Each line is a JSON array of events: "planning.started", "input.answered" ({inputId, answer, note?}; answer is an array for a choice question not asked with --single), "input.dismissed", "plan.approved", "plan.changes-requested" ({feedback}), "message.created" ({messageId, text}), "finished", "cancelled", "checkout.created" ({products}).
63
+ 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: "planning.started", "input.answered" ({inputId, answer, note?}; answer is an array for a choice question not asked with --single), "input.dismissed", "plan.approved", "plan.changes-requested" ({feedback}), "message.created" ({messageId, text}), "finished", "cancelled", "setup.created" ({products}).
65
64
 
66
65
  The user can message you at any time, including while a question is waiting. Read "message.created" events, acknowledge each with "npx setup-agent ${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.
67
66
 
68
67
  "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.
69
68
 
70
- Before planning, check the current checkout 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:
69
+ 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:
71
70
  npx setup-agent ${url} wait start
72
71
  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.
73
72
 
@@ -75,16 +74,16 @@ The remaining phases are plan, then install.
75
74
 
76
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:
77
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
78
- 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 checkout 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:
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:
79
78
  npx setup-agent ${url} ask --preset framework
80
79
  npx setup-agent ${url} ask --preset llm-provider
81
- 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 checkout:
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:
82
81
  npx setup-agent ${url} ask "assistant-ui is not installed in this project yet. Add it to this setup?" --product assistant-ui --wait
83
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.
84
83
  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.
85
84
  Then write the plan as markdown and submit it:
86
85
  npx setup-agent ${url} plan --file <path> --wait
87
- 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 checkout refuses steps until then.
86
+ 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.
88
87
 
89
88
  2. Install. Declare the steps from your plan up front, in order:
90
89
  npx setup-agent ${url} add-step "<title>" --detail "<one line>"
@@ -95,9 +94,9 @@ which writes ENV_VAR=<key> into that file. Use the answer's model in the chat ro
95
94
 
96
95
  When every product is installed and verified, run
97
96
  npx setup-agent ${url} done --summary "<one or two lines on what you installed and how to try it>" --preview http://localhost:<port>
98
- Before that, start the project's dev server in the background when the project has one, check that the page you changed loads, and pass the address as --preview (the page itself when it is not the root); leave the server running so the user can try the result. Omit --preview when there is nothing to open. This proposes closing the checkout; only the user closes it. Do not stop the stream or exit: stay idle on it, because the user may follow up with a "message.created" event. Handle it, then run "done" again. The stream prints "finished" once the user closes the checkout, and "cancelled" if they end it early. Keep the stream running after either: the user's next setup starts on this same URL and arrives as "checkout.created" with its products. Run
97
+ Before that, start the project's dev server in the background when the project has one, check that the page you changed loads, and pass the address as --preview (the page itself when it is not the root); leave the server running so the user can try the result. Omit --preview when there is nothing to open. This proposes closing the setup; only the user closes it. Do not stop the stream or exit: stay idle on it, because the user may follow up with a "message.created" event. Handle it, then run "done" again. The stream prints "finished" once the user closes the setup, and "cancelled" if they end it early. Keep the stream running after either: the user's next setup starts on this same URL and arrives as "setup.created" with its products. Run
99
98
  npx setup-agent ${url} wait start
100
- which returns once that setup begins, then run "status" for its products, guides and instructions and go through plan and install again. A "checkout.created" event while you are still working means the user replaced this setup: drop what you were doing and start over the same way.
99
+ 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.
101
100
  `;
102
101
 
103
102
  type Parsed = { positional: string[]; flags: Map<string, string | true> };
@@ -251,7 +250,7 @@ const launchAgent = async (url: string) => {
251
250
  };
252
251
 
253
252
  const requireState = (client: CheckoutClient): Checkout.State =>
254
- client.state ?? fail("the checkout has no state");
253
+ client.state ?? fail("the setup has no state");
255
254
 
256
255
  const summary = (state: Checkout.State) => {
257
256
  const plan = currentPlan(state);
@@ -304,7 +303,7 @@ type StreamEvent =
304
303
  | { event: "plan.changes-requested"; revision: number; feedback: string }
305
304
  | { event: "finished"; next: string }
306
305
  | { event: "cancelled"; next: string }
307
- | { event: "checkout.created"; products: string[] };
306
+ | { event: "setup.created"; products: string[] };
308
307
 
309
308
  const STAY_CONNECTED =
310
309
  "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.";
@@ -376,7 +375,7 @@ export const diffEvents = (
376
375
  }
377
376
  if (after.id !== null && after.id !== before.id) {
378
377
  events.push({
379
- event: "checkout.created",
378
+ event: "setup.created",
380
379
  products: after.products.map((product) => product.slug),
381
380
  });
382
381
  }
@@ -425,7 +424,7 @@ const stream = async (client: CheckoutClient) => {
425
424
  await new Promise(() => {});
426
425
  };
427
426
 
428
- /** Resolves once a checkout is planning or installing, or once the one being waited on is cancelled; a checkout already closed when the wait begins is over, so the wait is for the next one. */
427
+ /** Resolves once a setup is planning or installing, or once the one being waited on is cancelled; a setup already closed when the wait begins is over, so the wait is for the next one. */
429
428
  export const waitForStart = (
430
429
  client: Pick<CheckoutClient, "state" | "subscribe">,
431
430
  ) =>
@@ -586,18 +585,50 @@ export const askSeed = (
586
585
  };
587
586
  };
588
587
 
588
+ const assertEnvKey = (key: string) => {
589
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
590
+ throw new Error(`"${key}" is not an environment variable name`);
591
+ }
592
+ };
593
+
589
594
  /** Sets `key=value` in dotenv text, replacing an existing line for the key. */
590
595
  export const upsertEnvLine = (content: string, key: string, value: string) => {
596
+ assertEnvKey(key);
591
597
  const line = `${key}=${value}`;
592
598
  const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
593
- const index = lines.findIndex((entry) =>
594
- new RegExp(`^(export\\s+)?${key}\\s*=`).test(entry),
599
+ const index = lines.findIndex(
600
+ (entry) =>
601
+ /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(entry)?.[1] === key,
595
602
  );
596
603
  if (index === -1) lines.push(line);
597
604
  else lines[index] = line;
598
605
  return `${lines.join("\n")}\n`;
599
606
  };
600
607
 
608
+ /**
609
+ * Write the secret `take` returns to `file` under `key`, leaving the file
610
+ * owner-only. The file is opened before `take` runs, because taking the
611
+ * secret consumes it and an unwritable destination must fail while it is
612
+ * still deposited; it is read after, so lines written meanwhile survive.
613
+ */
614
+ export const writeEnvSecret = async (
615
+ file: string,
616
+ key: string,
617
+ take: () => Promise<string>,
618
+ ) => {
619
+ assertEnvKey(key);
620
+ const handle = await open(file, "a+", 0o600);
621
+ try {
622
+ await handle.chmod(0o600);
623
+ const value = await take();
624
+ const current = await handle.readFile("utf8");
625
+ await handle.truncate(0);
626
+ await handle.writeFile(upsertEnvLine(current, key, value));
627
+ } finally {
628
+ await handle.close();
629
+ }
630
+ };
631
+
601
632
  const answerFields = (input: Checkout.Input, answer: string) => ({
602
633
  answer: (input.multiple && parseMultipleAnswer(answer)) || answer,
603
634
  ...(input.note !== undefined && { note: input.note }),
@@ -637,7 +668,7 @@ export const main = async (argv: readonly string[]) => {
637
668
  ...(kind !== undefined && { kind }),
638
669
  }).catch(() => {});
639
670
  process.stdout.write(INSTRUCTIONS(url));
640
- process.stdout.write("\nCurrent checkout:\n");
671
+ process.stdout.write("\nCurrent setup:\n");
641
672
  print(summary(requireState(client)));
642
673
  break;
643
674
  }
@@ -738,27 +769,36 @@ export const main = async (argv: readonly string[]) => {
738
769
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(envKey)) {
739
770
  fail(`"${envKey}" is not an environment variable name`);
740
771
  }
772
+ const state = requireState(client);
741
773
  const input =
742
- requireState(client).inputs.find(
743
- (candidate) => candidate.id === inputId,
744
- ) ?? fail(`no input "${inputId}"`);
774
+ state.inputs.find((candidate) => candidate.id === inputId) ??
775
+ fail(`no input "${inputId}"`);
776
+ const setupId = state.id ?? fail("the setup has not been created yet");
777
+ if (isClosed(state)) {
778
+ fail("the setup is not open");
779
+ }
745
780
  if (input.status !== "answered") {
746
781
  fail(`input "${inputId}" is ${input.status}`);
747
782
  }
748
- if (parseModelAnswer(input.answer ?? "") === undefined) {
783
+ if (
784
+ input.kind !== "model" ||
785
+ !isValidModelAnswer(input, input.answer ?? "")
786
+ ) {
749
787
  fail(`input "${inputId}" is not an llm-provider answer`);
750
788
  }
751
789
  const file = resolve(flagText(flags, "file") ?? ".env.local");
752
- const response = await fetch(
753
- `${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}`,
754
- );
755
- if (response.status === 404) {
756
- fail(`the key for "${inputId}" was already taken or never deposited`);
757
- }
758
- if (!response.ok) fail(`could not fetch the key: ${response.status}`);
759
- const value = await response.text();
760
- const current = await readFile(file, "utf8").catch(() => "");
761
- await writeFile(file, upsertEnvLine(current, envKey, value));
790
+ await writeEnvSecret(file, envKey, async () => {
791
+ const response = await fetch(
792
+ `${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}?setup=${encodeURIComponent(setupId)}`,
793
+ );
794
+ if (response.status === 404) {
795
+ fail(
796
+ `the key for "${inputId}" was already taken or never deposited`,
797
+ );
798
+ }
799
+ if (!response.ok) fail(`could not fetch the key: ${response.status}`);
800
+ return response.text();
801
+ });
762
802
  print({ ok: true, envKey, file });
763
803
  break;
764
804
  }
@@ -784,7 +824,7 @@ export const main = async (argv: readonly string[]) => {
784
824
  print({
785
825
  ok: true,
786
826
  status: "proposed",
787
- next: "Keep the stream running and stay idle. The user closes the checkout or sends a follow-up message.",
827
+ next: "Keep the stream running and stay idle. The user closes the setup or sends a follow-up message.",
788
828
  });
789
829
  break;
790
830
  }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { connectCheckout } from "./client";
3
+
4
+ const { created } = vi.hoisted(() => ({
5
+ created: [] as {
6
+ options: { onError: (error: unknown) => void };
7
+ state: unknown;
8
+ listener: () => void;
9
+ dispose: ReturnType<typeof vi.fn>;
10
+ unsubscribe: ReturnType<typeof vi.fn>;
11
+ }[],
12
+ }));
13
+
14
+ vi.mock("statewire", async (importOriginal) => ({
15
+ ...(await importOriginal()),
16
+ StatewireHttp: () => ({}),
17
+ StatewireClient: class {
18
+ state: unknown = undefined;
19
+ listener = () => {};
20
+ connection = { status: "connecting" };
21
+ dispose = vi.fn();
22
+ unsubscribe = vi.fn();
23
+ options: { onError: (error: unknown) => void };
24
+ constructor(options: { onError: (error: unknown) => void }) {
25
+ this.options = options;
26
+ created.push(this);
27
+ }
28
+ subscribe = (listener: () => void) => {
29
+ this.listener = listener;
30
+ return this.unsubscribe;
31
+ };
32
+ },
33
+ }));
34
+
35
+ describe("connectCheckout", () => {
36
+ it("disposes the client when the first connection fails", async () => {
37
+ const connecting = connectCheckout("https://checkout.test/session");
38
+ const client = created.at(-1)!;
39
+ client.options.onError(new Error("offline"));
40
+ await expect(connecting).rejects.toThrow("offline");
41
+ expect(client.unsubscribe).toHaveBeenCalled();
42
+ expect(client.dispose).toHaveBeenCalled();
43
+ });
44
+
45
+ it("keeps the client it resolved with when a later error arrives", async () => {
46
+ const connecting = connectCheckout("https://checkout.test/session");
47
+ const client = created.at(-1)!;
48
+ client.state = {};
49
+ client.listener();
50
+ await expect(connecting).resolves.toBe(client);
51
+ client.options.onError(new Error("later"));
52
+ expect(client.dispose).not.toHaveBeenCalled();
53
+ });
54
+ });
package/src/client.ts CHANGED
@@ -6,27 +6,35 @@ export type CheckoutClient = StatewireClient<
6
6
  Checkout.Commands
7
7
  >;
8
8
 
9
- /** Attach to a checkout by its base URL and resolve once its state has arrived. */
9
+ /** Attach to a setup by its base URL and resolve once its state has arrived. */
10
10
  export const connectCheckout = (url: string): Promise<CheckoutClient> =>
11
11
  new Promise((resolve, reject) => {
12
+ let settled = false;
13
+ const fail = (error: unknown) => {
14
+ if (settled) return;
15
+ settled = true;
16
+ unsubscribe();
17
+ client.dispose();
18
+ reject(error);
19
+ };
12
20
  const client: CheckoutClient = new StatewireClient({
13
21
  transport: StatewireHttp({ url }),
14
- onError: (error) => reject(error),
22
+ onError: fail,
15
23
  });
16
24
  const settle = () => {
25
+ if (settled) return;
17
26
  if (client.state === undefined) {
18
27
  if (client.connection.status !== "stopped") return;
19
- unsubscribe();
20
- client.dispose();
21
- reject(
28
+ fail(
22
29
  new Error(
23
- `checkout unreachable: ${client.connection.reason}${
30
+ `setup unreachable: ${client.connection.reason}${
24
31
  client.connection.message ? ` (${client.connection.message})` : ""
25
32
  }`,
26
33
  ),
27
34
  );
28
35
  return;
29
36
  }
37
+ settled = true;
30
38
  unsubscribe();
31
39
  resolve(client);
32
40
  };