@flow-engineer/flow 0.2.1 → 0.2.3

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.
Files changed (70) hide show
  1. package/README.md +167 -10
  2. package/coding-agents/auth-skill.md +137 -0
  3. package/coding-agents/context-skill.md +94 -0
  4. package/coding-agents/skill.md +219 -17
  5. package/coding-agents/test-skill.md +3 -1
  6. package/coding-agents/tools-skill.md +78 -1
  7. package/dist/cli/src/auth.d.ts +11 -0
  8. package/dist/cli/src/auth.js +147 -0
  9. package/dist/cli/src/commands.d.ts +119 -0
  10. package/dist/cli/src/commands.js +212 -0
  11. package/dist/cli/src/index.d.ts +2 -0
  12. package/dist/cli/src/index.js +5 -0
  13. package/dist/cli/src/run.d.ts +28 -0
  14. package/dist/cli/src/run.js +523 -0
  15. package/dist/cli/src/terminal.d.ts +3 -0
  16. package/dist/cli/src/terminal.js +135 -0
  17. package/dist/flow-messaging/src/client.d.ts +2 -2
  18. package/dist/flow-messaging/src/client.js +3 -3
  19. package/dist/flow-messaging/src/index.js +17 -9
  20. package/dist/mcp/src/client.d.ts +54 -0
  21. package/dist/mcp/src/client.js +175 -0
  22. package/dist/mcp/src/describe.d.ts +41 -0
  23. package/dist/mcp/src/describe.js +110 -0
  24. package/dist/mcp/src/index.d.ts +4 -0
  25. package/dist/mcp/src/index.js +7 -0
  26. package/dist/mcp/src/server.d.ts +70 -0
  27. package/dist/mcp/src/server.js +252 -0
  28. package/dist/mcp/src/terminal.d.ts +24 -0
  29. package/dist/mcp/src/terminal.js +54 -0
  30. package/dist/simulators/src/index.d.ts +1 -1
  31. package/dist/simulators/src/page.html +267 -49
  32. package/dist/simulators/src/server.d.ts +27 -0
  33. package/dist/simulators/src/server.js +264 -40
  34. package/dist/src/agent.d.ts +69 -3
  35. package/dist/src/agent.js +162 -8
  36. package/dist/src/channels.d.ts +6 -4
  37. package/dist/src/channels.js +4 -3
  38. package/dist/src/conversation.d.ts +17 -1
  39. package/dist/src/conversation.js +170 -16
  40. package/dist/src/events.d.ts +1 -1
  41. package/dist/src/index.d.ts +2 -2
  42. package/dist/src/index.js +1 -1
  43. package/dist/src/model.d.ts +27 -0
  44. package/dist/src/model.js +85 -13
  45. package/dist/src/skills.d.ts +4 -0
  46. package/dist/src/skills.js +23 -0
  47. package/dist/src/store.d.ts +2 -0
  48. package/dist/src/store.js +18 -0
  49. package/dist/src/tools.d.ts +69 -0
  50. package/dist/src/tools.js +17 -1
  51. package/dist/src/traces.js +32 -4
  52. package/dist/src/types.d.ts +5 -0
  53. package/dist/testing/src/account.d.ts +1 -1
  54. package/dist/testing/src/account.js +368 -57
  55. package/dist/testing/src/chat.js +24 -16
  56. package/dist/testing/src/check.d.ts +4 -0
  57. package/dist/testing/src/check.js +90 -0
  58. package/dist/testing/src/cli.js +77 -19
  59. package/dist/testing/src/flow-login.d.ts +33 -0
  60. package/dist/testing/src/flow-login.js +30 -5
  61. package/dist/testing/src/new.d.ts +14 -5
  62. package/dist/testing/src/new.js +54 -20
  63. package/dist/testing/src/plugin.d.ts +4 -0
  64. package/dist/testing/src/plugin.js +116 -0
  65. package/docs/agents.md +87 -12
  66. package/examples/cafe/src/cli.ts +24 -0
  67. package/examples/cafe/src/mcp.ts +22 -0
  68. package/examples/cafe/src/tools.ts +13 -0
  69. package/flow-messaging/README.md +3 -1
  70. package/package.json +28 -1
@@ -18,22 +18,31 @@ import { hostname } from "node:os";
18
18
  import { basename, join } from "node:path";
19
19
  import { parseArgs } from "node:util";
20
20
  import { setTimeout as delay } from "node:timers/promises";
21
- import { currentToken, deleteCredentials, deletePending, LoginRefused, loginUrl, pollToken, readPending, REQUEST_TIMEOUT_MS, revoke, saveCredentials, savePending, SETUP_ENV, setup, startDevice, TimedOut, tryLink, warningsOf, whoami, } from "./flow-login.js";
21
+ import { currentToken, deleteCredentials, deletePending, LoginRefused, loginUrl, pollToken, readPending, REQUEST_TIMEOUT_MS, revoke, saveCredentials, savePending, modelCredit, SETUP_ENV, setup, startDevice, TimedOut, tryLink, warningsOf, whoami, } from "./flow-login.js";
22
22
  import { packageRoot } from "./new.js";
23
+ import { flowModelURL, hasModelKey, MODEL_KEY_NAMES, NO_MODEL_KEY } from "../../src/model.js";
23
24
  import { RUN } from "../../src/names.js";
