@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 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.28";
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-PETFNOJA.js";
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
@@ -9,7 +9,7 @@ import {
9
9
  resolveCheckouts,
10
10
  start,
11
11
  status
12
- } from "./chunk-PETFNOJA.js";
12
+ } from "./chunk-3SU3RBVY.js";
13
13
  export {
14
14
  AGENT_VERSION,
15
15
  browsersReady,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowriki/agent",
3
- "version": "0.1.0-beta.28",
3
+ "version": "0.1.0-beta.29",
4
4
  "description": "Run Flowriki UI tests on your own computer. Connects this machine to your Flowriki account.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",