@flow-engineer/flow 0.2.1 → 0.2.3

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 (70) hide show
  1. package/README.md +167 -10
  2. package/coding-agents/auth-skill.md +137 -0
  3. package/coding-agents/context-skill.md +94 -0
  4. package/coding-agents/skill.md +219 -17
  5. package/coding-agents/test-skill.md +3 -1
  6. package/coding-agents/tools-skill.md +78 -1
  7. package/dist/cli/src/auth.d.ts +11 -0
  8. package/dist/cli/src/auth.js +147 -0
  9. package/dist/cli/src/commands.d.ts +119 -0
  10. package/dist/cli/src/commands.js +212 -0
  11. package/dist/cli/src/index.d.ts +2 -0
  12. package/dist/cli/src/index.js +5 -0
  13. package/dist/cli/src/run.d.ts +28 -0
  14. package/dist/cli/src/run.js +523 -0
  15. package/dist/cli/src/terminal.d.ts +3 -0
  16. package/dist/cli/src/terminal.js +135 -0
  17. package/dist/flow-messaging/src/client.d.ts +2 -2
  18. package/dist/flow-messaging/src/client.js +3 -3
  19. package/dist/flow-messaging/src/index.js +17 -9
  20. package/dist/mcp/src/client.d.ts +54 -0
  21. package/dist/mcp/src/client.js +175 -0
  22. package/dist/mcp/src/describe.d.ts +41 -0
  23. package/dist/mcp/src/describe.js +110 -0
  24. package/dist/mcp/src/index.d.ts +4 -0
  25. package/dist/mcp/src/index.js +7 -0
  26. package/dist/mcp/src/server.d.ts +70 -0
  27. package/dist/mcp/src/server.js +252 -0
  28. package/dist/mcp/src/terminal.d.ts +24 -0
  29. package/dist/mcp/src/terminal.js +54 -0
  30. package/dist/simulators/src/index.d.ts +1 -1
  31. package/dist/simulators/src/page.html +267 -49
  32. package/dist/simulators/src/server.d.ts +27 -0
  33. package/dist/simulators/src/server.js +264 -40
  34. package/dist/src/agent.d.ts +69 -3
  35. package/dist/src/agent.js +162 -8
  36. package/dist/src/channels.d.ts +6 -4
  37. package/dist/src/channels.js +4 -3
  38. package/dist/src/conversation.d.ts +17 -1
  39. package/dist/src/conversation.js +170 -16
  40. package/dist/src/events.d.ts +1 -1
  41. package/dist/src/index.d.ts +2 -2
  42. package/dist/src/index.js +1 -1
  43. package/dist/src/model.d.ts +27 -0
  44. package/dist/src/model.js +85 -13
  45. package/dist/src/skills.d.ts +4 -0
  46. package/dist/src/skills.js +23 -0
  47. package/dist/src/store.d.ts +2 -0
  48. package/dist/src/store.js +18 -0
  49. package/dist/src/tools.d.ts +69 -0
  50. package/dist/src/tools.js +17 -1
  51. package/dist/src/traces.js +32 -4
  52. package/dist/src/types.d.ts +5 -0
  53. package/dist/testing/src/account.d.ts +1 -1
  54. package/dist/testing/src/account.js +368 -57
  55. package/dist/testing/src/chat.js +24 -16
  56. package/dist/testing/src/check.d.ts +4 -0
  57. package/dist/testing/src/check.js +90 -0
  58. package/dist/testing/src/cli.js +77 -19
  59. package/dist/testing/src/flow-login.d.ts +33 -0
  60. package/dist/testing/src/flow-login.js +30 -5
  61. package/dist/testing/src/new.d.ts +14 -5
  62. package/dist/testing/src/new.js +54 -20
  63. package/dist/testing/src/plugin.d.ts +4 -0
  64. package/dist/testing/src/plugin.js +116 -0
  65. package/docs/agents.md +87 -12
  66. package/examples/cafe/src/cli.ts +24 -0
  67. package/examples/cafe/src/mcp.ts +22 -0
  68. package/examples/cafe/src/tools.ts +13 -0
  69. package/flow-messaging/README.md +3 -1
  70. package/package.json +28 -1
