agent-led-growth 0.7.0 → 0.8.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-led-growth",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Score: evals for agents as customers. Runs a real agent through signup, key and first call on your product, before you ship. Track, the analytics half, measures which agents convert after.",
5
5
  "author": {
6
6
  "name": "Kat Laszlo",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 (2026-10-23)
4
+
5
+ - `alg connect`: sets up Track's key without copying a secret by hand. It prints a code and opens the browser; once approved it writes `AGENT_TRAFFIC_KEY` (and `AGENT_TRAFFIC_PROJECT_ID`, not a secret) to `.env` or `--env-file`, replacing existing lines for those names and keeping the rest, and creates a new file with mode 0600. It warns when the file is not gitignored in a git repo and offers to add it. In a folder linked to Vercel, or with `--vercel`, it offers to run `vercel env add AGENT_TRAFFIC_KEY production` with the key on stdin. The key is never printed, logged or put on a command line. `--endpoint` or `AGENT_TRAFFIC_ENDPOINT` points it at another Track server, which is then written as `AGENT_TRAFFIC_ENDPOINT`.
6
+
3
7
  ## 0.7.0 (2026-10-23)
4
8
 
5
9
  - `test` no longer stops when `alg.yml` has no `verify_call` and the product has no OpenAPI document. At a terminal it asks for a GET that needs a key (and the header that carries it, default `Authorization: Bearer {key}`), checks the answer, saves it to `alg.yml` and continues the run; Enter with nothing skips the check for that run. Answering "n" to an inferred call asks the same way instead of cancelling. Before, it printed `verify_call_invalid` and left the person to edit the file and start over.
package/README.md CHANGED
@@ -104,6 +104,14 @@ Add agent traffic to this API: read https://alg.katrinalaszlo.com/SKILL.md and f
104
104
  Add agent traffic to this API: read https://alg.katrinalaszlo.com/SKILL.md and follow it.
105
105
  ```
106
106
 
107
+ To set up the key yourself, run this in your API's folder:
108
+
109
+ ```bash
110
+ npx agent-led-growth connect
111
+ ```
112
+
113
+ It prints a code and opens your browser; approve there, and it writes `AGENT_TRAFFIC_KEY` to `.env` (plus `AGENT_TRAFFIC_PROJECT_ID`, which is not a secret and is optional). Nothing to copy: the key is never printed, logged or put on a command line. If `.env` is not in `.gitignore` it says so and offers to add it. In a folder linked to Vercel (`.vercel/project.json`) it asks whether to also save the key in Vercel production, and passes it to `vercel env add` on stdin. Then restart your server and open `https://alg.katrinalaszlo.com/install` to watch the first event.
114
+
107
115
  ## Full command reference
108
116
 
109
117
  ### The three steps
@@ -142,7 +150,7 @@ Commit `alg.yml` so CI reuses your answers. `test` needs Claude Code installed a
142
150
 
143
151
  ### Installed
144
152
 
145
- `npm i -g agent-led-growth`, then `alg check …` and `alg test …`.
153
+ `npm i -g agent-led-growth`, then `alg check …`, `alg test …` and `alg connect`.
146
154
 
147
155
  ### Flags
148
156
 
@@ -169,6 +177,17 @@ Commit `alg.yml` so CI reuses your answers. `test` needs Claude Code installed a
169
177
  | `--inbox <address>` | An inbox you relay mail from, for products that email a code. Or set `AGENTMAIL_API_KEY` and each run gets its own. |
170
178
  | `--json` | `agent-ready/verify@1` (or `verify-plan@1` with `--check`) to stdout. |
171
179
 
180
+ `connect`
181
+
182
+ | Flag | Does |
183
+ | --- | --- |
184
+ | `--endpoint <url>` | Track server. Default `AGENT_TRAFFIC_ENDPOINT`, else `https://alg.katrinalaszlo.com`. |
185
+ | `--env-file <path>` | File to write the key to. Default `.env`. Existing lines for the same names are replaced; the rest of the file is kept. A new file is created readable only by you. |
186
+ | `--vercel` | Also save the key in Vercel production. Asked by default when `.vercel/project.json` exists; without a terminal, only this flag does it. |
187
+ | `--no-vercel` | Do not offer Vercel. |
188
+
189
+ `connect` exits `0` when the key is saved and `1` when the sign-in is denied or expires.
190
+
172
191
  Exit codes, both commands: `0` done or passed, `1` fixes found or test failed, `2` usage or setup, `3` the site did not answer or the run was inconclusive, `130` cancelled.
173
192
 
174
193
  ### Cost, accounts and data
package/bin/cli.js CHANGED
@@ -35,6 +35,7 @@ import { inferVerifyCall, latestInterface, latestAnswers } from "../src/verify/i
35
35
  import { askVerifyCall } from "../src/verify/wizard.js";
36
36
  import { renderPrompt } from "../src/audit/prompts.js";
37
37
  import { patternInfo } from "../src/audit/index.js";
38
+ import { connect, resolveEndpoint } from "../src/connect/index.js";
38
39
  import { createInterface } from "node:readline/promises";
39
40
  import { spawnSync } from "node:child_process";
40
41
 
@@ -469,6 +470,46 @@ Examples:
469
470
  process.exitCode = verdict.exitCode;
470
471
  });
