@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,176 @@
1
+ export declare const LOGIN_URL = "https://flow.engineer";
2
+ /** The longest one request to flow.engineer may take; login's polls are cut shorter by what is left of --timeout. */
3
+ export declare const REQUEST_TIMEOUT_MS = 30000;
4
+ /** A login URL a token may go to: https, or http to this machine only (a local flow.engineer). */
5
+ export declare function safeLoginUrl(url: string): boolean;
6
+ /** flow.engineer, or FLOW_FRAMEWORK_LOGIN_URL, without trailing slashes (trimmed from the end, in linear time). */
7
+ export declare function loginUrl(env?: NodeJS.ProcessEnv): string;
8
+ /** ~/.config/flow-framework/credentials.json (XDG_CONFIG_HOME when set), written at mode 0600. */
9
+ export declare function credentialsPath(env?: NodeJS.ProcessEnv): string;
10
+ export interface Credentials {
11
+ token: string;
12
+ expires_at?: string;
13
+ /** The server that issued it, so logout revokes it there. */
14
+ url: string;
15
+ }
16
+ /**
17
+ * The token to use: FLOW_FRAMEWORK_TOKEN, else the credentials file's (with `url`, the server that
18
+ * issued it, the only one it is sent to); undefined when signed out.
19
+ */
20
+ export declare function currentToken(env?: NodeJS.ProcessEnv): {
21
+ token: string;
22
+ from: "env" | "file";
23
+ url?: string;
24
+ } | undefined;
25
+ export declare function readCredentials(env?: NodeJS.ProcessEnv): Credentials | undefined;
26
+ export declare function saveCredentials(credentials: Credentials, env?: NodeJS.ProcessEnv): string;
27
+ export declare function deleteCredentials(env?: NodeJS.ProcessEnv): boolean;
28
+ /** A device code started by `flow login` and not approved yet, kept for `flow login --wait`. */
29
+ export interface PendingLogin extends DeviceStart {
30
+ /** When the code expires, in ms since the epoch. */
31
+ expires_at: number;
32
+ /** The server that issued it. */
33
+ url: string;
34
+ }
35
+ /** ~/.config/flow-framework/pending.json, written at mode 0600 and deleted once the code is used or expired. */
36
+ export declare function pendingPath(env?: NodeJS.ProcessEnv): string;
37
+ export declare function readPending(env?: NodeJS.ProcessEnv): PendingLogin | undefined;
38
+ export declare function savePending(pending: PendingLogin, env?: NodeJS.ProcessEnv): void;
39
+ export declare function deletePending(env?: NodeJS.ProcessEnv): void;
40
+ export interface DeviceStart {
41
+ device_code: string;
42
+ user_code: string;
43
+ verification_uri: string;
44
+ verification_uri_complete?: string;
45
+ interval: number;
46
+ expires_in: number;
47
+ }
48
+ export type TokenAnswer = {
49
+ status: "signed_in";
50
+ token: string;
51
+ expires_at?: string;
52
+ account?: Account;
53
+ } | {
54
+ status: "authorization_pending" | "slow_down" | "expired_token" | "access_denied";
55
+ };
56
+ export interface Account {
57
+ email: string;
58
+ account: string;
59
+ }
60
+ /** setup's flow_key: the agent's one Flow key, for this mode (test or live). */
61
+ export interface FlowKey {
62
+ env?: string;
63
+ value?: string;
64
+ kid?: string;
65
+ mode?: string;
66
+ scopes?: string[];
67
+ agent_id?: string;
68
+ minted?: boolean;
69
+ /** When the key it replaced stops working (null: at once, or there was none). */
70
+ previous_expires_at?: string | null;
71
+ }
72
+ export interface SetupAnswer {
73
+ agent_id: string;
74
+ /** Only the keys minted in this call come back (those the request did not say it has). */
75
+ cloud_key?: string;
76
+ traces_key?: string;
77
+ messaging_key?: string;
78
+ /** Each channel's shared sender and phone link, only while Flow has that channel open. */
79
+ telegram_sender?: string;
80
+ whatsapp_sender?: string;
81
+ imessage_sender?: string;
82
+ whatsapp_link?: string;
83
+ imessage_link?: string;
84
+ /** Missing only when the server could not make one; the keys it minted are written anyway. */
85
+ join_code?: string;
86
+ account?: Account;
87
+ app_id?: string;
88
+ dashboard_url?: string;
89
+ /** The flow.engineer page that opens Telegram with the join message filled in. */
90
+ try_url?: string;
91
+ telegram_link?: string;
92
+ /** The try page per channel with a sender: {telegram: ".../try/<code>?c=telegram", ...}. */
93
+ try_urls?: Record<string, string>;
94
+ /** The account's free-trial counts per channel; limit null is unlimited. iMessage also counts the people it may reach. */
95
+ trial?: Record<string, {
96
+ limit: number | null;
97
+ used: number;
98
+ contacts?: {
99
+ limit: number;
100
+ used: number;
101
+ };
102
+ }>;
103
+ /** The agent's one Flow key (FLOW_KEY), only when made in this call: its value is shown once. */
104
+ flow_key?: FlowKey;
105
+ /** The account's Flow trial model credit, when the agent has no provider key; absent: none (warnings say why). */
106
+ model_credit?: {
107
+ limit_usd?: number;
108
+ left_usd?: number;
109
+ base_url?: string;
110
+ default_model?: string;
111
+ };
112
+ /** The answer fields of the keys made in this call. */
113
+ minted?: string[];
114
+ next?: string[];
115
+ /** Up to 20 lines for the owner, shown as they are. */
116
+ warnings?: string[];
117
+ }
118
+ /** The answer's warnings: its strings only, at most 20. */
119
+ export declare function warningsOf(body: Record<string, unknown>): string[];
120
+ /** Retry-After as seconds from now (delta-seconds or an HTTP date); undefined when absent or unreadable. */
121
+ export declare function retryAfterSeconds(header: string | null, now?: number): number | undefined;
122
+ /**
123
+ * The server answered with an error status; `code` is its `error` field when it gave one, `next` its
124
+ * next step, and `fields` the whole answer (a failed setup carries the keys it minted anyway).
125
+ */
126
+ export declare class LoginRefused extends Error {
127
+ readonly status: number;
128
+ readonly code: string;
129
+ readonly next: string | undefined;
130
+ readonly fields: Record<string, unknown>;
131
+ /** Seconds the server asked to wait (Retry-After), for a 429. */
132
+ readonly retryAfter: number | undefined;
133
+ constructor(status: number, code: string, message: string, next?: string, fields?: Record<string, unknown>, retryAfter?: number);
134
+ }
135
+ /** The server answered nothing in time: `ms` is how long this request was given. */
136
+ export declare class TimedOut extends Error {
137
+ readonly ms: number;
138
+ constructor(url: string, ms: number);
139
+ }
140
+ /** `clientName` and `hostname` are shown on the approval page. */
141
+ export declare function startDevice(url: string, clientName: string, hostname: string): Promise<DeviceStart>;
142
+ /** `timeoutMs` cuts this one poll short (login gives it what is left of --timeout). */
143
+ export declare function pollToken(url: string, deviceCode: string, timeoutMs?: number): Promise<TokenAnswer>;
144
+ export declare function revoke(url: string, token: string): Promise<void>;
145
+ export declare function whoami(url: string, token: string): Promise<Account>;
146
+ export declare function setup(url: string, token: string, request: {
147
+ name: string;
148
+ hostname: string;
149
+ channels: string[];
150
+ have: string[];
151
+ rotate?: true | string[];
152
+ rotate_now?: true;
153
+ }): Promise<SetupAnswer>;
154
+ /** GET /credit's answer: the account's Flow trial credit in US dollars. */
155
+ export interface ModelCredit {
156
+ limit_usd?: number;
157
+ spent_usd?: number;
158
+ left_usd?: number;
159
+ default_model?: string;
160
+ models?: string[];
161
+ }
162
+ /** The credit left on the account of a FLOW_KEY; `baseURL` is Flow's model API (flowModelURL). */
163
+ export declare function modelCredit(baseURL: string, key: string): Promise<ModelCredit>;
164
+ /** The agent's .env names for what setup hands back; only the keys' and the join's names are sent as `have`. */
165
+ export declare const SETUP_ENV: {
166
+ readonly cloud_key: "FLOW_FRAMEWORK_CLOUD_KEY";
167
+ readonly traces_key: "FLOW_FRAMEWORK_TRACES_KEY";
168
+ readonly messaging_key: "FLOW_MESSAGING_KEY";
169
+ readonly telegram_sender: "FLOW_MESSAGING_TELEGRAM_SENDER";
170
+ readonly whatsapp_sender: "FLOW_MESSAGING_WHATSAPP_SENDER";
171
+ readonly imessage_sender: "FLOW_MESSAGING_IMESSAGE_SENDER";
172
+ readonly join_code: "FLOW_FRAMEWORK_JOIN_CODE";
173
+ readonly try_url: "FLOW_FRAMEWORK_TRY_URL";
174
+ };
175
+ /** The phone link for a join code, built as the server builds its try_url, for a .env from before setup kept it. */
176
+ export declare function tryLink(url: string, joinCode: string, channel: string): string;
@@ -0,0 +1,274 @@
1
+ // The flow.engineer side of `flow login`, `logout`, `whoami` and `setup`: every call to it, and
2
+ // where the Flow CLI token is kept. Only this file knows the server's routes and field names
3
+ // (flow.engineer's CLI sign-in contract).
4
+ // POST /cli/device {client_name, hostname} -> {device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}
5
+ // POST /cli/token {device_code} -> {access_token, expires_at, account}, or 400 {error} as RFC 8628 has it:
6
+ // authorization_pending, slow_down, expired_token, access_denied, invalid_grant
7
+ // POST /cli/revoke (bearer) {} ends this token
8
+ // GET /cli/whoami (bearer) -> {account, email, token}
9
+ // POST /cli/setup (bearer) {name, hostname, channels, have, rotate?, rotate_now?}
10
+ // -> {agent_id, cloud_key?, traces_key?, messaging_key?, flow_key?, model_credit?, join_code, account, app_id,
11
+ // dashboard_url, try_url, <channel>_sender?, <channel>_link? (telegram, whatsapp, imessage: each only while
12
+ // that shared sender is open), try_urls?, trial?, minted, next}; a 502 carries every field that worked too;
13
+ // flow_key is the agent's one Flow key, {env: "FLOW_KEY", value: "flow_test_...", kid, mode, scopes, agent_id,
14
+ // minted: true, previous_expires_at}, only when made in this call (in every answer of that call, errors too;
15
+ // absent when have names FLOW_KEY and rotate does not); for now only the model uses it;
16
+ // model_credit is the account's Flow trial model credit, {limit_usd, left_usd, base_url, default_model}, when
17
+ // have names no provider key and the account has the credit (left_usd 0 when used up), else absent with
18
+ // the reason in warnings; every folder's setup makes its own key, the credit is the account's;
19
+ // rotate is true (every key) or the answer fields to make again, such as ["flow_key"]; rotate_now: true
20
+ // ends the old FLOW_KEY at once instead of 24 h later (previous_expires_at);
21
+ // 429 slow_down with Retry-After; 403 for a trial limit (sandbox_allowance_used, ...), its next prose
22
+ // Any answer may carry warnings: up to 20 strings to show the owner as they are.
23
+ // GET <model URL>/credit (bearer FLOW_KEY) -> {limit_usd, spent_usd, left_usd, default_model, models};
24
+ // the model URL is src/model.ts's flowModelURL (flow.engineer/llm/framework/v1); 401 invalid_api_key,
25
+ // 403 insufficient_scope (the key lacks model), 402 trial_credit_used or trial_credit_unavailable
26
+ // Every error is flat: {"error": "<code>", "error_description": "<sentence>", "next": "<command, URL or sentence>"}
27
+ // (the model API's are OpenAI's shape, {"error": {message, type, code, next}}).
28
+ // The token is opaque (fcli_...), good only for /cli/*, and sent only to the server that issued it,
29
+ // over https (http only to this machine). Every request gives up after REQUEST_TIMEOUT_MS at most.
30
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { configDir, configFile, RUN, setting } from "../../src/names.js";
33
+ export const LOGIN_URL = "https://flow.engineer";
34
+ /** The longest one request to flow.engineer may take; login's polls are cut shorter by what is left of --timeout. */
35
+ export const REQUEST_TIMEOUT_MS = 30_000;
36
+ /** A login URL a token may go to: https, or http to this machine only (a local flow.engineer). */
37
+ export function safeLoginUrl(url) {
38
+ const parsed = URL.parse(url);
39
+ if (!parsed)
40
+ return false;
41
+ if (parsed.protocol === "https:")
42
+ return true;
43
+ return parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
44
+ }
45
+ /** flow.engineer, or FLOW_FRAMEWORK_LOGIN_URL, without trailing slashes (trimmed from the end, in linear time). */
46
+ export function loginUrl(env = process.env) {
47
+ const url = setting("LOGIN_URL", env) || LOGIN_URL;
48
+ let end = url.length;
49
+ while (end > 0 && url[end - 1] === "/")
50
+ end--;
51
+ return url.slice(0, end);
52
+ }
53
+ /** ~/.config/flow-framework/credentials.json (XDG_CONFIG_HOME when set), written at mode 0600. */
54
+ export function credentialsPath(env = process.env) {
55
+ return join(configDir(env), "credentials.json");
56
+ }
57
+ /** The credentials file to read or delete: credentialsPath, else ~/.config/chinwag/credentials.json while only it exists. */
58
+ const savedCredentialsPath = (env) => configFile("credentials.json", env);
59
+ /**
60
+ * The token to use: FLOW_FRAMEWORK_TOKEN, else the credentials file's (with `url`, the server that
61
+ * issued it, the only one it is sent to); undefined when signed out.
62
+ */
63
+ export function currentToken(env = process.env) {
64
+ const fromEnv = setting("TOKEN", env)?.trim();
65
+ if (fromEnv)
66
+ return { token: fromEnv, from: "env" };
67
+ const saved = readCredentials(env);
68
+ return saved ? { token: saved.token, from: "file", url: saved.url || LOGIN_URL } : undefined;
69
+ }
70
+ export function readCredentials(env = process.env) {
71
+ const path = savedCredentialsPath(env);
72
+ if (!existsSync(path))
73
+ return undefined;
74
+ try {
75
+ const saved = JSON.parse(readFileSync(path, "utf8"));
76
+ return typeof saved.token === "string" && saved.token ? saved : undefined;
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ export function saveCredentials(credentials, env = process.env) {
83
+ const path = credentialsPath(env);
84
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
85
+ writeFileSync(path, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
86
+ chmodSync(path, 0o600); // a file that was already there keeps its mode on write
87
+ return path;
88
+ }
89
+ export function deleteCredentials(env = process.env) {
90
+ const path = savedCredentialsPath(env);
91
+ if (!existsSync(path))
92
+ return false;
93
+ rmSync(path);
94
+ return true;
95
+ }
96
+ /** ~/.config/flow-framework/pending.json, written at mode 0600 and deleted once the code is used or expired. */
97
+ export function pendingPath(env = process.env) {
98
+ return join(configDir(env), "pending.json");
99
+ }
100
+ export function readPending(env = process.env) {
101
+ try {
102
+ const saved = JSON.parse(readFileSync(pendingPath(env), "utf8"));
103
+ return typeof saved.device_code === "string" && typeof saved.expires_at === "number" ? saved : undefined;
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ export function savePending(pending, env = process.env) {
110
+ const path = pendingPath(env);
111
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
112
+ writeFileSync(path, `${JSON.stringify(pending, null, 2)}\n`, { mode: 0o600 });
113
+ chmodSync(path, 0o600);
114
+ }
115
+ export function deletePending(env = process.env) {
116
+ rmSync(pendingPath(env), { force: true });
117
+ }
118
+ /** The answer's warnings: its strings only, at most 20. */
119
+ export function warningsOf(body) {
120
+ return Array.isArray(body.warnings) ? body.warnings.filter((w) => typeof w === "string" && w !== "").slice(0, 20) : [];
121
+ }
122
+ /** Retry-After as seconds from now (delta-seconds or an HTTP date); undefined when absent or unreadable. */
123
+ export function retryAfterSeconds(header, now = Date.now()) {
124
+ if (!header)
125
+ return undefined;
126
+ const value = header.trim();
127
+ if (/^\d+$/.test(value))
128
+ return Number(value);
129
+ const at = Date.parse(value);
130
+ return Number.isNaN(at) ? undefined : Math.max(0, Math.ceil((at - now) / 1000));
131
+ }
132
+ /**
133
+ * The server answered with an error status; `code` is its `error` field when it gave one, `next` its
134
+ * next step, and `fields` the whole answer (a failed setup carries the keys it minted anyway).
135
+ */
136
+ export class LoginRefused extends Error {
137
+ status;
138
+ code;
139
+ next;
140
+ fields;
141
+ /** Seconds the server asked to wait (Retry-After), for a 429. */
142
+ retryAfter;
143
+ constructor(status, code, message, next, fields = {}, retryAfter) {
144
+ super(message);
145
+ this.status = status;
146
+ this.code = code;
147
+ this.next = next;
148
+ this.fields = fields;
149
+ this.retryAfter = retryAfter;
150
+ }
151
+ }
152
+ /** The server answered nothing in time: `ms` is how long this request was given. */
153
+ export class TimedOut extends Error {
154
+ ms;
155
+ constructor(url, ms) {
156
+ super(`${url} did not answer within ${Math.ceil(ms / 1000)} s.`);
157
+ this.ms = ms;
158
+ }
159
+ }
160
+ async function call(url, path, options = {}) {
161
+ if (!safeLoginUrl(url)) {
162
+ throw new LoginRefused(0, "insecure_login_url", `${url} is not https: the sign-in and its token go only over https (http only to localhost).`, `set FLOW_FRAMEWORK_LOGIN_URL to an https address, or unset it to use ${LOGIN_URL}`);
163
+ }
164
+ const timeoutMs = Math.max(0, Math.min(options.timeoutMs ?? REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS));
165
+ const headers = { accept: "application/json" };
166
+ if (options.token)
167
+ headers.authorization = `Bearer ${options.token}`;
168
+ if (options.body !== undefined)
169
+ headers["content-type"] = "application/json";
170
+ let response;
171
+ let text;
172
+ try {
173
+ response = await fetch(`${url}${path}`, {
174
+ method: options.method ?? "POST",
175
+ headers,
176
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
177
+ redirect: options.token ? "error" : "follow", // a redirect would carry the token to another address
178
+ signal: AbortSignal.timeout(timeoutMs),
179
+ });
180
+ text = await response.text();
181
+ }
182
+ catch (error) {
183
+ if (error.name === "TimeoutError")
184
+ throw new TimedOut(url, timeoutMs);
185
+ throw error;
186
+ }
187
+ let body = {};
188
+ try {
189
+ body = text ? JSON.parse(text) : {};
190
+ }
191
+ catch {
192
+ if (response.ok)
193
+ throw new LoginRefused(response.status, "bad_answer", `${url}${path} answered ${response.status} with no JSON.`);
194
+ }
195
+ return { status: response.status, body, retryAfter: retryAfterSeconds(response.headers.get("retry-after")) };
196
+ }
197
+ function refused(path, answer, url) {
198
+ // A bare 404: this server has no CLI sign-in (an older one, or not flow.engineer at all).
199
+ if (answer.status === 404 && typeof answer.body.error !== "string") {
200
+ return new LoginRefused(404, "server_unsupported", `${url} has no ${path}, so it cannot sign in the Flow CLI: it is an older server, or not flow.engineer.`, url === LOGIN_URL ? `${RUN}@latest login` : `unset FLOW_FRAMEWORK_LOGIN_URL to use ${LOGIN_URL}, or point it at a server that has /cli/`, answer.body);
201
+ }
202
+ const code = typeof answer.body.error === "string" ? answer.body.error : answer.status === 401 ? "not_signed_in" : `http_${answer.status}`;
203
+ const detail = typeof answer.body.error_description === "string" ? answer.body.error_description : typeof answer.body.message === "string" ? answer.body.message : "";
204
+ const next = typeof answer.body.next === "string" && answer.body.next ? answer.body.next : undefined;
205
+ const sentence = detail.endsWith(".") ? detail.slice(0, -1) : detail;
206
+ return new LoginRefused(answer.status, code, `${path} answered ${answer.status} ${code}${sentence ? `: ${sentence}` : ""}.`, next, answer.body, answer.retryAfter);
207
+ }
208
+ /** `clientName` and `hostname` are shown on the approval page. */
209
+ export async function startDevice(url, clientName, hostname) {
210
+ const answer = await call(url, "/cli/device", { body: { client_name: clientName, hostname } });
211
+ if (answer.status !== 200 || typeof answer.body.device_code !== "string")
212
+ throw refused("/cli/device", answer, url);
213
+ return answer.body;
214
+ }
215
+ const WAITING = new Set(["authorization_pending", "slow_down", "expired_token", "access_denied"]);
216
+ /** `timeoutMs` cuts this one poll short (login gives it what is left of --timeout). */
217
+ export async function pollToken(url, deviceCode, timeoutMs) {
218
+ const answer = await call(url, "/cli/token", { body: { device_code: deviceCode }, timeoutMs });
219
+ const token = answer.body.access_token;
220
+ if (answer.status === 200 && typeof token === "string") {
221
+ return { status: "signed_in", token, expires_at: answer.body.expires_at, account: answer.body.account };
222
+ }
223
+ const error = answer.body.error;
224
+ if (typeof error === "string" && WAITING.has(error))
225
+ return { status: error };
226
+ throw refused("/cli/token", answer, url);
227
+ }
228
+ export async function revoke(url, token) {
229
+ const answer = await call(url, "/cli/revoke", { token, body: {} });
230
+ // 401: the token had already ended, which is what logout wants.
231
+ if (answer.status >= 300 && answer.status !== 401)
232
+ throw refused("/cli/revoke", answer, url);
233
+ }
234
+ export async function whoami(url, token) {
235
+ const answer = await call(url, "/cli/whoami", { token, method: "GET" });
236
+ if (answer.status !== 200)
237
+ throw refused("/cli/whoami", answer, url);
238
+ return answer.body;
239
+ }
240
+ export async function setup(url, token, request) {
241
+ const answer = await call(url, "/cli/setup", { token, body: request });
242
+ // A 200 is returned whole even without a join code: the keys it minted are shown only now.
243
+ if (answer.status !== 200)
244
+ throw refused("/cli/setup", answer, url);
245
+ return answer.body;
246
+ }
247
+ /** The credit left on the account of a FLOW_KEY; `baseURL` is Flow's model API (flowModelURL). */
248
+ export async function modelCredit(baseURL, key) {
249
+ let end = baseURL.length;
250
+ while (end > 0 && baseURL[end - 1] === "/")
251
+ end--;
252
+ const answer = await call(baseURL.slice(0, end), "/credit", { token: key, method: "GET" });
253
+ if (answer.status !== 200) {
254
+ // OpenAI's error shape: {"error": {message, code, next}}.
255
+ const inner = answer.body.error && typeof answer.body.error === "object" ? answer.body.error : undefined;
256
+ throw refused("/credit", inner ? { ...answer, body: { error: inner.code, error_description: inner.message, next: inner.next } } : answer, baseURL);
257
+ }
258
+ return answer.body;
259
+ }
260
+ /** The agent's .env names for what setup hands back; only the keys' and the join's names are sent as `have`. */
261
+ export const SETUP_ENV = {
262
+ cloud_key: "FLOW_FRAMEWORK_CLOUD_KEY",
263
+ traces_key: "FLOW_FRAMEWORK_TRACES_KEY",
264
+ messaging_key: "FLOW_MESSAGING_KEY",
265
+ telegram_sender: "FLOW_MESSAGING_TELEGRAM_SENDER",
266
+ whatsapp_sender: "FLOW_MESSAGING_WHATSAPP_SENDER",
267
+ imessage_sender: "FLOW_MESSAGING_IMESSAGE_SENDER",
268
+ join_code: "FLOW_FRAMEWORK_JOIN_CODE",
269
+ try_url: "FLOW_FRAMEWORK_TRY_URL",
270
+ };
271
+ /** The phone link for a join code, built as the server builds its try_url, for a .env from before setup kept it. */
272
+ export function tryLink(url, joinCode, channel) {
273
+ return `${url}/try/${encodeURIComponent(joinCode)}?c=${encodeURIComponent(channel)}`;
274
+ }
@@ -2,8 +2,21 @@
2
2
  export declare function checkName(name: string): void;
3
3
  /** The @flow-engineer/flow package's folder: the root of a clone, or the installed package. */
4
4
  export declare function packageRoot(from?: string): string;
5
+ /** The longest display name: the join message carries it (flow.engineer's /try cuts it there too). */
6
+ export declare const MAX_DISPLAY_NAME = 60;
7
+ /** The name people call the agent, from the package name when none was given: "my-shop" is "my shop". */
8
+ export declare function displayNameOf(given: string | undefined, packageName: string): string;
9
+ interface MakeOptions {
10
+ root?: string;
11
+ /** What people call the agent; the package name in words when not given. */
12
+ displayName?: string;
13
+ }
5
14
  /** Makes `<into>/<name>` (the current folder by default) and returns it. */
6
- export declare function newStandalone(name: string, options?: {
15
+ export declare function newStandalone(name: string, options?: MakeOptions & {
7
16
  into?: string;
8
- root?: string;
9
17
  }): string;
18
+ /** The package name `flow init` gives the agent in `dir`: the folder's name in lowercase and dashes. */
19
+ export declare function folderName(dir: string): string;
20
+ /** Makes the agent in `dir` (the current folder by default), named after the folder; returns its name. */
21
+ export declare function initHere(dir?: string, options?: MakeOptions): string;
22
+ export {};
@@ -1,9 +1,12 @@
1
1
  // `flow new <name>`: makes ./<name>/, an agent of its own started from the café example (Pip),
2
- // that depends on the @flow-engineer/flow package. Its imports become "@flow-engineer/flow" and "@flow-engineer/flow/simulators", and
3
- // the coding-agent skills go in .claude/skills/, with an AGENTS.md (and a CLAUDE.md importing it)
4
- // pointing at them. Inside a clone, `npm run new -- <name>` makes agents/<name>/ instead.
2
+ // that depends on the @flow-engineer/flow package. Its imports become "@flow-engineer/flow",
3
+ // "@flow-engineer/flow/simulators" and "@flow-engineer/flow/flow-messaging", and the coding-agent
4
+ // skills go in .claude/skills/, with an AGENTS.md (and a CLAUDE.md importing it) pointing at them.
5
+ // `flow init` makes the same agent in the current folder. Inside a clone, `npm run new -- <name>`
6
+ // makes agents/<name>/ instead. `--display-name` is what people call the agent ("FlatX agent"), kept
7
+ // as package.json's displayName: the message a phone sends to reach it is "Hi <it> (ref <code>)".
5
8
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { dirname, join } from "node:path";
9
+ import { basename, dirname, join } from "node:path";
7
10
  import { fileURLToPath } from "node:url";
8
11
  const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
9
12
  /** Throws unless `name` can be an agent's folder and package name. */
@@ -36,8 +39,10 @@ const TSCONFIG = {
36
39
  },
37
40
  include: ["src"],
38
41
  };
39
- const ENV_EXAMPLE = `# One model key. The first line with a value is the one used.
40
- # Make it at the provider, then paste it here. Never commit this file.
42
+ const ENV_EXAMPLE = `# The model key. The first line with a value is the one used. npx @flow-engineer/flow setup writes
43
+ # FLOW_KEY, the agent's one Flow key, which calls models on the account's free trial credit, so no
44
+ # provider key is needed; your own, made at the provider and pasted on its line, wins over it.
45
+ # Never commit this file.
41
46
  # OPENROUTER_API_KEY https://openrouter.ai/keys
42
47
  # OPENAI_API_KEY https://platform.openai.com/api-keys
43
48
  # ANTHROPIC_API_KEY https://console.anthropic.com/settings/keys
@@ -46,11 +51,12 @@ OPENROUTER_API_KEY=
46
51
  OPENAI_API_KEY=
47
52
  ANTHROPIC_API_KEY=
48
53
  GEMINI_API_KEY=
54
+ FLOW_KEY=
49
55
 
50
56
  # Optional. The model id for that provider; empty is that provider's default.
51
57
  # CAFE_MODEL=
52
58
 
53
- # The test cloud (npx flow test --synthetic) and traces. Sign in at https://flow.engineer/observe,
59
+ # The test cloud (npx @flow-engineer/flow test --synthetic) and traces. Sign in at https://flow.engineer/observe,
54
60
  # add the agent, and on its Developers page make a CI key (oci_..., shown once); the trace key is
55
61
  # from the same site.
56
62
  FLOW_FRAMEWORK_CLOUD=https://flow.engineer/observe
@@ -62,21 +68,61 @@ const agentsMd = (name) => `# ${name}
62
68
 
63
69
  A chat agent on Flow Framework (https://github.com/flow-engineer/flow), started from its café example (Pip).
64
70
 
65
- - Load the skills in \`.claude/skills/\` before changing it: \`flow-framework\` to build the agent, \`flow-framework-tools\`
66
- for its tools and their test files, \`flow-framework-tests\` for its test scenarios.
71
+ - Load the skills in \`.claude/skills/\` before changing it: \`flow-framework\` to build the agent (and its MCP
72
+ server and CLI), \`flow-framework-tools\` for its tools and their test files, \`flow-framework-tests\` for its test
73
+ scenarios, \`flow-framework-context\` to gather what the tools are written from out of an existing
74
+ codebase, and \`flow-framework-auth\` for sign-in.
67
75
  - \`agent/AGENTS.md\` is the agent's own prompt, not instructions for coding agents.
68
- - \`npm start\` runs it in the playground. The owner puts one model key in \`.env\` (never read or
69
- print it): \`OPENROUTER_API_KEY\`, \`OPENAI_API_KEY\`, \`ANTHROPIC_API_KEY\` or \`GEMINI_API_KEY\`.
70
- \`npm run typecheck\` checks the types, \`npm test -- --synthetic\` runs the tests in the test
71
- cloud at https://flow.engineer/observe with the owner's \`FLOW_FRAMEWORK_CLOUD_KEY\` (\`npx flow --help\`).
76
+ - The model key goes in \`.env\` (never read or print it): \`npx @flow-engineer/flow link\` (or \`setup\`)
77
+ writes \`FLOW_KEY\`, the agent's one Flow key, which calls models on Flow's free trial credit, so no provider key is needed; a key the owner types
78
+ (\`OPENROUTER_API_KEY\`, \`OPENAI_API_KEY\`, \`ANTHROPIC_API_KEY\` or \`GEMINI_API_KEY\`) wins over it. \`npm start\` runs it in the playground,
79
+ and \`npx @flow-engineer/flow link\` signs in, puts its other keys in \`.env\` and prints the link that
80
+ reaches it from a phone while \`npm start\` runs.
81
+ - When the owner asks how to test it, recommend first the playground (\`npm start\`, then open the link
82
+ it prints: the test people and the channels side by side), then their phone (the \`link\`).
83
+ - Your own checks, not the owner's way to test: \`npx @flow-engineer/flow chat\` talks to it in the
84
+ terminal, \`npm run typecheck\` checks the types, \`npm test -- --synthetic\` runs the tests in the
85
+ test cloud at https://flow.engineer/observe with the owner's \`FLOW_FRAMEWORK_CLOUD_KEY\`
86
+ (\`npx @flow-engineer/flow --help\`).
87
+ - The guide for coding agents, with exit codes and next steps: https://github.com/flow-engineer/flow/blob/main/docs/agents.md
88
+ - \`npm run mcp\` serves the same tools as an MCP server (src/mcp.ts) and \`npm run cli -- --help\` is
89
+ its command line (src/cli.ts). \`npx flow check\` checks the tools and the CLI file for both.
72
90
  `;
91
+ /** The longest display name: the join message carries it (flow.engineer's /try cuts it there too). */
92
+ export const MAX_DISPLAY_NAME = 60;
93
+ /** The name people call the agent, from the package name when none was given: "my-shop" is "my shop". */
94
+ export function displayNameOf(given, packageName) {
95
+ const name = (given ?? "").replace(/[()]/g, " ").replace(/\s+/g, " ").trim();
96
+ if (name.length > MAX_DISPLAY_NAME)
97
+ throw new Error(`--display-name is at most ${MAX_DISPLAY_NAME} characters, such as "FlatX agent".`);
98
+ return name || packageName.replace(/-/g, " ");
99
+ }
73
100
  /** Makes `<into>/<name>` (the current folder by default) and returns it. */
74
101
  export function newStandalone(name, options = {}) {
75
102
  checkName(name);
76
- const root = options.root ?? packageRoot();
103
+ const displayName = displayNameOf(options.displayName, name);
77
104
  const dir = join(options.into ?? process.cwd(), name);
78
105
  if (existsSync(dir))
79
106
  throw new Error(`${dir} already exists: pick another name or remove it.`);
107
+ makeAgent(dir, name, displayName, options.root ?? packageRoot());
108
+ return dir;
109
+ }
110
+ /** What `flow init` writes into a folder; it refuses a folder that holds any of them, so nothing is overwritten. */
111
+ const MADE = ["package.json", "tsconfig.json", "src", "agent", "tests", ".env.example", "AGENTS.md", "CLAUDE.md", ".npmrc"];
112
+ /** The package name `flow init` gives the agent in `dir`: the folder's name in lowercase and dashes. */
113
+ export function folderName(dir) {
114
+ return basename(dir).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "my-agent";
115
+ }
116
+ /** Makes the agent in `dir` (the current folder by default), named after the folder; returns its name. */
117
+ export function initHere(dir = process.cwd(), options = {}) {
118
+ const taken = MADE.filter((f) => existsSync(join(dir, f)));
119
+ if (taken.length)
120
+ throw new Error(`${dir} has ${taken.join(", ")} already: run flow init in an empty folder, or flow new <name> to make one.`);
121
+ const name = folderName(dir);
122
+ makeAgent(dir, name, displayNameOf(options.displayName, name), options.root ?? packageRoot());
123
+ return name;
124
+ }
125
+ function makeAgent(dir, name, displayName, root) {
80
126
  const cafe = join(root, "examples", "cafe");
81
127
  for (const part of ["agent", "src", "tests"])
82
128
  cpSync(join(cafe, part), join(dir, part), { recursive: true });
@@ -84,26 +130,40 @@ export function newStandalone(name, options = {}) {
84
130
  const path = join(dir, "src", file);
85
131
  const source = readFileSync(path, "utf8")
86
132
  .replace(/(["'])(\.\.\/)+src\/index\.ts\1/g, '"@flow-engineer/flow"')
87
- .replace(/(["'])(\.\.\/)+simulators\/src\/index\.ts\1/g, '"@flow-engineer/flow/simulators"');
88
- if (/["']\.\.\/\.\.\//.test(source))
133
+ .replace(/(["'])(\.\.\/)+simulators\/src\/index\.ts\1/g, '"@flow-engineer/flow/simulators"')
134
+ .replace(/(["'])(\.\.\/)+flow-messaging\/src\/index\.ts\1/g, '"@flow-engineer/flow/flow-messaging"')
135
+ .replace(/(["'])(\.\.\/)+mcp\/src\/index\.ts\1/g, '"@flow-engineer/flow/mcp"')
136
+ .replace(/(["'])(\.\.\/)+cli\/src\/index\.ts\1/g, '"@flow-engineer/flow/cli"');
137
+ // The CLI is the agent's own command, named after it.
138
+ const text = file === "cli.ts" ? source.replace(/name: "cafe"/, `name: "${name}"`).replace(/CAFE_SERVER/g, `${name.toUpperCase().replace(/-/g, "_")}_SERVER`) : source;
139
+ if (/["']\.\.\/\.\.\//.test(text))
89
140
  throw new Error(`flow: ${file} imports a file outside the agent; only Flow Framework's public entries can be rewritten.`);
90
- writeFileSync(path, source);
141
+ writeFileSync(path, text);
91
142
  }
92
143
  const own = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
93
144
  const pkg = {
94
145
  name,
146
+ // What people call it: the message a phone sends to reach it is "Hi <displayName> (ref <code>)".
147
+ displayName,
95
148
  version: "0.1.0",
96
149
  private: true,
97
150
  description: `${name}: a Flow Framework agent, started from the café example.`,
98
151
  type: "module",
152
+ bin: { [name]: "src/cli.ts" },
99
153
  engines: own.engines,
100
- scripts: { start: "node --env-file-if-exists=.env src/main.ts", test: "flow test", typecheck: "tsc --noEmit" },
101
- dependencies: { "@flow-engineer/flow": `^${own.version}` },
154
+ scripts: { start: "node --env-file-if-exists=.env src/main.ts", mcp: "node src/mcp.ts", cli: "node src/cli.ts", test: "flow test", typecheck: "tsc --noEmit" },
155
+ // The café serves its tools over MCP (src/mcp.ts) and as a CLI (src/cli.ts): their SDKs, pinned as Flow Framework pins them.
156
+ dependencies: { "@flow-engineer/flow": `^${own.version}`, ...own.peerDependencies },
102
157
  devDependencies: { "@types/node": own.devDependencies["@types/node"], typescript: own.devDependencies.typescript },
103
158
  };
104
159
  writeFileSync(join(dir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
105
160
  writeFileSync(join(dir, "tsconfig.json"), `${JSON.stringify(TSCONFIG, null, 2)}\n`);
106
- writeFileSync(join(dir, ".gitignore"), "node_modules/\n.env\n.flow/\n");
161
+ // An existing .gitignore (flow init in a fresh repository) keeps its lines and gets the missing ones.
162
+ const ignore = join(dir, ".gitignore");
163
+ const ignored = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
164
+ const missing = ["node_modules/", ".env", ".flow/"].filter((line) => !ignored.split("\n").includes(line));
165
+ if (missing.length)
166
+ writeFileSync(ignore, `${ignored}${ignored && !ignored.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
107
167
  // npm install then refuses a Node older than the engines field says, before .ts files fail to load.
108
168
  writeFileSync(join(dir, ".npmrc"), "engine-strict=true\n");
109
169
  writeFileSync(join(dir, ".env.example"), ENV_EXAMPLE);
@@ -118,5 +178,4 @@ export function newStandalone(name, options = {}) {
118
178
  }
119
179
  writeFileSync(join(dir, "AGENTS.md"), agentsMd(name));
120
180
  writeFileSync(join(dir, "CLAUDE.md"), "@AGENTS.md\n");
121
- return dir;
122
181
  }
@@ -0,0 +1,4 @@
1
+ export declare const PLUGIN_LINE = " flow plugin --server <url> --out <dir> Write Claude Code and Agent Plugins packages";
2
+ export declare function pluginProblems(dir: string, out: string): string[];
3
+ export declare function writePlugins(dir: string, server: string, out: string, name?: string): void;
4
+ export declare function pluginCli(argv: string[], dir?: string): number;