@flowriki/agent 0.1.0-beta.3 → 0.1.0-beta.31

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