471
472
 
473
+ program
474
+ .command("connect")
475
+ .description("Connect this app to Track: approve in the browser, and the key is written to .env for you (and to Vercel if you say yes). The key is never printed.")
476
+ .option("--endpoint <url>", "Track server (default: AGENT_TRAFFIC_ENDPOINT, else https://alg.katrinalaszlo.com)")
477
+ .option("--env-file <path>", "env file to write the key to", ".env")
478
+ .option("--vercel", "also save the key in Vercel production (asked by default when .vercel/project.json exists)")
479
+ .option("--no-vercel", "do not offer to save the key in Vercel")
480
+ .option("--no-color", "plain output")
481
+ .addHelpText("after", `
482
+ Examples:
483
+ alg connect sign in, write AGENT_TRAFFIC_KEY to .env
484
+ alg connect --env-file .env.local write it to another file
485
+ alg connect --no-vercel skip the Vercel question`)
486
+ .exitOverride(usageExit)
487
+ .action(async (opts) => {
488
+ const endpoint = resolveEndpoint(opts.endpoint, process.env);
489
+ if (!/^https?:\/\/[^/]+/.test(endpoint)) fail(opts, 2, "invalid_address", `"${endpoint}" is not a web address.`, "Try: alg connect --endpoint https://alg.katrinalaszlo.com");
490
+ const style = makeStyle(Boolean(process.stderr.isTTY) && opts.color && !process.env.NO_COLOR);
491
+ const ask = async (question) => {
492
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
493
+ const reply = await rl.question(question).catch((err) => {
494
+ if (err.code === "ABORT_ERR") return "n";
495
+ throw err;
496
+ });
497
+ rl.close();
498
+ return reply;
499
+ };
500
+ process.exitCode = await connect({
501
+ endpoint,
502
+ envFile: resolve(opts.envFile),
503
+ vercel: opts.vercel,
504
+ cwd: process.cwd(),
505
+ interactive: Boolean(process.stdin.isTTY),
506
+ sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
507
+ ask,
508
+ say: (line) => console.error(line),
509
+ style,
510
+ });
511
+ });
512
+
472
513
  // 0.3 kept a Tanso workspace for verify runs. check and test need no Tanso account now; these
473
514
  // commands stay so scripts written for 0.3 get a clear answer instead of "unknown command".
