setup-agent 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -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,7 +13,8 @@ import {
14
13
  isAgentPresent,
15
14
  isClosed,
16
15
  isOptionIcon,
17
- parseModelAnswer,
16
+ isValidModelAnswer,
17
+ parseEntryPointOptions,
18
18
  parseMultipleAnswer,
19
19
  stepProgress,
20
20
  } from "./protocol";
@@ -29,17 +29,16 @@ Commands (the url always comes first):
29
29
  ack <message-id> Acknowledge a user message.
30
30
  ask <prompt> [--placeholder <text>] [--optional] [--step <step-id>] [--wait]
31
31
  Ask the user for a line of text.
32
- 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 [prompt] --preset <framework|llm-provider> [--only <id,id>] [--choices <id=description,...>] [--default <id>] [--optional] [--step <step-id>] [--wait]
33
+ Ask a standard question. --only restricts the options and leaves out --choices (one id locks it in); --default preselects one.
34
34
  "framework" answers "<framework>:<language>"; "llm-provider" answers JSON with provider, model and reasoningEffort; the key itself arrives through "env".
35
- "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]
37
36
  Ask the user to pick from your own options; they may also type their own answer.
38
37
  The user checks any number of options and the answer is a JSON array of option ids (their own text is one entry).
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
- ask <prompt> --product <slug> [--wait]
42
- Propose adding a product to this setup, such as one another product needs first. Answers "added"; the product and its guide then appear in "status".
40
+ ask <prompt> --entry-points '<JSON array>' [--default <id>] [--optional] [--step <step-id>] [--wait]
41
+ Ask where users should access the assistant. Each of 1–3 options has id, label, description and entryPoint {formFactor: modal|sidebar|full-page, placement, trigger, recommended?}. Answers one option id.
43
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]
@@ -55,7 +54,7 @@ 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
- export const INSTRUCTIONS = (
57
+ export const agentInstructions = (
59
58
  url: string,
60
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
 
@@ -74,13 +73,14 @@ Keep the stream running while you wait. Do not inspect the project, fetch guides
74
73
  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
- npx setup-agent ${url} ask "Which project should I install into?" --placeholder "An absolute path, or a path for a new Next.js app" --wait
76
+ npx setup-agent ${url} ask "Which project should I install into?" --placeholder "An absolute path to the app" --wait
78
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 setup:
82
- npx setup-agent ${url} ask "assistant-ui is not installed in this project yet. Add it to this setup?" --product assistant-ui --wait
83
- On "added", run "status", fetch the new product's guide, and install it first in your plan. If the user dismisses it, follow what the guide says about the missing product.
80
+ The setup's products are starting names and guide references. Explore each product's setup instructions, then discover and plan any other relevant products or dependencies the app needs. Read /catalog.md on the guide URL’s origin to find related products and fetch their setup guides. The starting list does not restrict the implementation or require adding another product to the list. Include relevant additions in the plan for approval.
81
+ When the setup puts an assistant in the app, ask how users should access it after discovering the actual app and before proposing the plan. Skip this question when no product's guide installs an assistant (a standalone library or service setup, for example). Use an entry-point question with only 1–3 sensible alternatives. Describe the actual app's pages, workflow and component placement, not generic placeholder copy. Recommend one clear fit when there is one; two options may use the same form factor in different places. Each option needs a stable id, label, contextual description and entryPoint {"formFactor":"modal"|"sidebar"|"full-page","placement":"where it appears","trigger":"how users open it or navigate to it","recommended":true?}. Write the JSON array for this app and send it with:
82
+ npx setup-agent ${url} ask "Where should users access the assistant?" --entry-points '<JSON array>' --wait
83
+ Mark a clear fit with "recommended":true rather than --default, so the user makes the pick. When you ask it, do not submit the plan before the user picks an entry point. Respect the selected placement and trigger in the plan and implementation. Do not ask for a new-versus-existing app setting; discover the app from its files and ask only for an install path when needed.
84
84
  Skip a preset the code already answers (a chat route on the AI SDK, a provider key in .env.local) and log why. Ask everything at once, keep investigating, and block with "wait <input-id>" only when nothing else can proceed. A framework answer is "<framework>:<language>", for example "ai-sdk:typescript". A model answer is JSON {"provider","model","reasoningEffort"?}. An answer may carry a "note" from the user; follow it. A choice answer that matches none of the options is the user's own text.
85
85
  Then write the plan as markdown and submit it:
86
86
  npx setup-agent ${url} plan --file <path> --wait
@@ -100,6 +100,8 @@ Before that, start the project's dev server in the background when the project h
100
100
  which returns once that setup begins, then run "status" for its products, guides and instructions and go through plan and install again. A "setup.created" event while you are still working means the user replaced this setup: drop what you were doing and start over the same way.
101
101
  `;
102
102
 
103
+ export const INSTRUCTIONS = agentInstructions;
104
+
103
105
  type Parsed = { positional: string[]; flags: Map<string, string | true> };
104
106
 
105
107
  const parseArgs = (argv: readonly string[]): Parsed => {
@@ -507,6 +509,52 @@ export const askSeed = (
507
509
  const stepId = flagText(flags, "step");
508
510
  const optional = flags.has("optional");
509
511
  const fallback = flagText(flags, "default");
512
+ if (flags.has("entry-points")) {
513
+ if (
514
+ [
515
+ "preset",
516
+ "product",
517
+ "choices",
518
+ "icons",
519
+ "multiple",
520
+ "single",
521
+ "only",
522
+ "placeholder",
523
+ ].some((flag) => flags.has(flag))
524
+ )
525
+ return fail(
526
+ "--entry-points cannot be combined with other input kinds, --multiple, --single or --only",
527
+ );
528
+ const json = flagText(flags, "entry-points");
529
+ let value: unknown;
530
+ try {
531
+ value = JSON.parse(json ?? "");
532
+ } catch {
533
+ return fail("--entry-points needs a JSON array of options");
534
+ }
535
+ const options = parseEntryPointOptions(value);
536
+ if (!options)
537
+ return fail(
538
+ "--entry-points needs 1–3 unique options with id, label, description and entryPoint {formFactor, placement, trigger, recommended?}; at most one is recommended",
539
+ );
540
+ if (
541
+ fallback !== undefined &&
542
+ !options.some((option) => option.id === fallback)
543
+ )
544
+ return fail(`no option "${fallback}" to default to`);
545
+ const prompt =
546
+ rest[0] ?? fail("usage: ask <prompt> --entry-points '<JSON array>'");
547
+ if (prompt.trim() === "")
548
+ return fail("an entry-point prompt cannot be empty");
549
+ return {
550
+ kind: "entry-point",
551
+ prompt,
552
+ options,
553
+ ...(fallback !== undefined && { default: fallback }),
554
+ optional,
555
+ ...(stepId !== undefined && { stepId }),
556
+ };
557
+ }
510
558
  const preset = flagText(flags, "preset");
511
559
  if (preset !== undefined) {
512
560
  if (!isPresetId(preset)) {
@@ -586,18 +634,50 @@ export const askSeed = (
586
634
  };
587
635
  };
588
636
 
637
+ const assertEnvKey = (key: string) => {
638
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
639
+ throw new Error(`"${key}" is not an environment variable name`);
640
+ }
641
+ };
642
+
589
643
  /** Sets `key=value` in dotenv text, replacing an existing line for the key. */
590
644
  export const upsertEnvLine = (content: string, key: string, value: string) => {
645
+ assertEnvKey(key);
591
646
  const line = `${key}=${value}`;
592
647
  const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
593
- const index = lines.findIndex((entry) =>
594
- new RegExp(`^(export\\s+)?${key}\\s*=`).test(entry),
648
+ const index = lines.findIndex(
649
+ (entry) =>
650
+ /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(entry)?.[1] === key,
595
651
  );
596
652
  if (index === -1) lines.push(line);
597
653
  else lines[index] = line;
598
654
  return `${lines.join("\n")}\n`;
599
655
  };
600
656
 
657
+ /**
658
+ * Write the secret `take` returns to `file` under `key`, leaving the file
659
+ * owner-only. The file is opened before `take` runs, because taking the
660
+ * secret consumes it and an unwritable destination must fail while it is
661
+ * still deposited; it is read after, so lines written meanwhile survive.
662
+ */
663
+ export const writeEnvSecret = async (
664
+ file: string,
665
+ key: string,
666
+ take: () => Promise<string>,
667
+ ) => {
668
+ assertEnvKey(key);
669
+ const handle = await open(file, "a+", 0o600);
670
+ try {
671
+ await handle.chmod(0o600);
672
+ const value = await take();
673
+ const current = await handle.readFile("utf8");
674
+ await handle.truncate(0);
675
+ await handle.writeFile(upsertEnvLine(current, key, value));
676
+ } finally {
677
+ await handle.close();
678
+ }
679
+ };
680
+
601
681
  const answerFields = (input: Checkout.Input, answer: string) => ({
602
682
  answer: (input.multiple && parseMultipleAnswer(answer)) || answer,
603
683
  ...(input.note !== undefined && { note: input.note }),
@@ -738,27 +818,36 @@ export const main = async (argv: readonly string[]) => {
738
818
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(envKey)) {
739
819
  fail(`"${envKey}" is not an environment variable name`);
740
820
  }
821
+ const state = requireState(client);
741
822
  const input =
742
- requireState(client).inputs.find(
743
- (candidate) => candidate.id === inputId,
744
- ) ?? fail(`no input "${inputId}"`);
823
+ state.inputs.find((candidate) => candidate.id === inputId) ??
824
+ fail(`no input "${inputId}"`);
825
+ const setupId = state.id ?? fail("the setup has not been created yet");
826
+ if (isClosed(state)) {
827
+ fail("the setup is not open");
828
+ }
745
829
  if (input.status !== "answered") {
746
830
  fail(`input "${inputId}" is ${input.status}`);
747
831
  }
748
- if (parseModelAnswer(input.answer ?? "") === undefined) {
832
+ if (
833
+ input.kind !== "model" ||
834
+ !isValidModelAnswer(input, input.answer ?? "")
835
+ ) {
749
836
  fail(`input "${inputId}" is not an llm-provider answer`);
750
837
  }
751
838
  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));
839
+ await writeEnvSecret(file, envKey, async () => {
840
+ const response = await fetch(
841
+ `${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}?setup=${encodeURIComponent(setupId)}`,
842
+ );
843
+ if (response.status === 404) {
844
+ fail(
845
+ `the key for "${inputId}" was already taken or never deposited`,
846
+ );
847
+ }
848
+ if (!response.ok) fail(`could not fetch the key: ${response.status}`);
849
+ return response.text();
850
+ });
762
851
  print({ ok: true, envKey, file });
763
852
  break;
764
853
  }
@@ -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
@@ -9,16 +9,23 @@ export type CheckoutClient = StatewireClient<
9
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
30
  `setup unreachable: ${client.connection.reason}${
24
31
  client.connection.message ? ` (${client.connection.message})` : ""
@@ -27,6 +34,7 @@ export const connectCheckout = (url: string): Promise<CheckoutClient> =>
27
34
  );
28
35
  return;
29
36
  }
37
+ settled = true;
30
38
  unsubscribe();
31
39
  resolve(client);
32
40
  };