24
- export const ACCOUNT_COMMANDS = new Set(["login", "logout", "whoami", "setup", "link"]);
25
+ export const ACCOUNT_COMMANDS = new Set(["login", "logout", "whoami", "setup", "link", "status"]);
25
26
  export const ACCOUNT_LINES = ` flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]
26
27
  Sign in to flow.engineer: opens the browser, prints the link and code,
27
28
  and waits up to --timeout (default 120) for approval; --wait goes on
28
29
  waiting for the same code, --no-wait returns at once
29
30
  flow logout [--json] End this machine's sign-in and delete its token
30
31
  flow whoami [--json] Show the account you are signed in as
31
- flow setup [--replace] [--ci] [--json]
32
- Write this agent's keys (test cloud, traces, Flow Messaging) and each
33
- open channel's sender into ./.env (in an agent's folder: package.json)
32
+ flow setup [--replace] [--rotate <keys> [--now]] [--ci] [--json]
33
+ Write this agent's keys (its Flow key FLOW_KEY, which also pays for the
34
+ model on the account's trial credit when there is no provider key, test
35
+ cloud, traces, Flow Messaging) and each open channel's sender into
36
+ ./.env (in an agent's folder: package.json); --rotate key,cloud,traces,
37
+ messaging or all makes those keys again (the old FLOW_KEY works 24 h
38
+ more; --now ends it at once)
34
39
  flow link [--channel telegram|whatsapp|imessage] [--timeout <s>] [--no-browser] [--json]
35
40
  Print the link that opens a chat with this agent from a phone; signs in
36
- and runs setup first when this folder is not set up yet`;
41
+ and runs setup first when this folder is not set up yet
42
+ flow status [--json] What this folder has and lacks: FLOW_KEY and the model key (never their
43
+ values) with the trial credit left, the
44
+ sign-in, each channel's sender in .env, and, signed in, which channels
45
+ are open now and their free-trial counts`;
37
46
  const USAGE = `Usage:\n${ACCOUNT_LINES}
38
47
 
39
48
  The token is kept in ~/.config/flow-framework/credentials.json (mode 0600); FLOW_FRAMEWORK_TOKEN, when
@@ -86,7 +95,7 @@ export async function accountCli(command, args) {
86
95
  force: { type: "boolean", default: false },
87
96
  }
88
97
  : {}),
89
- ...(command === "setup" ? { replace: { type: "boolean", default: false }, ci: { type: "boolean", default: false } } : {}),
98
+ ...(command === "setup" ? { replace: { type: "boolean", default: false }, rotate: { type: "string" }, now: { type: "boolean", default: false }, ci: { type: "boolean", default: false } } : {}),
90
99
  ...(command === "link" ? { channel: { type: "string", default: "telegram" } } : {}),
91
100
  },
92
101
  }));
@@ -109,6 +118,15 @@ export async function accountCli(command, args) {
109
118
  console.error(`flow link: --channel takes ${CHANNELS.join(", ")}.\n\n${USAGE}`);
110
119
  return EXIT.usage;
111
120
  }
121
+ const rotate = values.rotate === undefined ? [] : String(values.rotate).split(",").map((k) => k.trim());
122
+ if (rotate.some((k) => !(k in ROTATE)) || (rotate.includes("all") && rotate.length > 1)) {
123
+ console.error(`flow setup: --rotate takes ${Object.keys(ROTATE).filter((k) => k !== "all").join(", ")} (comma-separated), or all.\n\n${USAGE}`);
124
+ return EXIT.usage;
125
+ }
126
+ if (values.now === true && !rotate.includes("key") && !rotate.includes("all")) {
127
+ console.error(`flow setup: --now goes with --rotate key (or all): it ends the old FLOW_KEY at once.\n\n${USAGE}`);
128
+ return EXIT.usage;
129
+ }
112
130
  const json = values.json === true;
113
131
  // Straight to the file descriptors, never buffered: a coding agent's cut-off command keeps every line.
114
132
  const out = (report) => writeAll(1, `${JSON.stringify(report)}\n`);
