@flowriki/agent 0.1.0-beta.4 → 0.1.0-beta.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -7
- package/dist/accname.global.js +1 -0
- package/dist/chunk-ALLCHKXG.js +12075 -0
- package/dist/cli.js +2029 -3
- package/dist/engine.js +1 -1
- package/package.json +4 -2
- package/dist/chunk-7JLRYAQ2.js +0 -7265
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,2024 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
AGENT_VERSION,
|
|
4
|
+
RecordedStepSchema,
|
|
5
|
+
authenticationResolver,
|
|
6
|
+
checkAssertion,
|
|
7
|
+
closeRunSession,
|
|
4
8
|
connect,
|
|
9
|
+
connectionFor,
|
|
5
10
|
disconnect,
|
|
6
11
|
doctor,
|
|
12
|
+
executeStep,
|
|
13
|
+
fieldNames,
|
|
14
|
+
helperRunner,
|
|
15
|
+
mirrorCheckTargets,
|
|
16
|
+
openRunSession,
|
|
7
17
|
prepareBrowsers,
|
|
18
|
+
replayCase,
|
|
19
|
+
replayFlow,
|
|
8
20
|
resolveCheckouts,
|
|
21
|
+
resolveService,
|
|
22
|
+
runtimeValue,
|
|
23
|
+
similarity,
|
|
9
24
|
start,
|
|
10
25
|
status
|
|
11
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-ALLCHKXG.js";
|
|
12
27
|
|
|
13
28
|
// src/cli.ts
|
|
14
29
|
import { parseArgs } from "util";
|
|
30
|
+
|
|
31
|
+
// src/mcp/protocol.ts
|
|
32
|
+
import readline from "readline";
|
|
33
|
+
var PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
34
|
+
async function handle(message, tools, info) {
|
|
35
|
+
const reply = (result) => ({ jsonrpc: "2.0", id: message.id ?? null, result });
|
|
36
|
+
const fail = (code, text2) => ({ jsonrpc: "2.0", id: message.id ?? null, error: { code, message: text2 } });
|
|
37
|
+
if (message.id === void 0 || message.id === null) return null;
|
|
38
|
+
switch (message.method) {
|
|
39
|
+
case "initialize": {
|
|
40
|
+
const asked = String(message.params?.["protocolVersion"] ?? "");
|
|
41
|
+
const version = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
|
|
42
|
+
return reply({
|
|
43
|
+
protocolVersion: version,
|
|
44
|
+
capabilities: { tools: { listChanged: true } },
|
|
45
|
+
serverInfo: { name: info.name, version: info.version },
|
|
46
|
+
instructions: info.instructions
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
case "ping":
|
|
50
|
+
return reply({});
|
|
51
|
+
case "tools/list":
|
|
52
|
+
return reply({
|
|
53
|
+
tools: tools.map(({ name, title, description, inputSchema, annotations }) => ({
|
|
54
|
+
name,
|
|
55
|
+
...title ? { title } : {},
|
|
56
|
+
description,
|
|
57
|
+
inputSchema,
|
|
58
|
+
...annotations ? { annotations } : {}
|
|
59
|
+
}))
|
|
60
|
+
});
|
|
61
|
+
case "tools/call": {
|
|
62
|
+
const name = String(message.params?.["name"] ?? "");
|
|
63
|
+
const tool = tools.find((t) => t.name === name);
|
|
64
|
+
if (!tool) return fail(-32602, `no tool named ${name}`);
|
|
65
|
+
const args = message.params?.["arguments"] ?? {};
|
|
66
|
+
try {
|
|
67
|
+
return reply(await tool.run(args));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return reply({ content: [{ type: "text", text: error.message }], isError: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
return fail(-32601, `method not found: ${message.method}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function serve(tools, info, io = {}) {
|
|
77
|
+
const input = io.input ?? process.stdin;
|
|
78
|
+
const output = io.output ?? process.stdout;
|
|
79
|
+
if (!io.output) {
|
|
80
|
+
console.log = console.info = console.warn = console.debug = (...args) => {
|
|
81
|
+
process.stderr.write(`${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
82
|
+
`);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
86
|
+
const pending = /* @__PURE__ */ new Set();
|
|
87
|
+
lines.on("line", (line) => {
|
|
88
|
+
if (!line.trim()) return;
|
|
89
|
+
let message;
|
|
90
|
+
try {
|
|
91
|
+
message = JSON.parse(line);
|
|
92
|
+
} catch {
|
|
93
|
+
output.write(`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } })}
|
|
94
|
+
`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const task = handle(message, tools, info).then((answer) => {
|
|
98
|
+
if (answer) output.write(`${JSON.stringify(answer)}
|
|
99
|
+
`);
|
|
100
|
+
if (message.method === "notifications/initialized") {
|
|
101
|
+
output.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}
|
|
102
|
+
`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
pending.add(task);
|
|
106
|
+
void task.finally(() => pending.delete(task));
|
|
107
|
+
});
|
|
108
|
+
return new Promise((resolve) => lines.on("close", () => void Promise.allSettled([...pending]).then(() => resolve())));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/mcp/service-client.ts
|
|
112
|
+
var ServiceClient = class {
|
|
113
|
+
constructor(connection, fetcher = fetch) {
|
|
114
|
+
this.connection = connection;
|
|
115
|
+
this.fetcher = fetcher;
|
|
116
|
+
}
|
|
117
|
+
connection;
|
|
118
|
+
fetcher;
|
|
119
|
+
get origin() {
|
|
120
|
+
return this.connection.service;
|
|
121
|
+
}
|
|
122
|
+
async call(path, init = {}) {
|
|
123
|
+
const res = await this.fetcher(`${this.connection.service}${path}`, {
|
|
124
|
+
...init,
|
|
125
|
+
headers: {
|
|
126
|
+
"x-flowriki-device": this.connection.deviceId,
|
|
127
|
+
"x-flowriki-device-secret": this.connection.secret,
|
|
128
|
+
"content-type": "application/json"
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const text2 = await res.text();
|
|
132
|
+
let body = {};
|
|
133
|
+
try {
|
|
134
|
+
body = text2 ? JSON.parse(text2) : {};
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
if (res.status === 404 && !("error" in body)) {
|
|
139
|
+
throw new Error("the Flowriki service does not offer this yet \u2014 it may need updating");
|
|
140
|
+
}
|
|
141
|
+
throw new Error(String(body["error"] ?? `the service answered ${res.status}`));
|
|
142
|
+
}
|
|
143
|
+
return body;
|
|
144
|
+
}
|
|
145
|
+
projects() {
|
|
146
|
+
return this.call("/api/agent/projects");
|
|
147
|
+
}
|
|
148
|
+
createProject(name, repository, baseUrl) {
|
|
149
|
+
return this.call("/api/agent/projects", {
|
|
150
|
+
method: "POST",
|
|
151
|
+
body: JSON.stringify({ name, repository, ...baseUrl ? { baseUrl } : {} })
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catalog(projectId) {
|
|
155
|
+
return this.call(`/api/agent/catalog?projectId=${encodeURIComponent(projectId)}`);
|
|
156
|
+
}
|
|
157
|
+
prepare(projectId, steps, signIn) {
|
|
158
|
+
return this.call(`/api/agent/prepare?projectId=${encodeURIComponent(projectId)}`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
body: JSON.stringify({ steps, ...signIn ? { signIn } : {} })
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** This person's open branches that Claude wrote on this project. */
|
|
164
|
+
agentChanges(projectId) {
|
|
165
|
+
return this.call(
|
|
166
|
+
`/api/agent/changes?projectId=${encodeURIComponent(projectId)}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
/** Delete one — refused by the service unless it is all Claude's own,
|
|
170
|
+
* unedited, unpublished work. */
|
|
171
|
+
discardChange(projectId, changeId) {
|
|
172
|
+
return this.call(
|
|
173
|
+
`/api/agent/changes/${encodeURIComponent(changeId)}?projectId=${encodeURIComponent(projectId)}`,
|
|
174
|
+
{ method: "DELETE" }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
saveFlow(projectId, title, flow, where = {}, secretValues = {}) {
|
|
178
|
+
return this.call(
|
|
179
|
+
`/api/agent/flows?projectId=${encodeURIComponent(projectId)}`,
|
|
180
|
+
{ method: "POST", body: JSON.stringify({ title, flow, ...where, secretValues }) }
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
/** A sign-in made from a recorded login flow. */
|
|
184
|
+
createSignIn(projectId, input) {
|
|
185
|
+
return this.call(
|
|
186
|
+
`/api/agent/sign-ins?projectId=${encodeURIComponent(projectId)}`,
|
|
187
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
/** An account for a sign-in; secret values are sealed by the service and
|
|
191
|
+
* never come back. */
|
|
192
|
+
createAccount(projectId, input) {
|
|
193
|
+
return this.call(
|
|
194
|
+
`/api/agent/accounts?projectId=${encodeURIComponent(projectId)}`,
|
|
195
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
/** A helper, its secrets sealed by the service. */
|
|
199
|
+
createHelper(projectId, input) {
|
|
200
|
+
return this.call(
|
|
201
|
+
`/api/agent/helpers?projectId=${encodeURIComponent(projectId)}`,
|
|
202
|
+
{ method: "POST", body: JSON.stringify(input) }
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
/** What a new flow starts after: the flows, in run order, and their steps. */
|
|
206
|
+
flowsAfter(projectId, after) {
|
|
207
|
+
return this.call(
|
|
208
|
+
`/api/agent/flows/after?projectId=${encodeURIComponent(projectId)}`,
|
|
209
|
+
{ method: "POST", body: JSON.stringify({ after }) }
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
/** Build a test case from flows — as a dry run, or saved. */
|
|
213
|
+
buildCase(projectId, spec, dryRun) {
|
|
214
|
+
return this.call(`/api/agent/cases?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify({ ...spec, dryRun }) });
|
|
215
|
+
}
|
|
216
|
+
/** The test library, on Claude's working branch when it has one. */
|
|
217
|
+
library(projectId, opts = {}) {
|
|
218
|
+
const query = new URLSearchParams({ projectId, ...opts.q ? { q: opts.q } : {}, ...opts.featureId ? { featureId: opts.featureId } : {} });
|
|
219
|
+
return this.call(`/api/agent/library?${query}`);
|
|
220
|
+
}
|
|
221
|
+
/** Test cases written, edited or linked in one commit; duplicates skipped. */
|
|
222
|
+
saveLibrary(projectId, input) {
|
|
223
|
+
return this.call(`/api/agent/library?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify(input) });
|
|
224
|
+
}
|
|
225
|
+
// ── Manual cycles and bugs: the dashboard's own routes, behind this
|
|
226
|
+
// machine's credential, so the assistant can do what a person can. ──────
|
|
227
|
+
scoped(path, projectId, extra = {}) {
|
|
228
|
+
const query = new URLSearchParams({ projectId, ...extra });
|
|
229
|
+
return `/api/agent${path}?${query}`;
|
|
230
|
+
}
|
|
231
|
+
cycles(projectId) {
|
|
232
|
+
return this.call(this.scoped("/cycles", projectId));
|
|
233
|
+
}
|
|
234
|
+
cycle(projectId, id) {
|
|
235
|
+
return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}`, projectId));
|
|
236
|
+
}
|
|
237
|
+
createCycle(projectId, body, changeId) {
|
|
238
|
+
return this.call(this.scoped("/cycles", projectId, changeId ? { changeId } : {}), { method: "POST", body: JSON.stringify(body) });
|
|
239
|
+
}
|
|
240
|
+
updateCycle(projectId, id, body, changeId) {
|
|
241
|
+
return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}`, projectId, changeId ? { changeId } : {}), { method: "PATCH", body: JSON.stringify(body) });
|
|
242
|
+
}
|
|
243
|
+
deleteCycle(projectId, id) {
|
|
244
|
+
return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}`, projectId), { method: "DELETE" });
|
|
245
|
+
}
|
|
246
|
+
recordResult(projectId, id, caseId, body) {
|
|
247
|
+
return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}/results/${encodeURIComponent(caseId)}`, projectId), { method: "PUT", body: JSON.stringify(body) });
|
|
248
|
+
}
|
|
249
|
+
addEvidenceLink(projectId, id, caseId, body) {
|
|
250
|
+
return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}/results/${encodeURIComponent(caseId)}/evidence`, projectId), {
|
|
251
|
+
method: "POST",
|
|
252
|
+
body: JSON.stringify({ kind: "link", ...body })
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
bugs(projectId) {
|
|
256
|
+
return this.call(this.scoped("/bugs", projectId));
|
|
257
|
+
}
|
|
258
|
+
createBug(projectId, body) {
|
|
259
|
+
return this.call(this.scoped("/bugs", projectId), { method: "POST", body: JSON.stringify(body) });
|
|
260
|
+
}
|
|
261
|
+
updateBug(projectId, id, body) {
|
|
262
|
+
return this.call(this.scoped(`/bugs/${encodeURIComponent(id)}`, projectId), { method: "PATCH", body: JSON.stringify(body) });
|
|
263
|
+
}
|
|
264
|
+
// ── Knowledge, sources, requirements ──────────────────────
|
|
265
|
+
knowledgePages(projectId) {
|
|
266
|
+
return this.call(this.scoped("/knowledge", projectId));
|
|
267
|
+
}
|
|
268
|
+
knowledgePage(projectId, path) {
|
|
269
|
+
return this.call(this.scoped("/knowledge/page", projectId, { path }));
|
|
270
|
+
}
|
|
271
|
+
writeKnowledge(projectId, body) {
|
|
272
|
+
return this.call(`/api/agent/knowledge?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify(body) });
|
|
273
|
+
}
|
|
274
|
+
search(projectId, q) {
|
|
275
|
+
return this.call(this.scoped("/search", projectId, { q }));
|
|
276
|
+
}
|
|
277
|
+
sources(projectId) {
|
|
278
|
+
return this.call(this.scoped("/sources", projectId));
|
|
279
|
+
}
|
|
280
|
+
source(projectId, id) {
|
|
281
|
+
return this.call(this.scoped(`/sources/${encodeURIComponent(id)}`, projectId));
|
|
282
|
+
}
|
|
283
|
+
addSource(projectId, body, sourceId) {
|
|
284
|
+
return this.call(
|
|
285
|
+
this.scoped(sourceId ? `/sources/${encodeURIComponent(sourceId)}/versions` : "/sources", projectId),
|
|
286
|
+
{ method: "POST", body: JSON.stringify(body) }
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
requirements(projectId, opts = {}) {
|
|
290
|
+
return this.call(
|
|
291
|
+
this.scoped("/requirements", projectId, { ...opts.sourceId ? { sourceId: opts.sourceId } : {}, ...opts.state ? { state: opts.state } : {} })
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
deleteSource(projectId, id) {
|
|
295
|
+
return this.call(this.scoped(`/sources/${encodeURIComponent(id)}`, projectId), { method: "DELETE" });
|
|
296
|
+
}
|
|
297
|
+
updateRequirement(projectId, id, body) {
|
|
298
|
+
return this.call(this.scoped(`/requirements/${encodeURIComponent(id)}`, projectId), { method: "PATCH", body: JSON.stringify(body) });
|
|
299
|
+
}
|
|
300
|
+
/** Gather Claude's open branches here into one. */
|
|
301
|
+
gatherChanges(projectId, opts = {}) {
|
|
302
|
+
return this.call(
|
|
303
|
+
`/api/agent/changes/gather?projectId=${encodeURIComponent(projectId)}`,
|
|
304
|
+
{ method: "POST", body: JSON.stringify(opts) }
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// src/mcp/session.ts
|
|
310
|
+
var MAX_TREE = 14e3;
|
|
311
|
+
var MAX_STEP_VIEW = 6e3;
|
|
312
|
+
function topLevelBlocks(tree) {
|
|
313
|
+
const blocks = [];
|
|
314
|
+
for (const line of tree.split("\n")) {
|
|
315
|
+
if (line.startsWith("- ") || blocks.length === 0) blocks.push([line]);
|
|
316
|
+
else blocks[blocks.length - 1].push(line);
|
|
317
|
+
}
|
|
318
|
+
const named = (b, kinds) => kinds.some((k) => b[0].startsWith(`- ${k}`));
|
|
319
|
+
return {
|
|
320
|
+
dialogs: blocks.filter((b) => named(b, ["dialog", "alertdialog"])).map((b) => b.join("\n")),
|
|
321
|
+
navigation: blocks.find((b) => named(b, ["navigation", "banner"]))?.join("\n") ?? null
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
var STEP_TIMEOUT_MS = 1e4;
|
|
325
|
+
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;
|
|
326
|
+
var TYPED = "typed_";
|
|
327
|
+
var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
|
|
328
|
+
var ACTIONS = ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"];
|
|
329
|
+
var isAction = (value) => ACTIONS.includes(String(value));
|
|
330
|
+
var LANDMARKS = /* @__PURE__ */ new Set(["main", "navigation", "banner", "contentinfo", "complementary", "search", "form", "dialog", "alertdialog", "region", "group", "list", "table", "menu", "toolbar", "tablist"]);
|
|
331
|
+
function scopeOf(within) {
|
|
332
|
+
const said = within?.trim() ?? "";
|
|
333
|
+
if (!said) return { scope: "" };
|
|
334
|
+
return LANDMARKS.has(said.toLowerCase()) ? { scope: "", scopeRole: said.toLowerCase() } : { scope: said };
|
|
335
|
+
}
|
|
336
|
+
function asLabelled(target, fields) {
|
|
337
|
+
const field = fields.find((f) => f.role === target.role && (f.label === target.name || f.hint === target.name));
|
|
338
|
+
if (!field) return target;
|
|
339
|
+
return {
|
|
340
|
+
...target,
|
|
341
|
+
name: field.label,
|
|
342
|
+
nameFrom: "adjacent-label",
|
|
343
|
+
...field.hintFrom === "name" ? {} : { candidates: [{ strategy: field.hintFrom, value: field.hint }] }
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function labelTree(tree, fields) {
|
|
347
|
+
let out = tree;
|
|
348
|
+
for (const f of fields) out = out.split(`- ${f.role} ${JSON.stringify(f.hint)}`).join(`- ${f.role} ${JSON.stringify(f.label)}`);
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
function numberDuplicates(tree) {
|
|
352
|
+
const line = /^(\s*- )([\w-]+) "((?:[^"\\]|\\.)*)"(.*)$/;
|
|
353
|
+
const totals = /* @__PURE__ */ new Map();
|
|
354
|
+
const lines = tree.split("\n");
|
|
355
|
+
for (const l of lines) {
|
|
356
|
+
const m = line.exec(l);
|
|
357
|
+
if (m) totals.set(`${m[2]}|${m[3]}`, (totals.get(`${m[2]}|${m[3]}`) ?? 0) + 1);
|
|
358
|
+
}
|
|
359
|
+
const seen = /* @__PURE__ */ new Map();
|
|
360
|
+
return lines.map((l) => {
|
|
361
|
+
const m = line.exec(l);
|
|
362
|
+
if (!m) return l;
|
|
363
|
+
const key = `${m[2]}|${m[3]}`;
|
|
364
|
+
const total = totals.get(key) ?? 1;
|
|
365
|
+
if (total < 2) return l;
|
|
366
|
+
const at = seen.get(key) ?? 0;
|
|
367
|
+
seen.set(key, at + 1);
|
|
368
|
+
return `${l} [nth ${at} of ${total} with this name]`;
|
|
369
|
+
}).join("\n");
|
|
370
|
+
}
|
|
371
|
+
function describeRows(rows) {
|
|
372
|
+
return rows.map((r) => [
|
|
373
|
+
`Row ${r.row}: "${r.flow}" (${r.browser})`,
|
|
374
|
+
...r.checks.length ? r.checks.map((c) => ` ${c.on ? "[on] " : "[off]"} ${c.n}. ${c.check}`) : [" (no checks in this flow)"]
|
|
375
|
+
].join("\n")).join("\n");
|
|
376
|
+
}
|
|
377
|
+
function describe(step) {
|
|
378
|
+
const target = (step.name ? `${step.role || "element"} "${step.name}"` : step.role || "") + (step.nth !== void 0 ? ` (nth ${step.nth})` : "");
|
|
379
|
+
const where = step.scope ? ` in "${step.scope}"` : step.scopeRole ? ` in the ${step.scopeRole}` : "";
|
|
380
|
+
switch (step.type) {
|
|
381
|
+
case "fill":
|
|
382
|
+
return step.secret ? `type a secret (\u2022\u2022\u2022\u2022\u2022\u2022) into ${target}${where}` : `type "${step.value}" into ${target}${where}`;
|
|
383
|
+
case "select":
|
|
384
|
+
return `choose "${step.value}" in ${target}${where}`;
|
|
385
|
+
case "check":
|
|
386
|
+
return `${step.value === "false" ? "untick" : "tick"} ${target}${where}`;
|
|
387
|
+
case "press":
|
|
388
|
+
return `press ${step.value}${target ? ` in ${target}` : ""}`;
|
|
389
|
+
case "navigate":
|
|
390
|
+
return `go to ${step.value}`;
|
|
391
|
+
case "back":
|
|
392
|
+
case "forward":
|
|
393
|
+
return `browser ${step.type}`;
|
|
394
|
+
case "assert":
|
|
395
|
+
return `check ${step.proposals.map((p) => `${p.kind}${p.op && p.op !== "equals" ? ` ${p.op}` : ""} "${String(p.value)}"${p.target?.name ? ` on ${p.target.role} "${p.target.name}"` : ""}`).join(", ")}`;
|
|
396
|
+
case "useHelper":
|
|
397
|
+
return `use helper ${step.name || step.helper?.helperId} \u2192 {{${step.helper?.saveAs}}}${step.helper?.then === "open" ? ", then open it" : ""}`;
|
|
398
|
+
default:
|
|
399
|
+
return `${step.type} ${target}${where}`;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
var AuthoringSession = class {
|
|
403
|
+
constructor(service, opts = {}) {
|
|
404
|
+
this.service = service;
|
|
405
|
+
this.opts = opts;
|
|
406
|
+
}
|
|
407
|
+
service;
|
|
408
|
+
opts;
|
|
409
|
+
run = null;
|
|
410
|
+
page = null;
|
|
411
|
+
values = /* @__PURE__ */ new Map();
|
|
412
|
+
resolvers = null;
|
|
413
|
+
lastTree = null;
|
|
414
|
+
/** Values typed into secret fields, by their `typed:` reference. */
|
|
415
|
+
typed = /* @__PURE__ */ new Map();
|
|
416
|
+
draft = null;
|
|
417
|
+
catalog = null;
|
|
418
|
+
/** Text going back to Claude, with every secret typed here masked — a
|
|
419
|
+
* password box's value is in the page's tree, and a careless form puts it
|
|
420
|
+
* in the address. */
|
|
421
|
+
redact(text2) {
|
|
422
|
+
let out = text2;
|
|
423
|
+
for (const value of this.typed.values()) {
|
|
424
|
+
if (value.length < 3) continue;
|
|
425
|
+
out = out.split(value).join("\u2022\u2022\u2022\u2022\u2022\u2022");
|
|
426
|
+
const encoded = encodeURIComponent(value);
|
|
427
|
+
if (encoded !== value) out = out.split(encoded).join("\u2022\u2022\u2022\u2022\u2022\u2022");
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
get open() {
|
|
432
|
+
return this.run !== null;
|
|
433
|
+
}
|
|
434
|
+
requireOpen() {
|
|
435
|
+
if (!this.run || !this.page || !this.draft) throw new Error("no flow is being written \u2014 call start_flow first");
|
|
436
|
+
return { page: this.page, draft: this.draft, run: this.run };
|
|
437
|
+
}
|
|
438
|
+
/** What the steps so far, plus any about to run, need from the service:
|
|
439
|
+
* sign-ins, helpers, and their secret values. Asked again whenever a new
|
|
440
|
+
* helper or sign-in appears, never kept beyond this process. */
|
|
441
|
+
async prepare(extra = []) {
|
|
442
|
+
const draft = this.draft;
|
|
443
|
+
const prepared = await this.service.prepare(draft.projectId, withoutTyped([...draft.preparation ?? [], ...draft.steps, ...extra]), draft.signIn ?? null);
|
|
444
|
+
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
445
|
+
this.resolvers = {
|
|
446
|
+
resolveAuth: authenticationResolver(prepared.authentications),
|
|
447
|
+
resolveSecret: (ref) => this.typed.get(ref) ?? prepared.secrets[ref],
|
|
448
|
+
...runHelper ? { runHelper } : {}
|
|
449
|
+
};
|
|
450
|
+
return this.resolvers;
|
|
451
|
+
}
|
|
452
|
+
baseUrl() {
|
|
453
|
+
const base = this.catalog?.baseUrl ?? "";
|
|
454
|
+
if (!base) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
|
|
455
|
+
return base;
|
|
456
|
+
}
|
|
457
|
+
/** Open a browser for a new flow, signed in first if asked. */
|
|
458
|
+
async start(input, catalog) {
|
|
459
|
+
await this.close();
|
|
460
|
+
this.catalog = catalog;
|
|
461
|
+
this.draft = { ...input, steps: [] };
|
|
462
|
+
this.values = /* @__PURE__ */ new Map();
|
|
463
|
+
this.typed = /* @__PURE__ */ new Map();
|
|
464
|
+
this.lastTree = null;
|
|
465
|
+
const base = this.baseUrl();
|
|
466
|
+
this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
|
|
467
|
+
this.page = this.run.page;
|
|
468
|
+
if (input.signIn) {
|
|
469
|
+
const resolvers = await this.prepare();
|
|
470
|
+
const signIn = {
|
|
471
|
+
id: "sign-in",
|
|
472
|
+
type: "signIn",
|
|
473
|
+
auth: { ...input.signIn, from: "loginPage" },
|
|
474
|
+
role: "",
|
|
475
|
+
name: "Sign in",
|
|
476
|
+
scope: "",
|
|
477
|
+
value: "",
|
|
478
|
+
secret: false,
|
|
479
|
+
legacySelector: "",
|
|
480
|
+
proposals: []
|
|
481
|
+
};
|
|
482
|
+
this.page = await executeStep(this.page, signIn, {
|
|
483
|
+
pages: this.run.pages,
|
|
484
|
+
...resolvers,
|
|
485
|
+
values: this.values,
|
|
486
|
+
baseUrl: base,
|
|
487
|
+
timeoutMs: 2e4,
|
|
488
|
+
navigationTimeoutMs: 45e3
|
|
489
|
+
});
|
|
490
|
+
} else {
|
|
491
|
+
await this.page.goto(new URL(input.startPath || "/", base).href, { waitUntil: "domcontentloaded", timeout: 45e3 });
|
|
492
|
+
}
|
|
493
|
+
if (input.preparation?.length) {
|
|
494
|
+
const resolvers = await this.prepare(input.preparation);
|
|
495
|
+
for (const step of input.preparation) {
|
|
496
|
+
try {
|
|
497
|
+
this.page = await executeStep(this.page, step, {
|
|
498
|
+
pages: this.run.pages,
|
|
499
|
+
...resolvers,
|
|
500
|
+
values: this.values,
|
|
501
|
+
baseUrl: base,
|
|
502
|
+
timeoutMs: 2e4,
|
|
503
|
+
navigationTimeoutMs: 45e3
|
|
504
|
+
});
|
|
505
|
+
} catch (error) {
|
|
506
|
+
throw new Error(`The flows this one starts after did not finish, at "${step.name || step.type}": ${error.message.split("\n")[0]}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
await this.settle();
|
|
510
|
+
}
|
|
511
|
+
return this.snapshot();
|
|
512
|
+
}
|
|
513
|
+
/** The page as Claude reads it: where it is, and what is on it by role and name. */
|
|
514
|
+
async snapshot() {
|
|
515
|
+
return this.view(true);
|
|
516
|
+
}
|
|
517
|
+
/** WHAT CHANGED, after a step — not the whole page again.
|
|
518
|
+
*
|
|
519
|
+
* Every step used to answer with the full tree, which on a long storefront
|
|
520
|
+
* is twenty thousand characters repeated forty times to say "the box now
|
|
521
|
+
* has text in it". So a step answers with what it is about: an open dialog
|
|
522
|
+
* (with the navigation, which says who is signed in), or the lines that
|
|
523
|
+
* appeared and disappeared — and only after a real navigation, when most of
|
|
524
|
+
* the page is new, the page itself, capped. read_page is always the whole
|
|
525
|
+
* thing. */
|
|
526
|
+
async view(full) {
|
|
527
|
+
const { page, draft } = this.requireOpen();
|
|
528
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
529
|
+
const tree = numberDuplicates(labelTree(
|
|
530
|
+
await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
|
|
531
|
+
await fieldNames(page)
|
|
532
|
+
));
|
|
533
|
+
const title = await page.title().catch(() => "");
|
|
534
|
+
const head = `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
|
|
535
|
+
Steps so far: ${draft.steps.length}
|
|
536
|
+
|
|
537
|
+
`;
|
|
538
|
+
const before = this.lastTree;
|
|
539
|
+
this.lastTree = tree;
|
|
540
|
+
const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
|
|
541
|
+
\u2026 (cut \u2014 read_page shows the whole page)` : t;
|
|
542
|
+
if (full || before === null) return head + cap(tree, MAX_TREE);
|
|
543
|
+
const { dialogs, navigation } = topLevelBlocks(tree);
|
|
544
|
+
if (dialogs.length) {
|
|
545
|
+
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)";
|
|
546
|
+
}
|
|
547
|
+
const was = new Set(before.split("\n"));
|
|
548
|
+
const now = new Set(tree.split("\n"));
|
|
549
|
+
const added = tree.split("\n").filter((l) => !was.has(l));
|
|
550
|
+
const removed = before.split("\n").filter((l) => !now.has(l));
|
|
551
|
+
if (added.length === 0 && removed.length === 0) return head + "(the page did not change \u2014 read_page shows it)";
|
|
552
|
+
if (added.length + removed.length > 120) return head + cap(tree, MAX_STEP_VIEW);
|
|
553
|
+
return head + [
|
|
554
|
+
...navigation ? [navigation, ""] : [],
|
|
555
|
+
added.length ? `Appeared:
|
|
556
|
+
${added.join("\n")}` : "",
|
|
557
|
+
removed.length ? `Gone:
|
|
558
|
+
${removed.join("\n")}` : "",
|
|
559
|
+
"(changes since the last step \u2014 read_page shows the whole page)"
|
|
560
|
+
].filter(Boolean).join("\n");
|
|
561
|
+
}
|
|
562
|
+
/** Do one thing on the page, and keep it as a step if it worked. */
|
|
563
|
+
async act(input) {
|
|
564
|
+
const { page, draft, run } = this.requireOpen();
|
|
565
|
+
const navigating = input.action === "navigate" || input.action === "back" || input.action === "forward";
|
|
566
|
+
if (!navigating && input.action !== "press" && !(input.role && input.name)) {
|
|
567
|
+
throw new Error(`${input.action} needs the element's role and name, exactly as read_page shows them`);
|
|
568
|
+
}
|
|
569
|
+
if ((input.action === "fill" || input.action === "select" || input.action === "press" || input.action === "navigate") && input.value === void 0) {
|
|
570
|
+
throw new Error(`${input.action} needs a value`);
|
|
571
|
+
}
|
|
572
|
+
const target = navigating ? { role: "", name: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
|
|
573
|
+
const secret = input.action === "fill" && (input.secret ?? (SECRET_WORDS.test(input.name ?? "") || SECRET_WORDS.test(target.name)));
|
|
574
|
+
const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
|
|
575
|
+
if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
|
|
576
|
+
const step = RecordedStepSchema.parse({
|
|
577
|
+
id,
|
|
578
|
+
type: input.action,
|
|
579
|
+
...target,
|
|
580
|
+
...scopeOf(input.within),
|
|
581
|
+
...input.nth !== void 0 ? { nth: input.nth } : {},
|
|
582
|
+
value: secret ? "" : input.action === "check" ? input.value === "false" ? "false" : "true" : input.value ?? "",
|
|
583
|
+
secret,
|
|
584
|
+
...secret ? { secretRef: `${TYPED}${id}` } : {},
|
|
585
|
+
legacySelector: "",
|
|
586
|
+
proposals: [],
|
|
587
|
+
// A path is the application's own, so the step replays on whichever
|
|
588
|
+
// environment the run is pointed at rather than the one written on.
|
|
589
|
+
...input.action === "navigate" && input.value?.startsWith("/") ? { navigationBase: "application" } : {}
|
|
590
|
+
});
|
|
591
|
+
try {
|
|
592
|
+
this.page = await executeStep(page, step, {
|
|
593
|
+
pages: run.pages,
|
|
594
|
+
values: this.values,
|
|
595
|
+
baseUrl: this.baseUrl(),
|
|
596
|
+
...this.resolvers ?? {},
|
|
597
|
+
resolveSecret: (ref) => this.typed.get(ref) ?? this.resolvers?.resolveSecret(ref),
|
|
598
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
599
|
+
navigationTimeoutMs: 45e3
|
|
600
|
+
});
|
|
601
|
+
} catch (error) {
|
|
602
|
+
if (secret) this.typed.delete(`${TYPED}${id}`);
|
|
603
|
+
const said = error.message.split("\n")[0] ?? "";
|
|
604
|
+
const several = /strict mode violation|resolved to \d+ elements|\d+ elements match/i.test(said) ? "\nSeveral elements share this role and name. Pass nth (0 = the first, in the order read_page lists them \u2014 each shared name is marked [nth N of M]) to say which one." : "";
|
|
605
|
+
throw new Error(`That did not work, and was not kept: ${said}${several}
|
|
606
|
+
|
|
607
|
+
${await this.view(false)}`);
|
|
608
|
+
}
|
|
609
|
+
await this.settle();
|
|
610
|
+
draft.steps.push(step);
|
|
611
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}.
|
|
612
|
+
|
|
613
|
+
${await this.view(false)}`;
|
|
614
|
+
}
|
|
615
|
+
/** Check something about the page now, and keep the check if it holds. */
|
|
616
|
+
async check(input) {
|
|
617
|
+
const { page, draft } = this.requireOpen();
|
|
618
|
+
if ((input.kind === "heading" || input.kind === "message" || input.kind === "screen") && input.op && input.op !== "equals" && !input.role) {
|
|
619
|
+
throw new Error(`a ${input.kind} check without role and name matches the whole ${input.kind} \u2014 give its full text with op equals, or name the element (role + name) and use kind "text" with op ${input.op}`);
|
|
620
|
+
}
|
|
621
|
+
const assertion = RecordedStepSchema.shape.proposals.element.parse({
|
|
622
|
+
kind: input.kind,
|
|
623
|
+
value: input.value,
|
|
624
|
+
enabled: true,
|
|
625
|
+
...input.op && input.op !== "equals" ? { op: input.op } : {},
|
|
626
|
+
...input.role ? { target: { ...asLabelled({ role: input.role, name: input.name ?? "" }, await fieldNames(page)), ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} } } : {}
|
|
627
|
+
});
|
|
628
|
+
const filled = (text2) => text2.replace(/\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g, (_, name) => runtimeValue(this.values, name));
|
|
629
|
+
const expected = Array.isArray(assertion.value) ? assertion.value.map(filled) : filled(assertion.value);
|
|
630
|
+
try {
|
|
631
|
+
await checkAssertion(page, assertion.kind, expected, 5e3, { ...assertion, value: expected });
|
|
632
|
+
} catch (error) {
|
|
633
|
+
throw new Error(`That is not true of the page right now, so it was not kept: ${error.message.split("\n")[0]}`);
|
|
634
|
+
}
|
|
635
|
+
const step = RecordedStepSchema.parse(mirrorCheckTargets([{
|
|
636
|
+
id: `c${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
637
|
+
type: "assert",
|
|
638
|
+
role: "",
|
|
639
|
+
name: "",
|
|
640
|
+
scope: "",
|
|
641
|
+
value: "",
|
|
642
|
+
secret: false,
|
|
643
|
+
legacySelector: "",
|
|
644
|
+
proposals: [assertion]
|
|
645
|
+
}])[0]);
|
|
646
|
+
draft.steps.push(step);
|
|
647
|
+
return `Step ${draft.steps.length} kept: ${describe(step)}.`;
|
|
648
|
+
}
|
|
649
|
+
/** Get a value from outside the page — an email code, a 2FA code, a text,
|
|
650
|
+
* an API reply — and keep the step that gets it. */
|
|
651
|
+
async useHelper(input) {
|
|
652
|
+
const { page, draft, run } = this.requireOpen();
|
|
653
|
+
const helper = this.catalog?.helpers.find((h) => h.id === input.helperId || h.name.toLowerCase() === input.helperId.toLowerCase());
|
|
654
|
+
if (!helper) throw new Error(`no helper "${input.helperId}" \u2014 project_catalog lists them; one can be made on the Helpers screen`);
|
|
655
|
+
const missing = helper.inputs.filter((name) => !(input.inputs ?? {})[name]);
|
|
656
|
+
if (missing.length) throw new Error(`${helper.name} needs ${missing.map((n) => `{{${n}}}`).join(", ")} in inputs`);
|
|
657
|
+
const step = RecordedStepSchema.parse({
|
|
658
|
+
id: `h${draft.steps.length + 1}_${Date.now().toString(36)}`,
|
|
659
|
+
type: "useHelper",
|
|
660
|
+
role: "",
|
|
661
|
+
name: helper.name,
|
|
662
|
+
scope: "",
|
|
663
|
+
value: "",
|
|
664
|
+
secret: false,
|
|
665
|
+
legacySelector: "",
|
|
666
|
+
proposals: [],
|
|
667
|
+
helper: { helperId: helper.id, inputs: input.inputs ?? {}, saveAs: input.saveAs || (helper.kind === "mailbox" ? "code" : "value"), then: input.then ?? "remember" }
|
|
668
|
+
});
|
|
669
|
+
const resolvers = await this.prepare([step]);
|
|
670
|
+
let got = { value: "", detail: "" };
|
|
671
|
+
try {
|
|
672
|
+
this.page = await executeStep(page, step, {
|
|
673
|
+
pages: run.pages,
|
|
674
|
+
values: this.values,
|
|
675
|
+
baseUrl: this.baseUrl(),
|
|
676
|
+
...resolvers,
|
|
677
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
678
|
+
navigationTimeoutMs: 45e3,
|
|
679
|
+
onHelper: (answer) => {
|
|
680
|
+
got = answer;
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
} catch (error) {
|
|
684
|
+
throw new Error(`The helper did not get a value, and the step was not kept: ${error.message}`);
|
|
685
|
+
}
|
|
686
|
+
await this.settle();
|
|
687
|
+
draft.steps.push(step);
|
|
688
|
+
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" ? `
|
|
689
|
+
|
|
690
|
+
${await this.view(false)}` : "");
|
|
691
|
+
}
|
|
692
|
+
list() {
|
|
693
|
+
const draft = this.draft;
|
|
694
|
+
if (!draft) return "No flow is being written.";
|
|
695
|
+
const head = `"${draft.name}"${draft.signIn ? ", signed in first" : ""}, starting at ${draft.startPath}`;
|
|
696
|
+
if (draft.steps.length === 0) return `${head}. No steps yet.`;
|
|
697
|
+
return `${head}:
|
|
698
|
+
${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
|
|
699
|
+
}
|
|
700
|
+
/** Drop a step from the draft. The browser is not wound back — replay to
|
|
701
|
+
* see the flow as it now stands. */
|
|
702
|
+
remove(number) {
|
|
703
|
+
const { draft } = this.requireOpen();
|
|
704
|
+
if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
|
|
705
|
+
const [gone] = draft.steps.splice(number - 1, 1);
|
|
706
|
+
return `Removed step ${number} (${describe(gone)}). The browser was not undone \u2014 replay_flow shows the flow as it now stands.
|
|
707
|
+
|
|
708
|
+
${this.list()}`;
|
|
709
|
+
}
|
|
710
|
+
/** Run the whole draft from the start, in a browser of its own, the way a
|
|
711
|
+
* run will — the proof the steps work together, not just one at a time. */
|
|
712
|
+
async replay() {
|
|
713
|
+
const { draft } = this.requireOpen();
|
|
714
|
+
if (draft.steps.length === 0) throw new Error("there are no steps to replay yet");
|
|
715
|
+
const resolvers = await this.prepare();
|
|
716
|
+
const steps = [
|
|
717
|
+
...draft.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: { ...draft.signIn, from: "loginPage" }, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
|
|
718
|
+
...draft.preparation ?? [],
|
|
719
|
+
...draft.steps
|
|
720
|
+
];
|
|
721
|
+
const outcome = await replayFlow({ steps, baseUrl: this.baseUrl(), startPath: draft.startPath, headless: true, ...resolvers });
|
|
722
|
+
const lines = outcome.steps.map((s) => `${s.status === "passed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\xB7"} ${s.name || s.type}${s.status === "failed" && s.detail ? ` \u2014 ${s.detail}` : ""}`);
|
|
723
|
+
return `${outcome.status === "passed" ? "The whole flow passed from the start." : `The flow ${outcome.status}${outcome.error ? `: ${outcome.error}` : ""}.`}
|
|
724
|
+
${lines.join("\n")}`;
|
|
725
|
+
}
|
|
726
|
+
/** Save the draft as a flow on a new branch, for a person to review. */
|
|
727
|
+
async save(title, where = {}) {
|
|
728
|
+
const { draft } = this.requireOpen();
|
|
729
|
+
if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
|
|
730
|
+
if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
|
|
731
|
+
throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
|
|
732
|
+
}
|
|
733
|
+
const checksOff = draft.steps.map((step) => step.type === "assert" ? { ...step, proposals: step.proposals.map((p) => ({ ...p, enabled: false })) } : step);
|
|
734
|
+
const checkCount = checksOff.filter((step) => step.type === "assert").length;
|
|
735
|
+
const secretValues = {};
|
|
736
|
+
for (const step of checksOff) {
|
|
737
|
+
const value = step.secretRef ? this.typed.get(step.secretRef) : void 0;
|
|
738
|
+
if (value !== void 0) secretValues[step.id] = value;
|
|
739
|
+
}
|
|
740
|
+
const saved = await this.service.saveFlow(draft.projectId, title || "Tests written with Claude", {
|
|
741
|
+
name: draft.name,
|
|
742
|
+
...draft.featureId ? { featureId: draft.featureId } : { featureName: draft.featureName },
|
|
743
|
+
startPath: draft.startPath,
|
|
744
|
+
steps: withoutTyped(checksOff),
|
|
745
|
+
...draft.signIn ? { signIn: draft.signIn } : {},
|
|
746
|
+
...draft.after?.length ? { prerequisites: draft.after.map((a) => ({ flowId: a.flowId })) } : {}
|
|
747
|
+
}, where, secretValues);
|
|
748
|
+
const review = `${this.service.origin}/projects/${draft.projectId}/changes/${saved.changeId}`;
|
|
749
|
+
const what = saved.replaced ? `Replaced the earlier "${draft.name}"` : `Saved "${draft.name}"`;
|
|
750
|
+
const sealed = Object.keys(secretValues).length;
|
|
751
|
+
const vault = sealed ? ` ${sealed} secret value${sealed === 1 ? " was" : "s were"} put in the project's vault, not the flow.` : "";
|
|
752
|
+
const where_ = saved.newBranch ? `on a new branch, ${saved.branch}` : `on Claude's working branch, ${saved.branch}`;
|
|
753
|
+
const others = await this.service.agentChanges(draft.projectId).then((r) => r.changes.filter((c) => c.id !== saved.changeId)).catch(() => []);
|
|
754
|
+
const note = others.length ? `
|
|
755
|
+
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.` : "";
|
|
756
|
+
const after = draft.after?.length ? ` It starts after ${draft.after.map((a) => `"${a.name}"`).join(", ")}.` : "";
|
|
757
|
+
return `${what} ${where_}.${after}${vault} Its ${checkCount} check${checkCount === 1 ? " is" : "s are"} saved switched off \u2014 an automation switches on the ones it is about. Nothing is published yet.
|
|
758
|
+
Review and publish it here: ${review}${note}`;
|
|
759
|
+
}
|
|
760
|
+
/** A test case built from flows, without saving: its rows and every check,
|
|
761
|
+
* numbered, with which are on — what save_case's `checks` refer to. */
|
|
762
|
+
async previewCase(projectId, spec) {
|
|
763
|
+
const built = await this.service.buildCase(projectId, spec, true);
|
|
764
|
+
return `"${spec.name}" in ${spec.folder} would be:
|
|
765
|
+
${describeRows(built.rows)}`;
|
|
766
|
+
}
|
|
767
|
+
/** RUN THE CASE, THEN SAVE IT. Built as a dry run, replayed in a fresh
|
|
768
|
+
* browser exactly as a test run does it, and saved onto Claude's working
|
|
769
|
+
* branch only when every row passed. */
|
|
770
|
+
async saveCase(projectId, spec, catalog) {
|
|
771
|
+
const built = await this.service.buildCase(projectId, spec, true);
|
|
772
|
+
const testCase = built.testCase;
|
|
773
|
+
const steps = testCase.occurrences.flatMap((o) => o.steps);
|
|
774
|
+
const prepared = await this.service.prepare(projectId, steps, null);
|
|
775
|
+
const runHelper = helperRunner(prepared.helpers, prepared.secrets);
|
|
776
|
+
const base = catalog.baseUrl;
|
|
777
|
+
if (!base) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
|
|
778
|
+
const outcome = await replayCase({
|
|
779
|
+
occurrences: testCase.occurrences,
|
|
780
|
+
...testCase.actors ? { actors: testCase.actors } : {},
|
|
781
|
+
...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
|
|
782
|
+
baseUrl: base,
|
|
783
|
+
headless: true,
|
|
784
|
+
resolveAuth: authenticationResolver(prepared.authentications),
|
|
785
|
+
resolveSecret: (ref) => prepared.secrets[ref],
|
|
786
|
+
...runHelper ? { runHelper } : {}
|
|
787
|
+
});
|
|
788
|
+
const lines = outcome.occurrences.map((o, i) => {
|
|
789
|
+
const failed = o.steps.find((st) => st.status === "failed");
|
|
790
|
+
const why = o.status === "failed" ? `: ${failed ? `step "${failed.name || failed.type}" \u2014 ${(failed.detail ?? "").split("\n")[0]}` : (o.error ?? "").split("\n")[0]}` : "";
|
|
791
|
+
return `${o.status === "passed" ? "\u2713" : o.status === "failed" ? "\u2717" : "\xB7"} row ${i + 1} \u2014 ${o.name}${why}`;
|
|
792
|
+
});
|
|
793
|
+
if (outcome.status !== "passed") {
|
|
794
|
+
return `The case did not pass, so it was NOT saved.
|
|
795
|
+
${lines.join("\n")}
|
|
796
|
+
|
|
797
|
+
${describeRows(built.rows)}`;
|
|
798
|
+
}
|
|
799
|
+
const saved = await this.service.buildCase(projectId, spec, false);
|
|
800
|
+
const review = `${this.service.origin}${saved.path}`;
|
|
801
|
+
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.
|
|
802
|
+
${lines.join("\n")}
|
|
803
|
+
Review and publish it here: ${review}`;
|
|
804
|
+
}
|
|
805
|
+
async close() {
|
|
806
|
+
const run = this.run;
|
|
807
|
+
this.run = null;
|
|
808
|
+
this.page = null;
|
|
809
|
+
this.resolvers = null;
|
|
810
|
+
if (run) await closeRunSession(run, { passed: true }).catch(() => void 0);
|
|
811
|
+
}
|
|
812
|
+
/** Let a click's consequences land before the page is read again. */
|
|
813
|
+
async settle() {
|
|
814
|
+
const page = this.page;
|
|
815
|
+
if (!page) return;
|
|
816
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
|
|
817
|
+
await page.waitForTimeout(300);
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// src/mcp/tools.ts
|
|
822
|
+
var INSTRUCTIONS = `Flowriki tools write UI tests by driving a real browser on this computer.
|
|
823
|
+
Every step you take is performed first and kept only if it worked, so what you
|
|
824
|
+
save already ran once. Everything goes to ONE working branch per project that a
|
|
825
|
+
person reviews and publishes in the Flowriki dashboard \u2014 nothing you do is
|
|
826
|
+
published.
|
|
827
|
+
|
|
828
|
+
A new project \u2014 "set up Flowriki for this site, in this repository" \u2014 is made
|
|
829
|
+
with create_project; it then becomes the project the other tools work on.
|
|
830
|
+
|
|
831
|
+
THE MODEL \u2014 get this right before recording anything:
|
|
832
|
+
- A FLOW is a reusable building block: one piece of a journey ("Open the account
|
|
833
|
+
dialog", "Fill the sign-up form", "Sign in with a wrong password"). A flow can
|
|
834
|
+
START AFTER other flows (start_flow's "after"), so it holds only its own new
|
|
835
|
+
steps and links to the ones before it. A path that behaves differently \u2014 a
|
|
836
|
+
wrong password, a duplicate email \u2014 is its own flow, because the page does
|
|
837
|
+
something different.
|
|
838
|
+
- A flow records checks (add_check) that prove each thing happened, but they
|
|
839
|
+
are SAVED SWITCHED OFF: which ones matter depends on the test.
|
|
840
|
+
- An AUTOMATION is the test. It strings flows together as rows (in the same
|
|
841
|
+
browser, or a new one for a second person) and switches on the checks it is
|
|
842
|
+
about. Build it with preview_case, then save_case \u2014 which runs it first.
|
|
843
|
+
- SIGN-IN is shared setup: a flow or case that needs somebody signed in uses a
|
|
844
|
+
sign-in profile from the catalog (start_flow's signIn), never typed login steps.
|
|
845
|
+
None yet? Make it: record the login as a flow that ends with a check proving
|
|
846
|
+
it worked (the account menu, a heading, the URL), then create_sign_in from it
|
|
847
|
+
and create_account for each person who signs in (a customer, an admin). The
|
|
848
|
+
login flow types into its fields; the account supplies the values.
|
|
849
|
+
- HELPERS (an inbox for email codes, an authenticator, SMS, an API call) are made
|
|
850
|
+
with create_helper when the catalog has none that fits.
|
|
851
|
+
- SEVERAL ELEMENTS WITH THE SAME NAME (two "Proceed to Checkout" buttons, twelve
|
|
852
|
+
"Select & Customize") are told apart by POSITION, like an XPath index: read_page
|
|
853
|
+
marks each one [nth N of M], and act / add_check take nth (0 = the first). Use it
|
|
854
|
+
as soon as a name is shared \u2014 there is no need to look for another way round.
|
|
855
|
+
- PASSWORDS AND KEYS never go into a flow or a reply. Typed into a password
|
|
856
|
+
field, a value is kept as a secret and saved to the project's vault; given to
|
|
857
|
+
create_account or create_helper, it is sealed there too and never shown back.
|
|
858
|
+
Only use credentials the person gave you or that the app itself shows for
|
|
859
|
+
testing (a demo login) \u2014 never guess or reuse anyone's real password.
|
|
860
|
+
- FOLDERS do not nest. Name each one after the product area and what is in it,
|
|
861
|
+
the way a tester would look for it \u2014 "Account \u2013 Sign up", "Account \u2013 Sign in",
|
|
862
|
+
"Ordering \u2013 Pizza builder", "Checkout \u2013 Payment". Flows (features) and the
|
|
863
|
+
cases built from them use the same names. Keep one area per folder.
|
|
864
|
+
|
|
865
|
+
THE TEST LIBRARY is what the team checks, written down: test cases (TC-1,
|
|
866
|
+
TC-2\u2026) with steps, expected results, priority and status. An automation is how
|
|
867
|
+
a test case is checked by a machine, and each test case lists the automations
|
|
868
|
+
that check it. Read it with library_cases before deciding what to automate \u2014
|
|
869
|
+
the important cases nothing checks yet come first. Write cases with
|
|
870
|
+
save_test_cases (a case the library already has is skipped, so writing the
|
|
871
|
+
same thing twice is harmless), and after save_case, link the automation to the
|
|
872
|
+
test cases it proves with link_test_case.
|
|
873
|
+
|
|
874
|
+
KNOWLEDGE: project_brief is the project's memory \u2014 read it first. Search
|
|
875
|
+
everything (knowledge pages, documents, requirements, test cases) with
|
|
876
|
+
knowledge_search; read a page with knowledge_read; read documents with
|
|
877
|
+
sources. product.md is "About the product": the person's own description of
|
|
878
|
+
the product, in their words, as long as they like \u2014 read it first. They
|
|
879
|
+
write it, often with you in a conversation beside it; propose text there and
|
|
880
|
+
let them decide, rather than writing product.md unless they ask you to.
|
|
881
|
+
A linked codebase folder is learned into
|
|
882
|
+
codebase/<folder>.md, which must start "# <folder>", then "Commit: <full sha>"
|
|
883
|
+
and "Learned: <yyyy-mm-dd>" lines: that is how the Knowledge screen shows it
|
|
884
|
+
as learned. Keep what you learn with write_knowledge (features/<name>.md,
|
|
885
|
+
memory/decisions.md, a session summary), on Claude's branch for review \u2014
|
|
886
|
+
never a password, key or token.
|
|
887
|
+
|
|
888
|
+
FROM DOCUMENTS TO TESTS: documents (sources) are PRDs, CRs, feature docs.
|
|
889
|
+
requirements lists what was found in them and what the latest version changed
|
|
890
|
+
(new / changed / removed). For a new requirement: write test cases that cover
|
|
891
|
+
it (save_test_cases with covers: [requirement id]) \u2014 but first check
|
|
892
|
+
find_similar_cases, and link an existing case instead of writing a duplicate.
|
|
893
|
+
For a changed one: update the cases that cover it. For a removed one: mark
|
|
894
|
+
cases that only covered it deprecated. Never invent requirements.
|
|
895
|
+
|
|
896
|
+
AUTOMATING A TEST CASE: first write the plan with save_automation_plan \u2014 flows
|
|
897
|
+
to reuse, flows to record, which checks prove each expected result, accounts
|
|
898
|
+
and helpers it needs, and anything that cannot be automated and why. Show it
|
|
899
|
+
and wait for the person to approve before recording. Then record, save_case,
|
|
900
|
+
and link_test_case.
|
|
901
|
+
|
|
902
|
+
MANUAL TESTING: a test cycle is a set of library cases a person runs by hand
|
|
903
|
+
("Release 4.2 regression"); each case is marked passed, failed, blocked or
|
|
904
|
+
skipped, step by step, with a bug raised from the step that broke. Read them
|
|
905
|
+
with test_cycles and bugs; make and change cycles with manage_cycle; record
|
|
906
|
+
what happened with record_result; raise or update bugs with save_bug. Never
|
|
907
|
+
mark a case passed on a person's behalf without being told it passed.
|
|
908
|
+
|
|
909
|
+
HOW TO WORK:
|
|
910
|
+
1. Call project_catalog. Reuse what exists \u2014 published, and on Claude's working
|
|
911
|
+
branch \u2014 rather than writing it again.
|
|
912
|
+
2. Before recording, say the plan to the person: the folders, the building-block
|
|
913
|
+
flows (and what each starts after), and the automations (rows + what each
|
|
914
|
+
checks). Then record the blocks, earliest first.
|
|
915
|
+
3. Record with start_flow (pass "after" for the flows it continues from), read
|
|
916
|
+
the page with read_page, and act using the role and name EXACTLY as the page
|
|
917
|
+
shows them. If a name appears more than once, pass "within" with the name of
|
|
918
|
+
the region, dialog or form \u2014 or "dialog" / "navigation".
|
|
919
|
+
4. For things outside the page \u2014 an emailed code or link, an authenticator
|
|
920
|
+
code, a text message, an API call \u2014 use use_helper with a helper from the
|
|
921
|
+
catalog, then type {{its saveAs name}} into the field that needs it.
|
|
922
|
+
5. Add checks for what each step proves (a heading, a message, a value, the
|
|
923
|
+
URL). replay_flow, then save_flow. A flow must record at least one check.
|
|
924
|
+
6. Build each automation: preview_case lists its rows and every check, numbered;
|
|
925
|
+
save_case with the numbers to switch on runs the whole case and saves it only
|
|
926
|
+
if it passed. Give the person the review link.
|
|
927
|
+
7. Saving the same name again replaces it on the working branch. Old branches
|
|
928
|
+
of yours? gather_branches puts them into one; discard_branch deletes one \u2014
|
|
929
|
+
only after the person asks or agrees.
|
|
930
|
+
Test data you invent (a new user's email or password for a sign-up) is fine to
|
|
931
|
+
type. For anything that must be NEW on every run \u2014 a sign-up's email or
|
|
932
|
+
username \u2014 use {{unique}}: it is different on every run and the same within
|
|
933
|
+
one, e.g. qa+{{unique}}@ the domain of the project's mailbox helper, so the
|
|
934
|
+
helper can read that address's verification email with {"email": "qa+{{unique}}@\u2026"}.
|
|
935
|
+
A test that types a fixed new email passes once and fails every run after.`;
|
|
936
|
+
var CASE_INPUT = {
|
|
937
|
+
type: "object",
|
|
938
|
+
properties: {
|
|
939
|
+
name: { type: "string", description: 'The automation: the scenario it proves, e.g. "Wrong password is refused".' },
|
|
940
|
+
folder: { type: "string", description: 'The case folder \u2014 area and topic, e.g. "Account \u2013 Sign in". Made if new.' },
|
|
941
|
+
description: { type: "string" },
|
|
942
|
+
rows: {
|
|
943
|
+
type: "array",
|
|
944
|
+
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).",
|
|
945
|
+
items: {
|
|
946
|
+
type: "object",
|
|
947
|
+
properties: {
|
|
948
|
+
flow: { type: "string", description: "A flow's name or id from project_catalog." },
|
|
949
|
+
newBrowser: { type: "boolean" },
|
|
950
|
+
checks: {
|
|
951
|
+
description: `Which of this row's checks to switch on: their numbers from preview_case, or "all". The rest stay off.`,
|
|
952
|
+
anyOf: [{ type: "array", items: { type: "number" } }, { type: "string", enum: ["all"] }]
|
|
953
|
+
}
|
|
954
|
+
},
|
|
955
|
+
required: ["flow"],
|
|
956
|
+
additionalProperties: false
|
|
957
|
+
}
|
|
958
|
+
},
|
|
959
|
+
branch: { type: "string", description: "Optional: save onto this branch (chg_\u2026) instead of the working one." },
|
|
960
|
+
projectId: { type: "string" }
|
|
961
|
+
},
|
|
962
|
+
required: ["name", "folder", "rows"],
|
|
963
|
+
additionalProperties: false
|
|
964
|
+
};
|
|
965
|
+
var caseSpec = (args) => ({
|
|
966
|
+
name: String(args["name"] ?? ""),
|
|
967
|
+
folder: String(args["folder"] ?? ""),
|
|
968
|
+
...args["description"] ? { description: String(args["description"]) } : {},
|
|
969
|
+
rows: Array.isArray(args["rows"]) ? args["rows"] : [],
|
|
970
|
+
...args["branch"] ? { changeId: String(args["branch"]) } : {}
|
|
971
|
+
});
|
|
972
|
+
var text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...isError ? { isError } : {} });
|
|
973
|
+
var str = (value) => typeof value === "string" ? value : value === void 0 || value === null ? void 0 : String(value);
|
|
974
|
+
function authoringTools(service, session) {
|
|
975
|
+
let defaultProject = null;
|
|
976
|
+
const catalogs = /* @__PURE__ */ new Map();
|
|
977
|
+
const projectFor = async (asked) => {
|
|
978
|
+
const named = str(asked);
|
|
979
|
+
if (named) return defaultProject = named;
|
|
980
|
+
if (session.draft) return session.draft.projectId;
|
|
981
|
+
if (defaultProject) return defaultProject;
|
|
982
|
+
const { projects } = await service.projects();
|
|
983
|
+
if (projects.length === 1) return defaultProject = projects[0].id;
|
|
984
|
+
if (projects.length === 0) throw new Error("this account is not on any Flowriki project yet \u2014 create one in the dashboard");
|
|
985
|
+
throw new Error(`which project? Pass projectId \u2014 one of: ${projects.map((p) => `${p.name} (${p.id})`).join(", ")}`);
|
|
986
|
+
};
|
|
987
|
+
const catalogOf = async (projectId, fresh = false) => {
|
|
988
|
+
if (!fresh && catalogs.has(projectId)) return catalogs.get(projectId);
|
|
989
|
+
const catalog = await service.catalog(projectId);
|
|
990
|
+
catalogs.set(projectId, catalog);
|
|
991
|
+
return catalog;
|
|
992
|
+
};
|
|
993
|
+
const tools = [
|
|
994
|
+
{
|
|
995
|
+
name: "list_projects",
|
|
996
|
+
title: "List Flowriki projects",
|
|
997
|
+
description: "The Flowriki projects you can write tests for, with your role on each. Only needed when you are on more than one.",
|
|
998
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
999
|
+
annotations: { readOnlyHint: true },
|
|
1000
|
+
run: async () => {
|
|
1001
|
+
const { projects } = await service.projects();
|
|
1002
|
+
return text(projects.length ? projects.map((p) => `${p.name} \u2014 id ${p.id}, you are ${p.role}`).join("\n") : "You are not on any project yet.");
|
|
1003
|
+
}
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
name: "create_project",
|
|
1007
|
+
title: "Create a Flowriki project",
|
|
1008
|
+
description: "Create a new Flowriki project, connected to a git repository where its tests will be stored, and pointed at the app to test. The repository must already exist and must not hold another Flowriki project. You become its owner.",
|
|
1009
|
+
inputSchema: {
|
|
1010
|
+
type: "object",
|
|
1011
|
+
properties: {
|
|
1012
|
+
name: { type: "string", description: `The project's name, e.g. "Pizza Romano".` },
|
|
1013
|
+
repository: { type: "string", description: "owner/name, or the repository's address, e.g. https://github.com/acme/shop-tests.git" },
|
|
1014
|
+
baseUrl: { type: "string", description: "The app's address, e.g. https://shop.example.com/" }
|
|
1015
|
+
},
|
|
1016
|
+
required: ["name", "repository"],
|
|
1017
|
+
additionalProperties: false
|
|
1018
|
+
},
|
|
1019
|
+
run: async (args) => {
|
|
1020
|
+
const made = await service.createProject(str(args["name"]) ?? "", str(args["repository"]) ?? "", str(args["baseUrl"]));
|
|
1021
|
+
defaultProject = made.id;
|
|
1022
|
+
catalogs.delete(made.id);
|
|
1023
|
+
return text(`Created "${made.name}" (id ${made.id}), stored in ${made.repository}${made.baseUrl ? `, testing ${made.baseUrl}` : " \u2014 no base URL yet; set one in its settings"}.${made.note ? `
|
|
1024
|
+
Note: ${made.note}` : ""}
|
|
1025
|
+
It is now the project these tools work on. Open it: ${service.origin}/projects/${made.id}`);
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
name: "project_catalog",
|
|
1030
|
+
title: "What a test can be built from",
|
|
1031
|
+
description: "The project's base URL, features (folders for flows), existing flows, sign-in profiles with their accounts, and helpers (email codes, 2FA, SMS, API calls). Read this before starting a flow.",
|
|
1032
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Optional when you are on one project." } }, additionalProperties: false },
|
|
1033
|
+
annotations: { readOnlyHint: true },
|
|
1034
|
+
run: async (args) => {
|
|
1035
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1036
|
+
const c = await catalogOf(projectId, true);
|
|
1037
|
+
const flowsBy = (featureId) => c.flows.filter((f) => f.featureId === featureId).map((f) => ` - ${f.name} (${f.steps} steps)`).join("\n");
|
|
1038
|
+
return text([
|
|
1039
|
+
`Project ${projectId}. Base URL: ${c.baseUrl || "(not set)"}`,
|
|
1040
|
+
c.environments.length ? `Environments: ${c.environments.map((e) => `${e.name} ${e.baseUrl}`).join(", ")}` : "",
|
|
1041
|
+
`
|
|
1042
|
+
Features:${c.features.length ? "" : " none yet"}`,
|
|
1043
|
+
...c.features.map((f) => ` - ${f.name} (id ${f.id})${f.description ? ` \u2014 ${f.description}` : ""}
|
|
1044
|
+
${flowsBy(f.id)}`),
|
|
1045
|
+
...c.draft ? [
|
|
1046
|
+
`
|
|
1047
|
+
On Claude's working branch "${c.draft.title}" (${c.draft.changeId}), not yet published:`,
|
|
1048
|
+
...c.draft.features.length ? [` New features: ${c.draft.features.map((f) => `${f.name} (id ${f.id})`).join(", ")}`] : [],
|
|
1049
|
+
...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)"],
|
|
1050
|
+
...c.draft.caseFolders?.length ? [` New case folders: ${c.draft.caseFolders.map((f) => f.name).join(", ")}`] : [],
|
|
1051
|
+
...(c.draft.cases ?? []).map((t) => ` - case: ${t.name} (folder ${t.folderId}) \u2014 rows: ${t.rows.join(" \u2192 ")}`)
|
|
1052
|
+
] : [],
|
|
1053
|
+
`
|
|
1054
|
+
Test cases:${(c.cases ?? []).length ? "" : " none published"}`,
|
|
1055
|
+
...(c.caseFolders ?? []).map((f) => ` - ${f.name} (id ${f.id})
|
|
1056
|
+
${(c.cases ?? []).filter((t) => t.folderId === f.id).map((t) => ` - ${t.name} \u2014 rows: ${t.rows.join(" \u2192 ")}`).join("\n")}`),
|
|
1057
|
+
`
|
|
1058
|
+
Sign-in profiles:${c.signIns.length ? "" : " none"}`,
|
|
1059
|
+
...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"}`),
|
|
1060
|
+
`
|
|
1061
|
+
Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
|
|
1062
|
+
...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}` : ""}`)
|
|
1063
|
+
].filter(Boolean).join("\n"));
|
|
1064
|
+
}
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
name: "start_flow",
|
|
1068
|
+
title: "Start writing a flow",
|
|
1069
|
+
description: "Open a browser on this computer at the project's app and start a new flow. Returns the page. Closes any flow that was being written.",
|
|
1070
|
+
inputSchema: {
|
|
1071
|
+
type: "object",
|
|
1072
|
+
properties: {
|
|
1073
|
+
name: { type: "string", description: `The flow's name: the user journey, e.g. "Sign up and verify email".` },
|
|
1074
|
+
feature: { type: "string", description: "The feature it belongs to: an existing feature's id or name, or a new name." },
|
|
1075
|
+
startPath: { type: "string", description: 'Where it starts, relative to the base URL. Default "/".' },
|
|
1076
|
+
after: {
|
|
1077
|
+
type: "array",
|
|
1078
|
+
items: { type: "string" },
|
|
1079
|
+
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."
|
|
1080
|
+
},
|
|
1081
|
+
signIn: {
|
|
1082
|
+
type: "object",
|
|
1083
|
+
description: "Start signed in, with a profile (and optionally an account) from project_catalog.",
|
|
1084
|
+
properties: { profileId: { type: "string" }, accountId: { type: "string" } },
|
|
1085
|
+
required: ["profileId"],
|
|
1086
|
+
additionalProperties: false
|
|
1087
|
+
},
|
|
1088
|
+
projectId: { type: "string" }
|
|
1089
|
+
},
|
|
1090
|
+
required: ["name", "feature"],
|
|
1091
|
+
additionalProperties: false
|
|
1092
|
+
},
|
|
1093
|
+
annotations: { openWorldHint: true },
|
|
1094
|
+
run: async (args) => {
|
|
1095
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1096
|
+
const catalog = await catalogOf(projectId, true);
|
|
1097
|
+
const featureAsked = str(args["feature"]) ?? "";
|
|
1098
|
+
const feature = catalog.features.find((f) => f.id === featureAsked || f.name.toLowerCase() === featureAsked.toLowerCase());
|
|
1099
|
+
const signIn = args["signIn"];
|
|
1100
|
+
if (signIn && !catalog.signIns.some((s) => s.profileId === signIn.profileId)) {
|
|
1101
|
+
throw new Error(`no sign-in profile ${signIn.profileId} \u2014 project_catalog lists them`);
|
|
1102
|
+
}
|
|
1103
|
+
const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
|
|
1104
|
+
const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
|
|
1105
|
+
const page = await session.start({
|
|
1106
|
+
projectId,
|
|
1107
|
+
...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
|
|
1108
|
+
name: str(args["name"]) ?? "Untitled flow",
|
|
1109
|
+
...feature ? { featureId: feature.id } : { featureName: featureAsked },
|
|
1110
|
+
startPath: str(args["startPath"]) || "/",
|
|
1111
|
+
...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
|
|
1112
|
+
}, catalog);
|
|
1113
|
+
const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
|
|
1114
|
+
return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}.${after} The browser is open.
|
|
1115
|
+
|
|
1116
|
+
${page}`);
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
{
|
|
1120
|
+
name: "read_page",
|
|
1121
|
+
title: "Read the page",
|
|
1122
|
+
description: "The current page as its accessibility tree: each element's role and name, which is exactly what act and add_check refer to.",
|
|
1123
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1124
|
+
annotations: { readOnlyHint: true },
|
|
1125
|
+
run: async () => text(await session.snapshot())
|
|
1126
|
+
},
|
|
1127
|
+
{
|
|
1128
|
+
name: "act",
|
|
1129
|
+
title: "Do one thing on the page",
|
|
1130
|
+
description: `Perform one step and keep it in the flow if it worked. Returns the page afterwards. click/dblclick/rightclick/hover: role + name. fill: role + name + value (may use {{values}} from helpers). select: role + name + the option's label as value. check: role + name, value "true" or "false". press: a key such as "Enter" as value, optionally role + name of where. navigate: a path such as "/settings" or a full URL as value. back/forward: nothing.`,
|
|
1131
|
+
inputSchema: {
|
|
1132
|
+
type: "object",
|
|
1133
|
+
properties: {
|
|
1134
|
+
action: { type: "string", enum: ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"] },
|
|
1135
|
+
role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
|
|
1136
|
+
name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
|
|
1137
|
+
within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
|
|
1138
|
+
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." },
|
|
1139
|
+
value: { type: "string" },
|
|
1140
|
+
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." }
|
|
1141
|
+
},
|
|
1142
|
+
required: ["action"],
|
|
1143
|
+
additionalProperties: false
|
|
1144
|
+
},
|
|
1145
|
+
run: async (args) => {
|
|
1146
|
+
if (!isAction(args["action"])) throw new Error("action must be one of click, dblclick, rightclick, hover, fill, select, check, press, navigate, back, forward");
|
|
1147
|
+
return text(await session.act({
|
|
1148
|
+
action: args["action"],
|
|
1149
|
+
...str(args["role"]) ? { role: str(args["role"]) } : {},
|
|
1150
|
+
...str(args["name"]) !== void 0 ? { name: str(args["name"]) } : {},
|
|
1151
|
+
...str(args["within"]) ? { within: str(args["within"]) } : {},
|
|
1152
|
+
...Number.isInteger(args["nth"]) && args["nth"] >= 0 ? { nth: args["nth"] } : {},
|
|
1153
|
+
...str(args["value"]) !== void 0 ? { value: str(args["value"]) } : {},
|
|
1154
|
+
...typeof args["secret"] === "boolean" ? { secret: args["secret"] } : {}
|
|
1155
|
+
}));
|
|
1156
|
+
}
|
|
1157
|
+
},
|
|
1158
|
+
{
|
|
1159
|
+
name: "add_check",
|
|
1160
|
+
title: "Add a check",
|
|
1161
|
+
description: "Assert something about the page as it is now, and keep it in the flow if it holds. Page checks need only kind + value: heading, url (a path), title, message. Element checks also need role + name: text, value, visible, hidden, checked, count. op: equals (default), contains or matches.",
|
|
1162
|
+
inputSchema: {
|
|
1163
|
+
type: "object",
|
|
1164
|
+
properties: {
|
|
1165
|
+
kind: { type: "string", enum: ["heading", "url", "title", "message", "screen", "text", "value", "visible", "hidden", "checked", "enabled", "count"] },
|
|
1166
|
+
value: { type: "string", description: `What it should be: the heading's words, the path, "true" for a state, a number for count.` },
|
|
1167
|
+
op: { type: "string", enum: ["equals", "contains", "matches"] },
|
|
1168
|
+
role: { type: "string" },
|
|
1169
|
+
name: { type: "string" },
|
|
1170
|
+
within: { type: "string" },
|
|
1171
|
+
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])." }
|
|
1172
|
+
},
|
|
1173
|
+
required: ["kind", "value"],
|
|
1174
|
+
additionalProperties: false
|
|
1175
|
+
},
|
|
1176
|
+
run: async (args) => text(await session.check({
|
|
1177
|
+
kind: str(args["kind"]),
|
|
1178
|
+
value: str(args["value"]) ?? "",
|
|
1179
|
+
...str(args["op"]) ? { op: str(args["op"]) } : {},
|
|
1180
|
+
...str(args["role"]) ? { role: str(args["role"]), name: str(args["name"]) ?? "" } : {},
|
|
1181
|
+
...str(args["within"]) ? { within: str(args["within"]) } : {},
|
|
1182
|
+
...Number.isInteger(args["nth"]) && args["nth"] >= 0 ? { nth: args["nth"] } : {}
|
|
1183
|
+
}))
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
name: "use_helper",
|
|
1187
|
+
title: "Use a helper",
|
|
1188
|
+
description: `Get a value from outside the page with one of the project's helpers \u2014 the code or link from a verification email, an authenticator code, an SMS code, a field of an API reply \u2014 and keep the step. Later steps use it as {{saveAs}}. then: "open" goes to the value as a link (for verification links).`,
|
|
1189
|
+
inputSchema: {
|
|
1190
|
+
type: "object",
|
|
1191
|
+
properties: {
|
|
1192
|
+
helperId: { type: "string", description: "The helper's id or name from project_catalog." },
|
|
1193
|
+
inputs: { type: "object", additionalProperties: { type: "string" }, description: `Values for the helper's {{names}}, e.g. {"email": "qa+signup1@example.com"}.` },
|
|
1194
|
+
saveAs: { type: "string", description: "What to call the value, e.g. code. Default: code for a mailbox, value otherwise." },
|
|
1195
|
+
then: { type: "string", enum: ["remember", "open"] }
|
|
1196
|
+
},
|
|
1197
|
+
required: ["helperId"],
|
|
1198
|
+
additionalProperties: false
|
|
1199
|
+
},
|
|
1200
|
+
run: async (args) => text(await session.useHelper({
|
|
1201
|
+
helperId: str(args["helperId"]),
|
|
1202
|
+
...args["inputs"] && typeof args["inputs"] === "object" ? { inputs: Object.fromEntries(Object.entries(args["inputs"]).map(([k, v]) => [k, String(v)])) } : {},
|
|
1203
|
+
...str(args["saveAs"]) ? { saveAs: str(args["saveAs"]) } : {},
|
|
1204
|
+
...args["then"] === "open" ? { then: "open" } : {}
|
|
1205
|
+
}))
|
|
1206
|
+
},
|
|
1207
|
+
{
|
|
1208
|
+
name: "list_steps",
|
|
1209
|
+
title: "List the flow's steps",
|
|
1210
|
+
description: "The flow being written, step by step.",
|
|
1211
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1212
|
+
annotations: { readOnlyHint: true },
|
|
1213
|
+
run: async () => text(session.list())
|
|
1214
|
+
},
|
|
1215
|
+
{
|
|
1216
|
+
name: "remove_step",
|
|
1217
|
+
title: "Remove a step",
|
|
1218
|
+
description: "Remove one step by its number in list_steps. The browser is not wound back; use replay_flow to see the flow as it now stands.",
|
|
1219
|
+
inputSchema: { type: "object", properties: { number: { type: "integer", minimum: 1 } }, required: ["number"], additionalProperties: false },
|
|
1220
|
+
annotations: { destructiveHint: true },
|
|
1221
|
+
run: async (args) => text(session.remove(Number(args["number"])))
|
|
1222
|
+
},
|
|
1223
|
+
{
|
|
1224
|
+
name: "replay_flow",
|
|
1225
|
+
title: "Replay the flow from the start",
|
|
1226
|
+
description: "Run the whole flow from the beginning in a fresh browser, exactly as a test run will, and report each step. Do this before saving.",
|
|
1227
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1228
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
1229
|
+
run: async () => text(await session.replay())
|
|
1230
|
+
},
|
|
1231
|
+
{
|
|
1232
|
+
name: "save_flow",
|
|
1233
|
+
title: "Save the flow to a branch",
|
|
1234
|
+
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.",
|
|
1235
|
+
inputSchema: {
|
|
1236
|
+
type: "object",
|
|
1237
|
+
properties: {
|
|
1238
|
+
title: { type: "string", description: "The branch's title, used only when a new branch is started." },
|
|
1239
|
+
branch: { type: "string", description: "Optional: a branch id (chg_\u2026) from list_my_branches to save onto instead." },
|
|
1240
|
+
newBranch: { type: "boolean", description: "Start a separate branch instead of the working one. Only when the person asks for separate reviews." }
|
|
1241
|
+
},
|
|
1242
|
+
additionalProperties: false
|
|
1243
|
+
},
|
|
1244
|
+
run: async (args) => text(await session.save(str(args["title"]), {
|
|
1245
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {},
|
|
1246
|
+
...args["newBranch"] === true ? { newBranch: true } : {}
|
|
1247
|
+
}))
|
|
1248
|
+
},
|
|
1249
|
+
{
|
|
1250
|
+
name: "list_my_branches",
|
|
1251
|
+
title: "List Claude's open branches",
|
|
1252
|
+
description: "The project's open, unpublished branches that Claude wrote for this person, newest first, with review links. Use it to find a superseded branch to discard.",
|
|
1253
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Optional when you are on one project." } }, additionalProperties: false },
|
|
1254
|
+
annotations: { readOnlyHint: true },
|
|
1255
|
+
run: async (args) => {
|
|
1256
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1257
|
+
const { changes } = await service.agentChanges(projectId);
|
|
1258
|
+
if (!changes.length) return text("Claude has no open branches on this project.");
|
|
1259
|
+
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"));
|
|
1260
|
+
}
|
|
1261
|
+
},
|
|
1262
|
+
{
|
|
1263
|
+
name: "gather_branches",
|
|
1264
|
+
title: "Gather Claude's branches into one",
|
|
1265
|
+
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.",
|
|
1266
|
+
inputSchema: {
|
|
1267
|
+
type: "object",
|
|
1268
|
+
properties: {
|
|
1269
|
+
into: { type: "string", description: "The branch to gather into (chg_\u2026). Default: Claude's oldest open branch." },
|
|
1270
|
+
title: { type: "string", description: 'A title for the gathered branch, e.g. "Sign in and sign up tests".' },
|
|
1271
|
+
projectId: { type: "string" }
|
|
1272
|
+
},
|
|
1273
|
+
additionalProperties: false
|
|
1274
|
+
},
|
|
1275
|
+
annotations: { destructiveHint: true },
|
|
1276
|
+
run: async (args) => {
|
|
1277
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1278
|
+
const done = await service.gatherChanges(projectId, {
|
|
1279
|
+
...str(args["into"]) ? { into: str(args["into"]) } : {},
|
|
1280
|
+
...str(args["title"]) ? { title: str(args["title"]) } : {}
|
|
1281
|
+
});
|
|
1282
|
+
catalogs.delete(projectId);
|
|
1283
|
+
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}`);
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
{
|
|
1287
|
+
name: "discard_branch",
|
|
1288
|
+
title: "Delete one of Claude's branches",
|
|
1289
|
+
description: "Delete an open branch Claude wrote, with the flows on it \u2014 e.g. one a redo replaced. Only after the person asked for it or agreed. Refused for a branch a person started, edited, or published; those are deleted in the dashboard.",
|
|
1290
|
+
inputSchema: {
|
|
1291
|
+
type: "object",
|
|
1292
|
+
properties: { changeId: { type: "string", description: "The branch's id from list_my_branches (chg_\u2026)." }, projectId: { type: "string" } },
|
|
1293
|
+
required: ["changeId"],
|
|
1294
|
+
additionalProperties: false
|
|
1295
|
+
},
|
|
1296
|
+
annotations: { destructiveHint: true },
|
|
1297
|
+
run: async (args) => {
|
|
1298
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1299
|
+
const changeId = str(args["changeId"]) ?? "";
|
|
1300
|
+
const { deleted } = await service.discardChange(projectId, changeId);
|
|
1301
|
+
return text(`Deleted ${changeId} and its branch ${deleted}.`);
|
|
1302
|
+
}
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
name: "preview_case",
|
|
1306
|
+
title: "Preview an automation",
|
|
1307
|
+
description: "Build an automation 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.",
|
|
1308
|
+
inputSchema: CASE_INPUT,
|
|
1309
|
+
annotations: { readOnlyHint: true },
|
|
1310
|
+
run: async (args) => {
|
|
1311
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1312
|
+
return text(await session.previewCase(projectId, caseSpec(args)));
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
name: "save_case",
|
|
1317
|
+
title: "Run and save an automation",
|
|
1318
|
+
description: "Build the automation, 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.",
|
|
1319
|
+
inputSchema: CASE_INPUT,
|
|
1320
|
+
annotations: { openWorldHint: true },
|
|
1321
|
+
run: async (args) => {
|
|
1322
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1323
|
+
const out = await session.saveCase(projectId, caseSpec(args), await catalogOf(projectId, true));
|
|
1324
|
+
catalogs.delete(projectId);
|
|
1325
|
+
return text(out);
|
|
1326
|
+
}
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
name: "create_sign_in",
|
|
1330
|
+
title: "Make a sign-in profile",
|
|
1331
|
+
description: "Turn a saved login flow into a sign-in profile that flows and automations 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.",
|
|
1332
|
+
inputSchema: {
|
|
1333
|
+
type: "object",
|
|
1334
|
+
properties: {
|
|
1335
|
+
projectId: { type: "string" },
|
|
1336
|
+
flow: { type: "string", description: "The login flow's name or id (project_catalog)." },
|
|
1337
|
+
name: { type: "string", description: `What a tester calls this way of signing in, e.g. "Customer sign-in". Default: the flow's name.` },
|
|
1338
|
+
description: { type: "string" },
|
|
1339
|
+
signOutFlow: { type: "string", description: "Optional: a flow that signs out, by name or id." },
|
|
1340
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
1341
|
+
},
|
|
1342
|
+
required: ["flow"],
|
|
1343
|
+
additionalProperties: false
|
|
1344
|
+
},
|
|
1345
|
+
run: async (args) => {
|
|
1346
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1347
|
+
const made = await service.createSignIn(projectId, {
|
|
1348
|
+
flow: str(args["flow"]) ?? "",
|
|
1349
|
+
...str(args["name"]) ? { name: str(args["name"]) } : {},
|
|
1350
|
+
...str(args["description"]) ? { description: str(args["description"]) } : {},
|
|
1351
|
+
...str(args["signOutFlow"]) ? { signOutFlow: str(args["signOutFlow"]) } : {},
|
|
1352
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
1353
|
+
});
|
|
1354
|
+
catalogs.delete(projectId);
|
|
1355
|
+
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}.
|
|
1356
|
+
An account fills in: ${made.fields.map((f) => `"${f.label}"${f.secret ? " (secret)" : ""}`).join(", ") || "nothing \u2014 the flow types no values"}.
|
|
1357
|
+
Next: create_account for each person who signs in this way.`);
|
|
1358
|
+
}
|
|
1359
|
+
},
|
|
1360
|
+
{
|
|
1361
|
+
name: "create_account",
|
|
1362
|
+
title: "Make a sign-in account",
|
|
1363
|
+
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 / automations can use signIn {profileId, accountId}. Making the same name again updates Claude's earlier one.",
|
|
1364
|
+
inputSchema: {
|
|
1365
|
+
type: "object",
|
|
1366
|
+
properties: {
|
|
1367
|
+
projectId: { type: "string" },
|
|
1368
|
+
signIn: { type: "string", description: "The sign-in profile's name or id." },
|
|
1369
|
+
name: { type: "string", description: 'Who this is, e.g. "Demo customer", "Kitchen admin".' },
|
|
1370
|
+
values: { type: "object", additionalProperties: { type: "string" }, description: 'Field label \u2192 value, e.g. {"Email": "demo@example.com", "Password": "\u2026"}.' },
|
|
1371
|
+
environments: { type: "array", items: { type: "string" }, description: "Optional: the environments this account exists on. Default: all." },
|
|
1372
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
1373
|
+
},
|
|
1374
|
+
required: ["signIn", "name", "values"],
|
|
1375
|
+
additionalProperties: false
|
|
1376
|
+
},
|
|
1377
|
+
run: async (args) => {
|
|
1378
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1379
|
+
const values = Object.fromEntries(Object.entries(args["values"] && typeof args["values"] === "object" ? args["values"] : {}).map(([k, v]) => [k, String(v)]));
|
|
1380
|
+
const made = await service.createAccount(projectId, {
|
|
1381
|
+
signIn: str(args["signIn"]) ?? "",
|
|
1382
|
+
name: str(args["name"]) ?? "",
|
|
1383
|
+
values,
|
|
1384
|
+
...Array.isArray(args["environments"]) ? { environments: args["environments"].map(String) } : {},
|
|
1385
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
1386
|
+
});
|
|
1387
|
+
catalogs.delete(projectId);
|
|
1388
|
+
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.
|
|
1389
|
+
Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accountId}"}.`);
|
|
1390
|
+
}
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
name: "create_helper",
|
|
1394
|
+
title: "Make a helper",
|
|
1395
|
+
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.`,
|
|
1396
|
+
inputSchema: {
|
|
1397
|
+
type: "object",
|
|
1398
|
+
properties: {
|
|
1399
|
+
projectId: { type: "string" },
|
|
1400
|
+
name: { type: "string", description: 'e.g. "QA inbox", "Admin authenticator".' },
|
|
1401
|
+
description: { type: "string" },
|
|
1402
|
+
config: { type: "object", description: "The helper's settings, by kind (see above). Never put a secret here." },
|
|
1403
|
+
secretValues: { type: "object", additionalProperties: { type: "string" }, description: "The secret for the kind, keyed as above." },
|
|
1404
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
1405
|
+
},
|
|
1406
|
+
required: ["name", "config"],
|
|
1407
|
+
additionalProperties: false
|
|
1408
|
+
},
|
|
1409
|
+
run: async (args) => {
|
|
1410
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1411
|
+
const made = await service.createHelper(projectId, {
|
|
1412
|
+
name: str(args["name"]) ?? "",
|
|
1413
|
+
...str(args["description"]) ? { description: str(args["description"]) } : {},
|
|
1414
|
+
config: args["config"] && typeof args["config"] === "object" ? args["config"] : {},
|
|
1415
|
+
...args["secretValues"] && typeof args["secretValues"] === "object" ? { secretValues: Object.fromEntries(Object.entries(args["secretValues"]).map(([k, v]) => [k, String(v)])) } : {},
|
|
1416
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
1417
|
+
});
|
|
1418
|
+
catalogs.delete(projectId);
|
|
1419
|
+
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.`);
|
|
1420
|
+
}
|
|
1421
|
+
},
|
|
1422
|
+
{
|
|
1423
|
+
name: "library_cases",
|
|
1424
|
+
title: "Read the test library",
|
|
1425
|
+
description: "The project's test cases \u2014 what the team checks \u2014 with their key (TC-n), priority, status, steps and expected results, and which automations check each one. Optional q (title, key or tag) and featureId narrow it. Read before planning automations: cases with priority P0/P1 and no automation are the gaps.",
|
|
1426
|
+
inputSchema: {
|
|
1427
|
+
type: "object",
|
|
1428
|
+
properties: {
|
|
1429
|
+
projectId: { type: "string" },
|
|
1430
|
+
q: { type: "string", description: "Words in the title, a key like TC-12, or a tag." },
|
|
1431
|
+
featureId: { type: "string", description: "Only this feature's cases, e.g. f_checkout." }
|
|
1432
|
+
},
|
|
1433
|
+
additionalProperties: false
|
|
1434
|
+
},
|
|
1435
|
+
annotations: { readOnlyHint: true },
|
|
1436
|
+
run: async (args) => {
|
|
1437
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1438
|
+
const { cases, total } = await service.library(projectId, {
|
|
1439
|
+
...str(args["q"]) ? { q: str(args["q"]) } : {},
|
|
1440
|
+
...str(args["featureId"]) ? { featureId: str(args["featureId"]) } : {}
|
|
1441
|
+
});
|
|
1442
|
+
if (cases.length === 0) return text(total ? "No test case matches." : "The test library is empty. Write cases with save_test_cases.");
|
|
1443
|
+
const lines = cases.map((c) => {
|
|
1444
|
+
const automated = c.automation.links.length ? `automated by ${c.automation.links.map((l) => l.automationId).join(", ")}${c.automation.links.some((l) => l.caseRevision < c.revision) ? " (case changed since \u2014 check it still matches)" : ""}` : c.automation.state === "not-automatable" ? `manual only: ${c.automation.reason ?? ""}` : c.automation.state === "planned" ? "automation planned" : "NOT AUTOMATED";
|
|
1445
|
+
const steps = c.steps.map((st, i) => ` ${i + 1}. ${st.action}${st.data ? ` [${st.data}]` : ""}${st.expected ? ` \u2192 ${st.expected}` : ""}`).join("\n");
|
|
1446
|
+
return `${c.key} (id ${c.id}, revision ${c.revision}) ${c.title} \u2014 ${c.featureId}, ${c.priority}, ${c.status}, ${automated}${c.preconditions.length ? `
|
|
1447
|
+
Preconditions: ${c.preconditions.join("; ")}` : ""}${steps ? `
|
|
1448
|
+
${steps}` : ""}`;
|
|
1449
|
+
});
|
|
1450
|
+
return text(`${cases.length} of ${total} test cases:
|
|
1451
|
+
${lines.join("\n")}`);
|
|
1452
|
+
}
|
|
1453
|
+
},
|
|
1454
|
+
{
|
|
1455
|
+
name: "save_test_cases",
|
|
1456
|
+
title: "Write test cases",
|
|
1457
|
+
description: `Add test cases to the library, or edit existing ones, in one commit on Claude's working branch. create: [{feature: "Checkout" (or featureId), title, priority?: P0|P1|P2|P3, type?: functional|negative|edge|regression|smoke|accessibility|other, status?: draft|ready|deprecated, preconditions?: [..], steps: [{action, data?, expected?}], tags?: [..], description?}]. update: [{id, revision (from library_cases), ...only the fields to change}]. A new case the library already has is skipped and named in the answer \u2014 never duplicated.`,
|
|
1458
|
+
inputSchema: {
|
|
1459
|
+
type: "object",
|
|
1460
|
+
properties: {
|
|
1461
|
+
projectId: { type: "string" },
|
|
1462
|
+
create: { type: "array", items: { type: "object" } },
|
|
1463
|
+
update: { type: "array", items: { type: "object" } },
|
|
1464
|
+
branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
|
|
1465
|
+
},
|
|
1466
|
+
additionalProperties: false
|
|
1467
|
+
},
|
|
1468
|
+
run: async (args) => {
|
|
1469
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1470
|
+
const featureOf = (item) => {
|
|
1471
|
+
const id = str(item["featureId"]);
|
|
1472
|
+
if (id) return id;
|
|
1473
|
+
const name = str(item["feature"]) ?? "General";
|
|
1474
|
+
return `f_${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "general"}`;
|
|
1475
|
+
};
|
|
1476
|
+
const create = (Array.isArray(args["create"]) ? args["create"] : []).map((raw) => {
|
|
1477
|
+
const { feature: _feature, ...item } = raw;
|
|
1478
|
+
return { ...item, featureId: featureOf(raw) };
|
|
1479
|
+
});
|
|
1480
|
+
const update = (Array.isArray(args["update"]) ? args["update"] : []).map((raw) => {
|
|
1481
|
+
const item = raw;
|
|
1482
|
+
if (item["feature"] === void 0) return item;
|
|
1483
|
+
const { feature: _feature, ...rest } = item;
|
|
1484
|
+
return { ...rest, featureId: featureOf(item) };
|
|
1485
|
+
});
|
|
1486
|
+
const saved = await service.saveLibrary(projectId, {
|
|
1487
|
+
create,
|
|
1488
|
+
update,
|
|
1489
|
+
message: "Test cases written with Claude",
|
|
1490
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
1491
|
+
});
|
|
1492
|
+
const made = saved.created.map((c) => `${c.key} ${c.title} (id ${c.id})`);
|
|
1493
|
+
const edited = saved.updated.map((c) => `${c.key} ${c.title}`);
|
|
1494
|
+
return text([
|
|
1495
|
+
made.length ? `Added ${made.length}: ${made.join("; ")}.` : "",
|
|
1496
|
+
edited.length ? `Updated ${edited.length}: ${edited.join("; ")}.` : "",
|
|
1497
|
+
saved.duplicates.length ? `Skipped ${saved.duplicates.length} already in the library: ${saved.duplicates.map((d) => `"${d.title}" is ${d.matches}`).join("; ")}.` : "",
|
|
1498
|
+
`On Claude's working branch ${saved.branch}.`
|
|
1499
|
+
].filter(Boolean).join(" "));
|
|
1500
|
+
}
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
name: "link_test_case",
|
|
1504
|
+
title: "Link an automation to a test case",
|
|
1505
|
+
description: "Record that an automation (a case id from save_case or project_catalog) checks a library test case (its key like TC-12, or its id). unlink: true removes the link. Do this after save_case for every test case the automation proves.",
|
|
1506
|
+
inputSchema: {
|
|
1507
|
+
type: "object",
|
|
1508
|
+
properties: {
|
|
1509
|
+
projectId: { type: "string" },
|
|
1510
|
+
testCase: { type: "string", description: "TC-12, or the test case id." },
|
|
1511
|
+
automationId: { type: "string" },
|
|
1512
|
+
unlink: { type: "boolean" },
|
|
1513
|
+
branch: { type: "string" }
|
|
1514
|
+
},
|
|
1515
|
+
required: ["testCase", "automationId"],
|
|
1516
|
+
additionalProperties: false
|
|
1517
|
+
},
|
|
1518
|
+
run: async (args) => {
|
|
1519
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1520
|
+
const wanted = str(args["testCase"]) ?? "";
|
|
1521
|
+
const automationId = str(args["automationId"]) ?? "";
|
|
1522
|
+
const { cases } = await service.library(projectId);
|
|
1523
|
+
const target = cases.find((c) => c.id === wanted || c.key.toLowerCase() === wanted.toLowerCase());
|
|
1524
|
+
if (!target) return text(`No test case ${wanted} in the library \u2014 library_cases lists them.`);
|
|
1525
|
+
const has = target.automation.links.some((l) => l.automationId === automationId);
|
|
1526
|
+
const unlink = args["unlink"] === true;
|
|
1527
|
+
if (has !== unlink) return text(`${target.key} is ${unlink ? "not" : "already"} linked to ${automationId}.`);
|
|
1528
|
+
const links = unlink ? target.automation.links.filter((l) => l.automationId !== automationId) : [...target.automation.links, { automationId, caseRevision: target.revision + 1, at: (/* @__PURE__ */ new Date()).toISOString(), by: "Claude" }];
|
|
1529
|
+
const state = links.length ? "automated" : target.automation.state === "automated" ? "none" : target.automation.state;
|
|
1530
|
+
const saved = await service.saveLibrary(projectId, {
|
|
1531
|
+
update: [{ id: target.id, revision: target.revision, automation: { ...target.automation, state, links } }],
|
|
1532
|
+
message: `${unlink ? "Unlink" : "Link"} ${target.key} ${unlink ? "from" : "to"} ${automationId}`,
|
|
1533
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
1534
|
+
});
|
|
1535
|
+
return text(`${unlink ? "Unlinked" : "Linked"} ${target.key} "${target.title}" ${unlink ? "from" : "to"} automation ${automationId}, on Claude's working branch ${saved.branch}.`);
|
|
1536
|
+
}
|
|
1537
|
+
},
|
|
1538
|
+
{
|
|
1539
|
+
name: "project_brief",
|
|
1540
|
+
title: "Read the project's memory",
|
|
1541
|
+
description: "The project's own knowledge, in one call: About the product (product.md), memory/decisions.md, what each linked codebase folder was learned as, the list of feature pages and session summaries, and the documents it has. Call it first in every conversation.",
|
|
1542
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" } }, additionalProperties: false },
|
|
1543
|
+
annotations: { readOnlyHint: true },
|
|
1544
|
+
run: async (args) => {
|
|
1545
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1546
|
+
const [{ pages }, { sources }] = await Promise.all([
|
|
1547
|
+
service.knowledgePages(projectId),
|
|
1548
|
+
service.sources(projectId).catch(() => ({ sources: [] }))
|
|
1549
|
+
]);
|
|
1550
|
+
const read = async (path) => pages.some((p) => p.path === path) ? (await service.knowledgePage(projectId, path)).content : null;
|
|
1551
|
+
const [product, glossary, decisions] = await Promise.all(["product.md", "glossary.md", "memory/decisions.md"].map(read));
|
|
1552
|
+
const codebases = pages.filter((p) => p.path.startsWith("codebase/"));
|
|
1553
|
+
const headers = await Promise.all(codebases.map(async (p) => `- ${p.path}: ${(await read(p.path))?.split("\n").filter((l) => l.trim()).slice(0, 3).join(" \xB7 ") ?? ""}`));
|
|
1554
|
+
const shown = /* @__PURE__ */ new Set(["product.md", "glossary.md", "memory/decisions.md", ...codebases.map((p) => p.path)]);
|
|
1555
|
+
const parts = [
|
|
1556
|
+
product ? `## About the product (product.md)
|
|
1557
|
+
${product}` : "No About the product yet \u2014 the person writes it on the Knowledge screen; offer to help draft it.",
|
|
1558
|
+
glossary ? `## glossary.md (older page; terms now belong in product.md)
|
|
1559
|
+
${glossary}` : "",
|
|
1560
|
+
decisions ? `## memory/decisions.md
|
|
1561
|
+
${decisions}` : "",
|
|
1562
|
+
codebases.length ? `## Codebase, as learned
|
|
1563
|
+
${headers.join("\n")}` : "No codebase learned yet.",
|
|
1564
|
+
`## Other pages
|
|
1565
|
+
${pages.filter((p) => !shown.has(p.path)).map((p) => `- ${p.path}`).join("\n") || "none"}`,
|
|
1566
|
+
`## Documents
|
|
1567
|
+
${sources.map((s) => `- ${s.title} (id ${s.id}, v${s.version}) \u2014 requirements: ${s.requirements["new"] ?? 0} new, ${s.requirements["changed"] ?? 0} changed, ${s.requirements["active"] ?? 0} unchanged, ${s.requirements["removed"] ?? 0} removed`).join("\n") || "none"}`
|
|
1568
|
+
];
|
|
1569
|
+
return text(parts.filter(Boolean).join("\n\n"));
|
|
1570
|
+
}
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
name: "knowledge_search",
|
|
1574
|
+
title: "Search everything the project knows",
|
|
1575
|
+
description: "Search knowledge pages, source documents, requirements and test cases at once. Returns where each hit is, so it can be cited ([[knowledge:path]], [[source:id@vN]], [[requirement:id]], [[testCase:TC-n]]).",
|
|
1576
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, q: { type: "string" } }, required: ["q"], additionalProperties: false },
|
|
1577
|
+
annotations: { readOnlyHint: true },
|
|
1578
|
+
run: async (args) => {
|
|
1579
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1580
|
+
const { hits } = await service.search(projectId, str(args["q"]) ?? "");
|
|
1581
|
+
if (!hits.length) return text("Nothing found.");
|
|
1582
|
+
return text(hits.map((h) => `[[${h.kind}:${h.ref}]] ${h.title}
|
|
1583
|
+
${h.snippet}`).join("\n"));
|
|
1584
|
+
}
|
|
1585
|
+
},
|
|
1586
|
+
{
|
|
1587
|
+
name: "knowledge_read",
|
|
1588
|
+
title: "Read a knowledge page",
|
|
1589
|
+
description: "Read one knowledge page by path (product.md, codebase/<folder>.md, features/<name>.md, memory/\u2026).",
|
|
1590
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, path: { type: "string" } }, required: ["path"], additionalProperties: false },
|
|
1591
|
+
annotations: { readOnlyHint: true },
|
|
1592
|
+
run: async (args) => {
|
|
1593
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1594
|
+
const page = await service.knowledgePage(projectId, str(args["path"]) ?? "");
|
|
1595
|
+
return text(page.content);
|
|
1596
|
+
}
|
|
1597
|
+
},
|
|
1598
|
+
{
|
|
1599
|
+
name: "write_knowledge",
|
|
1600
|
+
title: "Write a knowledge page",
|
|
1601
|
+
description: `Write what the project should remember, on Claude's working branch for review: product.md (About the product \u2014 the person's own description; write it only when they ask), codebase/<folder>.md (what a linked folder holds \u2014 starting with "# <folder>", "Commit: <sha>" and "Learned: <yyyy-mm-dd>" lines, then routes, screens, forms and their validation, roles, APIs, flags; summarised, never copied code), features/<name>.md (screens, rules, quirks), memory/decisions.md (decisions and conventions), memory/sessions/<yyyy-mm-dd>-<topic>.md (what a job did). append: true adds to the end instead of replacing. Never a password, key or token \u2014 those are refused.`,
|
|
1602
|
+
inputSchema: {
|
|
1603
|
+
type: "object",
|
|
1604
|
+
properties: { projectId: { type: "string" }, path: { type: "string" }, content: { type: "string" }, append: { type: "boolean" }, branch: { type: "string" } },
|
|
1605
|
+
required: ["path", "content"],
|
|
1606
|
+
additionalProperties: false
|
|
1607
|
+
},
|
|
1608
|
+
run: async (args) => {
|
|
1609
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1610
|
+
const saved = await service.writeKnowledge(projectId, {
|
|
1611
|
+
path: str(args["path"]) ?? "",
|
|
1612
|
+
content: String(args["content"] ?? ""),
|
|
1613
|
+
...args["append"] === true ? { append: true } : {},
|
|
1614
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
1615
|
+
});
|
|
1616
|
+
return text(`Wrote ${saved.path} (${saved.length} characters) on Claude's working branch ${saved.branch}.`);
|
|
1617
|
+
}
|
|
1618
|
+
},
|
|
1619
|
+
{
|
|
1620
|
+
name: "sources",
|
|
1621
|
+
title: "Read source documents",
|
|
1622
|
+
description: "Without sourceId: every source (PRDs, CRs, feature docs, Jira stories, Figma files\u2026) with its version and how many requirements are new, changed, unchanged or removed. With sourceId: the latest version's text.",
|
|
1623
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, sourceId: { type: "string" } }, additionalProperties: false },
|
|
1624
|
+
annotations: { readOnlyHint: true },
|
|
1625
|
+
run: async (args) => {
|
|
1626
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1627
|
+
const id = str(args["sourceId"]);
|
|
1628
|
+
if (id) {
|
|
1629
|
+
const source = await service.source(projectId, id);
|
|
1630
|
+
return text(`# ${source.title} (v${source.version})
|
|
1631
|
+
|
|
1632
|
+
${source.text.slice(0, 6e4)}`);
|
|
1633
|
+
}
|
|
1634
|
+
const { sources } = await service.sources(projectId);
|
|
1635
|
+
if (!sources.length) return text("No sources yet. add_source adds a document by text or link; the Knowledge screen takes files.");
|
|
1636
|
+
return text(sources.map((s) => `${s.title} (id ${s.id}, v${s.version}, ${s.kind}${s.externalUrl ? `, ${s.externalUrl}` : ""}) \u2014 ${s.requirements["new"] ?? 0} new, ${s.requirements["changed"] ?? 0} changed, ${s.requirements["active"] ?? 0} unchanged, ${s.requirements["removed"] ?? 0} removed`).join("\n"));
|
|
1637
|
+
}
|
|
1638
|
+
},
|
|
1639
|
+
{
|
|
1640
|
+
name: "add_source",
|
|
1641
|
+
title: "Add a source document",
|
|
1642
|
+
description: "Add a document tests are written from \u2014 as text (Markdown is best: headings and lists are how requirements are found) or as a public link \u2014 or a new version of one (sourceId). The same text again is not a new version. Requirements are found and compared at once.",
|
|
1643
|
+
inputSchema: {
|
|
1644
|
+
type: "object",
|
|
1645
|
+
properties: { projectId: { type: "string" }, title: { type: "string" }, text: { type: "string" }, url: { type: "string" }, sourceId: { type: "string" } },
|
|
1646
|
+
additionalProperties: false
|
|
1647
|
+
},
|
|
1648
|
+
run: async (args) => {
|
|
1649
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1650
|
+
const made = await service.addSource(projectId, {
|
|
1651
|
+
...str(args["title"]) ? { title: str(args["title"]) } : {},
|
|
1652
|
+
...typeof args["text"] === "string" ? { text: args["text"] } : {},
|
|
1653
|
+
...str(args["url"]) ? { url: str(args["url"]) } : {}
|
|
1654
|
+
}, str(args["sourceId"]));
|
|
1655
|
+
const r = made.requirements;
|
|
1656
|
+
return text(made.changed ? `${made.source.title} is now v${made.source.version} (id ${made.source.id}). Requirements: ${r["added"] ?? 0} new, ${r["changed"] ?? 0} changed, ${r["unchanged"] ?? 0} unchanged, ${r["removed"] ?? 0} removed.` : `${made.source.title} has not changed since v${made.source.version}; nothing new.`);
|
|
1657
|
+
}
|
|
1658
|
+
},
|
|
1659
|
+
{
|
|
1660
|
+
name: "delete_source",
|
|
1661
|
+
title: "Delete a source document",
|
|
1662
|
+
description: "Delete a source and everything found in it, for good. Only after the person has said to.",
|
|
1663
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, sourceId: { type: "string" } }, required: ["sourceId"], additionalProperties: false },
|
|
1664
|
+
annotations: { destructiveHint: true },
|
|
1665
|
+
run: async (args) => {
|
|
1666
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1667
|
+
await service.deleteSource(projectId, str(args["sourceId"]) ?? "");
|
|
1668
|
+
return text(`Deleted the source ${str(args["sourceId"])} and its requirements.`);
|
|
1669
|
+
}
|
|
1670
|
+
},
|
|
1671
|
+
{
|
|
1672
|
+
name: "update_requirement",
|
|
1673
|
+
title: "File or correct a requirement",
|
|
1674
|
+
description: "Set which feature a requirement belongs to (featureId like f_checkout), or correct its wording where extraction got it wrong.",
|
|
1675
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, requirementId: { type: "string" }, featureId: { type: "string" }, text: { type: "string" } }, required: ["requirementId"], additionalProperties: false },
|
|
1676
|
+
run: async (args) => {
|
|
1677
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1678
|
+
const saved = await service.updateRequirement(projectId, str(args["requirementId"]) ?? "", {
|
|
1679
|
+
...typeof args["featureId"] === "string" ? { featureId: args["featureId"] } : {},
|
|
1680
|
+
...str(args["text"]) ? { text: str(args["text"]) } : {}
|
|
1681
|
+
});
|
|
1682
|
+
return text(`${saved.id}${saved.featureId ? ` is filed under ${saved.featureId}` : ""}: ${saved.text}`);
|
|
1683
|
+
}
|
|
1684
|
+
},
|
|
1685
|
+
{
|
|
1686
|
+
name: "requirements",
|
|
1687
|
+
title: "Read requirements",
|
|
1688
|
+
description: "Requirements found in sources, each with where it is (heading path), its state as of the source's latest version \u2014 new, changed (with what it said before), active (unchanged) or removed \u2014 and its id for covers. Optional sourceId and state narrow it.",
|
|
1689
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, sourceId: { type: "string" }, state: { type: "string", enum: ["new", "changed", "active", "removed"] } }, additionalProperties: false },
|
|
1690
|
+
annotations: { readOnlyHint: true },
|
|
1691
|
+
run: async (args) => {
|
|
1692
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1693
|
+
const [{ requirements }, { cases }] = await Promise.all([
|
|
1694
|
+
service.requirements(projectId, { ...str(args["sourceId"]) ? { sourceId: str(args["sourceId"]) } : {}, ...str(args["state"]) ? { state: str(args["state"]) } : {} }),
|
|
1695
|
+
service.library(projectId)
|
|
1696
|
+
]);
|
|
1697
|
+
if (!requirements.length) return text("No requirements match.");
|
|
1698
|
+
return text(requirements.map((r) => {
|
|
1699
|
+
const covering = cases.filter((c) => c.covers.includes(r.id)).map((c) => c.key);
|
|
1700
|
+
return `${r.id} [${r.state}] ${r.sourceTitle} \u203A ${r.anchor}: ${r.text}${r.state === "changed" && r.history.length ? `
|
|
1701
|
+
was: ${r.history.at(-1).text}` : ""}
|
|
1702
|
+
covered by: ${covering.join(", ") || "nothing yet"}`;
|
|
1703
|
+
}).join("\n"));
|
|
1704
|
+
}
|
|
1705
|
+
},
|
|
1706
|
+
{
|
|
1707
|
+
name: "find_similar_cases",
|
|
1708
|
+
title: "Find test cases like this one",
|
|
1709
|
+
description: "Before writing a test case, find existing ones that may already check the same thing \u2014 by title and steps. You judge whether one is really the same; if it is, link the requirement to it (covers) instead of writing a duplicate.",
|
|
1710
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, text: { type: "string", description: "The case you are about to write: its title and steps." } }, required: ["text"], additionalProperties: false },
|
|
1711
|
+
annotations: { readOnlyHint: true },
|
|
1712
|
+
run: async (args) => {
|
|
1713
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1714
|
+
const wanted = String(args["text"] ?? "");
|
|
1715
|
+
const { cases } = await service.library(projectId);
|
|
1716
|
+
const scored = cases.map((c) => ({ c, score: similarity(wanted, [c.title, ...c.steps.flatMap((st) => [st.action, st.expected ?? ""])].join(" ")) })).filter((x) => x.score >= 0.2).sort((a, b) => b.score - a.score).slice(0, 8);
|
|
1717
|
+
if (!scored.length) return text("Nothing similar in the library.");
|
|
1718
|
+
return text(scored.map(({ c, score }) => `${c.key} (id ${c.id}, revision ${c.revision}, ${Math.round(score * 100)}% alike) ${c.title}
|
|
1719
|
+
${c.steps.map((st, i) => ` ${i + 1}. ${st.action}${st.expected ? ` \u2192 ${st.expected}` : ""}`).join("\n")}`).join("\n"));
|
|
1720
|
+
}
|
|
1721
|
+
},
|
|
1722
|
+
{
|
|
1723
|
+
name: "save_automation_plan",
|
|
1724
|
+
title: "Write the plan to automate a test case",
|
|
1725
|
+
description: "Save the plan for automating a test case (TC key or id) before recording anything: flows to reuse, flows to record, which check proves each expected result, accounts and helpers it needs, and what cannot be automated and why. Marks it planned. approved: true only when the person has said the plan is right.",
|
|
1726
|
+
inputSchema: {
|
|
1727
|
+
type: "object",
|
|
1728
|
+
properties: { projectId: { type: "string" }, testCase: { type: "string" }, plan: { type: "string" }, approved: { type: "boolean" }, branch: { type: "string" } },
|
|
1729
|
+
required: ["testCase", "plan"],
|
|
1730
|
+
additionalProperties: false
|
|
1731
|
+
},
|
|
1732
|
+
run: async (args) => {
|
|
1733
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1734
|
+
const ref = str(args["testCase"]) ?? "";
|
|
1735
|
+
const { cases } = await service.library(projectId);
|
|
1736
|
+
const target = cases.find((c) => c.id === ref || c.key.toLowerCase() === ref.toLowerCase());
|
|
1737
|
+
if (!target) return text(`No test case ${ref} in the library.`);
|
|
1738
|
+
const saved = await service.saveLibrary(projectId, {
|
|
1739
|
+
update: [{
|
|
1740
|
+
id: target.id,
|
|
1741
|
+
revision: target.revision,
|
|
1742
|
+
automation: {
|
|
1743
|
+
...target.automation,
|
|
1744
|
+
plan: String(args["plan"] ?? ""),
|
|
1745
|
+
planApproved: args["approved"] === true,
|
|
1746
|
+
state: target.automation.links.length ? target.automation.state : "planned"
|
|
1747
|
+
}
|
|
1748
|
+
}],
|
|
1749
|
+
message: `Automation plan for ${target.key}`,
|
|
1750
|
+
...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
|
|
1751
|
+
});
|
|
1752
|
+
return text(`Saved the ${args["approved"] === true ? "approved " : ""}automation plan for ${target.key} "${target.title}" on Claude's working branch ${saved.branch}.`);
|
|
1753
|
+
}
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
name: "test_cycles",
|
|
1757
|
+
title: "Read manual test cycles",
|
|
1758
|
+
description: "Manual test cycles and how they are going. Without cycleId: every cycle with its counts (passed, failed, blocked, untested). With cycleId: each case in it \u2014 key, title, steps, the outcome of each step, notes, evidence and bugs.",
|
|
1759
|
+
inputSchema: {
|
|
1760
|
+
type: "object",
|
|
1761
|
+
properties: { projectId: { type: "string" }, cycleId: { type: "string" } },
|
|
1762
|
+
additionalProperties: false
|
|
1763
|
+
},
|
|
1764
|
+
annotations: { readOnlyHint: true },
|
|
1765
|
+
run: async (args) => {
|
|
1766
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1767
|
+
const counts = (s) => `${s.passed} passed, ${s.failed} failed, ${s.blocked} blocked, ${s.skipped} skipped, ${s.untested} untested of ${s.total}`;
|
|
1768
|
+
const id = str(args["cycleId"]);
|
|
1769
|
+
if (!id) {
|
|
1770
|
+
const { cycles } = await service.cycles(projectId);
|
|
1771
|
+
if (!cycles.length) return text("No test cycles yet. manage_cycle with action create makes one from library cases.");
|
|
1772
|
+
return text(cycles.map((c) => `${c.name} (id ${c.id}, ${c.status}) \u2014 ${counts(c.summary)}`).join("\n"));
|
|
1773
|
+
}
|
|
1774
|
+
const cycle = await service.cycle(projectId, id);
|
|
1775
|
+
const lines = cycle.entries.map((e) => {
|
|
1776
|
+
const r = e.result;
|
|
1777
|
+
const steps = e.steps.map((st, i) => {
|
|
1778
|
+
const got = r?.steps.find((x) => x.stepId === st.id);
|
|
1779
|
+
return ` ${i + 1}. ${st.action}${st.expected ? ` \u2192 ${st.expected}` : ""}: ${got?.outcome ?? "untested"}${got?.actual ? ` (actual: ${got.actual})` : ""}`;
|
|
1780
|
+
}).join("\n");
|
|
1781
|
+
return `${e.key} ${e.title} [${e.priority}] \u2014 ${r?.outcome ?? "untested"}${e.assignee ? `, assigned to ${e.assignee}` : ""}${r?.note ? `
|
|
1782
|
+
Note: ${r.note}` : ""}${r?.bugIds.length ? `
|
|
1783
|
+
Bugs: ${r.bugIds.join(", ")}` : ""}${r?.evidence.length ? `
|
|
1784
|
+
Evidence: ${r.evidence.map((ev) => ev.url ? `${ev.name} <${ev.url}>` : ev.name).join(", ")}` : ""}${steps ? `
|
|
1785
|
+
${steps}` : ""}`;
|
|
1786
|
+
});
|
|
1787
|
+
return text(`${cycle.name} (${cycle.status}) \u2014 ${counts(cycle.summary)}
|
|
1788
|
+
${lines.join("\n")}`);
|
|
1789
|
+
}
|
|
1790
|
+
},
|
|
1791
|
+
{
|
|
1792
|
+
name: "manage_cycle",
|
|
1793
|
+
title: "Make or change a test cycle",
|
|
1794
|
+
description: "action create: {name, description?, testCases: [TC keys or ids]} \u2014 cases are taken from the library as they are now (on Claude's working branch when it has one). action update: {cycleId, name?, description?, addTestCases?, removeTestCases?, assign?: {testCase, assignee}}. action close / reopen: {cycleId}. action delete: {cycleId} \u2014 deletes the cycle and its results for good; only after the person has said to.",
|
|
1795
|
+
inputSchema: {
|
|
1796
|
+
type: "object",
|
|
1797
|
+
properties: {
|
|
1798
|
+
projectId: { type: "string" },
|
|
1799
|
+
action: { type: "string", enum: ["create", "update", "close", "reopen", "delete"] },
|
|
1800
|
+
cycleId: { type: "string" },
|
|
1801
|
+
name: { type: "string" },
|
|
1802
|
+
description: { type: "string" },
|
|
1803
|
+
testCases: { type: "array", items: { type: "string" } },
|
|
1804
|
+
addTestCases: { type: "array", items: { type: "string" } },
|
|
1805
|
+
removeTestCases: { type: "array", items: { type: "string" } },
|
|
1806
|
+
assign: { type: "object", properties: { testCase: { type: "string" }, assignee: { type: "string" } }, additionalProperties: false }
|
|
1807
|
+
},
|
|
1808
|
+
required: ["action"],
|
|
1809
|
+
additionalProperties: false
|
|
1810
|
+
},
|
|
1811
|
+
run: async (args) => {
|
|
1812
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1813
|
+
const action = str(args["action"]);
|
|
1814
|
+
const cycleId = str(args["cycleId"]);
|
|
1815
|
+
const resolve = async (refs) => {
|
|
1816
|
+
const wanted = Array.isArray(refs) ? refs.map(String) : [];
|
|
1817
|
+
if (!wanted.length) return { ids: [], changeId: void 0 };
|
|
1818
|
+
const lib = await service.library(projectId);
|
|
1819
|
+
const ids = wanted.map((ref) => lib.cases.find((c) => c.id === ref || c.key.toLowerCase() === ref.toLowerCase())?.id);
|
|
1820
|
+
const missing = wanted.filter((_, i) => !ids[i]);
|
|
1821
|
+
if (missing.length) throw new Error(`no test case ${missing.join(", ")} in the library \u2014 library_cases lists them`);
|
|
1822
|
+
return { ids, changeId: lib.changeId };
|
|
1823
|
+
};
|
|
1824
|
+
if (action === "create") {
|
|
1825
|
+
const name = str(args["name"]);
|
|
1826
|
+
if (!name) return text("A cycle needs a name.");
|
|
1827
|
+
const { ids, changeId } = await resolve(args["testCases"]);
|
|
1828
|
+
const made = await service.createCycle(projectId, { name, ...str(args["description"]) ? { description: str(args["description"]) } : {}, testCaseIds: ids }, changeId);
|
|
1829
|
+
return text(`Made the cycle "${made.name}" (id ${made.id}) with ${made.entries.length} case${made.entries.length === 1 ? "" : "s"}: ${made.entries.map((e) => e.key).join(", ")}.`);
|
|
1830
|
+
}
|
|
1831
|
+
if (!cycleId) return text("Say which cycle (cycleId \u2014 test_cycles lists them).");
|
|
1832
|
+
if (action === "delete") {
|
|
1833
|
+
await service.deleteCycle(projectId, cycleId);
|
|
1834
|
+
return text(`Deleted the cycle ${cycleId} and its results.`);
|
|
1835
|
+
}
|
|
1836
|
+
if (action === "close" || action === "reopen") {
|
|
1837
|
+
const saved2 = await service.updateCycle(projectId, cycleId, { status: action === "close" ? "closed" : "open" });
|
|
1838
|
+
return text(`${saved2.name} is ${saved2.status}.`);
|
|
1839
|
+
}
|
|
1840
|
+
const current = await service.cycle(projectId, cycleId);
|
|
1841
|
+
const inCycle = (ref) => current.entries.find((e) => e.testCaseId === ref || e.key.toLowerCase() === ref.toLowerCase())?.testCaseId;
|
|
1842
|
+
const added = await resolve(args["addTestCases"]);
|
|
1843
|
+
const body = {
|
|
1844
|
+
...str(args["name"]) ? { name: str(args["name"]) } : {},
|
|
1845
|
+
...typeof args["description"] === "string" ? { description: args["description"] } : {},
|
|
1846
|
+
...added.ids.length ? { addCaseIds: added.ids } : {},
|
|
1847
|
+
...Array.isArray(args["removeTestCases"]) ? { removeCaseIds: args["removeTestCases"].map(String).map((r) => inCycle(r) ?? r) } : {}
|
|
1848
|
+
};
|
|
1849
|
+
const assign = args["assign"];
|
|
1850
|
+
if (assign?.testCase) body["assign"] = { testCaseId: inCycle(assign.testCase) ?? assign.testCase, assignee: assign.assignee ?? "" };
|
|
1851
|
+
const saved = await service.updateCycle(projectId, cycleId, body, added.changeId);
|
|
1852
|
+
return text(`Updated "${saved.name}": ${saved.entries.length} case${saved.entries.length === 1 ? "" : "s"} (${saved.entries.map((e) => e.key).join(", ")}).`);
|
|
1853
|
+
}
|
|
1854
|
+
},
|
|
1855
|
+
{
|
|
1856
|
+
name: "record_result",
|
|
1857
|
+
title: "Record how a case went",
|
|
1858
|
+
description: "Record a manual result for one case in a cycle: steps [{step: its number (1, 2\u2026), outcome: passed|failed|blocked|skipped|untested, actual?: what really happened}], or a whole outcome, a note, links as evidence ([{name, url}]). Any failed step fails the case. retest: true keeps the earlier attempt in the case's history. Only record what the person tells you happened.",
|
|
1859
|
+
inputSchema: {
|
|
1860
|
+
type: "object",
|
|
1861
|
+
properties: {
|
|
1862
|
+
projectId: { type: "string" },
|
|
1863
|
+
cycleId: { type: "string" },
|
|
1864
|
+
testCase: { type: "string", description: "TC-12, or the test case id." },
|
|
1865
|
+
steps: { type: "array", items: { type: "object" } },
|
|
1866
|
+
outcome: { type: "string", enum: ["passed", "failed", "blocked", "skipped", "untested"] },
|
|
1867
|
+
note: { type: "string" },
|
|
1868
|
+
retest: { type: "boolean" },
|
|
1869
|
+
evidenceLinks: { type: "array", items: { type: "object", properties: { name: { type: "string" }, url: { type: "string" } }, additionalProperties: false } }
|
|
1870
|
+
},
|
|
1871
|
+
required: ["cycleId", "testCase"],
|
|
1872
|
+
additionalProperties: false
|
|
1873
|
+
},
|
|
1874
|
+
run: async (args) => {
|
|
1875
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1876
|
+
const cycleId = str(args["cycleId"]) ?? "";
|
|
1877
|
+
const cycle = await service.cycle(projectId, cycleId);
|
|
1878
|
+
const ref = str(args["testCase"]) ?? "";
|
|
1879
|
+
const entry = cycle.entries.find((e) => e.testCaseId === ref || e.key.toLowerCase() === ref.toLowerCase());
|
|
1880
|
+
if (!entry) return text(`${ref} is not in "${cycle.name}" \u2014 test_cycles lists its cases.`);
|
|
1881
|
+
const steps = (Array.isArray(args["steps"]) ? args["steps"] : []).map((raw) => {
|
|
1882
|
+
const item = raw;
|
|
1883
|
+
const at = typeof item.step === "number" ? entry.steps[item.step - 1]?.id : void 0;
|
|
1884
|
+
return { stepId: String(item.stepId ?? at ?? ""), outcome: String(item.outcome ?? ""), ...typeof item.actual === "string" ? { actual: item.actual } : {} };
|
|
1885
|
+
});
|
|
1886
|
+
const saved = await service.recordResult(projectId, cycleId, entry.testCaseId, {
|
|
1887
|
+
steps,
|
|
1888
|
+
...str(args["outcome"]) ? { outcome: str(args["outcome"]) } : {},
|
|
1889
|
+
...typeof args["note"] === "string" ? { note: args["note"] } : {},
|
|
1890
|
+
...args["retest"] === true ? { retest: true } : {}
|
|
1891
|
+
});
|
|
1892
|
+
for (const link of Array.isArray(args["evidenceLinks"]) ? args["evidenceLinks"] : []) {
|
|
1893
|
+
const { name, url } = link;
|
|
1894
|
+
if (url) await service.addEvidenceLink(projectId, cycleId, entry.testCaseId, { name: name || "Evidence", url });
|
|
1895
|
+
}
|
|
1896
|
+
const now = saved.entries.find((e) => e.testCaseId === entry.testCaseId);
|
|
1897
|
+
return text(`${entry.key} is ${now.result?.outcome ?? "untested"} in "${saved.name}". The cycle: ${saved.summary.passed} passed, ${saved.summary.failed} failed, ${saved.summary.blocked} blocked, ${saved.summary.untested} untested.`);
|
|
1898
|
+
}
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
name: "bugs",
|
|
1902
|
+
title: "Read bugs",
|
|
1903
|
+
description: "The project's bugs: key (BUG-n), title, severity, status, and the test cases and cycle step they were found at. Optional status narrows it (open, in-progress, fixed, verified, closed, wont-fix).",
|
|
1904
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" }, status: { type: "string" } }, additionalProperties: false },
|
|
1905
|
+
annotations: { readOnlyHint: true },
|
|
1906
|
+
run: async (args) => {
|
|
1907
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1908
|
+
const { bugs } = await service.bugs(projectId);
|
|
1909
|
+
const shown = str(args["status"]) ? bugs.filter((b) => b.status === str(args["status"])) : bugs;
|
|
1910
|
+
if (!shown.length) return text("No bugs.");
|
|
1911
|
+
return text(shown.map((b) => `${b.key} (id ${b.id}) ${b.title} \u2014 ${b.severity}, ${b.status}${b.testCaseIds.length ? `, test cases ${b.testCaseIds.join(", ")}` : ""}${b.description ? `
|
|
1912
|
+
${b.description.slice(0, 300)}` : ""}`).join("\n"));
|
|
1913
|
+
}
|
|
1914
|
+
},
|
|
1915
|
+
{
|
|
1916
|
+
name: "save_bug",
|
|
1917
|
+
title: "Raise or update a bug",
|
|
1918
|
+
description: "New: {title, description?, severity?: critical|major|minor|trivial, cycleId?, testCase?, step? (its number)} \u2014 found in a cycle, it is attached to that case's result there. Update: {bugId, title?, description?, severity?, status?: open|in-progress|fixed|verified|closed|wont-fix}.",
|
|
1919
|
+
inputSchema: {
|
|
1920
|
+
type: "object",
|
|
1921
|
+
properties: {
|
|
1922
|
+
projectId: { type: "string" },
|
|
1923
|
+
bugId: { type: "string" },
|
|
1924
|
+
title: { type: "string" },
|
|
1925
|
+
description: { type: "string" },
|
|
1926
|
+
severity: { type: "string" },
|
|
1927
|
+
status: { type: "string" },
|
|
1928
|
+
cycleId: { type: "string" },
|
|
1929
|
+
testCase: { type: "string" },
|
|
1930
|
+
step: { type: "number" }
|
|
1931
|
+
},
|
|
1932
|
+
additionalProperties: false
|
|
1933
|
+
},
|
|
1934
|
+
run: async (args) => {
|
|
1935
|
+
const projectId = await projectFor(args["projectId"]);
|
|
1936
|
+
const bugId = str(args["bugId"]);
|
|
1937
|
+
if (bugId) {
|
|
1938
|
+
const body2 = Object.fromEntries(["title", "description", "severity", "status"].filter((k) => typeof args[k] === "string").map((k) => [k, args[k]]));
|
|
1939
|
+
const saved = await service.updateBug(projectId, bugId, body2);
|
|
1940
|
+
return text(`${saved.key} "${saved.title}" is ${saved.status} (${saved.severity}).`);
|
|
1941
|
+
}
|
|
1942
|
+
const title = str(args["title"]);
|
|
1943
|
+
if (!title) return text("A bug needs a title.");
|
|
1944
|
+
const body = {
|
|
1945
|
+
title,
|
|
1946
|
+
...typeof args["description"] === "string" ? { description: args["description"] } : {},
|
|
1947
|
+
...str(args["severity"]) ? { severity: str(args["severity"]) } : {}
|
|
1948
|
+
};
|
|
1949
|
+
const cycleId = str(args["cycleId"]);
|
|
1950
|
+
const ref = str(args["testCase"]);
|
|
1951
|
+
if (cycleId && ref) {
|
|
1952
|
+
const cycle = await service.cycle(projectId, cycleId);
|
|
1953
|
+
const entry = cycle.entries.find((e) => e.testCaseId === ref || e.key.toLowerCase() === ref.toLowerCase());
|
|
1954
|
+
if (!entry) return text(`${ref} is not in that cycle.`);
|
|
1955
|
+
const step = typeof args["step"] === "number" ? entry.steps[args["step"] - 1]?.id : void 0;
|
|
1956
|
+
body["foundAt"] = { cycleId, testCaseId: entry.testCaseId, ...step ? { stepId: step } : {} };
|
|
1957
|
+
} else if (ref) {
|
|
1958
|
+
const lib = await service.library(projectId);
|
|
1959
|
+
const found = lib.cases.find((c) => c.id === ref || c.key.toLowerCase() === ref.toLowerCase());
|
|
1960
|
+
if (found) body["testCaseIds"] = [found.id];
|
|
1961
|
+
}
|
|
1962
|
+
const made = await service.createBug(projectId, body);
|
|
1963
|
+
return text(`Raised ${made.key} "${made.title}" (${made.severity}).`);
|
|
1964
|
+
}
|
|
1965
|
+
},
|
|
1966
|
+
{
|
|
1967
|
+
name: "close_browser",
|
|
1968
|
+
title: "Close the browser",
|
|
1969
|
+
description: "Close the browser and drop the flow being written, unsaved.",
|
|
1970
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
1971
|
+
annotations: { destructiveHint: true },
|
|
1972
|
+
run: async () => {
|
|
1973
|
+
await session.close();
|
|
1974
|
+
session.draft = null;
|
|
1975
|
+
return text("Closed.");
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
];
|
|
1979
|
+
return tools.map((tool) => ({
|
|
1980
|
+
...tool,
|
|
1981
|
+
run: async (args) => {
|
|
1982
|
+
let result;
|
|
1983
|
+
try {
|
|
1984
|
+
result = await tool.run(args);
|
|
1985
|
+
} catch (error) {
|
|
1986
|
+
throw new Error(session.redact(error.message));
|
|
1987
|
+
}
|
|
1988
|
+
return { ...result, content: result.content.map((c) => c.type === "text" && c.text !== void 0 ? { ...c, text: session.redact(c.text) } : c) };
|
|
1989
|
+
}
|
|
1990
|
+
}));
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// src/mcp/index.ts
|
|
1994
|
+
async function runMcp(opts) {
|
|
1995
|
+
let service;
|
|
1996
|
+
try {
|
|
1997
|
+
service = resolveService(opts.service);
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
process.stderr.write(`${error.message}
|
|
2000
|
+
`);
|
|
2001
|
+
return 2;
|
|
2002
|
+
}
|
|
2003
|
+
const connection = connectionFor(service);
|
|
2004
|
+
const session = connection ? new AuthoringSession(new ServiceClient(connection), opts.headless ? { headless: true } : {}) : null;
|
|
2005
|
+
let tools = connection && session ? authoringTools(new ServiceClient(connection), session) : authoringTools(null, null);
|
|
2006
|
+
if (!connection) {
|
|
2007
|
+
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.`;
|
|
2008
|
+
tools = tools.map((tool) => ({ ...tool, run: async () => ({ content: [{ type: "text", text: why }], isError: true }) }));
|
|
2009
|
+
}
|
|
2010
|
+
const closing = async () => {
|
|
2011
|
+
await session?.close();
|
|
2012
|
+
process.exit(0);
|
|
2013
|
+
};
|
|
2014
|
+
process.on("SIGINT", () => void closing());
|
|
2015
|
+
process.on("SIGTERM", () => void closing());
|
|
2016
|
+
await serve(tools, { name: "flowriki", version: opts.version, instructions: INSTRUCTIONS });
|
|
2017
|
+
await session?.close();
|
|
2018
|
+
return 0;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// src/cli.ts
|
|
15
2022
|
var paint = (code) => (s) => `\x1B[${code}m${s}\x1B[0m`;
|
|
16
2023
|
var bold = paint("1");
|
|
17
2024
|
var dim = paint("2");
|
|
@@ -30,6 +2037,9 @@ ${bold("USAGE")}
|
|
|
30
2037
|
flowriki-agent doctor diagnose setup problems
|
|
31
2038
|
flowriki-agent prepare download the browser recording needs
|
|
32
2039
|
flowriki-agent disconnect forget this computer's credential
|
|
2040
|
+
flowriki-agent mcp Flowriki as tools for Claude \u2014 started by
|
|
2041
|
+
Claude Code or Claude Desktop, not by you:
|
|
2042
|
+
claude mcp add flowriki -- flowriki-agent mcp
|
|
33
2043
|
|
|
34
2044
|
${bold("OPTIONS")}
|
|
35
2045
|
--project <dir> a checkout to serve; repeat for several.
|
|
@@ -37,6 +2047,7 @@ ${bold("OPTIONS")}
|
|
|
37
2047
|
--name <label> how this computer appears in your dashboard
|
|
38
2048
|
--service <url> advanced: a self-hosted Flowriki. Defaults to the
|
|
39
2049
|
hosted service, which is what you want.
|
|
2050
|
+
--no-open print the approval link without opening a browser
|
|
40
2051
|
--json machine-readable output for status and doctor
|
|
41
2052
|
-h, --help this
|
|
42
2053
|
-v, --version version
|
|
@@ -56,6 +2067,7 @@ async function main(argv) {
|
|
|
56
2067
|
name: { type: "string" },
|
|
57
2068
|
service: { type: "string" },
|
|
58
2069
|
json: { type: "boolean" },
|
|
2070
|
+
"no-open": { type: "boolean" },
|
|
59
2071
|
help: { type: "boolean", short: "h" },
|
|
60
2072
|
version: { type: "boolean", short: "v" }
|
|
61
2073
|
}
|
|
@@ -79,8 +2091,13 @@ Run \`flowriki-agent --help\`.`);
|
|
|
79
2091
|
const service = values.service ? String(values.service) : void 0;
|
|
80
2092
|
const projects = (values.project ?? []).map(String);
|
|
81
2093
|
switch (command) {
|
|
2094
|
+
// FIRST, and before anything writes to stdout: in this mode stdout is the
|
|
2095
|
+
// protocol, and one stray line of output is a message Claude rejects.
|
|
2096
|
+
case "mcp":
|
|
2097
|
+
return runMcp({ ...service ? { service } : {}, version: AGENT_VERSION });
|
|
82
2098
|
case "connect": {
|
|
83
2099
|
const result = await connect({
|
|
2100
|
+
open: !values["no-open"],
|
|
84
2101
|
...service ? { service } : {},
|
|
85
2102
|
...values.name ? { machineName: String(values.name) } : {},
|
|
86
2103
|
onProgress: (note) => {
|
|
@@ -91,7 +2108,7 @@ Run \`flowriki-agent --help\`.`);
|
|
|
91
2108
|
console.log(` ${url}
|
|
92
2109
|
`);
|
|
93
2110
|
if (note.detail?.["canOpen"] === false) {
|
|
94
|
-
console.log(dim("
|
|
2111
|
+
console.log(dim(" Open the approval link in your browser.\n"));
|
|
95
2112
|
} else {
|
|
96
2113
|
console.log(dim(" Opening it for you. If nothing happens, use the link above.\n"));
|
|
97
2114
|
}
|
|
@@ -108,6 +2125,11 @@ Run \`flowriki-agent --help\`.`);
|
|
|
108
2125
|
return 0;
|
|
109
2126
|
}
|
|
110
2127
|
case "start": {
|
|
2128
|
+
process.on("unhandledRejection", (reason) => {
|
|
2129
|
+
const said = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
|
|
2130
|
+
console.error(red(`! a command failed without being handled \u2014 the agent is still running`));
|
|
2131
|
+
console.error(dim(said.split("\n").slice(0, 4).join("\n")));
|
|
2132
|
+
});
|
|
111
2133
|
const started = await start({
|
|
112
2134
|
...service ? { service } : {},
|
|
113
2135
|
...projects.length ? { projects } : {},
|
|
@@ -188,7 +2210,11 @@ Run \`flowriki-agent --help\`.`);
|
|
|
188
2210
|
console.log(` node ${found.node} ${dim(`${found.platform}/${found.arch}`)}`);
|
|
189
2211
|
console.log(` protocol ${found.protocolVersion}`);
|
|
190
2212
|
console.log(` data ${found.dataDir}`);
|
|
191
|
-
console.log(
|
|
2213
|
+
console.log(
|
|
2214
|
+
` service ${found.service} ${found.reachable ? green("reachable") : red("unreachable")}` + // WHICH FLOWRIKI, from the service's own mouth. Not the site being
|
|
2215
|
+
// tested — that is a project setting and changes nothing here.
|
|
2216
|
+
(found.environment ? dim(` [${found.environment}]`) : "")
|
|
2217
|
+
);
|
|
192
2218
|
console.log(` credential ${found.credentialFingerprint ?? dim("none stored")}`);
|
|
193
2219
|
console.log(
|
|
194
2220
|
` browsers ${found.playwright.chromiumInstalled ? green("chromium ready") : yellow("chromium not installed")} ${dim(found.playwright.browsersPath ?? "")}`
|