@flow-engineer/flow 0.2.0 → 0.2.2

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 (82) hide show
  1. package/LICENSE +202 -21
  2. package/README.md +190 -17
  3. package/coding-agents/auth-skill.md +137 -0
  4. package/coding-agents/context-skill.md +94 -0
  5. package/coding-agents/skill.md +245 -11
  6. package/coding-agents/test-skill.md +13 -11
  7. package/coding-agents/tools-skill.md +78 -1
  8. package/dist/cli/src/auth.d.ts +11 -0
  9. package/dist/cli/src/auth.js +147 -0
  10. package/dist/cli/src/commands.d.ts +119 -0
  11. package/dist/cli/src/commands.js +212 -0
  12. package/dist/cli/src/index.d.ts +2 -0
  13. package/dist/cli/src/index.js +5 -0
  14. package/dist/cli/src/run.d.ts +28 -0
  15. package/dist/cli/src/run.js +523 -0
  16. package/dist/cli/src/terminal.d.ts +3 -0
  17. package/dist/cli/src/terminal.js +135 -0
  18. package/dist/flow-messaging/src/client.d.ts +7 -1
  19. package/dist/flow-messaging/src/client.js +13 -1
  20. package/dist/flow-messaging/src/index.d.ts +38 -3
  21. package/dist/flow-messaging/src/index.js +242 -40
  22. package/dist/mcp/src/client.d.ts +54 -0
  23. package/dist/mcp/src/client.js +175 -0
  24. package/dist/mcp/src/describe.d.ts +41 -0
  25. package/dist/mcp/src/describe.js +110 -0
  26. package/dist/mcp/src/index.d.ts +4 -0
  27. package/dist/mcp/src/index.js +7 -0
  28. package/dist/mcp/src/server.d.ts +70 -0
  29. package/dist/mcp/src/server.js +252 -0
  30. package/dist/mcp/src/terminal.d.ts +24 -0
  31. package/dist/mcp/src/terminal.js +54 -0
  32. package/dist/simulators/src/index.d.ts +1 -1
  33. package/dist/simulators/src/page.html +267 -49
  34. package/dist/simulators/src/server.d.ts +27 -0
  35. package/dist/simulators/src/server.js +264 -40
  36. package/dist/src/agent.d.ts +69 -3
  37. package/dist/src/agent.js +162 -8
  38. package/dist/src/channels.d.ts +2 -0
  39. package/dist/src/conversation.d.ts +16 -1
  40. package/dist/src/conversation.js +140 -12
  41. package/dist/src/events.d.ts +1 -1
  42. package/dist/src/index.d.ts +2 -2
  43. package/dist/src/index.js +1 -1
  44. package/dist/src/model.d.ts +27 -0
  45. package/dist/src/model.js +85 -13
  46. package/dist/src/names.d.ts +5 -0
  47. package/dist/src/names.js +5 -0
  48. package/dist/src/skills.d.ts +4 -0
  49. package/dist/src/skills.js +23 -0
  50. package/dist/src/store.d.ts +2 -0
  51. package/dist/src/store.js +18 -0
  52. package/dist/src/tools.d.ts +69 -0
  53. package/dist/src/tools.js +17 -1
  54. package/dist/src/traces.js +32 -4
  55. package/dist/src/types.d.ts +2 -0
  56. package/dist/testing/src/account.d.ts +4 -0
  57. package/dist/testing/src/account.js +891 -0
  58. package/dist/testing/src/chat.d.ts +3 -0
  59. package/dist/testing/src/chat.js +125 -0
  60. package/dist/testing/src/check.d.ts +4 -0
  61. package/dist/testing/src/check.js +90 -0
  62. package/dist/testing/src/chinwag.js +1 -1
  63. package/dist/testing/src/cli.js +105 -18
  64. package/dist/testing/src/client.d.ts +2 -0
  65. package/dist/testing/src/client.js +4 -3
  66. package/dist/testing/src/flow-login.d.ts +176 -0
  67. package/dist/testing/src/flow-login.js +274 -0
  68. package/dist/testing/src/new.d.ts +15 -2
  69. package/dist/testing/src/new.js +80 -21
  70. package/dist/testing/src/plugin.d.ts +4 -0
  71. package/dist/testing/src/plugin.js +116 -0
  72. package/docs/agents.md +160 -0
  73. package/examples/cafe/src/agent.ts +6 -2
  74. package/examples/cafe/src/channels.ts +20 -0
  75. package/examples/cafe/src/cli.ts +24 -0
  76. package/examples/cafe/src/main.ts +17 -3
  77. package/examples/cafe/src/mcp.ts +22 -0
  78. package/examples/cafe/src/tools.ts +13 -0
  79. package/flow-messaging/README.md +66 -27
  80. package/package.json +30 -2
  81. package/spec/agent-endpoint.openapi.yaml +2 -2
  82. package/spec/nudges.openapi.yaml +2 -2
