@flowriki/agent 0.1.0-beta.33 → 0.1.0-beta.36

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.
@@ -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.33";
9
+ var AGENT_VERSION = "0.1.0-beta.36";
10
10
  async function prepareBrowsers() {
11
11
  if (browsersReady()) {
12
12
  return { ok: true, already: true, message: "chromium is already installed" };
@@ -11278,6 +11278,7 @@ export {
11278
11278
  closeRunSession,
11279
11279
  replayFlow,
11280
11280
  executeStep,
11281
+ replayCase,
11281
11282
  fieldNames,
11282
11283
  helperRunner,
11283
11284
  resolveCheckouts,
package/dist/cli.js CHANGED
@@ -15,13 +15,14 @@ import {
15
15
  mirrorCheckTargets,
16
16
  openRunSession,
17
17
  prepareBrowsers,
18
+ replayCase,
18
19
  replayFlow,
19
20
  resolveCheckouts,
20
21
  resolveService,
21
22
  runtimeValue,
22
23
  start,
23
24
  status
24
- } from "./chunk-3UUCI66O.js";
25
+ } from "./chunk-N3P4O3OH.js";
25
26
 
26
27
  // src/cli.ts
27
28
  import { parseArgs } from "util";
@@ -39,7 +40,7 @@ async function handle(message, tools, info) {
39
40
  const version = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
40
41
  return reply({
41
42
  protocolVersion: version,
42
- capabilities: { tools: { listChanged: false } },
43
+ capabilities: { tools: { listChanged: true } },
43
44
  serverInfo: { name: info.name, version: info.version },
44
45
  instructions: info.instructions
45
46
  });
@@ -95,6 +96,10 @@ function serve(tools, info, io = {}) {
95
96
  const task = handle(message, tools, info).then((answer) => {
96
97
  if (answer) output.write(`${JSON.stringify(answer)}
97
98
  `);
99
+ if (message.method === "notifications/initialized") {
100
+ output.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}
101
+ `);
102
+ }
98
103
  });
99
104
  pending.add(task);
100
105
  void task.finally(() => pending.delete(task));
@@ -168,17 +173,73 @@ var ServiceClient = class {
168
173
  { method: "DELETE" }
169
174
  );
170
175
  }
171
- saveFlow(projectId, title, flow) {
176
+ saveFlow(projectId, title, flow, where = {}, secretValues = {}) {
172
177
  return this.call(
173
178
  `/api/agent/flows?projectId=${encodeURIComponent(projectId)}`,
174
- { method: "POST", body: JSON.stringify({ title, flow }) }
179
+ { method: "POST", body: JSON.stringify({ title, flow, ...where, secretValues }) }
180
+ );
181
+ }
182
+ /** A sign-in made from a recorded login flow. */
183
+ createSignIn(projectId, input) {
184
+ return this.call(
185
+ `/api/agent/sign-ins?projectId=${encodeURIComponent(projectId)}`,
186
+ { method: "POST", body: JSON.stringify(input) }
187
+ );
188
+ }
189
+ /** An account for a sign-in; secret values are sealed by the service and
190
+ * never come back. */
191
+ createAccount(projectId, input) {
192
+ return this.call(
193
+ `/api/agent/accounts?projectId=${encodeURIComponent(projectId)}`,
194
+ { method: "POST", body: JSON.stringify(input) }
195
+ );
196
+ }
197
+ /** A helper, its secrets sealed by the service. */
198
+ createHelper(projectId, input) {
199
+ return this.call(
200
+ `/api/agent/helpers?projectId=${encodeURIComponent(projectId)}`,
201
+ { method: "POST", body: JSON.stringify(input) }
202
+ );
203
+ }
204
+ /** What a new flow starts after: the flows, in run order, and their steps. */
205
+ flowsAfter(projectId, after) {
206
+ return this.call(
207
+ `/api/agent/flows/after?projectId=${encodeURIComponent(projectId)}`,
208
+ { method: "POST", body: JSON.stringify({ after }) }
209
+ );
210
+ }
211
+ /** Build a test case from flows — as a dry run, or saved. */
212
+ buildCase(projectId, spec, dryRun) {
213
+ return this.call(`/api/agent/cases?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify({ ...spec, dryRun }) });
214
+ }
215
+ /** Gather Claude's open branches here into one. */
216
+ gatherChanges(projectId, opts = {}) {
217
+ return this.call(
218
+ `/api/agent/changes/gather?projectId=${encodeURIComponent(projectId)}`,
219
+ { method: "POST", body: JSON.stringify(opts) }
175
220
  );
176
221
  }
177
222
  };
178
223
 
179
224
  // src/mcp/session.ts
180
225
  var MAX_TREE = 14e3;
226
+ var MAX_STEP_VIEW = 6e3;
227
+ function topLevelBlocks(tree) {
228
+ const blocks = [];
229
+ for (const line of tree.split("\n")) {
230
+ if (line.startsWith("- ") || blocks.length === 0) blocks.push([line]);
231
+ else blocks[blocks.length - 1].push(line);
232
+ }
233
+ const named = (b, kinds) => kinds.some((k) => b[0].startsWith(`- ${k}`));
234
+ return {
235
+ dialogs: blocks.filter((b) => named(b, ["dialog", "alertdialog"])).map((b) => b.join("\n")),
236
+ navigation: blocks.find((b) => named(b, ["navigation", "banner"]))?.join("\n") ?? null
237
+ };
238
+ }
181
239
  var STEP_TIMEOUT_MS = 1e4;
240
+ var SECRET_WORDS = /password|passwd|pass[-_\s]?code|pwd|secret|otp|one[-_\s]?time[-_\s]?(code|pass)|security[-_\s]?(code|answer)|cvv|cvc|\bpin\b/i;
241
+ var TYPED = "typed_";
242
+ var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
182
243
  var ACTIONS = ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"];
183
244
  var isAction = (value) => ACTIONS.includes(String(value));
184
245
  var LANDMARKS = /* @__PURE__ */ new Set(["main", "navigation", "banner", "contentinfo", "complementary", "search", "form", "dialog", "alertdialog", "region", "group", "list", "table", "menu", "toolbar", "tablist"]);
@@ -202,12 +263,18 @@ function labelTree(tree, fields) {
202
263
  for (const f of fields) out = out.split(`- ${f.role} ${JSON.stringify(f.hint)}`).join(`- ${f.role} ${JSON.stringify(f.label)}`);
203
264
  return out;
204
265
  }
266
+ function describeRows(rows) {
267
+ return rows.map((r) => [
268
+ `Row ${r.row}: "${r.flow}" (${r.browser})`,
269
+ ...r.checks.length ? r.checks.map((c) => ` ${c.on ? "[on] " : "[off]"} ${c.n}. ${c.check}`) : [" (no checks in this flow)"]
270
+ ].join("\n")).join("\n");
271
+ }
205
272
  function describe(step) {
206
273
  const target = step.name ? `${step.role || "element"} "${step.name}"` : step.role || "";
207
274
  const where = step.scope ? ` in "${step.scope}"` : step.scopeRole ? ` in the ${step.scopeRole}` : "";
208
275
  switch (step.type) {
209
276
  case "fill":
210
- return `type "${step.value}" into ${target}${where}`;
277
+ return step.secret ? `type a secret (\u2022\u2022\u2022\u2022\u2022\u2022) into ${target}${where}` : `type "${step.value}" into ${target}${where}`;
211
278
  case "select":
212
279
  return `choose "${step.value}" in ${target}${where}`;
213
280
  case "check":
@@ -238,8 +305,24 @@ var AuthoringSession = class {
238
305
  page = null;
239
306
  values = /* @__PURE__ */ new Map();
240
307
  resolvers = null;
308
+ lastTree = null;
309
+ /** Values typed into secret fields, by their `typed:` reference. */
310
+ typed = /* @__PURE__ */ new Map();
241
311
  draft = null;
242
312
  catalog = null;
313
+ /** Text going back to Claude, with every secret typed here masked — a
314
+ * password box's value is in the page's tree, and a careless form puts it
315
+ * in the address. */
316
+ redact(text2) {
317
+ let out = text2;
318
+ for (const value of this.typed.values()) {
319
+ if (value.length < 3) continue;
320
+ out = out.split(value).join("\u2022\u2022\u2022\u2022\u2022\u2022");
321
+ const encoded = encodeURIComponent(value);
322
+ if (encoded !== value) out = out.split(encoded).join("\u2022\u2022\u2022\u2022\u2022\u2022");
323
+ }
324
+ return out;
325
+ }
243
326
  get open() {
244
327
  return this.run !== null;
245
328
  }
@@ -252,11 +335,11 @@ var AuthoringSession = class {
252
335
  * helper or sign-in appears, never kept beyond this process. */
253
336
  async prepare(extra = []) {
254
337
  const draft = this.draft;
255
- const prepared = await this.service.prepare(draft.projectId, [...draft.steps, ...extra], draft.signIn ?? null);
338
+ const prepared = await this.service.prepare(draft.projectId, withoutTyped([...draft.preparation ?? [], ...draft.steps, ...extra]), draft.signIn ?? null);
256
339
  const runHelper = helperRunner(prepared.helpers, prepared.secrets);
257
340
  this.resolvers = {
258
341
  resolveAuth: authenticationResolver(prepared.authentications),
259
- resolveSecret: (ref) => prepared.secrets[ref],
342
+ resolveSecret: (ref) => this.typed.get(ref) ?? prepared.secrets[ref],
260
343
  ...runHelper ? { runHelper } : {}
261
344
  };
262
345
  return this.resolvers;
@@ -272,6 +355,8 @@ var AuthoringSession = class {
272
355
  this.catalog = catalog;
273
356
  this.draft = { ...input, steps: [] };
274
357
  this.values = /* @__PURE__ */ new Map();
358
+ this.typed = /* @__PURE__ */ new Map();
359
+ this.lastTree = null;
275
360
  const base = this.baseUrl();
276
361
  this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
277
362
  this.page = this.run.page;
@@ -300,23 +385,74 @@ var AuthoringSession = class {
300
385
  } else {
301
386
  await this.page.goto(new URL(input.startPath || "/", base).href, { waitUntil: "domcontentloaded", timeout: 45e3 });
302
387
  }
388
+ if (input.preparation?.length) {
389
+ const resolvers = await this.prepare(input.preparation);
390
+ for (const step of input.preparation) {
391
+ try {
392
+ this.page = await executeStep(this.page, step, {
393
+ pages: this.run.pages,
394
+ ...resolvers,
395
+ values: this.values,
396
+ baseUrl: base,
397
+ timeoutMs: 2e4,
398
+ navigationTimeoutMs: 45e3
399
+ });
400
+ } catch (error) {
401
+ throw new Error(`The flows this one starts after did not finish, at "${step.name || step.type}": ${error.message.split("\n")[0]}`);
402
+ }
403
+ }
404
+ await this.settle();
405
+ }
303
406
  return this.snapshot();
304
407
  }
305
408
  /** The page as Claude reads it: where it is, and what is on it by role and name. */
306
409
  async snapshot() {
410
+ return this.view(true);
411
+ }
412
+ /** WHAT CHANGED, after a step — not the whole page again.
413
+ *
414
+ * Every step used to answer with the full tree, which on a long storefront
415
+ * is twenty thousand characters repeated forty times to say "the box now
416
+ * has text in it". So a step answers with what it is about: an open dialog
417
+ * (with the navigation, which says who is signed in), or the lines that
418
+ * appeared and disappeared — and only after a real navigation, when most of
419
+ * the page is new, the page itself, capped. read_page is always the whole
420
+ * thing. */
421
+ async view(full) {
307
422
  const { page, draft } = this.requireOpen();
308
423
  await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
309
- let tree = labelTree(
424
+ const tree = labelTree(
310
425
  await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
311
426
  await fieldNames(page)
312
427
  );
313
- if (tree.length > MAX_TREE) tree = `${tree.slice(0, MAX_TREE)}
314
- \u2026 (cut \u2014 the page is long; act on what is above, or scroll with press "PageDown")`;
315
428
  const title = await page.title().catch(() => "");
316
- return `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
429
+ const head = `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
317
430
  Steps so far: ${draft.steps.length}
318
431
 
319
- ${tree}`;
432
+ `;
433
+ const before = this.lastTree;
434
+ this.lastTree = tree;
435
+ const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
436
+ \u2026 (cut \u2014 read_page shows the whole page)` : t;
437
+ if (full || before === null) return head + cap(tree, MAX_TREE);
438
+ const { dialogs, navigation } = topLevelBlocks(tree);
439
+ if (dialogs.length) {
440
+ return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog and the navigation \u2014 read_page shows the whole page)";
441
+ }
442
+ const was = new Set(before.split("\n"));
443
+ const now = new Set(tree.split("\n"));
444
+ const added = tree.split("\n").filter((l) => !was.has(l));
445
+ const removed = before.split("\n").filter((l) => !now.has(l));
446
+ if (added.length === 0 && removed.length === 0) return head + "(the page did not change \u2014 read_page shows it)";
447
+ if (added.length + removed.length > 120) return head + cap(tree, MAX_STEP_VIEW);
448
+ return head + [
449
+ ...navigation ? [navigation, ""] : [],
450
+ added.length ? `Appeared:
451
+ ${added.join("\n")}` : "",
452
+ removed.length ? `Gone:
453
+ ${removed.join("\n")}` : "",
454
+ "(changes since the last step \u2014 read_page shows the whole page)"
455
+ ].filter(Boolean).join("\n");
320
456
  }
321
457
  /** Do one thing on the page, and keep it as a step if it worked. */
322
458
  async act(input) {
@@ -329,13 +465,17 @@ ${tree}`;
329
465
  throw new Error(`${input.action} needs a value`);
330
466
  }
331
467
  const target = navigating ? { role: "", name: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
468
+ const secret = input.action === "fill" && (input.secret ?? (SECRET_WORDS.test(input.name ?? "") || SECRET_WORDS.test(target.name)));
469
+ const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
470
+ if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
332
471
  const step = RecordedStepSchema.parse({
333
- id: `s${draft.steps.length + 1}_${Date.now().toString(36)}`,
472
+ id,
334
473
  type: input.action,
335
474
  ...target,
336
475
  ...scopeOf(input.within),
337
- value: input.action === "check" ? input.value === "false" ? "false" : "true" : input.value ?? "",
338
- secret: false,
476
+ value: secret ? "" : input.action === "check" ? input.value === "false" ? "false" : "true" : input.value ?? "",
477
+ secret,
478
+ ...secret ? { secretRef: `${TYPED}${id}` } : {},
339
479
  legacySelector: "",
340
480
  proposals: [],
341
481
  // A path is the application's own, so the step replays on whichever
@@ -348,19 +488,21 @@ ${tree}`;
348
488
  values: this.values,
349
489
  baseUrl: this.baseUrl(),
350
490
  ...this.resolvers ?? {},
491
+ resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref),
351
492
  timeoutMs: STEP_TIMEOUT_MS,
352
493
  navigationTimeoutMs: 45e3
353
494
  });
354
495
  } catch (error) {
496
+ if (secret) this.typed.delete(`${TYPED}${id}`);
355
497
  throw new Error(`That did not work, and was not kept: ${error.message.split("\n")[0]}
356
498
 
357
- ${await this.snapshot()}`);
499
+ ${await this.view(false)}`);
358
500
  }
359
501
  await this.settle();
360
502
  draft.steps.push(step);
361
503
  return `Step ${draft.steps.length} kept: ${describe(step)}.
362
504
 
363
- ${await this.snapshot()}`;
505
+ ${await this.view(false)}`;
364
506
  }
365
507
  /** Check something about the page now, and keep the check if it holds. */
366
508
  async check(input) {
@@ -437,7 +579,7 @@ ${await this.snapshot()}`;
437
579
  draft.steps.push(step);
438
580
  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" ? `
439
581
 
440
- ${await this.snapshot()}` : "");
582
+ ${await this.view(false)}` : "");
441
583
  }
442
584
  list() {
443
585
  const draft = this.draft;
@@ -465,6 +607,7 @@ ${this.list()}`;
465
607
  const resolvers = await this.prepare();
466
608
  const steps = [
467
609
  ...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: { ...draft.signIn, from: "loginPage" }, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
610
+ ...draft.preparation ?? [],
468
611
  ...draft.steps
469
612
  ];
470
613
  const outcome = await replayFlow({ steps, baseUrl: this.baseUrl(), startPath: draft.startPath, headless: true, ...resolvers });
@@ -473,25 +616,83 @@ ${this.list()}`;
473
616
  ${lines.join("\n")}`;
474
617
  }
475
618
  /** Save the draft as a flow on a new branch, for a person to review. */
476
- async save(title) {
619
+ async save(title, where = {}) {
477
620
  const { draft } = this.requireOpen();
478
621
  if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
479
622
  if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
480
623
  throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
481
624
  }
482
- const saved = await this.service.saveFlow(draft.projectId, title || `Write "${draft.name}" with Claude`, {
625
+ const checksOff = draft.steps.map((step) => step.type === "assert" ? { ...step, proposals: step.proposals.map((p) => ({ ...p, enabled: false })) } : step);
626
+ const checkCount = checksOff.filter((step) => step.type === "assert").length;
627
+ const secretValues = {};
628
+ for (const step of checksOff) {
629
+ const value = step.secretRef ? this.typed.get(step.secretRef) : void 0;
630
+ if (value !== void 0) secretValues[step.id] = value;
631
+ }
632
+ const saved = await this.service.saveFlow(draft.projectId, title || "Tests written with Claude", {
483
633
  name: draft.name,
484
634
  ...draft.featureId ? { featureId: draft.featureId } : { featureName: draft.featureName },
485
635
  startPath: draft.startPath,
486
- steps: draft.steps,
487
- ...draft.signIn ? { signIn: draft.signIn } : {}
488
- });
636
+ steps: withoutTyped(checksOff),
637
+ ...draft.signIn ? { signIn: draft.signIn } : {},
638
+ ...draft.after?.length ? { prerequisites: draft.after.map((a) => ({ flowId: a.flowId })) } : {}
639
+ }, where, secretValues);
489
640
  const review = `${this.service.origin}/projects/${draft.projectId}/changes/${saved.changeId}`;
641
+ const what = saved.replaced ? `Replaced the earlier "${draft.name}"` : `Saved "${draft.name}"`;
642
+ const sealed = Object.keys(secretValues).length;
643
+ const vault = sealed ? ` ${sealed} secret value${sealed === 1 ? " was" : "s were"} put in the project's vault, not the flow.` : "";
644
+ const where_ = saved.newBranch ? `on a new branch, ${saved.branch}` : `on Claude's working branch, ${saved.branch}`;
490
645
  const others = await this.service.agentChanges(draft.projectId).then((r) => r.changes.filter((c) => c.id !== saved.changeId)).catch(() => []);
491
646
  const note = others.length ? `
492
- Claude also has ${others.length} other open branch${others.length === 1 ? "" : "es"} on this project \u2014 list_my_branches shows them, if one is now superseded.` : "";
493
- return `Saved "${draft.name}" on a new branch, ${saved.branch}. Nothing is published yet.
647
+ Claude has ${others.length} other open branch${others.length === 1 ? "" : "es"} on this project \u2014 gather_branches puts them into this one, for one review.` : "";
648
+ const after = draft.after?.length ? ` It starts after ${draft.after.map((a) => `"${a.name}"`).join(", ")}.` : "";
649
+ return `${what} ${where_}.${after}${vault} Its ${checkCount} check${checkCount === 1 ? " is" : "s are"} saved switched off \u2014 a test case switches on the ones it is about. Nothing is published yet.
494
650
  Review and publish it here: ${review}${note}`;
651
+ }
652
+ /** A test case built from flows, without saving: its rows and every check,
653
+ * numbered, with which are on — what save_case's `checks` refer to. */
654
+ async previewCase(projectId, spec) {
655
+ const built = await this.service.buildCase(projectId, spec, true);
656
+ return `"${spec.name}" in ${spec.folder} would be:
657
+ ${describeRows(built.rows)}`;
658
+ }
659
+ /** RUN THE CASE, THEN SAVE IT. Built as a dry run, replayed in a fresh
660
+ * browser exactly as a test run does it, and saved onto Claude's working
661
+ * branch only when every row passed. */
662
+ async saveCase(projectId, spec, catalog) {
663
+ const built = await this.service.buildCase(projectId, spec, true);
664
+ const testCase = built.testCase;
665
+ const steps = testCase.occurrences.flatMap((o) => o.steps);
666
+ const prepared = await this.service.prepare(projectId, steps, null);
667
+ const runHelper = helperRunner(prepared.helpers, prepared.secrets);
668
+ const base = catalog.baseUrl;
669
+ if (!base) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
670
+ const outcome = await replayCase({
671
+ occurrences: testCase.occurrences,
672
+ ...testCase.actors ? { actors: testCase.actors } : {},
673
+ ...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
674
+ baseUrl: base,
675
+ headless: true,
676
+ resolveAuth: authenticationResolver(prepared.authentications),
677
+ resolveSecret: (ref) => prepared.secrets[ref],
678
+ ...runHelper ? { runHelper } : {}
679
+ });
680
+ const lines = outcome.occurrences.map((o, i) => {
681
+ const failed = o.steps.find((st) => st.status === "failed");
682
+ const why = o.status === "failed" ? `: ${failed ? `step "${failed.name || failed.type}" \u2014 ${(failed.detail ?? "").split("\n")[0]}` : (o.error ?? "").split("\n")[0]}` : "";
683
+ return `${o.status === "passed" ? "\u2713" : o.status === "failed" ? "\u2717" : "\xB7"} row ${i + 1} \u2014 ${o.name}${why}`;
684
+ });
685
+ if (outcome.status !== "passed") {
686
+ return `The case did not pass, so it was NOT saved.
687
+ ${lines.join("\n")}
688
+
689
+ ${describeRows(built.rows)}`;
690
+ }
691
+ const saved = await this.service.buildCase(projectId, spec, false);
692
+ const review = `${this.service.origin}${saved.path}`;
693
+ return `The case passed from the start${saved.replaced ? " and replaced the earlier version" : ""}, and is saved on ${saved.newBranch ? "a new branch" : "Claude's working branch"}. Nothing is published yet.
694
+ ${lines.join("\n")}
695
+ Review and publish it here: ${review}`;
495
696
  }
496
697
  async close() {
497
698
  const run = this.run;
@@ -511,43 +712,107 @@ Review and publish it here: ${review}${note}`;
511
712
 
512
713
  // src/mcp/tools.ts
513
714
  var INSTRUCTIONS = `Flowriki tools write UI tests by driving a real browser on this computer.
514
- Every step you take is performed first and kept only if it worked, so the flow
515
- you save already ran once. Saved flows go to a NEW branch that a person reviews
516
- and publishes in the Flowriki dashboard \u2014 nothing you do is published.
715
+ Every step you take is performed first and kept only if it worked, so what you
716
+ save already ran once. Everything goes to ONE working branch per project that a
717
+ person reviews and publishes in the Flowriki dashboard \u2014 nothing you do is
718
+ published.
517
719
 
518
720
  A new project \u2014 "set up Flowriki for this site, in this repository" \u2014 is made
519
721
  with create_project; it then becomes the project the other tools work on.
520
722
 
521
- How to write a good Flowriki test:
522
- 1. Call project_catalog first. Reuse an existing feature for the flow when one
523
- fits; otherwise name a new one. Check the existing flows so you do not write
524
- one that already exists.
525
- 2. One flow is one user journey with one purpose ("Sign up and verify email",
526
- "Pay by card"), named the way a tester would say it.
527
- 3. If the journey needs somebody signed in, pass signIn with a profile and an
528
- account from the catalog to start_flow. Never type real credentials.
529
- 4. Read the page with read_page and act using the role and name EXACTLY as the
530
- page shows them (e.g. button "Continue", textbox "Email"). If a name appears
531
- more than once, pass "within" with the name of the region, dialog or form
532
- that holds the one you mean.
533
- 5. For things outside the page \u2014 an emailed code or link, an authenticator
723
+ THE MODEL \u2014 get this right before recording anything:
724
+ - A FLOW is a reusable building block: one piece of a journey ("Open the account
725
+ dialog", "Fill the sign-up form", "Sign in with a wrong password"). A flow can
726
+ START AFTER other flows (start_flow's "after"), so it holds only its own new
727
+ steps and links to the ones before it. A path that behaves differently \u2014 a
728
+ wrong password, a duplicate email \u2014 is its own flow, because the page does
729
+ something different.
730
+ - A flow records checks (add_check) that prove each thing happened, but they
731
+ are SAVED SWITCHED OFF: which ones matter depends on the test.
732
+ - A TEST CASE is the test. It strings flows together as rows (in the same
733
+ browser, or a new one for a second person) and switches on the checks it is
734
+ about. Build it with preview_case, then save_case \u2014 which runs it first.
735
+ - SIGN-IN is shared setup: a flow or case that needs somebody signed in uses a
736
+ sign-in profile from the catalog (start_flow's signIn), never typed login steps.
737
+ None yet? Make it: record the login as a flow that ends with a check proving
738
+ it worked (the account menu, a heading, the URL), then create_sign_in from it
739
+ and create_account for each person who signs in (a customer, an admin). The
740
+ login flow types into its fields; the account supplies the values.
741
+ - HELPERS (an inbox for email codes, an authenticator, SMS, an API call) are made
742
+ with create_helper when the catalog has none that fits.
743
+ - PASSWORDS AND KEYS never go into a flow or a reply. Typed into a password
744
+ field, a value is kept as a secret and saved to the project's vault; given to
745
+ create_account or create_helper, it is sealed there too and never shown back.
746
+ Only use credentials the person gave you or that the app itself shows for
747
+ testing (a demo login) \u2014 never guess or reuse anyone's real password.
748
+ - FOLDERS do not nest. Name each one after the product area and what is in it,
749
+ the way a tester would look for it \u2014 "Account \u2013 Sign up", "Account \u2013 Sign in",
750
+ "Ordering \u2013 Pizza builder", "Checkout \u2013 Payment". Flows (features) and the
751
+ cases built from them use the same names. Keep one area per folder.
752
+
753
+ HOW TO WORK:
754
+ 1. Call project_catalog. Reuse what exists \u2014 published, and on Claude's working
755
+ branch \u2014 rather than writing it again.
756
+ 2. Before recording, say the plan to the person: the folders, the building-block
757
+ flows (and what each starts after), and the test cases (rows + what each
758
+ checks). Then record the blocks, earliest first.
759
+ 3. Record with start_flow (pass "after" for the flows it continues from), read
760
+ the page with read_page, and act using the role and name EXACTLY as the page
761
+ shows them. If a name appears more than once, pass "within" with the name of
762
+ the region, dialog or form \u2014 or "dialog" / "navigation".
763
+ 4. For things outside the page \u2014 an emailed code or link, an authenticator
534
764
  code, a text message, an API call \u2014 use use_helper with a helper from the
535
765
  catalog, then type {{its saveAs name}} into the field that needs it.
536
- 6. End with at least one check (add_check) that proves the journey worked:
537
- a heading, a message, the URL. Saving is refused for a flow that checks nothing.
538
- 7. Before saving, call replay_flow to run the whole flow from the start. If a
539
- step fails there, fix it (remove_step, act again) and replay again.
540
- 8. Save with save_flow and give the person the review link it returns.
541
- 9. Redoing a flow? Each save is a new branch, so clear the superseded one with
542
- list_my_branches and discard_branch \u2014 only after the person asks for it, or
543
- agrees, because it deletes the branch. Only Claude's own unedited branches
544
- can go this way; anything a person touched is theirs to delete.
766
+ 5. Add checks for what each step proves (a heading, a message, a value, the
767
+ URL). replay_flow, then save_flow. A flow must record at least one check.
768
+ 6. Build each test case: preview_case lists its rows and every check, numbered;
769
+ save_case with the numbers to switch on runs the whole case and saves it only
770
+ if it passed. Give the person the review link.
771
+ 7. Saving the same name again replaces it on the working branch. Old branches
772
+ of yours? gather_branches puts them into one; discard_branch deletes one \u2014
773
+ only after the person asks or agrees.
545
774
  Test data you invent (a new user's email or password for a sign-up) is fine to
546
775
  type. For anything that must be NEW on every run \u2014 a sign-up's email or
547
776
  username \u2014 use {{unique}}: it is different on every run and the same within
548
777
  one, e.g. qa+{{unique}}@ the domain of the project's mailbox helper, so the
549
778
  helper can read that address's verification email with {"email": "qa+{{unique}}@\u2026"}.
550
779
  A test that types a fixed new email passes once and fails every run after.`;
780
+ var CASE_INPUT = {
781
+ type: "object",
782
+ properties: {
783
+ name: { type: "string", description: 'The test case: the scenario it proves, e.g. "Wrong password is refused".' },
784
+ folder: { type: "string", description: 'The case folder \u2014 area and topic, e.g. "Account \u2013 Sign in". Made if new.' },
785
+ description: { type: "string" },
786
+ rows: {
787
+ type: "array",
788
+ description: "The flows to run, in order. Each row runs in the same browser as the row above unless newBrowser is true (a second person).",
789
+ items: {
790
+ type: "object",
791
+ properties: {
792
+ flow: { type: "string", description: "A flow's name or id from project_catalog." },
793
+ newBrowser: { type: "boolean" },
794
+ checks: {
795
+ description: `Which of this row's checks to switch on: their numbers from preview_case, or "all". The rest stay off.`,
796
+ anyOf: [{ type: "array", items: { type: "number" } }, { type: "string", enum: ["all"] }]
797
+ }
798
+ },
799
+ required: ["flow"],
800
+ additionalProperties: false
801
+ }
802
+ },
803
+ branch: { type: "string", description: "Optional: save onto this branch (chg_\u2026) instead of the working one." },
804
+ projectId: { type: "string" }
805
+ },
806
+ required: ["name", "folder", "rows"],
807
+ additionalProperties: false
808
+ };
809
+ var caseSpec = (args) => ({
810
+ name: String(args["name"] ?? ""),
811
+ folder: String(args["folder"] ?? ""),
812
+ ...args["description"] ? { description: String(args["description"]) } : {},
813
+ rows: Array.isArray(args["rows"]) ? args["rows"] : [],
814
+ ...args["branch"] ? { changeId: String(args["branch"]) } : {}
815
+ });
551
816
  var text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...isError ? { isError } : {} });
552
817
  var str = (value) => typeof value === "string" ? value : value === void 0 || value === null ? void 0 : String(value);
553
818
  function authoringTools(service, session) {
@@ -569,7 +834,7 @@ function authoringTools(service, session) {
569
834
  catalogs.set(projectId, catalog);
570
835
  return catalog;
571
836
  };
572
- return [
837
+ const tools = [
573
838
  {
574
839
  name: "list_projects",
575
840
  title: "List Flowriki projects",
@@ -621,11 +886,23 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
621
886
  Features:${c.features.length ? "" : " none yet"}`,
622
887
  ...c.features.map((f) => ` - ${f.name} (id ${f.id})${f.description ? ` \u2014 ${f.description}` : ""}
623
888
  ${flowsBy(f.id)}`),
889
+ ...c.draft ? [
890
+ `
891
+ On Claude's working branch "${c.draft.title}" (${c.draft.changeId}), not yet published:`,
892
+ ...c.draft.features.length ? [` New features: ${c.draft.features.map((f) => `${f.name} (id ${f.id})`).join(", ")}`] : [],
893
+ ...c.draft.flows.length ? c.draft.flows.map((f) => ` - flow: ${f.name} (${f.steps} steps, feature ${f.featureId}${f.after?.length ? `, after ${f.after.map((a) => `"${a}"`).join(", ")}` : ""})`) : [" (no flows yet)"],
894
+ ...c.draft.caseFolders?.length ? [` New case folders: ${c.draft.caseFolders.map((f) => f.name).join(", ")}`] : [],
895
+ ...(c.draft.cases ?? []).map((t) => ` - case: ${t.name} (folder ${t.folderId}) \u2014 rows: ${t.rows.join(" \u2192 ")}`)
896
+ ] : [],
897
+ `
898
+ Test cases:${(c.cases ?? []).length ? "" : " none published"}`,
899
+ ...(c.caseFolders ?? []).map((f) => ` - ${f.name} (id ${f.id})
900
+ ${(c.cases ?? []).filter((t) => t.folderId === f.id).map((t) => ` - ${t.name} \u2014 rows: ${t.rows.join(" \u2192 ")}`).join("\n")}`),
624
901
  `
625
902
  Sign-in profiles:${c.signIns.length ? "" : " none"}`,
626
- ...c.signIns.map((s) => ` - ${s.name} (profileId ${s.profileId}): ${s.accounts.map((a) => `${a.name} (accountId ${a.accountId})`).join(", ") || "no accounts yet"}`),
903
+ ...c.signIns.map((s) => ` - ${s.name} (profileId ${s.profileId})${s.fields?.length ? ` \u2014 fields ${s.fields.map((f) => `"${f}"`).join(", ")}` : ""}: ${s.accounts.map((a) => `${a.name} (accountId ${a.accountId})`).join(", ") || "no accounts yet"}`),
627
904
  `
628
- Helpers:${c.helpers.length ? "" : " none \u2014 they are made on the Helpers screen"}`,
905
+ Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
629
906
  ...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}` : ""}`)
630
907
  ].filter(Boolean).join("\n"));
631
908
  }
@@ -640,6 +917,11 @@ Helpers:${c.helpers.length ? "" : " none \u2014 they are made on the Helpers scr
640
917
  name: { type: "string", description: `The flow's name: the user journey, e.g. "Sign up and verify email".` },
641
918
  feature: { type: "string", description: "The feature it belongs to: an existing feature's id or name, or a new name." },
642
919
  startPath: { type: "string", description: 'Where it starts, relative to the base URL. Default "/".' },
920
+ after: {
921
+ type: "array",
922
+ items: { type: "string" },
923
+ description: "Flows this one starts after (names or ids from project_catalog). They are replayed first, so recording begins where they end; what THEY start after is added too. The saved flow links to them."
924
+ },
643
925
  signIn: {
644
926
  type: "object",
645
927
  description: "Start signed in, with a profile (and optionally an account) from project_catalog.",
@@ -662,14 +944,18 @@ Helpers:${c.helpers.length ? "" : " none \u2014 they are made on the Helpers scr
662
944
  if (signIn && !catalog.signIns.some((s) => s.profileId === signIn.profileId)) {
663
945
  throw new Error(`no sign-in profile ${signIn.profileId} \u2014 project_catalog lists them`);
664
946
  }
947
+ const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
948
+ const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
665
949
  const page = await session.start({
666
950
  projectId,
951
+ ...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
667
952
  name: str(args["name"]) ?? "Untitled flow",
668
953
  ...feature ? { featureId: feature.id } : { featureName: featureAsked },
669
954
  startPath: str(args["startPath"]) || "/",
670
955
  ...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
671
956
  }, catalog);
672
- return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}. The browser is open.
957
+ const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
958
+ return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}.${after} The browser is open.
673
959
 
674
960
  ${page}`);
675
961
  }
@@ -693,7 +979,8 @@ ${page}`);
693
979
  role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
694
980
  name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
695
981
  within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
696
- value: { type: "string" }
982
+ value: { type: "string" },
983
+ secret: { type: "boolean", description: "fill only: the value is a secret (a password, PIN, key) \u2014 kept out of the flow and saved to the vault. Fields named like a password are secret without this." }
697
984
  },
698
985
  required: ["action"],
699
986
  additionalProperties: false
@@ -705,7 +992,8 @@ ${page}`);
705
992
  ...str(args["role"]) ? { role: str(args["role"]) } : {},
706
993
  ...str(args["name"]) !== void 0 ? { name: str(args["name"]) } : {},
707
994
  ...str(args["within"]) ? { within: str(args["within"]) } : {},
708
- ...str(args["value"]) !== void 0 ? { value: str(args["value"]) } : {}
995
+ ...str(args["value"]) !== void 0 ? { value: str(args["value"]) } : {},
996
+ ...typeof args["secret"] === "boolean" ? { secret: args["secret"] } : {}
709
997
  }));
710
998
  }
711
999
  },
@@ -783,9 +1071,20 @@ ${page}`);
783
1071
  {
784
1072
  name: "save_flow",
785
1073
  title: "Save the flow to a branch",
786
- 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.",
787
- inputSchema: { type: "object", properties: { title: { type: "string", description: "The branch's title \u2014 what this change adds." } }, additionalProperties: false },
788
- run: async (args) => text(await session.save(str(args["title"])))
1074
+ description: "Save the flow onto Claude's working branch for this project (made on the first save), for a person to review and publish. Saving the same name again replaces the earlier version there. Returns the review link. Refused if the flow checks nothing.",
1075
+ inputSchema: {
1076
+ type: "object",
1077
+ properties: {
1078
+ title: { type: "string", description: "The branch's title, used only when a new branch is started." },
1079
+ branch: { type: "string", description: "Optional: a branch id (chg_\u2026) from list_my_branches to save onto instead." },
1080
+ newBranch: { type: "boolean", description: "Start a separate branch instead of the working one. Only when the person asks for separate reviews." }
1081
+ },
1082
+ additionalProperties: false
1083
+ },
1084
+ run: async (args) => text(await session.save(str(args["title"]), {
1085
+ ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {},
1086
+ ...args["newBranch"] === true ? { newBranch: true } : {}
1087
+ }))
789
1088
  },