@@ -0,0 +1,523 @@
1
+ // Runs an organization's CLI on Stricli (@stricli/core, the framework Sentry's CLI is built on):
2
+ // command groups whose help lists their subcommands (`acme order`, `acme order -h`), help for every
3
+ // command (`acme order place -h`), typo suggestions, typed flags and positional arguments. On top of
4
+ // it, the conventions agents and scripts rely on: --json (and --fields) on every tool command,
5
+ // --dry-run on anything that changes things, --yes needed without a terminal for what may delete or
6
+ // overwrite, and fixed exit codes. The commands come from the server's tools at run time, shaped by
7
+ // the CLI file (commands.ts).
8
+ import { runTerminal } from "./terminal.js";
9
+ import { randomUUID } from "node:crypto";
10
+ import { readFileSync } from "node:fs";
11
+ import { createInterface } from "node:readline/promises";
12
+ import { Client, InsufficientScopeError, SdkHttpError, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client";
13
+ import { buildApplication, buildCommand, buildRouteMap, numberParser, run as runStricli } from "@stricli/core";
14
+ import { isSignedIn, signIn, signOut, storedProvider } from "./auth.js";
15
+ import { BUILT_IN, commandTable, flagKind, flagName, findCommand, isCustom, skillFile, tokenEnvOf, typeHint, } from "./commands.js";
16
+ export const EXIT = { ok: 0, failed: 1, usage: 2, signIn: 3, approval: 4, unreachable: 5 };
17
+ export function processIO() {
18
+ return {
19
+ write: (text) => void process.stdout.write(text),
20
+ writeError: (text) => void process.stderr.write(text),
21
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
22
+ async *lines(prompt) {
23
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
24
+ if (prompt) {
25
+ rl.setPrompt(prompt);
26
+ rl.prompt();
27
+ }
28
+ try {
29
+ for await (const line of rl) {
30
+ yield line;
31
+ if (prompt)
32
+ rl.prompt();
33
+ }
34
+ }
35
+ finally {
36
+ rl.close();
37
+ }
38
+ },
39
+ async confirm(question) {
40
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
41
+ try {
42
+ return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim());
43
+ }
44
+ finally {
45
+ rl.close();
46
+ }
47
+ },
48
+ };
49
+ }
50
+ /** A failure the CLI reports with its own exit code. */
51
+ class CliExit extends Error {
52
+ code;
53
+ constructor(code, message) {
54
+ super(message);
55
+ this.code = code;
56
+ }
57
+ }
58
+ /** Takes `--server` and `--token` out before the commands are known (they decide which commands exist). */
59
+ function connectionFlags(argv) {
60
+ const rest = [];
61
+ const found = {};
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const m = /^--(server|token)(?:=(.*))?$/.exec(argv[i]);
64
+ if (!m)
65
+ rest.push(argv[i]);
66
+ else
67
+ found[m[1]] = m[2] ?? argv[++i] ?? "";
68
+ }
69
+ return { rest, server: found.server, token: found.token };
70
+ }
71
+ function callMeta(config, command) {
72
+ return { "chinwag/surface": "cli", "chinwag/command": command, "chinwag/client-version": config.version ?? "1", "chinwag/idempotency-key": randomUUID() };
73
+ }
74
+ function envName(config, suffix) {
75
+ return `${config.name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_${suffix}`;
76
+ }
77
+ function noServer(config) {
78
+ return `No server: pass --server <url> or set ${envName(config, "SERVER")}.`;
79
+ }
80
+ /** Run the CLI once; resolves with its exit code. */
81
+ export async function runCli(config, argv, io = processIO()) {
82
+ io.signal?.throwIfAborted();
83
+ const env = io.env ?? process.env;
84
+ const name = config.name;
85
+ const { rest, server: given, token: givenToken } = connectionFlags(argv);
86
+ const server = given ?? config.server ?? env[envName(config, "SERVER")];
87
+ const token = givenToken ?? env[tokenEnvOf(config)];
88
+ if (rest[0] === "chat" && !config.commands?.[rest[0]])
89
+ return runTerminal(config, server, token, rest, io);
90
+ const asksHelp = rest.length === 0 || rest[0] === "-h" || rest[0] === "--help";
91
+ let client;
92
+ let tools = [];
93
+ let problem;
94
+ if (server && rest[0] !== "auth") {
95
+ try {
96
+ client = await connect(config, server, token, io.signal);
97
+ io.signal?.throwIfAborted();
98
+ tools = await listTools(client);
99
+ }
100
+ catch (error) {
101
+ await client?.close();
102
+ client = undefined;
103
+ problem = explain(config, server, error);
104
+ }
105
+ }
106
+ if (io.signal?.aborted) {
107
+ await client?.close();
108
+ return EXIT.failed;
109
+ }
110
+ if (problem && !asksHelp) {
111
+ io.writeError(`${problem.message}\n`);
112
+ return problem.code;
113
+ }
114
+ const stricliProcess = {
115
+ stdout: { write: (text) => io.write(text) },
116
+ stderr: { write: (text) => io.writeError(text) },
117
+ env: { ...env, ...(env.NO_COLOR ? { STRICLI_NO_COLOR: "1" } : {}) },
118
+ exitCode: undefined,
119
+ };
120
+ const context = {
121
+ process: stricliProcess,
122
+ cli: {
123
+ call: async (tool, input) => {
124
+ if (!client)
125
+ throw new CliExit(EXIT.unreachable, noServer(config));
126
+ return valueOf(await client.callTool({ name: tool, arguments: input, _meta: callMeta(config, findCommand(commandTable(config, tools), rest)?.command.words.join(" ") ?? "custom") }));
127
+ },
128
+ print: (value, options = {}) => io.write(`${render(value, options)}\n`),
129
+ },
130
+ };
131
+ const disconnect = () => { void client?.close(); };
132
+ io.signal?.addEventListener("abort", disconnect, { once: true });
133
+ try {
134
+ const app = buildApplication(routes(config, server, tools, client, io), {
135
+ name,
136
+ scanner: { caseStyle: "original" },
137
+ determineExitCode: (error) => (error instanceof CliExit ? error.code : needsSignIn(error) ? EXIT.signIn : EXIT.failed),
138
+ });
139
+ await runStricli(app, rest, { ...context, process: stricliProcess });
140
+ }
141
+ finally {
142
+ io.signal?.removeEventListener("abort", disconnect);
143
+ await client?.close();
144
+ }
145
+ if (problem)
146
+ io.writeError(`\n${problem.message}\n`);
147
+ const code = Number(stricliProcess.exitCode ?? 0);
148
+ if (code < 0) {
149
+ // Stricli's own: an unknown command or a wrong argument. Point at the help of what was typed.
150
+ const words = rest.filter((w) => !w.startsWith("-"));
151
+ io.writeError(`Run \`${[name, ...wordsKnown(words, config, tools)].join(" ")} --help\` to see how to use it.\n`);
152
+ return EXIT.usage;
153
+ }
154
+ return code;
155
+ }
156
+ /** The words of the command line that name a group or command, for the help hint. */
157
+ function wordsKnown(words, config, tools) {
158
+ const paths = [...commandTable(config, tools).map((c) => c.words.join(" ")), ...BUILT_IN, "auth login", "auth logout", "auth status"];
159
+ const known = [];
160
+ for (const w of words) {
161
+ const next = [...known, w].join(" ");
162
+ if (!paths.some((p) => p === next || p.startsWith(`${next} `)))
163
+ break;
164
+ known.push(w);
165
+ }
166
+ return known;
167
+ }
168
+ async function connect(config, server, token, signal) {
169
+ const provider = token ? { token: async () => token } : isSignedIn(config, server) ? storedProvider(config, server, undefined, signal) : undefined;
170
+ const client = new Client({ name: `${config.name}-cli`, version: config.version ?? "1" });
171
+ try {
172
+ await client.connect(new StreamableHTTPClientTransport(new URL(server), provider ? { authProvider: provider } : {}));
173
+ signal?.throwIfAborted();
174
+ }
175
+ catch (error) {
176
+ await client.close();
177
+ throw error;
178
+ }
179
+ return client;
180
+ }
181
+ async function listTools(client) {
182
+ const tools = [];
183
+ let cursor;
184
+ do {
185
+ const page = await client.listTools(cursor ? { cursor } : {});
186
+ tools.push(...page.tools);
187
+ cursor = page.nextCursor;
188
+ } while (cursor);
189
+ return tools;
190
+ }
191
+ /** Why the server could not be used, as one line and an exit code. */
192
+ function explain(config, server, error) {
193
+ if (needsSignIn(error))
194
+ return new CliExit(EXIT.signIn, `Sign in first: ${config.name} auth login (or set ${tokenEnvOf(config)}).`);
195
+ const status = error instanceof SdkHttpError ? error.data?.status : undefined;
196
+ const why = short(error.message);
197
+ return new CliExit(EXIT.unreachable, status ? `${server} answered ${status}, not as an MCP server: is this its /mcp address? (${why})` : `Could not reach ${server}: ${why}`);
198
+ }
199
+ /** A server's error as one short line: a web page answering instead of an MCP server is not printed whole. */
200
+ function short(message) {
201
+ if (/<html|<!doctype/i.test(message))
202
+ return "it answered with a web page, not an MCP server (is this the /mcp address?)";
203
+ const line = message.split("\n")[0];
204
+ return line.length > 200 ? `${line.slice(0, 200)}…` : line;
205
+ }
206
+ /** Only a real challenge means sign in: a 401, or a 403 asking for more scope. Any other 403 is a refusal. */
207
+ function needsSignIn(error) {
208
+ if (error instanceof UnauthorizedError || error instanceof InsufficientScopeError)
209
+ return true;
210
+ return error instanceof SdkHttpError && error.data?.status === 401;
211
+ }
212
+ /** The command tree: groups from the commands' words, then the CLI's own commands. */
213
+ function routes(config, server, tools, client, io) {
214
+ const root = { children: new Map() };
215
+ for (const c of commandTable(config, tools)) {
216
+ let node = root;
217
+ for (const word of c.words) {
218
+ if (!node.children.has(word))
219
+ node.children.set(word, { children: new Map() });
220
+ node = node.children.get(word);
221
+ }
222
+ node.command = c.config && isCustom(c.config) ? c.config.command : toolCommand(config, c, client, io);
223
+ }
224
+ const build = (node, path) => {
225
+ const out = {};
226
+ for (const [word, child] of node.children) {
227
+ const here = [...path, word];
228
+ if (child.command && child.children.size)
229
+ throw new Error(`${config.name}: "${here.join(" ")}" is both a command and a group; a word is one or the other.`);
230
+ if (child.command)
231
+ out[word] = child.command;
232
+ else {
233
+ const sub = build(child, here);
234
+ out[word] = buildRouteMap({ routes: sub, docs: { brief: config.groups?.[here.join(" ")] ?? Object.keys(sub).join(", ") } });
235
+ }
236
+ }
237
+ return out;
238
+ };
239
+ const top = build(root, []);
240
+ for (const word of BUILT_IN)
241
+ if (top[word])
242
+ throw new Error(`${config.name}: "${word}" is the CLI's own command; name yours something else.`);
243
+ const notice = server ? `Server: ${server}` : noServer(config);
244
+ return buildRouteMap({
245
+ routes: { ...top,
246
+ chat: top.chat ?? buildCommand({ docs: { brief: "Chat with the agent in a terminal (chat --help)" }, parameters: {}, func() { } }),
247
+ tools: toolsCommand(tools, io), skill: skillCommand(config, tools, io), auth: authRoutes(config, server, io) },
248
+ docs: { brief: config.description ?? config.name, fullDescription: `${config.description ?? config.name}\n\n${notice}\n--server <url> and --token <token> work on every command.` },
249
+ });
250
+ }
251
+ /** Flags the CLI puts on tool commands itself. */
252
+ const OWN_FLAGS = ["json", "fields", "dry-run", "yes"];
253
+ /** A tool as a Stricli command: its input as flags (and the CLI file's positional arguments). */
254
+ function toolCommand(config, c, client, io) {
255
+ const tool = c.tool;
256
+ const settings = c.config && !isCustom(c.config) ? c.config : undefined;
257
+ const args = settings?.args ?? [];
258
+ const props = tool.inputSchema.properties ?? {};
259
+ const required = new Set(tool.inputSchema.required ?? []);
260
+ const writes = tool.annotations?.readOnlyHint !== true;
261
+ const destructive = writes && tool.annotations?.destructiveHint !== false;
262
+ const byFlag = new Map();
263
+ const flags = {};
264
+ for (const [field, schema] of Object.entries(props)) {
265
+ if (args.includes(field))
266
+ continue;
267
+ const key = flagName(field);
268
+ if (OWN_FLAGS.includes(key))
269
+ throw new Error(`${config.name}: ${tool.name}'s input ${field} clashes with the CLI's --${key}.`);
270
+ byFlag.set(key, field);
271
+ flags[key] = flagSpec(field, schema, !required.has(field));
272
+ }
273
+ flags.json = { kind: "boolean", brief: "Print JSON: for scripts and agents", optional: true, withNegated: false };
274
+ flags.fields = { kind: "parsed", parse: (v) => v.split(",").map((f) => f.trim()).filter(Boolean), brief: "Only these fields, comma-separated (a.b for nested ones)", placeholder: "a,b", optional: true };
275
+ if (writes)
276
+ flags["dry-run"] = { kind: "boolean", brief: "Show the call it would make, and send nothing", optional: true, withNegated: false };
277
+ if (destructive)
278
+ flags.yes = { kind: "boolean", brief: "Don't ask first (needed without a terminal)", optional: true, withNegated: false };
279
+ const examples = settings?.examples?.length ? `\n\nExamples:\n${settings.examples.map((e) => ` ${e}`).join("\n")}` : "";
280
+ const usage = `${config.name} ${c.words.join(" ")}`;
281
+ return buildCommand({
282
+ docs: { brief: c.summary, fullDescription: `${tool.description ?? c.summary}${examples}` },
283
+ parameters: {
284
+ flags: flags,
285
+ positional: { kind: "tuple", parameters: args.map((field) => ({ brief: props[field]?.description ?? field.replace(/_/g, " "), placeholder: field, parse: parserFor(field, props[field] ?? {}), optional: !required.has(field) })) },
286
+ },
287
+ async func(parsed, ...positional) {
288
+ const input = {};
289
+ args.forEach((field, i) => {
290
+ if (positional[i] !== undefined)
291
+ input[field] = positional[i];
292
+ });
293
+ for (const [key, field] of byFlag)
294
+ if (parsed[key] !== undefined)
295
+ input[field] = parsed[key];
296
+ if (writes && parsed["dry-run"])
297
+ return void io.write(`${JSON.stringify({ dryRun: true, tool: tool.name, arguments: input }, null, parsed.json ? undefined : 2)}\n`);
298
+ if (destructive && !parsed.yes) {
299
+ if (!io.interactive)
300
+ throw new CliExit(EXIT.approval, `${usage} may delete or overwrite: run it again with --yes.`);
301
+ if (!(await io.confirm(`${usage} may delete or overwrite. Go ahead?`)))
302
+ throw new CliExit(EXIT.approval, "Stopped; nothing was sent.");
303
+ }
304
+ if (!client)
305
+ throw new CliExit(EXIT.unreachable, noServer(config));
306
+ const result = await client.callTool({ name: tool.name, arguments: input, _meta: callMeta(config, c.words.join(" ")) }).catch((error) => {
307
+ throw needsSignIn(error) ? explain(config, "", error) : error;
308
+ });
309
+ if (result.isError)
310
+ throw new CliExit(EXIT.failed, textOf(result.content) || `${tool.name} failed.`);
311
+ io.write(`${render(valueOf(result), { json: Boolean(parsed.json), fields: parsed.fields, columns: settings?.columns, view: settings?.view })}\n`);
312
+ },
313
+ });
314
+ }
315
+ function flagSpec(field, schema, optional) {
316
+ const brief = schema.description ?? field.replace(/_/g, " ");
317
+ const kind = flagKind(schema);
318
+ if (schema.enum && schema.enum.every((v) => typeof v === "string"))
319
+ return { kind: "enum", values: schema.enum, brief, optional };
320
+ if (kind === "boolean")
321
+ return optional ? { kind: "boolean", brief, optional: true, withNegated: false } : { kind: "parsed", parse: parseBoolean, brief, placeholder: "true|false" };
322
+ if (kind === "list")
323
+ return { kind: "parsed", parse: parserFor(field, schema.items ?? {}), variadic: ",", brief: `${brief} (repeat the flag, or comma-separated)`, placeholder: flagKind(schema.items ?? {}), optional };
324
+ if (kind === "json")
325
+ return { kind: "parsed", parse: parserFor(field, schema), brief: `${schema.description ? `${schema.description}: ` : ""}JSON ${typeHint(schema)}, or @file.json`, placeholder: "json", optional };
326
+ return { kind: "parsed", parse: parserFor(field, schema), brief, placeholder: kind === "string" ? "text" : kind, optional };
327
+ }
328
+ function parseBoolean(v) {
329
+ if (/^(true|1|yes)$/i.test(v))
330
+ return true;
331
+ if (/^(false|0|no)$/i.test(v))
332
+ return false;
333
+ throw new Error("must be true or false");
334
+ }
335
+ function parserFor(field, schema) {
336
+ const kind = flagKind(schema);
337
+ if (kind === "number")
338
+ return numberParser;
339
+ if (kind === "integer") {
340
+ return (v) => {
341
+ const n = numberParser(v);
342
+ if (!Number.isInteger(n))
343
+ throw new Error(`${field} must be a whole number`);
344
+ return n;
345
+ };
346
+ }
347
+ if (kind === "boolean")
348
+ return parseBoolean;
349
+ if (kind === "json")
350
+ return (v) => JSON.parse(v.startsWith("@") ? readFileSync(v.slice(1), "utf8") : v);
351
+ return String;
352
+ }
353
+ function toolsCommand(tools, io) {
354
+ return buildCommand({
355
+ docs: { brief: "Every tool, with its input and output schema", fullDescription: "Every tool the server has, or one tool's full description: its input, output and hints." },
356
+ parameters: {
357
+ flags: {
358
+ search: { kind: "parsed", parse: String, brief: "Only tools whose name or description has this", placeholder: "text", optional: true },
359
+ json: { kind: "boolean", brief: "Print JSON", optional: true, withNegated: false },
360
+ },
361
+ positional: { kind: "tuple", parameters: [{ brief: "One tool's name", placeholder: "tool", parse: String, optional: true }] },
362
+ },
363
+ func(flags, named) {
364
+ if (named) {
365
+ const one = tools.find((t) => t.name === named);
366
+ if (!one)
367
+ throw new CliExit(EXIT.usage, `No tool named ${named}.`);
368
+ return void io.write(`${JSON.stringify(one, null, flags.json ? undefined : 2)}\n`);
369
+ }
370
+ const q = flags.search?.toLowerCase();
371
+ const picked = tools.filter((t) => !q || `${t.name} ${t.title ?? ""} ${t.description ?? ""}`.toLowerCase().includes(q));
372
+ if (flags.json)
373
+ return void io.write(`${JSON.stringify(picked)}\n`);
374
+ const width = Math.max(4, ...picked.map((t) => t.name.length));
375
+ io.write(`${picked.map((t) => `${t.name.padEnd(width)} ${t.title ?? ""}${t.annotations?.readOnlyHint ? "" : " (changes things)"}`).join("\n")}\n`);
376
+ },
377
+ });
378
+ }
379
+ function skillCommand(config, tools, io) {
380
+ return buildCommand({
381
+ docs: { brief: "This CLI's SKILL.md for coding agents", fullDescription: "Every command, flag, JSON shape and exit code, for Claude Code, Codex or Cursor. --write puts it in <dir>/<name>-cli/SKILL.md." },
382
+ parameters: { flags: { write: { kind: "parsed", parse: String, brief: "Write it into this skills folder", placeholder: "dir", optional: true } } },
383
+ async func(flags) {
384
+ const text = skillFile(config, tools);
385
+ if (!flags.write)
386
+ return void io.write(text);
387
+ const { mkdirSync, writeFileSync } = await import("node:fs");
388
+ const { join } = await import("node:path");
389
+ const folder = join(flags.write, `${config.name}-cli`);
390
+ mkdirSync(folder, { recursive: true });
391
+ writeFileSync(join(folder, "SKILL.md"), text);
392
+ io.writeError(`Wrote ${join(folder, "SKILL.md")}\n`);
393
+ },
394
+ });
395
+ }
396
+ function authRoutes(config, server, io) {
397
+ const need = () => {
398
+ if (!server)
399
+ throw new CliExit(EXIT.usage, noServer(config));
400
+ return server;
401
+ };
402
+ return buildRouteMap({
403
+ docs: { brief: "Sign in, sign out, or see who is signed in" },
404
+ routes: {
405
+ login: buildCommand({
406
+ docs: { brief: "Sign in on the organization's own login page" },
407
+ parameters: {},
408
+ async func() {
409
+ await signIn(config, need(), io.signal);
410
+ io.writeError(`Signed in to ${need()}.\n`);
411
+ },
412
+ }),
413
+ logout: buildCommand({
414
+ docs: { brief: "Forget this computer's sign-in" },
415
+ parameters: {},
416
+ func() {
417
+ signOut(config, need());
418
+ io.writeError(`Signed out of ${need()}.\n`);
419
+ },
420
+ }),
421
+ status: buildCommand({
422
+ docs: { brief: "Whether you are signed in, and how" },
423
+ parameters: { flags: { json: { kind: "boolean", brief: "Print JSON", optional: true, withNegated: false } } },
424
+ func(flags) {
425
+ const s = need();
426
+ const env = (io.env ?? process.env)[tokenEnvOf(config)];
427
+ const source = env ? "env" : isSignedIn(config, s) ? "login" : null;
428
+ if (flags.json)
429
+ return void io.write(`${JSON.stringify({ server: s, signedIn: source !== null, source })}\n`);
430
+ io.write(`${s}: ${env ? `using ${tokenEnvOf(config)}` : source ? "signed in" : "not signed in"}\n`);
431
+ },
432
+ }),
433
+ },
434
+ });
435
+ }
436
+ function textOf(content) {
437
+ return content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
438
+ }
439
+ function valueOf(result) {
440
+ if (result.isError)
441
+ throw new CliExit(EXIT.failed, textOf(result.content) || "The tool failed.");
442
+ if (result.structuredContent !== undefined)
443
+ return result.structuredContent;
444
+ const text = textOf((result.content ?? []));
445
+ try {
446
+ return JSON.parse(text);
447
+ }
448
+ catch {
449
+ return text;
450
+ }
451
+ }
452
+ /** A field by its dotted path ("event.id"). */
453
+ function get(value, path) {
454
+ let v = value;
455
+ for (const part of path.split("."))
456
+ v = v && typeof v === "object" ? v[part] : undefined;
457
+ return v;
458
+ }
459
+ function pick(value, fields) {
460
+ if (Array.isArray(value))
461
+ return value.map((v) => pick(v, fields));
462
+ if (!value || typeof value !== "object")
463
+ return value;
464
+ const out = {};
465
+ for (const f of fields) {
466
+ const v = get(value, f);
467
+ if (v !== undefined)
468
+ out[f] = v;
469
+ }
470
+ return out;
471
+ }
472
+ function render(value, options) {
473
+ const shown = options.fields ? pick(value, options.fields) : value;
474
+ if (options.json)
475
+ return JSON.stringify(shown);
476
+ return human(shown, options.fields ?? options.columns, options.fields ?? options.view);
477
+ }
478
+ /** For people: a table for a list of records (also one inside an object, its other fields after it), field lines for one record, else the value. */
479
+ function human(value, columns, view) {
480
+ if (typeof value === "string")
481
+ return value;
482
+ if (Array.isArray(value))
483
+ return table(value, columns);
484
+ if (value && typeof value === "object") {
485
+ const record = value;
486
+ const lists = Object.keys(record).filter((k) => isRecords(record[k]));
487
+ if (lists.length === 1 && !view) {
488
+ const rest = Object.keys(record).filter((k) => k !== lists[0]);
489
+ return [table(record[lists[0]], columns), ...(rest.length ? ["", fields(record, rest)] : [])].join("\n");
490
+ }
491
+ return fields(record, view ?? Object.keys(record));
492
+ }
493
+ return JSON.stringify(value);
494
+ }
495
+ function isRecords(v) {
496
+ return Array.isArray(v) && v.length > 0 && v.every((r) => r && typeof r === "object" && !Array.isArray(r));
497
+ }
498
+ function table(rows, columns) {
499
+ if (!rows.length)
500
+ return "(none)";
501
+ if (!isRecords(rows))
502
+ return rows.map(cell).join("\n");
503
+ const first = rows[0];
504
+ const cols = columns ?? Object.keys(first).filter((k) => isScalar(first[k])).slice(0, 6);
505
+ const cells = rows.map((r) => cols.map((c) => cell(get(r, c))));
506
+ const widths = cols.map((c, i) => Math.max(c.length, ...cells.map((row) => row[i].length)));
507
+ const line = (parts) => parts.map((p, i) => p.padEnd(widths[i])).join(" ").trimEnd();
508
+ return [line(cols.map((c) => c.toUpperCase())), ...cells.map(line)].join("\n");
509
+ }
510
+ function fields(record, keys) {
511
+ const width = Math.max(...keys.map((k) => k.length));
512
+ return keys.map((k) => `${k.padEnd(width)} ${cell(get(record, k))}`).join("\n");
513
+ }
514
+ function isScalar(v) {
515
+ return v === null || ["string", "number", "boolean"].includes(typeof v);
516
+ }
517
+ function cell(v) {
518
+ if (v === undefined || v === null)
519
+ return "";
520
+ if (Array.isArray(v) && v.every(isScalar))
521
+ return v.join(", ");
522
+ return isScalar(v) ? String(v) : JSON.stringify(v);
523
+ }
@@ -0,0 +1,3 @@
1
+ import { type CliConfig } from "./commands.ts";
2
+ import type { CliIO } from "./run.ts";
3
+ export declare function runTerminal(config: CliConfig, server: string | undefined, token: string | undefined, argv: string[], io: CliIO): Promise<number>;
@@ -0,0 +1,135 @@
1
+ // Chat uses the agent's authenticated terminal endpoint; it never calls an MCP tool.
2
+ import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parseArgs, styleText } from "node:util";
5
+ import { configDir, isSignedIn, storedAccessToken } from "./auth.js";
6
+ import { tokenEnvOf } from "./commands.js";
7
+ export async function runTerminal(config, server, token, argv, io) {
8
+ const usage = `${config.name} chat ["<message>"] [--json] [--new]`;
9
+ let flags;
10
+ try {
11
+ flags = parseArgs({ args: argv.slice(1), allowPositionals: true, options: { json: { type: "boolean" }, new: { type: "boolean" }, help: { type: "boolean", short: "h" } } });
12
+ }
13
+ catch {
14
+ io.writeError(`Usage: ${usage}\n`);
15
+ return 2;
16
+ }
17
+ if (flags.values.help) {
18
+ io.write(`${usage}\nTalk to the agent. --new forgets this terminal's saved conversation reference; shared person history stays with the agent.\n`);
19
+ return 0;
20
+ }
21
+ if (flags.positionals.length > 1) {
22
+ io.writeError(`Usage: ${usage}\n`);
23
+ return 2;
24
+ }
25
+ const single = flags.positionals.length === 1;
26
+ if (!single && !io.interactive) {
27
+ io.writeError(`chat needs a terminal; for a single reply use ${config.name} chat "<message>".\n`);
28
+ return 2;
29
+ }
30
+ if (!server) {
31
+ io.writeError("Pass --server <url> or configure the MCP server.\n");
32
+ return 5;
33
+ }
34
+ const stored = !token && isSignedIn(config, server);
35
+ try {
36
+ if (stored)
37
+ token = await storedAccessToken(config, server, false, io.signal);
38
+ }
39
+ catch {
40
+ io.writeError(`Sign in again: ${config.name} auth login.\n`);
41
+ return 3;
42
+ }
43
+ if (!token) {
44
+ io.writeError(`Sign in first: ${config.name} auth login (or set ${tokenEnvOf(config)}).\n`);
45
+ return 3;
46
+ }
47
+ const humanChat = !single && !flags.values.json;
48
+ const endpoint = config.agent ?? new URL("/agent/terminal", server).href;
49
+ const file = join(configDir(config), "chat.json");
50
+ let saved = {};
51
+ try {
52
+ saved = JSON.parse(readFileSync(file, "utf8"));
53
+ }
54
+ catch { }
55
+ if (flags.values.new)
56
+ delete saved[endpoint];
57
+ async function send(text) {
58
+ let response;
59
+ const clearStatus = humanChat && Boolean(process.stderr.isTTY) && (io.env ?? process.env).TERM !== "dumb";
60
+ if (humanChat)
61
+ io.writeError(`${styleText("dim", " Waiting for reply…")}${clearStatus ? "" : "\n"}`);
62
+ const request = () => fetch(endpoint, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ text, ...(saved[endpoint] ? { conversationId: saved[endpoint] } : {}) }), signal: io.signal ? AbortSignal.any([io.signal, AbortSignal.timeout(10 * 60_000)]) : AbortSignal.timeout(10 * 60_000) });
63
+ try {
64
+ response = await request();
65
+ if (response.status === 401 && stored) {
66
+ await response.body?.cancel();
67
+ token = await storedAccessToken(config, server, true, io.signal);
68
+ if (token)
69
+ response = await request();
70
+ }
71
+ }
72
+ catch {
73
+ response = undefined;
74
+ }
75
+ finally {
76
+ if (clearStatus)
77
+ io.writeError("\r\u001b[2K");
78
+ }
79
+ if (!response) {
80
+ io.writeError("Could not reach the agent.\n");
81
+ return 5;
82
+ }
83
+ if (response.status === 401 || response.status === 403) {
84
+ io.writeError(`Sign in first: ${config.name} auth login.\n`);
85
+ return 3;
86
+ }
87
+ if (!response.ok) {
88
+ io.writeError(`The agent could not finish (${response.status}).\n`);
89
+ return 1;
90
+ }
91
+ let result;
92
+ try {
93
+ result = await response.json();
94
+ }
95
+ catch {
96
+ io.writeError("The agent returned invalid JSON.\n");
97
+ return 1;
98
+ }
99
+ if (typeof result.conversationId !== "string" || !Array.isArray(result.replies)) {
100
+ io.writeError("The agent returned an invalid reply.\n");
101
+ return 1;
102
+ }
103
+ io.signal?.throwIfAborted();
104
+ saved[endpoint] = result.conversationId;
105
+ mkdirSync(configDir(config), { recursive: true, mode: 0o700 });
106
+ writeFileSync(file, `${JSON.stringify(saved)}\n`, { mode: 0o600 });
107
+ chmodSync(file, 0o600);
108
+ const messages = result.replies.filter(r => r.type === "message").map(r => r.text ?? "");
109
+ if (flags.values.json)
110
+ io.write(`${JSON.stringify(result)}\n`);
111
+ else if (humanChat && messages.length)
112
+ io.write(`\n${styleText(["bold", "green"], config.name)}\n${messages.join("\n\n")}\n\n`);
113
+ else
114
+ io.write(`${messages.join("\n")}\n`);
115
+ return 0;
116
+ }
117
+ if (single)
118
+ return send(flags.positionals[0]);
119
+ if (!io.lines) {
120
+ io.writeError("This CLI input does not support chat.\n");
121
+ return 2;
122
+ }
123
+ io.writeError(humanChat ? `\n${styleText("bold", `${config.name} chat`)}\n${styleText("dim", "Type /exit or press Ctrl-D to leave.")}\n\n` : "Type /exit or press Ctrl-D to leave.\n");
124
+ for await (const line of io.lines(humanChat ? `${styleText(["bold", "cyan"], "You")} › ` : undefined)) {
125
+ const text = line.trim();
126
+ if (text === "/exit")
127
+ break;
128
+ if (text) {
129
+ const code = await send(text);
130
+ if (code)
131
+ return code;
132
+ }
133
+ }
134
+ return 0;
135
+ }
@@ -28,8 +28,8 @@ export declare class Client {
28
28
  get<T>(path: string): Promise<T>;
29
29
  /** A file's bytes: GET /v1/files/{id} answers them or redirects to a signed URL, which fetch follows without the key. */
30
30
  download(fileId: string): Promise<Uint8Array>;
31
- /** A POST with its own Idempotency-Key, kept across the retries of this one call; `tries: 1` for courtesies. */
32
- post<T>(path: string, body: unknown, tries?: number): Promise<T>;
31
+ /** A POST with its own Idempotency-Key (the caller's, else a new one), kept across the retries of this one call; `tries: 1` for courtesies. */
32
+ post<T>(path: string, body: unknown, tries?: number, idempotencyKey?: string): Promise<T>;
33
33
  private request;
34
34
  }
35
35
  /**