@@ -0,0 +1,3 @@
1
+ export declare const CHAT_LINE = " flow chat [--channel <name>] [--person <phone>] [--json]\n Talk to the agent in this folder from the terminal: one message per line\n of stdin, no account needed (only the model key in .env)";
2
+ /** Runs `flow chat`; resolves to the exit status. */
3
+ export declare function chatCli(args: string[], input?: NodeJS.ReadableStream): Promise<number>;
@@ -0,0 +1,125 @@
1
+ // `flow chat`: talk to the agent in this folder from the terminal, with no account and no channel.
2
+ // It builds the agent from src/agent.ts (buildAgent({ events }), as `flow test` does) with the
3
+ // model key in .env, and writes as the agent's first test person, the way the playground does
4
+ // (agent.simulate). Each line read from stdin is one message; the agent's replies are printed once
5
+ // its turn is over, so it works typed by a person or piped by a coding agent:
6
+ // printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat
7
+ // --json prints each thing the agent sends as one JSON line, and an error as
8
+ // {"error":{"code","message","next"}} (no_model_key, no_agent, not_built, no_test_people,
9
+ // no_channel). The conversation lives only as long as the command. Exit status: 0 done (stdin
10
+ // ended), 1 usage or the agent could not be built.
11
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
12
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
13
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
14
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
15
+ });
16
+ }
17
+ return path;
18
+ };
19
+ import { existsSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { createInterface } from "node:readline";
22
+ import { pathToFileURL } from "node:url";
23
+ import { parseArgs } from "node:util";
24
+ import { NO_MODEL_KEY } from "../../src/model.js";
25
+ import { loadEnv } from "./report.js";
26
+ export const CHAT_LINE = ` flow chat [--channel <name>] [--person <phone>] [--json]
27
+ Talk to the agent in this folder from the terminal: one message per line
28
+ of stdin, no account needed (only the model key in .env)`;
29
+ const USAGE = `Usage:\n${CHAT_LINE}
30
+
31
+ Writes as the agent's first test person (createAgent({ testPeople })) on its first channel, unless
32
+ --person or --channel says otherwise. Exit status: 0 done, 1 usage or the agent could not be built.`;
33
+ /** Runs `flow chat`; resolves to the exit status. */
34
+ export async function chatCli(args, input = process.stdin) {
35
+ let values;
36
+ try {
37
+ ({ values } = parseArgs({ args, strict: true, options: { channel: { type: "string" }, person: { type: "string" }, json: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false } } }));
38
+ }
39
+ catch (error) {
40
+ console.error(`flow chat: ${error.message.split(". ")[0]}\n\n${USAGE}`);
41
+ return 1;
42
+ }
43
+ if (values.help) {
44
+ console.log(USAGE);
45
+ return 0;
46
+ }
47
+ loadEnv(undefined);
48
+ const json = values.json === true;
49
+ /** Stops with exit 1: the sentence on stderr, and with --json the error as one JSON line on stdout. */
50
+ const fail = (code, message, next, nextLine = next ? `Next: ${next}` : undefined) => {
51
+ console.error(`flow chat: ${message}${nextLine ? `\n${nextLine}` : ""}`);
52
+ if (json)
53
+ process.stdout.write(`${JSON.stringify({ error: { code, message, next: next ?? null } })}\n`);
54
+ return 1;
55
+ };
56
+ const file = join(process.cwd(), "src", "agent.ts");
57
+ if (!existsSync(file))
58
+ return fail("no_agent", "There is no src/agent.ts here: run it from your agent's folder.", "npx @flow-engineer/flow init", "Next: npx @flow-engineer/flow init makes one in an empty folder.");
59
+ const turns = [];
60
+ let agent;
61
+ try {
62
+ const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
63
+ if (typeof module.buildAgent !== "function")
64
+ throw new Error("src/agent.ts must export buildAgent({ events }).");
65
+ agent = module.buildAgent({ events: [(event) => turns.forEach((listener) => listener(event))] });
66
+ }
67
+ catch (error) {
68
+ if (error.code === NO_MODEL_KEY.code)
69
+ return fail(NO_MODEL_KEY.code, NO_MODEL_KEY.message, NO_MODEL_KEY.next, NO_MODEL_KEY.nextLine);
70
+ return fail("not_built", error.message);
71
+ }
72
+ const person = values.person ? agent.testPeople().find((p) => p.phone.replace(/\D/g, "") === values.person.replace(/\D/g, "")) : agent.testPeople()[0];
73
+ const channel = values.channel ? agent.channels().find((c) => c.name === values.channel) : agent.channels()[0];
74
+ if (!person)
75
+ return fail("no_test_people", `${values.person ? `${values.person} is not one of the agent's testPeople` : "the agent has no testPeople"}; declare them in createAgent({ testPeople: [{ name, phone }] }).`);
76
+ if (!channel)
77
+ return fail("no_channel", `${values.channel ? `no channel named ${values.channel}` : "the agent has no channels"}; it has ${agent.channels().map((c) => c.name).join(", ") || "none"}.`);
78
+ agent.attachPlayground(({ outbound }) => print(outbound, channel.name, json));
79
+ turns.push((event) => {
80
+ if (event.type === "model.error")
81
+ console.error(`flow chat: the model failed: ${String(event.error ?? event.message ?? "error")}`);
82
+ });
83
+ const prompt = process.stdin.isTTY && input === process.stdin;
84
+ if (!json)
85
+ console.error(`Talking to the agent on ${channel.name} as ${person.name} (${person.phone}). Ctrl-D ends.`);
86
+ const lines = createInterface({ input, terminal: false });
87
+ if (prompt)
88
+ process.stderr.write("you: ");
89
+ let conversationId;
90
+ for await (const line of lines) {
91
+ const text = line.trim();
92
+ if (text) {
93
+ const taken = await agent.simulate({ person: person.phone, channel: channel.name, message: text });
94
+ if (taken)
95
+ await agent.conversation((conversationId = taken.conversationId))?.whenIdle();
96
+ }
97
+ if (prompt)
98
+ process.stderr.write("you: ");
99
+ }
100
+ // A tool still running in the background answers in a turn of its own: wait for it before leaving.
101
+ while (conversationId && agent.status().backgroundTasks > 0) {
102
+ await new Promise((done) => turns.push((event) => event.type === "task.done" && done()));
103
+ await agent.conversation(conversationId)?.whenIdle();
104
+ }
105
+ await agent.flushTraces();
106
+ return 0;
107
+ }
108
+ /** One thing the agent sent, as a person would read it (or one JSON line). */
109
+ function print(outbound, channel, json) {
110
+ if (outbound.type === "typing" || outbound.type === "status" || outbound.type === "read")
111
+ return;
112
+ if (json)
113
+ return void process.stdout.write(`${JSON.stringify({ channel, ...outbound })}\n`);
114
+ if (outbound.type === "reaction")
115
+ return void process.stdout.write(`agent reacted ${outbound.emoji}\n`);
116
+ if (outbound.type === "template")
117
+ return void process.stdout.write(`agent: [template ${outbound.name}]\n`);
118
+ const extra = [
119
+ outbound.image && `[photo ${outbound.image.url}]`,
120
+ outbound.link && `[${outbound.link.label}: ${outbound.link.url}]`,
121
+ outbound.buttons?.map((b) => `[${b}]`).join(" "),
122
+ outbound.list && `[${outbound.list.button}: ${outbound.list.rows.map((r) => r.title).join(" | ")}]`,
123
+ ].filter(Boolean);
124
+ process.stdout.write(`agent: ${[outbound.text, ...extra].filter(Boolean).join(" ")}\n`);
125
+ }
@@ -0,0 +1,4 @@
1
+ export declare const CHECK_LINE = " flow check [--json] [--plugin <dir>] Check the agent's tools, CLI file and skills for MCP and the CLI";
2
+ export declare function checkCli(argv: string[], dir?: string): Promise<number>;
3
+ /** `flow mcp update`: loads the agent with every mcpTools call writing its server's current tools to mcp.lock.json. */
4
+ export declare function mcpUpdateCli(dir?: string): Promise<number>;
@@ -0,0 +1,90 @@
1
+ // `flow check`, from an agent's folder: whether its tools can be served over MCP exactly as
2
+ // declared, whether its CLI file (src/cli.ts) still matches them, and whether the skills it would
3
+ // send to MCP hosts name tools only chats have. Rules only, no model: what it reports is
4
+ // objectively wrong (errors, exit 1) or worth a look (warnings).
5
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
6
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
7
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
8
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
9
+ });
10
+ }
11
+ return path;
12
+ };
13
+ import { existsSync } from "node:fs";
14
+ import { pluginProblems } from "./plugin.js";
15
+ import { join, resolve } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { cliProblems } from "../../cli/src/commands.js";
18
+ import { CHAT_ONLY, describe, toolsProblems } from "../../mcp/src/describe.js";
19
+ import { loadSkills, readAgentFolder } from "../../src/skills.js";
20
+ export const CHECK_LINE = ` flow check [--json] [--plugin <dir>] Check the agent's tools, CLI file and skills for MCP and the CLI`;
21
+ export async function checkCli(argv, dir = process.cwd()) {
22
+ const json = argv.includes("--json");
23
+ const errors = [];
24
+ const warnings = [];
25
+ const pluginAt = argv.indexOf("--plugin");
26
+ const pluginDir = pluginAt >= 0 ? argv[pluginAt + 1] : undefined;
27
+ if (pluginAt >= 0 && (!pluginDir || pluginDir.startsWith("--")))
28
+ errors.push("--plugin needs its generated folder");
29
+ if (pluginDir)
30
+ errors.push(...pluginProblems(dir, resolve(dir, pluginDir)));
31
+ const agentFile = join(dir, "src/agent.ts");
32
+ if (!existsSync(agentFile)) {
33
+ console.error(`No src/agent.ts in ${dir}. Run flow check from the agent's folder.`);
34
+ return 1;
35
+ }
36
+ const agent = await import(__rewriteRelativeImportExtension(pathToFileURL(agentFile).href));
37
+ const tools = Array.isArray(agent.tools) ? agent.tools : [];
38
+ if (!Array.isArray(agent.tools))
39
+ errors.push("src/agent.ts exports no tools array");
40
+ errors.push(...toolsProblems(tools));
41
+ if (existsSync(join(dir, "agent"))) {
42
+ for (const skill of loadSkills(readAgentFolder(join(dir, "agent")))) {
43
+ const named = CHAT_ONLY.exec(skill.body);
44
+ if (named)
45
+ (pluginAt >= 0 ? errors : warnings).push(`skill ${skill.name}: names ${named[1]}, which MCP hosts and the CLI do not have`);
46
+ }
47
+ }
48
+ const cliFile = join(dir, "src/cli.ts");
49
+ if (existsSync(cliFile)) {
50
+ const cli = (await import(__rewriteRelativeImportExtension(pathToFileURL(cliFile).href))).cli;
51
+ if (!cli)
52
+ errors.push("src/cli.ts exports no cli (export const cli = defineCli({ ... }))");
53
+ else {
54
+ const described = tools.flatMap((t) => {
55
+ try {
56
+ return [describe(t)];
57
+ }
58
+ catch {
59
+ return [];
60
+ }
61
+ });
62
+ const found = cliProblems(cli, described);
63
+ errors.push(...found.errors.map((e) => `src/cli.ts: ${e}`));
64
+ warnings.push(...found.warnings.map((w) => `src/cli.ts: ${w}`));
65
+ }
66
+ }
67
+ if (json)
68
+ console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }));
69
+ else {
70
+ for (const e of errors)
71
+ console.log(`error ${e}`);
72
+ for (const w of warnings)
73
+ console.log(`warn ${w}`);
74
+ console.log(errors.length ? `${errors.length} error${errors.length === 1 ? "" : "s"}.` : `No errors in ${tools.length} tool${tools.length === 1 ? "" : "s"}.`);
75
+ }
76
+ return errors.length ? 1 : 0;
77
+ }
78
+ /** `flow mcp update`: loads the agent with every mcpTools call writing its server's current tools to mcp.lock.json. */
79
+ export async function mcpUpdateCli(dir = process.cwd()) {
80
+ const agentFile = join(dir, "src/agent.ts");
81
+ if (!existsSync(agentFile)) {
82
+ console.error(`No src/agent.ts in ${dir}. Run flow mcp update from the agent's folder.`);
83
+ return 1;
84
+ }
85
+ const { UPDATE_ENV } = await import("../../mcp/src/client.js");
86
+ process.env[UPDATE_ENV] = "1";
87
+ await import(__rewriteRelativeImportExtension(pathToFileURL(agentFile).href));
88
+ console.error(`Updated ${join(dir, "mcp.lock.json")}. Run flow check and the tests, then commit it.`);
89
+ return 0;
90
+ }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // The `chinwag` command from before the rename, kept for one release: it says so on stderr (stdout
3
3
  // stays the report's, for --json), then runs the flow command (testing/src/cli.ts) as it is.
