@arbium/enterprise-agent-connect-client 0.0.0-stage → 0.1.2-dev.d2aa5ca

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/lib/steps.js ADDED
@@ -0,0 +1,716 @@
1
+ // What the admin pages show, worked out from the admin API's answers: setup steps and their
2
+ // placeholders, statuses, the Integrations tab's grouping, Teams setup and new key setup.
3
+ // Pure functions, no fetch, so a browser bundle can use them.
4
+ /** A copy block's text: a line with {scope} repeats once per scope, {scopes} becomes the
5
+ * scopes space-separated, and any other {key} is filled from the connection's settings.
6
+ * Unknown placeholders stay visible so a missing value is obvious. */
7
+ export function expandCopy(lines, scopes, config = {},
8
+ /** How {scopes} is joined, e.g. ", " in click steps people read. */
9
+ joiner = " ") {
10
+ return lines
11
+ .flatMap((line) => line.includes("{scope}") ? scopes.map((s) => line.replaceAll("{scope}", s)) : [line])
12
+ .map((line) => line
13
+ .replaceAll("{scopes}", scopes.join(joiner))
14
+ .replace(/\{([A-Za-z_]+)\}/g, (whole, key) => config[key] ?? whole))
15
+ .join("\n");
16
+ }
17
+ /** {name} values the ticked capabilities fill into steps, later ones winning, e.g. the
18
+ * site role: read, or write once adding files is ticked. */
19
+ export function fillsFor(connector, ticked) {
20
+ return Object.assign({}, ...connector.capabilities.filter((c) => ticked.includes(c.id)).map((c) => c.fill ?? {}));
21
+ }
22
+ /** The choice fields' values: the stored or typed one, else each field's first option, which
23
+ * also holds while the earlier choice a field `requires` is not made. */
24
+ export function choiceValues(connector, values) {
25
+ const chosen = {};
26
+ for (const f of connector.fields)
27
+ if (f.options)
28
+ chosen[f.id] =
29
+ (isChosen(f.requires, chosen) && f.options.find((o) => o.value === values[f.id])?.value) ||
30
+ f.options[0].value;
31
+ return chosen;
32
+ }
33
+ /** True with no condition, or while its option is the chosen one. */
34
+ export const isChosen = (option, chosen) => !option || chosen[option.field] === option.value;
35
+ /** The choice fields shown: one that `requires` an earlier choice only while it is made. */
36
+ export const askedFields = (fields, values) => {
37
+ const chosen = choiceValues({ fields }, values);
38
+ return fields.filter((f) => isChosen(f.requires, chosen));
39
+ };
40
+ /** The steps that apply under the chosen options (a step with `option` only while chosen). */
41
+ export function chosenSteps(connector, steps, values) {
42
+ const chosen = choiceValues(connector, values);
43
+ return steps.filter((s) => isChosen(s.option, chosen));
44
+ }
45
+ /** Choices that change the permissions the setup steps ask for: made in step 1, with what
46
+ * agents may do, so the steps after it already show those permissions. */
47
+ export function permissionChoices(connector) {
48
+ return connector.fields.filter((f) => f.options?.some((o) => o.scopes?.length));
49
+ }
50
+ /** Permissions the chosen options need, e.g. Sites.Read.All for reading every site. */
51
+ export function optionScopes(connector, values) {
52
+ const chosen = choiceValues(connector, values);
53
+ return connector.fields.flatMap((f) => f.options?.find((o) => o.value === chosen[f.id])?.scopes ?? []);
54
+ }
55
+ /** {site} and {siteRef} (how Graph and PowerShell name it: host, or host:/path) for a site's
56
+ * grant steps and note. */
57
+ export function siteFills(site) {
58
+ const { hostname, pathname } = new URL(site);
59
+ return { site, siteRef: pathname === "/" ? hostname : `${hostname}:${pathname}` };
60
+ }
61
+ /** The note to send whoever manages SharePoint: the connector's request with the site, the
62
+ * app and the role filled, and its first grant step's commands where it says
63
+ * {grantCommands}. */
64
+ /** The site grant steps for the site role the ticks need (`site_role`). */
65
+ export function grantSteps(connector, siteRole) {
66
+ return (connector.siteAccess?.grant ?? []).filter((s) => !s.siteRole || s.siteRole === siteRole);
67
+ }
68
+ export function siteRequest(connector, site, scopes, config) {
69
+ const fills = { ...config, ...siteFills(site) };
70
+ const first = connector.siteAccess?.grant[0]?.copy ?? [];
71
+ return expandCopy([connector.siteAccess?.request ?? ""], scopes, {
72
+ ...fills,
73
+ grantCommands: expandCopy(first, scopes, fills),
74
+ });
75
+ }
76
+ /** The apps a key uses, in the order the apps are listed. */
77
+ export function keyApps(agent, connectors) {
78
+ return connectors.filter((c) => agent.grants.some((g) => g.connectorId === c.id));
79
+ }
80
+ /** How many active keys use an app (a disabled key uses nothing). */
81
+ export function keysUsing(agents, connectorId) {
82
+ return agents.filter((a) => a.status === "active" && a.grants.some((g) => g.connectorId === connectorId)).length;
83
+ }
84
+ /** The values a connected app reported for its discover probe, e.g. Jira project keys. */
85
+ export function discovered(connector, checks) {
86
+ if (!connector.discover || !checks)
87
+ return [];
88
+ return checks.find((c) => c.label === connector.discover.label)?.values ?? [];
89
+ }
90
+ /** Parses free-typed list input ("KAN, SAM1") into values. */
91
+ export function splitList(raw) {
92
+ return [
93
+ ...new Set(raw
94
+ .split(/[\s,]+/)
95
+ .map((v) => v.trim())
96
+ .filter(Boolean)),
97
+ ];
98
+ }
99
+ /** Shown under Click through for a step the vendor offers only as commands. */
100
+ export const CLICK_UNAVAILABLE = "Microsoft has no admin center page for this step: it is done only with commands. Open Commands, or send them to whoever manages it.";
101
+ /** What a free-typed list box shows: the admin's own text while it still parses to the
102
+ * saved values (so a typed comma or space stays put), else the values themselves. */
103
+ export function listText(typed, values) {
104
+ const parsed = splitList(typed);
105
+ return parsed.length === values.length && parsed.every((v, i) => v === values[i])
106
+ ? typed
107
+ : values.join(", ");
108
+ }
109
+ /** Scopes or in-app permissions the ticked capabilities need, in connector order, each once. */
110
+ export function neededFor(connector, ticked, kind) {
111
+ return [
112
+ ...new Set([
113
+ ...(kind === "scopes" ? (connector.scopes ?? []) : []),
114
+ ...connector.capabilities.filter((c) => ticked.includes(c.id)).flatMap((c) => c[kind]),
115
+ ]),
116
+ ];
117
+ }
118
+ /** The capabilities after ticking or unticking one, in connector order. Required ones stay on;
119
+ * a destructive one is only turned on when `confirm` accepts its warning. */
120
+ export function toggleCapability(connector, current, id, on, confirm) {
121
+ const capability = connector.capabilities.find((c) => c.id === id);
122
+ if (!capability || capability.required)
123
+ return [...current];
124
+ if (on &&
125
+ capability.destructive &&
126
+ !current.includes(id) &&
127
+ !confirm(destructiveWarning(connector, capability)))
128
+ return [...current];
129
+ return connector.capabilities
130
+ .filter((c) => c.required || (c.id === id ? on : current.includes(c.id)))
131
+ .map((c) => c.id);
132
+ }
133
+ export function destructiveWarning(connector, capability) {
134
+ return `Turn on ${capability.label} in ${connector.name}? ${capability.description} Agents can do this without asking anyone first.`;
135
+ }
136
+ /** The provider form's values to send: only the fields on show, so a secret typed and then
137
+ * hidden by going back to the certificate is not sent (a secret sent wins the sign-in). */
138
+ export function providerFields(fields, useSecret, values) {
139
+ return Object.fromEntries(fields
140
+ .filter((f) => (!f.secret || useSecret) && values[f.id] !== undefined)
141
+ .map((f) => [f.id, values[f.id]]));
142
+ }
143
+ /** Whether a provider connection is usable by its apps. */
144
+ export function providerReady(connection) {
145
+ return connection !== null && connection.status !== "pending";
146
+ }
147
+ /** A provider connection's status, as its card and page show it. */
148
+ export function providerStatus(connection) {
149
+ if (!providerReady(connection))
150
+ return "not_connected";
151
+ return connection.status === "connected" ? "connected" : "needs_reconnect";
152
+ }
153
+ /** The Integrations tab's layout: apps of their own first, then each provider's apps
154
+ * together under it, by their `order`, then name. Providers without apps are left out. */
155
+ export function groupByProvider(apps, providers) {
156
+ const byOrder = (a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.name.localeCompare(b.name);
157
+ const own = apps.filter((a) => !a.provider).sort(byOrder);
158
+ return [
159
+ ...(own.length ? [{ provider: null, apps: own }] : []),
160
+ ...providers
161
+ .map((provider) => ({
162
+ provider,
163
+ apps: apps.filter((a) => a.provider === provider.id).sort(byOrder),
164
+ }))
165
+ .filter((g) => g.apps.length > 0),
166
+ ];
167
+ }
168
+ /** What the Integrations tab says about a provider connection: its certificate running out
169
+ * with no new one waiting (every app of it stops that day). */
170
+ export function certificateNotice(connection, now = Date.now()) {
171
+ const certificate = connection?.certificate;
172
+ if (!certificate || connection.nextCertificate || !expiresSoon(certificate, now))
173
+ return null;
174
+ return { expiresAt: certificate.expiresAt };
175
+ }
176
+ /** A certificate expiring within this many days is called out. */
177
+ export const CERTIFICATE_WARNING_DAYS = 30;
178
+ export function expiresSoon(info, now = Date.now()) {
179
+ return new Date(info.expiresAt).getTime() - now < CERTIFICATE_WARNING_DAYS * 86_400_000;
180
+ }
181
+ /** How a provider connection signs in, in the admin's words. */
182
+ export function signInMethod(connection) {
183
+ const auth = connection.config.auth;
184
+ if (auth === "certificate")
185
+ return "Certificate";
186
+ if (auth === "client_secret")
187
+ return "Client secret";
188
+ if (auth === "arbium_app")
189
+ return "The shared app, approved by your administrator";
190
+ throw new Error(`Unknown sign-in ${auth}`);
191
+ }
192
+ /** A saved connection whose last check failed needs reconnecting, as does one marked so; one
193
+ * whose check passed with a warning (e.g. Jira can browse no project yet) needs steps. */
194
+ export function appStatus(connection, healthy = true, warned = false) {
195
+ if (!connection)
196
+ return "not_connected";
197
+ if (connection.status !== "connected" || !healthy)
198
+ return "needs_reconnect";
199
+ return warned ? "needs_steps" : "connected";
200
+ }
201
+ /** A check that passed but found something to do. */
202
+ export function checkWarned(health) {
203
+ return health?.ok === true && health.checks.some((c) => c.warning);
204
+ }
205
+ /** What an app's own page shows, so it never contradicts its badge: a failed check, a failed
206
+ * recorded check, or a check that passed with a warning each offer Check again, and a warning
207
+ * leaves Prepare and "What agents can see" not done. */
208
+ export function appPageState(connection, health) {
209
+ const warned = checkWarned(health);
210
+ const notice = health && !health.ok
211
+ ? "failed"
212
+ : connection?.status === "needs_reconnect"
213
+ ? "recorded"
214
+ : warned
215
+ ? "warned"
216
+ : null;
217
+ return { notice, prepareDone: connection !== null && !warned, seeDone: !warned };
218
+ }
219
+ /** An app of its own on the Integrations tab: its stored status, then its live read-only
220
+ * check (failed: needs reconnecting; could not run: needs attention; warned: needs steps).
221
+ * Before the check answers (`checks` null in `integrationCards`), the stored status alone. */
222
+ export function cardStatus(connection, check) {
223
+ const base = appStatus(connection, check?.ok !== false, checkWarned(check));
224
+ return base === "connected" && check === null ? "needs_attention" : base;
225
+ }
226
+ /** A provider's status as its card and its page's header show it: the sign-in's, then its
227
+ * apps' (reconnecting, attention, steps), with how many apps are on and how many need steps
228
+ * or attention, each app counted by `appState`, as its row and its own page show it. */
229
+ export function providerSummary(definition, connection, apps, confirmed) {
230
+ const rows = apps.map((a) => appState({
231
+ definition,
232
+ provider: connection,
233
+ connector: a.connector,
234
+ connection: a.connection,
235
+ state: a.state,
236
+ marks: confirmed,
237
+ }).status);
238
+ const steps = rows.filter((r) => r === "needs_steps").length;
239
+ const attention = rows.filter((r) => r === "needs_attention" || r === "needs_reconnect").length;
240
+ const signIn = providerStatus(connection);
241
+ const status = signIn !== "connected"
242
+ ? signIn
243
+ : rows.includes("needs_reconnect")
244
+ ? "needs_reconnect"
245
+ : attention
246
+ ? "needs_attention"
247
+ : steps
248
+ ? "needs_steps"
249
+ : "connected";
250
+ return { status, on: apps.filter((a) => a.connection).length, steps, attention };
251
+ }
252
+ /** Where a bot's setup stands, in the order the admin does it. The service sees what Add to Teams
253
+ * made and what it published; Teams telling it the bot was added somewhere proves the rest. */
254
+ export function teamsSetup(bot) {
255
+ const signedIn = bot?.status === "connected";
256
+ const places = bot?.installedIn ?? [];
257
+ const published = bot?.publishedVersion ?? null;
258
+ return [
259
+ {
260
+ label: "Bot made in your Microsoft tenant",
261
+ done: Boolean(bot?.botAppId),
262
+ detail: bot?.botAppId ?? undefined,
263
+ },
264
+ {
265
+ label: "Bot signs in to Microsoft",
266
+ done: signedIn,
267
+ detail: bot?.status === "needs_reconnect" ? "The last sign-in failed" : undefined,
268
+ },
269
+ {
270
+ label: "Published to your organization's Teams apps",
271
+ // A bot set up with the script publishes itself; being added somewhere proves it.
272
+ done: published !== null || places.length > 0,
273
+ detail: published && bot && published !== bot.packageVersion
274
+ ? "Its package changed: publish it again"
275
+ : undefined,
276
+ },
277
+ {
278
+ label: "Added to a team, chat or person",
279
+ done: places.length > 0,
280
+ detail: places.length
281
+ ? places.map(placeName).join(", ")
282
+ : (bot?.lastRefusedReason ?? undefined),
283
+ },
284
+ ];
285
+ }
286
+ export function placeName(place) {
287
+ if (place.name)
288
+ return place.name;
289
+ return place.kind === "personal"
290
+ ? "A one-to-one chat"
291
+ : place.kind === "groupChat"
292
+ ? "A group chat"
293
+ : "A team";
294
+ }
295
+ /** The service's default runner, the only one whose fields the trigger form edits. */
296
+ export const CLAUDE_RUNNER = "claude-managed-agents";
297
+ export function triggerForm(trigger) {
298
+ return {
299
+ enabled: trigger?.enabled ?? false,
300
+ runner: trigger?.runner ?? CLAUDE_RUNNER,
301
+ settings: trigger?.settings ?? {},
302
+ managedAgentId: trigger?.managedAgentId ?? "",
303
+ environmentId: trigger?.environmentId ?? "",
304
+ vaultIds: trigger?.vaultIds.join(", ") ?? "",
305
+ budgetDollars: trigger ? (trigger.budgetCents / 100).toFixed(2) : "1.00",
306
+ dailyDollars: trigger ? (trigger.maxCentsPerDay / 100).toFixed(2) : "5.00",
307
+ maxRunsPerHour: String(trigger?.maxRunsPerHour ?? 20),
308
+ replyMode: trigger?.replyMode ?? "mentions",
309
+ allowedTeams: trigger?.allowedTeams ?? [],
310
+ allowedPeople: trigger?.allowedPeople.join(", ") ?? "",
311
+ anyoneInOrganization: trigger?.anyoneInOrganization ?? false,
312
+ };
313
+ }
314
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
315
+ /** The trigger to save, or what to fix. The service checks again, and that the Claude agent
316
+ * and environment exist under the organization's key. */
317
+ export function triggerInput(form) {
318
+ const claude = form.runner === CLAUDE_RUNNER;
319
+ const managedAgentId = form.managedAgentId.trim();
320
+ const environmentId = form.environmentId.trim();
321
+ if (claude && !managedAgentId)
322
+ return { ok: false, error: "Enter the Claude agent's ID" };
323
+ if (claude && !environmentId)
324
+ return { ok: false, error: "Enter the Claude environment's ID" };
325
+ const money = /^\d+(\.\d{1,2})?$/;
326
+ const dollars = Number(form.budgetDollars);
327
+ if (!money.test(form.budgetDollars.trim()) || dollars < 0.01 || dollars > 20)
328
+ return { ok: false, error: "The spending limit per answer is between $0.01 and $20" };
329
+ const daily = Number(form.dailyDollars);
330
+ if (!money.test(form.dailyDollars.trim()) || daily < dollars || daily > 1000)
331
+ return {
332
+ ok: false,
333
+ error: "The daily spending limit is between the limit per answer and $1,000",
334
+ };
335
+ const runs = Number(form.maxRunsPerHour);
336
+ if (!Number.isInteger(runs) || runs < 1 || runs > 100)
337
+ return { ok: false, error: "Answers per hour is a whole number from 1 to 100" };
338
+ const allowedTeams = form.allowedTeams;
339
+ const allowedPeople = splitList(form.allowedPeople);
340
+ const bad = allowedPeople.find((v) => !ID.test(v));
341
+ if (bad)
342
+ return { ok: false, error: `${bad} is not a Microsoft ID (like 0f3e…-…)` };
343
+ if (form.enabled && !form.anyoneInOrganization && !allowedPeople.length)
344
+ return {
345
+ ok: false,
346
+ error: "Name the people who may ask it, or allow anyone in your organization",
347
+ };
348
+ return {
349
+ ok: true,
350
+ value: {
351
+ enabled: form.enabled,
352
+ runner: form.runner,
353
+ settings: form.settings,
354
+ ...(claude ? { managedAgentId, environmentId, vaultIds: splitList(form.vaultIds) } : {}),
355
+ budgetCents: Math.round(dollars * 100),
356
+ maxCentsPerDay: Math.round(daily * 100),
357
+ maxRunsPerHour: runs,
358
+ replyMode: form.replyMode,
359
+ allowedTeams,
360
+ allowedPeople,
361
+ anyoneInOrganization: form.anyoneInOrganization,
362
+ },
363
+ };
364
+ }
365
+ /** One answer in Recent Teams activity, in the admin's words. */
366
+ export function runSummary(run) {
367
+ const state = {
368
+ starting: "Starting",
369
+ running: "Answering",
370
+ idle: "Answered",
371
+ ended: "Finished",
372
+ failed: "Failed",
373
+ no_reply: "Finished without replying",
374
+ }[run.state];
375
+ const cost = run.costCents === null ? null : `$${(run.costCents / 100).toFixed(2)}`;
376
+ const turns = run.turns === 1 ? "1 message" : `${run.turns} messages`;
377
+ return [state, turns, cost].filter(Boolean).join(" · ");
378
+ }
379
+ /** An agent's address, limited to characters that are inert inside the commands it fills. */
380
+ export const AGENT_EMAIL = /^[A-Za-z0-9._+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
381
+ /** A Teams bot's name: the setup script hands it to az, which runs through cmd.exe on Windows,
382
+ * so only characters inert there (the same rule as the service's). */
383
+ export const BOT_NAME = /^[A-Za-z0-9 ._-]{1,30}$/;
384
+ /** A key's name as a starting bot name: other characters become spaces. */
385
+ export function botNameFrom(name) {
386
+ return name
387
+ .replace(/[^A-Za-z0-9 ._-]+/g, " ")
388
+ .replace(/\s+/g, " ")
389
+ .trim()
390
+ .slice(0, 30)
391
+ .trim();
392
+ }
393
+ /** {agentEmail} and {agentName} (the part before the @) for the add-an-agent steps. */
394
+ export function agentFills(email) {
395
+ const at = email.trim();
396
+ return AGENT_EMAIL.test(at) ? { agentEmail: at, agentName: at.split("@")[0] } : {};
397
+ }
398
+ /** The add-an-agent steps for the apps ticked on a new key. */
399
+ export function agentSteps(provider, on) {
400
+ return provider.addAgent.filter((s) => s.apps.some((a) => on.includes(a)));
401
+ }
402
+ /** What a new key's agent needs outside the service for the apps ticked on it: its email where an
403
+ * app opens the agent's own address (its mailbox), each provider's steps for those apps, and
404
+ * the apps that give each agent its own bot. Apps with nothing per agent add nothing. */
405
+ export function newKeySetup(ticked, providers) {
406
+ const ids = ticked.map((c) => c.id);
407
+ return {
408
+ addressed: ticked.filter((c) => c.agentAccess),
409
+ steps: providers.flatMap((provider) => agentSteps(provider, ids).map((step) => ({ provider: provider.id, step }))),
410
+ bots: ticked.filter((c) => c.signsInAs === "agentBot"),
411
+ };
412
+ }
413
+ /** The list limits a new key must be given for an app before it is made (e.g. SharePoint's
414
+ * sites): required, and not the agent's own address, which comes from its email. */
415
+ export function newKeyLimits(c) {
416
+ return c.limits.filter((l) => l.kind === "list" && l.required);
417
+ }
418
+ /** "App: Limit" for each required list limit still empty on a new key's ticked apps. */
419
+ export function missingNewKeyLimits(ticked, chosen) {
420
+ return ticked.flatMap((c) => newKeyLimits(c)
421
+ .filter((l) => {
422
+ const v = chosen[c.id]?.[l.id];
423
+ return !Array.isArray(v) || v.length === 0;
424
+ })
425
+ .map((l) => `${c.name}: ${l.label}`));
426
+ }
427
+ /** The grants a new key starts with: each ticked app at what its page allows (its ceiling),
428
+ * with the limits chosen on the form, and the agent's email as its address where the app
429
+ * has one. */
430
+ export function newKeyGrants(ticked, email, chosen) {
431
+ return ticked.map(({ connector: c, ceiling }) => ({
432
+ app: c.id,
433
+ capabilities: ceiling,
434
+ limits: {
435
+ ...Object.fromEntries(newKeyLimits(c).map((l) => [l.id, chosen[c.id]?.[l.id] ?? []])),
436
+ ...(c.agentAccess ? { [c.agentAccess.limit]: email.trim() } : {}),
437
+ },
438
+ }));
439
+ }
440
+ /** The keys whose `limit` (an agent's own address) is `value`, in any app. */
441
+ export function keysWithAddress(agents, connectors, value) {
442
+ const wanted = value.trim().toLowerCase();
443
+ return agents.filter((a) => a.grants.some((g) => {
444
+ const limit = connectors.find((c) => c.id === g.connectorId)?.agentAccess?.limit;
445
+ const held = limit ? g.limits[limit] : undefined;
446
+ return typeof held === "string" && held.toLowerCase() === wanted;
447
+ }));
448
+ }
449
+ /** The warning that active keys already hold an address, or null. */
450
+ export function holdersNotice(holders) {
451
+ const active = holders.filter((a) => a.status === "active").map((a) => a.name);
452
+ if (!active.length)
453
+ return null;
454
+ return `${active.join(", ")} already ${active.length === 1 ? "uses" : "use"} this address.`;
455
+ }
456
+ const status = (state, detail, extra = {}) => ({ state, detail, confirm: false, locked: false, ...extra });
457
+ /** Connecting a provider, before any app: its setup steps (registering its app, which only the
458
+ * sign-in can prove, so the admin marks it; the certificate, made here, its upload proven by
459
+ * the sign-in), then connecting, a live sign-in. Each step opens once the one before it
460
+ * passes. `marks`: the steps the admin marked done; `secret`: the admin chose a client secret
461
+ * over the certificate. */
462
+ export function providerSteps(setup, connection, marks, secret) {
463
+ const signedIn = connection?.status === "connected";
464
+ const certificate = Boolean(connection?.config.certificate);
465
+ const proven = `Proven by the sign-in in step ${setup.length + 1}.`;
466
+ const out = [];
467
+ let open = true;
468
+ for (const step of setup) {
469
+ let next;
470
+ let passed = true;
471
+ if (signedIn)
472
+ next = status("done", step.certificate && connection.config.auth === "client_secret"
473
+ ? "Signs in with a client secret instead."
474
+ : proven);
475
+ else if (step.certificate) {
476
+ passed = certificate || secret;
477
+ next = status("todo", certificate
478
+ ? "Certificate created. Upload it, then connect: the sign-in proves it."
479
+ : secret
480
+ ? "Using a client secret instead: enter it when you connect."
481
+ : "Create the certificate here, then upload it to the app.");
482
+ }
483
+ else if (step.confirm) {
484
+ // Making the certificate came after registering, so that counts as the mark.
485
+ passed = marks.includes(step.confirm) || certificate;
486
+ next = status(passed ? "marked" : "todo", passed
487
+ ? "Marked done. The sign-in proves it."
488
+ : "The app can be seen here only once you connect. Mark it done to go on.", { confirm: true });
489
+ }
490
+ else
491
+ next = status("todo", "");
492
+ out.push({ ...next, locked: !open });
493
+ open = open && passed;
494
+ }
495
+ out.push(status(signedIn ? "done" : connection?.status === "needs_reconnect" ? "failing" : "todo", signedIn
496
+ ? "Signs in."
497
+ : connection?.status === "needs_reconnect"
498
+ ? "The last sign-in failed."
499
+ : "Not connected yet.", { locked: !open }));
500
+ return out;
501
+ }
502
+ const settled = (s) => s.state === "done" || s.state === "marked";
503
+ const numbered = (ns) => ns.length === 1
504
+ ? `step ${ns[0]}`
505
+ : `steps ${ns.slice(0, -1).join(", ")} and ${ns[ns.length - 1]}`;
506
+ export function appSteps(input) {
507
+ const { connector, connection, health, ceiling, marks } = input;
508
+ const setup = chosenSteps(connector, connector.setup, input.choices ?? {});
509
+ const afterConnect = chosenSteps(connector, connector.afterConnect, input.choices ?? {});
510
+ const on = connection !== null;
511
+ const changed = on && connection.capabilities.join(",") !== ceiling.join(",");
512
+ const before = input.providerReady ? {} : { locked: true };
513
+ const after = on ? {} : { locked: true };
514
+ const checks = health?.ok ? health.checks : null;
515
+ const permissions = checks?.find((c) => c.label === connector.verify.label);
516
+ const confirmStep = (step, extra) => marks.includes(step.confirm)
517
+ ? status("marked", "You marked this done.", { confirm: true, ...extra })
518
+ : status("todo", "This cannot be checked from here. Mark it done once it is.", {
519
+ confirm: true,
520
+ ...extra,
521
+ });
522
+ const checking = (extra) => status("checking", "Checking…", extra);
523
+ const ownCheck = (id, extra) => {
524
+ if (input.checking)
525
+ return checking(extra);
526
+ const found = input.live?.[id];
527
+ if (!found)
528
+ return status("failing", "This could not be checked. Reload the page.", extra);
529
+ return status(found.state === "done" ? "done" : found.state === "failing" ? "failing" : "todo", found.message, extra);
530
+ };
531
+ const liveStep = (step, extra) => step.live
532
+ ? ownCheck(step.live, extra)
533
+ : step.confirm
534
+ ? confirmStep(step, extra)
535
+ : !on
536
+ ? status("todo", "Checked live when you turn it on.", extra)
537
+ : input.checking
538
+ ? checking(extra)
539
+ : health && !health.ok
540
+ ? status("failing", `The check failed: ${health.error}`, extra)
541
+ : permissions?.warning
542
+ ? status("failing", permissions.warning, extra)
543
+ : status("done", "Checked live: the app's permissions match.", extra);
544
+ const setupStep = (step, i, extra, prefix) => ({
545
+ id: `${prefix}-${i}`,
546
+ kind: "setup",
547
+ title: step.title,
548
+ step,
549
+ status: liveStep(step, extra),
550
+ });
551
+ const steps = [
552
+ {
553
+ id: "capabilities",
554
+ kind: "capabilities",
555
+ title: "Choose what agents may do",
556
+ status: changed
557
+ ? status("todo", "Changed. Save it.", before)
558
+ : on
559
+ ? status("done", "Saved.", before)
560
+ : status("todo", "Saved when you turn it on.", before),
561
+ },
562
+ ...(input.approval ? [] : [...setup, ...afterConnect]).map((s, i) => setupStep(s, i, before, "setup")),
563
+ ];
564
+ const needsKey = connector.capabilities.filter((c) => c.claudeKey && ceiling.includes(c.id));
565
+ if (needsKey.length)
566
+ steps.push({
567
+ id: "claude-key",
568
+ kind: "claudeKey",
569
+ title: "Add your organization's Claude API key",
570
+ status: input.checking
571
+ ? checking(before)
572
+ : input.claudeKey
573
+ ? status("done", "Set.", before)
574
+ : status("todo", `Needed because ${needsKey.map((c) => c.label).join(" and ")} is ticked: each answer uses this key.`, before),
575
+ });
576
+ const later = input.approval
577
+ ? [...setup.filter((s) => s.confirm), ...afterConnect].map((s, i) => setupStep(s, i, after, "after"))
578
+ : [];
579
+ if (connector.siteAccess) {
580
+ const sites = input.sites ?? [];
581
+ const open = sites.filter((s) => s.status === "granted").length;
582
+ later.push({
583
+ id: "sites",
584
+ kind: "sites",
585
+ title: "Add the sites agents may use",
586
+ status: on && input.checking
587
+ ? checking(after)
588
+ : sites.length === 0
589
+ ? status("todo", "No sites yet.", after)
590
+ : open === sites.length
591
+ ? status("done", `${many(sites.length, "site")}, all open to the app.`, after)
592
+ : status("todo", `${open} of ${many(sites.length, "site")} open to the app.`, after),
593
+ });
594
+ }
595
+ const turnOnAt = steps.length + 1;
596
+ // Confirm steps before turning on hold it back; live ones are checked by turning on.
597
+ const waiting = steps.flatMap((s, i) => (s.status.confirm && !settled(s.status) ? [i + 1] : []));
598
+ const unfinished = [
599
+ ...steps.flatMap((s, i) => (s.kind !== "capabilities" && !settled(s.status) ? [i + 1] : [])),
600
+ ...later.flatMap((s, i) => (settled(s.status) ? [] : [turnOnAt + 1 + i])),
601
+ ];
602
+ const turnOn = !on
603
+ ? waiting.length && input.providerReady
604
+ ? status("todo", "Not on yet.", {
605
+ locked: true,
606
+ why: `Finish ${numbered(waiting)} first.`,
607
+ })
608
+ : status("todo", "Not on yet.", before)
609
+ : connection.status !== "connected"
610
+ ? status("failing", "Needs reconnecting.")
611
+ : input.checking
612
+ ? status("checking", "On. Checking…")
613
+ : health && !health.ok
614
+ ? status("failing", `The check failed: ${health.error}`)
615
+ : permissions?.warning
616
+ ? status("failing", `On, but the permission check fails: ${permissions.warning}`)
617
+ : unfinished.length
618
+ ? status("todo", `On, but ${numbered(unfinished)} ${unfinished.length === 1 ? "is" : "are"} not done yet.`)
619
+ : status("done", "On. Every step is done and the check passed.");
620
+ return waitForSettings([
621
+ ...steps,
622
+ { id: "turn-on", kind: "turnOn", title: `Turn on ${connector.name}`, status: turnOn },
623
+ ...later,
624
+ ], input.saved ?? {});
625
+ }
626
+ /** A step whose text fills in a value saved at another step waits until it is saved: its
627
+ * words would show the raw {name} and its commands would not run. */
628
+ export function waitForSettings(steps, saved) {
629
+ const at = new Map();
630
+ steps.forEach((s, i) => s.step?.settings?.forEach((x) => at.set(x.id, { n: i + 1, label: x.label })));
631
+ return steps.map((s) => {
632
+ if (!s.step || s.step.settings)
633
+ return s;
634
+ const text = [s.step.body, ...(s.step.click ?? []), ...(s.step.copy ?? [])].join("\n");
635
+ const missing = [...at].filter(([id]) => text.includes(`{${id}}`) && !saved[id]?.trim());
636
+ if (!missing.length)
637
+ return s;
638
+ const labels = missing.map(([, x]) => x.label);
639
+ const where = [...new Set(missing.map(([, x]) => x.n))];
640
+ const what = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(", ")} and ${labels.at(-1)}`;
641
+ return {
642
+ ...s,
643
+ status: status("todo", `Waiting for ${what}.`, {
644
+ locked: true,
645
+ why: `Save the ${what} in ${numbered(where)} first.`,
646
+ }),
647
+ };
648
+ });
649
+ }
650
+ /** The status an app's page shows by its name: needing steps while it is on but any step is
651
+ * not done. */
652
+ export function appPageStatus(connection, healthy, steps) {
653
+ const base = appStatus(connection, healthy);
654
+ if (base !== "connected")
655
+ return base;
656
+ return steps.every((s) => s.kind === "capabilities" || settled(s.status))
657
+ ? "connected"
658
+ : "needs_steps";
659
+ }
660
+ /** An app's live state before its checks answer: steps they decide show as checking, and its
661
+ * status is the stored one. */
662
+ export const CHECKING = {
663
+ health: null,
664
+ sites: null,
665
+ claudeKey: false,
666
+ live: null,
667
+ checking: true,
668
+ };
669
+ /** How a provider's app signs in: through the provider connection, or (nothing connected yet,
670
+ * where the shared app is offered) by approving that app when turning it on. */
671
+ export function signInRoute(definition, provider) {
672
+ const signsIn = providerReady(provider);
673
+ return { signsIn, approve: !signsIn && definition.approval };
674
+ }
675
+ /** A provider app's steps and status: the one computation behind its page's badge, its row on
676
+ * the provider's page and the provider's header and card, so none contradicts another. A
677
+ * check that could not run needs attention. `ceiling` and `choices`: as ticked and typed on
678
+ * the app's page, else as saved. */
679
+ export function appState(input) {
680
+ const { connector, connection, state } = input;
681
+ const { signsIn, approve } = signInRoute(input.definition, input.provider);
682
+ const steps = appSteps({
683
+ connector,
684
+ connection,
685
+ providerReady: signsIn || approve,
686
+ approval: approve,
687
+ health: state.health,
688
+ ceiling: input.ceiling ??
689
+ connection?.capabilities ??
690
+ connector.capabilities.filter((c) => c.required).map((c) => c.id),
691
+ sites: state.sites,
692
+ claudeKey: state.claudeKey,
693
+ marks: input.marks,
694
+ live: state.live,
695
+ checking: state.checking,
696
+ saved: input.provider?.config ?? {},
697
+ choices: input.choices ?? connection?.config ?? {},
698
+ });
699
+ const healthy = input.provider?.status === "connected" && state.health?.ok !== false;
700
+ if (state.checking)
701
+ return { steps, status: appStatus(connection, healthy) };
702
+ const unknown = state.health === null || (Boolean(connector.siteAccess) && state.sites === null);
703
+ const status = unknown && appStatus(connection, healthy) === "connected"
704
+ ? "needs_attention"
705
+ : appPageStatus(connection, healthy, steps);
706
+ return { steps, status };
707
+ }
708
+ const many = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
709
+ /** A key's own addresses (its mailbox), per app that has one, e.g. for its access checks. */
710
+ export function agentAddresses(agent, connectors) {
711
+ return connectors.flatMap((connector) => {
712
+ const limit = connector.agentAccess?.limit;
713
+ const held = agent.grants.find((g) => g.connectorId === connector.id)?.limits[limit ?? ""];
714
+ return limit && typeof held === "string" && held ? [{ connector, value: held }] : [];
715
+ });
716
+ }