@flowriki/agent 0.1.0-beta.28 → 0.1.0-beta.29
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/README.md +24 -0
- package/dist/{chunk-PETFNOJA.js → chunk-3SU3RBVY.js} +11 -1
- package/dist/cli.js +748 -1
- package/dist/engine.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,30 @@ npx @flowriki/agent@latest doctor # diagnose setup problems
|
|
|
25
25
|
npx @flowriki/agent@latest disconnect # forget this computer's credential
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
## Write tests with Claude
|
|
29
|
+
|
|
30
|
+
The agent is also an MCP server, so Claude Code or Claude Desktop can write
|
|
31
|
+
Flowriki tests for you, on your own Claude plan. Claude drives a browser on
|
|
32
|
+
this computer, each step is performed before it is kept, and what it writes is
|
|
33
|
+
saved to a **new branch** for you to review and publish in the dashboard.
|
|
34
|
+
|
|
35
|
+
Connect this computer first (`connect` above), then add Flowriki to Claude:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
claude mcp add flowriki -- npx -y @flowriki/agent@latest mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For Claude Desktop, add this to its MCP settings (`claude_desktop_config.json`):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{ "mcpServers": { "flowriki": { "command": "npx", "args": ["-y", "@flowriki/agent@latest", "mcp"] } } }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then ask, for example: *"Write a Flowriki test that signs up and verifies the
|
|
48
|
+
email"*. Claude reads your project's features, sign-in profiles and helpers,
|
|
49
|
+
and uses them. You do not run `mcp` yourself — Claude starts it. It works
|
|
50
|
+
alongside `start`, in a browser window of its own.
|
|
51
|
+
|
|
28
52
|
## What you do not need
|
|
29
53
|
|
|
30
54
|
No checkout of Flowriki, and nothing to build. `connect` and `start` are the
|
|
@@ -6,7 +6,7 @@ import os from "os";
|
|
|
6
6
|
import path from "path";
|
|
7
7
|
import { createRequire } from "module";
|
|
8
8
|
import { spawnSync } from "child_process";
|
|
9
|
-
var AGENT_VERSION = "0.1.0-beta.
|
|
9
|
+
var AGENT_VERSION = "0.1.0-beta.29";
|
|
10
10
|
async function prepareBrowsers() {
|
|
11
11
|
if (browsersReady()) {
|
|
12
12
|
return { ok: true, already: true, message: "chromium is already installed" };
|
|
@@ -11206,7 +11206,17 @@ export {
|
|
|
11206
11206
|
AGENT_VERSION,
|
|
11207
11207
|
prepareBrowsers,
|
|
11208
11208
|
browsersReady,
|
|
11209
|
+
connectionFor,
|
|
11210
|
+
resolveService,
|
|
11209
11211
|
connect,
|
|
11212
|
+
RecordedStepSchema,
|
|
11213
|
+
authenticationResolver,
|
|
11214
|
+
checkAssertion,
|
|
11215
|
+
openRunSession,
|
|
11216
|
+
closeRunSession,
|
|
11217
|
+
replayFlow,
|
|
11218
|
+
executeStep,
|
|
11219
|
+
helperRunner,
|
|
11210
11220
|
resolveCheckouts,
|
|
11211
11221
|
start,
|
|
11212
11222
|
status,
|
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,757 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
AGENT_VERSION,
|
|
4
|
+
RecordedStepSchema,
|
|
5
|
+
authenticationResolver,
|
|
6
|
+
checkAssertion,
|
|
7
|
+
closeRunSession,
|
|
4
8
|
connect,
|
|
9
|
+
connectionFor,
|
|
5
10
|
disconnect,
|
|
6
11
|
doctor,
|
|
12
|
+
executeStep,
|
|
13
|
+
helperRunner,
|
|
14
|
+
openRunSession,
|
|
7
15
|
prepareBrowsers,
|
|
16
|
+
replayFlow,
|
|
8
17
|
resolveCheckouts,
|
|
18
|
+
resolveService,
|
|
9
19
|
start,
|
|
10
20
|
status
|
|
11
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-3SU3RBVY.js";
|
|
12
22
|
|
|
13
23
|
// src/cli.ts
|
|
14
24
|
import { parseArgs } from "util";
|
|
25
|
+
|
|
26
|
+
// src/mcp/protocol.ts
|
|
27
|
+
import readline from "readline";
|
|
28
|
+
var PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
29
|
+
async function handle(message, tools, info) {
|
|
30
|
+
const reply = (result) => ({ jsonrpc: "2.0", id: message.id ?? null, result });
|
|
31
|
+
const fail = (code, text2) => ({ jsonrpc: "2.0", id: message.id ?? null, error: { code, message: text2 } });
|
|
32
|
+
if (message.id === void 0 || message.id === null) return null;
|
|
33
|
+
switch (message.method) {
|
|
34
|
+
case "initialize": {
|
|
35
|
+
const asked = String(message.params?.["protocolVersion"] ?? "");
|
|
36
|
+
const version = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
|
|
37
|
+
return reply({
|
|
38
|
+
protocolVersion: version,
|
|
39
|
+
capabilities: { tools: { listChanged: false } },
|
|
40
|
+
serverInfo: { name: info.name, version: info.version },
|
|
41
|
+
instructions: info.instructions
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
case "ping":
|
|
45
|
+
return reply({});
|
|
46
|
+
case "tools/list":
|
|
47
|
+
return reply({
|
|
48
|
+
tools: tools.map(({ name, title, description, inputSchema, annotations }) => ({
|
|
49
|
+
name,
|
|
50
|
+
...title ? { title } : {},
|
|
51
|
+
description,
|
|
52
|
+
inputSchema,
|
|
53
|
+
...annotations ? { annotations } : {}
|
|
54
|
+
}))
|
|
55
|
+
});
|
|
56
|
+
case "tools/call": {
|
|
57
|
+
const name = String(message.params?.["name"] ?? "");
|
|
58
|
+
const tool = tools.find((t) => t.name === name);
|
|
59
|
+
if (!tool) return fail(-32602, `no tool named ${name}`);
|
|
60
|
+
const args = message.params?.["arguments"] ?? {};
|
|
61
|
+
try {
|
|
62
|
+
return reply(await tool.run(args));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return reply({ content: [{ type: "text", text: error.message }], isError: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
return fail(-32601, `method not found: ${message.method}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function serve(tools, info, io = {}) {
|
|
72
|
+
const input = io.input ?? process.stdin;
|
|
73
|
+
const output = io.output ?? process.stdout;
|
|
74
|
+
if (!io.output) {
|
|
75
|
+
console.log = console.info = console.warn = console.debug = (...args) => {
|
|
76
|
+
process.stderr.write(`${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
77
|
+
`);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
81
|
+
const pending = /* @__PURE__ */ new Set();
|
|
82
|
+
lines.on("line", (line) => {
|
|
83
|
+
if (!line.trim()) return;
|
|
84
|
+
let message;
|
|
85
|
+
try {
|
|
86
|
+
message = JSON.parse(line);
|
|
87
|
+
} catch {
|
|
88
|
+
output.write(`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } })}
|
|
89
|
+
`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const task = handle(message, tools, info).then((answer) => {
|
|
93
|
+
if (answer) output.write(`${JSON.stringify(answer)}
|
|
94
|
+
`);
|
|
95
|
+
});
|
|
96
|
+
pending.add(task);
|
|
97
|
+
void task.finally(() => pending.delete(task));
|
|
98
|
+
});
|
|
99
|
+
return new Promise((resolve) => lines.on("close", () => void Promise.allSettled([...pending]).then(() => resolve())));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/mcp/service-client.ts
|
|
103
|
+
var ServiceClient = class {
|
|
104
|
+
constructor(connection, fetcher = fetch) {
|
|
105
|
+
this.connection = connection;
|
|
106
|
+
this.fetcher = fetcher;
|
|
107
|
+
}
|
|
108
|
+
connection;
|
|
109
|
+
fetcher;
|
|
110
|
+
get origin() {
|
|
111
|
+
return this.connection.service;
|
|
112
|
+
}
|
|
113
|
+
async call(path, init = {}) {
|
|
114
|
+
const res = await this.fetcher(`${this.connection.service}${path}`, {
|
|
115
|
+
...init,
|
|
116
|
+
headers: {
|
|
117
|
+
"x-flowriki-device": this.connection.deviceId,
|
|
118
|
+
"x-flowriki-device-secret": this.connection.secret,
|
|
119
|
+
"content-type": "application/json"
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
const text2 = await res.text();
|
|
123
|
+
let body = {};
|
|
124
|
+
try {
|
|
125
|
+
body = text2 ? JSON.parse(text2) : {};
|
|
126
|
+
} catch {
|
|
127
|
+
}
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
if (res.status === 404 && !("error" in body)) {
|
|
130
|
+
throw new Error("the Flowriki service does not offer this yet \u2014 it may need updating");
|
|
131
|
+
}
|
|
132
|
+
throw new Error(String(body["error"] ?? `the service answered ${res.status}`));
|
|
133
|
+
}
|
|
134
|
+
return body;
|
|
135
|
+
}
|
|
136
|
+
projects() {
|
|
137
|
+
return this.call("/api/agent/projects");
|
|
138
|
+
}
|
|
139
|
+
catalog(projectId) {
|
|
140
|
+
return this.call(`/api/agent/catalog?projectId=${encodeURIComponent(projectId)}`);
|
|
141
|
+
}
|
|
142
|
+
prepare(projectId, steps, signIn) {
|
|
143
|
+
return this.call(`/api/agent/prepare?projectId=${encodeURIComponent(projectId)}`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
body: JSON.stringify({ steps, ...signIn ? { signIn } : {} })
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
saveFlow(projectId, title, flow) {
|
|
149
|
+
return this.call(
|
|
150
|
+
`/api/agent/flows?projectId=${encodeURIComponent(projectId)}`,
|
|
151
|
+
{ method: "POST", body: JSON.stringify({ title, flow }) }
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// src/mcp/session.ts
|
|
157
|
+
var MAX_TREE = 14e3;
|
|
158
|
+
var STEP_TIMEOUT_MS = 1e4;
|
|
159
|
+
var ACTIONS = ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"];
|
|
160
|
+
var isAction = (value) => ACTIONS.includes(String(value));
|
|
161
|
+
var LANDMARKS = /* @__PURE__ */ new Set(["main", "navigation", "banner", "contentinfo", "complementary", "search", "form", "dialog", "alertdialog", "region", "group", "list", "table", "menu", "toolbar", "tablist"]);
|
|
162
|
+
function scopeOf(within) {
|
|
163
|
+
const said = within?.trim() ?? "";
|
|
164
|
+
if (!said) return { scope: "" };
|
|
165
|
+
return LANDMARKS.has(said.toLowerCase()) ? { scope: "", scopeRole: said.toLowerCase() } : { scope: said };
|
|
166
|
+
}
|
|
167
|
+
function describe(step) {
|
|
168
|
+
const target = step.name ? `${step.role || "element"} "${step.name}"` : step.role || "";
|
|
169
|
+
const where = step.scope ? ` in "${step.scope}"` : step.scopeRole ? ` in the ${step.scopeRole}` : "";
|
|
170
|
+
switch (step.type) {
|
|
171
|
+
case "fill":
|
|
172
|
+
return `type "${step.value}" into ${target}${where}`;
|
|
173
|
+
case "select":
|
|
174
|
+
return `choose "${step.value}" in ${target}${where}`;
|
|
175
|
+
case "check":
|
|
176
|
+
return `${step.value === "false" ? "untick" : "tick"} ${target}${where}`;
|
|
177
|
+
case "press":
|
|
178
|
+
return `press ${step.value}${target ? ` in ${target}` : ""}`;
|
|
179
|
+
case "navigate":
|
|
180
|
+
return `go to ${step.value}`;
|
|
181
|
+
case "back":
|
|
182
|
+
case "forward":
|
|
183
|
+
return `browser ${step.type}`;
|
|
184
|
+
case "assert":
|
|
185
|
+
return `check ${step.proposals.map((p) => `${p.kind}${p.op && p.op !== "equals" ? ` ${p.op}` : ""} "${String(p.value)}"${p.target?.name ? ` on ${p.target.role} "${p.target.name}"` : ""}`).join(", ")}`;
|
|
186
|
+
case "useHelper":
|
|
187
|
+
return `use helper ${step.name || step.helper?.helperId} \u2192 {{${step.helper?.saveAs}}}${step.helper?.then === "open" ? ", then open it" : ""}`;
|
|
188
|
+
default:
|
|
189
|
+
return `${step.type} ${target}${where}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
var AuthoringSession = class {
|
|
193
|
+
constructor(service, opts = {}) {
|
|
194
|
+
this.service = service;
|
|
195
|
+
this.opts = opts;
|
|
196
|
+
}
|
|
197
|
+
service;
|
|
198
|
+
opts;
|
|
199
|
+
run = null;
|
|
200
|
+
page = null;
|
|
201
|
+
values = /* @__PURE__ */ new Map();
|
|
202
|
+
resolvers = null;
|
|
203
|
+
draft = null;
|
|
204
|
+
catalog = null;
|
|
205
|
+
get open() {
|
|
206
|
+
return this.run !== null;
|
|
207
|
+
}
|
|
208
|
+
requireOpen() {
|
|
209
|
+
if (!this.run || !this.page || !this.draft) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
210
|
+
return { page: this.page, draft: this.draft, run: this.run };
|
|
211
|
+
}
|
|
212
|
+
/** What the steps so far, plus any about to run, need from the service:
|
|
213
|
+
* sign-ins, helpers, and their secret values. Asked again whenever a new
|
|
214
|
+
* helper or sign-in appears, never kept beyond this process. */
|
|
215
|
+
async prepare(extra = []) {
|
|
216
|
+
const draft = this.draft;
|
|
217
|
+
const prepared = await this.service.prepare(draft.projectId, [...draft.steps, ...extra], draft.signIn ?? null);
|
|
218
|
+
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
219
|
+
this.resolvers = {
|
|
220
|
+
resolveAuth: authenticationResolver(prepared.authentications),
|
|
221
|
+
resolveSecret: (ref) => prepared.secrets[ref],
|
|
222
|
+
...runHelper ? { runHelper } : {}
|
|
223
|
+
};
|
|
224
|
+
return this.resolvers;
|
|
225
|
+
}
|
|
226
|
+
baseUrl() {
|
|
227
|
+
const base = this.catalog?.baseUrl ?? "";
|
|
228
|
+
if (!base) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
|
|
229
|
+
return base;
|
|
230
|
+
}
|
|
231
|
+
/** Open a browser for a new flow, signed in first if asked. */
|
|
232
|
+
async start(input, catalog) {
|
|
233
|
+
await this.close();
|
|
234
|
+
this.catalog = catalog;
|
|
235
|
+
this.draft = { ...input, steps: [] };
|
|
236
|
+
this.values = /* @__PURE__ */ new Map();
|
|
237
|
+
const base = this.baseUrl();
|
|
238
|
+
this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
|
|
239
|
+
this.page = this.run.page;
|
|
240
|
+
if (input.signIn) {
|
|
241
|
+
const resolvers = await this.prepare();
|
|
242
|
+
const signIn = {
|
|
243
|
+
id: "sign-in",
|
|
244
|
+
type: "signIn",
|
|
245
|
+
auth: { ...input.signIn, from: "loginPage" },
|
|
246
|
+
role: "",
|
|
247
|
+
name: "Sign in",
|
|
248
|
+
scope: "",
|
|
249
|
+
value: "",
|
|
250
|
+
secret: false,
|
|
251
|
+
legacySelector: "",
|
|
252
|
+
proposals: []
|
|
253
|
+
};
|
|
254
|
+
this.page = await executeStep(this.page, signIn, {
|
|
255
|
+
pages: this.run.pages,
|
|
256
|
+
...resolvers,
|
|
257
|
+
values: this.values,
|
|
258
|
+
baseUrl: base,
|
|
259
|
+
timeoutMs: 2e4,
|
|
260
|
+
navigationTimeoutMs: 45e3
|
|
261
|
+
});
|
|
262
|
+
} else {
|
|
263
|
+
await this.page.goto(new URL(input.startPath || "/", base).href, { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
264
|
+
}
|
|
265
|
+
return this.snapshot();
|
|
266
|
+
}
|
|
267
|
+
/** The page as Claude reads it: where it is, and what is on it by role and name. */
|
|
268
|
+
async snapshot() {
|
|
269
|
+
const { page, draft } = this.requireOpen();
|
|
270
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
271
|
+
let tree = await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`);
|
|
272
|
+
if (tree.length > MAX_TREE) tree = `${tree.slice(0, MAX_TREE)}
|
|
273
|
+
\u2026 (cut \u2014 the page is long; act on what is above, or scroll with press "PageDown")`;
|
|
274
|
+
const title = await page.title().catch(() => "");
|
|
275
|
+
return `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
|
|
276
|
+
Steps so far: ${draft.steps.length}
|
|
277
|
+
|
|
278
|
+
${tree}`;
|
|
279
|
+
}
|
|
280
|
+
/** Do one thing on the page, and keep it as a step if it worked. */
|
|
281
|
+
async act(input) {
|
|
282
|
+
const { page, draft, run } = this.requireOpen();
|
|
283
|
+
const navigating = input.action === "navigate" || input.action === "back" || input.action === "forward";
|
|
284
|
+
if (!navigating && input.action !== "press" && !(input.role && input.name)) {
|
|
285
|
+
throw new Error(`${input.action} needs the element's role and name, exactly as read_page shows them`);
|
|
286
|
+
}
|
|
287
|
+
if ((input.action === "fill" || input.action === "select" || input.action === "press" || input.action === "navigate") && input.value === void 0) {
|
|
288
|
+
throw new Error(`${input.action} needs a value`);
|
|
289
|
+
}
|
|
290
|
+
const step = RecordedStepSchema.parse({
|
|
291
|
+
id: `s${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
292
|
+
type: input.action,
|
|
293
|
+
role: navigating ? "" : input.role ?? "",
|
|
294
|
+
name: navigating ? "" : input.name ?? "",
|
|
295
|
+
...scopeOf(input.within),
|
|
296
|
+
value: input.action === "check" ? input.value === "false" ? "false" : "true" : input.value ?? "",
|
|
297
|
+
secret: false,
|
|
298
|
+
legacySelector: "",
|
|
299
|
+
proposals: [],
|
|
300
|
+
// A path is the application's own, so the step replays on whichever
|
|
301
|
+
// environment the run is pointed at rather than the one written on.
|
|
302
|
+
...input.action === "navigate" && input.value?.startsWith("/") ? { navigationBase: "application" } : {}
|
|
303
|
+
});
|
|
304
|
+
try {
|
|
305
|
+
this.page = await executeStep(page, step, {
|
|
306
|
+
pages: run.pages,
|
|
307
|
+
values: this.values,
|
|
308
|
+
baseUrl: this.baseUrl(),
|
|
309
|
+
...this.resolvers ?? {},
|
|
310
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
311
|
+
navigationTimeoutMs: 45e3
|
|
312
|
+
});
|
|
313
|
+
} catch (error) {
|
|
314
|
+
throw new Error(`That did not work, and was not kept: ${error.message.split("\n")[0]}
|
|
315
|
+
|
|
316
|
+
${await this.snapshot()}`);
|
|
317
|
+
}
|
|
318
|
+
await this.settle();
|
|
319
|
+
draft.steps.push(step);
|
|
320
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}.
|
|
321
|
+
|
|
322
|
+
${await this.snapshot()}`;
|
|
323
|
+
}
|
|
324
|
+
/** Check something about the page now, and keep the check if it holds. */
|
|
325
|
+
async check(input) {
|
|
326
|
+
const { page, draft } = this.requireOpen();
|
|
327
|
+
if ((input.kind === "heading" || input.kind === "message" || input.kind === "screen") && input.op && input.op !== "equals" && !input.role) {
|
|
328
|
+
throw new Error(`a ${input.kind} check without role and name matches the whole ${input.kind} \u2014 give its full text with op equals, or name the element (role + name) and use kind "text" with op ${input.op}`);
|
|
329
|
+
}
|
|
330
|
+
const assertion = RecordedStepSchema.shape.proposals.element.parse({
|
|
331
|
+
kind: input.kind,
|
|
332
|
+
value: input.value,
|
|
333
|
+
enabled: true,
|
|
334
|
+
...input.op && input.op !== "equals" ? { op: input.op } : {},
|
|
335
|
+
...input.role ? { target: { role: input.role, name: input.name ?? "", ...scopeOf(input.within) } } : {}
|
|
336
|
+
});
|
|
337
|
+
try {
|
|
338
|
+
await checkAssertion(page, assertion.kind, assertion.value, 5e3, assertion);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
throw new Error(`That is not true of the page right now, so it was not kept: ${error.message.split("\n")[0]}`);
|
|
341
|
+
}
|
|
342
|
+
const step = RecordedStepSchema.parse({
|
|
343
|
+
id: `c${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
344
|
+
type: "assert",
|
|
345
|
+
role: "",
|
|
346
|
+
name: "",
|
|
347
|
+
scope: "",
|
|
348
|
+
value: "",
|
|
349
|
+
secret: false,
|
|
350
|
+
legacySelector: "",
|
|
351
|
+
proposals: [assertion]
|
|
352
|
+
});
|
|
353
|
+
draft.steps.push(step);
|
|
354
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}.`;
|
|
355
|
+
}
|
|
356
|
+
/** Get a value from outside the page — an email code, a 2FA code, a text,
|
|
357
|
+
* an API reply — and keep the step that gets it. */
|
|
358
|
+
async useHelper(input) {
|
|
359
|
+
const { page, draft, run } = this.requireOpen();
|
|
360
|
+
const helper = this.catalog?.helpers.find((h) => h.id === input.helperId || h.name.toLowerCase() === input.helperId.toLowerCase());
|
|
361
|
+
if (!helper) throw new Error(`no helper "${input.helperId}" \u2014 project_catalog lists them; one can be made on the Helpers screen`);
|
|
362
|
+
const missing = helper.inputs.filter((name) => !(input.inputs ?? {})[name]);
|
|
363
|
+
if (missing.length) throw new Error(`${helper.name} needs ${missing.map((n) => `{{${n}}}`).join(", ")} in inputs`);
|
|
364
|
+
const step = RecordedStepSchema.parse({
|
|
365
|
+
id: `h${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
366
|
+
type: "useHelper",
|
|
367
|
+
role: "",
|
|
368
|
+
name: helper.name,
|
|
369
|
+
scope: "",
|
|
370
|
+
value: "",
|
|
371
|
+
secret: false,
|
|
372
|
+
legacySelector: "",
|
|
373
|
+
proposals: [],
|
|
374
|
+
helper: { helperId: helper.id, inputs: input.inputs ?? {}, saveAs: input.saveAs || (helper.kind === "mailbox" ? "code" : "value"), then: input.then ?? "remember" }
|
|
375
|
+
});
|
|
376
|
+
const resolvers = await this.prepare([step]);
|
|
377
|
+
let got = { value: "", detail: "" };
|
|
378
|
+
try {
|
|
379
|
+
this.page = await executeStep(page, step, {
|
|
380
|
+
pages: run.pages,
|
|
381
|
+
values: this.values,
|
|
382
|
+
baseUrl: this.baseUrl(),
|
|
383
|
+
...resolvers,
|
|
384
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
385
|
+
navigationTimeoutMs: 45e3,
|
|
386
|
+
onHelper: (answer) => {
|
|
387
|
+
got = answer;
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
} catch (error) {
|
|
391
|
+
throw new Error(`The helper did not get a value, and the step was not kept: ${error.message}`);
|
|
392
|
+
}
|
|
393
|
+
await this.settle();
|
|
394
|
+
draft.steps.push(step);
|
|
395
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}. It got "${got.value}" (${got.detail}). Use {{${step.helper.saveAs}}} as the value of a later step.` + (step.helper.then === "open" ? `
|
|
396
|
+
|
|
397
|
+
${await this.snapshot()}` : "");
|
|
398
|
+
}
|
|
399
|
+
list() {
|
|
400
|
+
const draft = this.draft;
|
|
401
|
+
if (!draft) return "No flow is being written.";
|
|
402
|
+
const head = `"${draft.name}"${draft.signIn ? ", signed in first" : ""}, starting at ${draft.startPath}`;
|
|
403
|
+
if (draft.steps.length === 0) return `${head}. No steps yet.`;
|
|
404
|
+
return `${head}:
|
|
405
|
+
${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
|
|
406
|
+
}
|
|
407
|
+
/** Drop a step from the draft. The browser is not wound back — replay to
|
|
408
|
+
* see the flow as it now stands. */
|
|
409
|
+
remove(number) {
|
|
410
|
+
const { draft } = this.requireOpen();
|
|
411
|
+
if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
|
|
412
|
+
const [gone] = draft.steps.splice(number - 1, 1);
|
|
413
|
+
return `Removed step ${number} (${describe(gone)}). The browser was not undone \u2014 replay_flow shows the flow as it now stands.
|
|
414
|
+
|
|
415
|
+
${this.list()}`;
|
|
416
|
+
}
|
|
417
|
+
/** Run the whole draft from the start, in a browser of its own, the way a
|
|
418
|
+
* run will — the proof the steps work together, not just one at a time. */
|
|
419
|
+
async replay() {
|
|
420
|
+
const { draft } = this.requireOpen();
|
|
421
|
+
if (draft.steps.length === 0) throw new Error("there are no steps to replay yet");
|
|
422
|
+
const resolvers = await this.prepare();
|
|
423
|
+
const steps = [
|
|
424
|
+
...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: { ...draft.signIn, from: "loginPage" }, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
|
|
425
|
+
...draft.steps
|
|
426
|
+
];
|
|
427
|
+
const outcome = await replayFlow({ steps, baseUrl: this.baseUrl(), startPath: draft.startPath, headless: true, ...resolvers });
|
|
428
|
+
const lines = outcome.steps.map((s) => `${s.status === "passed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.name || s.type}${s.status === "failed" && s.detail ? ` \u2014 ${s.detail}` : ""}`);
|
|
429
|
+
return `${outcome.status === "passed" ? "The whole flow passed from the start." : `The flow ${outcome.status}${outcome.error ? `: ${outcome.error}` : ""}.`}
|
|
430
|
+
${lines.join("\n")}`;
|
|
431
|
+
}
|
|
432
|
+
/** Save the draft as a flow on a new branch, for a person to review. */
|
|
433
|
+
async save(title) {
|
|
434
|
+
const { draft } = this.requireOpen();
|
|
435
|
+
if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
|
|
436
|
+
if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
|
|
437
|
+
throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
|
|
438
|
+
}
|
|
439
|
+
const saved = await this.service.saveFlow(draft.projectId, title || `Write "${draft.name}" with Claude`, {
|
|
440
|
+
name: draft.name,
|
|
441
|
+
...draft.featureId ? { featureId: draft.featureId } : { featureName: draft.featureName },
|
|
442
|
+
startPath: draft.startPath,
|
|
443
|
+
steps: draft.steps,
|
|
444
|
+
...draft.signIn ? { signIn: draft.signIn } : {}
|
|
445
|
+
});
|
|
446
|
+
const review = `${this.service.origin}/projects/${draft.projectId}/changes/${saved.changeId}`;
|
|
447
|
+
return `Saved "${draft.name}" on a new branch, ${saved.branch}. Nothing is published yet.
|
|
448
|
+
Review and publish it here: ${review}`;
|
|
449
|
+
}
|
|
450
|
+
async close() {
|
|
451
|
+
const run = this.run;
|
|
452
|
+
this.run = null;
|
|
453
|
+
this.page = null;
|
|
454
|
+
this.resolvers = null;
|
|
455
|
+
if (run) await closeRunSession(run, { passed: true }).catch(() => void 0);
|
|
456
|
+
}
|
|
457
|
+
/** Let a click's consequences land before the page is read again. */
|
|
458
|
+
async settle() {
|
|
459
|
+
const page = this.page;
|
|
460
|
+
if (!page) return;
|
|
461
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
462
|
+
await page.waitForTimeout(300);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// src/mcp/tools.ts
|
|
467
|
+
var INSTRUCTIONS = `Flowriki tools write UI tests by driving a real browser on this computer.
|
|
468
|
+
Every step you take is performed first and kept only if it worked, so the flow
|
|
469
|
+
you save already ran once. Saved flows go to a NEW branch that a person reviews
|
|
470
|
+
and publishes in the Flowriki dashboard \u2014 nothing you do is published.
|
|
471
|
+
|
|
472
|
+
How to write a good Flowriki test:
|
|
473
|
+
1. Call project_catalog first. Reuse an existing feature for the flow when one
|
|
474
|
+
fits; otherwise name a new one. Check the existing flows so you do not write
|
|
475
|
+
one that already exists.
|
|
476
|
+
2. One flow is one user journey with one purpose ("Sign up and verify email",
|
|
477
|
+
"Pay by card"), named the way a tester would say it.
|
|
478
|
+
3. If the journey needs somebody signed in, pass signIn with a profile and an
|
|
479
|
+
account from the catalog to start_flow. Never type real credentials.
|
|
480
|
+
4. Read the page with read_page and act using the role and name EXACTLY as the
|
|
481
|
+
page shows them (e.g. button "Continue", textbox "Email"). If a name appears
|
|
482
|
+
more than once, pass "within" with the name of the region, dialog or form
|
|
483
|
+
that holds the one you mean.
|
|
484
|
+
5. For things outside the page \u2014 an emailed code or link, an authenticator
|
|
485
|
+
code, a text message, an API call \u2014 use use_helper with a helper from the
|
|
486
|
+
catalog, then type {{its saveAs name}} into the field that needs it.
|
|
487
|
+
6. End with at least one check (add_check) that proves the journey worked:
|
|
488
|
+
a heading, a message, the URL. Saving is refused for a flow that checks nothing.
|
|
489
|
+
7. Before saving, call replay_flow to run the whole flow from the start. If a
|
|
490
|
+
step fails there, fix it (remove_step, act again) and replay again.
|
|
491
|
+
8. Save with save_flow and give the person the review link it returns.
|
|
492
|
+
Test data you invent (a new user's email or password for a sign-up) is fine to
|
|
493
|
+
type; prefer an address the project's mailbox helper can read, such as
|
|
494
|
+
qa+something@ the mailbox's own domain.`;
|
|
495
|
+
var text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...isError ? { isError } : {} });
|
|
496
|
+
var str = (value) => typeof value === "string" ? value : value === void 0 || value === null ? void 0 : String(value);
|
|
497
|
+
function authoringTools(service, session) {
|
|
498
|
+
let defaultProject = null;
|
|
499
|
+
const catalogs = /* @__PURE__ */ new Map();
|
|
500
|
+
const projectFor = async (asked) => {
|
|
501
|
+
const named = str(asked);
|
|
502
|
+
if (named) return defaultProject = named;
|
|
503
|
+
if (session.draft) return session.draft.projectId;
|
|
504
|
+
if (defaultProject) return defaultProject;
|
|
505
|
+
const { projects } = await service.projects();
|
|
506
|
+
if (projects.length === 1) return defaultProject = projects[0].id;
|
|
507
|
+
if (projects.length === 0) throw new Error("this account is not on any Flowriki project yet \u2014 create one in the dashboard");
|
|
508
|
+
throw new Error(`which project? Pass projectId \u2014 one of: ${projects.map((p) => `${p.name} (${p.id})`).join(", ")}`);
|
|
509
|
+
};
|
|
510
|
+
const catalogOf = async (projectId, fresh = false) => {
|
|
511
|
+
if (!fresh && catalogs.has(projectId)) return catalogs.get(projectId);
|
|
512
|
+
const catalog = await service.catalog(projectId);
|
|
513
|
+
catalogs.set(projectId, catalog);
|
|
514
|
+
return catalog;
|
|
515
|
+
};
|
|
516
|
+
return [
|
|
517
|
+
{
|
|
518
|
+
name: "list_projects",
|
|
519
|
+
title: "List Flowriki projects",
|
|
520
|
+
description: "The Flowriki projects you can write tests for, with your role on each. Only needed when you are on more than one.",
|
|
521
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
522
|
+
annotations: { readOnlyHint: true },
|
|
523
|
+
run: async () => {
|
|
524
|
+
const { projects } = await service.projects();
|
|
525
|
+
return text(projects.length ? projects.map((p) => `${p.name} \u2014 id ${p.id}, you are ${p.role}`).join("\n") : "You are not on any project yet.");
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
name: "project_catalog",
|
|
530
|
+
title: "What a test can be built from",
|
|
531
|
+
description: "The project's base URL, features (folders for flows), existing flows, sign-in profiles with their accounts, and helpers (email codes, 2FA, SMS, API calls). Read this before starting a flow.",
|
|
532
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Optional when you are on one project." } }, additionalProperties: false },
|
|
533
|
+
annotations: { readOnlyHint: true },
|
|
534
|
+
run: async (args) => {
|
|
535
|
+
const projectId = await projectFor(args["projectId"]);
|
|
536
|
+
const c = await catalogOf(projectId, true);
|
|
537
|
+
const flowsBy = (featureId) => c.flows.filter((f) => f.featureId === featureId).map((f) => ` - ${f.name} (${f.steps} steps)`).join("\n");
|
|
538
|
+
return text([
|
|
539
|
+
`Project ${projectId}. Base URL: ${c.baseUrl || "(not set)"}`,
|
|
540
|
+
c.environments.length ? `Environments: ${c.environments.map((e) => `${e.name} ${e.baseUrl}`).join(", ")}` : "",
|
|
541
|
+
`
|
|
542
|
+
Features:${c.features.length ? "" : " none yet"}`,
|
|
543
|
+
...c.features.map((f) => ` - ${f.name} (id ${f.id})${f.description ? ` \u2014 ${f.description}` : ""}
|
|
544
|
+
${flowsBy(f.id)}`),
|
|
545
|
+
`
|
|
546
|
+
Sign-in profiles:${c.signIns.length ? "" : " none"}`,
|
|
547
|
+
...c.signIns.map((s) => ` - ${s.name} (profileId ${s.profileId}): ${s.accounts.map((a) => `${a.name} (accountId ${a.accountId})`).join(", ") || "no accounts yet"}`),
|
|
548
|
+
`
|
|
549
|
+
Helpers:${c.helpers.length ? "" : " none \u2014 they are made on the Helpers screen"}`,
|
|
550
|
+
...c.helpers.map((h) => ` - ${h.name} (id ${h.id}) \u2014 ${h.what}${h.inputs.length ? `; needs ${h.inputs.map((n) => `{{${n}}}`).join(", ")}` : ""}${h.description ? ` \u2014 ${h.description}` : ""}`)
|
|
551
|
+
].filter(Boolean).join("\n"));
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
name: "start_flow",
|
|
556
|
+
title: "Start writing a flow",
|
|
557
|
+
description: "Open a browser on this computer at the project's app and start a new flow. Returns the page. Closes any flow that was being written.",
|
|
558
|
+
inputSchema: {
|
|
559
|
+
type: "object",
|
|
560
|
+
properties: {
|
|
561
|
+
name: { type: "string", description: `The flow's name: the user journey, e.g. "Sign up and verify email".` },
|
|
562
|
+
feature: { type: "string", description: "The feature it belongs to: an existing feature's id or name, or a new name." },
|
|
563
|
+
startPath: { type: "string", description: 'Where it starts, relative to the base URL. Default "/".' },
|
|
564
|
+
signIn: {
|
|
565
|
+
type: "object",
|
|
566
|
+
description: "Start signed in, with a profile (and optionally an account) from project_catalog.",
|
|
567
|
+
properties: { profileId: { type: "string" }, accountId: { type: "string" } },
|
|
568
|
+
required: ["profileId"],
|
|
569
|
+
additionalProperties: false
|
|
570
|
+
},
|
|
571
|
+
projectId: { type: "string" }
|
|
572
|
+
},
|
|
573
|
+
required: ["name", "feature"],
|
|
574
|
+
additionalProperties: false
|
|
575
|
+
},
|
|
576
|
+
annotations: { openWorldHint: true },
|
|
577
|
+
run: async (args) => {
|
|
578
|
+
const projectId = await projectFor(args["projectId"]);
|
|
579
|
+
const catalog = await catalogOf(projectId, true);
|
|
580
|
+
const featureAsked = str(args["feature"]) ?? "";
|
|
581
|
+
const feature = catalog.features.find((f) => f.id === featureAsked || f.name.toLowerCase() === featureAsked.toLowerCase());
|
|
582
|
+
const signIn = args["signIn"];
|
|
583
|
+
if (signIn && !catalog.signIns.some((s) => s.profileId === signIn.profileId)) {
|
|
584
|
+
throw new Error(`no sign-in profile ${signIn.profileId} \u2014 project_catalog lists them`);
|
|
585
|
+
}
|
|
586
|
+
const page = await session.start({
|
|
587
|
+
projectId,
|
|
588
|
+
name: str(args["name"]) ?? "Untitled flow",
|
|
589
|
+
...feature ? { featureId: feature.id } : { featureName: featureAsked },
|
|
590
|
+
startPath: str(args["startPath"]) || "/",
|
|
591
|
+
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
|
|
592
|
+
}, catalog);
|
|
593
|
+
return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}. The browser is open.
|
|
594
|
+
|
|
595
|
+
${page}`);
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
name: "read_page",
|
|
600
|
+
title: "Read the page",
|
|
601
|
+
description: "The current page as its accessibility tree: each element's role and name, which is exactly what act and add_check refer to.",
|
|
602
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
603
|
+
annotations: { readOnlyHint: true },
|
|
604
|
+
run: async () => text(await session.snapshot())
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
name: "act",
|
|
608
|
+
title: "Do one thing on the page",
|
|
609
|
+
description: `Perform one step and keep it in the flow if it worked. Returns the page afterwards. click/dblclick/rightclick/hover: role + name. fill: role + name + value (may use {{values}} from helpers). select: role + name + the option's label as value. check: role + name, value "true" or "false". press: a key such as "Enter" as value, optionally role + name of where. navigate: a path such as "/settings" or a full URL as value. back/forward: nothing.`,
|
|
610
|
+
inputSchema: {
|
|
611
|
+
type: "object",
|
|
612
|
+
properties: {
|
|
613
|
+
action: { type: "string", enum: ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"] },
|
|
614
|
+
role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
|
|
615
|
+
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
616
|
+
within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
|
|
617
|
+
value: { type: "string" }
|
|
618
|
+
},
|
|
619
|
+
required: ["action"],
|
|
620
|
+
additionalProperties: false
|
|
621
|
+
},
|
|
622
|
+
run: async (args) => {
|
|
623
|
+
if (!isAction(args["action"])) throw new Error("action must be one of click, dblclick, rightclick, hover, fill, select, check, press, navigate, back, forward");
|
|
624
|
+
return text(await session.act({
|
|
625
|
+
action: args["action"],
|
|
626
|
+
...str(args["role"]) ? { role: str(args["role"]) } : {},
|
|
627
|
+
...str(args["name"]) !== void 0 ? { name: str(args["name"]) } : {},
|
|
628
|
+
...str(args["within"]) ? { within: str(args["within"]) } : {},
|
|
629
|
+
...str(args["value"]) !== void 0 ? { value: str(args["value"]) } : {}
|
|
630
|
+
}));
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
name: "add_check",
|
|
635
|
+
title: "Add a check",
|
|
636
|
+
description: "Assert something about the page as it is now, and keep it in the flow if it holds. Page checks need only kind + value: heading, url (a path), title, message. Element checks also need role + name: text, value, visible, hidden, checked, count. op: equals (default), contains or matches.",
|
|
637
|
+
inputSchema: {
|
|
638
|
+
type: "object",
|
|
639
|
+
properties: {
|
|
640
|
+
kind: { type: "string", enum: ["heading", "url", "title", "message", "screen", "text", "value", "visible", "hidden", "checked", "enabled", "count"] },
|
|
641
|
+
value: { type: "string", description: `What it should be: the heading's words, the path, "true" for a state, a number for count.` },
|
|
642
|
+
op: { type: "string", enum: ["equals", "contains", "matches"] },
|
|
643
|
+
role: { type: "string" },
|
|
644
|
+
name: { type: "string" },
|
|
645
|
+
within: { type: "string" }
|
|
646
|
+
},
|
|
647
|
+
required: ["kind", "value"],
|
|
648
|
+
additionalProperties: false
|
|
649
|
+
},
|
|
650
|
+
run: async (args) => text(await session.check({
|
|
651
|
+
kind: str(args["kind"]),
|
|
652
|
+
value: str(args["value"]) ?? "",
|
|
653
|
+
...str(args["op"]) ? { op: str(args["op"]) } : {},
|
|
654
|
+
...str(args["role"]) ? { role: str(args["role"]), name: str(args["name"]) ?? "" } : {},
|
|
655
|
+
...str(args["within"]) ? { within: str(args["within"]) } : {}
|
|
656
|
+
}))
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
name: "use_helper",
|
|
660
|
+
title: "Use a helper",
|
|
661
|
+
description: `Get a value from outside the page with one of the project's helpers \u2014 the code or link from a verification email, an authenticator code, an SMS code, a field of an API reply \u2014 and keep the step. Later steps use it as {{saveAs}}. then: "open" goes to the value as a link (for verification links).`,
|
|
662
|
+
inputSchema: {
|
|
663
|
+
type: "object",
|
|
664
|
+
properties: {
|
|
665
|
+
helperId: { type: "string", description: "The helper's id or name from project_catalog." },
|
|
666
|
+
inputs: { type: "object", additionalProperties: { type: "string" }, description: `Values for the helper's {{names}}, e.g. {"email": "qa+signup1@example.com"}.` },
|
|
667
|
+
saveAs: { type: "string", description: "What to call the value, e.g. code. Default: code for a mailbox, value otherwise." },
|
|
668
|
+
then: { type: "string", enum: ["remember", "open"] }
|
|
669
|
+
},
|
|
670
|
+
required: ["helperId"],
|
|
671
|
+
additionalProperties: false
|
|
672
|
+
},
|
|
673
|
+
run: async (args) => text(await session.useHelper({
|
|
674
|
+
helperId: str(args["helperId"]),
|
|
675
|
+
...args["inputs"] && typeof args["inputs"] === "object" ? { inputs: Object.fromEntries(Object.entries(args["inputs"]).map(([k, v]) => [k, String(v)])) } : {},
|
|
676
|
+
...str(args["saveAs"]) ? { saveAs: str(args["saveAs"]) } : {},
|
|
677
|
+
...args["then"] === "open" ? { then: "open" } : {}
|
|
678
|
+
}))
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
name: "list_steps",
|
|
682
|
+
title: "List the flow's steps",
|
|
683
|
+
description: "The flow being written, step by step.",
|
|
684
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
685
|
+
annotations: { readOnlyHint: true },
|
|
686
|
+
run: async () => text(session.list())
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
name: "remove_step",
|
|
690
|
+
title: "Remove a step",
|
|
691
|
+
description: "Remove one step by its number in list_steps. The browser is not wound back; use replay_flow to see the flow as it now stands.",
|
|
692
|
+
inputSchema: { type: "object", properties: { number: { type: "integer", minimum: 1 } }, required: ["number"], additionalProperties: false },
|
|
693
|
+
annotations: { destructiveHint: true },
|
|
694
|
+
run: async (args) => text(session.remove(Number(args["number"])))
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
name: "replay_flow",
|
|
698
|
+
title: "Replay the flow from the start",
|
|
699
|
+
description: "Run the whole flow from the beginning in a fresh browser, exactly as a test run will, and report each step. Do this before saving.",
|
|
700
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
701
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
702
|
+
run: async () => text(await session.replay())
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
name: "save_flow",
|
|
706
|
+
title: "Save the flow to a branch",
|
|
707
|
+
description: "Save the flow to a new branch in the project's repository, for a person to review and publish. Returns the review link. Refused if the flow checks nothing.",
|
|
708
|
+
inputSchema: { type: "object", properties: { title: { type: "string", description: "The branch's title \u2014 what this change adds." } }, additionalProperties: false },
|
|
709
|
+
run: async (args) => text(await session.save(str(args["title"])))
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
name: "close_browser",
|
|
713
|
+
title: "Close the browser",
|
|
714
|
+
description: "Close the browser and drop the flow being written, unsaved.",
|
|
715
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
716
|
+
annotations: { destructiveHint: true },
|
|
717
|
+
run: async () => {
|
|
718
|
+
await session.close();
|
|
719
|
+
session.draft = null;
|
|
720
|
+
return text("Closed.");
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
];
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/mcp/index.ts
|
|
727
|
+
async function runMcp(opts) {
|
|
728
|
+
let service;
|
|
729
|
+
try {
|
|
730
|
+
service = resolveService(opts.service);
|
|
731
|
+
} catch (error) {
|
|
732
|
+
process.stderr.write(`${error.message}
|
|
733
|
+
`);
|
|
734
|
+
return 2;
|
|
735
|
+
}
|
|
736
|
+
const connection = connectionFor(service);
|
|
737
|
+
const session = connection ? new AuthoringSession(new ServiceClient(connection), opts.headless ? { headless: true } : {}) : null;
|
|
738
|
+
let tools = connection && session ? authoringTools(new ServiceClient(connection), session) : authoringTools(null, null);
|
|
739
|
+
if (!connection) {
|
|
740
|
+
const why = `This computer is not connected to Flowriki at ${service}. Run \`flowriki-agent connect\` in a terminal, approve it in your browser, then restart Claude.`;
|
|
741
|
+
tools = tools.map((tool) => ({ ...tool, run: async () => ({ content: [{ type: "text", text: why }], isError: true }) }));
|
|
742
|
+
}
|
|
743
|
+
const closing = async () => {
|
|
744
|
+
await session?.close();
|
|
745
|
+
process.exit(0);
|
|
746
|
+
};
|
|
747
|
+
process.on("SIGINT", () => void closing());
|
|
748
|
+
process.on("SIGTERM", () => void closing());
|
|
749
|
+
await serve(tools, { name: "flowriki", version: opts.version, instructions: INSTRUCTIONS });
|
|
750
|
+
await session?.close();
|
|
751
|
+
return 0;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/cli.ts
|
|
15
755
|
var paint = (code) => (s) => `\x1B[${code}m${s}\x1B[0m`;
|
|
16
756
|
var bold = paint("1");
|
|
17
757
|
var dim = paint("2");
|
|
@@ -30,6 +770,9 @@ ${bold("USAGE")}
|
|
|
30
770
|
flowriki-agent doctor diagnose setup problems
|
|
31
771
|
flowriki-agent prepare download the browser recording needs
|
|
32
772
|
flowriki-agent disconnect forget this computer's credential
|
|
773
|
+
flowriki-agent mcp Flowriki as tools for Claude \u2014 started by
|
|
774
|
+
Claude Code or Claude Desktop, not by you:
|
|
775
|
+
claude mcp add flowriki -- flowriki-agent mcp
|
|
33
776
|
|
|
34
777
|
${bold("OPTIONS")}
|
|
35
778
|
--project <dir> a checkout to serve; repeat for several.
|
|
@@ -81,6 +824,10 @@ Run \`flowriki-agent --help\`.`);
|
|
|
81
824
|
const service = values.service ? String(values.service) : void 0;
|
|
82
825
|
const projects = (values.project ?? []).map(String);
|
|
83
826
|
switch (command) {
|
|
827
|
+
// FIRST, and before anything writes to stdout: in this mode stdout is the
|
|
828
|
+
// protocol, and one stray line of output is a message Claude rejects.
|
|
829
|
+
case "mcp":
|
|
830
|
+
return runMcp({ ...service ? { service } : {}, version: AGENT_VERSION });
|
|
84
831
|
case "connect": {
|
|
85
832
|
const result = await connect({
|
|
86
833
|
open: !values["no-open"],
|
package/dist/engine.js
CHANGED
package/package.json
CHANGED