4
- console.error("chinwag is now Flow Framework (@flow-engineer/flow): run npx flow, and npm i @flow-engineer/flow.");
4
+ console.error("chinwag is now Flow Framework (@flow-engineer/flow): run npx @flow-engineer/flow, and npm i @flow-engineer/flow.");
5
5
  await import("./cli.js");
6
6
  export {};
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // The flow command. `flow new <name>` makes ./<name>/, an agent started from the café example
3
- // (testing/src/new.ts). `flow test`, or `flow` with flags, runs an agent's tests in the cloud,
2
+ // The flow command. `flow new <name>` makes ./<name>/, an agent started from the café example, and
3
+ // `flow init` the same agent in the current folder (testing/src/new.ts). `flow chat` talks to the
4
+ // agent in this folder from the terminal (testing/src/chat.ts). `flow test`, or `flow` with flags, runs an agent's tests in the cloud,
4
5
  // from the agent's folder:
5
6
  // flow test [--cloud https://flow.engineer/observe] [--runs 3] [--only late-order,refund | --failed] [--json]
6
7
  // A hosted cloud (any --cloud off this machine, the default included) needs the
@@ -15,6 +16,8 @@
15
16
  // (what each run ended as and the judge's reasons, never the messages), and --failed runs again the
16
17
  // scenarios that did not pass. --json prints one report (testing/src/report.ts) on stdout at every exit,