@@ -121,8 +139,10 @@ export async function accountCli(command, args) {
121
139
  await logout(json, out, say);
122
140
  else if (command === "whoami")
123
141
  await showAccount(json, out, say);
142
+ else if (command === "status")
143
+ await status(json, out, say);
124
144
  else if (command === "setup")
125
- await setupEnv(values.replace === true, values.ci === true, json, out, say);
145
+ await setupEnv({ replace: values.replace === true, rotate, now: values.now === true, ci: values.ci === true }, json, out, say);
126
146
  else
127
147
  await link({ channel, noBrowser: values["no-browser"] === true, timeoutSeconds }, json, out, say);
128
148
  return EXIT.ok;
@@ -362,9 +382,11 @@ async function showAccount(json, out, say) {
362
382
  out(account);
363
383
  say(`Signed in to flow.engineer as ${account.email} (account ${account.account}).`);
364
384
  }
365
- const SECRET = new Set([SETUP_ENV.cloud_key, SETUP_ENV.traces_key, SETUP_ENV.messaging_key]);
385
+ const SECRET = new Set([SETUP_ENV.cloud_key, SETUP_ENV.traces_key, SETUP_ENV.messaging_key, ...MODEL_KEY_NAMES]);
366
386
  const masked = (name, value) => (SECRET.has(name) ? `${/^[a-z]+_(?:test_|live_)?/.exec(value)?.[0] ?? ""}…${value.slice(-4)}` : value);
367
387
  const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/;
388
+ /** `--rotate`'s names and the answer field each makes again; `all` is every key. */
389
+ const ROTATE = { key: "flow_key", cloud: "cloud_key", traces: "traces_key", messaging: "messaging_key", all: "" };
368
390
  /** The names set in a .env's lines, with their values unquoted. */
369
391
  function envValues(lines) {
370
392
  const values = new Map();
@@ -389,8 +411,76 @@ function agentFolder(dir) {
389
411
  return;
390
412
  throw new Stop("not_an_agent_folder", `${dir} has no package.json, so it is not an agent's folder; its keys would land in the wrong .env.`, `cd into your agent's folder, or make an agent here first: ${RUN} init`, EXIT.usage);
391
413
  }
392
- async function setupEnv(replace, ci, json, out, say, forLink) {
393
- if (ci) {
414
+ /** The agent's one Flow key; for the model it is last in the model keys' order, so a provider key the owner sets wins. */
415
+ const FLOW_KEY = MODEL_KEY_NAMES.at(-1);
416
+ const USED_UP = "This account's Flow trial credit is used up.";
417
+ const NO_CREDIT = `Flow has no trial model credit for this account, so ${FLOW_KEY} cannot pay for the model.`;
418
+ const OWN_KEY = "add your own model key to .env";
419
+ /** setup's model_credit or GET /credit's answer as FlowCredit: its numbers only. */
420
+ function creditOf(from) {
421
+ if (!from)
422
+ return null;
423
+ const credit = {};
424
+ for (const field of ["limit_usd", "left_usd", "spent_usd"]) {
425
+ const n = from[field];
426
+ if (typeof n === "number" && Number.isFinite(n))
427
+ credit[field] = n;
428
+ }
429
+ return credit;
430
+ }
431
+ const dollars = (n) => `$${n.toFixed(2)}`;
432
+ /**
433
+ * The model key's state: `line` to print, and with no key the step that gets one, which goes first
434
+ * in the run's one `next`. `credit`: Flow's credit from setup's answer or GET /credit. `afterSetup`:
435
+ * setup ran just now, so its answer says why there is none.
436
+ */
437
+ function modelKeyReport(value, credit, afterSetup) {
438
+ const env = MODEL_KEY_NAMES.find((name) => value(name)) ?? null;
439
+ const flowCredit = creditOf(credit);
440
+ const usedUp = flowCredit?.left_usd !== undefined && flowCredit.left_usd <= 0;
441
+ if (env && env !== FLOW_KEY)
442
+ return { report: { set: true, env, flow_credit: null }, line: `Model: ${env} is set` };
443
+ if (env) {
444
+ if (usedUp)
445
+ return { report: { set: true, env, flow_credit: flowCredit, message: USED_UP }, line: USED_UP, next: OWN_KEY };
446
+ // Setup just ran and gave no credit (warnings say why): FLOW_KEY is there, but cannot pay for the model.
447
+ if (afterSetup && !flowCredit)
448
+ return { report: { set: true, env, flow_credit: null, message: NO_CREDIT }, line: NO_CREDIT, next: OWN_KEY };
449
+ const left = flowCredit?.left_usd !== undefined ? ` (${dollars(flowCredit.left_usd)} left)` : "";
450
+ return { report: { set: true, env, flow_credit: flowCredit }, line: `Model: Flow trial credit${left}` };
451
+ }
452
+ // No key, and the account's credit is used up: setup made none.
453
+ if (usedUp)
454
+ return { report: { set: false, env: null, flow_credit: flowCredit, message: USED_UP }, line: USED_UP, next: OWN_KEY };
455
+ const report = { set: false, env: null, flow_credit: null, message: NO_MODEL_KEY.message };
456
+ // Setup ran and flow.engineer gave no FLOW_KEY (an older server, or none for this account now).
457
+ if (afterSetup)
458
+ return { report, line: NO_MODEL_KEY.message, next: `${OWN_KEY} (flow.engineer gave no trial credit)` };
459
+ return { report, line: NO_MODEL_KEY.message, next: NO_MODEL_KEY.nextLine.replace(/^Next: /, "").replace(/\.$/, "") };
460
+ }
461
+ /** What people call this folder's agent (package.json displayName, from `flow init`), else its package name in words. */
462
+ function agentDisplayName(dir = process.cwd()) {
463
+ const file = join(dir, "package.json");
464
+ const pkg = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
465
+ const named = typeof pkg.displayName === "string" ? pkg.displayName.trim() : "";
466
+ return named || (pkg.name ?? basename(dir)).replace(/^@[^/]+\//, "").replace(/-/g, " ");
467
+ }
468
+ /** The /try page with the agent's name (`n`), so it fills in "Hi <name> (ref <code>)". */
469
+ function namedPage(url, name) {
470
+ const parsed = URL.parse(url);
471
+ if (!parsed)
472
+ return url;
473
+ parsed.searchParams.set("n", name);
474
+ return parsed.toString();
475
+ }
476
+ /** How to use each channel's direct link. */
477
+ const OPEN_HINT = {
478
+ telegram: "Open it and press Start.",
479
+ whatsapp: "Open it and send the message it fills in.",
480
+ imessage: "Open it on an iPhone or a Mac and send the message it fills in.",
481
+ };
482
+ async function setupEnv(options, json, out, say, forLink) {
483
+ if (options.ci) {
394
484
  const steps = [
395
485
  `CI never signs in. It runs the tests with the agent's test cloud key, ${SETUP_ENV.cloud_key}, as a secret.`,
396
486
  `Run ${NEXT.setup} on your machine first, then copy the key from .env into your CI without printing it, for GitHub:`,
@@ -401,7 +491,7 @@ async function setupEnv(replace, ci, json, out, say, forLink) {
401
491
  out({ ci: { secret: SETUP_ENV.cloud_key, steps } });
402
492
  for (const line of steps)
403
493
  say(line);
404
- return;
494
+ return { answer: {} };
405
495
  }
406
496
  const dir = process.cwd();
407
497
  agentFolder(dir);
@@ -415,24 +505,39 @@ async function setupEnv(replace, ci, json, out, say, forLink) {
415
505
  const named = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name ?? basename(dir);
416
506
  // The server takes 1 to 64 letters, digits, spaces, -, _ and .: a package's scope and other characters go.
417
507
  const name = named.replace(/^@[^/]*\//, "").replace(/[^A-Za-z0-9 ._-]/g, "-").slice(0, 64) || "my-agent";
418
- // An empty value (`cp .env.example .env`) is missing: the server mints it.
419
- const have = Object.values(SETUP_ENV).filter((n) => n !== SETUP_ENV.try_url && before.get(n));
508
+ const all = options.rotate.includes("all");
509
+ const rotated = new Set(all ? Object.values(ROTATE).filter(Boolean) : options.rotate.map((k) => ROTATE[k]));
510
+ const rotatedEnv = new Set([...rotated].map((field) => (field === "flow_key" ? FLOW_KEY : SETUP_ENV[field])));
511
+ // An empty value (`cp .env.example .env`) is missing: the server mints it. A model key counts in
512
+ // the shell too (npm start reads both): FLOW_KEY is made only when it is not named as had, and
513
+ // the trial model credit is given only when no provider key is.
514
+ const have = [
515
+ ...Object.values(SETUP_ENV).filter((n) => n !== SETUP_ENV.try_url && before.get(n)),
516
+ ...MODEL_KEY_NAMES.filter((n) => before.get(n) || process.env[n]?.trim()),
517
+ ].filter((n) => !rotatedEnv.has(n));
518
+ // Dropping a key's name from `have` makes it again; `rotate` names them too, so the server replaces
519
+ // (and, for FLOW_KEY, ends) the old one rather than adding a second.
520
+ const rotate = all ? true : rotated.size ? [...rotated] : undefined;
521
+ const rotateNow = options.now === true && rotated.has("flow_key");
420
522
  let answer;
421
523
  try {
422
524
  // Every channel: each comes back with its shared sender only while it is open.
423
- answer = await setup(url, token, { name, hostname: hostname(), channels: [...CHANNELS], have });
525
+ answer = await setup(url, token, { name, hostname: hostname(), channels: [...CHANNELS], have, ...(rotate ? { rotate } : {}), ...(rotateNow ? { rotate_now: true } : {}) });
424
526
  }
425
527
  catch (error) {
426
528
  // The keys minted anyway are not shown again, so whatever the error carries goes into .env first.
427
529
  if (error instanceof LoginRefused) {
428
- const written = writeEnv(file, lines, before, error.fields, replace);
530
+ const written = writeEnv(file, lines, before, envEntries(error.fields), (n) => options.replace || rotatedEnv.has(n));
429
531
  if (written.some((w) => w.action === "added" || w.action === "replaced"))
430
532
  say(`Kept in .env before the error: ${written.map((w) => `${w.name} ${w.action}`).join(", ")}.`);
431
533
  }
432
534
  throw error;
433
535
  }
434
- const warnings = warningsOf(answer);
435
- const written = writeEnv(file, lines, before, answer, replace);
536
+ const fields = answer;
537
+ const warnings = warningsOf(fields);
538
+ const had = new Map(before);
539
+ // --replace takes every new value but a model key: only --rotate key replaces FLOW_KEY.
540
+ const written = writeEnv(file, lines, before, envEntries(fields), (n) => rotatedEnv.has(n) || (options.replace && !MODEL_KEY_NAMES.includes(n)));
436
541
  if (typeof answer.join_code !== "string" || !answer.join_code) {
437
542
  const kept = written.filter((w) => w.action === "added" || w.action === "replaced").map((w) => w.name);
438
543
  const stop = new Stop("no_join_code", `flow.engineer gave no join code${kept.length ? ` (the keys it made are in .env: ${kept.join(", ")})` : ""}, so there is no phone link yet.`, answer.next?.[0] ?? NEXT.setup, EXIT.refused);
@@ -440,36 +545,87 @@ async function setupEnv(replace, ci, json, out, say, forLink) {
440
545
  throw stop;
441
546
  }
442
547
  printWarnings(warnings);
443
- const open = CHANNELS.filter((c) => typeof answer[`${c}_sender`] === "string");
444
- const links = Object.fromEntries(open.map((c) => [c, answer.try_urls?.[c] ?? (c === "telegram" && answer.try_url ? answer.try_url : tryLink(url, answer.join_code, c))]));
445
- const next = [`${RUN} test --synthetic`, "npm start", ...(answer.next?.length ? answer.next : [NEXT.link])];
548
+ const open = CHANNELS.filter((c) => typeof fields[`${c}_sender`] === "string");
549
+ const displayName = agentDisplayName(dir);
550
+ const links = Object.fromEntries(open.map((c) => [c, namedPage(answer.try_urls?.[c] ?? (c === "telegram" && answer.try_url ? answer.try_url : tryLink(url, answer.join_code, c)), displayName)]));
551
+ // Said by link instead, which asks for its own; a page that does not answer leaves only the page.
552
+ const direct = {};
553
+ if (!forLink) {
554
+ for (const c of open) {
555
+ const found = await directLink(links[c]).catch((error) => (error instanceof Stop ? undefined : Promise.reject(error)));
556
+ if (found)
557
+ direct[c] = found;
558
+ }
559
+ }
560
+ // A closed channel's trial counts are not shown: only the open ones'.
561
+ const trial = answer.trial ? Object.fromEntries(Object.entries(answer.trial).filter(([c]) => open.includes(c))) : null;
562
+ const model = modelKeyReport((n) => before.get(n) || process.env[n]?.trim() || undefined, objectField(fields, "model_credit"), true);
563
+ const flowKey = objectField(fields, "flow_key");
564
+ // A rotated FLOW_KEY: when the one it replaced stops working.
565
+ const previous = rotated.has("flow_key") && flowKey?.minted
566
+ ? flowKey.previous_expires_at ? `The old ${FLOW_KEY} works until ${flowKey.previous_expires_at}.` : rotateNow ? `The old ${FLOW_KEY} stopped working now.` : undefined
567
+ : undefined;
568
+ // A first run's order: a model key if there is none, the playground (npm start), then the phone.
569
+ // chat and test --synthetic are the coding agent's own checks, not the owner's way to test.
570
+ const next = [...(model.next ? [model.next] : []), "npm start", ...(answer.next?.length ? answer.next : [NEXT.link])];
446
571
  if (json) {
447
572
  out({
448
573
  agent: answer.agent_id, join_code: answer.join_code, env_written: { ".env": written }, minted: answer.minted ?? [],
449
574
  dashboard_url: answer.dashboard_url ?? null, try_url: answer.try_url ?? null, telegram_link: answer.telegram_link ?? null,
450
- whatsapp_link: answer.whatsapp_link ?? null, imessage_link: answer.imessage_link ?? null, try_urls: links, trial: answer.trial ?? null, next, warnings,
575
+ whatsapp_link: answer.whatsapp_link ?? null, imessage_link: answer.imessage_link ?? null, try_urls: links,
576
+ direct_links: Object.fromEntries(Object.entries(direct).map(([c, d]) => [c, d.link])), join_message: Object.values(direct)[0]?.message ?? null,
577
+ trial, model_key: model.report,
578
+ // FLOW_KEY's state, never its value: made in this call, with its id, mode and scopes.
579
+ flow_key: { set: Boolean(before.get(FLOW_KEY) || process.env[FLOW_KEY]?.trim()), minted: flowKey?.minted === true,
580
+ ...(flowKey?.minted ? { kid: flowKey.kid ?? null, mode: flowKey.mode ?? null, scopes: flowKey.scopes ?? [], previous_expires_at: flowKey.previous_expires_at ?? null } : {}) },
581
+ // link's own line (or its error) has the run's next steps: one list per run.
582
+ ...(forLink ? {} : { next }), warnings,
451
583
  });
452
584
  }
453
585
  say(`Agent ${answer.agent_id}. In .env:`);
454
586
  for (const w of written)
455
- say(` ${w.name}=${w.value} ${w.action}${w.action === "kept" ? " (yours differs; --replace takes the new one)" : ""}`);
587
+ say(` ${w.name}=${w.value} ${w.action}${w.action === "kept" ? (w.name === FLOW_KEY ? " (--rotate key makes a new one)" : " (yours differs; --replace takes the new one)") : ""}`);
588
+ if (previous)
589
+ say(previous);
456
590
  if (answer.dashboard_url)
457
591
  say(`Its dashboard: ${answer.dashboard_url}`);
458
- const people = answer.trial?.imessage?.contacts;
592
+ // Said by link instead, once, with its link.
593
+ if (!forLink)
594
+ say(model.line);
595
+ const people = trial?.imessage?.contacts;
459
596
  if (people && typeof people.used === "number" && typeof people.limit === "number")
460
597
  say(`iMessage: ${people.used} of ${people.limit} people`);
461
598
  if (!forLink) {
462
599
  for (const c of open)
463
- say(`On ${CHANNEL_NAME[c]}: ${links[c]}${trialNote(answer.trial?.[c])}`);
600
+ say(`On ${CHANNEL_NAME[c]}: ${direct[c] ? `${direct[c].link} (on a computer: ${links[c]})` : links[c]}${trialNote(trial?.[c])}`);
601
+ const added = addedToRunning((n) => had.get(n) || process.env[n]?.trim(), (n) => before.get(n) || process.env[n]?.trim());
602
+ if (added)
603
+ say(added);
464
604
  say(`Next: ${next.join(", then ")}`);
465
605
  }
606
+ return { answer };
466
607
  }
467
608
  const trialNote = (t) => (t && typeof t.limit === "number" ? ` (free trial: ${t.used} of ${t.limit} messages used)` : "");
468
- /** Merges setup's fields into .env's lines and writes the file (mode 0600) when anything changed. */
469
- function writeEnv(file, lines, before, fields, replace) {
609
+ /** An answer's field when it is an object (flow_key, model_credit), else undefined (an older server sends none). */
610
+ function objectField(fields, name) {
611
+ const value = fields[name];
612
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
613
+ }
614
+ /**
615
+ * The .env lines an answer gives: SETUP_ENV's fields, then the agent's Flow key as FLOW_KEY (no
616
+ * other name: a provider key the owner typed is never written over, and wins for the model).
617
+ */
618
+ function envEntries(fields) {
619
+ const entries = Object.entries(SETUP_ENV).map(([field, envName]) => [envName, fields[field]]);
620
+ const key = objectField(fields, "flow_key");
621
+ if ((key?.env ?? FLOW_KEY) === FLOW_KEY && typeof key?.value === "string")
622
+ entries.push([FLOW_KEY, key.value]);
623
+ return entries;
624
+ }
625
+ /** Merges the entries into .env's lines and writes the file (mode 0600) when anything changed; `replace(name)` says when a value there gives way. */
626
+ function writeEnv(file, lines, before, entries, replace) {
470
627
  const written = [];
471
- for (const [field, envName] of Object.entries(SETUP_ENV)) {
472
- const value = fields[field];
628
+ for (const [envName, value] of entries) {
473
629
  if (typeof value !== "string" || !value)
474
630
  continue;
475
631
  const old = before.get(envName);
@@ -485,7 +641,7 @@ function writeEnv(file, lines, before, fields, replace) {
485
641
  }
486
642
  else if (old === value)
487
643
  action = "unchanged";
488
- else if (!replace)
644
+ else if (!replace(envName))
489
645
  action = "kept";
490
646
  else {
491
647
  lines[at] = `${envName}=${value}`;
@@ -500,25 +656,123 @@ function writeEnv(file, lines, before, fields, replace) {
500
656
  chmodSync(file, 0o600); // a .env that was there keeps its old mode on write
501
657
  return written;
502
658
  }
659
+ /**
660
+ * What a running agent (npm start reads .env once, when it starts) lacks after .env changed from
661
+ * `had` to `now`: the channels it is not on yet and a model key, as one sentence; undefined when
662
+ * nothing it uses is new.
663
+ */
664
+ function addedToRunning(had, now) {
665
+ // The starter is on Telegram whenever it has the Flow Messaging key, and on WhatsApp and iMessage
666
+ // when .env names their senders; without the key it was on no channel at all.
667
+ const on = (env, c) => Boolean(env(SETUP_ENV.messaging_key) && (c === "telegram" || env(SENDER_ENV[c])));
668
+ const channels = CHANNELS.filter((c) => on(now, c) && !on(had, c)).map((c) => CHANNEL_NAME[c]);
669
+ const model = !MODEL_KEY_NAMES.some((n) => had(n)) && MODEL_KEY_NAMES.some((n) => now(n)) ? ["the model key"] : [];
670
+ const what = [...channels, ...model];
671
+ if (!what.length)
672
+ return undefined;
673
+ return `If npm start is running, restart it to add ${what.length === 1 ? what[0] : `${what.slice(0, -1).join(", ")} and ${what.at(-1)}`}.`;
674
+ }
675
+ /**
676
+ * `flow status`: what this folder has and lacks, read from .env and the environment, and, when
677
+ * signed in and set up, flow.engineer's answer for which channels are open now and their trial
678
+ * counts. It changes nothing: setup is asked with every key named as had, so nothing is made, and
679
+ * its answer is not written. Not signed in, or flow.engineer unreachable: that part is left out.
680
+ */
681
+ async function status(json, out, say) {
682
+ const value = dotenvValue();
683
+ const model = await flowModelStatus(value);
684
+ const joinCode = value(SETUP_ENV.join_code) ?? null;
685
+ let account = null;
686
+ let server = null;
687
+ const current = currentToken();
688
+ const url = loginUrl();
689
+ if (current && (!current.url || current.url === url)) {
690
+ try {
691
+ account = await whoami(url, current.token);
692
+ if (joinCode && existsSync(join(process.cwd(), "package.json"))) {
693
+ const named = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")).name ?? basename(process.cwd());
694
+ const name = named.replace(/^@[^/]*\//, "").replace(/[^A-Za-z0-9 ._-]/g, "-").slice(0, 64) || "my-agent";
695
+ const have = [...Object.values(SETUP_ENV).filter((n) => n !== SETUP_ENV.try_url), ...MODEL_KEY_NAMES];
696
+ server = await setup(url, current.token, { name, hostname: hostname(), channels: [...CHANNELS], have });
697
+ }
698
+ }
699
+ catch {
700
+ // Signed out on the server, or not reachable: status says what it can from here.
701
+ }
702
+ }
703
+ const fields = (server ?? {});
704
+ const channels = Object.fromEntries(CHANNELS.map((c) => [c, {
705
+ sender: Boolean(c === "telegram" ? value(SETUP_ENV.messaging_key) : value(SENDER_ENV[c])),
706
+ open: server ? typeof fields[`${c}_sender`] === "string" : null,
707
+ trial: server && typeof fields[`${c}_sender`] === "string" ? server.trial?.[c] ?? null : null,
708
+ }]));
709
+ // The playground (npm start) before the phone, as setup's next.
710
+ const next = [
711
+ ...(model.next ? [model.next] : []),
712
+ ...(model.report.set && !model.next ? ["npm start"] : []),
713
+ ...(!joinCode || !value(SETUP_ENV.messaging_key) ? [NEXT.link] : CHANNELS.filter((c) => channels[c].open && !channels[c].sender).map((c) => `${NEXT.link} --channel ${c}`)),
714
+ ];
715
+ if (json)
716
+ out({ model_key: model.report, flow_key: { set: Boolean(value(FLOW_KEY)) }, signed_in: account ? { email: account.email, account: account.account } : false, join_code: joinCode, messaging_key: Boolean(value(SETUP_ENV.messaging_key)), channels, next });
717
+ if (model.report.env && model.report.env !== FLOW_KEY)
718
+ say(value(FLOW_KEY) ? `${FLOW_KEY} is set.` : `${FLOW_KEY}: not set yet (${RUN} setup writes it).`);
719
+ say(model.line);
720
+ say(account ? `Signed in as ${account.email}.` : current ? "Not signed in here (or flow.engineer could not be reached)." : "Not signed in.");
721
+ for (const c of CHANNELS) {
722
+ const { sender, open, trial } = channels[c];
723
+ const where = sender ? "in .env" : "not in .env";
724
+ const now = open === null ? "" : open ? "; open now" : "; not open yet";
725
+ say(`${CHANNEL_NAME[c]}: ${c === "telegram" ? `Flow Messaging key ${where}` : `sender ${where}`}${now}${trialNote(trial ?? undefined)}`);
726
+ }
727
+ if (next.length)
728
+ say(`Next: ${next.join(", then ")}`);
729
+ }
730
+ /**
731
+ * status's model line. Flow's key the active one: its credit from GET /credit. A key Flow does not
732
+ * know says so with the step that makes a new one; Flow not reachable leaves the credit unknown.
733
+ */
734
+ async function flowModelStatus(value) {
735
+ const local = modelKeyReport(value, undefined, false);
736
+ if (local.report.env !== FLOW_KEY)
737
+ return local;
738
+ const key = value(FLOW_KEY);
739
+ try {
740
+ return modelKeyReport(value, await modelCredit(flowModelURL({ ...process.env, FLOW_MODEL_URL: value("FLOW_MODEL_URL") }), key), false);
741
+ }
742
+ catch (error) {
743
+ if (error instanceof LoginRefused && (error.status === 401 || error.status === 403)) {
744
+ const message = error.status === 401
745
+ ? `${FLOW_KEY} is not a key Flow knows (revoked, or from another server).`
746
+ : `${FLOW_KEY} may not call Flow's models (its scopes lack model).`;
747
+ return { report: { ...local.report, message }, line: message, next: `${RUN} setup --rotate key` };
748
+ }
749
+ if (error instanceof LoginRefused && error.status === 402) {
750
+ const message = error.code === "trial_credit_used" ? USED_UP : NO_CREDIT;
751
+ return { report: { ...local.report, flow_credit: error.code === "trial_credit_used" ? { left_usd: 0 } : null, message }, line: message, next: OWN_KEY };
752
+ }
753
+ return { ...local, line: `${local.line} (credit left unknown: Flow could not be reached)` };
754
+ }
755
+ }
503
756
  /** The names set in ./.env, with what the environment sets winning. */
504
757
  function dotenvValue() {
505
758
  const file = join(process.cwd(), ".env");
506
759
  const saved = existsSync(file) ? envValues(readFileSync(file, "utf8").split("\n")) : new Map();
507
- return (name) => process.env[name] || saved.get(name);
760
+ return (name) => process.env[name]?.trim() || saved.get(name);
508
761
  }
509
762
  /**
510
763
  * The phone link for this folder's join code. Not set up yet (no join code or no Flow Messaging key
511
764
  * in .env, or no sender for a WhatsApp or iMessage link): it signs in first when it has to (the same
512
765
  * login, resuming a code already waiting) and runs setup, then asks flow.engineer for the channel's
513
- * page, so a channel that is not open yet answers with the server's next step instead of a link
514
- * that does not work.
766
+ * page. A channel that is not open yet is said once, with the links of the channels that are (exit
767
+ * 0); with none open, it stops with the server's next step.
515
768
  */
516
769
  async function link(options, json, out, say) {
517
770
  const again = `${NEXT.link}${options.channel === "telegram" ? "" : ` --channel ${options.channel}`}`;
518
771
  const what = CHANNEL_NAME[options.channel];
519
772
  const needsSender = options.channel !== "telegram";
520
773
  let value = dotenvValue();
521
- let restart = false;
774
+ let credit;
775
+ let had;
522
776
  if (!value(SETUP_ENV.join_code) || !value(SETUP_ENV.messaging_key) || (needsSender && !value(SENDER_ENV[options.channel]))) {
523
777
  agentFolder(process.cwd());
524
778
  if (!(await signedInAccount(loginUrl()))) {
@@ -526,37 +780,90 @@ async function link(options, json, out, say) {
526
780
  await login({ noBrowser: options.noBrowser, wait: false, noWait: false, force: true, timeoutSeconds: options.timeoutSeconds, resume: { then: again } }, json, out, say);
527
781
  }
528
782
  say("Setting up this agent's keys in .env.");
529
- await setupEnv(false, false, json, out, say, { channel: options.channel });
783
+ had = value;
784
+ ({ answer: { model_credit: credit } } = await setupEnv({ replace: false, rotate: [], ci: false }, json, out, say, { channel: options.channel }));
530
785
  value = dotenvValue();
531
- restart = true;
532
786
  }
533
787
  const joinCode = value(SETUP_ENV.join_code);
534
788
  if (!joinCode)
535
789
  throw new Stop("no_join_code", `Setup wrote no ${SETUP_ENV.join_code} into .env.`, NEXT.setup, EXIT.refused);
536
- if (needsSender && !value(SENDER_ENV[options.channel])) {
537
- throw new Stop("channel_not_open", `${what} is not open on Flow's shared senders yet, so setup got no ${SENDER_ENV[options.channel]}.`, NEXT.link, EXIT.refused);
538
- }
539
790
  // The server's own try_url when setup kept one for this join code and channel, else the same page built here.
540
791
  const fromServer = value(SETUP_ENV.try_url);
541
- const url = fromServer?.includes(`/try/${encodeURIComponent(joinCode)}?`) && URL.parse(fromServer)?.searchParams.get("c") === options.channel ? fromServer : tryLink(loginUrl(), joinCode, options.channel);
542
- await checkTryPage(url);
543
- const replyOnly = REPLY_ONLY[options.channel];
544
- const sayToPerson = `Open this on your phone and send the message ${what} fills in: ${url}${replyOnly ? ` ${replyOnly}` : ""}`;
545
- if (json)
546
- out({ url, channel: options.channel, join_code: joinCode, reply_only: Boolean(replyOnly), say_to_person: sayToPerson });
547
- say(url);
548
- say(`Open it on a phone with ${what} and send the message it fills in, with your join code. From then on`);
549
- say("that phone chats with this agent while it runs (npm start), with no public URL or tunnel.");
550
- if (replyOnly)
551
- say(replyOnly);
552
- if (restart)
553
- say("If the agent is running already, restart it (npm start) so it picks up the new .env.");
792
+ const name = agentDisplayName();
793
+ const pageFor = (channel) => namedPage(fromServer?.includes(`/try/${encodeURIComponent(joinCode)}?`) && URL.parse(fromServer)?.searchParams.get("c") === channel ? fromServer : tryLink(loginUrl(), joinCode, channel), name);
794
+ /** The channel's direct link, or the Stop that says it is not open (no sender in .env, or the try page says channel_not_open). */
795
+ const open = async (channel) => {
796
+ if (channel !== "telegram" && !value(SENDER_ENV[channel])) {
797
+ return new Stop("channel_not_open", `${CHANNEL_NAME[channel]} is not open on Flow's shared senders yet, so setup got no ${SENDER_ENV[channel]}.`, NEXT.link, EXIT.refused);
798
+ }
799
+ try {
800
+ return await directLink(pageFor(channel));
801
+ }
802
+ catch (error) {
803
+ if (error instanceof Stop && error.code === "channel_not_open")
804
+ return error;
805
+ throw error;
806
+ }
807
+ };
808
+ const model = modelKeyReport(value, credit, had !== undefined);
809
+ const asked = await open(options.channel);
810
+ let links;
811
+ let notOpen;
812
+ if (!(asked instanceof Stop))
813
+ links = [[options.channel, asked]];
814
+ else {
815
+ // The asked-for channel is closed: the others' links instead, each checked the same way.
816
+ links = [];
817
+ for (const c of CHANNELS.filter((c) => c !== options.channel)) {
818
+ const other = await open(c);
819
+ if (!(other instanceof Stop))
820
+ links.push([c, other]);
821
+ }
822
+ if (!links.length)
823
+ throw asked;
824
+ notOpen = `${what} isn't open yet; here ${links.length === 1 ? "is your" : "are your"} ${links.map(([c]) => CHANNEL_NAME[c]).join(" and ")} link${links.length === 1 ? "" : "s"}.`;
825
+ }
826
+ const [channel, first] = links[0];
827
+ const replyOnly = REPLY_ONLY[channel];
828
+ const sayToPerson = `${notOpen ? `${what} isn't open yet. ` : ""}Your ${CHANNEL_NAME[channel]} link: ${first.link} ${OPEN_HINT[channel]} `
829
+ + `On a computer without ${CHANNEL_NAME[channel]}, open ${first.page} instead.${replyOnly ? ` ${replyOnly}` : ""}`;
830
+ if (json) {
831
+ out({
832
+ url: first.link, page: first.page, join_message: first.message, channel, join_code: joinCode, reply_only: Boolean(replyOnly), say_to_person: sayToPerson,
833
+ ...(notOpen ? { not_open: options.channel, message: notOpen, links: Object.fromEntries(links.map(([c, d]) => [c, d.link])) } : {}),
834
+ model_key: model.report, ...(model.next ? { next: model.next } : {}),
835
+ });
836
+ }
837
+ if (notOpen) {
838
+ say(notOpen);
839
+ for (const [c, d] of links)
840
+ say(`On ${CHANNEL_NAME[c]}: ${d.link} (on a computer: ${d.page})`);
841
+ }
842
+ else {
843
+ say(first.link);
844
+ say(`On a computer without ${CHANNEL_NAME[channel]}: ${first.page}`);
845
+ }
846
+ if (model.report.set)
847
+ say(model.line);
848
+ const one = notOpen && links.length > 1 ? "one" : "it";
849
+ say(`${channel === "telegram" ? `Open ${one} on a phone and press Start` : `Open ${one} on a phone and send the message it fills in, "${first.message}"`}. From then on`);
850
+ // No model key: the agent cannot answer yet, so the phone does not chat with it until there is one.
851
+ say(model.report.set ? "that phone chats with this agent while it runs (npm start), with no public URL or tunnel." : "that phone reaches this agent while it runs (npm start), but it answers only once .env has a model key:");
852
+ if (model.next)
853
+ say(`${model.line}\nNext: ${model.next}.`);
854
+ for (const [c] of links)
855
+ if (REPLY_ONLY[c])
856
+ say(REPLY_ONLY[c]);
857
+ const added = had ? addedToRunning(had, value) : undefined;
858
+ if (added)
859
+ say(added);
554
860
  }
555
861
  /**
556
- * Asks flow.engineer for the try page as JSON: a channel it cannot open yet (or a join code it does
557
- * not know) answers {"error", "error_description", "next"}, which stops here with that next step.
862
+ * Asks flow.engineer's /try page for its direct link as JSON ({"channel", "message", "link", "page"}):
863
+ * a channel it cannot open yet (or a join code it does not know) answers {"error",
864
+ * "error_description", "next"}, which stops here with that next step.
558
865
  */
559
- async function checkTryPage(url) {
866
+ async function directLink(url) {
560
867
  let response;
561
868
  try {
562
869
  response = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
@@ -566,14 +873,18 @@ async function checkTryPage(url) {
566
873
  throw new TimedOut(url, REQUEST_TIMEOUT_MS);
567
874
  throw error;
568
875
  }
569
- if (response.ok)
570
- return void (await response.body?.cancel());
571
876
  const text = await response.text();
572
877
  let body = {};
573
878
  try {
574
879
  body = JSON.parse(text);
575
880
  }
576
881
  catch { }
882
+ if (response.ok) {
883
+ const { link, message, page } = body;
884
+ if (typeof link === "string" && link && typeof message === "string" && message)
885
+ return { link, message, page: typeof page === "string" && page ? page : url };
886
+ throw new Stop("bad_answer", `${url} answered without a link.`, undefined, EXIT.refused);
887
+ }
577
888
  const code = typeof body.error === "string" ? body.error : `http_${response.status}`;
578
889
  const detail = typeof body.error_description === "string" ? body.error_description : `${url} answered ${response.status}.`;
579
890
  throw new Stop(code, detail, typeof body.next === "string" && body.next ? body.next : undefined, EXIT.refused);