@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,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 {};
|
package/dist/testing/src/new.js
CHANGED
|
@@ -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"
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 = `#
|
|
40
|
-
#
|
|
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
|
|
66
|
-
for its tools and their test files, \`flow-framework-tests\` for its test
|
|
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
|
-
-
|
|
69
|
-
|
|
70
|
-
\`
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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;
|