17
18
  // and `flow show <run>` prints one run's record from the cloud (testing/src/show.ts).
19
+ // `flow login`, `logout`, `whoami`, `setup` and `link` sign in to flow.engineer and get the agent's
20
+ // keys and its phone link (testing/src/account.ts).
18
21
  var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
19
22
  if (typeof path === "string" && /^\.\.?\//.test(path)) {
20
23
  return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
@@ -29,22 +32,38 @@ import { request as httpsRequest } from "node:https";
29
32
  import { basename, dirname, join } from "node:path";
30
33
  import { pathToFileURL } from "node:url";
31
34
  import { parseArgs } from "node:util";
35
+ import { hasModelKey, NO_MODEL_KEY } from "../../src/model.js";
36
+ import { PLUGIN_LINE, pluginCli } from "./plugin.js";
32
37
  import { OBSERVE_URL, setting } from "../../src/traces.js";
33
38
  import { cloudHeaders, runInCloud, SessionStopped } from "./client.js";
34
39
  import { runPrinter } from "./print-run.js";
35
40
  import { checkEndpointCli } from "./check-endpoint.js";
41
+ import { CHECK_LINE as TOOLS_CHECK_LINE, checkCli, mcpUpdateCli } from "./check.js";
42
+ import { ACCOUNT_COMMANDS, ACCOUNT_LINES, accountCli } from "./account.js";
43
+ import { CHAT_LINE, chatCli } from "./chat.js";
36
44
  import { httpAgent } from "./http-agent.js";
37
- import { newStandalone, packageRoot } from "./new.js";
45
+ import { createInterface } from "node:readline/promises";
46
+ import { displayNameOf, folderName, initHere, newStandalone, packageRoot } from "./new.js";
38
47
  import { EXIT, finishedOutcome, loadEnv, networkCode, reportError, runVerdict, stoppedOutcome } from "./report.js";
39
48
  import { LAST_SESSION, lastSessionIn, SHOW_LINE, showCli } from "./show.js";
40
49
  import { loadSuite, parseScenario, parseToolFile, suiteHash } from "./suite.js";
41
- const NEW_LINE = " flow new <name> Make ./<name>/, a new agent started from the café example";
50
+ const NEW_LINE = ` flow new <name> [--display-name <name>]
51
+ Make ./<name>/, a new agent started from the café example
52
+ flow init [--display-name <name>]
53
+ Make that agent in the current folder, named after it.
54
+ --display-name is what people call it, such as "FlatX agent": a
55
+ phone reaches it with "Hi FlatX agent (ref <code>)" (asked for when
56
+ run in a terminal; else the folder's name in words)`;
42
57
  const TEST_LINE = ` flow test [options] Run the agent's tests in a test cloud, from the agent's folder
43
58
  (src/agent.ts exports buildAgent({ model, events, tools }) and tools;
44
59
  the tests are in tests/scenarios and tests/tools)`;
45
60
  const CHECK_LINE = ` flow check-endpoint <url> [--token-env NAME] [--json]
46
61
  Check an agent's HTTP endpoint against the contract, for --agent-url
47
62
  (flow check-endpoint --help)`;
63
+ const MCP_LINE = ` flow mcp update Write the tools of the MCP servers the agent uses (mcpTools) to
64
+ mcp.lock.json, from the agent's folder; commit it after the tests`;
65
+ const CLI_LINE = ` flow cli --server <url> <command>
66
+ Any MCP server's tools as commands (flow cli --server <url> --help)`;
48
67
  const NEW_USAGE = `Usage:\n${NEW_LINE}\n\n -h, --help Show this help`;
49
68
  const TEST_OPTIONS = `Options for test:
50
69
  --cloud <url> The test cloud (default: FLOW_FRAMEWORK_CLOUD, else https://flow.engineer/observe)
@@ -91,15 +110,29 @@ with the judge's reasons. Never the messages. The folder is git-ignored and the
91
110
  Environment (also read from the agent folder's .env, or --dotenv; variables already set win;
92
111
  the CHINWAG_ names from before the rename still work):
93
112
  FLOW_FRAMEWORK_CLOUD The test cloud, when --cloud is not given
94
- FLOW_FRAMEWORK_CLOUD_KEY The agent's CI key, for a hosted cloud (oci_...: make one at
95
- https://flow.engineer/observe, your agent, Developers)
113
+ FLOW_FRAMEWORK_CLOUD_KEY The agent's CI key, for a hosted cloud (oci_...: flow setup
114
+ writes it, or make one at https://flow.engineer/observe, your agent, Developers)
96
115
  FLOW_FRAMEWORK_AGENT_TOKEN
97
116
  With --agent-url: sent to it as authorization: Bearer <token>
98
117
  OPENROUTER_API_KEY The agent's model key, or OPENAI_API_KEY, ANTHROPIC_API_KEY or
99
- GEMINI_API_KEY: the first with a value is used`;
118
+ GEMINI_API_KEY: the first with a value is used, else FLOW_KEY
119
+ (the agent's Flow key, on its trial credit; flow setup writes it)`;
100
120
  const TEST_USAGE = `Usage:\n${TEST_LINE}\n\n${TEST_OPTIONS}`;
101
- const USAGE = `Usage:\n${NEW_LINE}\n${TEST_LINE}\n${SHOW_LINE}\n${CHECK_LINE}\n flow --version Show the version\n\n${TEST_OPTIONS}`;
121
+ const USAGE = `Usage:\n${NEW_LINE}\n${CHAT_LINE}\n${TEST_LINE}\n${SHOW_LINE}\n${CHECK_LINE}\n${TOOLS_CHECK_LINE}\n${MCP_LINE}\n${CLI_LINE}
122
+ ${PLUGIN_LINE}\n${ACCOUNT_LINES}\n flow --version Show the version\n\n${TEST_OPTIONS}`;
123
+ /** What to do after new or init, from step 2; a model key comes first when none is set here. */
124
+ function nextSteps() {
125
+ const steps = [
126
+ ...(hasModelKey(process.env) ? [] : [`Model key: none yet. ${NO_MODEL_KEY.nextLine.replace(/^Next: r/, "R")} (in .env: cp .env.example .env; the file says where to make one)`]),
127
+ `npm start, and open the link it prints: try it in the playground as its test people, channels side by side`,
128
+ `npx @flow-engineer/flow link: signs in, writes the other keys, prints the link for your phone;\n keep npm start running and text it`,
129
+ `Tell your coding agent what to build: its skills are in .claude/skills/`,
130
+ ];
131
+ return steps.map((step, i) => ` ${i + 2}. ${step}`).join("\n");
132
+ }
102
133
  checkNode(process.versions.node);
134
+ if (process.argv[2] === "plugin")
135
+ process.exit(pluginCli(process.argv.slice(3)));
103
136
  if (process.argv[2] === "check-endpoint") {
104
137
  // Its token may be in the agent folder's .env; no value is ever printed.
105
138
  loadEnv(undefined);
@@ -107,6 +140,24 @@ if (process.argv[2] === "check-endpoint") {
107
140
  }
108
141
  if (process.argv[2] === "show")
109
142
  process.exit(await showCli(process.argv.slice(3)));
143
+ if (process.argv[2] === "check")
144
+ process.exit(await checkCli(process.argv.slice(3)));
145
+ if (process.argv[2] === "mcp") {
146
+ if (process.argv[3] !== "update") {
147
+ console.error(MCP_LINE.trim());
148
+ process.exit(1);
149
+ }
150
+ process.exit(await mcpUpdateCli());
151
+ }
152
+ if (process.argv[2] === "cli") {
153
+ // Loaded only here: @flow-engineer/flow/cli needs @modelcontextprotocol/client, an optional peer.
154
+ const { defineCli, runCli } = await import("../../cli/src/index.js");
155
+ process.exit(await runCli(defineCli({ name: "flow" }), process.argv.slice(3)));
156
+ }
157
+ if (process.argv[2] === "chat")
158
+ process.exit(await chatCli(process.argv.slice(3)));
159
+ if (ACCOUNT_COMMANDS.has(process.argv[2] ?? ""))
160
+ process.exit(await accountCli(process.argv[2], process.argv.slice(3)));
110
161
  const TEST_FLAGS = {
111
162
  cloud: { type: "string" },
112
163
  runs: { type: "string", default: "1" },
@@ -134,23 +185,59 @@ if (values.version) {
134
185
  process.exit(0);
135
186
  }
136
187
  if (values.help || process.argv.length <= 2) {
137
- console.log(command === "new" ? NEW_USAGE : command === "test" ? TEST_USAGE : USAGE);
188
+ console.log(command === "new" || command === "init" ? NEW_USAGE : command === "test" ? TEST_USAGE : USAGE);
138
189
  process.exit(0);
139
190
  }
191
+ /** --display-name, else asked for in a terminal (Enter takes the folder's name in words), else that default. */
192
+ async function askDisplayName(given, packageName) {
193
+ if (given !== undefined || !process.stdin.isTTY || !process.stdout.isTTY)
194
+ return given;
195
+ const fallback = displayNameOf(undefined, packageName);
196
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
197
+ try {
198
+ return (await rl.question(`What should people call your agent? Their phone says "Hi <it>" (Enter for "${fallback}"): `)).trim() || undefined;
199
+ }
200
+ finally {
201
+ rl.close();
202
+ }
203
+ }
204
+ const namedLine = (dir) => {
205
+ const displayName = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).displayName;
206
+ return `People call it "${displayName}" (package.json displayName): a phone reaches it with "Hi ${displayName} (ref <code>)".`;
207
+ };
208
+ const displayNameFlag = values["display-name"];
140
209
  if (command === "new") {
141
210
  const name = rest.length === 1 ? rest[0] : "";
211
+ let dir;
142
212
  try {
143
- newStandalone(name);
213
+ if (!name)
214
+ throw new Error("Usage: flow new <name> [--display-name <name>]");
215
+ dir = newStandalone(name, { displayName: await askDisplayName(displayNameFlag, name) });
144
216
  }
145
217
  catch (error) {
146
- console.error(name ? error.message : "Usage: flow new <name>");
218
+ console.error(error.message);
147
219
  process.exit(1);
148
220
  }
149
- console.log(`Made ./${name}/ from the café example (Pip). Next:
221
+ console.log(`Made ./${name}/ from the café example (Pip). ${namedLine(dir)} Next:
150
222
  1. cd ${name} && npm install
151
- 2. Add one model key: cp .env.example .env; the file says where to make one
152
- 3. npm start, then open the link it prints
153
- 4. Tell your coding agent what to build: its skills are in .claude/skills/`);
223
+ ${nextSteps()}`);
224
+ process.exit(0);
225
+ }
226
+ if (command === "init") {
227
+ let name;
228
+ try {
229
+ if (rest.length)
230
+ throw new Error("Usage: flow init [--display-name <name>] (it makes the agent in the current folder; flow new <name> makes a folder for it)");
231
+ name = initHere(process.cwd(), { displayName: await askDisplayName(displayNameFlag, folderName(process.cwd())) });
232
+ }
233
+ catch (error) {
234
+ console.error(error.message);
235
+ process.exit(1);
236
+ }
237
+ loadEnv(undefined); // a .env already here may hold a model key
238
+ console.log(`Made ${name}, started from the café example (Pip), in this folder. ${namedLine(process.cwd())} Next:
239
+ 1. npm install
240
+ ${nextSteps()}`);
154
241
  process.exit(0);
155
242
  }
156
243
  /** Every human line of `flow test`: stdout, or stderr with --json, where stdout is the report. */
@@ -183,7 +270,7 @@ try {
183
270
  });
184
271
  }
185
272
  catch (error) {
186
- const where = !ciKey && cloud.replace(/\/+$/, "") === OBSERVE_URL ? ` Make a key at ${OBSERVE_URL}: your agent, Developers.` : "";
273
+ const where = !ciKey && cloud.replace(/\/+$/, "") === OBSERVE_URL ? " Run npx @flow-engineer/flow login, then npx @flow-engineer/flow setup, to get one into .env." : "";
187
274
  invalid(`${error.message}${where}`);
188
275
  }
189
276
  const dir = process.cwd();
@@ -499,8 +586,8 @@ function stoppedReason(error, cloud) {
499
586
  }
500
587
  /** The command's flags, or exits 1 with what is wrong and that command's usage. */
501
588
  function parseCommandLine() {
502
- const isNew = process.argv[2] === "new";
503
- const options = isNew ? { help: TEST_FLAGS.help, version: TEST_FLAGS.version } : TEST_FLAGS;
589
+ const isNew = process.argv[2] === "new" || process.argv[2] === "init";
590
+ const options = isNew ? { help: TEST_FLAGS.help, version: TEST_FLAGS.version, "display-name": { type: "string" } } : TEST_FLAGS;
504
591
  try {
505
592
  return parseArgs({ allowPositionals: true, strict: true, options: options });
506
593
  }
@@ -34,6 +34,8 @@ export interface ConnectOptions {
34
34
  retryBudgetMs?: number;
35
35
  /** How long a lost stream keeps trying to resume, on a cloud that resumes (Opened.resume). Default 2 minutes, as long as the cloud holds its commands. */
36
36
  resumeWindowMs?: number;
37
+ /** The clock the resume window is measured on. Default Date.now; a test passes one it moves itself. */
38
+ now?: () => number;
37
39
  /** How often a resumable stream is replaced, before the tunnel's one-hour request cap. Default 50 minutes. */
38
40
  streamRefreshMs?: number;
39
41
  /** The longest Retry-After a busy cloud's refused open is sent again after, once. Default 60s. */
@@ -204,6 +204,7 @@ async function playSession(options, only) {
204
204
  const retryBudgetMs = options.retryBudgetMs ?? RETRY_BUDGET_MS;
205
205
  const openRetryCapMs = options.openRetryCapMs ?? OPEN_RETRY_CAP_MS;
206
206
  const resumeWindowMs = options.resumeWindowMs ?? RESUME_WINDOW_MS;
207
+ const now = options.now ?? Date.now;
207
208
  const streamRefreshMs = options.streamRefreshMs ?? STREAM_REFRESH_MS;
208
209
  let idempotent = false;
209
210
  const base = options.cloud.replace(/\/$/, "");
@@ -536,13 +537,13 @@ async function playSession(options, only) {
536
537
  /** The stream opened again after the last command handled, with backoff, until the resume window closes. */
537
538
  const reconnect = async (why) => {
538
539
  log(`stream ${why}; resuming after command ${lastId}`);
539
- const deadline = Date.now() + resumeWindowMs;
540
+ const deadline = now() + resumeWindowMs;
540
541
  let last;
541
542
  for (let attempt = 0;; attempt++) {
542
543
  const backoff = attempt === 0 ? 0 : Math.min(10_000, 250 * 2 ** attempt) * (0.8 + Math.random() * 0.4);
543
544
  const refused = last instanceof Error && last.cause instanceof PostError ? last.cause : undefined;
544
545
  const wait = Math.max(backoff, refused?.retryAfterMs ?? 0);
545
- if (Date.now() + wait >= deadline) {
546
+ if (now() + wait >= deadline) {
546
547
  const detail = last instanceof Error ? `; last try: ${last.message}` : "";
547
548
  throw new StreamLost(`the stream did not resume within ${resumeWindowMs / 1000}s (${why})${detail}`);
548
549
  }
@@ -550,7 +551,7 @@ async function playSession(options, only) {
550
551
  if (failure)
551
552
  throw failure;
552
553
  try {
553
- return await openStream(streamUrl, { ...auth, "last-event-id": String(lastId) }, controller.signal, Math.max(1, Math.min(postTimeoutMs, deadline - Date.now())));
554
+ return await openStream(streamUrl, { ...auth, "last-event-id": String(lastId) }, controller.signal, Math.max(1, Math.min(postTimeoutMs, deadline - now())));
554
555
  }
555
556
  catch (error) {
556
557
  if (failure)