474
515
  for (const name of ["account", "login", "logout"]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-led-growth",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Score: evals for agents as customers. Runs a real agent through signup, key and first call on your product, before you ship. Track, the analytics half, measures which agents convert after.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "bin/cli.js",
16
16
  "src/index.js",
17
17
  "src/audit/",
18
+ "src/connect/",
18
19
  "src/dashboard/strip.js",
19
20
  "src/crash/",
20
21
  "src/harness/",
@@ -0,0 +1,209 @@
1
+ import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ // `alg connect`: a device login against Track. The person approves in the browser, and the key Track returns is
6
+ // written to the env file (and, if they say yes, to Vercel) without ever being printed, logged or put on a command
7
+ // line. Everything that touches the outside (fetch, child processes, the clock, the prompt) is passed in, so tests
8
+ // run it against a fake server.
9
+
10
+ export const DEFAULT_ENDPOINT = "https://alg.katrinalaszlo.com";
11
+
12
+ export function resolveEndpoint(flag, env) {
13
+ return (flag || env.AGENT_TRAFFIC_ENDPOINT || DEFAULT_ENDPOINT).replace(/\/+$/, "");
14
+ }
15
+
16
+ const defaultRun = (cmd, args, options = {}) => spawnSync(cmd, args, { encoding: "utf8", ...options });
17
+
18
+ async function postJson(fetchImpl, url, body) {
19
+ let res;
20
+ try {
21
+ res = await fetchImpl(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
22
+ } catch (err) {
23
+ throw new Error(`Could not reach ${url}: ${err.cause?.message || err.message}`);
24
+ }
25
+ // A proxy or a 5xx page can answer in HTML: only a JSON answer is read, and the status alone says what happened.
26
+ const isJson = (res.headers.get("content-type") || "").includes("json");
27
+ return { status: res.status, body: isJson ? await res.json() : null };
28
+ }
29
+
30
+ export async function startDeviceLogin({ endpoint, fetchImpl }) {
31
+ const res = await postJson(fetchImpl, `${endpoint}/v1/connect/start`, { client: "agent-led-growth" });
32
+ if (res.status !== 201 || !res.body?.device_code || !res.body?.user_code || !res.body?.verification_url) {
33
+ throw new Error(`${endpoint} did not start a sign-in (${res.status}${res.body?.error ? ` ${res.body.error}` : ""}).`);
34
+ }
35
+ return res.body;
36
+ }
37
+
38
+ // Polls until the person approves, denies, or the code expires. Returns { ok: true, result } or
39
+ // { ok: false, reason, message }.
40
+ export async function pollForKey({ endpoint, start, fetchImpl, sleep, now }) {
41
+ let intervalSeconds = Number(start.interval) || 5;
42
+ const deadline = now() + (Number(start.expires_in) || 600) * 1000;
43
+ const expired = { ok: false, reason: "expired", message: "The sign-in code expired before it was approved. Run alg connect again." };
44
+ for (;;) {
45
+ await sleep(intervalSeconds * 1000);
46
+ if (now() >= deadline) return expired;
47
+ const res = await postJson(fetchImpl, `${endpoint}/v1/connect/poll`, { device_code: start.device_code });
48
+ if (res.status === 202) continue;
49
+ if (res.status === 429) {
50
+ intervalSeconds += 2;
51
+ continue;
52
+ }
53
+ if (res.status === 200 && res.body?.key) return { ok: true, result: res.body };
54
+ if (res.status === 403) return { ok: false, reason: "denied", message: "The sign-in was denied in the browser. Nothing was saved." };
55
+ if (res.status === 410) return expired;
56
+ return { ok: false, reason: "unexpected", message: `${endpoint} answered ${res.status}${res.body?.error ? ` ${res.body.error}` : ""} while waiting for approval. Nothing was saved.` };
57
+ }
58
+ }
59
+
60
+ // Sets each name in values in the env file: an existing line for that name is replaced in place (a second one is
61
+ // dropped), every other line is kept as it is, and a missing name is added at the end. A new file is created
62
+ // readable only by its owner. Returns { created }.
63
+ export function writeEnvFile(path, values) {
64
+ const created = !existsSync(path);
65
+ const lines = created ? [] : readFileSync(path, "utf8").split("\n");
66
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
67
+ const written = new Set();
68
+ const out = [];
69
+ for (const line of lines) {
70
+ const name = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
71
+ if (name && name in values) {
72
+ if (written.has(name)) continue;
73
+ out.push(`${name}=${values[name]}`);
74
+ written.add(name);
75
+ continue;
76
+ }
77
+ out.push(line);
78
+ }
79
+ for (const [name, value] of Object.entries(values)) if (!written.has(name)) out.push(`${name}=${value}`);
80
+ const text = out.join("\n") + "\n";
81
+ if (created) writeFileSync(path, text, { mode: 0o600 });
82
+ else writeFileSync(path, text);
83
+ return { created };
84
+ }
85
+
86
+ // "ignored", "not_ignored", "no_repo" (not inside a git work tree) or "no_git" (git is not installed).
87
+ export function gitIgnoreStatus(path, run = defaultRun) {
88
+ const cwd = dirname(path);
89
+ const inside = run("git", ["rev-parse", "--is-inside-work-tree"], { cwd });
90
+ if (inside.error) return "no_git";
91
+ if (inside.status !== 0 || inside.stdout.trim() !== "true") return "no_repo";
92
+ const check = run("git", ["check-ignore", "-q", basename(path)], { cwd });
93
+ return check.status === 0 ? "ignored" : "not_ignored";
94
+ }
95
+
96
+ // Adds the file's name to the .gitignore next to it, creating that .gitignore if there is none.
97
+ export function addToGitignore(path) {
98
+ const gitignore = join(dirname(path), ".gitignore");
99
+ const before = existsSync(gitignore) ? readFileSync(gitignore, "utf8") : "";
100
+ appendFileSync(gitignore, `${before === "" || before.endsWith("\n") ? "" : "\n"}${basename(path)}\n`);
101
+ return gitignore;
102
+ }
103
+
104
+ export function openBrowser(url, run = defaultRun, platform = process.platform) {
105
+ if (platform === "darwin") return run("open", [url]);
106
+ if (platform === "win32") return run("cmd", ["/c", "start", '""', url.replace(/&/g, "^&")], { windowsVerbatimArguments: true });
107
+ return run("xdg-open", [url]);
108
+ }
109
+
110
+ const vercelByHand = "To add it yourself: run vercel env add AGENT_TRAFFIC_KEY production and paste the AGENT_TRAFFIC_KEY value from your env file when asked, or add it in the Vercel dashboard under Settings, Environment Variables.";
111
+
112
+ // The key goes to the child on stdin only. Child output is shown only on failure, with the key cut out.
113
+ export async function saveKeyInVercel({ key, ask, say, run = defaultRun, cwd }) {
114
+ const hide = (text) => String(text || "").split(key).join("[key]").trim();
115
+ const version = run("vercel", ["--version"], { cwd });
116
+ if (version.error) {
117
+ say(" The vercel command was not found, so the key was not saved in Vercel.");
118
+ say(` ${vercelByHand}`);
119
+ return "vercel_missing";
120
+ }
121
+ const list = run("vercel", ["env", "ls", "production"], { cwd });
122
+ if (list.status !== 0) {
123
+ say(` vercel env ls failed, so the key was not saved in Vercel: ${hide(list.stderr) || `exit ${list.status}`}`);
124
+ say(` ${vercelByHand}`);
125
+ return "failed";
126
+ }
127
+ if (/(^|\s)AGENT_TRAFFIC_KEY(\s|$)/m.test(list.stdout)) {
128
+ const reply = await ask(" AGENT_TRAFFIC_KEY is already set in Vercel production. Replace it? (Y/n) ");
129
+ if (!/^(y(es)?)?$/i.test(reply.trim())) {
130
+ say(" Kept the existing AGENT_TRAFFIC_KEY in Vercel production.");
131
+ return "kept";
132
+ }
133
+ const removed = run("vercel", ["env", "rm", "AGENT_TRAFFIC_KEY", "production", "--yes"], { cwd });
134
+ if (removed.status !== 0) {
135
+ say(` vercel env rm failed, so the key was not replaced in Vercel: ${hide(removed.stderr) || `exit ${removed.status}`}`);
136
+ say(` ${vercelByHand}`);
137
+ return "failed";
138
+ }
139
+ }
140
+ const added = run("vercel", ["env", "add", "AGENT_TRAFFIC_KEY", "production"], { cwd, input: key });
141
+ if (added.status !== 0) {
142
+ say(` vercel env add failed, so the key was not saved in Vercel: ${hide(added.stderr) || `exit ${added.status}`}`);
143
+ say(` ${vercelByHand}`);
144
+ return "failed";
145
+ }
146
+ say(" Saved AGENT_TRAFFIC_KEY in Vercel production. It takes effect on the next deploy.");
147
+ return "saved";
148
+ }
149
+
150
+ // Runs the whole flow and returns the exit code: 0 connected, 1 denied, expired or failed.
151
+ export async function connect({ endpoint, envFile, vercel, cwd, interactive, fetchImpl = fetch, sleep, now = Date.now, run = defaultRun, ask, say, style }) {
152
+ const bold = style?.bold || ((s) => s);
153
+ const dim = style?.dim || ((s) => s);
154
+ say("");
155
+ say(` ${bold("alg connect")} · ${new URL(endpoint).host}`);
156
+ const start = await startDeviceLogin({ endpoint, fetchImpl });
157
+ say("");
158
+ say(` Your code: ${bold(start.user_code)}`);
159
+ const opened = openBrowser(start.verification_url, run);
160
+ if (opened.error || opened.status) say(` Open ${start.verification_url} in your browser.`);
161
+ else say(` Opened ${start.verification_url} in your browser.`);
162
+ say(` Check the code there matches, then approve. ${dim("Waiting…")}`);
163
+
164
+ const polled = await pollForKey({ endpoint, start, fetchImpl, sleep, now });
165
+ if (!polled.ok) {
166
+ say("");
167
+ say(` ${polled.message}`);
168
+ return 1;
169
+ }
170
+ const { key, project_id: projectId, project_name: projectName } = polled.result;
171
+ const trackEndpoint = (polled.result.endpoint || endpoint).replace(/\/+$/, "");
172
+
173
+ const values = { AGENT_TRAFFIC_KEY: key };
174
+ if (projectId) values.AGENT_TRAFFIC_PROJECT_ID = projectId;
175
+ // The middleware sends to the default host unless told otherwise.
176
+ if (trackEndpoint !== DEFAULT_ENDPOINT) values.AGENT_TRAFFIC_ENDPOINT = trackEndpoint;
177
+ const { created } = writeEnvFile(envFile, values);
178
+ const shown = relative(cwd, envFile).startsWith("..") ? envFile : relative(cwd, envFile);
179
+ say("");
180
+ say(` Connected to ${bold(projectName || projectId || "your Track project")}.`);
181
+ say(` Saved ${Object.keys(values).join(", ")} to ${shown}${created ? " (new file, readable only by you)" : ""}.`);
182
+
183
+ const ignored = gitIgnoreStatus(envFile, run);
184
+ if (ignored === "not_ignored") {
185
+ say(` ${shown} is not in .gitignore, so git would commit the key.`);
186
+ const reply = interactive ? await ask(` Add ${basename(envFile)} to .gitignore? (Y/n) `) : "n";
187
+ if (/^(y(es)?)?$/i.test(reply.trim())) {
188
+ const gitignore = addToGitignore(envFile);
189
+ say(` Added ${basename(envFile)} to ${relative(cwd, gitignore).startsWith("..") ? gitignore : relative(cwd, gitignore)}.`);
190
+ } else say(` Add ${basename(envFile)} to .gitignore before you commit.`);
191
+ } else if (ignored === "no_git") say(` ${dim("git was not found, so .gitignore was not checked.")}`);
192
+
193
+ const linked = existsSync(join(cwd, ".vercel", "project.json"));
194
+ if (vercel === true || (vercel !== false && linked)) {
195
+ say("");
196
+ if (interactive) {
197
+ const reply = await ask(" Also save the key in Vercel production? (Y/n) ");
198
+ if (/^(y(es)?)?$/i.test(reply.trim())) await saveKeyInVercel({ key, ask, say, run, cwd });
199
+ else say(" Not saved in Vercel.");
200
+ } else if (vercel === true) {
201
+ await saveKeyInVercel({ key, ask: async () => "y", say, run, cwd });
202
+ } else say(` ${dim("This folder is linked to Vercel. Run alg connect --vercel to save the key there too.")}`);
203
+ }
204
+
205
+ say("");
206
+ say(` Next: restart your server, then open ${trackEndpoint}/install to watch the first event.`);
207
+ say("");
208
+ return 0;
209
+ }