@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
|
@@ -6,7 +6,9 @@ description: Write or change a Flow Framework agent's tools and their test files
|
|
|
6
6
|
# Writing tools for a Flow Framework agent
|
|
7
7
|
|
|
8
8
|
Read the `flow-framework` skill (`skill.md`) first if you haven't. Tests that use these tool files are
|
|
9
|
-
in the `flow-framework-tests` skill (`test-skill.md`).
|
|
9
|
+
in the `flow-framework-tests` skill (`test-skill.md`). For a company with an existing app or API, build
|
|
10
|
+
the context pack first (the `flow-framework-context` skill, `context-skill.md`) and write the tools from
|
|
11
|
+
it.
|
|
10
12
|
|
|
11
13
|
## The tool
|
|
12
14
|
|
|
@@ -49,6 +51,77 @@ export const findSlots = tool({
|
|
|
49
51
|
searched ("Mon Oct 12 through Sun Oct 18", "all open times") and never cut results short
|
|
50
52
|
silently. If there are more, say so.
|
|
51
53
|
|
|
54
|
+
## Tools from the context pack
|
|
55
|
+
|
|
56
|
+
- One tool per task in `context/workflows.arazzo.yaml`, never one per endpoint: "place an order"
|
|
57
|
+
is one tool even when it is three API calls. A host or a small model chooses well among a few
|
|
58
|
+
task-shaped tools and badly among dozens of raw ones.
|
|
59
|
+
- Take the input and output shapes from `context/openapi.yaml` with `context/overlay.yaml` applied,
|
|
60
|
+
the words and rules from `business.md`, and leave out the personal fields `data.md` marks unless
|
|
61
|
+
the task needs them.
|
|
62
|
+
- `signIn` and `hints` come from `context/access.yaml` (below).
|
|
63
|
+
- When the team sends a new spec, re-read the overlay and the workflows against it, change only the
|
|
64
|
+
tools whose operations changed, and run `flow check` and the tests.
|
|
65
|
+
|
|
66
|
+
## The fields MCP and the CLI read
|
|
67
|
+
|
|
68
|
+
The same tool is served to Claude and ChatGPT over MCP (`@flow-engineer/flow/mcp`) and as a command (`@flow-engineer/flow/cli`).
|
|
69
|
+
These optional fields describe it there. **Chat ignores all of them**: they change nothing on
|
|
70
|
+
WhatsApp, SMS or iMessage.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
export const placeOrder = tool({
|
|
74
|
+
name: "place_order",
|
|
75
|
+
title: "Place an order", // default: "Place order", from the name
|
|
76
|
+
description: "Place a confirmed pickup order. Returns the order number and when it will be ready.",
|
|
77
|
+
input: z.object({ name: z.string(), items: z.array(item) }),
|
|
78
|
+
output: z.object({ order_id: z.string(), ready_by: z.string() }), // the result's shape
|
|
79
|
+
status: { running: "Placing your order…", done: "Order placed" }, // at most 64 characters each
|
|
80
|
+
signIn: "required", // "required" (default) | "optional" | "none"
|
|
81
|
+
hints: { readOnly: false, destructive: false, idempotent: false, openWorld: false },
|
|
82
|
+
run: async (input, context) => pos.place(input, context.idempotencyKey),
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- `output`: over MCP the result is checked against it and sent as structured content, and the CLI
|
|
87
|
+
shows its fields with `--json` and `--fields`. Give it whenever the result has a fixed shape.
|
|
88
|
+
- `signIn`: "none" for what anyone may do signed out (the menu, opening hours), "optional" for what
|
|
89
|
+
works either way and does more signed in, "required" (the default) for everything about a
|
|
90
|
+
person's own account. It applies only where sign-in is set up (the `flow-framework-auth` skill).
|
|
91
|
+
- `hints` describe the tool to hosts, which ask the person before risky calls; nothing enforces
|
|
92
|
+
them. `readOnly` (default false: a tool counts as a write), `destructive` (a write that may delete
|
|
93
|
+
or overwrite; default true for writes), `idempotent` (calling again with the same input changes
|
|
94
|
+
nothing more; default false), `openWorld` (reaches outside the business's own systems, such as the
|
|
95
|
+
web; default true). Set them honestly: a read-only tool should say so, or hosts treat it as a
|
|
96
|
+
write and the CLI asks for `--yes`.
|
|
97
|
+
- `flow check` (from the agent's folder) refuses what cannot be served exactly: a name outside
|
|
98
|
+
1 to 128 of `A-Z a-z 0-9 _ - .`, an empty description, a description naming a chat-only tool
|
|
99
|
+
(`send_*`, `share_updates`, `load_skill`), an input that is not a `z.object`, an input or output
|
|
100
|
+
with no exact JSON Schema (`z.date()`, transforms; use an ISO string), `readOnly` with
|
|
101
|
+
`destructive`, or a status line over 64 characters.
|
|
102
|
+
|
|
103
|
+
## Tools from another MCP server
|
|
104
|
+
|
|
105
|
+
An existing MCP server's tools become the agent's own, so the agent uses them on WhatsApp or
|
|
106
|
+
iMessage like any tool:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { mcpTools } from "@flow-engineer/flow/mcp";
|
|
110
|
+
|
|
111
|
+
export const tools = [
|
|
112
|
+
...myTools,
|
|
113
|
+
...(await mcpTools({ name: "todoist", url: "https://ai.todoist.net/mcp", headers: { authorization: `Bearer ${process.env.TODOIST_KEY}` } })),
|
|
114
|
+
];
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- They are pinned: `flow mcp update` (from the agent's folder) writes the server's tools to
|
|
118
|
+
`mcp.lock.json`, and only those are used. Commit it with the tests. A tool the server changes or
|
|
119
|
+
removes later is refused with a clear error until the lock is updated, tested and committed; a
|
|
120
|
+
tool it adds is not offered until then. `only: [...]` keeps a few of its tools, `prefix` keeps
|
|
121
|
+
two servers' names apart, and `live: true` skips the lock (for a server you do not build around).
|
|
122
|
+
- Each needs a `tests/tools/<tool>.yaml` like any tool. A tool without `readOnlyHint` counts as a
|
|
123
|
+
write. Keep keys in the environment, never in the lock.
|
|
124
|
+
|
|
52
125
|
## The tool's test file
|
|
53
126
|
|
|
54
127
|
In tests no real tool runs: a model plays each tool from the scenario's world. `tests/tools/<tool>.yaml`
|
|
@@ -87,3 +160,7 @@ responses:
|
|
|
87
160
|
never as written dates, which go stale.
|
|
88
161
|
- Latency lives only here. Scenarios describe situations ("the point of sale is slow this
|
|
89
162
|
morning"); the simulator picks the matching response and latency on each call.
|
|
163
|
+
|
|
164
|
+
## Authenticated backend access
|
|
165
|
+
|
|
166
|
+
A tool can use `await context.credentials!.get<YourClient>("backend")` when the agent/server supplies a `credentials` provider. That provider resolves access from Flow Framework’s verified `context.key`/`context.person`, independently of model input. Share it between chat and MCP; never put credentials into tool schemas, outputs, errors or logs. See `auth-skill.md` for verified-phone and OAuth entry points.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type OAuthClientProvider } from "@modelcontextprotocol/client";
|
|
2
|
+
import type { CliConfig } from "./commands.ts";
|
|
3
|
+
export declare function configDir(config: CliConfig): string;
|
|
4
|
+
/** An OAuth client provider whose state lives in the CLI's auth file, for one server. */
|
|
5
|
+
export declare function storedProvider(config: CliConfig, server: string, redirectUrl?: string, signal?: AbortSignal): OAuthClientProvider;
|
|
6
|
+
/** Terminal HTTP uses the same stored OAuth session as MCP, including refresh and issuer binding. */
|
|
7
|
+
export declare function storedAccessToken(config: CliConfig, server: string, forceRefresh?: boolean, signal?: AbortSignal): Promise<string | undefined>;
|
|
8
|
+
export declare function isSignedIn(config: CliConfig, server: string): boolean;
|
|
9
|
+
export declare function signOut(config: CliConfig, server: string): void;
|
|
10
|
+
/** Sign in: the browser goes to the organization's own login, and comes back to a loopback port. */
|
|
11
|
+
export declare function signIn(config: CliConfig, server: string, signal?: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The CLI's sign-in: the same OAuth a host such as Claude runs against the organization's MCP
|
|
2
|
+
// server, as a native app with a loopback redirect, so people sign in on the organization's own
|
|
3
|
+
// pages. Tokens are kept per server in ~/.config/<name>/auth.json (XDG_CONFIG_HOME), mode 600, and
|
|
4
|
+
// refreshed by the SDK. A token in <NAME>_TOKEN (CI, agents) is used as is and never stored.
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { auth, discoverOAuthServerInfo, refreshAuthorization, } from "@modelcontextprotocol/client";
|
|
11
|
+
export function configDir(config) {
|
|
12
|
+
return config.directory ?? join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), config.name);
|
|
13
|
+
}
|
|
14
|
+
function authFile(config) {
|
|
15
|
+
return join(configDir(config), "auth.json");
|
|
16
|
+
}
|
|
17
|
+
function readAll(config) {
|
|
18
|
+
const file = authFile(config);
|
|
19
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
20
|
+
}
|
|
21
|
+
function writeAll(config, all) {
|
|
22
|
+
mkdirSync(configDir(config), { recursive: true, mode: 0o700 });
|
|
23
|
+
const file = authFile(config);
|
|
24
|
+
writeFileSync(file, JSON.stringify(all, null, 2) + "\n", { mode: 0o600 });
|
|
25
|
+
chmodSync(file, 0o600);
|
|
26
|
+
}
|
|
27
|
+
/** An OAuth client provider whose state lives in the CLI's auth file, for one server. */
|
|
28
|
+
export function storedProvider(config, server, redirectUrl, signal) {
|
|
29
|
+
const read = () => readAll(config)[server] ?? {};
|
|
30
|
+
const write = (change) => {
|
|
31
|
+
signal?.throwIfAborted();
|
|
32
|
+
const all = readAll(config);
|
|
33
|
+
all[server] = { ...all[server], ...change };
|
|
34
|
+
writeAll(config, all);
|
|
35
|
+
};
|
|
36
|
+
const metadata = {
|
|
37
|
+
client_name: `${config.name} CLI`,
|
|
38
|
+
application_type: "native",
|
|
39
|
+
redirect_uris: redirectUrl ? [redirectUrl] : [],
|
|
40
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
41
|
+
response_types: ["code"],
|
|
42
|
+
token_endpoint_auth_method: "none",
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
get redirectUrl() {
|
|
46
|
+
return redirectUrl;
|
|
47
|
+
},
|
|
48
|
+
get clientMetadata() {
|
|
49
|
+
return metadata;
|
|
50
|
+
},
|
|
51
|
+
...(config.clientMetadataUrl ? { clientMetadataUrl: config.clientMetadataUrl } : {}),
|
|
52
|
+
clientInformation: () => config.clientId ? { client_id: config.clientId } : read().client,
|
|
53
|
+
saveClientInformation: (client) => write({ client }),
|
|
54
|
+
tokens: () => read().tokens,
|
|
55
|
+
saveTokens: (tokens) => write({ tokens, tokensSavedAt: Date.now() }),
|
|
56
|
+
state: () => { const state = randomBytes(32).toString("base64url"); write({ state }); return state; },
|
|
57
|
+
discoveryState: () => read().discovery,
|
|
58
|
+
saveDiscoveryState: (discovery) => write({ discovery }),
|
|
59
|
+
redirectToAuthorization: async (url) => {
|
|
60
|
+
if (config.openUrl)
|
|
61
|
+
await config.openUrl(url.toString());
|
|
62
|
+
else
|
|
63
|
+
console.error(`Open this link to sign in:\n ${url}`);
|
|
64
|
+
},
|
|
65
|
+
saveCodeVerifier: (verifier) => write({ verifier }),
|
|
66
|
+
codeVerifier: () => {
|
|
67
|
+
const verifier = read().verifier;
|
|
68
|
+
if (!verifier)
|
|
69
|
+
throw new Error("No sign-in in progress.");
|
|
70
|
+
return verifier;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Terminal HTTP uses the same stored OAuth session as MCP, including refresh and issuer binding. */
|
|
75
|
+
export async function storedAccessToken(config, server, forceRefresh = false, signal) {
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
const saved = readAll(config)[server];
|
|
78
|
+
if (!saved?.tokens)
|
|
79
|
+
return undefined;
|
|
80
|
+
const { tokens } = saved;
|
|
81
|
+
const expired = saved.tokensSavedAt !== undefined && tokens.expires_in !== undefined && saved.tokensSavedAt + tokens.expires_in * 1000 <= Date.now() + 30_000;
|
|
82
|
+
if (!expired && !forceRefresh)
|
|
83
|
+
return tokens.access_token;
|
|
84
|
+
if (!tokens.refresh_token || (!saved.client && !config.clientId))
|
|
85
|
+
return undefined;
|
|
86
|
+
const info = await discoverOAuthServerInfo(server);
|
|
87
|
+
const issuer = info.authorizationServerMetadata?.issuer ?? String(info.authorizationServerUrl);
|
|
88
|
+
if (tokens.issuer && tokens.issuer !== issuer)
|
|
89
|
+
throw new Error("The authorization server changed; sign in again.");
|
|
90
|
+
const fresh = await refreshAuthorization(info.authorizationServerUrl, { metadata: info.authorizationServerMetadata, clientInformation: config.clientId ? { client_id: config.clientId } : saved.client, refreshToken: tokens.refresh_token, resource: info.resourceMetadata?.resource });
|
|
91
|
+
await storedProvider(config, server, undefined, signal).saveTokens({ ...fresh, issuer });
|
|
92
|
+
return fresh.access_token;
|
|
93
|
+
}
|
|
94
|
+
export function isSignedIn(config, server) {
|
|
95
|
+
return Boolean(readAll(config)[server]?.tokens);
|
|
96
|
+
}
|
|
97
|
+
export function signOut(config, server) {
|
|
98
|
+
const all = readAll(config);
|
|
99
|
+
delete all[server];
|
|
100
|
+
writeAll(config, all);
|
|
101
|
+
}
|
|
102
|
+
/** Sign in: the browser goes to the organization's own login, and comes back to a loopback port. */
|
|
103
|
+
export async function signIn(config, server, signal) {
|
|
104
|
+
signal?.throwIfAborted();
|
|
105
|
+
const callback = createServer();
|
|
106
|
+
await new Promise((resolve, reject) => { callback.once("error", reject); callback.listen(config.redirectPort ?? 0, "127.0.0.1", resolve); });
|
|
107
|
+
const { port } = callback.address();
|
|
108
|
+
const redirectUrl = `http://127.0.0.1:${port}/callback`;
|
|
109
|
+
let cancel;
|
|
110
|
+
try {
|
|
111
|
+
const provider = storedProvider(config, server, redirectUrl, signal);
|
|
112
|
+
const code = new Promise((resolve, reject) => {
|
|
113
|
+
cancel = () => reject(new Error("Sign-in cancelled."));
|
|
114
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
115
|
+
if (signal?.aborted)
|
|
116
|
+
cancel();
|
|
117
|
+
callback.on("request", (req, res) => {
|
|
118
|
+
const url = new URL(req.url ?? "/", redirectUrl);
|
|
119
|
+
if (url.pathname !== "/callback")
|
|
120
|
+
return void res.writeHead(404).end();
|
|
121
|
+
const error = url.searchParams.get("error");
|
|
122
|
+
const got = url.searchParams.get("code");
|
|
123
|
+
if (req.method !== "GET" || req.headers.host !== `127.0.0.1:${port}` || !readAll(config)[server]?.state || url.searchParams.get("state") !== readAll(config)[server]?.state) {
|
|
124
|
+
res.writeHead(400, { "content-type": "text/plain" }).end("Invalid sign-in response.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end(got ? `Signed in. You can close this tab and go back to ${config.name}.` : `Sign-in failed: ${error ?? "no code"}.`);
|
|
128
|
+
if (got)
|
|
129
|
+
resolve({ code: got, iss: url.searchParams.get("iss") ?? undefined });
|
|
130
|
+
else
|
|
131
|
+
reject(new Error(`Sign-in failed: ${error ?? "no code came back"}.`));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
// Cancellation may arrive while discovery or the authorization request is still in flight.
|
|
135
|
+
void code.catch(() => { });
|
|
136
|
+
const first = await auth(provider, { serverUrl: server });
|
|
137
|
+
if (first === "AUTHORIZED")
|
|
138
|
+
return;
|
|
139
|
+
const answer = await code;
|
|
140
|
+
await auth(provider, { serverUrl: server, authorizationCode: answer.code, iss: answer.iss });
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
if (cancel)
|
|
144
|
+
signal?.removeEventListener("abort", cancel);
|
|
145
|
+
callback.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Command as StricliCommand, CommandContext } from "@stricli/core";
|
|
2
|
+
export interface ToolCommand {
|
|
3
|
+
/** The tool this command calls. */
|
|
4
|
+
tool: string;
|
|
5
|
+
/** Input fields taken from positional arguments, in order (`order view 1042` → orderId). */
|
|
6
|
+
args?: string[];
|
|
7
|
+
/** For a list: the columns of the table people see. */
|
|
8
|
+
columns?: string[];
|
|
9
|
+
/** For one record: the fields people see, in order. */
|
|
10
|
+
view?: string[];
|
|
11
|
+
/** Another name for the command. */
|
|
12
|
+
alias?: string;
|
|
13
|
+
/** One line under the command in help (default: the tool's title). */
|
|
14
|
+
summary?: string;
|
|
15
|
+
examples?: string[];
|
|
16
|
+
}
|
|
17
|
+
/** What a command of the team's own gets as `this` (Stricli's context): the server's tools and the CLI's output. */
|
|
18
|
+
export interface CliContext extends CommandContext {
|
|
19
|
+
readonly cli: {
|
|
20
|
+
/** Call one of the server's tools; resolves with its result, throws its error. */
|
|
21
|
+
call(tool: string, input: Record<string, unknown>): Promise<unknown>;
|
|
22
|
+
/** Print a value as the CLI prints tool results (a table, fields, or JSON with --json). */
|
|
23
|
+
print(value: unknown, options?: {
|
|
24
|
+
json?: boolean;
|
|
25
|
+
fields?: string[];
|
|
26
|
+
columns?: string[];
|
|
27
|
+
}): void;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** A command of the team's own: any Stricli command (buildCommand from @stricli/core), one level down. */
|
|
31
|
+
export interface CustomCommand {
|
|
32
|
+
command: StricliCommand<CliContext>;
|
|
33
|
+
}
|
|
34
|
+
export type CommandConfig = ToolCommand | CustomCommand;
|
|
35
|
+
export interface CliConfig {
|
|
36
|
+
/** The command's name ("acme"): its help, its config folder (~/.config/<name>) and token variable. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** The CLI client version sent to the server (default "1"). */
|
|
39
|
+
version?: string;
|
|
40
|
+
/** The organization's MCP endpoint. Else `--server` or <NAME>_SERVER. */
|
|
41
|
+
server?: string;
|
|
42
|
+
/** The agent terminal URL; default the server origin plus /agent/terminal. */
|
|
43
|
+
agent?: string;
|
|
44
|
+
/** The variable a token is read from for CI and agents (default <NAME>_TOKEN). */
|
|
45
|
+
tokenEnv?: string;
|
|
46
|
+
/** Override the local credential/conversation directory, e.g. for an isolated embedded session. */
|
|
47
|
+
directory?: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
/** Commands by their words ("order place"): the first words are groups. Tools no command names keep their own name. */
|
|
50
|
+
commands?: Record<string, CommandConfig>;
|
|
51
|
+
/** One line about each group, shown in help ("order": "Place and track orders"). Default: its subcommands. */
|
|
52
|
+
groups?: Record<string, string>;
|
|
53
|
+
/** A Client ID Metadata Document URL for sign-in; without it the CLI registers itself (DCR). */
|
|
54
|
+
clientMetadataUrl?: string;
|
|
55
|
+
/** A registered public OAuth client, for providers without dynamic registration. No client secret in a distributed CLI. */
|
|
56
|
+
clientId?: string;
|
|
57
|
+
/** A provider requiring an exact registered loopback port can fix it here; default chooses a free port. */
|
|
58
|
+
redirectPort?: number;
|
|
59
|
+
/** How a sign-in link is opened (default: printed for the person to open). */
|
|
60
|
+
openUrl?: (url: string) => void | Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
/** What the CLI knows of a tool, from the server's tools/list. */
|
|
63
|
+
export interface ToolInfo {
|
|
64
|
+
name: string;
|
|
65
|
+
title?: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
inputSchema: {
|
|
68
|
+
properties?: Record<string, JsonSchema>;
|
|
69
|
+
required?: string[];
|
|
70
|
+
[key: string]: unknown;
|
|
71
|
+
};
|
|
72
|
+
outputSchema?: JsonSchema;
|
|
73
|
+
annotations?: {
|
|
74
|
+
readOnlyHint?: boolean;
|
|
75
|
+
destructiveHint?: boolean;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export interface JsonSchema {
|
|
79
|
+
type?: string | string[];
|
|
80
|
+
enum?: unknown[];
|
|
81
|
+
items?: JsonSchema;
|
|
82
|
+
properties?: Record<string, JsonSchema>;
|
|
83
|
+
description?: string;
|
|
84
|
+
[key: string]: unknown;
|
|
85
|
+
}
|
|
86
|
+
export interface Command {
|
|
87
|
+
words: string[];
|
|
88
|
+
summary: string;
|
|
89
|
+
tool?: ToolInfo;
|
|
90
|
+
config?: CommandConfig;
|
|
91
|
+
}
|
|
92
|
+
export declare function defineCli(config: CliConfig): CliConfig;
|
|
93
|
+
export declare function isCustom(config: CommandConfig): config is CustomCommand;
|
|
94
|
+
/** Words the CLI keeps for itself. */
|
|
95
|
+
export declare const BUILT_IN: Set<string>;
|
|
96
|
+
/** "place_order" → "place-order". */
|
|
97
|
+
export declare function derivedName(tool: string): string;
|
|
98
|
+
/** Every command: the CLI file's, then each tool it does not name, under the tool's own name. */
|
|
99
|
+
export declare function commandTable(config: CliConfig, tools: ToolInfo[]): Command[];
|
|
100
|
+
/** The command the words start with, the longest match first, and the words left after it. */
|
|
101
|
+
export declare function findCommand(commands: Command[], words: string[]): {
|
|
102
|
+
command: Command;
|
|
103
|
+
rest: string[];
|
|
104
|
+
} | null;
|
|
105
|
+
/** What is wrong in a CLI file against the server's tools, one sentence each; warnings apart. */
|
|
106
|
+
export declare function cliProblems(config: CliConfig, tools: ToolInfo[]): {
|
|
107
|
+
errors: string[];
|
|
108
|
+
warnings: string[];
|
|
109
|
+
};
|
|
110
|
+
/** The CLI's SKILL.md for coding agents, made from the tools and the CLI file, never written by hand. */
|
|
111
|
+
export declare function skillFile(config: CliConfig, tools: ToolInfo[]): string;
|
|
112
|
+
export declare function tokenEnvOf(config: CliConfig): string;
|
|
113
|
+
export declare function flagName(field: string): string;
|
|
114
|
+
/** One line per flag: its name, type and what it is for. */
|
|
115
|
+
export declare function flagList(tool: ToolInfo, args?: string[]): string[];
|
|
116
|
+
/** A short picture of a JSON value's shape: "list of {item, size, milk?, quantity?}". */
|
|
117
|
+
export declare function typeHint(s: JsonSchema): string;
|
|
118
|
+
export type FlagKind = "string" | "number" | "integer" | "boolean" | "list" | "json";
|
|
119
|
+
export declare function flagKind(s: JsonSchema): FlagKind;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// An organization's CLI commands: every tool of its MCP server is a command under its own name
|
|
2
|
+
// (place_order → `place-order`), and the CLI file the team's coding agent writes (defineCli) adds
|
|
3
|
+
// what needs judgement: command groups and verbs (`order place`, where `order` is a group whose
|
|
4
|
+
// help lists its subcommands), the positional arguments, a table's columns, examples, aliases, and
|
|
5
|
+
// commands of its own. cliProblems is the rule-based check that keeps that file true to the tools;
|
|
6
|
+
// it never judges taste.
|
|
7
|
+
export function defineCli(config) {
|
|
8
|
+
return config;
|
|
9
|
+
}
|
|
10
|
+
export function isCustom(config) {
|
|
11
|
+
return config.command !== undefined;
|
|
12
|
+
}
|
|
13
|
+
/** The MCP server's own way to read its skills: plumbing for hosts, not a command, unless the CLI file names it. */
|
|
14
|
+
const HOST_ONLY = new Set(["read_skill"]);
|
|
15
|
+
/** Words the CLI keeps for itself. */
|
|
16
|
+
export const BUILT_IN = new Set(["auth", "tools", "skill"]);
|
|
17
|
+
/** "place_order" → "place-order". */
|
|
18
|
+
export function derivedName(tool) {
|
|
19
|
+
return tool.replace(/_/g, "-");
|
|
20
|
+
}
|
|
21
|
+
/** Every command: the CLI file's, then each tool it does not name, under the tool's own name. */
|
|
22
|
+
export function commandTable(config, tools) {
|
|
23
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
24
|
+
const commands = [];
|
|
25
|
+
const named = new Set();
|
|
26
|
+
for (const [words, c] of Object.entries(config.commands ?? {})) {
|
|
27
|
+
const split = words.trim().split(/\s+/);
|
|
28
|
+
if (isCustom(c)) {
|
|
29
|
+
commands.push({ words: split, summary: "", config: c });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const tool = byName.get(c.tool);
|
|
33
|
+
if (!tool)
|
|
34
|
+
continue;
|
|
35
|
+
named.add(c.tool);
|
|
36
|
+
commands.push({ words: split, summary: c.summary ?? tool.title ?? tool.name, tool, config: c });
|
|
37
|
+
if (c.alias)
|
|
38
|
+
commands.push({ words: c.alias.trim().split(/\s+/), summary: `Same as ${words}`, tool, config: c });
|
|
39
|
+
}
|
|
40
|
+
for (const tool of tools) {
|
|
41
|
+
if (!named.has(tool.name) && !HOST_ONLY.has(tool.name))
|
|
42
|
+
commands.push({ words: [derivedName(tool.name)], summary: tool.title ?? tool.name, tool });
|
|
43
|
+
}
|
|
44
|
+
return commands;
|
|
45
|
+
}
|
|
46
|
+
/** The command the words start with, the longest match first, and the words left after it. */
|
|
47
|
+
export function findCommand(commands, words) {
|
|
48
|
+
let best = null;
|
|
49
|
+
for (const c of commands) {
|
|
50
|
+
if (c.words.length <= words.length && c.words.every((w, i) => w === words[i]) && (!best || c.words.length > best.words.length))
|
|
51
|
+
best = c;
|
|
52
|
+
}
|
|
53
|
+
return best ? { command: best, rest: words.slice(best.words.length) } : null;
|
|
54
|
+
}
|
|
55
|
+
/** Fields a list's rows or a record carry: the output schema's, or for an array, its items'. */
|
|
56
|
+
function outputFields(tool) {
|
|
57
|
+
const schema = tool.outputSchema;
|
|
58
|
+
if (!schema)
|
|
59
|
+
return null;
|
|
60
|
+
const props = schema.properties ?? schema.items?.properties;
|
|
61
|
+
if (!props)
|
|
62
|
+
return null;
|
|
63
|
+
const fields = new Set(Object.keys(props));
|
|
64
|
+
// A list wrapped in an object ({ orders: [...] }) shows its rows' fields too.
|
|
65
|
+
for (const p of Object.values(props))
|
|
66
|
+
for (const k of Object.keys(p.items?.properties ?? {}))
|
|
67
|
+
fields.add(k);
|
|
68
|
+
return fields;
|
|
69
|
+
}
|
|
70
|
+
/** What is wrong in a CLI file against the server's tools, one sentence each; warnings apart. */
|
|
71
|
+
export function cliProblems(config, tools) {
|
|
72
|
+
const errors = [];
|
|
73
|
+
const warnings = [];
|
|
74
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
75
|
+
const words = new Map();
|
|
76
|
+
const claim = (w, owner) => {
|
|
77
|
+
const key = w.trim().split(/\s+/).join(" ");
|
|
78
|
+
if (words.has(key))
|
|
79
|
+
errors.push(`command "${key}": both ${words.get(key)} and ${owner} use it`);
|
|
80
|
+
else
|
|
81
|
+
words.set(key, owner);
|
|
82
|
+
};
|
|
83
|
+
const named = new Set();
|
|
84
|
+
for (const [w, c] of Object.entries(config.commands ?? {})) {
|
|
85
|
+
claim(w, `"${w}"`);
|
|
86
|
+
if (isCustom(c))
|
|
87
|
+
continue;
|
|
88
|
+
if (c.alias)
|
|
89
|
+
claim(c.alias, `the alias of "${w}"`);
|
|
90
|
+
const tool = byName.get(c.tool);
|
|
91
|
+
if (!tool) {
|
|
92
|
+
errors.push(`command "${w}": no tool named ${c.tool} on the server`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
named.add(c.tool);
|
|
96
|
+
const input = new Set(Object.keys(tool.inputSchema.properties ?? {}));
|
|
97
|
+
for (const a of c.args ?? [])
|
|
98
|
+
if (!input.has(a))
|
|
99
|
+
errors.push(`command "${w}": args names ${a}, which ${c.tool}'s input does not have`);
|
|
100
|
+
const output = outputFields(tool);
|
|
101
|
+
for (const key of ["columns", "view"]) {
|
|
102
|
+
for (const f of c[key] ?? []) {
|
|
103
|
+
if (!output)
|
|
104
|
+
warnings.push(`command "${w}": ${key} names ${f}, but ${c.tool} declares no output to check it against`);
|
|
105
|
+
else if (!output.has(f.split(".")[0]))
|
|
106
|
+
errors.push(`command "${w}": ${key} names ${f}, which ${c.tool}'s output does not have`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const t of tools) {
|
|
111
|
+
if (named.has(t.name) || HOST_ONLY.has(t.name))
|
|
112
|
+
continue;
|
|
113
|
+
claim(derivedName(t.name), `tool ${t.name}`);
|
|
114
|
+
if (config.commands)
|
|
115
|
+
warnings.push(`tool ${t.name} has no command in the CLI file; it is reachable as "${derivedName(t.name)}"`);
|
|
116
|
+
}
|
|
117
|
+
for (const [key, owner] of words) {
|
|
118
|
+
const first = key.split(" ")[0];
|
|
119
|
+
if (BUILT_IN.has(first))
|
|
120
|
+
errors.push(`command "${key}": ${first} is the CLI's own (${[...BUILT_IN].join(", ")})`);
|
|
121
|
+
for (const other of words.keys()) {
|
|
122
|
+
if (other !== key && other.startsWith(`${key} `))
|
|
123
|
+
errors.push(`command "${key}" (${owner}): it is also the group of "${other}"; a word is a command or a group, not both`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { errors, warnings };
|
|
127
|
+
}
|
|
128
|
+
/** The CLI's SKILL.md for coding agents, made from the tools and the CLI file, never written by hand. */
|
|
129
|
+
export function skillFile(config, tools) {
|
|
130
|
+
const name = config.name;
|
|
131
|
+
const lines = [
|
|
132
|
+
"---",
|
|
133
|
+
`name: ${name}-cli`,
|
|
134
|
+
`description: Use the ${name} command line${config.description ? ` (${config.description})` : ""}. Load before running ${name} commands or when asked to do something in ${name} from a terminal.`,
|
|
135
|
+
"---",
|
|
136
|
+
"",
|
|
137
|
+
`# The ${name} command line`,
|
|
138
|
+
"",
|
|
139
|
+
`Run \`${name} <command> --json\` and read the JSON; never parse the human output. \`--fields a,b\` keeps only those fields.`,
|
|
140
|
+
"Commands that change things accept `--dry-run` (shows the call, sends nothing) and, when they may delete or overwrite, need `--yes` without a terminal.",
|
|
141
|
+
`Sign in once with \`${name} auth login\`, or set ${tokenEnvOf(config)} in CI.`,
|
|
142
|
+
"",
|
|
143
|
+
"Exit codes: 0 done, 1 the tool failed (its message on stderr), 2 wrong command or arguments, 3 sign-in needed, 4 needs --yes, 5 the server could not be reached.",
|
|
144
|
+
"",
|
|
145
|
+
"## Talking to the agent",
|
|
146
|
+
"",
|
|
147
|
+
`\`${name} chat "<message>" --json\` sends one message to the agent; \`${name} chat\` opens an interactive loop (Ctrl-D or /exit to leave). Both need sign-in.`,
|
|
148
|
+
"The CLI keeps its conversation reference in chat.json in its config folder; --new forgets that reference and preserves shared person history. chat without a message needs a terminal (otherwise exits 2).",
|
|
149
|
+
"",
|
|
150
|
+
"## Commands",
|
|
151
|
+
];
|
|
152
|
+
for (const c of commandTable(config, tools)) {
|
|
153
|
+
if (!c.tool)
|
|
154
|
+
continue;
|
|
155
|
+
lines.push("", `### ${name} ${c.words.join(" ")}`, "", c.summary);
|
|
156
|
+
if (c.tool) {
|
|
157
|
+
if (c.tool.description && c.tool.description !== c.summary)
|
|
158
|
+
lines.push("", c.tool.description);
|
|
159
|
+
const args = c.config && !isCustom(c.config) ? (c.config.args ?? []) : [];
|
|
160
|
+
const flags = flagList(c.tool, args);
|
|
161
|
+
if (args.length || flags.length)
|
|
162
|
+
lines.push("", `Usage: \`${name} ${c.words.join(" ")}${args.map((a) => ` <${a}>`).join("")}${flags.length ? " [flags]" : ""}\``);
|
|
163
|
+
for (const f of flags)
|
|
164
|
+
lines.push(`- ${f}`);
|
|
165
|
+
if (c.tool.annotations?.readOnlyHint !== true)
|
|
166
|
+
lines.push(`- Changes things${c.tool.annotations?.destructiveHint === false ? "" : " and may delete or overwrite: needs --yes"}.`);
|
|
167
|
+
if (c.tool.outputSchema)
|
|
168
|
+
lines.push("", "JSON output schema:", "", "```json", JSON.stringify(c.tool.outputSchema), "```");
|
|
169
|
+
}
|
|
170
|
+
for (const e of c.config && !isCustom(c.config) ? (c.config.examples ?? []) : [])
|
|
171
|
+
lines.push("", "```bash", e, "```");
|
|
172
|
+
}
|
|
173
|
+
return lines.join("\n") + "\n";
|
|
174
|
+
}
|
|
175
|
+
export function tokenEnvOf(config) {
|
|
176
|
+
return config.tokenEnv ?? `${config.name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_TOKEN`;
|
|
177
|
+
}
|
|
178
|
+
export function flagName(field) {
|
|
179
|
+
return field.replace(/_/g, "-").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
180
|
+
}
|
|
181
|
+
/** One line per flag: its name, type and what it is for. */
|
|
182
|
+
export function flagList(tool, args = []) {
|
|
183
|
+
const required = new Set(tool.inputSchema.required ?? []);
|
|
184
|
+
return Object.entries(tool.inputSchema.properties ?? {})
|
|
185
|
+
.filter(([field]) => !args.includes(field))
|
|
186
|
+
.map(([field, s]) => {
|
|
187
|
+
const kind = s.enum ? s.enum.join("|") : flagKind(s) === "json" ? `JSON: ${typeHint(s)} (or @file.json)` : flagKind(s);
|
|
188
|
+
return `\`--${flagName(field)}\` ${kind}${required.has(field) ? ", required" : ""}${s.description ? `: ${s.description}` : ""}`;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/** A short picture of a JSON value's shape: "list of {item, size, milk?, quantity?}". */
|
|
192
|
+
export function typeHint(s) {
|
|
193
|
+
const type = Array.isArray(s.type) ? s.type.find((t) => t !== "null") : s.type;
|
|
194
|
+
if (type === "array")
|
|
195
|
+
return `list of ${s.items ? typeHint(s.items) : "values"}`;
|
|
196
|
+
if (type === "object" || s.properties) {
|
|
197
|
+
const required = new Set(s.required ?? []);
|
|
198
|
+
const keys = Object.keys(s.properties ?? {}).map((k) => (required.has(k) ? k : `${k}?`));
|
|
199
|
+
return keys.length ? `{${keys.join(", ")}}` : "object";
|
|
200
|
+
}
|
|
201
|
+
return s.enum ? s.enum.join("|") : (type ?? "value");
|
|
202
|
+
}
|
|
203
|
+
export function flagKind(s) {
|
|
204
|
+
const type = Array.isArray(s.type) ? s.type.find((t) => t !== "null") : s.type;
|
|
205
|
+
if (type === "array") {
|
|
206
|
+
const item = Array.isArray(s.items?.type) ? s.items?.type[0] : s.items?.type;
|
|
207
|
+
return item === "string" || item === "number" || item === "integer" ? "list" : "json";
|
|
208
|
+
}
|
|
209
|
+
if (type === "number" || type === "integer" || type === "boolean" || type === "string")
|
|
210
|
+
return type;
|
|
211
|
+
return s.enum ? "string" : "json";
|
|
212
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// @flow-engineer/flow/cli: an organization's command line over its MCP server (the same tools, sign-in and
|
|
2
|
+
// gating as Claude or ChatGPT get). `defineCli` is the team's CLI file; `runCli` runs it. Needs
|
|
3
|
+
// @modelcontextprotocol/client and @stricli/core, optional peers of Flow Framework.
|
|
4
|
+
export { cliProblems, commandTable, defineCli, derivedName, skillFile } from "./commands.js";
|
|
5
|
+
export { EXIT, processIO, runCli } from "./run.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type CliConfig } from "./commands.ts";
|
|
2
|
+
export declare const EXIT: {
|
|
3
|
+
readonly ok: 0;
|
|
4
|
+
readonly failed: 1;
|
|
5
|
+
readonly usage: 2;
|
|
6
|
+
readonly signIn: 3;
|
|
7
|
+
readonly approval: 4;
|
|
8
|
+
readonly unreachable: 5;
|
|
9
|
+
};
|
|
10
|
+
export interface CliIO {
|
|
11
|
+
/** An embedded client's environment; omitted uses the local process environment. */
|
|
12
|
+
env?: Record<string, string | undefined>;
|
|
13
|
+
/** Cancels an embedded sign-in or terminal request when its session ends. */
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
/** Write to stdout as is. */
|
|
16
|
+
write(text: string): void;
|
|
17
|
+
/** Write to stderr as is. */
|
|
18
|
+
writeError(text: string): void;
|
|
19
|
+
/** Whether a person is at a terminal to answer a question. */
|
|
20
|
+
interactive: boolean;
|
|
21
|
+
/** Lines from an interactive terminal, with an optional input prompt; injectable for embedded CLIs and tests. */
|
|
22
|
+
lines?: (prompt?: string) => AsyncIterable<string>;
|
|
23
|
+
/** Ask a yes-or-no question (only when interactive). */
|
|
24
|
+
confirm(question: string): Promise<boolean>;
|
|
25
|
+
}
|
|
26
|
+
export declare function processIO(): CliIO;
|
|
27
|
+
/** Run the CLI once; resolves with its exit code. */
|
|
28
|
+
export declare function runCli(config: CliConfig, argv: string[], io?: CliIO): Promise<number>;
|