790
1089
  {
791
1090
  name: "list_my_branches",
@@ -800,6 +1099,30 @@ ${page}`);
800
1099
  return text(changes.map((c) => `- ${c.id} \u2014 "${c.title}" (${new Date(c.createdAt).toISOString().slice(0, 16).replace("T", " ")}) ${service.origin}${c.path}`).join("\n"));
801
1100
  }
802
1101
  },
1102
+ {
1103
+ name: "gather_branches",
1104
+ title: "Gather Claude's branches into one",
1105
+ description: "Move the flows from Claude's open branches on this project into one branch, and delete the emptied ones \u2014 so the person reviews and publishes once. Only Claude's own, unedited branches; anything a person touched stops it.",
1106
+ inputSchema: {
1107
+ type: "object",
1108
+ properties: {
1109
+ into: { type: "string", description: "The branch to gather into (chg_\u2026). Default: Claude's oldest open branch." },
1110
+ title: { type: "string", description: 'A title for the gathered branch, e.g. "Sign in and sign up tests".' },
1111
+ projectId: { type: "string" }
1112
+ },
1113
+ additionalProperties: false
1114
+ },
1115
+ annotations: { destructiveHint: true },
1116
+ run: async (args) => {
1117
+ const projectId = await projectFor(args["projectId"]);
1118
+ const done = await service.gatherChanges(projectId, {
1119
+ ...str(args["into"]) ? { into: str(args["into"]) } : {},
1120
+ ...str(args["title"]) ? { title: str(args["title"]) } : {}
1121
+ });
1122
+ catalogs.delete(projectId);
1123
+ return text(`Gathered ${done.gathered.length} branch${done.gathered.length === 1 ? "" : "es"} into "${done.title}" (${done.changeId}). Review and publish it here: ${service.origin}${done.path}`);
1124
+ }
1125
+ },
803
1126
  {
804
1127
  name: "discard_branch",
805
1128
  title: "Delete one of Claude's branches",
@@ -818,6 +1141,124 @@ ${page}`);
818
1141
  return text(`Deleted ${changeId} and its branch ${deleted}.`);
