@flowriki/agent 0.1.0-beta.64 → 0.1.0-beta.65
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
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
AGENT_VERSION,
|
|
5
5
|
agentUpdateFor,
|
|
6
6
|
authenticationResolver,
|
|
7
|
+
backupsFor,
|
|
7
8
|
checkAssertion,
|
|
8
9
|
closeRunSession,
|
|
9
10
|
connect,
|
|
@@ -23,8 +24,9 @@ import {
|
|
|
23
24
|
resolveService,
|
|
24
25
|
runtimeValue,
|
|
25
26
|
start,
|
|
26
|
-
status
|
|
27
|
-
|
|
27
|
+
status,
|
|
28
|
+
withBackups
|
|
29
|
+
} from "./chunk-FJVNOLHT.js";
|
|
28
30
|
import {
|
|
29
31
|
CLOUD_PROVIDERS,
|
|
30
32
|
CLOUD_PROVIDER_NAME,
|
|
@@ -52,7 +54,7 @@ import {
|
|
|
52
54
|
slugify,
|
|
53
55
|
stepRefusedOn,
|
|
54
56
|
targetFor
|
|
55
|
-
} from "./chunk-
|
|
57
|
+
} from "./chunk-PDUMGI6V.js";
|
|
56
58
|
|
|
57
59
|
// src/cli.ts
|
|
58
60
|
import { parseArgs } from "util";
|
|
@@ -245,6 +247,13 @@ var ServiceClient = class {
|
|
|
245
247
|
{ method: "POST", body: JSON.stringify(input) }
|
|
246
248
|
);
|
|
247
249
|
}
|
|
250
|
+
/** A test case that cannot be automated, and why. */
|
|
251
|
+
reportBlocker(projectId, input) {
|
|
252
|
+
return this.call(
|
|
253
|
+
`/api/agent/cases/blocker?projectId=${encodeURIComponent(projectId)}`,
|
|
254
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
255
|
+
);
|
|
256
|
+
}
|
|
248
257
|
/** A helper, its secrets sealed by the service. */
|
|
249
258
|
createHelper(projectId, input) {
|
|
250
259
|
return this.call(
|
|
@@ -253,6 +262,11 @@ var ServiceClient = class {
|
|
|
253
262
|
);
|
|
254
263
|
}
|
|
255
264
|
/** What a new flow starts after: the flows, in run order, and their steps. */
|
|
265
|
+
/** A saved flow's steps, to fix it from rather than record it again. */
|
|
266
|
+
savedFlow(projectId, name, platform) {
|
|
267
|
+
const q = new URLSearchParams({ projectId, name, ...platform ? { platform } : {} });
|
|
268
|
+
return this.call(`/api/agent/flows/saved?${q}`);
|
|
269
|
+
}
|
|
256
270
|
flowsAfter(projectId, after) {
|
|
257
271
|
return this.call(
|
|
258
272
|
`/api/agent/flows/after?projectId=${encodeURIComponent(projectId)}`,
|
|
@@ -417,6 +431,15 @@ import fs from "fs";
|
|
|
417
431
|
import path from "path";
|
|
418
432
|
var MAX_TREE = 14e3;
|
|
419
433
|
var MAX_STEP_VIEW = 6e3;
|
|
434
|
+
function quiet(tree) {
|
|
435
|
+
return tree.split("\n").filter((line) => {
|
|
436
|
+
const t = line.trim();
|
|
437
|
+
if (/^- \/url:/.test(t)) return false;
|
|
438
|
+
if (/^- img$/.test(t) || /^- img:$/.test(t)) return false;
|
|
439
|
+
if (/^- listitem$/.test(t)) return false;
|
|
440
|
+
return true;
|
|
441
|
+
}).join("\n");
|
|
442
|
+
}
|
|
420
443
|
function topLevelBlocks(tree) {
|
|
421
444
|
const blocks = [];
|
|
422
445
|
for (const line of tree.split("\n")) {
|
|
@@ -430,7 +453,7 @@ function topLevelBlocks(tree) {
|
|
|
430
453
|
};
|
|
431
454
|
}
|
|
432
455
|
var STEP_TIMEOUT_MS = 1e4;
|
|
433
|
-
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;
|
|
456
|
+
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|token|api[-_\s]?key|access[-_\s]?key|card[-_\s]?number|credit[-_\s]?card/i;
|
|
434
457
|
var TYPED = "typed_";
|
|
435
458
|
var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
|
|
436
459
|
var SIGN_UP = /\b(sign[\s-]?up|register|registration|create (an |your )?account|join)\b/i;
|
|
@@ -609,6 +632,8 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
609
632
|
values = /* @__PURE__ */ new Map();
|
|
610
633
|
resolvers = null;
|
|
611
634
|
lastTree = null;
|
|
635
|
+
/** The navigation as last shown, so a step repeats it only when it changed. */
|
|
636
|
+
lastNavigation = null;
|
|
612
637
|
/** Values typed into secret fields, by their `typed:` reference. */
|
|
613
638
|
typed = /* @__PURE__ */ new Map();
|
|
614
639
|
draft = null;
|
|
@@ -670,6 +695,8 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
670
695
|
* it is kept, so a flow interrupted — the program stopped, the limit hit,
|
|
671
696
|
* the agent restarted — is not lost: starting the same flow again replays
|
|
672
697
|
* what was kept and carries on from there, unless `fresh` says start over. */
|
|
698
|
+
/** Steps of a saved flow still to put back, after one failed on the way. */
|
|
699
|
+
waiting = [];
|
|
673
700
|
async start(input, catalog, opts = {}) {
|
|
674
701
|
await this.close();
|
|
675
702
|
this.catalog = catalog;
|
|
@@ -678,7 +705,8 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
678
705
|
this.typed = /* @__PURE__ */ new Map();
|
|
679
706
|
this.lastTree = null;
|
|
680
707
|
this.verified = null;
|
|
681
|
-
|
|
708
|
+
this.waiting = [];
|
|
709
|
+
const left = opts.fresh ? null : this.readDraft(input.projectId, input.name) ?? (opts.saved?.length ? { steps: opts.saved } : null);
|
|
682
710
|
if (opts.fresh) this.dropDraft(input.projectId, input.name);
|
|
683
711
|
const opened = isMobile(input.platform) ? await this.startMobile(input.platform, catalog) : await this.startWeb(input, catalog);
|
|
684
712
|
this.report();
|
|
@@ -691,7 +719,7 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
691
719
|
async resume(steps, opened) {
|
|
692
720
|
const draft = this.draft;
|
|
693
721
|
let failed = null;
|
|
694
|
-
for (const step of steps) {
|
|
722
|
+
for (const [at, step] of steps.entries()) {
|
|
695
723
|
try {
|
|
696
724
|
if (this.mobile) {
|
|
697
725
|
const done = await runMobileSteps(this.mobile, [step], this.mobileResolvers());
|
|
@@ -713,17 +741,28 @@ var AuthoringSession = class _AuthoringSession {
|
|
|
713
741
|
this.checkpoint();
|
|
714
742
|
} catch (error) {
|
|
715
743
|
failed = `${describe(step)} \u2014 ${error.message.split("\n")[0]}`;
|
|
744
|
+
this.waiting = steps.slice(at + 1);
|
|
716
745
|
break;
|
|
717
746
|
}
|
|
718
747
|
}
|
|
719
748
|
this.checkpoint();
|
|
720
749
|
const kept = draft.steps.length;
|
|
721
|
-
const
|
|
750
|
+
const later = this.waiting.length ? ` ${this.waiting.length} later step${this.waiting.length === 1 ? " is" : "s are"} waiting: do the failed step again the way the page now needs, then continue_flow puts ${this.waiting.length === 1 ? "it" : "them"} back.` : "";
|
|
751
|
+
const head = failed ? `Resumed "${draft.name}": ${kept} of ${steps.length} earlier steps are back; the next one did not work this time and was dropped (${failed}).${later}` : `Resumed "${draft.name}": its ${kept} earlier step${kept === 1 ? "" : "s"} ${kept === 1 ? "is" : "are"} back. Carry on from here.`;
|
|
722
752
|
const view = this.mobile ? await this.snapshot() : opened.includes("\n") ? await this.snapshot() : opened;
|
|
723
753
|
return `${head}
|
|
724
754
|
|
|
725
755
|
${view}`;
|
|
726
756
|
}
|
|
757
|
+
/** Put back the steps that were waiting after a failed one — replayed,
|
|
758
|
+
* kept as they pass; the next that fails is where you take over again. */
|
|
759
|
+
async continueFlow() {
|
|
760
|
+
this.requireOpen();
|
|
761
|
+
const steps = this.waiting;
|
|
762
|
+
if (!steps.length) throw new Error("no steps are waiting \u2014 the flow is whole; add_check, replay_flow and save_flow");
|
|
763
|
+
this.waiting = [];
|
|
764
|
+
return this.resume(steps, "");
|
|
765
|
+
}
|
|
727
766
|
// ── Drafts on disk ──────────────────────────────────────────────────────
|
|
728
767
|
static draftFile(projectId, name) {
|
|
729
768
|
return path.join(flowrikiHome(), "drafts", projectId.replace(/[^A-Za-z0-9_-]/g, "_"), `${slugify(name) || "flow"}.json`);
|
|
@@ -945,32 +984,36 @@ Record the flows it needs; on every act, add_check and use_helper pass line: the
|
|
|
945
984
|
const before = this.lastTree;
|
|
946
985
|
this.lastTree = tree;
|
|
947
986
|
const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
|
|
948
|
-
\u2026 (cut
|
|
949
|
-
if (full || before === null)
|
|
950
|
-
|
|
987
|
+
\u2026 (cut)` : t;
|
|
988
|
+
if (full || before === null) {
|
|
989
|
+
this.lastNavigation = topLevelBlocks(tree).navigation ?? null;
|
|
990
|
+
return head + cap(tree, MAX_TREE);
|
|
991
|
+
}
|
|
992
|
+
const { dialogs, navigation: nav } = topLevelBlocks(tree);
|
|
993
|
+
const navigation = nav && nav !== this.lastNavigation ? nav : null;
|
|
994
|
+
this.lastNavigation = nav ?? null;
|
|
951
995
|
if (dialogs.length) {
|
|
952
|
-
return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog
|
|
996
|
+
return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog)";
|
|
953
997
|
}
|
|
954
998
|
const was = new Set(before.split("\n"));
|
|
955
999
|
const now = new Set(tree.split("\n"));
|
|
956
1000
|
const added = tree.split("\n").filter((l) => !was.has(l));
|
|
957
1001
|
const removed = before.split("\n").filter((l) => !now.has(l));
|
|
958
|
-
if (added.length === 0 && removed.length === 0) return head + "(the page did not change
|
|
1002
|
+
if (added.length === 0 && removed.length === 0) return head + "(the page did not change)";
|
|
959
1003
|
if (added.length + removed.length > 120) return head + cap(tree, MAX_STEP_VIEW);
|
|
960
1004
|
return head + [
|
|
961
1005
|
...navigation ? [navigation, ""] : [],
|
|
962
1006
|
added.length ? `Appeared:
|
|
963
1007
|
${added.join("\n")}` : "",
|
|
964
1008
|
removed.length ? `Gone:
|
|
965
|
-
${removed.join("\n")}` : ""
|
|
966
|
-
"(changes since the last step \u2014 read_page shows the whole page)"
|
|
1009
|
+
${removed.join("\n")}` : ""
|
|
967
1010
|
].filter(Boolean).join("\n");
|
|
968
1011
|
}
|
|
969
1012
|
async webTree() {
|
|
970
1013
|
const { page, draft } = this.requireOpen();
|
|
971
1014
|
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
972
1015
|
const tree = numberDuplicates(labelTree(
|
|
973
|
-
await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
|
|
1016
|
+
quiet(await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`)),
|
|
974
1017
|
await fieldNames(page)
|
|
975
1018
|
));
|
|
976
1019
|
const title = await page.title().catch(() => "");
|
|
@@ -980,7 +1023,28 @@ Steps so far: ${draft.steps.length}
|
|
|
980
1023
|
` };
|
|
981
1024
|
}
|
|
982
1025
|
/** Do one thing on the page, and keep it as a step if it worked. */
|
|
1026
|
+
/** The step that failed last, and how many times in a row. */
|
|
1027
|
+
failing = null;
|
|
1028
|
+
/** DO ONE STEP — and notice when the same step keeps failing. Retrying it
|
|
1029
|
+
* turn after turn was where whole jobs went: each try re-reads the entire
|
|
1030
|
+
* conversation. On the third failure in a row, the answer says to stop. */
|
|
983
1031
|
async act(input) {
|
|
1032
|
+
const key = [input.action, input.role ?? "", input.name ?? "", input.within ?? "", input.nth ?? ""].join("|");
|
|
1033
|
+
try {
|
|
1034
|
+
const done = await this.actOnce(input);
|
|
1035
|
+
this.failing = null;
|
|
1036
|
+
return done;
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
this.failing = this.failing?.key === key ? { key, times: this.failing.times + 1 } : { key, times: 1 };
|
|
1039
|
+
if (this.failing.times >= 3) {
|
|
1040
|
+
error.message = `${error.message}
|
|
1041
|
+
|
|
1042
|
+
THIS STEP HAS FAILED ${this.failing.times} TIMES IN A ROW. Do not try it again: if another way to reach the same thing is not obvious from the page, call report_blocker for the test case (kind: stuck, with the line) and move on.`;
|
|
1043
|
+
}
|
|
1044
|
+
throw error;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
async actOnce(input) {
|
|
984
1048
|
if (this.mobile) return this.mobileAct(input);
|
|
985
1049
|
const refused = stepRefusedOn(input.action === "tap" ? "click" : input.action, "web");
|
|
986
1050
|
if (refused) throw new Error(refused);
|
|
@@ -992,11 +1056,11 @@ Steps so far: ${draft.steps.length}
|
|
|
992
1056
|
if ((input.action === "fill" || input.action === "select" || input.action === "press" || input.action === "navigate") && input.value === void 0) {
|
|
993
1057
|
throw new Error(`${input.action} needs a value`);
|
|
994
1058
|
}
|
|
995
|
-
const target = navigating ? { role: "", name: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
|
|
1059
|
+
const target = navigating ? { role: "", name: "", scope: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
|
|
996
1060
|
const secret = input.action === "fill" && (input.secret ?? (SECRET_WORDS.test(input.name ?? "") || SECRET_WORDS.test(target.name)));
|
|
997
1061
|
const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
|
|
998
1062
|
if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
|
|
999
|
-
|
|
1063
|
+
let step = RecordedStepSchema.parse({
|
|
1000
1064
|
id,
|
|
1001
1065
|
type: input.action,
|
|
1002
1066
|
...target,
|
|
@@ -1011,6 +1075,7 @@ Steps so far: ${draft.steps.length}
|
|
|
1011
1075
|
// environment the run is pointed at rather than the one written on.
|
|
1012
1076
|
...input.action === "navigate" && input.value?.startsWith("/") ? { navigationBase: "application" } : {}
|
|
1013
1077
|
});
|
|
1078
|
+
if (!navigating && input.action !== "press") step = RecordedStepSchema.parse(withBackups(step, await backupsFor(page, step)));
|
|
1014
1079
|
try {
|
|
1015
1080
|
this.page = await executeStep(page, step, {
|
|
1016
1081
|
pages: run.pages,
|
|
@@ -1133,9 +1198,7 @@ ${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
|
|
|
1133
1198
|
if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
|
|
1134
1199
|
const [gone] = draft.steps.splice(number - 1, 1);
|
|
1135
1200
|
this.checkpoint();
|
|
1136
|
-
return `Removed step ${number} (${describe(gone)}). The browser was not undone
|
|
1137
|
-
|
|
1138
|
-
${this.list()}`;
|
|
1201
|
+
return `Removed step ${number} (${describe(gone)}); ${draft.steps.length} steps now. The browser was not undone.`;
|
|
1139
1202
|
}
|
|
1140
1203
|
/** Run the whole draft from the start, in a browser of its own, the way a
|
|
1141
1204
|
* run will — the proof the steps work together, not just one at a time. */
|
|
@@ -1347,7 +1410,7 @@ Steps so far: ${draft.steps.length}
|
|
|
1347
1410
|
throw new Error(`${input.action} needs the element's role and name, exactly as read_page shows them`);
|
|
1348
1411
|
}
|
|
1349
1412
|
if ((type === "fill" || type === "select" || type === "navigate") && input.value === void 0) throw new Error(`${input.action} needs a value`);
|
|
1350
|
-
let target = { role: "", name: "" };
|
|
1413
|
+
let target = { role: "", name: "", scope: "" };
|
|
1351
1414
|
let password = false;
|
|
1352
1415
|
if (input.role) {
|
|
1353
1416
|
const asked = { role: input.role, name: input.name ?? "", ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} };
|
|
@@ -1552,6 +1615,20 @@ THE MODEL \u2014 get this right before recording anything:
|
|
|
1552
1615
|
account.
|
|
1553
1616
|
- HELPERS (an inbox for email codes, an authenticator, SMS, an API call) are made
|
|
1554
1617
|
with create_helper when the catalog has none that fits.
|
|
1618
|
+
- HEALING fixes a test's automation, never its business: change only how steps
|
|
1619
|
+
find and reach things (an element renamed or moved, a new screen in the way,
|
|
1620
|
+
a wait). Never change a case's Given / When / Then, what a check expects or
|
|
1621
|
+
which checks are on, and never drop a row \u2014 in a heal the service refuses
|
|
1622
|
+
such a save. If the app itself behaves differently now, report_blocker
|
|
1623
|
+
(app-bug) and leave that case.
|
|
1624
|
+
- WHEN A CASE CANNOT BE AUTOMATED, say so with report_blocker and move on to
|
|
1625
|
+
the next case \u2014 never loop. Report it when the app itself is broken at that
|
|
1626
|
+
step (app-bug), when it needs something only a person has \u2014 a captcha, a
|
|
1627
|
+
real card, a code sent to somebody's own phone (needs-person), when a
|
|
1628
|
+
business step is unclear or does not match the app (unclear-step), when the
|
|
1629
|
+
screen or feature cannot be reached (not-found), or after 3 failed tries at
|
|
1630
|
+
the same step (stuck). Name the line it stopped at and what would unblock it.
|
|
1631
|
+
A case marked manual by a person is not to be automated at all.
|
|
1555
1632
|
- SEVERAL ELEMENTS WITH THE SAME NAME (two "Proceed to Checkout" buttons, twelve
|
|
1556
1633
|
"Select & Customize") are told apart by POSITION, like an XPath index: read_page
|
|
1557
1634
|
marks each one [nth N of M], and act / add_check take nth (0 = the first). Use it
|
|
@@ -1571,7 +1648,7 @@ THE MODEL \u2014 get this right before recording anything:
|
|
|
1571
1648
|
"Ordering \u2013 Pizza builder", "Checkout \u2013 Payment". Flows (features) and the
|
|
1572
1649
|
cases built from them use the same names. Keep one area per folder.
|
|
1573
1650
|
|
|
1574
|
-
KNOWLEDGE: project_brief is the project's memory \u2014
|
|
1651
|
+
KNOWLEDGE: project_brief is the project's memory \u2014 given with the request when there is one (then do not call it again). Search the
|
|
1575
1652
|
knowledge pages with knowledge_search; read one with knowledge_read.
|
|
1576
1653
|
product.md is "About the product": the person's own description of
|
|
1577
1654
|
the product, in their words, as long as they like \u2014 read it first. Beside it,
|
|
@@ -1631,14 +1708,17 @@ mix rows on a phone with rows in a browser (a customer orders in the app, an
|
|
|
1631
1708
|
admin approves on the website).
|
|
1632
1709
|
|
|
1633
1710
|
HOW TO WORK:
|
|
1634
|
-
1.
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
checks)
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1711
|
+
1. Reuse what exists \u2014 published, and on Claude's working branch \u2014 rather than
|
|
1712
|
+
writing it again: project_catalog lists it (once is enough; a repeat says
|
|
1713
|
+
whether anything changed).
|
|
1714
|
+
2. Plan the folders, the building-block flows (and what each starts after) and
|
|
1715
|
+
the test cases (rows + what each checks), then record the blocks, earliest
|
|
1716
|
+
first. No need to wait for an answer: nobody is at the keyboard.
|
|
1717
|
+
3. Record with start_flow (pass "after" for the flows it continues from) \u2014 it
|
|
1718
|
+
answers with the page \u2014 and act using the role and name EXACTLY as the page
|
|
1719
|
+
shows them, several steps per act when they are all on the page already.
|
|
1720
|
+
Each step answers with what changed; read_page only when you need the whole
|
|
1721
|
+
page again. If a name appears more than once, pass "within" with the name of
|
|
1642
1722
|
the region, dialog or form \u2014 or "dialog" / "navigation".
|
|
1643
1723
|
4. For things outside the page \u2014 an emailed code or link, an authenticator
|
|
1644
1724
|
code, a text message, an API call \u2014 use use_helper with a helper from the
|
|
@@ -1724,8 +1804,19 @@ var caseSpec = (args) => ({
|
|
|
1724
1804
|
});
|
|
1725
1805
|
var text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...isError ? { isError } : {} });
|
|
1726
1806
|
var str = (value) => typeof value === "string" ? value : value === void 0 || value === null ? void 0 : String(value);
|
|
1807
|
+
var STEP_PROPERTIES = {
|
|
1808
|
+
action: { type: "string", enum: ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward", "tap", "longPress", "swipe", "scroll", "hideKeyboard", "permission", "launchApp", "terminateApp", "resetApp", "background", "rotate"] },
|
|
1809
|
+
role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox." },
|
|
1810
|
+
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
1811
|
+
within: { type: "string", description: "When the name is not unique: the region, dialog or form that holds it." },
|
|
1812
|
+
nth: { type: "integer", minimum: 0, description: "Which one when several share role and name: 0 is the first ([nth N of M] in read_page)." },
|
|
1813
|
+
value: { type: "string" },
|
|
1814
|
+
secret: { type: "boolean", description: "fill only: a password, PIN or key \u2014 kept out of the flow, saved to the vault." },
|
|
1815
|
+
line: { type: "integer", minimum: 1, description: "The test case line (from start_case) this step carries out." }
|
|
1816
|
+
};
|
|
1727
1817
|
function authoringTools(service, session) {
|
|
1728
|
-
let defaultProject = null;
|
|
1818
|
+
let defaultProject = process.env["FLOWRIKI_PROJECT_ID"]?.trim() || null;
|
|
1819
|
+
const catalogSaid = /* @__PURE__ */ new Map();
|
|
1729
1820
|
const catalogs = /* @__PURE__ */ new Map();
|
|
1730
1821
|
const projectFor = async (asked) => {
|
|
1731
1822
|
const named = str(asked);
|
|
@@ -1819,13 +1910,20 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
|
|
|
1819
1910
|
{
|
|
1820
1911
|
name: "project_catalog",
|
|
1821
1912
|
title: "What a test can be built from",
|
|
1822
|
-
description: "
|
|
1823
|
-
inputSchema: { type: "object", properties: {
|
|
1913
|
+
description: "What exists to build from: base URL, features with their flows, test-case folders (counts \u2014 name a folder to list its cases), sign-in profiles with accounts, and helpers. start_flow reads it by itself; call this to see what to reuse. platform: only that platform's flows. A second call in the same job says whether anything changed.",
|
|
1914
|
+
inputSchema: { type: "object", properties: {
|
|
1915
|
+
projectId: { type: "string", description: "Optional when you are on one project." },
|
|
1916
|
+
platform: { type: "string", enum: ["web", "android", "ios"], description: "Only this platform's flows." },
|
|
1917
|
+
folder: { type: "string", description: "List the test cases of this folder (by name)." }
|
|
1918
|
+
}, additionalProperties: false },
|
|
1824
1919
|
annotations: { readOnlyHint: true },
|
|
1825
1920
|
run: async (args) => {
|
|
1826
1921
|
const projectId = await projectFor(args["projectId"]);
|
|
1827
1922
|
const c = await catalogOf(projectId, true);
|
|
1828
|
-
const
|
|
1923
|
+
const onlyPlatform = str(args["platform"]);
|
|
1924
|
+
const onPlatform = (f) => !onlyPlatform || (onlyPlatform === "web" ? !isMobile(f.platform) : f.platform === onlyPlatform);
|
|
1925
|
+
const flowsBy = (featureId) => c.flows.filter((f) => f.featureId === featureId && onPlatform(f)).map((f) => ` - ${f.name} (${f.steps} steps${isMobile(f.platform) ? `, ${f.platform}` : ""})`).join("\n");
|
|
1926
|
+
const askedFolder = str(args["folder"])?.toLowerCase();
|
|
1829
1927
|
const platforms = platformsOf(c);
|
|
1830
1928
|
const phones = platforms.android || platforms.ios ? await devicesSummary() : [];
|
|
1831
1929
|
const phoneLines = ["android", "ios"].filter((p) => platforms[p]).map((p) => {
|
|
@@ -1834,11 +1932,11 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
|
|
|
1834
1932
|
const farms = (c.mobile?.cloud ?? []).map((f) => CLOUD_PROVIDER_NAME[f.provider]);
|
|
1835
1933
|
const chosen = c.mobile?.devices[p];
|
|
1836
1934
|
return `${p === "android" ? "Android" : "iOS"} app: ${app ?? "(not named \u2014 Settings \u2192 Environments)"}
|
|
1837
|
-
Phones on this computer: ${here.
|
|
1935
|
+
Phones on this computer: ${here.filter((d) => d.state === "ready").map((d) => d.name).join(", ") || "none running"}${here.some((d) => d.state !== "ready") ? ` (+${here.filter((d) => d.state !== "ready").length} switched off)` : ""}` + (farms.length ? `
|
|
1838
1936
|
Cloud device farms: ${farms.join(", ")}` : "") + `
|
|
1839
1937
|
Runs: ${c.runWhere?.[p] === "cloud" ? "in the cloud" : "on this computer"}` + (chosen ? ` \u2014 ${chosen.kind === "local" ? chosen.name || chosen.id : `${CLOUD_PROVIDER_NAME[chosen.provider]}: ${chosen.device}`}` : "");
|
|
1840
1938
|
});
|
|
1841
|
-
|
|
1939
|
+
const out = [
|
|
1842
1940
|
`Project ${projectId}. Tests: ${[platforms.web && "web", platforms.android && "Android app", platforms.ios && "iOS app"].filter(Boolean).join(", ")}.`,
|
|
1843
1941
|
platforms.web ? `Base URL: ${c.baseUrl || "(not set)"}${c.runWhere?.web === "cloud" ? " \u2014 test runs use a cloud browser; recording is on this computer" : ""}` : "",
|
|
1844
1942
|
...phoneLines,
|
|
@@ -1857,15 +1955,27 @@ On Claude's working branch "${c.draft.title}" (${c.draft.changeId}), not yet pub
|
|
|
1857
1955
|
] : [],
|
|
1858
1956
|
`
|
|
1859
1957
|
Test cases:${(c.cases ?? []).length ? "" : " none published"}`,
|
|
1860
|
-
|
|
1861
|
-
|
|
1958
|
+
// COUNTS, unless a folder is asked for: hundreds of cases written
|
|
1959
|
+
// and not automated yet were most of this answer, re-read on every
|
|
1960
|
+
// turn after. read_test_cases has any case's steps.
|
|
1961
|
+
...(c.caseFolders ?? []).map((f) => {
|
|
1962
|
+
const inside = (c.cases ?? []).filter((t) => t.folderId === f.id);
|
|
1963
|
+
const head = ` - ${f.key ? `${f.key} ` : ""}${f.name} (id ${f.id}) \u2014 ${inside.length} case${inside.length === 1 ? "" : "s"}, ${inside.filter((t) => t.rows.length).length} automated`;
|
|
1964
|
+
if (askedFolder !== f.name.toLowerCase()) return head;
|
|
1965
|
+
return `${head}
|
|
1966
|
+
${inside.map((t) => ` - ${t.key ? `${t.key} ` : ""}${t.name} \u2014 ${t.rows.length ? `rows: ${t.rows.join(" \u2192 ")}` : "not automated yet"}`).join("\n")}`;
|
|
1967
|
+
}),
|
|
1862
1968
|
`
|
|
1863
1969
|
Sign-in profiles:${c.signIns.length ? "" : " none"}`,
|
|
1864
1970
|
...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"}`),
|
|
1865
1971
|
`
|
|
1866
1972
|
Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
1867
1973
|
...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}` : ""}`)
|
|
1868
|
-
].filter(Boolean).join("\n")
|
|
1974
|
+
].filter(Boolean).join("\n");
|
|
1975
|
+
const key = `${projectId}|${onlyPlatform ?? ""}|${askedFolder ?? ""}`;
|
|
1976
|
+
if (catalogSaid.get(key) === out) return text("Nothing has changed since you last read the catalog in this job.");
|
|
1977
|
+
catalogSaid.set(key, out);
|
|
1978
|
+
return text(out);
|
|
1869
1979
|
}
|
|
1870
1980
|
},
|
|
1871
1981
|
{
|
|
@@ -1957,9 +2067,13 @@ ${m.platform === "web" ? "Web pages" : "Android screens"} runs reached:`);
|
|
|
1957
2067
|
if (signIn && !catalog.signIns.some((s) => s.profileId === signIn.profileId)) {
|
|
1958
2068
|
throw new Error(`no sign-in profile ${signIn.profileId} \u2014 project_catalog lists them`);
|
|
1959
2069
|
}
|
|
1960
|
-
const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
1961
|
-
const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
|
|
1962
2070
|
const platforms = platformsOf(catalog);
|
|
2071
|
+
const named = str(args["name"]) ?? "";
|
|
2072
|
+
const wantedPlatform = str(args["platform"]);
|
|
2073
|
+
const saved = args["fresh"] === true ? null : await Promise.resolve().then(() => service.savedFlow(projectId, named, wantedPlatform)).then((r) => r.flow).catch(() => null);
|
|
2074
|
+
const askedAfter = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
2075
|
+
const asked = askedAfter.length ? askedAfter : saved?.after ?? [];
|
|
2076
|
+
const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
|
|
1963
2077
|
const enabled = ["web", "android", "ios"].filter((p) => platforms[p]);
|
|
1964
2078
|
const platform = str(args["platform"]) ?? (enabled.length === 1 ? enabled[0] : enabled.includes("web") ? "web" : void 0);
|
|
1965
2079
|
if (!platform) throw new Error(`this project tests ${enabled.join(" and ")} \u2014 say which with platform`);
|
|
@@ -1970,10 +2084,10 @@ ${m.platform === "web" ? "Web pages" : "Android screens"} runs reached:`);
|
|
|
1970
2084
|
...isMobile(platform) ? { platform, ...device ? { device } : {} } : {},
|
|
1971
2085
|
...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
|
|
1972
2086
|
name: str(args["name"]) ?? "Untitled flow",
|
|
1973
|
-
...feature ? { featureId: feature.id } : { featureName: featureAsked },
|
|
1974
|
-
startPath: str(args["startPath"]) || (isMobile(platform) ? "" : "/"),
|
|
1975
|
-
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
|
|
1976
|
-
}, catalog, { ...args["fresh"] === true ? { fresh: true } : {} });
|
|
2087
|
+
...feature ? { featureId: feature.id } : saved ? { featureId: saved.featureId } : { featureName: featureAsked },
|
|
2088
|
+
startPath: str(args["startPath"]) || saved?.startPath || (isMobile(platform) ? "" : "/"),
|
|
2089
|
+
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : saved?.signIn ? { signIn: saved.signIn } : {}
|
|
2090
|
+
}, catalog, { ...args["fresh"] === true ? { fresh: true } : {}, ...saved ? { saved: saved.steps } : {} });
|
|
1977
2091
|
const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
|
|
1978
2092
|
return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}.${after} ${isMobile(platform) ? "The app is open on the phone." : "The browser is open."}
|
|
1979
2093
|
|
|
@@ -1990,37 +2104,60 @@ ${page}`);
|
|
|
1990
2104
|
},
|
|
1991
2105
|
{
|
|
1992
2106
|
name: "act",
|
|
1993
|
-
title: "Do
|
|
1994
|
-
description: `Perform
|
|
2107
|
+
title: "Do things on the page",
|
|
2108
|
+
description: `Perform a step \u2014 or several, in order, as steps: [...] \u2014 and keep each one that works. Stops at the first that fails; returns one line per step and the page afterwards. Batch steps whose elements are already on the page (filling a form, then submitting). 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 (on a phone, a deep link). back/forward: nothing. PHONE ONLY: tap (role + name), longPress (role + name), swipe (value up/down/left/right, optionally on role + name), scroll (to role + name, or value up/down), hideKeyboard, permission (value allow/deny), launchApp, terminateApp, resetApp, background (value: seconds), rotate (value portrait/landscape).`,
|
|
1995
2109
|
inputSchema: {
|
|
1996
2110
|
type: "object",
|
|
1997
2111
|
properties: {
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
2001
|
-
within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
|
|
2002
|
-
nth: { type: "integer", minimum: 0, description: "Which one, when several elements share this role and name: 0 is the first in the order read_page lists them (each shared name is marked [nth N of M]). Leave it out when the name is unique." },
|
|
2003
|
-
value: { type: "string" },
|
|
2004
|
-
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." },
|
|
2005
|
-
line: { type: "integer", minimum: 1, description: "The number of the test case's line (from start_case) this step carries out." }
|
|
2112
|
+
...STEP_PROPERTIES,
|
|
2113
|
+
steps: { type: "array", maxItems: 25, items: { type: "object", properties: STEP_PROPERTIES, required: ["action"], additionalProperties: false }, description: "Several steps in order, instead of one." }
|
|
2006
2114
|
},
|
|
2007
|
-
required: ["action"],
|
|
2008
2115
|
additionalProperties: false
|
|
2009
2116
|
},
|
|
2010
2117
|
run: async (args) => {
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2118
|
+
const stepOf = (raw) => {
|
|
2119
|
+
if (!isAction(raw["action"])) throw new Error("action must be one of click, dblclick, rightclick, hover, fill, select, check, press, navigate, back, forward \u2014 or on a phone tap, longPress, swipe, scroll, hideKeyboard, permission, launchApp, terminateApp, resetApp, background, rotate");
|
|
2120
|
+
return {
|
|
2121
|
+
action: raw["action"],
|
|
2122
|
+
...str(raw["role"]) ? { role: str(raw["role"]) } : {},
|
|
2123
|
+
...str(raw["name"]) !== void 0 ? { name: str(raw["name"]) } : {},
|
|
2124
|
+
...str(raw["within"]) ? { within: str(raw["within"]) } : {},
|
|
2125
|
+
...Number.isInteger(raw["nth"]) && raw["nth"] >= 0 ? { nth: raw["nth"] } : {},
|
|
2126
|
+
...str(raw["value"]) !== void 0 ? { value: str(raw["value"]) } : {},
|
|
2127
|
+
...typeof raw["secret"] === "boolean" ? { secret: raw["secret"] } : {}
|
|
2128
|
+
};
|
|
2129
|
+
};
|
|
2130
|
+
const batch = Array.isArray(args["steps"]) ? args["steps"].slice(0, 25) : null;
|
|
2131
|
+
if (!batch?.length) return tagged(args, async () => session.act(stepOf(args)));
|
|
2132
|
+
const said = [];
|
|
2133
|
+
let page = "";
|
|
2134
|
+
for (let i = 0; i < batch.length; i++) {
|
|
2135
|
+
const raw = batch[i];
|
|
2136
|
+
const what = `${String(raw["action"])}${str(raw["name"]) ? ` "${str(raw["name"])}"` : str(raw["value"]) && raw["action"] !== "fill" ? ` ${str(raw["value"])}` : ""}`;
|
|
2137
|
+
const before = session.keptCount();
|
|
2138
|
+
try {
|
|
2139
|
+
page = await session.act(stepOf(raw));
|
|
2140
|
+
const line = Number(raw["line"]);
|
|
2141
|
+
said.push(`${i + 1}. ${what} \u2014 kept${Number.isInteger(line) && line > 0 ? session.tagSince(before, line) : ""}`);
|
|
2142
|
+
} catch (error) {
|
|
2143
|
+
said.push(`${i + 1}. ${what} \u2014 FAILED: ${error.message}`);
|
|
2144
|
+
if (i < batch.length - 1) said.push(`Steps ${i + 2}\u2013${batch.length} were not done.`);
|
|
2145
|
+
break;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
return text(`${said.join("\n")}${page ? `
|
|
2149
|
+
|
|
2150
|
+
The page after the last kept step:
|
|
2151
|
+
${page}` : ""}`);
|
|
2022
2152
|
}
|
|
2023
2153
|
},
|
|
2154
|
+
{
|
|
2155
|
+
name: "continue_flow",
|
|
2156
|
+
title: "Put back the waiting steps",
|
|
2157
|
+
description: "After start_flow reopened a saved flow and one step no longer worked: once you have done that step again the way the page now needs, put back the saved steps that came after it \u2014 replayed and kept as they pass, up to the next that fails.",
|
|
2158
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
2159
|
+
run: async () => text(await session.continueFlow())
|
|
2160
|
+
},
|
|
2024
2161
|
{
|
|
2025
2162
|
name: "add_check",
|
|
2026
2163
|
title: "Add a check",
|
|
@@ -2336,8 +2473,10 @@ ${c.key ? `${c.key} ` : ""}${c.feature} \u203A ${c.name} (${c.id})`, ...c.busine
|
|
|
2336
2473
|
const { cases, missing } = await service.readCases(projectId, refs);
|
|
2337
2474
|
const parts = cases.map((c) => [
|
|
2338
2475
|
`${c.key ? `${c.key} ` : ""}${c.folder} \u203A ${c.name} (${c.id}) \u2014 ${c.automated ? `automated, rows: ${c.rows.join(" \u2192 ")}` : "not automated yet"}`,
|
|
2476
|
+
...c.manual ? [" MANUAL \u2014 a person runs this one by hand. Do not automate it."] : [],
|
|
2477
|
+
...c.blocked ? [` BLOCKED last time (${c.blocked.kind}${c.blocked.line ? `, at line ${c.blocked.line}` : ""}): ${c.blocked.detail}${c.blocked.needs ? ` \u2014 needs: ${c.blocked.needs}` : ""}. Try again only if that has changed; otherwise report_blocker again.`] : [],
|
|
2339
2478
|
...c.description ? [` ${c.description}`] : [],
|
|
2340
|
-
...c.businessSteps.length ? c.businessSteps.map((s) => ` ${s.keyword} ${s.text}`) : [" (no business steps written)"]
|
|
2479
|
+
...c.businessSteps.length ? c.businessSteps.map((s, i) => ` ${i + 1}. ${s.keyword} ${s.text}`) : [" (no business steps written)"]
|
|
2341
2480
|
].join("\n"));
|
|
2342
2481
|
if (missing.length) parts.push(`Not found: ${missing.join(", ")} \u2014 project_catalog lists the test cases.`);
|
|
2343
2482
|
return text(parts.join("\n\n") || "No test cases found.");
|
|
@@ -2463,6 +2602,39 @@ Next: create_account for each person who signs in this way.`);
|
|
|
2463
2602
|
Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accountId}"}.`);
|
|
2464
2603
|
}
|
|
2465
2604
|
},
|
|
2605
|
+
{
|
|
2606
|
+
name: "report_blocker",
|
|
2607
|
+
title: "Say a test case cannot be automated",
|
|
2608
|
+
description: "Record that a test case could not be automated, and why \u2014 so it is marked Blocked on its page with the line it stopped at and what would unblock it, instead of looking untouched. kind: app-bug (the app itself is broken at that step), needs-person (a captcha, a real card, a code on somebody's own phone \u2014 something only a person has), unclear-step (a business step is unclear or does not match the app), not-found (the screen or feature cannot be reached), stuck (3 failed tries at the same step). Then move on to the next case. Saving the case automated later takes the mark off by itself.",
|
|
2609
|
+
inputSchema: {
|
|
2610
|
+
type: "object",
|
|
2611
|
+
properties: {
|
|
2612
|
+
projectId: { type: "string" },
|
|
2613
|
+
case: { type: "string", description: "The case's id, TC-\u2026 (read_test_cases gives it)." },
|
|
2614
|
+
kind: { type: "string", enum: ["app-bug", "needs-person", "unclear-step", "not-found", "stuck"] },
|
|
2615
|
+
line: { type: "number", description: "The business step it stopped at, counted from 1." },
|
|
2616
|
+
detail: { type: "string", description: "What happened, in a sentence or two: what you did and what the app did." },
|
|
2617
|
+
needs: { type: "string", description: 'What would unblock it, said for a person \u2014 e.g. "a helper for the SMS code", "the step should say which plan to pick".' },
|
|
2618
|
+
platform: { type: "string", enum: ["web", "android", "ios"] },
|
|
2619
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches the case is on." }
|
|
2620
|
+
},
|
|
2621
|
+
required: ["case", "kind", "detail"],
|
|
2622
|
+
additionalProperties: false
|
|
2623
|
+
},
|
|
2624
|
+
run: async (args) => {
|
|
2625
|
+
const projectId = await projectFor(args["projectId"]);
|
|
2626
|
+
const made = await service.reportBlocker(projectId, {
|
|
2627
|
+
case: str(args["case"]) ?? "",
|
|
2628
|
+
kind: str(args["kind"]) ?? "",
|
|
2629
|
+
detail: str(args["detail"]) ?? "",
|
|
2630
|
+
...typeof args["line"] === "number" ? { line: args["line"] } : {},
|
|
2631
|
+
...str(args["needs"]) ? { needs: str(args["needs"]) } : {},
|
|
2632
|
+
...str(args["platform"]) ? { platform: str(args["platform"]) } : {},
|
|
2633
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
2634
|
+
});
|
|
2635
|
+
return text(`Marked ${made.key ?? made.caseId} Blocked \u2014 ${made.label}. A person sees it on the case with what it needs. Move on to the next case.`);
|
|
2636
|
+
}
|
|
2637
|
+
},
|
|
2466
2638
|
{
|
|
2467
2639
|
name: "create_helper",
|
|
2468
2640
|
title: "Make a helper",
|
|
@@ -2496,7 +2668,7 @@ Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accou
|
|
|
2496
2668
|
{
|
|
2497
2669
|
name: "project_brief",
|
|
2498
2670
|
title: "Read the project's memory",
|
|
2499
|
-
description: "The project's own knowledge, in one call: About the product (product.md, and product/<web|android|ios>.md where a platform differs), memory/decisions.md, what each linked codebase folder was learned as and which platform's code it holds, what each platform looked like when explored (explore/<platform>.md), and the list of feature pages and session summaries. Call it
|
|
2671
|
+
description: "The project's own knowledge, in one call: About the product (product.md, and product/<web|android|ios>.md where a platform differs), memory/decisions.md, what each linked codebase folder was learned as and which platform's code it holds, what each platform looked like when explored (explore/<platform>.md), and the list of feature pages and session summaries. Call it at the start when no brief was given with the request (a given brief IS this). With platform and/or features, the brief is the slice for that job: that platform's part and exploration, those features' notes, the code tagged for the platform, and who can sign in.",
|
|
2500
2672
|
inputSchema: { type: "object", properties: { projectId: { type: "string" }, platform: { type: "string", enum: ["web", "android", "ios"], description: "The platform the job is about." }, features: { type: "array", items: { type: "string" }, description: "The features (or case folders) the job is about, by name." } }, additionalProperties: false },
|
|
2501
2673
|
annotations: { readOnlyHint: true },
|
|
2502
2674
|
run: async (args) => {
|
|
@@ -2839,6 +3011,10 @@ async function runMcp(opts) {
|
|
|
2839
3011
|
const connection = connectionFor(service);
|
|
2840
3012
|
const session = connection ? new AuthoringSession(new ServiceClient(connection), opts.headless ? { headless: true } : {}) : null;
|
|
2841
3013
|
let tools = connection && session ? authoringTools(new ServiceClient(connection), session) : authoringTools(null, null);
|
|
3014
|
+
const only = (process.env["FLOWRIKI_TOOLS"] ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
3015
|
+
if (only.length) tools = tools.filter((tool) => only.includes(tool.name));
|
|
3016
|
+
const except = (process.env["FLOWRIKI_TOOLS_EXCEPT"] ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
3017
|
+
if (except.length) tools = tools.filter((tool) => !except.includes(tool.name));
|
|
2842
3018
|
if (!connection) {
|
|
2843
3019
|
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.`;
|
|
2844
3020
|
tools = tools.map((tool) => ({ ...tool, run: async () => ({ content: [{ type: "text", text: why }], isError: true }) }));
|
|
@@ -2959,6 +3135,8 @@ Run \`flowriki-agent --help\`.`);
|
|
|
2959
3135
|
console.log(`
|
|
2960
3136
|
${bold("Approve this computer in your browser")}`);
|
|
2961
3137
|
console.log(` ${url}
|
|
3138
|
+
`);
|
|
3139
|
+
if (note.detail?.["code"]) console.log(` ${bold("Your code:")} ${bold(String(note.detail["code"]))} ${dim("\u2014 the page asks for it")}
|
|
2962
3140
|
`);
|
|
2963
3141
|
if (note.detail?.["canOpen"] === false) {
|
|
2964
3142
|
console.log(dim(" Open the approval link in your browser.\n"));
|
|
@@ -3025,11 +3203,16 @@ Run \`flowriki-agent --help\`.`);
|
|
|
3025
3203
|
setInterval(() => void tellIfBehind(), 6 * 60 * 6e4).unref();
|
|
3026
3204
|
let stopping = false;
|
|
3027
3205
|
const shutdown = async (signal) => {
|
|
3028
|
-
if (stopping)
|
|
3206
|
+
if (stopping) {
|
|
3207
|
+
console.log(dim(" stopping now"));
|
|
3208
|
+
process.exit(130);
|
|
3209
|
+
}
|
|
3029
3210
|
stopping = true;
|
|
3030
3211
|
console.log(dim(`
|
|
3031
|
-
${signal} \u2014 stopping`));
|
|
3032
|
-
|
|
3212
|
+
${signal} \u2014 stopping (press Ctrl-C again to stop at once)`));
|
|
3213
|
+
const force = setTimeout(() => process.exit(1), 1e4);
|
|
3214
|
+
force.unref();
|
|
3215
|
+
await started.stop().catch(() => void 0);
|
|
3033
3216
|
process.exit(0);
|
|
3034
3217
|
};
|
|
3035
3218
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|