@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.
- package/LICENSE +202 -21
- package/README.md +190 -17
- package/coding-agents/auth-skill.md +137 -0
- package/coding-agents/context-skill.md +94 -0
- package/coding-agents/skill.md +245 -11
- package/coding-agents/test-skill.md +13 -11
- package/coding-agents/tools-skill.md +78 -1
- package/dist/cli/src/auth.d.ts +11 -0
- package/dist/cli/src/auth.js +147 -0
- package/dist/cli/src/commands.d.ts +119 -0
- package/dist/cli/src/commands.js +212 -0
- package/dist/cli/src/index.d.ts +2 -0
- package/dist/cli/src/index.js +5 -0
- package/dist/cli/src/run.d.ts +28 -0
- package/dist/cli/src/run.js +523 -0
- package/dist/cli/src/terminal.d.ts +3 -0
- package/dist/cli/src/terminal.js +135 -0
- package/dist/flow-messaging/src/client.d.ts +7 -1
- package/dist/flow-messaging/src/client.js +13 -1
- package/dist/flow-messaging/src/index.d.ts +38 -3
- package/dist/flow-messaging/src/index.js +242 -40
- package/dist/mcp/src/client.d.ts +54 -0
- package/dist/mcp/src/client.js +175 -0
- package/dist/mcp/src/describe.d.ts +41 -0
- package/dist/mcp/src/describe.js +110 -0
- package/dist/mcp/src/index.d.ts +4 -0
- package/dist/mcp/src/index.js +7 -0
- package/dist/mcp/src/server.d.ts +70 -0
- package/dist/mcp/src/server.js +252 -0
- package/dist/mcp/src/terminal.d.ts +24 -0
- package/dist/mcp/src/terminal.js +54 -0
- package/dist/simulators/src/index.d.ts +1 -1
- package/dist/simulators/src/page.html +267 -49
- package/dist/simulators/src/server.d.ts +27 -0
- package/dist/simulators/src/server.js +264 -40
- package/dist/src/agent.d.ts +69 -3
- package/dist/src/agent.js +162 -8
- package/dist/src/channels.d.ts +2 -0
- package/dist/src/conversation.d.ts +16 -1
- package/dist/src/conversation.js +140 -12
- package/dist/src/events.d.ts +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/model.d.ts +27 -0
- package/dist/src/model.js +85 -13
- package/dist/src/names.d.ts +5 -0
- package/dist/src/names.js +5 -0
- package/dist/src/skills.d.ts +4 -0
- package/dist/src/skills.js +23 -0
- package/dist/src/store.d.ts +2 -0
- package/dist/src/store.js +18 -0
- package/dist/src/tools.d.ts +69 -0
- package/dist/src/tools.js +17 -1
- package/dist/src/traces.js +32 -4
- package/dist/src/types.d.ts +2 -0
- package/dist/testing/src/account.d.ts +4 -0
- package/dist/testing/src/account.js +891 -0
- package/dist/testing/src/chat.d.ts +3 -0
- package/dist/testing/src/chat.js +125 -0
- package/dist/testing/src/check.d.ts +4 -0
- package/dist/testing/src/check.js +90 -0
- package/dist/testing/src/chinwag.js +1 -1
- package/dist/testing/src/cli.js +105 -18
- package/dist/testing/src/client.d.ts +2 -0
- package/dist/testing/src/client.js +4 -3
- package/dist/testing/src/flow-login.d.ts +176 -0
- package/dist/testing/src/flow-login.js +274 -0
- package/dist/testing/src/new.d.ts +15 -2
- package/dist/testing/src/new.js +80 -21
- package/dist/testing/src/plugin.d.ts +4 -0
- package/dist/testing/src/plugin.js +116 -0
- package/docs/agents.md +160 -0
- package/examples/cafe/src/agent.ts +6 -2
- package/examples/cafe/src/channels.ts +20 -0
- package/examples/cafe/src/cli.ts +24 -0
- package/examples/cafe/src/main.ts +17 -3
- package/examples/cafe/src/mcp.ts +22 -0
- package/examples/cafe/src/tools.ts +13 -0
- package/flow-messaging/README.md +66 -27
- package/package.json +30 -2
- package/spec/agent-endpoint.openapi.yaml +2 -2
- package/spec/nudges.openapi.yaml +2 -2
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
// `flow login | logout | whoami | setup | link`: sign in to flow.engineer once, then get the
|
|
2
|
+
// agent's keys into .env and a link to chat with it from a phone, with no key made by hand. `link`
|
|
3
|
+
// alone does it all: it signs in and runs setup first when this folder has not been set up.
|
|
4
|
+
// The calls to flow.engineer are in testing/src/flow-login.ts. Every command takes --json: one JSON
|
|
5
|
+
// object per line on stdout (login prints a "pending" line first, so a coding agent can show the
|
|
6
|
+
// link while it waits), every human line on stderr. Errors are {"error":{code,message,next}}, the
|
|
7
|
+
// next step the server's when it gave one. Every next step names the package (npx @flow-engineer/flow).
|
|
8
|
+
// Nothing reads stdin or needs a terminal, so a coding agent can run every command. login opens the
|
|
9
|
+
// browser itself and waits at most --timeout seconds; a code not approved by then stays in
|
|
10
|
+
// ~/.config/flow-framework/pending.json for `login --wait`, which polls the same code again.
|
|
11
|
+
// Exit status: 0 done, 1 usage or not signed in, 2 network, 3 the server refused (or the code was
|
|
12
|
+
// denied or expired), 4 the code is still waiting for approval, or flow.engineer asked to wait
|
|
13
|
+
// (429 slow_down: the wait is printed, and is retry_after with --json). A server's warnings go to
|
|
14
|
+
// stderr one per line, and into the JSON as `warnings`.
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync, writeSync } from "node:fs";
|
|
17
|
+
import { hostname } from "node:os";
|
|
18
|
+
import { basename, join } from "node:path";
|
|
19
|
+
import { parseArgs } from "node:util";
|
|
20
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
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
|
+
import { packageRoot } from "./new.js";
|
|
23
|
+
import { flowModelURL, hasModelKey, MODEL_KEY_NAMES, NO_MODEL_KEY } from "../../src/model.js";
|
|
24
|
+
import { RUN } from "../../src/names.js";
|
|
25
|
+
export const ACCOUNT_COMMANDS = new Set(["login", "logout", "whoami", "setup", "link", "status"]);
|
|
26
|
+
export const ACCOUNT_LINES = ` flow login [--wait | --no-wait] [--timeout <s>] [--force] [--no-browser] [--json]
|
|
27
|
+
Sign in to flow.engineer: opens the browser, prints the link and code,
|
|
28
|
+
and waits up to --timeout (default 120) for approval; --wait goes on
|
|
29
|
+
waiting for the same code, --no-wait returns at once
|
|
30
|
+
flow logout [--json] End this machine's sign-in and delete its token
|
|
31
|
+
flow whoami [--json] Show the account you are signed in as
|
|
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)
|
|
39
|
+
flow link [--channel telegram|whatsapp|imessage] [--timeout <s>] [--no-browser] [--json]
|
|
40
|
+
Print the link that opens a chat with this agent from a phone; signs in
|
|
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`;
|
|
46
|
+
const USAGE = `Usage:\n${ACCOUNT_LINES}
|
|
47
|
+
|
|
48
|
+
The token is kept in ~/.config/flow-framework/credentials.json (mode 0600); FLOW_FRAMEWORK_TOKEN, when
|
|
49
|
+
set, is used instead. setup never prints a key, only its last 4 characters, and keeps any value
|
|
50
|
+
already in .env unless --replace (a name with no value counts as missing), and sets .env to 0600.
|
|
51
|
+
The token goes only to the server that issued it, over https (http only to localhost). CI never signs in: setup --ci says how to give it the agent's key.
|
|
52
|
+
Nothing asks for input. A code not approved in time is kept in ~/.config/flow-framework/pending.json
|
|
53
|
+
(mode 0600) until it expires, for ${RUN} login --wait. Signed in already, login says so; --force signs in again.
|
|
54
|
+
|
|
55
|
+
Exit status: 0 done, 1 usage or not signed in, 2 network, 3 refused, denied or expired,
|
|
56
|
+
4 still waiting for approval (run ${RUN} login --wait), or flow.engineer asked to wait (try again after the time it gives).`;
|
|
57
|
+
const NEXT = { login: `${RUN} login`, wait: `${RUN} login --wait`, setup: `${RUN} setup`, link: `${RUN} link` };
|
|
58
|
+
const CHANNELS = ["telegram", "whatsapp", "imessage"];
|
|
59
|
+
const EXIT = { ok: 0, usage: 1, network: 2, refused: 3, pending: 4 };
|
|
60
|
+
class Stop extends Error {
|
|
61
|
+
code;
|
|
62
|
+
next;
|
|
63
|
+
exit;
|
|
64
|
+
/** For a 429: the seconds flow.engineer asked to wait, null when it gave none. */
|
|
65
|
+
retryAfter;
|
|
66
|
+
/** The server's warnings that came with the error. */
|
|
67
|
+
warnings = [];
|
|
68
|
+
constructor(code, message, next, exit) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.code = code;
|
|
71
|
+
this.next = next;
|
|
72
|
+
this.exit = exit;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A next step to run (a command or a URL) goes after "Next:"; anything else is a sentence for the
|
|
77
|
+
* owner (a trial limit's next) and is printed on its own line as it came.
|
|
78
|
+
*/
|
|
79
|
+
const isCommand = (next) => /^(?:npx|npm|flow|gh) |^https?:\/\//.test(next);
|
|
80
|
+
/** Runs one account command; resolves to the exit status. */
|
|
81
|
+
export async function accountCli(command, args) {
|
|
82
|
+
let values;
|
|
83
|
+
try {
|
|
84
|
+
({ values } = parseArgs({
|
|
85
|
+
args,
|
|
86
|
+
strict: true,
|
|
87
|
+
options: {
|
|
88
|
+
json: { type: "boolean", default: false },
|
|
89
|
+
help: { type: "boolean", short: "h", default: false },
|
|
90
|
+
...(command === "login" || command === "link" ? { "no-browser": { type: "boolean", default: false }, timeout: { type: "string", default: "120" } } : {}),
|
|
91
|
+
...(command === "login"
|
|
92
|
+
? {
|
|
93
|
+
wait: { type: "boolean", default: false },
|
|
94
|
+
"no-wait": { type: "boolean", default: false },
|
|
95
|
+
force: { type: "boolean", default: false },
|
|
96
|
+
}
|
|
97
|
+
: {}),
|
|
98
|
+
...(command === "setup" ? { replace: { type: "boolean", default: false }, rotate: { type: "string" }, now: { type: "boolean", default: false }, ci: { type: "boolean", default: false } } : {}),
|
|
99
|
+
...(command === "link" ? { channel: { type: "string", default: "telegram" } } : {}),
|
|
100
|
+
},
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.error(`flow ${command}: ${error.message.split(". ")[0]}\n\n${USAGE}`);
|
|
105
|
+
return EXIT.usage;
|
|
106
|
+
}
|
|
107
|
+
if (values.help) {
|
|
108
|
+
console.log(USAGE);
|
|
109
|
+
return EXIT.ok;
|
|
110
|
+
}
|
|
111
|
+
const timeoutSeconds = Number(values.timeout);
|
|
112
|
+
if ((command === "login" || command === "link") && (values.wait && values["no-wait"] || !(values.timeout !== "" && timeoutSeconds >= 0))) {
|
|
113
|
+
console.error(`flow ${command}: ${values.wait && values["no-wait"] ? "--wait and --no-wait cannot go together" : "--timeout is a number of seconds, 0 or more"}.\n\n${USAGE}`);
|
|
114
|
+
return EXIT.usage;
|
|
115
|
+
}
|
|
116
|
+
const channel = String(values.channel);
|
|
117
|
+
if (command === "link" && !CHANNELS.includes(channel)) {
|
|
118
|
+
console.error(`flow link: --channel takes ${CHANNELS.join(", ")}.\n\n${USAGE}`);
|
|
119
|
+
return EXIT.usage;
|
|
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
|
+
}
|
|
130
|
+
const json = values.json === true;
|
|
131
|
+
// Straight to the file descriptors, never buffered: a coding agent's cut-off command keeps every line.
|
|
132
|
+
const out = (report) => writeAll(1, `${JSON.stringify(report)}\n`);
|
|
133
|
+
const say = (line) => writeAll(json ? 2 : 1, `${line}\n`);
|
|
134
|
+
try {
|
|
135
|
+
if (command === "login") {
|
|
136
|
+
await login({ noBrowser: values["no-browser"] === true, wait: values.wait === true, noWait: values["no-wait"] === true, force: values.force === true, timeoutSeconds }, json, out, say);
|
|
137
|
+
}
|
|
138
|
+
else if (command === "logout")
|
|
139
|
+
await logout(json, out, say);
|
|
140
|
+
else if (command === "whoami")
|
|
141
|
+
await showAccount(json, out, say);
|
|
142
|
+
else if (command === "status")
|
|
143
|
+
await status(json, out, say);
|
|
144
|
+
else if (command === "setup")
|
|
145
|
+
await setupEnv({ replace: values.replace === true, rotate, now: values.now === true, ci: values.ci === true }, json, out, say);
|
|
146
|
+
else
|
|
147
|
+
await link({ channel, noBrowser: values["no-browser"] === true, timeoutSeconds }, json, out, say);
|
|
148
|
+
return EXIT.ok;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
const stop = asStop(error);
|
|
152
|
+
printWarnings(stop.warnings);
|
|
153
|
+
writeAll(2, `${!stop.next ? stop.message : isCommand(stop.next) ? `${stop.message} Next: ${stop.next}` : `${stop.message}\n${stop.next}`}\n`);
|
|
154
|
+
if (json) {
|
|
155
|
+
out(stop instanceof Pending ? stop.report : {
|
|
156
|
+
error: { code: stop.code, message: stop.message, next: stop.next ?? null, ...(stop.retryAfter !== undefined ? { retry_after: stop.retryAfter } : {}) },
|
|
157
|
+
...(stop.warnings.length ? { warnings: stop.warnings } : {}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return stop.exit;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** The server's warnings, one per line on stderr, as they came. */
|
|
164
|
+
function printWarnings(warnings) {
|
|
165
|
+
for (const warning of warnings)
|
|
166
|
+
writeAll(2, `${warning}\n`);
|
|
167
|
+
}
|
|
168
|
+
/** Writes all of the text to the descriptor before going on. */
|
|
169
|
+
function writeAll(fd, text) {
|
|
170
|
+
let bytes = Buffer.from(text);
|
|
171
|
+
while (bytes.length)
|
|
172
|
+
bytes = bytes.subarray(writeSync(fd, bytes));
|
|
173
|
+
}
|
|
174
|
+
function asStop(error) {
|
|
175
|
+
if (error instanceof Stop)
|
|
176
|
+
return error;
|
|
177
|
+
if (error instanceof LoginRefused) {
|
|
178
|
+
let stop;
|
|
179
|
+
if (error.status === 401)
|
|
180
|
+
stop = new Stop("not_signed_in", "Your sign-in has ended or was revoked.", error.next ?? NEXT.login, EXIT.refused);
|
|
181
|
+
else if (error.status === 429) {
|
|
182
|
+
// Asked to wait: retryable, so the same exit as a sign-in still waiting.
|
|
183
|
+
const wait = error.retryAfter;
|
|
184
|
+
stop = new Stop(error.code, `${error.message} ${wait !== undefined ? `Wait ${wait} s before trying again.` : "Wait a little before trying again."}`, error.next, EXIT.pending);
|
|
185
|
+
stop.retryAfter = wait ?? null;
|
|
186
|
+
}
|
|
187
|
+
// Status 0: refused here, before any request (an http login URL).
|
|
188
|
+
else
|
|
189
|
+
stop = new Stop(error.code, error.message, error.next, error.status === 0 ? EXIT.usage : EXIT.refused);
|
|
190
|
+
stop.warnings = warningsOf(error.fields);
|
|
191
|
+
return stop;
|
|
192
|
+
}
|
|
193
|
+
if (error instanceof TimedOut)
|
|
194
|
+
return new Stop("timeout", error.message, undefined, EXIT.network);
|
|
195
|
+
const cause = error.cause?.code;
|
|
196
|
+
return new Stop("network", `Could not reach ${loginUrl()}: ${cause ?? error.message}.`, undefined, EXIT.network);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The token to send and the server to send it to. A saved token goes only to the server that issued
|
|
200
|
+
* it: with FLOW_FRAMEWORK_LOGIN_URL now naming another one, it is not sent at all.
|
|
201
|
+
*/
|
|
202
|
+
function signedIn() {
|
|
203
|
+
const url = loginUrl();
|
|
204
|
+
const current = currentToken();
|
|
205
|
+
if (!current)
|
|
206
|
+
throw new Stop("not_signed_in", "Not signed in to flow.engineer.", NEXT.login, EXIT.usage);
|
|
207
|
+
if (current.url && current.url !== url) {
|
|
208
|
+
throw new Stop("signed_in_elsewhere", `The saved sign-in is for ${current.url}, not ${url}; its token goes only to the server that issued it.`, `${RUN} login --force`, EXIT.usage);
|
|
209
|
+
}
|
|
210
|
+
return { token: current.token, url };
|
|
211
|
+
}
|
|
212
|
+
const POLL_MIN_MS = 1000;
|
|
213
|
+
const WAIT_NEXT = `approve in the browser, then run: ${NEXT.wait}`;
|
|
214
|
+
async function login(options, json, out, say) {
|
|
215
|
+
const url = loginUrl();
|
|
216
|
+
if (!options.force && !options.wait) {
|
|
217
|
+
const already = await signedInAccount(url);
|
|
218
|
+
if (already) {
|
|
219
|
+
if (json)
|
|
220
|
+
out({ status: "signed_in", already: true, account: already });
|
|
221
|
+
say(`Already signed in as ${already.email}. ${RUN} login --force signs in again.`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// A waiting code's expires_at is only a hint: an approved code can still be collected for a while
|
|
226
|
+
// after it, so the code is polled and the server's expired_token decides.
|
|
227
|
+
let pending = (options.wait || options.resume) && !options.fresh ? readPending() : undefined;
|
|
228
|
+
if (pending && pending.url !== url) {
|
|
229
|
+
say(`The waiting sign-in code is from ${pending.url}, not ${url}; starting a new sign-in.`);
|
|
230
|
+
deletePending();
|
|
231
|
+
pending = undefined;
|
|
232
|
+
}
|
|
233
|
+
else if (options.wait && !pending && !options.fresh)
|
|
234
|
+
say("No sign-in is waiting; starting a new one.");
|
|
235
|
+
const waitNext = options.resume ? `approve in the browser, then run: ${options.resume.then}` : WAIT_NEXT;
|
|
236
|
+
const resumed = pending !== undefined;
|
|
237
|
+
let opened = false;
|
|
238
|
+
if (!pending) {
|
|
239
|
+
const start = await startDevice(url, `flow ${JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8")).version}`, hostname());
|
|
240
|
+
pending = { ...start, expires_at: Date.now() + start.expires_in * 1000, url };
|
|
241
|
+
savePending(pending);
|
|
242
|
+
opened = !options.noBrowser && (await openBrowser(pending.verification_uri_complete || pending.verification_uri));
|
|
243
|
+
}
|
|
244
|
+
const page = pending.verification_uri_complete || pending.verification_uri;
|
|
245
|
+
const expiresIn = Math.max(0, Math.round((pending.expires_at - Date.now()) / 1000));
|
|
246
|
+
// Written straight to the file descriptors, so a coding agent whose command is cut off still has them.
|
|
247
|
+
if (json)
|
|
248
|
+
out({ status: "pending", verification_uri: pending.verification_uri, verification_uri_complete: page, user_code: pending.user_code, expires_in: expiresIn, opened_browser: opened, resumed, next: waitNext });
|
|
249
|
+
if (resumed)
|
|
250
|
+
say(`Still waiting for ${page} (code ${pending.user_code}).`);
|
|
251
|
+
else if (opened)
|
|
252
|
+
say(`Opened your browser at ${page}`);
|
|
253
|
+
else
|
|
254
|
+
say(`${options.noBrowser ? "Open" : "Could not open a browser here; open"} ${page}`);
|
|
255
|
+
if (!resumed)
|
|
256
|
+
say(`and check it shows the code ${pending.user_code}, then approve.`);
|
|
257
|
+
if (options.noWait)
|
|
258
|
+
throw new Pending(pending, null, waitNext, options.resume?.then);
|
|
259
|
+
let interval = pending.interval;
|
|
260
|
+
const startedAt = Date.now();
|
|
261
|
+
const stopAt = startedAt + options.timeoutSeconds * 1000;
|
|
262
|
+
for (;;) {
|
|
263
|
+
await delay(Math.max(0, Math.min(interval * 1000, stopAt - Date.now())));
|
|
264
|
+
let answer;
|
|
265
|
+
try {
|
|
266
|
+
// Each poll gets what is left of --timeout (at least POLL_MIN_MS, so --timeout 0 still asks once).
|
|
267
|
+
answer = await pollToken(url, pending.device_code, Math.max(POLL_MIN_MS, Math.min(REQUEST_TIMEOUT_MS, stopAt - Date.now())));
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (error instanceof TimedOut && Date.now() >= stopAt)
|
|
271
|
+
throw new Pending(pending, options.timeoutSeconds, waitNext, options.resume?.then);
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
if (answer.status === "slow_down")
|
|
275
|
+
interval += 5; // RFC 8628: each slow_down adds 5 seconds for good
|
|
276
|
+
if (answer.status === "authorization_pending" || answer.status === "slow_down") {
|
|
277
|
+
if (Date.now() >= stopAt)
|
|
278
|
+
throw new Pending(pending, options.timeoutSeconds, waitNext, options.resume?.then);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
deletePending();
|
|
282
|
+
if (answer.status === "expired_token" && resumed) {
|
|
283
|
+
// A code from an earlier run ran out: once, a new sign-in with what is left of --timeout.
|
|
284
|
+
say("The pending sign-in code has expired; starting a new sign-in.");
|
|
285
|
+
const left = Math.max(0, options.timeoutSeconds - Math.floor((Date.now() - startedAt) / 1000));
|
|
286
|
+
return login({ ...options, force: true, fresh: true, timeoutSeconds: left }, json, out, say);
|
|
287
|
+
}
|
|
288
|
+
if (answer.status === "expired_token")
|
|
289
|
+
throw expired();
|
|
290
|
+
if (answer.status !== "signed_in")
|
|
291
|
+
throw new Stop("access_denied", "The sign-in was denied in the browser.", NEXT.login, EXIT.refused);
|
|
292
|
+
const path = saveCredentials({ token: answer.token, ...(answer.expires_at ? { expires_at: answer.expires_at } : {}), url });
|
|
293
|
+
const account = answer.account ?? (await whoami(url, answer.token));
|
|
294
|
+
if (json)
|
|
295
|
+
out({ status: "signed_in", account, token_path: path });
|
|
296
|
+
say(`Signed in as ${account.email}. The token is in ${path}.`);
|
|
297
|
+
if (!options.resume)
|
|
298
|
+
say(`Next, from your agent's folder: ${NEXT.setup}`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** The account of the token in use, or undefined when there is none or the server no longer takes it. */
|
|
303
|
+
async function signedInAccount(url) {
|
|
304
|
+
const current = currentToken();
|
|
305
|
+
// A saved token from another server is never sent here: this server needs its own sign-in.
|
|
306
|
+
if (!current || (current.url && current.url !== url))
|
|
307
|
+
return undefined;
|
|
308
|
+
try {
|
|
309
|
+
return await whoami(url, current.token);
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (error instanceof LoginRefused && error.status === 401)
|
|
313
|
+
return undefined;
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Not approved yet: the code stays in pending.json for `flow login --wait` (or `flow link` again), and the exit is 4. */
|
|
318
|
+
class Pending extends Stop {
|
|
319
|
+
report;
|
|
320
|
+
/** `waited` is the seconds this run polled, null when it did not (--no-wait). */
|
|
321
|
+
constructor(pending, waited, next = WAIT_NEXT, command = NEXT.wait) {
|
|
322
|
+
const page = pending.verification_uri_complete || pending.verification_uri;
|
|
323
|
+
super("pending", waited !== null ? `Not approved yet after ${waited} s; the code ${pending.user_code} still works at ${page}.` : `Approve the code ${pending.user_code} at ${page}.`, command, EXIT.pending);
|
|
324
|
+
this.report = { status: "pending", user_code: pending.user_code, verification_uri: pending.verification_uri, waited_s: waited ?? 0, next };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const expired = () => {
|
|
328
|
+
deletePending();
|
|
329
|
+
return new Stop("expired_token", "The code expired before it was approved.", NEXT.login, EXIT.refused);
|
|
330
|
+
};
|
|
331
|
+
/** The commands that open a page in the default browser here, in the order to try them. */
|
|
332
|
+
function browserCommands(page) {
|
|
333
|
+
if (process.platform === "darwin")
|
|
334
|
+
return [["open", page]];
|
|
335
|
+
if (process.platform === "win32")
|
|
336
|
+
return [["cmd", "/c", "start", "", page]];
|
|
337
|
+
const wsl = Boolean(process.env.WSL_DISTRO_NAME) || (existsSync("/proc/version") && /microsoft/i.test(readFileSync("/proc/version", "utf8")));
|
|
338
|
+
if (wsl)
|
|
339
|
+
return [["wslview", page], ["cmd.exe", "/c", "start", "", page]];
|
|
340
|
+
// xdg-open needs a display; without one (a server, a sandbox) there is no browser to open.
|
|
341
|
+
return process.env.DISPLAY || process.env.WAYLAND_DISPLAY ? [["xdg-open", page]] : [];
|
|
342
|
+
}
|
|
343
|
+
/** Opens the page in the default browser, never waiting for it; false when nothing here could (it is printed anyway). */
|
|
344
|
+
async function openBrowser(page) {
|
|
345
|
+
for (const [command, ...args] of browserCommands(page)) {
|
|
346
|
+
const started = await new Promise((done) => {
|
|
347
|
+
try {
|
|
348
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
349
|
+
child.once("spawn", () => done(true));
|
|
350
|
+
child.once("error", () => done(false));
|
|
351
|
+
child.unref();
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
done(false);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
if (started)
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
async function logout(json, out, say) {
|
|
363
|
+
const current = currentToken();
|
|
364
|
+
if (!current) {
|
|
365
|
+
if (json)
|
|
366
|
+
out({ status: "signed_out" });
|
|
367
|
+
say("Not signed in; nothing to do.");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
await revoke(current.url ?? loginUrl(), current.token);
|
|
371
|
+
const deleted = deleteCredentials();
|
|
372
|
+
if (json)
|
|
373
|
+
out({ status: "signed_out", revoked: true, deleted_file: deleted });
|
|
374
|
+
say("Signed out: the token is revoked on flow.engineer and deleted from this machine.");
|
|
375
|
+
if (current.from === "env")
|
|
376
|
+
say("FLOW_FRAMEWORK_TOKEN is still set in this shell: unset it.");
|
|
377
|
+
}
|
|
378
|
+
async function showAccount(json, out, say) {
|
|
379
|
+
const { token, url } = signedIn();
|
|
380
|
+
const account = await whoami(url, token);
|
|
381
|
+
if (json)
|
|
382
|
+
out(account);
|
|
383
|
+
say(`Signed in to flow.engineer as ${account.email} (account ${account.account}).`);
|
|
384
|
+
}
|
|
385
|
+
const SECRET = new Set([SETUP_ENV.cloud_key, SETUP_ENV.traces_key, SETUP_ENV.messaging_key, ...MODEL_KEY_NAMES]);
|
|
386
|
+
const masked = (name, value) => (SECRET.has(name) ? `${/^[a-z]+_(?:test_|live_)?/.exec(value)?.[0] ?? ""}…${value.slice(-4)}` : value);
|
|
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: "" };
|
|
390
|
+
/** The names set in a .env's lines, with their values unquoted. */
|
|
391
|
+
function envValues(lines) {
|
|
392
|
+
const values = new Map();
|
|
393
|
+
for (const line of lines) {
|
|
394
|
+
const match = ENV_LINE.exec(line);
|
|
395
|
+
if (match)
|
|
396
|
+
values.set(match[1], match[2].trim().replace(/^(["'])(.*)\1$/, "$2"));
|
|
397
|
+
}
|
|
398
|
+
return values;
|
|
399
|
+
}
|
|
400
|
+
const CHANNEL_NAME = { telegram: "Telegram", whatsapp: "WhatsApp", imessage: "iMessage" };
|
|
401
|
+
/** Each channel's sender in .env: the agent listens on a channel only when its sender is there. */
|
|
402
|
+
const SENDER_ENV = { telegram: SETUP_ENV.telegram_sender, whatsapp: SETUP_ENV.whatsapp_sender, imessage: SETUP_ENV.imessage_sender };
|
|
403
|
+
/** What a channel on Flow's shared senders cannot do: start a conversation. */
|
|
404
|
+
const REPLY_ONLY = {
|
|
405
|
+
whatsapp: "The agent can only reply after you write to it first, within 24 hours of your last message; it cannot message you first.",
|
|
406
|
+
imessage: "The agent can only reply after you write to it first; it cannot message you first.",
|
|
407
|
+
};
|
|
408
|
+
/** Refuses a folder that is not an agent's (no package.json), before anything signs in or is minted. */
|
|
409
|
+
function agentFolder(dir) {
|
|
410
|
+
if (existsSync(join(dir, "package.json")))
|
|
411
|
+
return;
|
|
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);
|
|
413
|
+
}
|
|
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) {
|
|
484
|
+
const steps = [
|
|
485
|
+
`CI never signs in. It runs the tests with the agent's test cloud key, ${SETUP_ENV.cloud_key}, as a secret.`,
|
|
486
|
+
`Run ${NEXT.setup} on your machine first, then copy the key from .env into your CI without printing it, for GitHub:`,
|
|
487
|
+
` grep '^${SETUP_ENV.cloud_key}=' .env | cut -d= -f2- | gh secret set ${SETUP_ENV.cloud_key}`,
|
|
488
|
+
`and in the workflow: ${RUN} test --synthetic --json`,
|
|
489
|
+
];
|
|
490
|
+
if (json)
|
|
491
|
+
out({ ci: { secret: SETUP_ENV.cloud_key, steps } });
|
|
492
|
+
for (const line of steps)
|
|
493
|
+
say(line);
|
|
494
|
+
return { answer: {} };
|
|
495
|
+
}
|
|
496
|
+
const dir = process.cwd();
|
|
497
|
+
agentFolder(dir);
|
|
498
|
+
const { token, url } = signedIn();
|
|
499
|
+
const file = join(dir, ".env");
|
|
500
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
501
|
+
const lines = text ? text.split("\n") : [];
|
|
502
|
+
if (lines.at(-1) === "")
|
|
503
|
+
lines.pop();
|
|
504
|
+
const before = envValues(lines);
|
|
505
|
+
const named = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name ?? basename(dir);
|
|
506
|
+
// The server takes 1 to 64 letters, digits, spaces, -, _ and .: a package's scope and other characters go.
|
|
507
|
+
const name = named.replace(/^@[^/]*\//, "").replace(/[^A-Za-z0-9 ._-]/g, "-").slice(0, 64) || "my-agent";
|
|
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");
|
|
522
|
+
let answer;
|
|
523
|
+
try {
|
|
524
|
+
// Every channel: each comes back with its shared sender only while it is open.
|
|
525
|
+
answer = await setup(url, token, { name, hostname: hostname(), channels: [...CHANNELS], have, ...(rotate ? { rotate } : {}), ...(rotateNow ? { rotate_now: true } : {}) });
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
// The keys minted anyway are not shown again, so whatever the error carries goes into .env first.
|
|
529
|
+
if (error instanceof LoginRefused) {
|
|
530
|
+
const written = writeEnv(file, lines, before, envEntries(error.fields), (n) => options.replace || rotatedEnv.has(n));
|
|
531
|
+
if (written.some((w) => w.action === "added" || w.action === "replaced"))
|
|
532
|
+
say(`Kept in .env before the error: ${written.map((w) => `${w.name} ${w.action}`).join(", ")}.`);
|
|
533
|
+
}
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
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)));
|
|
541
|
+
if (typeof answer.join_code !== "string" || !answer.join_code) {
|
|
542
|
+
const kept = written.filter((w) => w.action === "added" || w.action === "replaced").map((w) => w.name);
|
|
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);
|
|
544
|
+
stop.warnings = warnings;
|
|
545
|
+
throw stop;
|
|
546
|
+
}
|
|
547
|
+
printWarnings(warnings);
|
|
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])];
|
|
571
|
+
if (json) {
|
|
572
|
+
out({
|
|
573
|
+
agent: answer.agent_id, join_code: answer.join_code, env_written: { ".env": written }, minted: answer.minted ?? [],
|
|
574
|
+
dashboard_url: answer.dashboard_url ?? null, try_url: answer.try_url ?? null, telegram_link: answer.telegram_link ?? null,
|
|
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,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
say(`Agent ${answer.agent_id}. In .env:`);
|
|
586
|
+
for (const w of written)
|
|
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);
|
|
590
|
+
if (answer.dashboard_url)
|
|
591
|
+
say(`Its dashboard: ${answer.dashboard_url}`);
|
|
592
|
+
// Said by link instead, once, with its link.
|
|
593
|
+
if (!forLink)
|
|
594
|
+
say(model.line);
|
|
595
|
+
const people = trial?.imessage?.contacts;
|
|
596
|
+
if (people && typeof people.used === "number" && typeof people.limit === "number")
|
|
597
|
+
say(`iMessage: ${people.used} of ${people.limit} people`);
|
|
598
|
+
if (!forLink) {
|
|
599
|
+
for (const c of open)
|
|
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);
|
|
604
|
+
say(`Next: ${next.join(", then ")}`);
|
|
605
|
+
}
|
|
606
|
+
return { answer };
|
|
607
|
+
}
|
|
608
|
+
const trialNote = (t) => (t && typeof t.limit === "number" ? ` (free trial: ${t.used} of ${t.limit} messages used)` : "");
|
|
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) {
|
|
627
|
+
const written = [];
|
|
628
|
+
for (const [envName, value] of entries) {
|
|
629
|
+
if (typeof value !== "string" || !value)
|
|
630
|
+
continue;
|
|
631
|
+
const old = before.get(envName);
|
|
632
|
+
const at = lines.findIndex((line) => ENV_LINE.exec(line)?.[1] === envName);
|
|
633
|
+
let action;
|
|
634
|
+
if (!old) {
|
|
635
|
+
// Missing, or there with no value (`NAME=` from .env.example): the new value takes that line.
|
|
636
|
+
if (at >= 0)
|
|
637
|
+
lines[at] = `${envName}=${value}`;
|
|
638
|
+
else
|
|
639
|
+
lines.push(`${envName}=${value}`);
|
|
640
|
+
action = "added";
|
|
641
|
+
}
|
|
642
|
+
else if (old === value)
|
|
643
|
+
action = "unchanged";
|
|
644
|
+
else if (!replace(envName))
|
|
645
|
+
action = "kept";
|
|
646
|
+
else {
|
|
647
|
+
lines[at] = `${envName}=${value}`;
|
|
648
|
+
action = "replaced";
|
|
649
|
+
}
|
|
650
|
+
before.set(envName, action === "kept" ? old : value);
|
|
651
|
+
written.push({ name: envName, action, value: masked(envName, action === "kept" ? old : value) });
|
|
652
|
+
}
|
|
653
|
+
if (written.some((w) => w.action === "added" || w.action === "replaced"))
|
|
654
|
+
writeFileSync(file, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
655
|
+
if (existsSync(file))
|
|
656
|
+
chmodSync(file, 0o600); // a .env that was there keeps its old mode on write
|
|
657
|
+
return written;
|
|
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
|
+
}
|
|
756
|
+
/** The names set in ./.env, with what the environment sets winning. */
|
|
757
|
+
function dotenvValue() {
|
|
758
|
+
const file = join(process.cwd(), ".env");
|
|
759
|
+
const saved = existsSync(file) ? envValues(readFileSync(file, "utf8").split("\n")) : new Map();
|
|
760
|
+
return (name) => process.env[name]?.trim() || saved.get(name);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* The phone link for this folder's join code. Not set up yet (no join code or no Flow Messaging key
|
|
764
|
+
* in .env, or no sender for a WhatsApp or iMessage link): it signs in first when it has to (the same
|
|
765
|
+
* login, resuming a code already waiting) and runs setup, then asks flow.engineer for the channel's
|
|
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.
|
|
768
|
+
*/
|
|
769
|
+
async function link(options, json, out, say) {
|
|
770
|
+
const again = `${NEXT.link}${options.channel === "telegram" ? "" : ` --channel ${options.channel}`}`;
|
|
771
|
+
const what = CHANNEL_NAME[options.channel];
|
|
772
|
+
const needsSender = options.channel !== "telegram";
|
|
773
|
+
let value = dotenvValue();
|
|
774
|
+
let credit;
|
|
775
|
+
let had;
|
|
776
|
+
if (!value(SETUP_ENV.join_code) || !value(SETUP_ENV.messaging_key) || (needsSender && !value(SENDER_ENV[options.channel]))) {
|
|
777
|
+
agentFolder(process.cwd());
|
|
778
|
+
if (!(await signedInAccount(loginUrl()))) {
|
|
779
|
+
say("Not signed in to flow.engineer yet: signing in first.");
|
|
780
|
+
await login({ noBrowser: options.noBrowser, wait: false, noWait: false, force: true, timeoutSeconds: options.timeoutSeconds, resume: { then: again } }, json, out, say);
|
|
781
|
+
}
|
|
782
|
+
say("Setting up this agent's keys in .env.");
|
|
783
|
+
had = value;
|
|
784
|
+
({ answer: { model_credit: credit } } = await setupEnv({ replace: false, rotate: [], ci: false }, json, out, say, { channel: options.channel }));
|
|
785
|
+
value = dotenvValue();
|
|
786
|
+
}
|
|
787
|
+
const joinCode = value(SETUP_ENV.join_code);
|
|
788
|
+
if (!joinCode)
|
|
789
|
+
throw new Stop("no_join_code", `Setup wrote no ${SETUP_ENV.join_code} into .env.`, NEXT.setup, EXIT.refused);
|
|
790
|
+
// The server's own try_url when setup kept one for this join code and channel, else the same page built here.
|
|
791
|
+
const fromServer = value(SETUP_ENV.try_url);
|
|
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);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
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.
|
|
865
|
+
*/
|
|
866
|
+
async function directLink(url) {
|
|
867
|
+
let response;
|
|
868
|
+
try {
|
|
869
|
+
response = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
if (error.name === "TimeoutError")
|
|
873
|
+
throw new TimedOut(url, REQUEST_TIMEOUT_MS);
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
876
|
+
const text = await response.text();
|
|
877
|
+
let body = {};
|
|
878
|
+
try {
|
|
879
|
+
body = JSON.parse(text);
|
|
880
|
+
}
|
|
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
|
+
}
|
|
888
|
+
const code = typeof body.error === "string" ? body.error : `http_${response.status}`;
|
|
889
|
+
const detail = typeof body.error_description === "string" ? body.error_description : `${url} answered ${response.status}.`;
|
|
890
|
+
throw new Stop(code, detail, typeof body.next === "string" && body.next ? body.next : undefined, EXIT.refused);
|
|
891
|
+
}
|