819
1142
  }
820
1143
  },
1144
+ {
1145
+ name: "preview_case",
1146
+ title: "Preview a test case",
1147
+ description: "Build a test case from flows without saving or running it: its rows (each flow with what it starts after folded in, and which browser it runs in) and every check, numbered, with which are on. Use the numbers in save_case.",
1148
+ inputSchema: CASE_INPUT,
1149
+ annotations: { readOnlyHint: true },
1150
+ run: async (args) => {
1151
+ const projectId = await projectFor(args["projectId"]);
1152
+ return text(await session.previewCase(projectId, caseSpec(args)));
1153
+ }
1154
+ },
1155
+ {
1156
+ name: "save_case",
1157
+ title: "Run and save a test case",
1158
+ description: "Build the test case, run it from the start in a fresh browser exactly as a test run will, and \u2014 only if every row passed \u2014 save it onto Claude's working branch. Saving the same name in the same folder again replaces it. Refused if it switches on no checks.",
1159
+ inputSchema: CASE_INPUT,
1160
+ annotations: { openWorldHint: true },
1161
+ run: async (args) => {
1162
+ const projectId = await projectFor(args["projectId"]);
1163
+ const out = await session.saveCase(projectId, caseSpec(args), await catalogOf(projectId, true));
1164
+ catalogs.delete(projectId);
1165
+ return text(out);
1166
+ }
1167
+ },
1168
+ {
1169
+ name: "create_sign_in",
1170
+ title: "Make a sign-in profile",
1171
+ description: "Turn a saved login flow into a sign-in profile that flows and test cases can start signed in with. The flow must not itself sign in or start after other flows, and must end with a check proving the login worked \u2014 its last check is switched on here. Returns the fields an account fills in. Making the same name again replaces Claude's earlier one.",
1172
+ inputSchema: {
1173
+ type: "object",
1174
+ properties: {
1175
+ projectId: { type: "string" },
1176
+ flow: { type: "string", description: "The login flow's name or id (project_catalog)." },
1177
+ name: { type: "string", description: `What a tester calls this way of signing in, e.g. "Customer sign-in". Default: the flow's name.` },
1178
+ description: { type: "string" },
1179
+ signOutFlow: { type: "string", description: "Optional: a flow that signs out, by name or id." },
1180
+ branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
1181
+ },
1182
+ required: ["flow"],
1183
+ additionalProperties: false
1184
+ },
1185
+ run: async (args) => {
1186
+ const projectId = await projectFor(args["projectId"]);
1187
+ const made = await service.createSignIn(projectId, {
1188
+ flow: str(args["flow"]) ?? "",
1189
+ ...str(args["name"]) ? { name: str(args["name"]) } : {},
1190
+ ...str(args["description"]) ? { description: str(args["description"]) } : {},
1191
+ ...str(args["signOutFlow"]) ? { signOutFlow: str(args["signOutFlow"]) } : {},
1192
+ ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
1193
+ });
1194
+ catalogs.delete(projectId);
1195
+ return text(`${made.replaced ? "Replaced" : "Made"} the sign-in "${made.name}" (profileId ${made.profileId}) from "${made.flow}", on ${made.newBranch ? "a new branch" : "Claude's working branch"} ${made.branch}.
1196
+ An account fills in: ${made.fields.map((f) => `"${f.label}"${f.secret ? " (secret)" : ""}`).join(", ") || "nothing \u2014 the flow types no values"}.
1197
+ Next: create_account for each person who signs in this way.`);
1198
+ }
1199
+ },
1200
+ {
1201
+ name: "create_account",
1202
+ title: "Make a sign-in account",
1203
+ description: "An account for a sign-in profile: who signs in, and the values its login flow types, by field label (create_sign_in and project_catalog list them). Secret fields are sealed in the project's vault and never shown again. Then start_flow / test cases can use signIn {profileId, accountId}. Making the same name again updates Claude's earlier one.",
1204
+ inputSchema: {
1205
+ type: "object",
1206
+ properties: {
1207
+ projectId: { type: "string" },
1208
+ signIn: { type: "string", description: "The sign-in profile's name or id." },
1209
+ name: { type: "string", description: 'Who this is, e.g. "Demo customer", "Kitchen admin".' },
1210
+ values: { type: "object", additionalProperties: { type: "string" }, description: 'Field label \u2192 value, e.g. {"Email": "demo@example.com", "Password": "\u2026"}.' },
1211
+ environments: { type: "array", items: { type: "string" }, description: "Optional: the environments this account exists on. Default: all." },
1212
+ branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
1213
+ },
1214
+ required: ["signIn", "name", "values"],
1215
+ additionalProperties: false
1216
+ },
1217
+ run: async (args) => {
1218
+ const projectId = await projectFor(args["projectId"]);
1219
+ const values = Object.fromEntries(Object.entries(args["values"] && typeof args["values"] === "object" ? args["values"] : {}).map(([k, v]) => [k, String(v)]));
1220
+ const made = await service.createAccount(projectId, {
1221
+ signIn: str(args["signIn"]) ?? "",
1222
+ name: str(args["name"]) ?? "",
1223
+ values,
1224
+ ...Array.isArray(args["environments"]) ? { environments: args["environments"].map(String) } : {},
1225
+ ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
1226
+ });
1227
+ catalogs.delete(projectId);
1228
+ return text(`${made.replaced ? "Updated" : "Made"} the account "${made.name}" (accountId ${made.accountId}) for "${made.signIn}", on Claude's working branch ${made.branch}. Filled: ${made.filled.join(", ")} \u2014 secrets are in the vault, not the repository.
1229
+ Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accountId}"}.`);
1230
+ }
1231
+ },
1232
+ {
1233
+ name: "create_helper",
1234
+ title: "Make a helper",
1235
+ description: `A helper for things outside the page, as the Helpers screen makes one. config by kind \u2014 mailbox: {kind, host, port?, secure?, username, folder?, match?: {to, from, subject}, extract?: {take: code|link|subject|body, pattern}, waitSeconds?} with secretValues {passwordRef: the mailbox password}. totp: {kind, digits?, period?, algorithm?} with secretValues {secretRef: the base32 secret}. sms: {kind, accountSid, to, match?, extract?} with secretValues {authTokenRef: the Twilio token}. http: {kind, method, url, headers?, secretHeaders?: {name: ""}, body?, expectStatus?, extract?: {take: json|pattern|status|nothing, path, pattern}} with secretValues {"header:<name>": value}. Use {{name}} in fields for inputs a step supplies. Secrets are sealed in the vault and never shown again. Making the same name again updates Claude's earlier one.`,
1236
+ inputSchema: {
1237
+ type: "object",
1238
+ properties: {
1239
+ projectId: { type: "string" },
1240
+ name: { type: "string", description: 'e.g. "QA inbox", "Admin authenticator".' },
1241
+ description: { type: "string" },
1242
+ config: { type: "object", description: "The helper's settings, by kind (see above). Never put a secret here." },
1243
+ secretValues: { type: "object", additionalProperties: { type: "string" }, description: "The secret for the kind, keyed as above." },
1244
+ branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
1245
+ },
1246
+ required: ["name", "config"],
1247
+ additionalProperties: false
1248
+ },
1249
+ run: async (args) => {
1250
+ const projectId = await projectFor(args["projectId"]);
1251
+ const made = await service.createHelper(projectId, {
1252
+ name: str(args["name"]) ?? "",
1253
+ ...str(args["description"]) ? { description: str(args["description"]) } : {},
1254
+ config: args["config"] && typeof args["config"] === "object" ? args["config"] : {},
1255
+ ...args["secretValues"] && typeof args["secretValues"] === "object" ? { secretValues: Object.fromEntries(Object.entries(args["secretValues"]).map(([k, v]) => [k, String(v)])) } : {},
1256
+ ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
1257
+ });
1258
+ catalogs.delete(projectId);
1259
+ return text(`${made.replaced ? "Updated" : "Made"} the ${made.kind} helper "${made.name}" (id ${made.helperId}) on Claude's working branch ${made.branch}.${made.inputs.length ? ` A step supplies ${made.inputs.map((n) => `{{${n}}}`).join(", ")}.` : ""} Use it with use_helper.`);
1260
+ }
1261
+ },
821
1262
  {
822
1263
  name: "close_browser",
823
1264
  title: "Close the browser",
@@ -831,6 +1272,18 @@ ${page}`);
831
1272
  }
832
1273
  }
833
1274
  ];
1275
+ return tools.map((tool) => ({
1276
+ ...tool,
1277
+ run: async (args) => {
1278
+ let result;
1279
+ try {
1280
+ result = await tool.run(args);
1281
+ } catch (error) {
1282
+ throw new Error(session.redact(error.message));
1283
+ }
1284
+ return { ...result, content: result.content.map((c) => c.type === "text" && c.text !== void 0 ? { ...c, text: session.redact(c.text) } : c) };
1285
+ }
1286
+ }));
834
1287
  }
835
1288
 
836
1289
  // src/mcp/index.ts
package/dist/engine.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  resolveCheckouts,
10
10
  start,
11
11
  status
12
- } from "./chunk-3UUCI66O.js";
12
+ } from "./chunk-N3P4O3OH.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.33",
3
+ "version": "0.1.0-beta.36",
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",