@agentcomms/resend 0.13.0 → 0.13.1
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 +4 -4
- package/dist/cli.mjs +95 -57
- package/dist/index.d.mts +12250 -12022
- package/dist/index.mjs +5 -6
- package/dist/{send-D2ZOWO_6.mjs → send-DtBNDnE4.mjs} +2257 -812
- package/dist/{server-CLjdcZDg.mjs → server-B5FPOBcz.mjs} +15 -16
- package/dist/{stdio-entry-BCnq_EyF.mjs → stdio-entry-CUFTANFM.mjs} +1 -1
- package/package.json +7 -5
|
@@ -4,15 +4,15 @@ import path, { basename, delimiter, dirname, extname, isAbsolute, join, relative
|
|
|
4
4
|
import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
6
6
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
7
|
+
import { homedir, platform, userInfo } from "node:os";
|
|
7
8
|
import "node:readline/promises";
|
|
8
9
|
import { styleText } from "node:util";
|
|
9
|
-
import {
|
|
10
|
-
import { constants as constants$1, realpathSync } from "node:fs";
|
|
10
|
+
import { accessSync, constants as constants$1, readFileSync, realpathSync, statSync } from "node:fs";
|
|
11
11
|
import { execFile, spawn } from "node:child_process";
|
|
12
12
|
//#endregion
|
|
13
13
|
//#region src/version.ts
|
|
14
14
|
/** The package version, read from package.json at build time. */
|
|
15
|
-
const VERSION$1 = "0.13.
|
|
15
|
+
const VERSION$1 = "0.13.1";
|
|
16
16
|
//#endregion
|
|
17
17
|
//#region src/api/routes.ts
|
|
18
18
|
/**
|
|
@@ -187,7 +187,21 @@ function refusedBecause(method, pathname) {
|
|
|
187
187
|
return null;
|
|
188
188
|
}
|
|
189
189
|
//#endregion
|
|
190
|
-
//#region
|
|
190
|
+
//#region src/caller.ts
|
|
191
|
+
/** This package's name, as its manifest has it: what core checks a caller against. */
|
|
192
|
+
const PACKAGE_NAME = "@agentcomms/resend";
|
|
193
|
+
/**
|
|
194
|
+
* This package as core's caller (CUE-403; CONTRIBUTING.md, "Telling a person what to run"): one of its own modules and
|
|
195
|
+
* its name, so every command it prints for a person runs this installation, with its folders pinned. Its CLI and its
|
|
196
|
+
* server open core with it, and nothing else does. Bundled, this module is part of the bundle's file — still inside
|
|
197
|
+
* this package — so the check that the caller is this package holds in the published build as in a checkout.
|
|
198
|
+
*/
|
|
199
|
+
const RESEND_CALLER = Object.freeze({
|
|
200
|
+
url: import.meta.url,
|
|
201
|
+
packageName: PACKAGE_NAME
|
|
202
|
+
});
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region ../core/dist/update-check-BPwwmUCi.mjs
|
|
191
205
|
/**
|
|
192
206
|
* Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
|
|
193
207
|
* optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
|
|
@@ -1049,7 +1063,7 @@ const CHANNEL_SNAPSHOT = [
|
|
|
1049
1063
|
guarantee: {
|
|
1050
1064
|
ceiling: "code",
|
|
1051
1065
|
floor: "code",
|
|
1052
|
-
why: "Resend has no read-only key, and a full-access key can also manage domains and keys: read mode, and send mode's limit to sending, are enforced by
|
|
1066
|
+
why: "Resend has no read-only key, and a full-access key can also manage domains and keys: read mode, and send mode's limit to sending, are enforced by this package's own code. A sending-access key can only send, and Resend enforces that."
|
|
1053
1067
|
}
|
|
1054
1068
|
},
|
|
1055
1069
|
narrowing: [{
|
|
@@ -1169,25 +1183,6 @@ function manifestOf(channel) {
|
|
|
1169
1183
|
function accountChannels() {
|
|
1170
1184
|
return MANIFESTS.filter((manifest) => manifest.accounts !== void 0);
|
|
1171
1185
|
}
|
|
1172
|
-
/**
|
|
1173
|
-
* Words as a list a person reads: `a`, `a or b`, `a, b or c` — with `oxford`, `a, b, or c` once there are three.
|
|
1174
|
-
*/
|
|
1175
|
-
function listed(words, conjunction, options = {}) {
|
|
1176
|
-
if (words.length <= 1) return words.join("");
|
|
1177
|
-
const last = words.at(-1);
|
|
1178
|
-
const rest = words.slice(0, -1);
|
|
1179
|
-
return `${rest.join(", ")}${options.oxford && rest.length > 1 ? "," : ""} ${conjunction} ${last}`;
|
|
1180
|
-
}
|
|
1181
|
-
/**
|
|
1182
|
-
* Every channel's own approve command, quoted: "`agent-gmail approve` or `agent-slack approve`".
|
|
1183
|
-
*
|
|
1184
|
-
* `sending` keeps only the channels whose accounts can be in `send`: the commands that can have prepared a send. A
|
|
1185
|
-
* channel that never sends — WhatsApp — has an approve command for the changes its `mcp install` and `mcp prune`
|
|
1186
|
-
* make, and naming it where the approval in hand is a send would send a person to a command that never prepared one.
|
|
1187
|
-
*/
|
|
1188
|
-
function channelApproveCommands(options = {}) {
|
|
1189
|
-
return listed(accountChannels().filter((manifest) => options.sending !== true || manifest.accounts?.modes.includes("send") === true).flatMap((manifest) => manifest.approve ? [`\`${manifest.approve}\``] : []), "or");
|
|
1190
|
-
}
|
|
1191
1186
|
/** The noun for one account on `channel` — `mailbox`, `workspace` — or `account` for a channel that names none. */
|
|
1192
1187
|
function accountNoun(channel) {
|
|
1193
1188
|
return manifestOf(channel)?.accounts?.noun ?? "account";
|
|
@@ -1205,106 +1200,20 @@ function narrowingOwner(option) {
|
|
|
1205
1200
|
return MANIFESTS.find((manifest) => manifest.narrowing?.some((narrowing) => narrowing.option === option));
|
|
1206
1201
|
}
|
|
1207
1202
|
/**
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1203
|
+
* How a mailbox is connected, for a hint about there being none: the `inbox add` of the channel whose accounts are
|
|
1204
|
+
* mailboxes, in the `inboxes` map — named in words, "Gmail's inbox add", never as a command. Core does not have that
|
|
1205
|
+
* channel's CLI, and finds it only among this machine's registrations (CUE-403); a hint made where none were read must
|
|
1206
|
+
* not invent the command from the manifest's binary.
|
|
1210
1207
|
*/
|
|
1211
|
-
function
|
|
1208
|
+
function connectMailboxWords() {
|
|
1212
1209
|
const mail = accountChannels().find((manifest) => manifest.accounts?.map === "inboxes");
|
|
1213
|
-
return mail ? `${mail.
|
|
1214
|
-
}
|
|
1215
|
-
/** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
|
|
1216
|
-
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1217
|
-
function randomString(length) {
|
|
1218
|
-
const bytes = randomBytes(length);
|
|
1219
|
-
let out = "";
|
|
1220
|
-
for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
|
|
1221
|
-
return out;
|
|
1222
|
-
}
|
|
1223
|
-
/** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
|
|
1224
|
-
const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1225
|
-
const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1226
|
-
function newApprovalId() {
|
|
1227
|
-
return `ap_${randomString(26)}`;
|
|
1228
|
-
}
|
|
1229
|
-
function newPlanToken() {
|
|
1230
|
-
return `pl_${randomString(26)}`;
|
|
1231
|
-
}
|
|
1232
|
-
const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1233
|
-
/** A short challenge a human types back to approve: letters only, no look-alikes. */
|
|
1234
|
-
function newChallenge(length = 4) {
|
|
1235
|
-
let out = "";
|
|
1236
|
-
for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
|
|
1237
|
-
return out;
|
|
1238
|
-
}
|
|
1239
|
-
/** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
|
|
1240
|
-
function hashChallenge(challenge) {
|
|
1241
|
-
return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
|
|
1242
|
-
}
|
|
1243
|
-
/** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
|
|
1244
|
-
function challengeMatches(answer, storedHash) {
|
|
1245
|
-
const a = Buffer.from(hashChallenge(answer), "hex");
|
|
1246
|
-
const b = Buffer.from(storedHash, "hex");
|
|
1247
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
1248
|
-
}
|
|
1249
|
-
function okEnvelope(data) {
|
|
1250
|
-
return {
|
|
1251
|
-
ok: true,
|
|
1252
|
-
schemaVersion: 1,
|
|
1253
|
-
data
|
|
1254
|
-
};
|
|
1255
|
-
}
|
|
1256
|
-
function errorEnvelope(error) {
|
|
1257
|
-
const body = {
|
|
1258
|
-
code: error.code,
|
|
1259
|
-
message: error.message
|
|
1260
|
-
};
|
|
1261
|
-
if (error.hint !== void 0) body.hint = error.hint;
|
|
1262
|
-
if (error.details !== void 0) body.details = error.details;
|
|
1263
|
-
return {
|
|
1264
|
-
ok: false,
|
|
1265
|
-
schemaVersion: 1,
|
|
1266
|
-
error: body
|
|
1267
|
-
};
|
|
1268
|
-
}
|
|
1269
|
-
const defaultStreams = {
|
|
1270
|
-
stdout: process.stdout,
|
|
1271
|
-
stderr: process.stderr,
|
|
1272
|
-
stdin: process.stdin
|
|
1273
|
-
};
|
|
1274
|
-
function colorEnabled(env, stream, flag) {
|
|
1275
|
-
if (flag === false) return false;
|
|
1276
|
-
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
1277
|
-
if (env.TERM === "dumb") return false;
|
|
1278
|
-
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
1279
|
-
return Boolean(stream.isTTY);
|
|
1280
|
-
}
|
|
1281
|
-
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
1282
|
-
function canPrompt(env, streams, options) {
|
|
1283
|
-
if (options.json || options.noInput) return false;
|
|
1284
|
-
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
1285
|
-
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
1286
|
-
}
|
|
1287
|
-
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
1288
|
-
const AGENT_MARKERS = [
|
|
1289
|
-
"CLAUDECODE",
|
|
1290
|
-
"CLAUDE_CODE_ENTRYPOINT",
|
|
1291
|
-
"CODEX_SANDBOX",
|
|
1292
|
-
"CODEX_HOME",
|
|
1293
|
-
"CURSOR_AGENT",
|
|
1294
|
-
"GEMINI_CLI",
|
|
1295
|
-
"AGENT_COMMS_AGENT"
|
|
1296
|
-
];
|
|
1297
|
-
function agentMarker(env) {
|
|
1298
|
-
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
1299
|
-
return null;
|
|
1300
|
-
}
|
|
1301
|
-
function paint(color, format, text) {
|
|
1302
|
-
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
1210
|
+
return mail ? `${mail.label}'s inbox add` : void 0;
|
|
1303
1211
|
}
|
|
1304
1212
|
/**
|
|
1305
1213
|
* A command line for a person to copy and run, each word quoted only where the shell it is pasted into would need it.
|
|
1306
|
-
* Every command this
|
|
1307
|
-
*
|
|
1214
|
+
* Every command this suite prints to be run — a change to run again with its approval, a folder to take out, an entry
|
|
1215
|
+
* to register again or remove — is quoted here, when the locator or `externalCommand` makes it, so no printer quotes
|
|
1216
|
+
* for a shell of its own.
|
|
1308
1217
|
*
|
|
1309
1218
|
* Everywhere but Windows that shell is a POSIX one, and a word goes in single quotes, inside which nothing is special
|
|
1310
1219
|
* but the quote itself. Windows has two shells, and neither reads single quotes that way: cmd.exe does not take them
|
|
@@ -1343,31 +1252,58 @@ function paint(color, format, text) {
|
|
|
1343
1252
|
* hint's `--force` replaced it — and before that, a word quoted for PowerShell's single quotes, which cmd.exe reads
|
|
1344
1253
|
* as characters, ran `whoami` from a server named `$x&whoami&`. See `commandAsJson` for why the JSON runs nothing.
|
|
1345
1254
|
*
|
|
1255
|
+
* - The program itself has to be left as it is. Quoted, it is a string to PowerShell rather than a command — PowerShell
|
|
1256
|
+
* runs a quoted program only after `&`, which cmd.exe reads as the end of a command — so a program whose path needs
|
|
1257
|
+
* quotes, as `C:\Program Files\nodejs\node.exe` does, leaves the command with no line either (CUE-403).
|
|
1258
|
+
*
|
|
1346
1259
|
* Everywhere else there is always a line: single quotes make any word safe.
|
|
1260
|
+
*
|
|
1261
|
+
* Not a way to make a command for a person: what it returns is not one, and nothing that shows a command takes it. One
|
|
1262
|
+
* of this suite's own commands comes from `locateCliCommand`, any other program's from `externalCommand`, and each of
|
|
1263
|
+
* them quotes its words here.
|
|
1347
1264
|
*/
|
|
1348
|
-
function
|
|
1265
|
+
function quoteCommand(words, platform) {
|
|
1349
1266
|
if (platform !== "win32") return {
|
|
1350
1267
|
words,
|
|
1351
1268
|
line: words.map(posixShellWord).join(" "),
|
|
1352
1269
|
platform
|
|
1353
1270
|
};
|
|
1354
1271
|
const printed = words.map(windowsShellWord);
|
|
1272
|
+
const programBare = words.length === 0 || printed[0] === words[0];
|
|
1355
1273
|
return {
|
|
1356
1274
|
words,
|
|
1357
|
-
line: printed.includes(null) ? null : printed.join(" "),
|
|
1275
|
+
line: printed.includes(null) || !programBare ? null : printed.join(" "),
|
|
1358
1276
|
platform
|
|
1359
1277
|
};
|
|
1360
1278
|
}
|
|
1361
|
-
/**
|
|
1362
|
-
|
|
1363
|
-
|
|
1279
|
+
/**
|
|
1280
|
+
* A printed command's line with words the agent is to fill in — a placeholder such as `<folder>`, printed as written,
|
|
1281
|
+
* never quoted as a word — before the command's first `--`, where the CLI still reads them as options. Null when the
|
|
1282
|
+
* command has no line.
|
|
1283
|
+
*/
|
|
1284
|
+
function quotedLineWithWordsToFill(command, ...toFill) {
|
|
1285
|
+
if (command.line === null) return null;
|
|
1286
|
+
const sentinel = command.words.indexOf("--");
|
|
1287
|
+
if (sentinel < 0) return [command.line, ...toFill].join(" ");
|
|
1288
|
+
const word = command.platform === "win32" ? windowsShellWord : posixShellWord;
|
|
1289
|
+
const printed = command.words.map((each) => word(each));
|
|
1290
|
+
return [
|
|
1291
|
+
...printed.slice(0, sentinel),
|
|
1292
|
+
...toFill,
|
|
1293
|
+
...printed.slice(sentinel)
|
|
1294
|
+
].join(" ");
|
|
1295
|
+
}
|
|
1296
|
+
/** Whether a Windows command has no line because its program's path needs quotes, which PowerShell would not run. */
|
|
1297
|
+
function programNeedsQuotes(command) {
|
|
1298
|
+
const [program] = command.words;
|
|
1299
|
+
return command.platform === "win32" && program !== void 0 && windowsShellWord(program) !== program;
|
|
1364
1300
|
}
|
|
1365
1301
|
function posixShellWord(word) {
|
|
1366
1302
|
return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
|
|
1367
1303
|
}
|
|
1368
1304
|
/**
|
|
1369
1305
|
* The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
|
|
1370
|
-
* does (see `
|
|
1306
|
+
* does (see `quoteCommand`). Bare: a plain option, or ordinary characters that do not start like a number.
|
|
1371
1307
|
*/
|
|
1372
1308
|
function windowsShellWord(word) {
|
|
1373
1309
|
if (/^(?:--?[A-Za-z][A-Za-z0-9-]*|(?!\+?\.?\d)[\w+=:./\\][\w@+=:./\\-]*)$/.test(word)) return word;
|
|
@@ -1405,140 +1341,189 @@ function commandAsJson(words) {
|
|
|
1405
1341
|
}
|
|
1406
1342
|
/** What is said beside a command shown as JSON. */
|
|
1407
1343
|
const TO_TYPE = "the command's words, written as JSON: one of them cannot be quoted the same way for cmd.exe and for PowerShell, so type the command yourself, with that word quoted for the shell you use";
|
|
1408
|
-
/**
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
/** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
|
|
1413
|
-
function commandText(command) {
|
|
1414
|
-
return command.line === null ? `${commandAsJson(command.words)} (${TO_TYPE})` : command.line;
|
|
1344
|
+
/** What is said beside a command shown as JSON because its program's path needs quotes. */
|
|
1345
|
+
const TO_TYPE_PROGRAM = "the command's words, written as JSON: the program's path needs quotes, and PowerShell runs a quoted program only after `&`, which cmd.exe does not accept, so type the command yourself, with each word quoted for the shell you use and, in PowerShell, `&` before the program";
|
|
1346
|
+
function toType(command) {
|
|
1347
|
+
return programNeedsQuotes(command) ? TO_TYPE_PROGRAM : TO_TYPE;
|
|
1415
1348
|
}
|
|
1416
|
-
/**
|
|
1417
|
-
function
|
|
1418
|
-
|
|
1419
|
-
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
1420
|
-
return;
|
|
1421
|
-
}
|
|
1422
|
-
const text = human(data);
|
|
1423
|
-
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
1424
|
-
}
|
|
1425
|
-
/** Writes an error and returns the exit code to use. */
|
|
1426
|
-
function writeError(error, options, streams = defaultStreams) {
|
|
1427
|
-
const commsError = toCommsError(error);
|
|
1428
|
-
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
1429
|
-
else {
|
|
1430
|
-
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
1431
|
-
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
1432
|
-
}
|
|
1433
|
-
return commsError.exitCode;
|
|
1349
|
+
/** Quoted words in backticks, for a sentence — as words in JSON, saying it has to be typed, when there is no line. */
|
|
1350
|
+
function inlineQuoted(command) {
|
|
1351
|
+
return command.line === null ? `\`${commandAsJson(command.words)}\` (${toType(command)})` : `\`${command.line}\``;
|
|
1434
1352
|
}
|
|
1435
|
-
/**
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
await body();
|
|
1439
|
-
return EXIT_CODES.OK;
|
|
1440
|
-
} catch (error) {
|
|
1441
|
-
return writeError(error, options, streams);
|
|
1442
|
-
}
|
|
1353
|
+
/** Quoted words as text of their own — a list's line, a field's value — or as words in JSON, saying to type them. */
|
|
1354
|
+
function quotedText(command) {
|
|
1355
|
+
return command.line === null ? `${commandAsJson(command.words)} (${toType(command)})` : command.line;
|
|
1443
1356
|
}
|
|
1444
1357
|
/**
|
|
1445
|
-
* The
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1448
|
-
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
1449
|
-
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
1358
|
+
* The same command with words the agent is to fill in — a placeholder such as `<folder>` — before its first `--`: for
|
|
1359
|
+
* showing as words in JSON when the command has no line. With a line, `quotedLineWithWordsToFill` prints them as they
|
|
1360
|
+
* are written instead, never quoted as a word.
|
|
1450
1361
|
*/
|
|
1451
|
-
function
|
|
1452
|
-
const
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
1460
|
-
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: `Run \`${gate.command}\` directly in a terminal.` });
|
|
1362
|
+
function quotedWithWordsToFill(command, toFill) {
|
|
1363
|
+
const sentinel = command.words.indexOf("--");
|
|
1364
|
+
const at = sentinel < 0 ? command.words.length : sentinel;
|
|
1365
|
+
return quoteCommand([
|
|
1366
|
+
...command.words.slice(0, at),
|
|
1367
|
+
...toFill,
|
|
1368
|
+
...command.words.slice(at)
|
|
1369
|
+
], command.platform);
|
|
1461
1370
|
}
|
|
1462
|
-
/**
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
[847, 847],
|
|
1470
|
-
[1564, 1564],
|
|
1471
|
-
[4447, 4448],
|
|
1472
|
-
[6068, 6069],
|
|
1473
|
-
[6155, 6159],
|
|
1474
|
-
[8203, 8207],
|
|
1475
|
-
[8232, 8233],
|
|
1476
|
-
[8234, 8238],
|
|
1477
|
-
[8288, 8292],
|
|
1478
|
-
[8294, 8303],
|
|
1479
|
-
[12644, 12644],
|
|
1480
|
-
[65024, 65039],
|
|
1481
|
-
[65279, 65279],
|
|
1482
|
-
[65440, 65440],
|
|
1483
|
-
[917504, 917631]
|
|
1484
|
-
];
|
|
1485
|
-
function isInvisible(codePoint) {
|
|
1486
|
-
for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
|
|
1487
|
-
return false;
|
|
1371
|
+
/** Crockford-style alphabet without I, L, O and U, so ids and challenges survive being read aloud or retyped. */
|
|
1372
|
+
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1373
|
+
function randomString(length) {
|
|
1374
|
+
const bytes = randomBytes(length);
|
|
1375
|
+
let out = "";
|
|
1376
|
+
for (let i = 0; i < length; i += 1) out += ALPHABET[(bytes[i] ?? 0) & 31];
|
|
1377
|
+
return out;
|
|
1488
1378
|
}
|
|
1489
|
-
/**
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
if (codePoint === 9 || codePoint === 10) return false;
|
|
1495
|
-
return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
|
|
1379
|
+
/** `ap_` + 26 characters: 130 random bits. Validated before an id ever names a file. */
|
|
1380
|
+
const APPROVAL_ID_PATTERN = /^ap_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1381
|
+
const PLAN_TOKEN_PATTERN = /^pl_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1382
|
+
function newApprovalId() {
|
|
1383
|
+
return `ap_${randomString(26)}`;
|
|
1496
1384
|
}
|
|
1497
|
-
function
|
|
1498
|
-
return
|
|
1385
|
+
function newPlanToken() {
|
|
1386
|
+
return `pl_${randomString(26)}`;
|
|
1499
1387
|
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
*
|
|
1504
|
-
* This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
|
|
1505
|
-
* drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
|
|
1506
|
-
* has to happen before any such pattern runs, on every path, not only on the ones that render a body.
|
|
1507
|
-
*/
|
|
1508
|
-
function stripInvisible(text) {
|
|
1509
|
-
let removed = 0;
|
|
1388
|
+
const CHALLENGE_LETTERS = "ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1389
|
+
/** A short challenge a human types back to approve: letters only, no look-alikes. */
|
|
1390
|
+
function newChallenge(length = 4) {
|
|
1510
1391
|
let out = "";
|
|
1511
|
-
for (
|
|
1512
|
-
|
|
1392
|
+
for (let i = 0; i < length; i += 1) out += CHALLENGE_LETTERS[randomInt(22)];
|
|
1393
|
+
return out;
|
|
1394
|
+
}
|
|
1395
|
+
/** Challenges are stored only as hashes, so no listing or tool result can reveal one. */
|
|
1396
|
+
function hashChallenge(challenge) {
|
|
1397
|
+
return createHash("sha256").update(challenge.trim().toUpperCase()).digest("hex");
|
|
1398
|
+
}
|
|
1399
|
+
/** Case-insensitive, constant-time comparison of an answer with a stored challenge hash. */
|
|
1400
|
+
function challengeMatches(answer, storedHash) {
|
|
1401
|
+
const a = Buffer.from(hashChallenge(answer), "hex");
|
|
1402
|
+
const b = Buffer.from(storedHash, "hex");
|
|
1403
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1404
|
+
}
|
|
1405
|
+
function okEnvelope(data) {
|
|
1513
1406
|
return {
|
|
1514
|
-
|
|
1515
|
-
|
|
1407
|
+
ok: true,
|
|
1408
|
+
schemaVersion: 1,
|
|
1409
|
+
data
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
function errorEnvelope(error) {
|
|
1413
|
+
const body = {
|
|
1414
|
+
code: error.code,
|
|
1415
|
+
message: error.message
|
|
1416
|
+
};
|
|
1417
|
+
if (error.hint !== void 0) body.hint = error.hint;
|
|
1418
|
+
if (error.details !== void 0) body.details = error.details;
|
|
1419
|
+
return {
|
|
1420
|
+
ok: false,
|
|
1421
|
+
schemaVersion: 1,
|
|
1422
|
+
error: body
|
|
1516
1423
|
};
|
|
1517
1424
|
}
|
|
1518
1425
|
const APP_DIR_NAME = "agent-communications";
|
|
1519
|
-
/**
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1426
|
+
/** The five suite directories a CLI can pin independently. */
|
|
1427
|
+
const PATH_OPTIONS = [
|
|
1428
|
+
{
|
|
1429
|
+
key: "configDir",
|
|
1430
|
+
option: "config-dir",
|
|
1431
|
+
flag: "--config-dir"
|
|
1432
|
+
},
|
|
1433
|
+
{
|
|
1434
|
+
key: "stateDir",
|
|
1435
|
+
option: "state-dir",
|
|
1436
|
+
flag: "--state-dir"
|
|
1437
|
+
},
|
|
1438
|
+
{
|
|
1439
|
+
key: "dataDir",
|
|
1440
|
+
option: "data-dir",
|
|
1441
|
+
flag: "--data-dir"
|
|
1442
|
+
},
|
|
1443
|
+
{
|
|
1444
|
+
key: "secretsDir",
|
|
1445
|
+
option: "secrets-dir",
|
|
1446
|
+
flag: "--secrets-dir"
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
key: "downloadsDir",
|
|
1450
|
+
option: "downloads-dir",
|
|
1451
|
+
flag: "--downloads-dir"
|
|
1452
|
+
}
|
|
1453
|
+
];
|
|
1454
|
+
/**
|
|
1455
|
+
* Words with the five path options and their values taken out where an option parser would read them — before the
|
|
1456
|
+
* first `--`, in both the `--x value` and `--x=value` forms — and every word from `--` on kept as it is.
|
|
1457
|
+
*
|
|
1458
|
+
* What a registered entry is recognised by. A pinned folder is a folder: `/tmp/@agentcomms/slack`, or one ending in
|
|
1459
|
+
* `packages/slack/src/cli.ts`, read as a package or an entry, made a Gmail registration Slack's. `dangling` says a
|
|
1460
|
+
* spaced option had no value before `--` or the end, which is a broken entry rather than one to guess about.
|
|
1461
|
+
*/
|
|
1462
|
+
function withoutPathOptions(words) {
|
|
1463
|
+
const flags = PATH_OPTIONS.map(({ flag }) => flag);
|
|
1464
|
+
const kept = [];
|
|
1465
|
+
let dangling = false;
|
|
1466
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
1467
|
+
const word = words[index];
|
|
1468
|
+
if (word === "--") {
|
|
1469
|
+
kept.push(...words.slice(index));
|
|
1470
|
+
break;
|
|
1471
|
+
}
|
|
1472
|
+
if (flags.includes(word)) {
|
|
1473
|
+
const value = words[index + 1];
|
|
1474
|
+
if (value === void 0 || value === "--") dangling = true;
|
|
1475
|
+
else index += 1;
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
if (flags.some((flag) => word.startsWith(`${flag}=`))) continue;
|
|
1479
|
+
kept.push(word);
|
|
1480
|
+
}
|
|
1481
|
+
return {
|
|
1482
|
+
words: kept,
|
|
1483
|
+
dangling
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Where everything lives. `AGENT_COMMS_CONFIG_DIR` wins; then `XDG_CONFIG_HOME` (honoured on macOS too, because that is
|
|
1488
|
+
* where agents and people look first); then `~/.config` on macOS and Linux, `%APPDATA%` on Windows.
|
|
1489
|
+
*
|
|
1490
|
+
* On Windows, state and the file secret store go under `%LOCALAPPDATA%` rather than beside the config: `%APPDATA%` is
|
|
1491
|
+
* the roaming profile, which a domain copies between machines — and refresh tokens, approvals and audit records are
|
|
1492
|
+
* exactly what should not travel that way. An explicit `AGENT_COMMS_CONFIG_DIR` keeps everything together, because
|
|
1493
|
+
* someone who names a directory means that directory.
|
|
1494
|
+
*/
|
|
1528
1495
|
function resolvePaths(options = {}) {
|
|
1496
|
+
return resolvePathIdentity(options).paths;
|
|
1497
|
+
}
|
|
1498
|
+
/** Resolves the environment/default identity, then overlays each explicit pin without re-deriving any sibling. */
|
|
1499
|
+
function resolvePathIdentity(options = {}) {
|
|
1529
1500
|
const env = options.env ?? process.env;
|
|
1530
1501
|
const platform = options.platform ?? process.platform;
|
|
1531
1502
|
const home = options.home ?? homeOf(env, platform);
|
|
1532
1503
|
const configDir = resolve(env.AGENT_COMMS_CONFIG_DIR || (env.XDG_CONFIG_HOME ? join(env.XDG_CONFIG_HOME, "agent-communications") : platform === "win32" ? join(env.APPDATA || join(home, "AppData", "Roaming"), "agent-communications") : join(home, ".config", "agent-communications")));
|
|
1533
1504
|
const explicitConfigDir = Boolean(env.AGENT_COMMS_CONFIG_DIR);
|
|
1534
1505
|
const localRoot = platform === "win32" && !explicitConfigDir ? join(env.LOCALAPPDATA || join(home, "AppData", "Local"), APP_DIR_NAME) : configDir;
|
|
1535
|
-
|
|
1506
|
+
const derived = {
|
|
1536
1507
|
configDir,
|
|
1537
1508
|
stateDir: resolve(env.AGENT_COMMS_STATE_DIR || join(localRoot, "state")),
|
|
1538
1509
|
secretsDir: resolve(join(localRoot, "secrets")),
|
|
1539
1510
|
dataDir: resolve(env.AGENT_COMMS_DATA_DIR || (platform === "win32" ? join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "agent-communications") : env.XDG_DATA_HOME ? join(env.XDG_DATA_HOME, "agent-communications") : join(home, ".local", "share", "agent-communications"))),
|
|
1540
1511
|
downloadsDir: resolve(join(home, "Downloads", APP_DIR_NAME))
|
|
1541
1512
|
};
|
|
1513
|
+
const pathOverrides = {};
|
|
1514
|
+
for (const { key } of PATH_OPTIONS) {
|
|
1515
|
+
const value = options.pathOverrides?.[key];
|
|
1516
|
+
if (value === void 0) continue;
|
|
1517
|
+
if (value.length === 0) throw new TypeError(`${key} cannot be empty`);
|
|
1518
|
+
pathOverrides[key] = resolve(value);
|
|
1519
|
+
}
|
|
1520
|
+
return {
|
|
1521
|
+
paths: {
|
|
1522
|
+
...derived,
|
|
1523
|
+
...pathOverrides
|
|
1524
|
+
},
|
|
1525
|
+
pathOverrides: Object.freeze(pathOverrides)
|
|
1526
|
+
};
|
|
1542
1527
|
}
|
|
1543
1528
|
/**
|
|
1544
1529
|
* The home the environment names, which is where Node's own `homedir()` looks for the running process — `HOME`, or
|
|
@@ -1608,6 +1593,294 @@ function shortenHome(path, home, platform = process.platform) {
|
|
|
1608
1593
|
function homeDirectory(env = process.env) {
|
|
1609
1594
|
return env.HOME || env.USERPROFILE || homedir();
|
|
1610
1595
|
}
|
|
1596
|
+
const defaultStreams = {
|
|
1597
|
+
stdout: process.stdout,
|
|
1598
|
+
stderr: process.stderr,
|
|
1599
|
+
stdin: process.stdin
|
|
1600
|
+
};
|
|
1601
|
+
/** Converts the shared parser's five global option values into independently applied core path pins. */
|
|
1602
|
+
function pathOverridesFromCliOptions(values) {
|
|
1603
|
+
const overrides = {};
|
|
1604
|
+
for (const { key, option, flag } of PATH_OPTIONS) {
|
|
1605
|
+
const value = values[option];
|
|
1606
|
+
if (value === void 0) continue;
|
|
1607
|
+
if (value.length === 0) throw new CommsError("USAGE", `${flag} needs a non-empty directory`);
|
|
1608
|
+
overrides[key] = value;
|
|
1609
|
+
}
|
|
1610
|
+
return overrides;
|
|
1611
|
+
}
|
|
1612
|
+
/** Inserts generated option words immediately before the first end-of-options sentinel. */
|
|
1613
|
+
function insertWordsBeforeSentinel(words, ...inserted) {
|
|
1614
|
+
const sentinel = words.indexOf("--");
|
|
1615
|
+
const at = sentinel < 0 ? words.length : sentinel;
|
|
1616
|
+
return [
|
|
1617
|
+
...words.slice(0, at),
|
|
1618
|
+
...inserted,
|
|
1619
|
+
...words.slice(at)
|
|
1620
|
+
];
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Removes the named options only where the CLI parser can see them: before the first `--`. Both `--x value` and
|
|
1624
|
+
* `--x=value` are removed. The sentinel and every positional word after it are byte-for-byte data.
|
|
1625
|
+
*/
|
|
1626
|
+
function withoutOptionsBeforeSentinel(words, flags) {
|
|
1627
|
+
const kept = [];
|
|
1628
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
1629
|
+
const word = words[index];
|
|
1630
|
+
if (word === "--") {
|
|
1631
|
+
kept.push(...words.slice(index));
|
|
1632
|
+
break;
|
|
1633
|
+
}
|
|
1634
|
+
const spaced = flags.find((flag) => word === flag);
|
|
1635
|
+
if (spaced !== void 0) {
|
|
1636
|
+
const value = words[index + 1];
|
|
1637
|
+
if (value === void 0 || value === "--") throw new CommsError("USAGE", `${spaced} needs a value before --`);
|
|
1638
|
+
index += 1;
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
if (flags.some((flag) => word.startsWith(`${flag}=`))) continue;
|
|
1642
|
+
kept.push(word);
|
|
1643
|
+
}
|
|
1644
|
+
return kept;
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Replaces every pre-sentinel suite path option with one canonical pin per supplied directory. `insertAt` counts
|
|
1648
|
+
* words after the old pins are removed: zero puts global options after the executable held elsewhere, one puts them
|
|
1649
|
+
* after an entry/package word. Downloads are included only when the caller supplies that pin.
|
|
1650
|
+
*/
|
|
1651
|
+
function normalizePathOptionWords(words, pins, insertAt) {
|
|
1652
|
+
const stripped = withoutOptionsBeforeSentinel(words, PATH_OPTIONS.map(({ flag }) => flag));
|
|
1653
|
+
const sentinel = stripped.indexOf("--");
|
|
1654
|
+
const optionsEnd = sentinel < 0 ? stripped.length : sentinel;
|
|
1655
|
+
if (!Number.isSafeInteger(insertAt) || insertAt < 0 || insertAt > optionsEnd) throw new TypeError(`path-option insertion point ${insertAt} is outside the option words`);
|
|
1656
|
+
const pathWords = PATH_OPTIONS.flatMap(({ key, flag }) => {
|
|
1657
|
+
const value = pins[key];
|
|
1658
|
+
if (value === void 0) return [];
|
|
1659
|
+
if (value.length === 0) throw new CommsError("USAGE", `${flag} needs a non-empty directory`);
|
|
1660
|
+
return [flag, resolve(value)];
|
|
1661
|
+
});
|
|
1662
|
+
return [
|
|
1663
|
+
...stripped.slice(0, insertAt),
|
|
1664
|
+
...pathWords,
|
|
1665
|
+
...stripped.slice(insertAt)
|
|
1666
|
+
];
|
|
1667
|
+
}
|
|
1668
|
+
function colorEnabled(env, stream, flag) {
|
|
1669
|
+
if (flag === false) return false;
|
|
1670
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
1671
|
+
if (env.TERM === "dumb") return false;
|
|
1672
|
+
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
|
|
1673
|
+
return Boolean(stream.isTTY);
|
|
1674
|
+
}
|
|
1675
|
+
/** True when a human could answer a prompt: both ends are terminals, no --json/--no-input, not CI. */
|
|
1676
|
+
function canPrompt(env, streams, options) {
|
|
1677
|
+
if (options.json || options.noInput) return false;
|
|
1678
|
+
if (env.CI && env.CI !== "0" && env.CI !== "false") return false;
|
|
1679
|
+
return Boolean(streams.stdin?.isTTY && streams.stdout.isTTY);
|
|
1680
|
+
}
|
|
1681
|
+
/** Environment variables well-known coding agents set. Used only as a speed bump, never as a security boundary. */
|
|
1682
|
+
const AGENT_MARKERS = [
|
|
1683
|
+
"CLAUDECODE",
|
|
1684
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
1685
|
+
"CODEX_SANDBOX",
|
|
1686
|
+
"CODEX_HOME",
|
|
1687
|
+
"CURSOR_AGENT",
|
|
1688
|
+
"GEMINI_CLI",
|
|
1689
|
+
"AGENT_COMMS_AGENT"
|
|
1690
|
+
];
|
|
1691
|
+
function agentMarker(env) {
|
|
1692
|
+
for (const name of AGENT_MARKERS) if (env[name] !== void 0 && env[name] !== "") return name;
|
|
1693
|
+
return null;
|
|
1694
|
+
}
|
|
1695
|
+
function paint(color, format, text) {
|
|
1696
|
+
return color ? styleText(format, text, { validateStream: false }) : text;
|
|
1697
|
+
}
|
|
1698
|
+
/** A command in backticks, for a sentence — as its words in JSON, saying it has to be typed, when it has no line. */
|
|
1699
|
+
function inlineCommand(command) {
|
|
1700
|
+
return inlineQuoted(command);
|
|
1701
|
+
}
|
|
1702
|
+
/** A command as text of its own — a list's line, a field's value — or its words in JSON, saying it has to be typed. */
|
|
1703
|
+
function commandText(command) {
|
|
1704
|
+
return quotedText(command);
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* A command's line with words the agent is to fill in — a placeholder such as `<folder>`, printed as written, never
|
|
1708
|
+
* quoted as a word — before the command's first `--`, where the CLI still reads them as options. Null when the
|
|
1709
|
+
* command has no line.
|
|
1710
|
+
*/
|
|
1711
|
+
function lineWithWordsToFill(command, ...toFill) {
|
|
1712
|
+
return quotedLineWithWordsToFill(command, ...toFill);
|
|
1713
|
+
}
|
|
1714
|
+
/** Writes a successful result: the envelope with --json, otherwise the human rendering. */
|
|
1715
|
+
function writeResult(data, options, human, streams = defaultStreams) {
|
|
1716
|
+
if (options.json) {
|
|
1717
|
+
streams.stdout.write(`${JSON.stringify(okEnvelope(data))}\n`);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const text = human(data);
|
|
1721
|
+
if (text) streams.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
1722
|
+
}
|
|
1723
|
+
/** Writes an error and returns the exit code to use. */
|
|
1724
|
+
function writeError(error, options, streams = defaultStreams) {
|
|
1725
|
+
const commsError = toCommsError(error);
|
|
1726
|
+
if (options.json) streams.stdout.write(`${JSON.stringify(errorEnvelope(commsError))}\n`);
|
|
1727
|
+
else {
|
|
1728
|
+
streams.stderr.write(`${paint(options.color, "red", "error")}: ${commsError.message}\n`);
|
|
1729
|
+
if (commsError.hint) streams.stderr.write(`${paint(options.color, "dim", "hint")}: ${commsError.hint}\n`);
|
|
1730
|
+
}
|
|
1731
|
+
return commsError.exitCode;
|
|
1732
|
+
}
|
|
1733
|
+
/** Runs a command body and exits with the documented code. Unexpected errors keep only their message. */
|
|
1734
|
+
async function runCommand(options, body, streams = defaultStreams) {
|
|
1735
|
+
try {
|
|
1736
|
+
await body();
|
|
1737
|
+
return EXIT_CODES.OK;
|
|
1738
|
+
} catch (error) {
|
|
1739
|
+
return writeError(error, options, streams);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* The first two steps of `requirePerson` — an agent is refused, then anything without a terminal — for a command
|
|
1744
|
+
* whose code is not its own to make up.
|
|
1745
|
+
*
|
|
1746
|
+
* `agentcomms approve` asks for a code the approval store issued and will check, so it cannot use `askChallenge`;
|
|
1747
|
+
* but it refuses in exactly the same order and words, because it is the same gate.
|
|
1748
|
+
*/
|
|
1749
|
+
function refuseUnlessPerson(env, streams, gate) {
|
|
1750
|
+
const marker = agentMarker(env);
|
|
1751
|
+
if (marker) throw new CommsError("LOOSENING_REFUSED", gate.refusedToAgent, {
|
|
1752
|
+
hint: gateSentence(gate.command, (command) => `Ask the user to run ${command} in their own terminal.`),
|
|
1753
|
+
details: { marker }
|
|
1754
|
+
});
|
|
1755
|
+
const prompting = {};
|
|
1756
|
+
if (gate.json !== void 0) prompting.json = gate.json;
|
|
1757
|
+
if (gate.noInput !== void 0) prompting.noInput = gate.noInput;
|
|
1758
|
+
if (!canPrompt(env, streams, prompting)) throw new CommsError("LOOSENING_REFUSED", gate.refusedWithoutTerminal, { hint: gateSentence(gate.command, (command) => `Run ${command} directly in a terminal.`) });
|
|
1759
|
+
}
|
|
1760
|
+
/** A gate's command in a sentence: in backticks, or the sentence saying why there is none in its place. */
|
|
1761
|
+
function gateSentence(command, say) {
|
|
1762
|
+
return "message" in command ? command.message : say(inlineCommand(command));
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* The handoffs a store or a deep function was given (`core.handoffs`, handed on by `openCore`), for the command one of
|
|
1766
|
+
* its refusals names. A programming error when there are none: the package that opened core did not give it its
|
|
1767
|
+
* caller (`openCore({ caller })`), and there is no command to print in its place — not a bare name, not a guess.
|
|
1768
|
+
*/
|
|
1769
|
+
function requiredHandoffs(handoffs) {
|
|
1770
|
+
if (handoffs === void 0) throw new TypeError("core was opened without its caller, so it cannot locate a command: pass openCore({ caller })");
|
|
1771
|
+
return handoffs;
|
|
1772
|
+
}
|
|
1773
|
+
/** Whether a handoff is a command to run, rather than the sentence saying why there is none. */
|
|
1774
|
+
function isCommand(handoff) {
|
|
1775
|
+
return !("message" in handoff);
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* A handoff as a value of its own — a list's line, a field's value: the command's line, or its words as JSON with what
|
|
1779
|
+
* to do when no line is safe in every Windows shell; or, with no command, the sentence saying why.
|
|
1780
|
+
*/
|
|
1781
|
+
function handoffText(handoff) {
|
|
1782
|
+
return isCommand(handoff) ? commandText(handoff) : handoff.message;
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1785
|
+
* A remedy, a line per argument: one part, or the parts of one line in order — `[remove, ', then add it again']`.
|
|
1786
|
+
* A command is shown as `handoffText` shows it: its line, its words as JSON with what to do when no Windows line is
|
|
1787
|
+
* safe, or why there is none here.
|
|
1788
|
+
*/
|
|
1789
|
+
function remedy(...lines) {
|
|
1790
|
+
const part = (each) => typeof each === "string" ? each : handoffText(each);
|
|
1791
|
+
return lines.map((line) => Array.isArray(line) ? line.map(part).join("") : part(line)).join("\n");
|
|
1792
|
+
}
|
|
1793
|
+
/** The sentence saying why there is no command, after another way when there is one. */
|
|
1794
|
+
function noCommand(handoff, options) {
|
|
1795
|
+
return options.instead === void 0 ? handoff.message : `${options.instead} ${handoff.message}`;
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* A sentence that gives the command, in backticks, made by `say`; or, with no command, the sentence saying why there is
|
|
1799
|
+
* none — after `instead`, when given — never `say` with something else in the command's place.
|
|
1800
|
+
*/
|
|
1801
|
+
function handoffSentence(handoff, say, options = {}) {
|
|
1802
|
+
return isCommand(handoff) ? say(inlineCommand(handoff)) : noCommand(handoff, options);
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* As `handoffSentence`, for a command with words the agent is to fill in — a placeholder such as `<folder>`, printed as
|
|
1806
|
+
* written — before its first `--`. With no line to show them in, the command is shown as its words, placeholders among
|
|
1807
|
+
* them, saying it has to be typed.
|
|
1808
|
+
*/
|
|
1809
|
+
function handoffSentenceToFill(handoff, toFill, say, options = {}) {
|
|
1810
|
+
if (!isCommand(handoff)) return noCommand(handoff, options);
|
|
1811
|
+
const line = lineWithWordsToFill(handoff, ...toFill);
|
|
1812
|
+
if (line !== null) return say(`\`${line}\``);
|
|
1813
|
+
return say(inlineQuoted(quotedWithWordsToFill(handoff, toFill)));
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Commands that each could do it — one per channel, say — as one phrase: the commands joined by "or", then, for each
|
|
1817
|
+
* that has none here, the sentence saying why. `none` is said when not one of them is a command.
|
|
1818
|
+
*/
|
|
1819
|
+
function handoffChoices(handoffs, none, options = {}) {
|
|
1820
|
+
const commands = handoffs.filter((handoff) => isCommand(handoff)).map((handoff) => inlineCommand(handoff));
|
|
1821
|
+
const missing = handoffs.filter((handoff) => !isCommand(handoff));
|
|
1822
|
+
const reasons = [...new Set(missing.map((handoff) => handoff.message))].join(" ");
|
|
1823
|
+
if (commands.length === 0) return `${none} ${reasons}`.trim();
|
|
1824
|
+
const conjunction = options.conjunction ?? "or";
|
|
1825
|
+
const joined = commands.length === 1 ? commands[0] : `${commands.slice(0, -1).join(", ")} ${conjunction} ${commands.at(-1)}`;
|
|
1826
|
+
return reasons === "" ? joined : `${joined} (${reasons.replace(/\.$/, "")})`;
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* One table of dangerous characters, shared by the inbound sanitiser (which strips them from sender-controlled text)
|
|
1830
|
+
* and the preview renderer (which makes them visible), so the two can never disagree.
|
|
1831
|
+
*/
|
|
1832
|
+
/** Zero-width and formatting characters, bidi controls, variation selectors and Unicode tag characters. */
|
|
1833
|
+
const INVISIBLE_RANGES = [
|
|
1834
|
+
[173, 173],
|
|
1835
|
+
[847, 847],
|
|
1836
|
+
[1564, 1564],
|
|
1837
|
+
[4447, 4448],
|
|
1838
|
+
[6068, 6069],
|
|
1839
|
+
[6155, 6159],
|
|
1840
|
+
[8203, 8207],
|
|
1841
|
+
[8232, 8233],
|
|
1842
|
+
[8234, 8238],
|
|
1843
|
+
[8288, 8292],
|
|
1844
|
+
[8294, 8303],
|
|
1845
|
+
[12644, 12644],
|
|
1846
|
+
[65024, 65039],
|
|
1847
|
+
[65279, 65279],
|
|
1848
|
+
[65440, 65440],
|
|
1849
|
+
[917504, 917631]
|
|
1850
|
+
];
|
|
1851
|
+
function isInvisible(codePoint) {
|
|
1852
|
+
for (const [from, to] of INVISIBLE_RANGES) if (codePoint >= from && codePoint <= to) return true;
|
|
1853
|
+
return false;
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* C0 controls except tab and newline (ESC, and so every ANSI/OSC sequence, starts here), DEL, and C1 controls (CSI
|
|
1857
|
+
* among them). A lone carriage return counts: it moves a terminal cursor back over text already printed.
|
|
1858
|
+
*/
|
|
1859
|
+
function isControl(codePoint) {
|
|
1860
|
+
if (codePoint === 9 || codePoint === 10) return false;
|
|
1861
|
+
return codePoint < 32 || codePoint === 127 || codePoint >= 128 && codePoint <= 159;
|
|
1862
|
+
}
|
|
1863
|
+
function isDangerous(codePoint) {
|
|
1864
|
+
return isControl(codePoint) || isInvisible(codePoint);
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Strips control characters (ESC, CSI, OSC and the rest), DEL, lone carriage returns, zero-width, bidi-control and tag
|
|
1868
|
+
* characters from sender-controlled text; returns the text and how many were removed. CRLF becomes LF first.
|
|
1869
|
+
*
|
|
1870
|
+
* This lives beside the table rather than in the sanitiser because `neutralise` needs it too, and the two must not
|
|
1871
|
+
* drift: a pattern that looks for `</untrusted-content` cannot see it through a zero-width space, so stripping
|
|
1872
|
+
* has to happen before any such pattern runs, on every path, not only on the ones that render a body.
|
|
1873
|
+
*/
|
|
1874
|
+
function stripInvisible(text) {
|
|
1875
|
+
let removed = 0;
|
|
1876
|
+
let out = "";
|
|
1877
|
+
for (const char of text.replace(/\r\n/g, "\n")) if (isDangerous(char.codePointAt(0) ?? 0)) removed += 1;
|
|
1878
|
+
else out += char;
|
|
1879
|
+
return {
|
|
1880
|
+
text: out,
|
|
1881
|
+
removed
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1611
1884
|
const WINDOWS_RESERVED$1 = /^(con|prn|aux|nul|conin\$|conout\$|com[0-9¹²³]|lpt[0-9¹²³])$/i;
|
|
1612
1885
|
const UNSAFE_FILENAME_CHARS = /[/\\\u0000-\u001f\u007f<>:"|?*]/g;
|
|
1613
1886
|
/** Truncates a string to at most `maxBytes` UTF-8 bytes without splitting a code point. */
|
|
@@ -1740,7 +2013,14 @@ async function checkAttachable(path, policy) {
|
|
|
1740
2013
|
throw new CommsError("NOT_FOUND", `attachment not found: ${path}`);
|
|
1741
2014
|
}
|
|
1742
2015
|
if (!(await stat(real)).isFile()) throw new CommsError("BAD_DATA", `not a regular file: ${path}`);
|
|
1743
|
-
if (!(await Promise.all(policy.roots.filter((root) => namesItsPlace(root)).map((root) => realOrResolved(expandHome(root, home))))).some((root) => isInside(real, root)))
|
|
2016
|
+
if (!(await Promise.all(policy.roots.filter((root) => namesItsPlace(root)).map((root) => realOrResolved(expandHome(root, home))))).some((root) => isInside(real, root))) {
|
|
2017
|
+
const allow = handoffSentenceToFill(requiredHandoffs(policy.handoffs).core([
|
|
2018
|
+
"attach",
|
|
2019
|
+
"roots",
|
|
2020
|
+
"add"
|
|
2021
|
+
]), ["<folder>"], (command) => `allow its folder with ${command} (needs your approval).`, { instead: "allow its folder with comms_attach from a chat (needs your approval)." });
|
|
2022
|
+
throw new CommsError("BAD_DATA", `attachments must come from an allowed folder; ${path} is outside them`, { hint: `Copy the file under your home folder — not into one of its hidden folders — and name the copy instead, or ${allow}` });
|
|
2023
|
+
}
|
|
1744
2024
|
const name = basename(real);
|
|
1745
2025
|
const homeDir = await realOrResolved(home ?? (await import("node:os")).homedir());
|
|
1746
2026
|
for (const entry of policy.deny) {
|
|
@@ -2216,21 +2496,24 @@ function committedSecretsStore(config) {
|
|
|
2216
2496
|
* asked for, the keychain by default. `choosing` says this command is the one choosing it.
|
|
2217
2497
|
*
|
|
2218
2498
|
* A different backend asked for is refused, not taken: one backend holds everything here, and changing it has to move
|
|
2219
|
-
* what is already stored, which is what `agentcomms secrets migrate` does and nothing else does.
|
|
2499
|
+
* what is already stored, which is what `agentcomms secrets migrate` does and nothing else does. The refusal names that
|
|
2500
|
+
* command, made by `handoffs` — the caller's, located (CUE-403).
|
|
2220
2501
|
*/
|
|
2221
|
-
function secretsStoreFor(config, requested,
|
|
2502
|
+
function secretsStoreFor(config, requested, handoffs) {
|
|
2222
2503
|
const committed = committedSecretsStore(config);
|
|
2223
2504
|
if (committed === null) return {
|
|
2224
2505
|
store: requested ?? "keychain",
|
|
2225
2506
|
choosing: true
|
|
2226
2507
|
};
|
|
2227
|
-
if (requested !== void 0 && requested !== committed)
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2508
|
+
if (requested !== void 0 && requested !== committed) {
|
|
2509
|
+
const migrate = handoffSentence(handoffs.core([
|
|
2510
|
+
"secrets",
|
|
2511
|
+
"migrate",
|
|
2512
|
+
"--to",
|
|
2513
|
+
requested
|
|
2514
|
+
]), (command) => `To change it, run ${command}, then run this again.`, { instead: "To change it, call comms_secrets_migrate from a chat, then run this again." });
|
|
2515
|
+
throw new CommsError("CONFIG", `this configuration already keeps its secrets in the ${committed} store`, { hint: `Everything here uses one store, and changing it moves what is already stored. ${migrate}` });
|
|
2516
|
+
}
|
|
2234
2517
|
return {
|
|
2235
2518
|
store: committed,
|
|
2236
2519
|
choosing: false
|
|
@@ -2292,10 +2575,20 @@ function parseConfig$1(text, source = "config.json") {
|
|
|
2292
2575
|
var ConfigStore = class {
|
|
2293
2576
|
path;
|
|
2294
2577
|
#lockPath;
|
|
2578
|
+
#handoffs;
|
|
2295
2579
|
#cache = null;
|
|
2296
|
-
|
|
2580
|
+
/**
|
|
2581
|
+
* @param options.handoffs the printing package's handoffs (`core.handoffs`, CUE-403), for the command a refusal
|
|
2582
|
+
* names; left out, a refusal that has to name one is a programming error (`requiredHandoffs`).
|
|
2583
|
+
*/
|
|
2584
|
+
constructor(configDir, options = {}) {
|
|
2297
2585
|
this.path = join(configDir, "config.json");
|
|
2298
2586
|
this.#lockPath = join(configDir, ".config.lock");
|
|
2587
|
+
this.#handoffs = options.handoffs;
|
|
2588
|
+
}
|
|
2589
|
+
/** "Run the names migration again", as core's command — located from whatever is printing — or the tool. */
|
|
2590
|
+
#namesMigrateAgain(why) {
|
|
2591
|
+
return handoffSentence(requiredHandoffs(this.#handoffs).core(["names", "migrate"]), (command) => `Run ${command} again ${why}.`, { instead: `Call comms_names_migrate again ${why}.` });
|
|
2299
2592
|
}
|
|
2300
2593
|
/** The current config; an empty one when the file does not exist yet. */
|
|
2301
2594
|
async load() {
|
|
@@ -2328,7 +2621,7 @@ var ConfigStore = class {
|
|
|
2328
2621
|
this.#cache = null;
|
|
2329
2622
|
const current = structuredClone(await this.load());
|
|
2330
2623
|
const next = await mutator(structuredClone(current));
|
|
2331
|
-
if (next.version !== current.version) throw new CommsError("CONFIG", `refusing to change the config version from ${current.version} to ${String(next.version)}`, { hint: "Only
|
|
2624
|
+
if (next.version !== current.version) throw new CommsError("CONFIG", `refusing to change the config version from ${current.version} to ${String(next.version)}`, { hint: "Only the names migration changes the version. This is a bug — please report it." });
|
|
2332
2625
|
const parsed = schemaFor(current.version).safeParse(next);
|
|
2333
2626
|
if (!parsed.success) throw new CommsError("CONFIG", `refusing to write invalid config: ${describeIssues(parsed.error, current.version)}`);
|
|
2334
2627
|
if (current.version === 2 && parsed.data.version === 2) {
|
|
@@ -2339,7 +2632,7 @@ var ConfigStore = class {
|
|
|
2339
2632
|
const allowed = new Set(options.consent?.paths ?? []);
|
|
2340
2633
|
const unconsented = loosened.filter((path) => !allowed.has(path));
|
|
2341
2634
|
if (unconsented.length > 0) throw new CommsError("LOOSENING_REFUSED", `this change loosens a safety setting: ${unconsented.join(", ")}`, {
|
|
2342
|
-
hint: "A person approves a loosening, from a chat or at a terminal. From a chat, the tool that makes the change returns a preview and an approval id: the person says yes in the chat (change policy `chat`) or runs
|
|
2635
|
+
hint: "A person approves a loosening, from a chat or at a terminal. From a chat, the tool that makes the change returns a preview and an approval id: the person says yes in the chat (change policy `chat`) or runs the approve command the result gives at their own terminal (`confirm`), and the tool is called again with the id. At a terminal, run the matching command and approve the change it shows.",
|
|
2343
2636
|
details: { paths: unconsented }
|
|
2344
2637
|
});
|
|
2345
2638
|
const approved = options.consent?.changes;
|
|
@@ -2399,13 +2692,13 @@ var ConfigStore = class {
|
|
|
2399
2692
|
const raw = await readFileIfExists(this.path);
|
|
2400
2693
|
const current = raw === null ? emptyConfig() : parseConfig$1(raw, this.path);
|
|
2401
2694
|
if (current.version === 2) {
|
|
2402
|
-
if (!migrationApplied(current, rows)) throw new CommsError("TRANSIENT", "the names were migrated while this ran, and not to these names", { hint: "
|
|
2695
|
+
if (!migrationApplied(current, rows)) throw new CommsError("TRANSIENT", "the names were migrated while this ran, and not to these names", { hint: this.#namesMigrateAgain("to see what they are called now") });
|
|
2403
2696
|
return {
|
|
2404
2697
|
status: "already-migrated",
|
|
2405
2698
|
config: current
|
|
2406
2699
|
};
|
|
2407
2700
|
}
|
|
2408
|
-
if (configFingerprint(current) !== expected) throw new CommsError("TRANSIENT", "the configuration changed after the preview was made", { hint: "
|
|
2701
|
+
if (configFingerprint(current) !== expected) throw new CommsError("TRANSIENT", "the configuration changed after the preview was made", { hint: this.#namesMigrateAgain("to see the mapping for the configuration as it is now") });
|
|
2409
2702
|
const parsed = configV2Schema.safeParse(build(structuredClone(current)));
|
|
2410
2703
|
if (!parsed.success) throw new CommsError("CONFIG", `refusing to write invalid config: ${describeIssues(parsed.error, 2)}`);
|
|
2411
2704
|
const unchanged = onlyKeysRenamed(current, parsed.data);
|
|
@@ -2968,13 +3261,40 @@ function publicView(record) {
|
|
|
2968
3261
|
const { challengeHash: _hidden, ...rest } = record;
|
|
2969
3262
|
return rest;
|
|
2970
3263
|
}
|
|
3264
|
+
/**
|
|
3265
|
+
* The refusals of a send's approval offered to a change. They name each sending channel's `approve`; another product's
|
|
3266
|
+
* is found only among the registrations read for it, which the store does not read under its lock — so a caller that
|
|
3267
|
+
* can read them says it again with them (`sendApprovesHint`).
|
|
3268
|
+
*/
|
|
3269
|
+
const MISDIRECTED_SENDS = /* @__PURE__ */ new WeakSet();
|
|
3270
|
+
/**
|
|
3271
|
+
* What an agent is told when a change waits for a person at a terminal: the printing package's own `approve` with this
|
|
3272
|
+
* id, located, or why there is none here.
|
|
3273
|
+
*/
|
|
3274
|
+
function approvePendingHint(handoffs, approvalId, platform) {
|
|
3275
|
+
const located = requiredHandoffs(handoffs);
|
|
3276
|
+
return handoffSentence(located.on(platform ?? located.platform).own(["approve", approvalId]), (command) => `Ask the user to run ${command} in their own terminal, then try again with the same approval.`);
|
|
3277
|
+
}
|
|
3278
|
+
/** Whether `error` is the refusal of a send's approval offered as a change's. */
|
|
3279
|
+
function isMisdirectedSend(error) {
|
|
3280
|
+
return error instanceof CommsError && MISDIRECTED_SENDS.has(error);
|
|
3281
|
+
}
|
|
3282
|
+
/**
|
|
3283
|
+
* What approves the send `approvalId`: the `approve` of every channel that sends, one of which prepared it — a send
|
|
3284
|
+
* record does not say which (D1). Each is a located command, or why it has none here.
|
|
3285
|
+
*/
|
|
3286
|
+
function sendApprovesHint(maker, approvalId) {
|
|
3287
|
+
return `It is approved with the command that prepared it — ${handoffChoices(accountChannels().filter((manifest) => manifest.accounts?.modes.includes("send") === true).map((manifest) => maker.of(manifest.channel, ["approve", approvalId])), "and none is locatable here:")} — and permits only that send.`;
|
|
3288
|
+
}
|
|
2971
3289
|
var ApprovalStore = class {
|
|
2972
3290
|
directory;
|
|
2973
3291
|
#now;
|
|
2974
3292
|
#ttlMs;
|
|
2975
3293
|
#downloadTtlMs;
|
|
3294
|
+
#handoffs;
|
|
2976
3295
|
constructor(stateDir, options = {}) {
|
|
2977
3296
|
this.directory = join(stateDir, "approvals");
|
|
3297
|
+
this.#handoffs = options.handoffs;
|
|
2978
3298
|
this.#now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
2979
3299
|
this.#ttlMs = options.ttlMs ?? 6e5;
|
|
2980
3300
|
this.#downloadTtlMs = options.downloadTtlMs ?? 18e5;
|
|
@@ -3080,17 +3400,17 @@ var ApprovalStore = class {
|
|
|
3080
3400
|
* Nothing written, because the caller made a mistake about an approval that may be perfectly good: voiding a post
|
|
3081
3401
|
* somebody is about to approve, because an agent passed its id to a change, would punish the wrong party.
|
|
3082
3402
|
*/
|
|
3083
|
-
#requireKind(record, kind, platform) {
|
|
3403
|
+
#requireKind(record, kind, platform, handoffs = this.#handoffs) {
|
|
3404
|
+
const maker = () => requiredHandoffs(handoffs).on(platform);
|
|
3084
3405
|
const actual = approvalKind(record);
|
|
3085
3406
|
if (actual === kind) return;
|
|
3086
3407
|
const id = record.approvalId;
|
|
3087
3408
|
if (actual === "download") throw (kind === "change" ? refuseChange : refuse$1)("USAGE", `${id} is a question about where to save files, not ${kind === "send" ? "a send" : "a configuration change"}`, record, DOWNLOAD_ANSWER_HINT);
|
|
3088
3409
|
if (kind === "download") throw refuseDownload("USAGE", `${id} is ${actual === "change" ? "a configuration change" : "a send"}, not a question about where to save files`, record, "Pass the choice id the download’s question came with, and the person’s answer beside it.");
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
], platform))} — or ${channelApproveCommands()}, whichever is installed — and it permits only the change it was prepared for.`) : refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, `It is approved with the command that prepared it — ${channelApproveCommands({ sending: true })} — and permits only that send.`);
|
|
3410
|
+
if (kind === "send") throw refuse$1("USAGE", `approval ${id} is for a configuration change, not a send`, record, handoffSentence(maker().own(["approve", id]), (command) => `A person approves it with ${command}, and it permits only the change it was prepared for.`));
|
|
3411
|
+
const misdirected = refuseChange("USAGE", `approval ${id} is for a send, not a configuration change`, record, sendApprovesHint(maker(), id));
|
|
3412
|
+
MISDIRECTED_SENDS.add(misdirected);
|
|
3413
|
+
throw misdirected;
|
|
3094
3414
|
}
|
|
3095
3415
|
/** Issues a new challenge to show a human; only its hash is kept. */
|
|
3096
3416
|
async issueChallenge(approvalId, kind = "send", platform = process.platform) {
|
|
@@ -3173,7 +3493,7 @@ var ApprovalStore = class {
|
|
|
3173
3493
|
async claimForSend(approvalId, live, options = {}) {
|
|
3174
3494
|
let failure = null;
|
|
3175
3495
|
const result = await this.#transition(approvalId, (current) => {
|
|
3176
|
-
this.#requireKind(current, "send", options.platform ?? process.platform);
|
|
3496
|
+
this.#requireKind(current, "send", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
|
|
3177
3497
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
3178
3498
|
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
3179
3499
|
const voidWith = (code, reason) => {
|
|
@@ -3285,7 +3605,7 @@ var ApprovalStore = class {
|
|
|
3285
3605
|
const digest = changeDigest(live.change);
|
|
3286
3606
|
let failure = null;
|
|
3287
3607
|
const result = await this.#transition(approvalId, (current) => {
|
|
3288
|
-
this.#requireKind(current, "change", options.platform ?? process.platform);
|
|
3608
|
+
this.#requireKind(current, "change", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
|
|
3289
3609
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
3290
3610
|
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
3291
3611
|
const voidWith = (reason) => {
|
|
@@ -3301,11 +3621,7 @@ var ApprovalStore = class {
|
|
|
3301
3621
|
};
|
|
3302
3622
|
if (digest !== current.digest) return voidWith(current.change ? changeDrift(current.change, live.change) : "the change is not the one that was approved");
|
|
3303
3623
|
if (stricterPolicy(live.policy, current.requiredPolicy) !== "chat") {
|
|
3304
|
-
if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ??
|
|
3305
|
-
"agentcomms",
|
|
3306
|
-
"approve",
|
|
3307
|
-
approvalId
|
|
3308
|
-
], options.platform ?? process.platform))} in their own terminal, then try again with the same approval.`);
|
|
3624
|
+
if (current.state !== "approved") throw refuseChange("APPROVAL_PENDING", "this change needs a person to approve it at a terminal first", current, options.pendingHint ?? approvePendingHint(options.handoffs ?? this.#handoffs, approvalId, options.platform));
|
|
3309
3625
|
if (current.approvedVia !== "terminal") return voidWith("the change policy is confirm, and this was not approved at a terminal");
|
|
3310
3626
|
}
|
|
3311
3627
|
return {
|
|
@@ -3427,7 +3743,7 @@ var ApprovalStore = class {
|
|
|
3427
3743
|
const digest = downloadDigest(live);
|
|
3428
3744
|
let failure = null;
|
|
3429
3745
|
const result = await this.#transition(approvalId, (current) => {
|
|
3430
|
-
this.#requireKind(current, "download", options.platform ?? process.platform);
|
|
3746
|
+
this.#requireKind(current, "download", options.platform ?? process.platform, options.handoffs ?? this.#handoffs);
|
|
3431
3747
|
if (current.state !== "pending" && current.state !== "approved") throw this.#stateError(current);
|
|
3432
3748
|
if (options.signal?.aborted) throw cancelledClaim(current);
|
|
3433
3749
|
const voidWith = (reason) => {
|
|
@@ -3578,125 +3894,99 @@ var AuditLog = class {
|
|
|
3578
3894
|
return out.reverse();
|
|
3579
3895
|
}
|
|
3580
3896
|
};
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3897
|
+
/** Every command a manifest declares — each channel's `binary` and its server's other `bins` — lowercased. */
|
|
3898
|
+
const SUITE_COMMANDS = new Set(CHANNEL_SNAPSHOT.flatMap(({ manifest }) => [manifest.binary, ...manifest.server.bins ?? []]).map((name) => name.toLowerCase()));
|
|
3899
|
+
/** The extensions Windows finds an executable by: `agent-gmail.cmd` is `agent-gmail`. */
|
|
3900
|
+
const EXECUTABLE_EXTENSION = /\.(?:cmd|exe|bat|ps1|com)$/i;
|
|
3585
3901
|
/**
|
|
3586
|
-
*
|
|
3587
|
-
*
|
|
3588
|
-
* A name is user input and the maps are plain objects, so `config.inboxes.constructor` is a function — and a lookup
|
|
3589
|
-
* of an inbox called `constructor`, which version 1 allows, found it.
|
|
3902
|
+
* The suite command a word names as a program — its last path part, without a Windows extension, compared without
|
|
3903
|
+
* case as Windows compares executable names — or null.
|
|
3590
3904
|
*/
|
|
3591
|
-
function
|
|
3592
|
-
|
|
3905
|
+
function suiteCommandOf(word) {
|
|
3906
|
+
const name = (word.split(/[\\/]/).at(-1) ?? "").replace(EXECUTABLE_EXTENSION, "").toLowerCase();
|
|
3907
|
+
return SUITE_COMMANDS.has(name) ? name : null;
|
|
3593
3908
|
}
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
alias: name,
|
|
3599
|
-
inbox
|
|
3600
|
-
};
|
|
3601
|
-
} else {
|
|
3602
|
-
const account = own$1(config.accounts, name);
|
|
3603
|
-
if (account) return {
|
|
3604
|
-
alias: name,
|
|
3605
|
-
account
|
|
3606
|
-
};
|
|
3607
|
-
}
|
|
3608
|
-
const renamed = formerNameRefusal(config, kind, name);
|
|
3609
|
-
if (renamed) throw renamed;
|
|
3610
|
-
throw notFound?.() ?? defaultNotFound(config, kind, name);
|
|
3611
|
-
}
|
|
3612
|
-
/** What to say when there are no mailboxes at all: the command that connects one, from the mail channel's manifest. */
|
|
3613
|
-
function noInboxesHint() {
|
|
3614
|
-
const connect = connectMailboxCommand();
|
|
3615
|
-
return connect ? `No inboxes yet: add one with \`${connect}\`.` : "No inboxes yet.";
|
|
3909
|
+
/** A parsed `package.json`, as an object, or null when there is none, it cannot be read, or it is not an object. */
|
|
3910
|
+
function readPackageManifest(dir) {
|
|
3911
|
+
const read = readPackageFile(dir);
|
|
3912
|
+
return read === "absent" ? null : read;
|
|
3616
3913
|
}
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3914
|
+
/** `absent` when the directory has no `package.json`; otherwise what it says, or null when that cannot be read. */
|
|
3915
|
+
function readPackageFile(dir) {
|
|
3916
|
+
let text;
|
|
3917
|
+
try {
|
|
3918
|
+
text = readFileSync(join(dir, "package.json"), "utf8");
|
|
3919
|
+
} catch (error) {
|
|
3920
|
+
const code = error.code;
|
|
3921
|
+
return code === "ENOENT" || code === "ENOTDIR" ? "absent" : null;
|
|
3922
|
+
}
|
|
3923
|
+
try {
|
|
3924
|
+
const parsed = JSON.parse(text);
|
|
3925
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
3926
|
+
} catch {
|
|
3927
|
+
return null;
|
|
3621
3928
|
}
|
|
3622
|
-
return new CommsError("NOT_FOUND", `no account called "${name}"`);
|
|
3623
|
-
}
|
|
3624
|
-
function lookupName(config, kind, name) {
|
|
3625
|
-
return kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.accounts, name);
|
|
3626
3929
|
}
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3930
|
+
/**
|
|
3931
|
+
* The nearest package at or above `start`: the first directory with a `package.json`, and what it says (null when it
|
|
3932
|
+
* cannot be read or parsed). Not the nearest one with a particular name — the package a module belongs to is the
|
|
3933
|
+
* nearest one, as Node itself decides, and a caller that names a different one has to be told so.
|
|
3934
|
+
*/
|
|
3935
|
+
function nearestPackage(start) {
|
|
3936
|
+
let dir = resolve(start);
|
|
3937
|
+
for (;;) {
|
|
3938
|
+
const manifest = readPackageFile(dir);
|
|
3939
|
+
if (manifest !== "absent") return {
|
|
3940
|
+
root: dir,
|
|
3941
|
+
manifest
|
|
3632
3942
|
};
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
}
|
|
3637
|
-
return null;
|
|
3943
|
+
const parent = dirname(dir);
|
|
3944
|
+
if (parent === dir) return null;
|
|
3945
|
+
dir = parent;
|
|
3946
|
+
}
|
|
3638
3947
|
}
|
|
3639
3948
|
/**
|
|
3640
|
-
*
|
|
3641
|
-
*
|
|
3642
|
-
* The replacement is looked up by id rather than read from the record, so a chain of renames ends at the name the
|
|
3643
|
-
* account has today — and an account removed since its rename is said to be gone, rather than pointing somebody at a
|
|
3644
|
-
* name that now belongs to nothing.
|
|
3949
|
+
* Whether `path` is `root` or inside it, by whole path segments: `/x/core-evil` is not inside `/x/core`, which a
|
|
3950
|
+
* comparison of strings would say it was. Both are taken as given; a caller compares real paths with real paths.
|
|
3645
3951
|
*/
|
|
3646
|
-
function
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
}
|
|
3952
|
+
function isWithin(root, path) {
|
|
3953
|
+
const between = relative(resolve(root), resolve(path));
|
|
3954
|
+
return between === "" || !isAbsolute(between) && between !== ".." && !/^\.\.[\\/]/.test(between);
|
|
3955
|
+
}
|
|
3956
|
+
/** The real path of `path`, or of its deepest ancestor that exists with the rest joined on; null when none does. */
|
|
3957
|
+
function realpathOfExisting(path) {
|
|
3958
|
+
let current = resolve(path);
|
|
3959
|
+
const rest = [];
|
|
3960
|
+
for (;;) try {
|
|
3961
|
+
const real = realpathSync(current);
|
|
3962
|
+
return rest.length === 0 ? real : join(real, ...rest.reverse());
|
|
3963
|
+
} catch {
|
|
3964
|
+
const parent = dirname(current);
|
|
3965
|
+
if (parent === current) return null;
|
|
3966
|
+
rest.push(basename(current));
|
|
3967
|
+
current = parent;
|
|
3968
|
+
}
|
|
3663
3969
|
}
|
|
3664
3970
|
/**
|
|
3665
|
-
*
|
|
3666
|
-
*
|
|
3667
|
-
*
|
|
3668
|
-
* older release also writes: a mailbox name need only be free among mailboxes, a workspace name among both. Version
|
|
3669
|
-
* 2 is stricter and the same for both: the grammar, the platform, free in both maps, and never a former name of
|
|
3670
|
-
* either kind.
|
|
3671
|
-
*
|
|
3672
|
-
* A check, not a reservation. Callers repeat it inside the config write, where the schema enforces the same rules
|
|
3673
|
-
* again for version 2, because anything can happen between asking and writing.
|
|
3971
|
+
* The root of the suite package `path` is in — itself or any ancestor whose `package.json` names a package of this
|
|
3972
|
+
* suite — as written and after resolving links, or null. Every ancestor is looked at, not only the nearest package:
|
|
3973
|
+
* a file in `packages/gmail/node_modules/x` is still inside Gmail's root.
|
|
3674
3974
|
*/
|
|
3675
|
-
function
|
|
3676
|
-
const
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
if (own$1(config.inboxes, name) || own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
|
|
3689
|
-
for (const map of ["inboxes", "accounts"]) {
|
|
3690
|
-
const former = own$1(config.formerNames[map], name);
|
|
3691
|
-
if (former) return refuse(new CommsError("CONFIG", `"${name}" was the name of another account and cannot be used again`, {
|
|
3692
|
-
hint: "A former name keeps pointing people at the account that had it. Choose another name.",
|
|
3693
|
-
details: {
|
|
3694
|
-
formerName: name,
|
|
3695
|
-
id: former.id
|
|
3696
|
-
}
|
|
3697
|
-
}));
|
|
3975
|
+
function suitePackageRootOf(path) {
|
|
3976
|
+
const candidates = [resolve(path)];
|
|
3977
|
+
const real = realpathOfExisting(path);
|
|
3978
|
+
if (real !== null && real !== candidates[0]) candidates.push(real);
|
|
3979
|
+
for (const candidate of candidates) {
|
|
3980
|
+
let dir = candidate;
|
|
3981
|
+
for (;;) {
|
|
3982
|
+
const name = readPackageManifest(dir)?.name;
|
|
3983
|
+
if (typeof name === "string" && name.startsWith("@agentcomms/")) return dir;
|
|
3984
|
+
const parent = dirname(dir);
|
|
3985
|
+
if (parent === dir) break;
|
|
3986
|
+
dir = parent;
|
|
3987
|
+
}
|
|
3698
3988
|
}
|
|
3699
|
-
return
|
|
3989
|
+
return null;
|
|
3700
3990
|
}
|
|
3701
3991
|
/**
|
|
3702
3992
|
* Enough of TOML to read codex's `config.toml`, and a refusal for anything else.
|
|
@@ -4332,10 +4622,126 @@ async function scanRegisteredServers(env = process.env, platform = process.platf
|
|
|
4332
4622
|
};
|
|
4333
4623
|
}
|
|
4334
4624
|
function packageFrom(server) {
|
|
4335
|
-
|
|
4625
|
+
return commandLinePackage(server.command, server.args);
|
|
4626
|
+
}
|
|
4627
|
+
/**
|
|
4628
|
+
* The npm package an entry's command line launches, when one can be read from it: `@scope/name`, or a bare `…-mcp`.
|
|
4629
|
+
* Read from the words left once the suite's path options are taken out (`withoutPathOptions`): a pinned folder such as
|
|
4630
|
+
* `/tmp/@agentcomms/slack` is not a package, and read as one it made a Gmail entry Slack's.
|
|
4631
|
+
*/
|
|
4632
|
+
function commandLinePackage(command, args) {
|
|
4633
|
+
const line = [command, ...withoutPathOptions(args).words].join(" ");
|
|
4336
4634
|
return /(@[\w.-]+\/[\w.-]+|(?<=\s)[\w.-]+-mcp)(?=@|\s|$)/.exec(line)?.[1];
|
|
4337
4635
|
}
|
|
4338
4636
|
/**
|
|
4637
|
+
* A command for a person to run that is not this suite's: `chmod`, or an MCP client's own `claude mcp …` and
|
|
4638
|
+
* `codex mcp …` (design 2026-10-04, D5).
|
|
4639
|
+
*
|
|
4640
|
+
* Every command printed for a person is one of two kinds. One of this suite's own CLIs is a `PrintedCommand`, which
|
|
4641
|
+
* only the locator makes (`cli-command.ts`): it names this Node and a checked file of the product, because a bare
|
|
4642
|
+
* `agent-gmail` is not on the PATH of most people it is printed for (CUE-403). Anything else is an `ExternalCommand`,
|
|
4643
|
+
* made here and nowhere else, with the reason that program is one a person may be told to run.
|
|
4644
|
+
*
|
|
4645
|
+
* So that this is not a way round the locator, `externalCommand` refuses words that would start one of this suite's
|
|
4646
|
+
* products by another route: a word containing one of the suite's commands — in any case, alone, as a path's last
|
|
4647
|
+
* part or inside a wrapper's payload (`sh -c "agent-gmail …"`) — a word naming a package of the suite
|
|
4648
|
+
* (`npx @agentcomms/gmail`), or a path inside the root of a suite package, as written or through a link
|
|
4649
|
+
* (`node …/packages/gmail/dist/cli.mjs`). Every word is looked at, not only the first: the program is often a wrapper.
|
|
4650
|
+
*
|
|
4651
|
+
* This module is a leaf: it reads the manifests' facts and the files a path names, and imports nothing that registers,
|
|
4652
|
+
* scans or locates, so nothing it refuses can be routed back through it.
|
|
4653
|
+
*/
|
|
4654
|
+
/** The gate a constructor call has to pass: only this module holds it. */
|
|
4655
|
+
const GATE$1 = Symbol("externalCommand");
|
|
4656
|
+
/**
|
|
4657
|
+
* One reviewed command of a program that is not this suite's. Rendered as every printed command is — a line to paste,
|
|
4658
|
+
* or on Windows its words as JSON when no line is safe in every shell (`inlineCommand`, `commandText`).
|
|
4659
|
+
*
|
|
4660
|
+
* A class with a private field, so the type is nominal: an object literal with the same fields is not one, and nor is
|
|
4661
|
+
* a spread copy of one with other words in it. Only the type is exported; the class is not.
|
|
4662
|
+
*/
|
|
4663
|
+
var ExternalCommand = class {
|
|
4664
|
+
#reason;
|
|
4665
|
+
/** The words, as the program is to receive them. */
|
|
4666
|
+
words;
|
|
4667
|
+
/** The line to paste; null when one of the words has no printing that every Windows shell passes on alike. */
|
|
4668
|
+
line;
|
|
4669
|
+
platform;
|
|
4670
|
+
constructor(gate, words, platform, reason) {
|
|
4671
|
+
if (gate !== GATE$1) throw new TypeError("an external command is made by externalCommand, and only there");
|
|
4672
|
+
this.#reason = reason;
|
|
4673
|
+
this.words = Object.freeze([...words]);
|
|
4674
|
+
this.line = quoteCommand(this.words, platform).line;
|
|
4675
|
+
this.platform = platform;
|
|
4676
|
+
Object.freeze(this);
|
|
4677
|
+
}
|
|
4678
|
+
/** Why this program, which is not part of this suite, is one a person may be told to run. */
|
|
4679
|
+
get reason() {
|
|
4680
|
+
return this.#reason;
|
|
4681
|
+
}
|
|
4682
|
+
/** As JSON, the text it always was: its line, or its words as JSON with what to do (as a `PrintedCommand`). */
|
|
4683
|
+
toJSON() {
|
|
4684
|
+
return quotedText(this);
|
|
4685
|
+
}
|
|
4686
|
+
/** Never interpolated: rendered with `commandText` or `inlineCommand`. */
|
|
4687
|
+
[Symbol.toPrimitive]() {
|
|
4688
|
+
throw new TypeError("an external command is rendered with commandText or inlineCommand, never interpolated");
|
|
4689
|
+
}
|
|
4690
|
+
};
|
|
4691
|
+
/** A package specifier of the suite, with either separator: `@agentcomms/gmail`, `node_modules\@agentcomms\gmail`. */
|
|
4692
|
+
const SUITE_PACKAGE = /@agentcomms[\\/]/i;
|
|
4693
|
+
/**
|
|
4694
|
+
* The parts of a word a shell or a wrapper could take as a command name: split wherever a character could not be part
|
|
4695
|
+
* of one, so `sh -c "cd x && agent-gmail approve"`, `--run=agent-gmail` and `C:\npm\agent-gmail.cmd` each yield
|
|
4696
|
+
* `agent-gmail`. Compared without case, as Windows compares executable names; on a case-sensitive system that refuses
|
|
4697
|
+
* a little more than it must, and nothing that it should not.
|
|
4698
|
+
*/
|
|
4699
|
+
function commandNames(word) {
|
|
4700
|
+
return word.split(/[^A-Za-z0-9._-]+/).map((part) => part.replace(/^\.+|\.+$/g, "").replace(EXECUTABLE_EXTENSION, "").toLowerCase()).filter(Boolean);
|
|
4701
|
+
}
|
|
4702
|
+
/**
|
|
4703
|
+
* The paths in a word: the word itself, and each part of it between spaces, quotes, `=` and a shell's separators, that
|
|
4704
|
+
* is absolute, `~/…`, or a `file:` URL. A relative path is not resolved: the folder a person pastes the command in is
|
|
4705
|
+
* not known here, and a server called `7/gmail` is a name, not a path.
|
|
4706
|
+
*/
|
|
4707
|
+
function pathsIn(word) {
|
|
4708
|
+
const paths = [];
|
|
4709
|
+
for (const part of [word, ...word.split(/[\s"'`=;&|<>(),]+/)]) {
|
|
4710
|
+
if (part.length === 0) continue;
|
|
4711
|
+
if (/^file:/i.test(part)) try {
|
|
4712
|
+
paths.push(fileURLToPath(part));
|
|
4713
|
+
} catch {}
|
|
4714
|
+
else if (part === "~" || /^~[\\/]/.test(part)) paths.push(join(homedir(), part.slice(2)));
|
|
4715
|
+
else if (isAbsolute(part)) paths.push(part);
|
|
4716
|
+
}
|
|
4717
|
+
return paths;
|
|
4718
|
+
}
|
|
4719
|
+
/** Why these words must not be an external command, or null when they may be. */
|
|
4720
|
+
function refusal$1(words) {
|
|
4721
|
+
for (const [index, word] of words.entries()) {
|
|
4722
|
+
if (SUITE_PACKAGE.test(word)) return `word ${index} names a package of this suite`;
|
|
4723
|
+
for (const path of pathsIn(word)) {
|
|
4724
|
+
const root = suitePackageRootOf(path);
|
|
4725
|
+
if (root !== null) return `word ${index} is a path inside ${root}, a package of this suite`;
|
|
4726
|
+
}
|
|
4727
|
+
if (commandNames(word).some((name) => SUITE_COMMANDS.has(name))) return `word ${index} names a command of this suite`;
|
|
4728
|
+
}
|
|
4729
|
+
return null;
|
|
4730
|
+
}
|
|
4731
|
+
/**
|
|
4732
|
+
* The one way to make an `ExternalCommand`: `words` for a program that is not this suite's, and `reason`, why a person
|
|
4733
|
+
* may be told to run it. Throws a `TypeError` for words that would start a product of this suite (see the module), so
|
|
4734
|
+
* a call site whose words come from somewhere else — a server's name read from a client's file, a folder — says what
|
|
4735
|
+
* to do in prose instead when one is refused. `platform` is the shell it is quoted for.
|
|
4736
|
+
*/
|
|
4737
|
+
function externalCommand(words, reason, platform = process.platform) {
|
|
4738
|
+
if (words.length === 0 || (words[0] ?? "").length === 0) throw new TypeError("an external command needs a program");
|
|
4739
|
+
if (reason.trim().length === 0) throw new TypeError("an external command needs the reason it may be run");
|
|
4740
|
+
const refused = refusal$1(words);
|
|
4741
|
+
if (refused !== null) throw new TypeError(`not an external command: ${refused}; a command of this suite comes from locateCliCommand`);
|
|
4742
|
+
return new ExternalCommand(GATE$1, words, platform, reason);
|
|
4743
|
+
}
|
|
4744
|
+
/**
|
|
4339
4745
|
* The environment a child process is started with: `env` as it is, and on Windows also
|
|
4340
4746
|
* `NoDefaultCurrentDirectoryInExePath=1`, so that the child — `rundll32.exe`, a Node started again — never takes a
|
|
4341
4747
|
* program from its current folder either. Elsewhere nothing is added: a Unix shell looks in the current folder only
|
|
@@ -4434,28 +4840,55 @@ async function resolveNode(env) {
|
|
|
4434
4840
|
return await whichExecutable("node", env) ?? process.execPath;
|
|
4435
4841
|
}
|
|
4436
4842
|
function minimalEnv(context, node) {
|
|
4437
|
-
const env = {
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
"/bin"
|
|
4444
|
-
].join(delimiter)
|
|
4445
|
-
};
|
|
4843
|
+
const env = { PATH: [
|
|
4844
|
+
dirname(node),
|
|
4845
|
+
"/usr/local/bin",
|
|
4846
|
+
"/usr/bin",
|
|
4847
|
+
"/bin"
|
|
4848
|
+
].join(delimiter) };
|
|
4446
4849
|
if (process.platform === "linux") for (const key of ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]) {
|
|
4447
4850
|
const value = context.env[key];
|
|
4448
4851
|
if (value) env[key] = value;
|
|
4449
4852
|
}
|
|
4450
4853
|
return env;
|
|
4451
4854
|
}
|
|
4855
|
+
/** The complete suite identity every installer-written server registration carries. Downloads are operation-local. */
|
|
4856
|
+
function registrationPathArgs(context) {
|
|
4857
|
+
const { configDir, stateDir, dataDir, secretsDir } = context.core.paths;
|
|
4858
|
+
return [
|
|
4859
|
+
"--config-dir",
|
|
4860
|
+
configDir,
|
|
4861
|
+
"--state-dir",
|
|
4862
|
+
stateDir,
|
|
4863
|
+
"--data-dir",
|
|
4864
|
+
dataDir,
|
|
4865
|
+
"--secrets-dir",
|
|
4866
|
+
secretsDir
|
|
4867
|
+
];
|
|
4868
|
+
}
|
|
4452
4869
|
function unscoped(packageName) {
|
|
4453
4870
|
return packageName.split("/").at(-1) ?? packageName;
|
|
4454
4871
|
}
|
|
4455
|
-
function escapeRegExp$
|
|
4872
|
+
function escapeRegExp$2(text) {
|
|
4456
4873
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4457
4874
|
}
|
|
4458
4875
|
/**
|
|
4876
|
+
* An MCP client's own command about one of its entries — `claude mcp get <name>`, `codex mcp remove <name>` — in
|
|
4877
|
+
* backticks for a hint, or, when there is none to give, `instead`.
|
|
4878
|
+
*
|
|
4879
|
+
* The client's program, so an external command (`externalCommand`), and the name is whatever the entry is called. A
|
|
4880
|
+
* name that is also one of this suite's commands — the core server's own default, `agentcomms`, is one — is refused
|
|
4881
|
+
* there, because such a word is how a suite command would get round the locator; the hint then says the same thing in
|
|
4882
|
+
* words rather than printing a command with that name in it.
|
|
4883
|
+
*/
|
|
4884
|
+
function clientCommandHint(words, platform, instead) {
|
|
4885
|
+
try {
|
|
4886
|
+
return inlineCommand(externalCommand(words, "the MCP client's own command reads or removes an entry of its own configuration", platform));
|
|
4887
|
+
} catch {
|
|
4888
|
+
return instead;
|
|
4889
|
+
}
|
|
4890
|
+
}
|
|
4891
|
+
/**
|
|
4459
4892
|
* The directory the managed launcher installs one product's exact version into: `<data>/runtime/<version>-<name>`.
|
|
4460
4893
|
*
|
|
4461
4894
|
* Exported so that everything which reads these paths back — both doctors, `prune`, the tests — builds them here
|
|
@@ -4476,11 +4909,12 @@ function managedRuntimeEntry(dataDir, packageName, version) {
|
|
|
4476
4909
|
*
|
|
4477
4910
|
* Both `runtime/0.4.0/…` (every Gmail install before the move) and `runtime/0.4.0-gmail/…` (every install
|
|
4478
4911
|
* since) are read, and a prerelease keeps its own hyphen: `0.5.0-rc.1-gmail` is `0.5.0-rc.1`. Either separator,
|
|
4479
|
-
* and allowed to start the string, so a Windows or a relative path is not missed.
|
|
4912
|
+
* and allowed to start the string, so a Windows or a relative path is not missed. On Windows (`platform`), whose
|
|
4913
|
+
* paths are not case-sensitive, the folder names are read without case; the version keeps the case it has.
|
|
4480
4914
|
*/
|
|
4481
|
-
function managedRuntimeVersion(path, packageName) {
|
|
4482
|
-
const scope = packageName.split("/").map(escapeRegExp$
|
|
4483
|
-
return new RegExp(`(?:^|[/\\\\])runtime[/\\\\]([^/\\\\]+?)(?:-${escapeRegExp$
|
|
4915
|
+
function managedRuntimeVersion(path, packageName, platform) {
|
|
4916
|
+
const scope = packageName.split("/").map(escapeRegExp$2).join("[/\\\\]");
|
|
4917
|
+
return new RegExp(`(?:^|[/\\\\])runtime[/\\\\]([^/\\\\]+?)(?:-${escapeRegExp$2(unscoped(packageName))})?[/\\\\]node_modules[/\\\\]${scope}[/\\\\]`, platform === "win32" ? "i" : "").exec(path)?.[1] ?? null;
|
|
4484
4918
|
}
|
|
4485
4919
|
/**
|
|
4486
4920
|
* The version one argument of a registered entry pins, or null when it pins none.
|
|
@@ -4488,10 +4922,10 @@ function managedRuntimeVersion(path, packageName) {
|
|
|
4488
4922
|
* Two launchers pin, and they look nothing alike: `managed` writes a runtime path, `npx` a package spec. Reading
|
|
4489
4923
|
* only the first reported an `npx`-pinned install as current for ever. `local` pins nothing and is ignored.
|
|
4490
4924
|
*/
|
|
4491
|
-
function pinnedVersion(argument, product) {
|
|
4492
|
-
const names = [.../* @__PURE__ */ new Set([product.packageName, product.npxPackage])].map(escapeRegExp$
|
|
4493
|
-
const spec = new RegExp(`^(?:${names})@(\\d[^\\s]*)
|
|
4494
|
-
return managedRuntimeVersion(argument, product.packageName) ?? spec.exec(argument)?.[1] ?? null;
|
|
4925
|
+
function pinnedVersion(argument, product, platform) {
|
|
4926
|
+
const names = [.../* @__PURE__ */ new Set([product.packageName, product.npxPackage])].map(escapeRegExp$2).join("|");
|
|
4927
|
+
const spec = new RegExp(`^(?:${names})@(\\d[^\\s]*)$`, platform === "win32" ? "i" : "");
|
|
4928
|
+
return managedRuntimeVersion(argument, product.packageName, platform) ?? spec.exec(argument)?.[1] ?? null;
|
|
4495
4929
|
}
|
|
4496
4930
|
/**
|
|
4497
4931
|
* Whether a registered entry starts this product's server.
|
|
@@ -4501,11 +4935,20 @@ function pinnedVersion(argument, product) {
|
|
|
4501
4935
|
* by, compared as whole runs of segments — `@agentcomms/gmail-evil/dist/cli.mjs`, `@agentcomms/gmail/dist/
|
|
4502
4936
|
* index.mjs` and `packages/gmail/src/nested/cli.ts` are all near misses, and each was once accepted by a looser
|
|
4503
4937
|
* match somewhere in this repository.
|
|
4938
|
+
*
|
|
4939
|
+
* Only the entry's own words are read: the suite's path options and their values are taken out first, because a pinned
|
|
4940
|
+
* folder that ends in `packages/slack/src/cli.ts` is a folder, not Slack (`withoutPathOptions`). On Windows
|
|
4941
|
+
* (`platform`), whose file names are not case-sensitive, the command's name and every path segment are compared
|
|
4942
|
+
* without case, as Windows itself finds them; everywhere else exactly. Left out, it compares exactly.
|
|
4504
4943
|
*/
|
|
4505
|
-
function isProductServer(server, product) {
|
|
4506
|
-
const
|
|
4507
|
-
|
|
4508
|
-
if (
|
|
4944
|
+
function isProductServer(server, product, platform) {
|
|
4945
|
+
const fold = platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
|
|
4946
|
+
const launched = fold((server.command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
|
|
4947
|
+
if (launched && [product.binary, ...product.bins ?? []].map(fold).includes(launched)) return true;
|
|
4948
|
+
if (server.packageName) {
|
|
4949
|
+
const named = fold(server.packageName);
|
|
4950
|
+
return named === fold(product.packageName) || named === fold(product.npxPackage);
|
|
4951
|
+
}
|
|
4509
4952
|
const short = unscoped(product.packageName);
|
|
4510
4953
|
const entries = [
|
|
4511
4954
|
[
|
|
@@ -4528,9 +4971,9 @@ function isProductServer(server, product) {
|
|
|
4528
4971
|
],
|
|
4529
4972
|
...product.entryFiles ?? []
|
|
4530
4973
|
];
|
|
4531
|
-
return [server.command, ...server.args].some((part) => {
|
|
4532
|
-
const segments = part.split(/[\\/]+/).filter(Boolean);
|
|
4533
|
-
return entries.some((entry) => segments.length >= entry.length && entry.every((wanted, index) => segments[segments.length - entry.length + index] === wanted));
|
|
4974
|
+
return [server.command, ...withoutPathOptions(server.args).words].some((part) => {
|
|
4975
|
+
const segments = part.split(/[\\/]+/).filter(Boolean).map(fold);
|
|
4976
|
+
return entries.some((entry) => segments.length >= entry.length && entry.every((wanted, index) => segments[segments.length - entry.length + index] === fold(wanted)));
|
|
4534
4977
|
});
|
|
4535
4978
|
}
|
|
4536
4979
|
/**
|
|
@@ -4626,6 +5069,7 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
4626
5069
|
const launcher = options.launcher ?? "managed";
|
|
4627
5070
|
const { node } = resolved;
|
|
4628
5071
|
const env = minimalEnv(context, node);
|
|
5072
|
+
const pathArgs = registrationPathArgs(context);
|
|
4629
5073
|
const serverArgs = product.serverArgs(options);
|
|
4630
5074
|
if (launcher === "npx") return {
|
|
4631
5075
|
entry: {
|
|
@@ -4633,6 +5077,7 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
4633
5077
|
args: [
|
|
4634
5078
|
"-y",
|
|
4635
5079
|
`${product.npxPackage}@${product.version}`,
|
|
5080
|
+
...pathArgs,
|
|
4636
5081
|
...product.npxArgs ?? [],
|
|
4637
5082
|
...serverArgs
|
|
4638
5083
|
],
|
|
@@ -4649,6 +5094,7 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
4649
5094
|
args: [
|
|
4650
5095
|
...entryPath.endsWith(".ts") ? ["--experimental-strip-types", "--disable-warning=ExperimentalWarning"] : [],
|
|
4651
5096
|
entryPath,
|
|
5097
|
+
...pathArgs,
|
|
4652
5098
|
"mcp",
|
|
4653
5099
|
...serverArgs
|
|
4654
5100
|
],
|
|
@@ -4666,6 +5112,7 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
4666
5112
|
command: node,
|
|
4667
5113
|
args: [
|
|
4668
5114
|
ready ?? managedRuntimeEntry(dataDir, product.packageName, product.version),
|
|
5115
|
+
...pathArgs,
|
|
4669
5116
|
"mcp",
|
|
4670
5117
|
...serverArgs
|
|
4671
5118
|
],
|
|
@@ -4680,6 +5127,7 @@ async function buildEntry(context, product, options, apply, resolved) {
|
|
|
4680
5127
|
command: node,
|
|
4681
5128
|
args: [
|
|
4682
5129
|
await installManagedRuntime(context, product, product.version),
|
|
5130
|
+
...pathArgs,
|
|
4683
5131
|
"mcp",
|
|
4684
5132
|
...serverArgs
|
|
4685
5133
|
],
|
|
@@ -4764,13 +5212,17 @@ function snippetFor(client, name, entry) {
|
|
|
4764
5212
|
* Project-scoped entries are not looked at: every write here targets user scope, so they are not what would be
|
|
4765
5213
|
* replaced.
|
|
4766
5214
|
*/
|
|
4767
|
-
function claimName(product, options, name, existing,
|
|
5215
|
+
function claimName(product, options, name, existing, _platform) {
|
|
4768
5216
|
const taken = existing.filter((server) => server.client === options.client && server.name === name && server.scope !== "project");
|
|
4769
5217
|
const foreign = taken.find((server) => !isProductServer(server, product));
|
|
4770
5218
|
if (foreign) {
|
|
4771
5219
|
const what = foreign.packageName ?? (foreign.url ? displayUrl(foreign.url) : void 0) ?? (foreign.command || "something else");
|
|
4772
5220
|
const other = name === product.binary ? `${product.binary}-${product.defaultServerName}` : product.binary;
|
|
4773
|
-
throw
|
|
5221
|
+
throw refusedWithRemedy("CONFIG", `${options.client} already has an MCP server called "${name}", and it is not this one (it runs ${what})`, {
|
|
5222
|
+
binary: product.binary,
|
|
5223
|
+
words: installWords(product, options, other, []),
|
|
5224
|
+
say: (command) => `Register this one under another name: ${command}. --force does not replace a server this did not install.`
|
|
5225
|
+
});
|
|
4774
5226
|
}
|
|
4775
5227
|
if (taken.length > 0 && !options.force) {
|
|
4776
5228
|
for (const pin of PIN_OPTIONS) {
|
|
@@ -4778,14 +5230,22 @@ function claimName(product, options, name, existing, platform) {
|
|
|
4778
5230
|
const served = taken.map((server) => product.narrowingOf(server.args)[pin]).find(Boolean);
|
|
4779
5231
|
if (!wanted || !served || served === wanted) continue;
|
|
4780
5232
|
const second = `${product.defaultServerName}-${wanted.split("/")[0]}`;
|
|
4781
|
-
throw
|
|
5233
|
+
throw refusedWithRemedy("CONFIG", `${options.client} already has this server registered as "${name}"`, {
|
|
5234
|
+
binary: product.binary,
|
|
5235
|
+
words: installWords(product, options, second === name ? `${name}-2` : second, []),
|
|
5236
|
+
say: (command) => `That entry serves ${served}; to serve ${wanted} as well, register a second entry under its own name: ${command}.`
|
|
5237
|
+
});
|
|
4782
5238
|
}
|
|
4783
5239
|
const npx = taken.some((server) => server.args.some((argument) => argument.startsWith(`${product.npxPackage}@`)));
|
|
4784
5240
|
const again = {
|
|
4785
5241
|
...keepNarrowing(product, options, taken).options,
|
|
4786
5242
|
launcher: options.launcher ?? (npx ? "npx" : void 0)
|
|
4787
5243
|
};
|
|
4788
|
-
throw
|
|
5244
|
+
throw refusedWithRemedy("CONFIG", `${options.client} already has this server registered as "${name}"`, {
|
|
5245
|
+
binary: product.binary,
|
|
5246
|
+
words: installWords(product, again, name, ["--force"]),
|
|
5247
|
+
say: (command) => `Pass --force to replace it — that is how an upgrade reaches a client: ${command}.`
|
|
5248
|
+
});
|
|
4789
5249
|
}
|
|
4790
5250
|
return taken;
|
|
4791
5251
|
}
|
|
@@ -4821,20 +5281,17 @@ function keepNarrowing(product, options, previous) {
|
|
|
4821
5281
|
};
|
|
4822
5282
|
}
|
|
4823
5283
|
/**
|
|
4824
|
-
* The `mcp install` that repeats this one under `name`, with every flag that decides what the server may reach
|
|
5284
|
+
* The `mcp install` that repeats this one under `name`, with every flag that decides what the server may reach: its
|
|
5285
|
+
* words after the program.
|
|
4825
5286
|
*
|
|
4826
5287
|
* The hints used to be `mcp install --client <c> --force` whatever had been asked. Refused for `--name slack-acme
|
|
4827
5288
|
* --workspace acme/slack`, the hint registered a second server under the default name, pinned to nothing — every
|
|
4828
5289
|
* workspace on the machine; for Gmail it dropped `--inbox` and `--read-only` the same way. Doctor's repair already
|
|
4829
5290
|
* rebuilds these flags for exactly that reason. The product's `serverArgs` are the install command's own flags for
|
|
4830
5291
|
* the pin and the narrowing, so they are repeated as they are.
|
|
4831
|
-
*
|
|
4832
|
-
* A pin kept from the entry being replaced was read from the client's file, which may hold anything, so on Windows the
|
|
4833
|
-
* command can come back with no line to paste; every hint gives it with `inlineCommand`, which shows it as words then.
|
|
4834
5292
|
*/
|
|
4835
|
-
function
|
|
5293
|
+
function installWords(product, options, name, extra) {
|
|
4836
5294
|
const words = [
|
|
4837
|
-
product.binary,
|
|
4838
5295
|
"mcp",
|
|
4839
5296
|
"install",
|
|
4840
5297
|
"--client",
|
|
@@ -4844,7 +5301,24 @@ function installCommand$1(product, options, name, extra, platform) {
|
|
|
4844
5301
|
words.push(...product.serverArgs(options));
|
|
4845
5302
|
if (options.launcher && options.launcher !== "managed") words.push("--launcher", options.launcher);
|
|
4846
5303
|
words.push(...extra);
|
|
4847
|
-
return
|
|
5304
|
+
return words;
|
|
5305
|
+
}
|
|
5306
|
+
const REMEDIES = /* @__PURE__ */ new WeakMap();
|
|
5307
|
+
/** The remedy a refusal of `mcp install` carries, for the operation that called it to locate; undefined for any other. */
|
|
5308
|
+
function installRemedyOf(error) {
|
|
5309
|
+
return typeof error === "object" && error !== null ? REMEDIES.get(error) : void 0;
|
|
5310
|
+
}
|
|
5311
|
+
/**
|
|
5312
|
+
* A refusal whose remedy is another install: until the caller locates it, its hint says the words in prose — never a
|
|
5313
|
+
* command with a bare program.
|
|
5314
|
+
*/
|
|
5315
|
+
function refusedWithRemedy(code, message, remedy, options = {}) {
|
|
5316
|
+
const error = new CommsError(code, message, {
|
|
5317
|
+
hint: remedy.say(`its own \`mcp install\` with ${remedy.words.slice(2).join(" ")}`),
|
|
5318
|
+
...options.cause === void 0 ? {} : { cause: options.cause }
|
|
5319
|
+
});
|
|
5320
|
+
REMEDIES.set(error, remedy);
|
|
5321
|
+
return error;
|
|
4848
5322
|
}
|
|
4849
5323
|
/**
|
|
4850
5324
|
* A command's exit status and what it printed, kept here and never shown: codex prints an entry's env. Started as
|
|
@@ -4887,16 +5361,16 @@ function capture$1(env, command, args) {
|
|
|
4887
5361
|
* or a failure that is not "no such server", stops the install: not knowing is not the same as nothing there.
|
|
4888
5362
|
* Nothing codex prints is repeated, because its answer carries the entry's env.
|
|
4889
5363
|
*/
|
|
4890
|
-
async function codexRegistration(env,
|
|
4891
|
-
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${
|
|
5364
|
+
async function codexRegistration(env, cliPath, name, path, platform) {
|
|
5365
|
+
const unknown = () => new CommsError("CONFIG", `codex would not say what it has registered as "${name}", so nothing was written`, { hint: `Look with ${clientCommandHint([
|
|
4892
5366
|
"codex",
|
|
4893
5367
|
"mcp",
|
|
4894
5368
|
"get",
|
|
4895
5369
|
name
|
|
4896
|
-
], platform)
|
|
5370
|
+
], platform, `codex's own \`mcp get\`, for the entry called "${name}"`)}. If it is not an older copy of this server, choose another --name; \`--print\` shows the entry to add by hand.` });
|
|
4897
5371
|
let answer;
|
|
4898
5372
|
try {
|
|
4899
|
-
answer = await capture$1(env,
|
|
5373
|
+
answer = await capture$1(env, cliPath, [
|
|
4900
5374
|
"mcp",
|
|
4901
5375
|
"get",
|
|
4902
5376
|
name,
|
|
@@ -4951,15 +5425,15 @@ async function backUp(context, client, name, previous, now) {
|
|
|
4951
5425
|
async function installTarget(context, options) {
|
|
4952
5426
|
const apply = options.apply ?? true;
|
|
4953
5427
|
const cliName = options.client === "claude-code" ? "claude" : options.client === "codex" ? "codex" : null;
|
|
4954
|
-
const
|
|
5428
|
+
const cliPath = cliName ? await findClientCli(cliName, context.env) : null;
|
|
4955
5429
|
const own = knownClientConfigs(context.env).find((file) => file.client === options.client)?.path;
|
|
4956
5430
|
const configPath = options.client === "claude-code" ? void 0 : own;
|
|
4957
5431
|
return {
|
|
4958
5432
|
cliName,
|
|
4959
|
-
|
|
5433
|
+
cliPath,
|
|
4960
5434
|
own,
|
|
4961
5435
|
configPath,
|
|
4962
|
-
writes: apply && (cliName ?
|
|
5436
|
+
writes: apply && (cliName ? cliPath !== null : configPath !== void 0)
|
|
4963
5437
|
};
|
|
4964
5438
|
}
|
|
4965
5439
|
/**
|
|
@@ -4991,14 +5465,14 @@ async function preflightInstall(context, product, options) {
|
|
|
4991
5465
|
const scan = await scanRegisteredServers(context.env);
|
|
4992
5466
|
const existing = scan.servers;
|
|
4993
5467
|
const target = await installTarget(context, options);
|
|
4994
|
-
const {
|
|
5468
|
+
const { cliPath, own, configPath, writes } = target;
|
|
4995
5469
|
if (writes) {
|
|
4996
5470
|
const blind = scan.unreadable.find((file) => file.path === own);
|
|
4997
5471
|
if (blind) throw new CommsError("CONFIG", `${blind.path} could not be read (${blind.reason}), so what is already registered there cannot be checked; nothing was written`, { hint: "Fix the file, or add the entry by hand: `--print` shows it without writing anything." });
|
|
4998
5472
|
}
|
|
4999
5473
|
let previous = writes ? claimName(product, options, name, existing, context.platform) : [];
|
|
5000
|
-
if (writes &&
|
|
5001
|
-
const reported = await codexRegistration(context.env,
|
|
5474
|
+
if (writes && cliPath && options.client === "codex") {
|
|
5475
|
+
const reported = await codexRegistration(context.env, cliPath, name, configPath ?? "codex", context.platform);
|
|
5002
5476
|
if (reported) previous = claimName(product, options, name, [reported], context.platform);
|
|
5003
5477
|
}
|
|
5004
5478
|
const { options: effective, kept } = keepNarrowing(product, options, previous);
|
|
@@ -5043,7 +5517,7 @@ function plannedInstall(preflight) {
|
|
|
5043
5517
|
return {
|
|
5044
5518
|
writes: target.writes,
|
|
5045
5519
|
cliName: target.cliName,
|
|
5046
|
-
|
|
5520
|
+
cliPath: target.cliPath,
|
|
5047
5521
|
configPath: target.configPath ?? null,
|
|
5048
5522
|
own: target.own ?? null,
|
|
5049
5523
|
replaces: previous.map((server) => JSON.stringify([
|
|
@@ -5077,7 +5551,7 @@ function planDrift(product, client, name, planned, now) {
|
|
|
5077
5551
|
if (planned.writes !== now.writes) {
|
|
5078
5552
|
if (cli !== null) drift.push(now.writes ? `${cli} was not on PATH when this was planned, and is now, so it would register the server rather than print its entry` : `${cli} was on PATH when this was planned, and is not now, so it would only print the entry`);
|
|
5079
5553
|
else drift.push(now.writes ? "it would write the entry rather than print it" : "it would only print the entry");
|
|
5080
|
-
} else if (planned.
|
|
5554
|
+
} else if (planned.cliPath !== now.cliPath) drift.push(`it would register through another ${cli ?? "client CLI"} than the one planned: ${now.cliPath}`);
|
|
5081
5555
|
if (planned.configPath !== now.configPath || planned.own !== now.own) drift.push(`${client}'s config is now ${now.own ?? now.configPath ?? "nowhere"}, not ${planned.own ?? planned.configPath ?? "nowhere"}`);
|
|
5082
5556
|
if (JSON.stringify(planned.replaces) !== JSON.stringify(now.replaces)) drift.push(`what is registered as "${name}" is not what it was planned to replace`);
|
|
5083
5557
|
if (JSON.stringify(planned.narrowing) !== JSON.stringify(now.narrowing)) {
|
|
@@ -5123,22 +5597,23 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
5123
5597
|
...pinsOf(planned.narrowing)
|
|
5124
5598
|
};
|
|
5125
5599
|
const existing = scan.servers;
|
|
5126
|
-
const { cliName,
|
|
5600
|
+
const { cliName, cliPath, own, configPath, writes } = target;
|
|
5127
5601
|
const warnings = (product.warnAbout?.(existing.filter((server) => server.client === options.client), context.platform) ?? []).filter(Boolean);
|
|
5128
5602
|
if (kept.length > 0) {
|
|
5129
|
-
const
|
|
5603
|
+
const byHand = `delete "${name}" from ${configPath}`;
|
|
5604
|
+
const removal = options.client === "claude-code" ? clientCommandHint([
|
|
5130
5605
|
"claude",
|
|
5131
5606
|
"mcp",
|
|
5132
5607
|
"remove",
|
|
5133
5608
|
name,
|
|
5134
5609
|
"--scope",
|
|
5135
5610
|
"user"
|
|
5136
|
-
], context.platform)
|
|
5611
|
+
], context.platform, byHand) : options.client === "codex" ? clientCommandHint([
|
|
5137
5612
|
"codex",
|
|
5138
5613
|
"mcp",
|
|
5139
5614
|
"remove",
|
|
5140
5615
|
name
|
|
5141
|
-
], context.platform)
|
|
5616
|
+
], context.platform, byHand) : byHand;
|
|
5142
5617
|
warnings.push(`Kept ${kept.join(" ")} from the "${name}" entry this replaced, because this install did not say otherwise. To register it wider on purpose, remove that entry first (${removal}), then install without them.`);
|
|
5143
5618
|
}
|
|
5144
5619
|
if (!writes) {
|
|
@@ -5166,7 +5641,7 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
5166
5641
|
let method = "printed";
|
|
5167
5642
|
let applied = false;
|
|
5168
5643
|
let notApplied;
|
|
5169
|
-
if (cliName &&
|
|
5644
|
+
if (cliName && cliPath && writes) {
|
|
5170
5645
|
const codexEnv = (values) => Object.entries(values ?? {}).flatMap(([key, value]) => ["--env", `${key}=${value}`]);
|
|
5171
5646
|
const args = options.client === "claude-code" ? [
|
|
5172
5647
|
"mcp",
|
|
@@ -5198,13 +5673,13 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
5198
5673
|
name
|
|
5199
5674
|
];
|
|
5200
5675
|
try {
|
|
5201
|
-
await run(context.env,
|
|
5676
|
+
await run(context.env, cliPath, removal);
|
|
5202
5677
|
} catch (error) {
|
|
5203
5678
|
const message = error instanceof CommsError ? error.message : String(error);
|
|
5204
5679
|
if (!/no (mcp )?server|not found|does not exist/i.test(message)) throw error;
|
|
5205
5680
|
}
|
|
5206
5681
|
try {
|
|
5207
|
-
await run(context.env,
|
|
5682
|
+
await run(context.env, cliPath, args);
|
|
5208
5683
|
} catch (error) {
|
|
5209
5684
|
const restore = options.client === "claude-code" ? [
|
|
5210
5685
|
"mcp",
|
|
@@ -5228,31 +5703,36 @@ async function mcpInstall(context, product, options, planned) {
|
|
|
5228
5703
|
];
|
|
5229
5704
|
let restored = true;
|
|
5230
5705
|
try {
|
|
5231
|
-
await run(context.env,
|
|
5706
|
+
await run(context.env, cliPath, restore);
|
|
5232
5707
|
} catch {
|
|
5233
5708
|
restored = false;
|
|
5234
5709
|
}
|
|
5235
|
-
throw new CommsError("CONFIG",
|
|
5236
|
-
hint:
|
|
5710
|
+
if (restored) throw new CommsError("CONFIG", `could not register "${name}"; the previous entry was put back`, {
|
|
5711
|
+
hint: "Check the client is not running, then try again.",
|
|
5237
5712
|
cause: error
|
|
5238
5713
|
});
|
|
5714
|
+
throw refusedWithRemedy("CONFIG", `could not register "${name}", and the previous entry could not be put back either — ${cliName} now has no server called "${name}"`, {
|
|
5715
|
+
binary: product.binary,
|
|
5716
|
+
words: installWords(product, effective, name, []),
|
|
5717
|
+
say: (command) => `Re-register it with ${command}. The old entry is in ${backupPath}.`
|
|
5718
|
+
}, { cause: error });
|
|
5239
5719
|
}
|
|
5240
5720
|
} else try {
|
|
5241
|
-
await run(context.env,
|
|
5721
|
+
await run(context.env, cliPath, args);
|
|
5242
5722
|
} catch (error) {
|
|
5243
5723
|
const message = error instanceof CommsError ? error.message : String(error);
|
|
5244
5724
|
if (/already exists/i.test(message)) throw new CommsError("CONFIG", `${cliName} already has an MCP server called "${name}", somewhere this could not read`, {
|
|
5245
|
-
hint: `Look at it with ${
|
|
5725
|
+
hint: `Look at it with ${clientCommandHint([
|
|
5246
5726
|
cliName,
|
|
5247
5727
|
"mcp",
|
|
5248
5728
|
"get",
|
|
5249
5729
|
name
|
|
5250
|
-
], context.platform)
|
|
5730
|
+
], context.platform, `${cliName}'s own \`mcp get\`, for the entry called "${name}"`)}. If it is an older copy of this server, remove it with ${clientCommandHint([
|
|
5251
5731
|
cliName,
|
|
5252
5732
|
"mcp",
|
|
5253
5733
|
"remove",
|
|
5254
5734
|
name
|
|
5255
|
-
], context.platform)
|
|
5735
|
+
], context.platform, `${cliName}'s own \`mcp remove\``)} and run this again; if not, choose another --name.`,
|
|
5256
5736
|
cause: error
|
|
5257
5737
|
});
|
|
5258
5738
|
throw error;
|
|
@@ -5527,46 +6007,964 @@ async function pruneManagedRuntimes(context, product, options = {}) {
|
|
|
5527
6007
|
});
|
|
5528
6008
|
continue;
|
|
5529
6009
|
}
|
|
5530
|
-
if (!result.dryRun) await rm(path, { recursive: true });
|
|
5531
|
-
result.removed.push({
|
|
5532
|
-
path,
|
|
5533
|
-
version
|
|
5534
|
-
});
|
|
5535
|
-
}
|
|
5536
|
-
if (options.includePrinted && !result.dryRun && result.removed.length > 0) {
|
|
5537
|
-
const gone = result.removed.map((item) => item.path);
|
|
5538
|
-
const left = handedOut.filter((record) => !gone.some((path) => mentions([path], record.runtime)));
|
|
5539
|
-
if (left.length !== handedOut.length) await writeFileAtomic(ledger, left.map((record) => `${JSON.stringify(record)}\n`).join(""));
|
|
5540
|
-
}
|
|
5541
|
-
return result;
|
|
6010
|
+
if (!result.dryRun) await rm(path, { recursive: true });
|
|
6011
|
+
result.removed.push({
|
|
6012
|
+
path,
|
|
6013
|
+
version
|
|
6014
|
+
});
|
|
6015
|
+
}
|
|
6016
|
+
if (options.includePrinted && !result.dryRun && result.removed.length > 0) {
|
|
6017
|
+
const gone = result.removed.map((item) => item.path);
|
|
6018
|
+
const left = handedOut.filter((record) => !gone.some((path) => mentions([path], record.runtime)));
|
|
6019
|
+
if (left.length !== handedOut.length) await writeFileAtomic(ledger, left.map((record) => `${JSON.stringify(record)}\n`).join(""));
|
|
6020
|
+
}
|
|
6021
|
+
return result;
|
|
6022
|
+
}
|
|
6023
|
+
/** Every runtime the installer handed out, or null when the record is there and cannot be read. */
|
|
6024
|
+
async function readHandedOut(path) {
|
|
6025
|
+
let text;
|
|
6026
|
+
try {
|
|
6027
|
+
text = await readFile(path, "utf8");
|
|
6028
|
+
} catch (error) {
|
|
6029
|
+
return error.code === "ENOENT" ? [] : null;
|
|
6030
|
+
}
|
|
6031
|
+
const records = [];
|
|
6032
|
+
for (const line of text.split("\n")) {
|
|
6033
|
+
if (!line.trim()) continue;
|
|
6034
|
+
try {
|
|
6035
|
+
const record = JSON.parse(line);
|
|
6036
|
+
if (typeof record.runtime !== "string") return null;
|
|
6037
|
+
records.push({
|
|
6038
|
+
at: String(record.at ?? ""),
|
|
6039
|
+
client: String(record.client ?? ""),
|
|
6040
|
+
name: String(record.name ?? ""),
|
|
6041
|
+
runtime: record.runtime,
|
|
6042
|
+
...typeof record.config === "string" ? { config: record.config } : {},
|
|
6043
|
+
...record.applied === true ? { applied: true } : {}
|
|
6044
|
+
});
|
|
6045
|
+
} catch {
|
|
6046
|
+
return null;
|
|
6047
|
+
}
|
|
6048
|
+
}
|
|
6049
|
+
return records;
|
|
6050
|
+
}
|
|
6051
|
+
/** A channel's registration facts, from its manifest. */
|
|
6052
|
+
function registrationFactsOf({ packageName, manifest }) {
|
|
6053
|
+
return {
|
|
6054
|
+
packageName,
|
|
6055
|
+
npxPackage: manifest.server.npxPackage,
|
|
6056
|
+
binary: manifest.binary,
|
|
6057
|
+
...manifest.server.bins === void 0 ? {} : { bins: manifest.server.bins },
|
|
6058
|
+
...manifest.server.entryFiles === void 0 ? {} : { entryFiles: manifest.server.entryFiles }
|
|
6059
|
+
};
|
|
6060
|
+
}
|
|
6061
|
+
/** How a registered entry starts its server: the launcher `mcp install` wrote it with, or `other` for one it did not. */
|
|
6062
|
+
function launcherOf(server, facts, platform) {
|
|
6063
|
+
const fold = folding(platform);
|
|
6064
|
+
const parts = [server.command, ...server.args];
|
|
6065
|
+
if (parts.some((part) => managedRuntimeVersion(part, facts.packageName, platform) !== null)) return "managed";
|
|
6066
|
+
if (server.args.some((arg) => fold(arg).startsWith(fold(`${facts.npxPackage}@`)))) return "npx";
|
|
6067
|
+
const local = new RegExp("[/\\\\]packages[/\\\\][^/\\\\]+[/\\\\](?:src[/\\\\]cli\\.ts|dist[/\\\\]cli\\.mjs)$", platform === "win32" ? "i" : "");
|
|
6068
|
+
if (parts.some((part) => local.test(part))) return "local";
|
|
6069
|
+
return "other";
|
|
6070
|
+
}
|
|
6071
|
+
/** The version an entry pins — a managed runtime's or an npx spec's — or null for one that pins none. */
|
|
6072
|
+
function registrationVersion(server, facts, platform) {
|
|
6073
|
+
return server.args.map((arg) => pinnedVersion(arg, facts, platform)).find((found) => found !== null) ?? null;
|
|
6074
|
+
}
|
|
6075
|
+
PATH_OPTIONS.filter(({ key }) => key !== "downloadsDir");
|
|
6076
|
+
/**
|
|
6077
|
+
* Whether an entry starts this product, judged on its own words: the suite's path options and their values are taken
|
|
6078
|
+
* out, and the package its command line launches is read again from what is left rather than taken from the entry.
|
|
6079
|
+
* A folder pinned as `/tmp/@agentcomms/slack` never makes a Gmail registration Slack's.
|
|
6080
|
+
*/
|
|
6081
|
+
function startsProduct(server, facts, platform) {
|
|
6082
|
+
const { words: args } = withoutPathOptions(server.args);
|
|
6083
|
+
const packageName = commandLinePackage(server.command, args);
|
|
6084
|
+
return isProductServer({
|
|
6085
|
+
command: server.command,
|
|
6086
|
+
args,
|
|
6087
|
+
...packageName === void 0 ? {} : { packageName }
|
|
6088
|
+
}, facts, platform);
|
|
6089
|
+
}
|
|
6090
|
+
/**
|
|
6091
|
+
* A command's name as Windows finds it — its last path segment without `.cmd`, `.exe`, `.bat` or `.ps1` — without case
|
|
6092
|
+
* on Windows (`platform`), and exactly elsewhere.
|
|
6093
|
+
*/
|
|
6094
|
+
function commandStem(command, platform) {
|
|
6095
|
+
return folding(platform)((command.split(/[\\/]+/).at(-1) ?? "").replace(/\.(?:cmd|exe|bat|ps1)$/i, ""));
|
|
6096
|
+
}
|
|
6097
|
+
/** Whether npx runs the entry: its program is npx, or it names the product's package at a version, as npx does. */
|
|
6098
|
+
function isNpxRegistration(server, facts, platform) {
|
|
6099
|
+
return commandStem(server.command, platform) === "npx" || launcherOf(server, facts, platform) === "npx";
|
|
6100
|
+
}
|
|
6101
|
+
/**
|
|
6102
|
+
* The files an entry names as what it starts, as it writes them: every script among its words before `--` once the
|
|
6103
|
+
* path options are out (an interpreter's flags may name one too, so each is a candidate, not the answer), and its
|
|
6104
|
+
* program when that is one of the product's own commands — a link into the package, or a script npm wrote beside it.
|
|
6105
|
+
*/
|
|
6106
|
+
function registrationFiles(server, facts, platform) {
|
|
6107
|
+
const { words } = withoutPathOptions(server.args);
|
|
6108
|
+
const sentinel = words.indexOf("--");
|
|
6109
|
+
const options = sentinel < 0 ? words : words.slice(0, sentinel);
|
|
6110
|
+
const fold = folding(platform);
|
|
6111
|
+
return {
|
|
6112
|
+
scripts: options.flatMap((word) => [word, ...word.startsWith("--") && word.includes("=") ? [word.slice(word.indexOf("=") + 1)] : []]).filter((word) => !word.startsWith("-") && /\.(?:[cm]?js|[cm]?ts)$/i.test(word)),
|
|
6113
|
+
command: [facts.binary, ...facts.bins ?? []].map(fold).includes(commandStem(server.command, platform)) ? server.command : null
|
|
6114
|
+
};
|
|
6115
|
+
}
|
|
6116
|
+
function folding(platform) {
|
|
6117
|
+
return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
|
|
6118
|
+
}
|
|
6119
|
+
/** The package version, read from package.json at build time. */
|
|
6120
|
+
const VERSION = "0.13.1";
|
|
6121
|
+
/**
|
|
6122
|
+
* Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
|
|
6123
|
+
* to decide whether the release it last heard of is newer than the one running.
|
|
6124
|
+
*
|
|
6125
|
+
* Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
|
|
6126
|
+
* every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
|
|
6127
|
+
* all of it, so nothing that imported these from there changes.
|
|
6128
|
+
*/
|
|
6129
|
+
/**
|
|
6130
|
+
* A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
|
|
6131
|
+
*
|
|
6132
|
+
* Anything the registry answers is checked against this before it is used, because a version goes into a directory
|
|
6133
|
+
* name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
|
|
6134
|
+
* mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
|
|
6135
|
+
*/
|
|
6136
|
+
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
6137
|
+
function isVersion(value) {
|
|
6138
|
+
return typeof value === "string" && VERSION_PATTERN.test(value);
|
|
6139
|
+
}
|
|
6140
|
+
/** Two numeric strings with no leading zeros, compared as numbers of any size. */
|
|
6141
|
+
function compareNumeric(a, b) {
|
|
6142
|
+
if (a.length !== b.length) return a.length < b.length ? -1 : 1;
|
|
6143
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
6144
|
+
}
|
|
6145
|
+
/**
|
|
6146
|
+
* Semver precedence: -1, 0 or 1, or null when either is not a version.
|
|
6147
|
+
*
|
|
6148
|
+
* Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
|
|
6149
|
+
* counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
|
|
6150
|
+
* few packages as it can.
|
|
6151
|
+
*/
|
|
6152
|
+
function compareVersions(a, b) {
|
|
6153
|
+
const left = VERSION_PATTERN.exec(a);
|
|
6154
|
+
const right = VERSION_PATTERN.exec(b);
|
|
6155
|
+
if (!left || !right) return null;
|
|
6156
|
+
for (const index of [
|
|
6157
|
+
1,
|
|
6158
|
+
2,
|
|
6159
|
+
3
|
|
6160
|
+
]) {
|
|
6161
|
+
const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
|
|
6162
|
+
if (order !== 0) return order < 0 ? -1 : 1;
|
|
6163
|
+
}
|
|
6164
|
+
const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
|
|
6165
|
+
const [ours, theirs] = [pre(left), pre(right)];
|
|
6166
|
+
if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
|
|
6167
|
+
for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
|
|
6168
|
+
const x = ours[index];
|
|
6169
|
+
const y = theirs[index];
|
|
6170
|
+
if (x === void 0) return -1;
|
|
6171
|
+
if (y === void 0) return 1;
|
|
6172
|
+
const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
|
|
6173
|
+
if (numericX && numericY) {
|
|
6174
|
+
const order = compareNumeric(x, y);
|
|
6175
|
+
if (order !== 0) return order < 0 ? -1 : 1;
|
|
6176
|
+
} else if (numericX !== numericY) return numericX ? -1 : 1;
|
|
6177
|
+
else if (x !== y) return x < y ? -1 : 1;
|
|
6178
|
+
}
|
|
6179
|
+
return 0;
|
|
6180
|
+
}
|
|
6181
|
+
/** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
|
|
6182
|
+
function isBehind(version, latest) {
|
|
6183
|
+
if (version === latest) return false;
|
|
6184
|
+
const order = compareVersions(version, latest);
|
|
6185
|
+
return order === null ? true : order < 0;
|
|
6186
|
+
}
|
|
6187
|
+
/**
|
|
6188
|
+
* Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
|
|
6189
|
+
* machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
|
|
6190
|
+
*/
|
|
6191
|
+
function isPrerelease(version) {
|
|
6192
|
+
return VERSION_PATTERN.exec(version)?.[4] !== void 0;
|
|
6193
|
+
}
|
|
6194
|
+
/**
|
|
6195
|
+
* Whether `version` is within a package's `engines` range: true or false, or null when the range is not one this reads.
|
|
6196
|
+
*
|
|
6197
|
+
* Read are the comparators `>=`, `>`, `<=`, `<` and `=` (or none) before a whole version, joined by spaces, which must
|
|
6198
|
+
* all hold, and alternatives joined by `||`. That is every range this suite declares (`>=22.12.0`, `>=22.16.0`).
|
|
6199
|
+
* Anything else — `^22.12.0`, `>=22`, `22.x` — is null rather than a guess: a command printed to run under this Node
|
|
6200
|
+
* is printed only when the range it has to meet says it may. A leading `v`, as `process.version` has it, is read.
|
|
6201
|
+
*/
|
|
6202
|
+
function satisfiesRange(version, range) {
|
|
6203
|
+
const wanted = version.replace(/^v/, "");
|
|
6204
|
+
if (!isVersion(wanted)) return null;
|
|
6205
|
+
const alternatives = range.split("||").map((alternative) => alternative.trim().split(/\s+/).filter(Boolean));
|
|
6206
|
+
if (alternatives.some((comparators) => comparators.length === 0)) return null;
|
|
6207
|
+
let any = false;
|
|
6208
|
+
for (const comparators of alternatives) {
|
|
6209
|
+
let all = true;
|
|
6210
|
+
for (const comparator of comparators) {
|
|
6211
|
+
const match = /^(>=|<=|>|<|=)?(.+)$/.exec(comparator);
|
|
6212
|
+
const bound = match?.[2] ?? "";
|
|
6213
|
+
if (!isVersion(bound)) return null;
|
|
6214
|
+
const order = compareVersions(wanted, bound);
|
|
6215
|
+
if (!(match?.[1] === ">=" ? order >= 0 : match?.[1] === "<=" ? order <= 0 : match?.[1] === ">" ? order > 0 : match?.[1] === "<" ? order < 0 : order === 0)) all = false;
|
|
6216
|
+
}
|
|
6217
|
+
if (all) any = true;
|
|
6218
|
+
}
|
|
6219
|
+
return any;
|
|
6220
|
+
}
|
|
6221
|
+
/**
|
|
6222
|
+
* The command a person is told to run for one of this suite's own CLIs (design 2026-10-04, D1 and D5; CUE-403).
|
|
6223
|
+
*
|
|
6224
|
+
* A bare `agent-gmail approve …` is not runnable for most of the people it is printed for: a package run by npx, by a
|
|
6225
|
+
* managed runtime or from a checkout puts nothing on their PATH. So every such command names this process's Node and
|
|
6226
|
+
* a checked file of the product it runs, with the suite directories it uses pinned as options — and when no file can
|
|
6227
|
+
* be checked, there is no command at all, only a sentence saying what is missing. The caller says which package it is
|
|
6228
|
+
* and what to run; it never supplies the program or quotes the line.
|
|
6229
|
+
*
|
|
6230
|
+
* Three directions. A product's own CLI is found from the product itself, walking from one of its own modules to its
|
|
6231
|
+
* manifest: no registration is ever asked. A channel's handoff to core is found the way the channel itself finds core,
|
|
6232
|
+
* through its installed dependency, and only the same release. Another product's command is found among the servers
|
|
6233
|
+
* registered with the person's MCP clients, which the caller scanned and passes in: only a file-backed registration of
|
|
6234
|
+
* exactly the printing package's version, checked on disk. Nothing a registration names is run, or asked anything —
|
|
6235
|
+
* not even its interpreter for a version: no installer wrote down where a scanned entry came from, so none is trusted
|
|
6236
|
+
* to run (D1). This module never scans, spawns or probes.
|
|
6237
|
+
*
|
|
6238
|
+
* What is checked is checked when the command is printed, not for ever (D6): the file it names is real, readable and
|
|
6239
|
+
* inside its package then, and a later change to that installation is a later fact.
|
|
6240
|
+
*/
|
|
6241
|
+
/** The gate a constructor call has to pass: only this module holds it. */
|
|
6242
|
+
const GATE = Symbol("locateCliCommand");
|
|
6243
|
+
/**
|
|
6244
|
+
* A command that runs one of this suite's own CLIs, made only by `locateCliCommand`. Rendered as every printed command
|
|
6245
|
+
* is — a line to paste, or on Windows its words as JSON when no line is safe in every shell (`inlineCommand`,
|
|
6246
|
+
* `commandText`).
|
|
6247
|
+
*
|
|
6248
|
+
* A class with a private field, so the type is nominal: an object literal with the same fields is not one, and nor is
|
|
6249
|
+
* a spread copy of one with other words in it, as it would be with a symbol-keyed brand. Only the type is exported;
|
|
6250
|
+
* the class and its gate are not.
|
|
6251
|
+
*/
|
|
6252
|
+
var PrintedCommand = class {
|
|
6253
|
+
#entry;
|
|
6254
|
+
/** The words, as the program is to receive them: this Node, its flags, the entry, then the CLI's own words. */
|
|
6255
|
+
words;
|
|
6256
|
+
/** The line to paste; null when one of the words has no printing that every Windows shell passes on alike. */
|
|
6257
|
+
line;
|
|
6258
|
+
platform;
|
|
6259
|
+
constructor(gate, words, platform, entry) {
|
|
6260
|
+
if (gate !== GATE) throw new TypeError("a printed command is made by locateCliCommand, and only there");
|
|
6261
|
+
this.#entry = entry;
|
|
6262
|
+
this.words = Object.freeze([...words]);
|
|
6263
|
+
this.line = quoteCommand(this.words, platform).line;
|
|
6264
|
+
this.platform = platform;
|
|
6265
|
+
Object.freeze(this);
|
|
6266
|
+
}
|
|
6267
|
+
/**
|
|
6268
|
+
* The real path of the file it runs, as it was checked when the command was located: a fact about then (D6). The
|
|
6269
|
+
* command names this path and nothing else, so what runs is whatever is there when the person runs it.
|
|
6270
|
+
*/
|
|
6271
|
+
get entry() {
|
|
6272
|
+
return this.#entry;
|
|
6273
|
+
}
|
|
6274
|
+
/**
|
|
6275
|
+
* As JSON — a result's field written out by `--json`, or returned to an MCP client — it is the text it always was:
|
|
6276
|
+
* its line, or its words as JSON with what to do when no Windows line is safe (`commandText`). Rendered where it
|
|
6277
|
+
* leaves the process, so a field can hold the command itself and the wire stays the same.
|
|
6278
|
+
*/
|
|
6279
|
+
toJSON() {
|
|
6280
|
+
return quotedText(this);
|
|
6281
|
+
}
|
|
6282
|
+
/** Never interpolated: a sentence gives a command through `handoffSentence`, a value through `handoffText`. */
|
|
6283
|
+
[Symbol.toPrimitive]() {
|
|
6284
|
+
throw new TypeError("a printed command is rendered with handoffText or handoffSentence, never interpolated");
|
|
6285
|
+
}
|
|
6286
|
+
};
|
|
6287
|
+
const STRIP_TYPES = "--experimental-strip-types";
|
|
6288
|
+
const TRANSFORM_TYPES = "--experimental-transform-types";
|
|
6289
|
+
/** The suite's packages, by name, and its channel packages, which reach core through their own dependency on it. */
|
|
6290
|
+
const SUITE_PACKAGES = new Set(CHANNEL_SNAPSHOT.map(({ packageName }) => packageName));
|
|
6291
|
+
const CHANNEL_PACKAGES$1 = new Set(CHANNEL_SNAPSHOT.filter(({ manifest }) => manifest.channel !== "core").map(({ packageName }) => packageName));
|
|
6292
|
+
/**
|
|
6293
|
+
* Locates the command that runs `request.target`'s CLI with `request.words`, or says why there is none.
|
|
6294
|
+
*
|
|
6295
|
+
* The program is always this process's Node (`process.execPath`), which must satisfy the target's `engines.node`; no
|
|
6296
|
+
* interpreter named anywhere else is run, or asked its version. A TypeScript entry gets `--experimental-strip-types`,
|
|
6297
|
+
* which Node 22.12–22.17 needs, and `--experimental-transform-types` only when this source run was started with it;
|
|
6298
|
+
* no other flag this process was started with is carried — debug, preload, condition and memory flags are not what the
|
|
6299
|
+
* CLI needs. The suite directories the command uses go between the entry and its words, one canonical option each,
|
|
6300
|
+
* before any `--`.
|
|
6301
|
+
*
|
|
6302
|
+
* Synchronous, so a hint or a renderer can call it where it builds its sentence; for another product the caller scans
|
|
6303
|
+
* the registrations first and passes them in (`request.registrations`).
|
|
6304
|
+
*/
|
|
6305
|
+
function locateCliCommand(request, runtime = {
|
|
6306
|
+
version: process.version,
|
|
6307
|
+
execArgv: process.execArgv
|
|
6308
|
+
}) {
|
|
6309
|
+
const { caller, target } = request;
|
|
6310
|
+
const [first] = request.words;
|
|
6311
|
+
if (first !== void 0 && (isAbsolute(first) || suiteCommandOf(first) !== null)) throw new TypeError(`the words to run start after the program: ${JSON.stringify(first)} is one`);
|
|
6312
|
+
const platform = request.platform ?? process.platform;
|
|
6313
|
+
let version = VERSION;
|
|
6314
|
+
const fail = (reason, detail, extra = {}) => notLocated(target, version, reason, detail, runtime.version, extra);
|
|
6315
|
+
let callerFile;
|
|
6316
|
+
try {
|
|
6317
|
+
callerFile = fileURLToPath(caller.url);
|
|
6318
|
+
} catch {
|
|
6319
|
+
return fail("caller", `${caller.url} is not a module on this machine`);
|
|
6320
|
+
}
|
|
6321
|
+
const callerSource = callerFile.endsWith(".ts");
|
|
6322
|
+
const nearest = nearestPackage(dirname(callerFile));
|
|
6323
|
+
const callerManifest = nearest?.manifest ?? null;
|
|
6324
|
+
if (nearest === null || callerManifest === null || callerManifest.name !== caller.packageName) {
|
|
6325
|
+
const name = typeof callerManifest?.name === "string" ? callerManifest.name : "no named package";
|
|
6326
|
+
return fail("manifest", `the package ${callerFile} belongs to is ${name}, not ${caller.packageName}`);
|
|
6327
|
+
}
|
|
6328
|
+
const callerVersion = typeof callerManifest.version === "string" ? callerManifest.version : null;
|
|
6329
|
+
if (callerVersion !== null) version = callerVersion;
|
|
6330
|
+
let callerRoot;
|
|
6331
|
+
try {
|
|
6332
|
+
callerRoot = realpathSync(nearest.root);
|
|
6333
|
+
} catch {
|
|
6334
|
+
return fail("caller", `${nearest.root} cannot be resolved`);
|
|
6335
|
+
}
|
|
6336
|
+
if (!isAbsolute(process.execPath)) return fail("interpreter", `this Node, ${process.execPath}, is not a full path`);
|
|
6337
|
+
const pins = {};
|
|
6338
|
+
for (const key of request.uses) pins[key] = request.paths[key];
|
|
6339
|
+
let words;
|
|
6340
|
+
try {
|
|
6341
|
+
words = normalizePathOptionWords(request.words, pins, 0);
|
|
6342
|
+
} catch (error) {
|
|
6343
|
+
return fail("arguments", error instanceof Error ? error.message : String(error));
|
|
6344
|
+
}
|
|
6345
|
+
const build = (found) => buildCommand(target, found, words, runtime, callerSource, platform);
|
|
6346
|
+
const located = (built, found, direction) => Object.freeze({
|
|
6347
|
+
ok: true,
|
|
6348
|
+
command: built.command,
|
|
6349
|
+
basis: Object.freeze({
|
|
6350
|
+
callerSource,
|
|
6351
|
+
callerRoot,
|
|
6352
|
+
callerVersion,
|
|
6353
|
+
targetRoot: found.root,
|
|
6354
|
+
targetVersion: typeof found.manifest.version === "string" ? found.manifest.version : null,
|
|
6355
|
+
callerWorkspace: null,
|
|
6356
|
+
targetWorkspace: null,
|
|
6357
|
+
entryKind: built.entryKind,
|
|
6358
|
+
...direction
|
|
6359
|
+
})
|
|
6360
|
+
});
|
|
6361
|
+
if (caller.packageName === target.packageName) {
|
|
6362
|
+
const found = {
|
|
6363
|
+
root: callerRoot,
|
|
6364
|
+
manifest: callerManifest,
|
|
6365
|
+
source: callerSource
|
|
6366
|
+
};
|
|
6367
|
+
const built = build(found);
|
|
6368
|
+
if (!built.ok) return fail(built.reason, built.detail, { nodeRange: built.nodeRange });
|
|
6369
|
+
return located(built, found, {
|
|
6370
|
+
direction: "own",
|
|
6371
|
+
sameCheckout: true
|
|
6372
|
+
});
|
|
6373
|
+
}
|
|
6374
|
+
if (target.manifest.channel === "core" && CHANNEL_PACKAGES$1.has(caller.packageName)) {
|
|
6375
|
+
const installed = installedPackage(dirname(callerFile), target.packageName);
|
|
6376
|
+
if (installed === null) return fail("core", `${caller.packageName} has no ${target.packageName} installed where it can find one`);
|
|
6377
|
+
const manifest = readPackageManifest(installed);
|
|
6378
|
+
if (manifest === null || manifest.name !== target.packageName) return fail("manifest", `the package found as ${target.packageName}, at ${installed}, is not that package`);
|
|
6379
|
+
if (manifest.version !== version) {
|
|
6380
|
+
const theirs = typeof manifest.version === "string" ? manifest.version : "of no version";
|
|
6381
|
+
return fail("version", `${caller.packageName} ${version} finds ${target.packageName} ${theirs}, at ${installed}`);
|
|
6382
|
+
}
|
|
6383
|
+
const callerWorkspace = workspaceOf(callerRoot);
|
|
6384
|
+
const targetWorkspace = workspaceOf(installed);
|
|
6385
|
+
const sameCheckout = callerWorkspace !== null && callerWorkspace === targetWorkspace;
|
|
6386
|
+
const found = {
|
|
6387
|
+
root: installed,
|
|
6388
|
+
manifest,
|
|
6389
|
+
source: callerSource && sameCheckout
|
|
6390
|
+
};
|
|
6391
|
+
const built = build(found);
|
|
6392
|
+
if (!built.ok) return fail(built.reason, built.detail, { nodeRange: built.nodeRange });
|
|
6393
|
+
return located(built, found, {
|
|
6394
|
+
direction: "channel-to-core",
|
|
6395
|
+
callerWorkspace,
|
|
6396
|
+
targetWorkspace,
|
|
6397
|
+
sameCheckout
|
|
6398
|
+
});
|
|
6399
|
+
}
|
|
6400
|
+
if (!SUITE_PACKAGES.has(caller.packageName)) return fail("direction", `${caller.packageName} is not a package of this suite`);
|
|
6401
|
+
if (request.registrations === void 0) return fail("no-registrations", "nothing registered was given to look through for another product's command");
|
|
6402
|
+
const facts = registrationFactsOf(target);
|
|
6403
|
+
const notUsed = [];
|
|
6404
|
+
const usable = [];
|
|
6405
|
+
let engineRange;
|
|
6406
|
+
for (const server of request.registrations) {
|
|
6407
|
+
if (!startsProduct(server, facts, platform)) continue;
|
|
6408
|
+
const reject = (why) => notUsed.push(Object.freeze({
|
|
6409
|
+
client: server.client,
|
|
6410
|
+
path: server.path,
|
|
6411
|
+
name: server.name,
|
|
6412
|
+
why
|
|
6413
|
+
}));
|
|
6414
|
+
if (isNpxRegistration(server, facts, platform)) {
|
|
6415
|
+
reject("npx");
|
|
6416
|
+
continue;
|
|
6417
|
+
}
|
|
6418
|
+
if (withoutPathOptions(server.args).dangling) {
|
|
6419
|
+
reject("arguments");
|
|
6420
|
+
continue;
|
|
6421
|
+
}
|
|
6422
|
+
const launcher = launcherOf(server, facts, platform);
|
|
6423
|
+
const files = registrationFiles(server, facts, platform);
|
|
6424
|
+
if (![...files.scripts, ...files.command === null ? [] : [files.command]].some((file) => isAbsolute(file))) {
|
|
6425
|
+
reject("no-file");
|
|
6426
|
+
continue;
|
|
6427
|
+
}
|
|
6428
|
+
const found = registeredPackage(files, target, facts);
|
|
6429
|
+
if (found === null) {
|
|
6430
|
+
reject("no-package");
|
|
6431
|
+
continue;
|
|
6432
|
+
}
|
|
6433
|
+
const managedAt = launcher === "managed" ? registrationVersion(server, facts, platform) : version;
|
|
6434
|
+
if (found.manifest.version !== version || managedAt !== version) {
|
|
6435
|
+
reject("version");
|
|
6436
|
+
continue;
|
|
6437
|
+
}
|
|
6438
|
+
const built = build(found);
|
|
6439
|
+
if (!built.ok) {
|
|
6440
|
+
reject(built.reason);
|
|
6441
|
+
if (built.reason === "engine") engineRange ??= built.nodeRange;
|
|
6442
|
+
continue;
|
|
6443
|
+
}
|
|
6444
|
+
usable.push({
|
|
6445
|
+
server,
|
|
6446
|
+
launcher,
|
|
6447
|
+
built,
|
|
6448
|
+
found
|
|
6449
|
+
});
|
|
6450
|
+
}
|
|
6451
|
+
const rank = ({ server, launcher }) => [
|
|
6452
|
+
launcher === "managed" ? "0" : "1",
|
|
6453
|
+
server.client,
|
|
6454
|
+
server.path,
|
|
6455
|
+
server.name,
|
|
6456
|
+
server.command,
|
|
6457
|
+
...server.args
|
|
6458
|
+
];
|
|
6459
|
+
const [best] = usable.sort((a, b) => compareWords(rank(a), rank(b)));
|
|
6460
|
+
if (best === void 0) {
|
|
6461
|
+
if (engineRange !== void 0) return fail("engine", `it needs Node ${engineRange}, and this is Node ${runtime.version}`, {
|
|
6462
|
+
nodeRange: engineRange,
|
|
6463
|
+
registrations: notUsed
|
|
6464
|
+
});
|
|
6465
|
+
return fail("not-registered", notRegistered(notUsed, version), { registrations: notUsed });
|
|
6466
|
+
}
|
|
6467
|
+
const { client, path, name } = best.server;
|
|
6468
|
+
return located(best.built, best.found, {
|
|
6469
|
+
direction: "cross-product",
|
|
6470
|
+
sameCheckout: false,
|
|
6471
|
+
registration: Object.freeze({
|
|
6472
|
+
client,
|
|
6473
|
+
path,
|
|
6474
|
+
name,
|
|
6475
|
+
launcher: best.launcher
|
|
6476
|
+
})
|
|
6477
|
+
});
|
|
6478
|
+
}
|
|
6479
|
+
/**
|
|
6480
|
+
* Checks what was found and makes its command: the target's Node range against this Node, then the entry — the source
|
|
6481
|
+
* CLI or the manifest's bin for the command it declares — as a readable file inside its package.
|
|
6482
|
+
*/
|
|
6483
|
+
function buildCommand(target, found, words, runtime, callerSource, platform) {
|
|
6484
|
+
const engines = found.manifest.engines;
|
|
6485
|
+
const range = typeof engines === "object" && engines !== null ? engines.node : void 0;
|
|
6486
|
+
if (typeof range !== "string") return {
|
|
6487
|
+
ok: false,
|
|
6488
|
+
reason: "engine",
|
|
6489
|
+
detail: `${target.packageName} at ${found.root} declares no Node range this can check`
|
|
6490
|
+
};
|
|
6491
|
+
if (satisfiesRange(runtime.version, range) !== true) return {
|
|
6492
|
+
ok: false,
|
|
6493
|
+
reason: "engine",
|
|
6494
|
+
detail: `it needs Node ${range}, and this is Node ${runtime.version}`,
|
|
6495
|
+
nodeRange: range
|
|
6496
|
+
};
|
|
6497
|
+
let entry;
|
|
6498
|
+
if (found.source) entry = join(found.root, "src", "cli.ts");
|
|
6499
|
+
else {
|
|
6500
|
+
const declared = found.manifest.agentcomms;
|
|
6501
|
+
const binary = typeof declared === "object" && declared !== null ? declared.binary : void 0;
|
|
6502
|
+
const bin = found.manifest.bin;
|
|
6503
|
+
const path = binary === target.manifest.binary && typeof bin === "object" && bin !== null && Object.hasOwn(bin, target.manifest.binary) ? bin[target.manifest.binary] : void 0;
|
|
6504
|
+
if (typeof path !== "string" || path.length === 0) return {
|
|
6505
|
+
ok: false,
|
|
6506
|
+
reason: "bin",
|
|
6507
|
+
detail: `the bin of ${target.packageName} at ${found.root} does not name the command it declares`
|
|
6508
|
+
};
|
|
6509
|
+
entry = resolve(found.root, path);
|
|
6510
|
+
}
|
|
6511
|
+
const checked = checkedEntry(found.root, entry);
|
|
6512
|
+
if (checked.ok === false) return {
|
|
6513
|
+
ok: false,
|
|
6514
|
+
reason: "entry",
|
|
6515
|
+
detail: checked.why
|
|
6516
|
+
};
|
|
6517
|
+
const flags = /\.[cm]?ts$/.test(checked.path) ? [STRIP_TYPES, ...callerSource && runtime.execArgv.includes(TRANSFORM_TYPES) ? [TRANSFORM_TYPES] : []] : [];
|
|
6518
|
+
return {
|
|
6519
|
+
ok: true,
|
|
6520
|
+
command: new PrintedCommand(GATE, [
|
|
6521
|
+
process.execPath,
|
|
6522
|
+
...flags,
|
|
6523
|
+
checked.path,
|
|
6524
|
+
...words
|
|
6525
|
+
], platform, checked.path),
|
|
6526
|
+
entryKind: found.source ? "source" : "bin"
|
|
6527
|
+
};
|
|
6528
|
+
}
|
|
6529
|
+
/**
|
|
6530
|
+
* The target's package a registration's files lead to, or null: a script it starts, real or as written when it is
|
|
6531
|
+
* gone, inside the package (or inside the product's own server wrapper, which depends on it); or, for the product's
|
|
6532
|
+
* own command, the package that command links into or that npm installed beside it.
|
|
6533
|
+
*/
|
|
6534
|
+
function registeredPackage(files, target, facts) {
|
|
6535
|
+
for (const script of files.scripts) {
|
|
6536
|
+
if (!isAbsolute(script)) continue;
|
|
6537
|
+
const found = packageAround(script, target, facts);
|
|
6538
|
+
if (found !== null) {
|
|
6539
|
+
const source = join(found.root, "src", "cli.ts");
|
|
6540
|
+
const real = realpathOfExisting(script);
|
|
6541
|
+
return {
|
|
6542
|
+
...found,
|
|
6543
|
+
source: real !== null && sameFile(real, source)
|
|
6544
|
+
};
|
|
6545
|
+
}
|
|
6546
|
+
}
|
|
6547
|
+
const { command } = files;
|
|
6548
|
+
if (command === null || !isAbsolute(command)) return null;
|
|
6549
|
+
const linked = packageAround(command, target, facts);
|
|
6550
|
+
if (linked !== null) return {
|
|
6551
|
+
...linked,
|
|
6552
|
+
source: false
|
|
6553
|
+
};
|
|
6554
|
+
const dir = dirname(command);
|
|
6555
|
+
for (const candidate of [join(dir, "node_modules"), join(dir, "..", "lib", "node_modules")]) {
|
|
6556
|
+
let root;
|
|
6557
|
+
try {
|
|
6558
|
+
root = realpathSync(join(candidate, ...target.packageName.split("/")));
|
|
6559
|
+
} catch {
|
|
6560
|
+
continue;
|
|
6561
|
+
}
|
|
6562
|
+
const manifest = readPackageManifest(root);
|
|
6563
|
+
if (manifest?.name === target.packageName) return {
|
|
6564
|
+
root,
|
|
6565
|
+
manifest,
|
|
6566
|
+
source: false
|
|
6567
|
+
};
|
|
6568
|
+
}
|
|
6569
|
+
return null;
|
|
6570
|
+
}
|
|
6571
|
+
/** The target's package that `file` is in — directly, or through the product's server wrapper, which depends on it. */
|
|
6572
|
+
function packageAround(file, target, facts) {
|
|
6573
|
+
const real = realpathOfExisting(file);
|
|
6574
|
+
if (real === null) return null;
|
|
6575
|
+
const nearest = nearestPackage(dirname(real));
|
|
6576
|
+
if (nearest === null || nearest.manifest === null) return null;
|
|
6577
|
+
let root;
|
|
6578
|
+
try {
|
|
6579
|
+
root = realpathSync(nearest.root);
|
|
6580
|
+
} catch {
|
|
6581
|
+
return null;
|
|
6582
|
+
}
|
|
6583
|
+
if (nearest.manifest.name === target.packageName) return {
|
|
6584
|
+
root,
|
|
6585
|
+
manifest: nearest.manifest
|
|
6586
|
+
};
|
|
6587
|
+
if (facts.npxPackage === target.packageName || nearest.manifest.name !== facts.npxPackage) return null;
|
|
6588
|
+
const installed = installedPackage(root, target.packageName);
|
|
6589
|
+
const manifest = installed === null ? null : readPackageManifest(installed);
|
|
6590
|
+
return installed !== null && manifest?.name === target.packageName ? {
|
|
6591
|
+
root: installed,
|
|
6592
|
+
manifest
|
|
6593
|
+
} : null;
|
|
6594
|
+
}
|
|
6595
|
+
function sameFile(a, b) {
|
|
6596
|
+
try {
|
|
6597
|
+
return realpathSync(a) === realpathSync(b);
|
|
6598
|
+
} catch {
|
|
6599
|
+
return false;
|
|
6600
|
+
}
|
|
6601
|
+
}
|
|
6602
|
+
/** Two lists of words in order: the first that differs decides, and a list that runs out first comes first. */
|
|
6603
|
+
function compareWords(a, b) {
|
|
6604
|
+
for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
|
|
6605
|
+
const [x, y] = [a[index], b[index]];
|
|
6606
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
6607
|
+
}
|
|
6608
|
+
return a.length - b.length;
|
|
6609
|
+
}
|
|
6610
|
+
/** Why no registration of another product could be used, in a clause: how many were looked at, and what each was. */
|
|
6611
|
+
function notRegistered(notUsed, version) {
|
|
6612
|
+
if (notUsed.length === 0) return "no MCP client on this machine has it registered";
|
|
6613
|
+
const said = {
|
|
6614
|
+
npx: "run by npx, which is never used for this",
|
|
6615
|
+
arguments: "with a path option that has no value",
|
|
6616
|
+
"no-file": "not started from a file named by its full path",
|
|
6617
|
+
"no-package": "whose package cannot be found or read",
|
|
6618
|
+
version: "of another or an unknown version",
|
|
6619
|
+
engine: "whose Node range this Node is outside, or which declares none",
|
|
6620
|
+
bin: "whose package does not name its command",
|
|
6621
|
+
entry: "whose command file is missing, unreadable or outside its package"
|
|
6622
|
+
};
|
|
6623
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6624
|
+
for (const { why } of notUsed) counts.set(why, (counts.get(why) ?? 0) + 1);
|
|
6625
|
+
const parts = [...counts].map(([why, count]) => `${count} ${said[why]}`);
|
|
6626
|
+
return `none of ${notUsed.length === 1 ? "the 1 registration" : `the ${notUsed.length} registrations`} of it is ${version} in a file this can check (${parts.join("; ")})`;
|
|
6627
|
+
}
|
|
6628
|
+
function notLocated(target, version, reason, detail, nodeVersion, extra) {
|
|
6629
|
+
const named = `${target.manifest.label} ${version} (${target.packageName})`;
|
|
6630
|
+
const { nodeRange, registrations } = extra;
|
|
6631
|
+
const message = nodeRange === void 0 ? `${named} is not locatable here: ${detail}. Install or update it through your usual route, then try again.` : `${named} needs Node ${nodeRange}, and this is Node ${nodeVersion}, so there is no command to run it with here. Run it again under a Node in that range.`;
|
|
6632
|
+
const notLocated = {
|
|
6633
|
+
ok: false,
|
|
6634
|
+
reason,
|
|
6635
|
+
product: target.manifest.label,
|
|
6636
|
+
package: target.packageName,
|
|
6637
|
+
version,
|
|
6638
|
+
...nodeRange === void 0 ? {} : { nodeRange },
|
|
6639
|
+
...registrations === void 0 ? {} : { registrations: Object.freeze([...registrations]) },
|
|
6640
|
+
detail,
|
|
6641
|
+
message
|
|
6642
|
+
};
|
|
6643
|
+
Object.defineProperties(notLocated, {
|
|
6644
|
+
toJSON: { value: () => message },
|
|
6645
|
+
[Symbol.toPrimitive]: { value: () => {
|
|
6646
|
+
throw new TypeError("a missing command is said with handoffText or handoffSentence, never interpolated");
|
|
6647
|
+
} }
|
|
6648
|
+
});
|
|
6649
|
+
return Object.freeze(notLocated);
|
|
6650
|
+
}
|
|
6651
|
+
/**
|
|
6652
|
+
* The entry's real path, when it is a readable regular file inside its package's real root by whole segments — as
|
|
6653
|
+
* written and after links are resolved — or why not.
|
|
6654
|
+
*
|
|
6655
|
+
* Checked now, for the command printed now (D6). This does not make the path durable: an npx cache evicted, a global
|
|
6656
|
+
* install upgraded in place or a file replaced after this is what the command meets when it runs — a missing file
|
|
6657
|
+
* fails with Node's own error naming it, a replaced one runs as it then is. Nothing here, or in the command, looks
|
|
6658
|
+
* anywhere else for the product, PATH included, and a later locate checks the path again.
|
|
6659
|
+
*/
|
|
6660
|
+
function checkedEntry(root, entry) {
|
|
6661
|
+
if (!isWithin(root, entry)) return {
|
|
6662
|
+
ok: false,
|
|
6663
|
+
why: `${entry} is outside its package, ${root}`
|
|
6664
|
+
};
|
|
6665
|
+
let real;
|
|
6666
|
+
try {
|
|
6667
|
+
real = realpathSync(entry);
|
|
6668
|
+
} catch {
|
|
6669
|
+
return {
|
|
6670
|
+
ok: false,
|
|
6671
|
+
why: `${entry} is not there`
|
|
6672
|
+
};
|
|
6673
|
+
}
|
|
6674
|
+
if (!isWithin(root, real)) return {
|
|
6675
|
+
ok: false,
|
|
6676
|
+
why: `${entry} leads to ${real}, outside its package, ${root}`
|
|
6677
|
+
};
|
|
6678
|
+
try {
|
|
6679
|
+
if (!statSync(real).isFile()) return {
|
|
6680
|
+
ok: false,
|
|
6681
|
+
why: `${entry} is not a file`
|
|
6682
|
+
};
|
|
6683
|
+
accessSync(real, constants$1.R_OK);
|
|
6684
|
+
} catch {
|
|
6685
|
+
return {
|
|
6686
|
+
ok: false,
|
|
6687
|
+
why: `${entry} cannot be read`
|
|
6688
|
+
};
|
|
6689
|
+
}
|
|
6690
|
+
return {
|
|
6691
|
+
ok: true,
|
|
6692
|
+
path: real
|
|
6693
|
+
};
|
|
6694
|
+
}
|
|
6695
|
+
/**
|
|
6696
|
+
* The real root of the package `name` installed for a module in `start`, found as Node's own resolution finds one: in
|
|
6697
|
+
* `node_modules` of each directory from there up. Not resolved through its `exports` — core's point at a build, and
|
|
6698
|
+
* which entry to run is decided here — and never from a global folder, which `import` does not read either.
|
|
6699
|
+
*/
|
|
6700
|
+
function installedPackage(start, name) {
|
|
6701
|
+
let dir = resolve(start);
|
|
6702
|
+
for (;;) {
|
|
6703
|
+
if (basename(dir) !== "node_modules") {
|
|
6704
|
+
const candidate = join(dir, "node_modules", ...name.split("/"));
|
|
6705
|
+
try {
|
|
6706
|
+
if (statSync(candidate).isDirectory()) return realpathSync(candidate);
|
|
6707
|
+
} catch {}
|
|
6708
|
+
}
|
|
6709
|
+
const parent = dirname(dir);
|
|
6710
|
+
if (parent === dir) return null;
|
|
6711
|
+
dir = parent;
|
|
6712
|
+
}
|
|
6713
|
+
}
|
|
6714
|
+
/**
|
|
6715
|
+
* The real repository checkout whose workspace lists `root` as one of its packages, or null: the nearest
|
|
6716
|
+
* `pnpm-workspace.yaml` above it, and its `packages` globs. A package under the checkout but not one of them — an
|
|
6717
|
+
* installed copy in its `node_modules` — is not a workspace package of it.
|
|
6718
|
+
*/
|
|
6719
|
+
function workspaceOf(root) {
|
|
6720
|
+
let dir = dirname(root);
|
|
6721
|
+
for (;;) {
|
|
6722
|
+
const globs = workspaceGlobs(dir);
|
|
6723
|
+
if (globs !== null) return inWorkspace(relative(dir, root).split(sep), globs) ? dir : null;
|
|
6724
|
+
const parent = dirname(dir);
|
|
6725
|
+
if (parent === dir) return null;
|
|
6726
|
+
dir = parent;
|
|
6727
|
+
}
|
|
6728
|
+
}
|
|
6729
|
+
/** The `packages` globs of `dir/pnpm-workspace.yaml`, or null when there is no such file. */
|
|
6730
|
+
function workspaceGlobs(dir) {
|
|
6731
|
+
let text;
|
|
6732
|
+
try {
|
|
6733
|
+
text = readFileSync(join(dir, "pnpm-workspace.yaml"), "utf8");
|
|
6734
|
+
} catch (error) {
|
|
6735
|
+
const code = error.code;
|
|
6736
|
+
return code === "ENOENT" || code === "ENOTDIR" ? null : [];
|
|
6737
|
+
}
|
|
6738
|
+
const unquote = (glob) => glob.trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
6739
|
+
const lines = text.split(/\r?\n/);
|
|
6740
|
+
const at = lines.findIndex((line) => /^packages\s*:/.test(line));
|
|
6741
|
+
if (at < 0) return [];
|
|
6742
|
+
const inline = /^packages\s*:\s*\[(.*)\]\s*(?:#.*)?$/.exec(lines[at]);
|
|
6743
|
+
if (inline) return (inline[1] ?? "").split(",").map(unquote).filter(Boolean);
|
|
6744
|
+
const globs = [];
|
|
6745
|
+
for (const line of lines.slice(at + 1)) {
|
|
6746
|
+
if (/^\s*(?:#.*)?$/.test(line)) continue;
|
|
6747
|
+
const item = /^\s+-\s*(.*?)\s*(?:#.*)?$/.exec(line);
|
|
6748
|
+
if (!item) break;
|
|
6749
|
+
globs.push(unquote(item[1] ?? ""));
|
|
6750
|
+
}
|
|
6751
|
+
return globs;
|
|
6752
|
+
}
|
|
6753
|
+
/** Whether a path, as segments from the workspace root, is one of its packages: some glob takes it and no `!` one. */
|
|
6754
|
+
function inWorkspace(segments, globs) {
|
|
6755
|
+
const matches = (glob) => segmentsMatch(segments, glob.replace(/^\.\//, "").replace(/\/+$/, "").split("/").filter((part) => part !== "."));
|
|
6756
|
+
const included = globs.filter((glob) => !glob.startsWith("!"));
|
|
6757
|
+
const excluded = globs.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1));
|
|
6758
|
+
return included.some(matches) && !excluded.some(matches);
|
|
6759
|
+
}
|
|
6760
|
+
function segmentsMatch(path, pattern) {
|
|
6761
|
+
const [head, ...rest] = pattern;
|
|
6762
|
+
if (head === void 0) return path.length === 0;
|
|
6763
|
+
if (head === "**") return segmentsMatch(path, rest) || path.length > 0 && segmentsMatch(path.slice(1), pattern);
|
|
6764
|
+
const [segment] = path;
|
|
6765
|
+
if (segment === void 0) return false;
|
|
6766
|
+
return new RegExp(`^${head.split("*").map(escapeRegExp$1).join("[^/]*")}$`).test(segment) && segmentsMatch(path.slice(1), rest);
|
|
6767
|
+
}
|
|
6768
|
+
function escapeRegExp$1(text) {
|
|
6769
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6770
|
+
}
|
|
6771
|
+
/**
|
|
6772
|
+
* The commands a package tells a person to run, made once per printing process (design 2026-10-04, D1 and D5;
|
|
6773
|
+
* CUE-403). How a channel migrates a handoff to these is in CONTRIBUTING.md, "Telling a person what to run".
|
|
6774
|
+
*
|
|
6775
|
+
* A package makes them from where it is — a module of its own and its package name, the caller — with the folders and
|
|
6776
|
+
* shell of the process printing: `openCore({ caller })` puts them on `core.handoffs`. Each method takes the CLI's own
|
|
6777
|
+
* words, never the program, and returns a `Handoff`: a located `PrintedCommand`, or why there is none here. Nothing
|
|
6778
|
+
* here quotes a line by hand, names a bare binary, or guesses an installation.
|
|
6779
|
+
*/
|
|
6780
|
+
/** The suite folders every CLI command opens: what a command is pinned to unless it says otherwise. */
|
|
6781
|
+
const HANDOFF_FOLDERS = Object.freeze([
|
|
6782
|
+
"configDir",
|
|
6783
|
+
"stateDir",
|
|
6784
|
+
"dataDir",
|
|
6785
|
+
"secretsDir"
|
|
6786
|
+
]);
|
|
6787
|
+
Object.freeze({
|
|
6788
|
+
url: import.meta.url,
|
|
6789
|
+
packageName: "@agentcomms/core"
|
|
6790
|
+
});
|
|
6791
|
+
/** A channel of this release, by its word; a programming error for a word that is none. */
|
|
6792
|
+
function entryOf(channel) {
|
|
6793
|
+
const entry = CHANNEL_SNAPSHOT.find((each) => each.manifest.channel === channel);
|
|
6794
|
+
if (entry === void 0) throw new TypeError(`"${channel}" is not a channel of this release`);
|
|
6795
|
+
return entry;
|
|
6796
|
+
}
|
|
6797
|
+
/** The folders a command is pinned to. */
|
|
6798
|
+
function usesOf(use) {
|
|
6799
|
+
return [...use?.uses ?? HANDOFF_FOLDERS, ...use?.downloads ? ["downloadsDir"] : []];
|
|
6800
|
+
}
|
|
6801
|
+
/** The commands a package prints, located from `caller` (CONTRIBUTING.md, "Telling a person what to run"). */
|
|
6802
|
+
function cliHandoffs(options) {
|
|
6803
|
+
const { caller, paths, registrations } = options;
|
|
6804
|
+
const platform = options.platform ?? process.platform;
|
|
6805
|
+
const own = CHANNEL_SNAPSHOT.find((entry) => entry.packageName === caller.packageName);
|
|
6806
|
+
if (own === void 0) throw new TypeError(`${caller.packageName} is not a package of this suite`);
|
|
6807
|
+
const locate = (target, words, use) => {
|
|
6808
|
+
const result = locateCliCommand({
|
|
6809
|
+
caller,
|
|
6810
|
+
target,
|
|
6811
|
+
words,
|
|
6812
|
+
uses: usesOf(use),
|
|
6813
|
+
paths,
|
|
6814
|
+
platform,
|
|
6815
|
+
...registrations === void 0 ? {} : { registrations }
|
|
6816
|
+
}, ...options.runtime === void 0 ? [] : [options.runtime]);
|
|
6817
|
+
return result.ok ? result.command : result;
|
|
6818
|
+
};
|
|
6819
|
+
const handoffs = Object.freeze({
|
|
6820
|
+
caller,
|
|
6821
|
+
platform,
|
|
6822
|
+
own: (words, use) => locate(own, words, use),
|
|
6823
|
+
core: (words, use) => locate(entryOf("core"), words, use),
|
|
6824
|
+
of: (channel, words, use) => locate(entryOf(channel), words, use),
|
|
6825
|
+
on: (other) => other === platform ? handoffs : cliHandoffs({
|
|
6826
|
+
...options,
|
|
6827
|
+
platform: other
|
|
6828
|
+
}),
|
|
6829
|
+
registered: async () => cliHandoffs({
|
|
6830
|
+
...options,
|
|
6831
|
+
registrations: (await scanRegisteredServers(options.env ?? process.env, platform)).servers
|
|
6832
|
+
}),
|
|
6833
|
+
withRegistrations: (servers) => cliHandoffs({
|
|
6834
|
+
...options,
|
|
6835
|
+
registrations: servers
|
|
6836
|
+
})
|
|
6837
|
+
});
|
|
6838
|
+
return handoffs;
|
|
6839
|
+
}
|
|
6840
|
+
/**
|
|
6841
|
+
* `core.handoffs` — quoted for `platform` when one is given — for anything that prints a command. A programming error
|
|
6842
|
+
* when core was opened without its caller: the package has to give it one (`openCore({ caller })`), and there is no
|
|
6843
|
+
* bare name to print instead.
|
|
6844
|
+
*/
|
|
6845
|
+
function requireHandoffs(core, platform) {
|
|
6846
|
+
const handoffs = requiredHandoffs(core.handoffs);
|
|
6847
|
+
return platform === void 0 ? handoffs : handoffs.on(platform);
|
|
6848
|
+
}
|
|
6849
|
+
const MAP = {
|
|
6850
|
+
inbox: "inboxes",
|
|
6851
|
+
account: "accounts"
|
|
6852
|
+
};
|
|
6853
|
+
/**
|
|
6854
|
+
* An own property only.
|
|
6855
|
+
*
|
|
6856
|
+
* A name is user input and the maps are plain objects, so `config.inboxes.constructor` is a function — and a lookup
|
|
6857
|
+
* of an inbox called `constructor`, which version 1 allows, found it.
|
|
6858
|
+
*/
|
|
6859
|
+
function own$1(map, key) {
|
|
6860
|
+
return Object.hasOwn(map, key) ? map[key] : void 0;
|
|
6861
|
+
}
|
|
6862
|
+
function resolveName(config, kind, name, notFound) {
|
|
6863
|
+
if (kind === "inbox") {
|
|
6864
|
+
const inbox = own$1(config.inboxes, name);
|
|
6865
|
+
if (inbox) return {
|
|
6866
|
+
alias: name,
|
|
6867
|
+
inbox
|
|
6868
|
+
};
|
|
6869
|
+
} else {
|
|
6870
|
+
const account = own$1(config.accounts, name);
|
|
6871
|
+
if (account) return {
|
|
6872
|
+
alias: name,
|
|
6873
|
+
account
|
|
6874
|
+
};
|
|
6875
|
+
}
|
|
6876
|
+
const renamed = formerNameRefusal(config, kind, name);
|
|
6877
|
+
if (renamed) throw renamed;
|
|
6878
|
+
throw notFound?.() ?? defaultNotFound(config, kind, name);
|
|
6879
|
+
}
|
|
6880
|
+
/** What to say when there are no mailboxes at all: how one is connected, in words (`connectMailboxWords`). */
|
|
6881
|
+
function noInboxesHint() {
|
|
6882
|
+
const connect = connectMailboxWords();
|
|
6883
|
+
return connect ? `No inboxes yet: add one with ${connect}, at a terminal or from a chat.` : "No inboxes yet.";
|
|
6884
|
+
}
|
|
6885
|
+
function defaultNotFound(config, kind, name) {
|
|
6886
|
+
if (kind === "inbox") {
|
|
6887
|
+
const known = Object.keys(config.inboxes);
|
|
6888
|
+
return new CommsError("NOT_FOUND", `no inbox called "${name}"`, { hint: known.length ? `Known inboxes: ${known.join(", ")}.` : noInboxesHint() });
|
|
6889
|
+
}
|
|
6890
|
+
return new CommsError("NOT_FOUND", `no account called "${name}"`);
|
|
6891
|
+
}
|
|
6892
|
+
function lookupName(config, kind, name) {
|
|
6893
|
+
return kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.accounts, name);
|
|
6894
|
+
}
|
|
6895
|
+
function findById(config, kind, id) {
|
|
6896
|
+
if (kind === "inbox") {
|
|
6897
|
+
for (const [alias, inbox] of Object.entries(config.inboxes)) if (inbox.id === id) return {
|
|
6898
|
+
alias,
|
|
6899
|
+
inbox
|
|
6900
|
+
};
|
|
6901
|
+
} else for (const [alias, account] of Object.entries(config.accounts)) if (account.id === id) return {
|
|
6902
|
+
alias,
|
|
6903
|
+
account
|
|
6904
|
+
};
|
|
6905
|
+
return null;
|
|
6906
|
+
}
|
|
6907
|
+
/**
|
|
6908
|
+
* The refusal for a former name, or null when `name` is not one.
|
|
6909
|
+
*
|
|
6910
|
+
* The replacement is looked up by id rather than read from the record, so a chain of renames ends at the name the
|
|
6911
|
+
* account has today — and an account removed since its rename is said to be gone, rather than pointing somebody at a
|
|
6912
|
+
* name that now belongs to nothing.
|
|
6913
|
+
*/
|
|
6914
|
+
function formerNameRefusal(config, kind, name) {
|
|
6915
|
+
if (config.version !== 2) return null;
|
|
6916
|
+
const former = own$1(config.formerNames[MAP[kind]], name);
|
|
6917
|
+
if (!former) return null;
|
|
6918
|
+
const current = kind === "inbox" ? findById(config, "inbox", former.id) : findById(config, "account", former.id);
|
|
6919
|
+
if (!current) return new CommsError("NOT_FOUND", `"${name}" was renamed to "${former.name}", which has since been removed`, { details: {
|
|
6920
|
+
formerName: name,
|
|
6921
|
+
id: former.id
|
|
6922
|
+
} });
|
|
6923
|
+
return new CommsError("NOT_FOUND", `"${name}" was renamed to "${current.alias}"`, {
|
|
6924
|
+
hint: `Use "${current.alias}".`,
|
|
6925
|
+
details: {
|
|
6926
|
+
formerName: name,
|
|
6927
|
+
currentName: current.alias,
|
|
6928
|
+
id: former.id
|
|
6929
|
+
}
|
|
6930
|
+
});
|
|
5542
6931
|
}
|
|
5543
|
-
/**
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
6932
|
+
/**
|
|
6933
|
+
* Whether `name` may be given to a new or renamed account of this kind and platform.
|
|
6934
|
+
*
|
|
6935
|
+
* Each version keeps its own rule, because version 1's differ by kind today and must not change under a file an
|
|
6936
|
+
* older release also writes: a mailbox name need only be free among mailboxes, a workspace name among both. Version
|
|
6937
|
+
* 2 is stricter and the same for both: the grammar, the platform, free in both maps, and never a former name of
|
|
6938
|
+
* either kind.
|
|
6939
|
+
*
|
|
6940
|
+
* A check, not a reservation. Callers repeat it inside the config write, where the schema enforces the same rules
|
|
6941
|
+
* again for version 2, because anything can happen between asking and writing.
|
|
6942
|
+
*/
|
|
6943
|
+
function nameAvailable(config, kind, name, platform) {
|
|
6944
|
+
const refuse = (error) => ({
|
|
6945
|
+
ok: false,
|
|
6946
|
+
error
|
|
6947
|
+
});
|
|
6948
|
+
if (config.version === 1) {
|
|
6949
|
+
if (RESERVED_ALIASES.has(name)) return refuse(new CommsError("USAGE", `"${name}" is reserved`, { hint: "Choose another name." }));
|
|
6950
|
+
if (!isValidAlias(name)) return refuse(new CommsError("USAGE", `"${name}" is not a usable name`, { hint: "Lower-case letters, digits and dashes, up to 32 characters, not starting with a dash." }));
|
|
6951
|
+
if (kind === "inbox" ? own$1(config.inboxes, name) : own$1(config.inboxes, name) ?? own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
|
|
6952
|
+
return { ok: true };
|
|
5550
6953
|
}
|
|
5551
|
-
const
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
...record.applied === true ? { applied: true } : {}
|
|
5564
|
-
});
|
|
5565
|
-
} catch {
|
|
5566
|
-
return null;
|
|
5567
|
-
}
|
|
6954
|
+
const problem = nameShapeProblem(name, platform);
|
|
6955
|
+
if (problem) return refuse(new CommsError("USAGE", problem));
|
|
6956
|
+
if (own$1(config.inboxes, name) || own$1(config.accounts, name)) return refuse(new CommsError("CONFIG", `"${name}" is already connected`, { hint: "Choose another name." }));
|
|
6957
|
+
for (const map of ["inboxes", "accounts"]) {
|
|
6958
|
+
const former = own$1(config.formerNames[map], name);
|
|
6959
|
+
if (former) return refuse(new CommsError("CONFIG", `"${name}" was the name of another account and cannot be used again`, {
|
|
6960
|
+
hint: "A former name keeps pointing people at the account that had it. Choose another name.",
|
|
6961
|
+
details: {
|
|
6962
|
+
formerName: name,
|
|
6963
|
+
id: former.id
|
|
6964
|
+
}
|
|
6965
|
+
}));
|
|
5568
6966
|
}
|
|
5569
|
-
return
|
|
6967
|
+
return { ok: true };
|
|
5570
6968
|
}
|
|
5571
6969
|
/**
|
|
5572
6970
|
* A pattern for each package a channel's manifest names as a rival (`rivals.packages`).
|
|
@@ -5577,36 +6975,45 @@ async function readHandedOut(path) {
|
|
|
5577
6975
|
function rivalPatterns(packages) {
|
|
5578
6976
|
return packages.map(({ name, unscoped }) => {
|
|
5579
6977
|
const bare = name.includes("/") ? name.slice(name.indexOf("/") + 1) : name;
|
|
5580
|
-
const whole = name.startsWith("@") ? escapeRegExp$
|
|
5581
|
-
const alone = unscoped && name.startsWith("@") ? `|(?<![\\w@/-])${escapeRegExp$
|
|
6978
|
+
const whole = name.startsWith("@") ? escapeRegExp$3(name) : `(?<![\\w@/-])${escapeRegExp$3(name)}`;
|
|
6979
|
+
const alone = unscoped && name.startsWith("@") ? `|(?<![\\w@/-])${escapeRegExp$3(bare)}` : "";
|
|
5582
6980
|
return {
|
|
5583
6981
|
pattern: new RegExp(`${whole}${alone}`),
|
|
5584
6982
|
name
|
|
5585
6983
|
};
|
|
5586
6984
|
});
|
|
5587
6985
|
}
|
|
5588
|
-
function escapeRegExp$
|
|
6986
|
+
function escapeRegExp$3(text) {
|
|
5589
6987
|
return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&");
|
|
5590
6988
|
}
|
|
5591
6989
|
/**
|
|
5592
6990
|
* The command that removes a registered server, in its client's own terms — the name as the client's file has it,
|
|
5593
6991
|
* which may be anything, so on Windows it can have no line to paste, and is shown as its words, to be typed
|
|
5594
|
-
* (`
|
|
6992
|
+
* (`quoteCommand`). Never as a line with the name left out: that removed whatever entry had the stand-in's name.
|
|
6993
|
+
*
|
|
6994
|
+
* The client's own command, so an external one (`externalCommand`). A name that is also one of this suite's commands,
|
|
6995
|
+
* or a path into one of its packages, is refused there, since such words are how a suite command would get round the
|
|
6996
|
+
* locator; that entry is removed by hand instead, from the file that holds it.
|
|
5595
6997
|
*/
|
|
5596
|
-
function removalCommand(client,
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
6998
|
+
function removalCommand(client, server, platform) {
|
|
6999
|
+
try {
|
|
7000
|
+
return remedy(externalCommand([
|
|
7001
|
+
client,
|
|
7002
|
+
"mcp",
|
|
7003
|
+
"remove",
|
|
7004
|
+
server.name
|
|
7005
|
+
], "the MCP client's own command removes an entry from its own configuration", platform));
|
|
7006
|
+
} catch {
|
|
7007
|
+
return remedy(`remove "${server.name}" from ${server.path} by hand, then restart ${server.client}`);
|
|
7008
|
+
}
|
|
5603
7009
|
}
|
|
5604
|
-
function gmailRemoval(
|
|
5605
|
-
|
|
7010
|
+
function gmailRemoval(registered, platform) {
|
|
7011
|
+
const { client, name: server, path, scope } = registered;
|
|
7012
|
+
if (scope === "project") return remedy(`remove "${server}" from the project entry in ${path} by hand`);
|
|
5606
7013
|
switch (client) {
|
|
5607
|
-
case "claude-code": return removalCommand("claude",
|
|
5608
|
-
case "codex": return removalCommand("codex",
|
|
5609
|
-
default: return `remove the "${server}" entry from ${path}, then restart ${client}
|
|
7014
|
+
case "claude-code": return removalCommand("claude", registered, platform);
|
|
7015
|
+
case "codex": return removalCommand("codex", registered, platform);
|
|
7016
|
+
default: return remedy(`remove the "${server}" entry from ${path}, then restart ${client}`);
|
|
5610
7017
|
}
|
|
5611
7018
|
}
|
|
5612
7019
|
/**
|
|
@@ -5617,7 +7024,7 @@ function findRivalPackageServers(servers, packages, platform = process.platform)
|
|
|
5617
7024
|
const patterns = rivalPatterns(packages);
|
|
5618
7025
|
const findings = [];
|
|
5619
7026
|
for (const server of servers) {
|
|
5620
|
-
const line = [server.command, ...server.args].join(" ");
|
|
7027
|
+
const line = [server.command, ...withoutPathOptions(server.args).words].join(" ");
|
|
5621
7028
|
const known = patterns.find((candidate) => candidate.pattern.test(line));
|
|
5622
7029
|
if (!known) continue;
|
|
5623
7030
|
findings.push({
|
|
@@ -5638,11 +7045,11 @@ function rivalPackageWarnings(servers, packages, platform = process.platform) {
|
|
|
5638
7045
|
* `product` itself: a channel's `rivals.word`, for a service with too many other servers to list.
|
|
5639
7046
|
*/
|
|
5640
7047
|
function findRivalWordServers(servers, word, product) {
|
|
5641
|
-
const pattern = new RegExp(escapeRegExp$
|
|
7048
|
+
const pattern = new RegExp(escapeRegExp$3(word), "i");
|
|
5642
7049
|
return servers.filter((server) => !isProductServer(server, product) && pattern.test([
|
|
5643
7050
|
server.name,
|
|
5644
7051
|
server.command,
|
|
5645
|
-
...server.args,
|
|
7052
|
+
...withoutPathOptions(server.args).words,
|
|
5646
7053
|
server.url ?? ""
|
|
5647
7054
|
].join(" ")));
|
|
5648
7055
|
}
|
|
@@ -5915,10 +7322,6 @@ function renderUpdate(result, color) {
|
|
|
5915
7322
|
if (result.next) lines.push("", result.next);
|
|
5916
7323
|
return lines.join("\n");
|
|
5917
7324
|
}
|
|
5918
|
-
/** The channel's fixed approval-command prefix with this generated id, rendered by the common shell rule. */
|
|
5919
|
-
function changeApprovalCommand(approveCommand, approvalId, platform = process.platform) {
|
|
5920
|
-
return shellCommand([...(approveCommand ?? "agentcomms approve").trim().split(/\s+/), approvalId], platform);
|
|
5921
|
-
}
|
|
5922
7325
|
/**
|
|
5923
7326
|
* The change policy that governs a change, as `config` stands: the strictest of the policies over everything the change
|
|
5924
7327
|
* touches.
|
|
@@ -6030,7 +7433,7 @@ async function prepareChange(core, request, options) {
|
|
|
6030
7433
|
change: binding
|
|
6031
7434
|
}),
|
|
6032
7435
|
expiresAt: record.expiresAt,
|
|
6033
|
-
next: nextStep(record.approvalId, policy, options)
|
|
7436
|
+
next: nextStep(record.approvalId, policy, core, options)
|
|
6034
7437
|
};
|
|
6035
7438
|
} catch (error) {
|
|
6036
7439
|
await auditRefusal(core, "change.prepare", error, {
|
|
@@ -6062,8 +7465,9 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
6062
7465
|
change: binding,
|
|
6063
7466
|
policy
|
|
6064
7467
|
}, {
|
|
6065
|
-
pendingHint: `Ask the user to run ${
|
|
6066
|
-
platform: options.platform
|
|
7468
|
+
pendingHint: handoffSentence(requireHandoffs(core, options.platform).own(["approve", approvalId]), (command) => `Ask the user to run ${command} in their own terminal, then try again with the same approval.`),
|
|
7469
|
+
platform: options.platform,
|
|
7470
|
+
handoffs: core.handoffs
|
|
6067
7471
|
});
|
|
6068
7472
|
const decided = stricterPolicy(policy, record.requiredPolicy) === "chat" ? "chat" : "confirm";
|
|
6069
7473
|
await auditChange(core, {
|
|
@@ -6081,7 +7485,11 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
6081
7485
|
paths: binding.loosened.map((loosening) => loosening.path),
|
|
6082
7486
|
changes: binding.loosened
|
|
6083
7487
|
};
|
|
6084
|
-
} catch (
|
|
7488
|
+
} catch (caught) {
|
|
7489
|
+
const error = isMisdirectedSend(caught) ? new CommsError(caught.code, caught.message, {
|
|
7490
|
+
hint: sendApprovesHint(await requireHandoffs(core, options.platform).registered(), approvalId),
|
|
7491
|
+
...caught.details === void 0 ? {} : { details: caught.details }
|
|
7492
|
+
}) : caught;
|
|
6085
7493
|
await auditRefusal(core, "change.claim", error, {
|
|
6086
7494
|
surface: options.surface,
|
|
6087
7495
|
approvalId,
|
|
@@ -6092,11 +7500,11 @@ async function claimChange(core, approvalId, expect, options) {
|
|
|
6092
7500
|
}
|
|
6093
7501
|
}
|
|
6094
7502
|
/** A change approval, checked to be one and to describe the change its digest binds. */
|
|
6095
|
-
async function changeRecord(core, approvalId) {
|
|
7503
|
+
async function changeRecord(core, approvalId, options = {}) {
|
|
6096
7504
|
const record = await core.approvals.get(approvalId);
|
|
6097
7505
|
if (!record) throw new CommsError("NOT_FOUND", `no approval ${approvalId}`, { hint: "Prepare the change again; an approval expires ten minutes after it is made." });
|
|
6098
7506
|
if (approvalKind(record) === "download") throw new CommsError("USAGE", `${approvalId} is a question about where to save files, not a configuration change`, { hint: DOWNLOAD_ANSWER_HINT });
|
|
6099
|
-
if (approvalKind(record) !== "change") throw new CommsError("USAGE", `approval ${approvalId} is for a send, not a configuration change`, { hint:
|
|
7507
|
+
if (approvalKind(record) !== "change") throw new CommsError("USAGE", `approval ${approvalId} is for a send, not a configuration change`, { hint: sendApprovesHint(await requireHandoffs(core, options.platform).registered(), approvalId) });
|
|
6100
7508
|
if (!record.change || changeDigest(record.change) !== record.digest) throw new CommsError("BAD_DATA", "this approval does not describe the change it is bound to", {
|
|
6101
7509
|
hint: "Nothing was approved. Prepare the change again.",
|
|
6102
7510
|
details: { approvalId }
|
|
@@ -6111,7 +7519,7 @@ async function changeRecord(core, approvalId) {
|
|
|
6111
7519
|
async function beginChangeApproval(core, approvalId, options) {
|
|
6112
7520
|
let record;
|
|
6113
7521
|
try {
|
|
6114
|
-
record = await changeRecord(core, approvalId);
|
|
7522
|
+
record = await changeRecord(core, approvalId, options);
|
|
6115
7523
|
const challenge = await core.approvals.issueChallenge(approvalId, "change", options.platform);
|
|
6116
7524
|
return {
|
|
6117
7525
|
approvalId,
|
|
@@ -6132,7 +7540,7 @@ async function beginChangeApproval(core, approvalId, options) {
|
|
|
6132
7540
|
async function finishChangeApproval(core, approvalId, answer, options) {
|
|
6133
7541
|
let record;
|
|
6134
7542
|
try {
|
|
6135
|
-
record = await changeRecord(core, approvalId);
|
|
7543
|
+
record = await changeRecord(core, approvalId, options);
|
|
6136
7544
|
const digest = changeDigest(record.change);
|
|
6137
7545
|
const approved = await core.approvals.approve(approvalId, "terminal", {
|
|
6138
7546
|
draftMessageId: digest,
|
|
@@ -6193,8 +7601,8 @@ async function revokeChange(core, approvalId, reason, options) {
|
|
|
6193
7601
|
function changePolicyOf$1(record) {
|
|
6194
7602
|
return record.requiredPolicy === "chat" ? "chat" : "confirm";
|
|
6195
7603
|
}
|
|
6196
|
-
function nextStep(approvalId, policy, options) {
|
|
6197
|
-
return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : `The change policy is confirm: ask the user to run ${
|
|
7604
|
+
function nextStep(approvalId, policy, core, options) {
|
|
7605
|
+
return policy === "chat" ? `Show this preview to the user and ask. If they say yes, claim approval ${approvalId} and apply the change; if not, revoke it.` : handoffSentence(requireHandoffs(core, options.platform).own(["approve", approvalId]), (command) => `The change policy is confirm: ask the user to run ${command} in their own terminal and type the code it shows. Then claim approval ${approvalId} and apply the change.`);
|
|
6198
7606
|
}
|
|
6199
7607
|
/**
|
|
6200
7608
|
* One line per step of a change approval: who asked, from which surface, under which policy, and how it ended.
|
|
@@ -6354,13 +7762,11 @@ async function gatedChange(core, change, options) {
|
|
|
6354
7762
|
status: "approval-required",
|
|
6355
7763
|
prepared: await prepareChange(core, request, {
|
|
6356
7764
|
surface: options.surface,
|
|
6357
|
-
approveCommand: options.approveCommand,
|
|
6358
7765
|
platform: options.platform
|
|
6359
7766
|
})
|
|
6360
7767
|
};
|
|
6361
7768
|
const consent = await claimChange(core, options.approvalId, request, {
|
|
6362
7769
|
surface: options.surface,
|
|
6363
|
-
approveCommand: options.approveCommand,
|
|
6364
7770
|
platform: options.platform
|
|
6365
7771
|
});
|
|
6366
7772
|
return {
|
|
@@ -6419,11 +7825,9 @@ function changeToolResult(outcome) {
|
|
|
6419
7825
|
*/
|
|
6420
7826
|
async function gatedChangeAtTerminal(core, change, options) {
|
|
6421
7827
|
const streams = options.streams ?? defaultStreams;
|
|
6422
|
-
const { approveCommand } = options;
|
|
6423
7828
|
const first = await gatedChange(core, change, {
|
|
6424
7829
|
surface: "cli",
|
|
6425
7830
|
approvalId: options.approvalId,
|
|
6426
|
-
approveCommand,
|
|
6427
7831
|
platform: options.output.platform
|
|
6428
7832
|
});
|
|
6429
7833
|
if (first.status === "applied") return first.result;
|
|
@@ -6431,9 +7835,9 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
6431
7835
|
if (!(agentMarker(options.env) === null && canPrompt(options.env, streams, { json: options.output.json === true }))) {
|
|
6432
7836
|
if (options.output.json !== true) streams.stdout.write(`${prepared.preview}\n\n`);
|
|
6433
7837
|
const carrying = [options.approvalFlag ?? "--approval", prepared.approvalId];
|
|
6434
|
-
const
|
|
7838
|
+
const handoffs = requireHandoffs(core, options.output.platform);
|
|
6435
7839
|
throw new CommsError("APPROVAL_PENDING", `this change needs approval first: ${prepared.summary}`, {
|
|
6436
|
-
hint: options.pendingHint?.(prepared) ?? approvalHint(prepared,
|
|
7840
|
+
hint: options.pendingHint?.(prepared) ?? approvalHint(prepared, handoffs[options.rerunOn ?? "own"](insertWordsBeforeSentinel(options.rerun, ...carrying)), handoffs),
|
|
6437
7841
|
details: {
|
|
6438
7842
|
approvalId: prepared.approvalId,
|
|
6439
7843
|
policy: prepared.policy,
|
|
@@ -6454,7 +7858,6 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
6454
7858
|
const second = await gatedChange(core, change, {
|
|
6455
7859
|
surface: "cli",
|
|
6456
7860
|
approvalId: prepared.approvalId,
|
|
6457
|
-
approveCommand,
|
|
6458
7861
|
platform: options.output.platform
|
|
6459
7862
|
});
|
|
6460
7863
|
if (second.status !== "applied") throw new CommsError("UNEXPECTED", "the approved change asked for approval again");
|
|
@@ -6467,9 +7870,13 @@ async function gatedChangeAtTerminal(core, change, options) {
|
|
|
6467
7870
|
* Shared with a command that reports a waiting change rather than failing on it — `agent-gmail setup`, which stops at
|
|
6468
7871
|
* its registration step and says why in its report — so the two say it in the same words.
|
|
6469
7872
|
*/
|
|
6470
|
-
function approvalHint(prepared, rerun,
|
|
6471
|
-
const run =
|
|
6472
|
-
|
|
7873
|
+
function approvalHint(prepared, rerun, maker) {
|
|
7874
|
+
const run = isCommand(rerun) ? inlineCommand(rerun) : null;
|
|
7875
|
+
const rerunSaid = isCommand(rerun) ? "" : rerun.message;
|
|
7876
|
+
if (prepared.policy !== "confirm") return run === null ? `Show the person the preview. ${rerunSaid}` : `Show the person the preview. Once they say yes, run ${run}.`;
|
|
7877
|
+
const approve = maker.own(["approve", prepared.approvalId]);
|
|
7878
|
+
if (isCommand(approve) && run !== null) return `Show the person the preview. They run ${inlineCommand(approve)}; then run ${run}.`;
|
|
7879
|
+
return `Show the person the preview. ${handoffSentence(approve, (command) => `They run ${command}.`)} ${run === null ? rerunSaid : `Then run ${run}.`}`;
|
|
6473
7880
|
}
|
|
6474
7881
|
/**
|
|
6475
7882
|
* Approves a configuration change at a terminal, the way a person approves a send: read the preview, type the code.
|
|
@@ -6489,11 +7896,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
|
|
|
6489
7896
|
refuseUnlessPerson(env, streams, {
|
|
6490
7897
|
refusedToAgent: "only a person can approve a change, not an agent",
|
|
6491
7898
|
refusedWithoutTerminal: "approving a change needs an interactive terminal",
|
|
6492
|
-
command:
|
|
6493
|
-
"agentcomms",
|
|
6494
|
-
"approve",
|
|
6495
|
-
approvalId
|
|
6496
|
-
], output.platform)),
|
|
7899
|
+
command: requireHandoffs(core, output.platform).own(["approve", approvalId]),
|
|
6497
7900
|
color: output.color,
|
|
6498
7901
|
json: output.json
|
|
6499
7902
|
});
|
|
@@ -6679,11 +8082,17 @@ const KEYCHAIN_SERVICE = "agent-communications";
|
|
|
6679
8082
|
* answer an OS prompt, so it must not hang.
|
|
6680
8083
|
*/
|
|
6681
8084
|
const KEYCHAIN_TIMEOUT_MS = 12e3;
|
|
8085
|
+
/** "Run the doctor": core's, located from whatever is printing, or why there is none here. */
|
|
8086
|
+
function doctorSentence(handoffs, say) {
|
|
8087
|
+
return handoffSentence(requiredHandoffs(handoffs).core(["doctor"]), say, { instead: "Call comms_doctor from a chat." });
|
|
8088
|
+
}
|
|
6682
8089
|
var FileSecretStore = class {
|
|
6683
8090
|
kind = "file";
|
|
6684
8091
|
directory;
|
|
6685
|
-
|
|
8092
|
+
#handoffs;
|
|
8093
|
+
constructor(directory, options = {}) {
|
|
6686
8094
|
this.directory = directory;
|
|
8095
|
+
this.#handoffs = options.handoffs;
|
|
6687
8096
|
}
|
|
6688
8097
|
#path(ref) {
|
|
6689
8098
|
return join(this.directory, `${createHash("sha256").update(ref).digest("hex").slice(0, 32)}.json`);
|
|
@@ -6703,7 +8112,7 @@ var FileSecretStore = class {
|
|
|
6703
8112
|
} catch (error) {
|
|
6704
8113
|
if (error.code === "ENOENT") return null;
|
|
6705
8114
|
throw new CommsError("SECRET_STORE_UNAVAILABLE", "a stored secret file could not be read", {
|
|
6706
|
-
hint:
|
|
8115
|
+
hint: `${doctorSentence(this.#handoffs, (command) => `Run ${command}.`)} Re-authorise the inbox if the file is damaged.`,
|
|
6707
8116
|
cause: error
|
|
6708
8117
|
});
|
|
6709
8118
|
}
|
|
@@ -6738,10 +8147,10 @@ async function loadKeyringModule() {
|
|
|
6738
8147
|
var KeychainTimeout = class extends Error {
|
|
6739
8148
|
name = "KeychainTimeout";
|
|
6740
8149
|
};
|
|
6741
|
-
function keychainError(action, cause, timeoutMs) {
|
|
8150
|
+
function keychainError(action, cause, timeoutMs, handoffs) {
|
|
6742
8151
|
const timedOut = cause instanceof KeychainTimeout;
|
|
6743
8152
|
return new CommsError(timedOut ? "KEYCHAIN_APPROVAL_PENDING" : "SECRET_STORE_UNAVAILABLE", timedOut ? `the system keychain did not answer within ${Math.round(timeoutMs / 1e3)}s while trying to ${action}` : `the system keychain refused to ${action} (it may be locked, or access was denied)`, {
|
|
6744
|
-
hint: timedOut ?
|
|
8153
|
+
hint: timedOut ? `Look for a system dialog asking to allow access to the keychain, then retry. ${doctorSentence(handoffs, (command) => `Run ${command} for details.`)}` : `Unlock the keychain and retry. ${doctorSentence(handoffs, (command) => `Run ${command} for details.`)} On macOS a Node upgrade can require allowing access again.`,
|
|
6745
8154
|
details: { reason: timedOut ? "KEYCHAIN_APPROVAL_PENDING" : "KEYCHAIN_UNAVAILABLE" },
|
|
6746
8155
|
cause
|
|
6747
8156
|
});
|
|
@@ -6766,6 +8175,7 @@ var KeychainSecretStore = class {
|
|
|
6766
8175
|
#module;
|
|
6767
8176
|
#timeoutMs;
|
|
6768
8177
|
#namespace;
|
|
8178
|
+
#handoffs;
|
|
6769
8179
|
/** One keychain call at a time: parallel calls would each raise their own OS prompt and exhaust the thread pool. */
|
|
6770
8180
|
#queue = Promise.resolve();
|
|
6771
8181
|
/**
|
|
@@ -6779,17 +8189,18 @@ var KeychainSecretStore = class {
|
|
|
6779
8189
|
* @param namespace keeps entries of different config directories apart (a test run or a second setup must never
|
|
6780
8190
|
* read or overwrite another's `client:default:secret`); see {@link keychainNamespace}.
|
|
6781
8191
|
*/
|
|
6782
|
-
constructor(module, namespace, timeoutMs = KEYCHAIN_TIMEOUT_MS) {
|
|
8192
|
+
constructor(module, namespace, timeoutMs = KEYCHAIN_TIMEOUT_MS, options = {}) {
|
|
6783
8193
|
this.#module = module;
|
|
6784
8194
|
this.#namespace = namespace;
|
|
6785
8195
|
this.#timeoutMs = timeoutMs;
|
|
8196
|
+
this.#handoffs = options.handoffs;
|
|
6786
8197
|
}
|
|
6787
8198
|
#entry(ref) {
|
|
6788
8199
|
return new this.#module.AsyncEntry(KEYCHAIN_SERVICE, `${this.#namespace}:${ref}`, { linux: { store: "secret-service" } });
|
|
6789
8200
|
}
|
|
6790
8201
|
#serial(action, fn) {
|
|
6791
8202
|
const run = this.#queue.then(async () => {
|
|
6792
|
-
if (this.#stuck) throw keychainError(action, new KeychainTimeout(), this.#timeoutMs);
|
|
8203
|
+
if (this.#stuck) throw keychainError(action, new KeychainTimeout(), this.#timeoutMs, this.#handoffs);
|
|
6793
8204
|
const native = fn();
|
|
6794
8205
|
try {
|
|
6795
8206
|
return await raceTimeout(native, this.#timeoutMs);
|
|
@@ -6801,7 +8212,7 @@ var KeychainSecretStore = class {
|
|
|
6801
8212
|
if (this.#stuck === stuck) this.#stuck = null;
|
|
6802
8213
|
});
|
|
6803
8214
|
}
|
|
6804
|
-
throw keychainError(action, error, this.#timeoutMs);
|
|
8215
|
+
throw keychainError(action, error, this.#timeoutMs, this.#handoffs);
|
|
6805
8216
|
}
|
|
6806
8217
|
});
|
|
6807
8218
|
this.#queue = run.catch(() => void 0);
|
|
@@ -6841,10 +8252,11 @@ function keychainNamespace(configDir) {
|
|
|
6841
8252
|
}
|
|
6842
8253
|
/** Opens the backend recorded in config for an inbox or client. */
|
|
6843
8254
|
async function openSecretStore(kind, options) {
|
|
6844
|
-
|
|
8255
|
+
const told = { handoffs: options.handoffs };
|
|
8256
|
+
if (kind === "file") return new FileSecretStore(options.secretsDir, told);
|
|
6845
8257
|
const keyring = options.keyring === void 0 ? await loadKeyringModule() : options.keyring;
|
|
6846
8258
|
if (!keyring) throw new CommsError("SECRET_STORE_UNAVAILABLE", "secrets are kept in the system keychain, but the keychain module is missing", { hint: "Reinstall with optional dependencies, or re-add the inbox with --store file." });
|
|
6847
|
-
return new KeychainSecretStore(keyring, options.namespace);
|
|
8259
|
+
return new KeychainSecretStore(keyring, options.namespace, KEYCHAIN_TIMEOUT_MS, told);
|
|
6848
8260
|
}
|
|
6849
8261
|
var InboxStateStore = class {
|
|
6850
8262
|
directory;
|
|
@@ -6878,25 +8290,37 @@ var InboxStateStore = class {
|
|
|
6878
8290
|
}
|
|
6879
8291
|
};
|
|
6880
8292
|
function openCore(options = {}) {
|
|
6881
|
-
const paths =
|
|
8293
|
+
const { paths, pathOverrides } = resolvePathIdentity(options);
|
|
6882
8294
|
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
6883
|
-
const
|
|
8295
|
+
const handoffs = options.caller === void 0 ? void 0 : cliHandoffs({
|
|
8296
|
+
caller: options.caller,
|
|
8297
|
+
paths,
|
|
8298
|
+
platform: options.platform,
|
|
8299
|
+
env: options.env
|
|
8300
|
+
});
|
|
8301
|
+
const config = new ConfigStore(paths.configDir, { handoffs });
|
|
6884
8302
|
let cached = null;
|
|
6885
8303
|
return {
|
|
6886
8304
|
paths,
|
|
8305
|
+
pathOverrides,
|
|
6887
8306
|
config,
|
|
6888
8307
|
states: new InboxStateStore(paths.stateDir),
|
|
6889
|
-
approvals: new ApprovalStore(paths.stateDir, {
|
|
8308
|
+
approvals: new ApprovalStore(paths.stateDir, {
|
|
8309
|
+
now,
|
|
8310
|
+
handoffs
|
|
8311
|
+
}),
|
|
6890
8312
|
ledger: new SendLedger(paths.stateDir, now),
|
|
6891
8313
|
plans: new PlanStore(paths.stateDir, now),
|
|
6892
8314
|
taint: new TaintStore(paths.stateDir, now),
|
|
6893
8315
|
audit: new AuditLog(paths.stateDir, now),
|
|
8316
|
+
handoffs,
|
|
6894
8317
|
async secrets(kind) {
|
|
6895
8318
|
const chosen = kind ?? secretsStoreOf(await config.load());
|
|
6896
8319
|
if (cached?.kind === chosen) return cached.store;
|
|
6897
8320
|
const store = await openSecretStore(chosen, {
|
|
6898
8321
|
secretsDir: paths.secretsDir,
|
|
6899
|
-
namespace: keychainNamespace(paths.configDir)
|
|
8322
|
+
namespace: keychainNamespace(paths.configDir),
|
|
8323
|
+
handoffs
|
|
6900
8324
|
});
|
|
6901
8325
|
cached = {
|
|
6902
8326
|
kind: chosen,
|
|
@@ -7095,79 +8519,6 @@ strictObject({
|
|
|
7095
8519
|
appId: string().regex(SLACK_APP_ID_PATTERN, "a Slack app id is A followed by 2–20 capital letters or digits").optional(),
|
|
7096
8520
|
sha256: string().regex(/^[0-9a-f]{64}$/, "a SHA-256 is 64 lowercase hex digits")
|
|
7097
8521
|
});
|
|
7098
|
-
/**
|
|
7099
|
-
* Versions as semver writes and orders them — what an update compares, and what the daily update check's reader needs
|
|
7100
|
-
* to decide whether the release it last heard of is newer than the one running.
|
|
7101
|
-
*
|
|
7102
|
-
* Here rather than in `npm.ts`, which also talks to the registry: the reader of `update-check.json` is imported by
|
|
7103
|
-
* every server, WhatsApp's included, and must carry no network code at all (design 2026-09-28 §1). `npm.ts` re-exports
|
|
7104
|
-
* all of it, so nothing that imported these from there changes.
|
|
7105
|
-
*/
|
|
7106
|
-
/**
|
|
7107
|
-
* A version, exactly as semver writes one: `1.2.3`, `1.2.3-rc.1`, `1.2.3+build`.
|
|
7108
|
-
*
|
|
7109
|
-
* Anything the registry answers is checked against this before it is used, because a version goes into a directory
|
|
7110
|
-
* name (`runtime/<version>-gmail`), a sentence a person approves, and an argument to `npm install`. A registry — or a
|
|
7111
|
-
* mirror somebody configured — that answered `../x` or `1.0.0 && …` would otherwise put that in all three.
|
|
7112
|
-
*/
|
|
7113
|
-
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
7114
|
-
function isVersion(value) {
|
|
7115
|
-
return typeof value === "string" && VERSION_PATTERN.test(value);
|
|
7116
|
-
}
|
|
7117
|
-
/** Two numeric strings with no leading zeros, compared as numbers of any size. */
|
|
7118
|
-
function compareNumeric(a, b) {
|
|
7119
|
-
if (a.length !== b.length) return a.length < b.length ? -1 : 1;
|
|
7120
|
-
return a < b ? -1 : a > b ? 1 : 0;
|
|
7121
|
-
}
|
|
7122
|
-
/**
|
|
7123
|
-
* Semver precedence: -1, 0 or 1, or null when either is not a version.
|
|
7124
|
-
*
|
|
7125
|
-
* Numbers compare as numbers (`0.10.0` is after `0.9.9`), a prerelease comes before its release, and build metadata
|
|
7126
|
-
* counts for nothing. Written out rather than taken from a dependency: it is twenty lines, and the core installs as
|
|
7127
|
-
* few packages as it can.
|
|
7128
|
-
*/
|
|
7129
|
-
function compareVersions(a, b) {
|
|
7130
|
-
const left = VERSION_PATTERN.exec(a);
|
|
7131
|
-
const right = VERSION_PATTERN.exec(b);
|
|
7132
|
-
if (!left || !right) return null;
|
|
7133
|
-
for (const index of [
|
|
7134
|
-
1,
|
|
7135
|
-
2,
|
|
7136
|
-
3
|
|
7137
|
-
]) {
|
|
7138
|
-
const order = compareNumeric(left[index] ?? "0", right[index] ?? "0");
|
|
7139
|
-
if (order !== 0) return order < 0 ? -1 : 1;
|
|
7140
|
-
}
|
|
7141
|
-
const pre = (match) => match[4] === void 0 ? [] : match[4].split(".");
|
|
7142
|
-
const [ours, theirs] = [pre(left), pre(right)];
|
|
7143
|
-
if (ours.length === 0 || theirs.length === 0) return ours.length === theirs.length ? 0 : ours.length === 0 ? 1 : -1;
|
|
7144
|
-
for (let index = 0; index < Math.max(ours.length, theirs.length); index += 1) {
|
|
7145
|
-
const x = ours[index];
|
|
7146
|
-
const y = theirs[index];
|
|
7147
|
-
if (x === void 0) return -1;
|
|
7148
|
-
if (y === void 0) return 1;
|
|
7149
|
-
const [numericX, numericY] = [/^\d+$/.test(x), /^\d+$/.test(y)];
|
|
7150
|
-
if (numericX && numericY) {
|
|
7151
|
-
const order = compareNumeric(x, y);
|
|
7152
|
-
if (order !== 0) return order < 0 ? -1 : 1;
|
|
7153
|
-
} else if (numericX !== numericY) return numericX ? -1 : 1;
|
|
7154
|
-
else if (x !== y) return x < y ? -1 : 1;
|
|
7155
|
-
}
|
|
7156
|
-
return 0;
|
|
7157
|
-
}
|
|
7158
|
-
/** Whether `version` is older than `latest`: by semver when both are versions, and otherwise whenever they differ. */
|
|
7159
|
-
function isBehind(version, latest) {
|
|
7160
|
-
if (version === latest) return false;
|
|
7161
|
-
const order = compareVersions(version, latest);
|
|
7162
|
-
return order === null ? true : order < 0;
|
|
7163
|
-
}
|
|
7164
|
-
/**
|
|
7165
|
-
* Whether a version is a prerelease — `0.8.0-rc.1` — which the daily update check never counts as an update: a
|
|
7166
|
-
* machine is not told to "update" to a release candidate, nor a candidate told to go back to the last release.
|
|
7167
|
-
*/
|
|
7168
|
-
function isPrerelease(version) {
|
|
7169
|
-
return VERSION_PATTERN.exec(version)?.[4] !== void 0;
|
|
7170
|
-
}
|
|
7171
8522
|
const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
|
|
7172
8523
|
/**
|
|
7173
8524
|
* The registry npm itself would read: `npm_config_registry` when it is set — which npm sets for everything it runs,
|
|
@@ -7313,8 +8664,6 @@ async function npmInstallGlobal(env, spec) {
|
|
|
7313
8664
|
throw new Error(`npm install --global ${spec} failed${said ? `: ${said}` : ""}`);
|
|
7314
8665
|
}
|
|
7315
8666
|
}
|
|
7316
|
-
/** The package version, read from package.json at build time. */
|
|
7317
|
-
const VERSION = "0.13.0";
|
|
7318
8667
|
/**
|
|
7319
8668
|
* Registering, pruning and listing the MCP servers of every channel, from the core.
|
|
7320
8669
|
*
|
|
@@ -7515,7 +8864,9 @@ function serverInstallChange(core, env, request, own) {
|
|
|
7515
8864
|
checkPin(config, request);
|
|
7516
8865
|
const product = await productNow();
|
|
7517
8866
|
const { version } = product;
|
|
7518
|
-
const preflight = await preflightInstall(context, product, installOptions(request))
|
|
8867
|
+
const preflight = await preflightInstall(context, product, installOptions(request)).catch(async (error) => {
|
|
8868
|
+
throw await remedyLocated(error, core);
|
|
8869
|
+
});
|
|
7519
8870
|
const { target, previous, effective, kept, command } = preflight;
|
|
7520
8871
|
planned = {
|
|
7521
8872
|
product,
|
|
@@ -7546,7 +8897,9 @@ function serverInstallChange(core, env, request, own) {
|
|
|
7546
8897
|
},
|
|
7547
8898
|
apply: async () => {
|
|
7548
8899
|
if (planned === void 0) throw new CommsError("UNEXPECTED", "the registration was applied before it was planned");
|
|
7549
|
-
const result = await mcpInstall(context, planned.product, installOptions(request), planned.install)
|
|
8900
|
+
const result = await mcpInstall(context, planned.product, installOptions(request), planned.install).catch(async (error) => {
|
|
8901
|
+
throw await remedyLocated(error, core);
|
|
8902
|
+
});
|
|
7550
8903
|
return {
|
|
7551
8904
|
...result,
|
|
7552
8905
|
restart: result.applied ? `Restart ${request.client} to load "${result.name}": no MCP client loads a new server into a session that is already running.` : null
|
|
@@ -7555,6 +8908,23 @@ function serverInstallChange(core, env, request, own) {
|
|
|
7555
8908
|
};
|
|
7556
8909
|
}
|
|
7557
8910
|
/**
|
|
8911
|
+
* A refusal of `mcp install` with its remedy — another install of the same product — said with the command located:
|
|
8912
|
+
* the printing package's own, core's, or another product's found among this machine's registrations (CUE-403). The
|
|
8913
|
+
* installer says only the words; it may not import the locator.
|
|
8914
|
+
*/
|
|
8915
|
+
async function remedyLocated(error, core) {
|
|
8916
|
+
const remedy = installRemedyOf(error);
|
|
8917
|
+
if (remedy === void 0 || !(error instanceof CommsError)) return error;
|
|
8918
|
+
const channel = CHANNELS.find((each) => channelServer(each).binary === remedy.binary);
|
|
8919
|
+
if (channel === void 0) return error;
|
|
8920
|
+
const maker = await requireHandoffs(core).registered();
|
|
8921
|
+
return new CommsError(error.code, error.message, {
|
|
8922
|
+
hint: handoffSentence(maker.of(channel, remedy.words), remedy.say),
|
|
8923
|
+
...error.details === void 0 ? {} : { details: error.details },
|
|
8924
|
+
...error.cause === void 0 ? {} : { cause: error.cause }
|
|
8925
|
+
});
|
|
8926
|
+
}
|
|
8927
|
+
/**
|
|
7558
8928
|
* Removing a channel's unused managed runtimes, as a change.
|
|
7559
8929
|
*
|
|
7560
8930
|
* A dry run is free: it removes nothing. Otherwise the plan is that same dry run, and each runtime it would remove is
|
|
@@ -7572,7 +8942,7 @@ function serverPruneChange(core, env, request, own) {
|
|
|
7572
8942
|
};
|
|
7573
8943
|
const { packageName, version } = ownProduct(request.channel, own) ?? {
|
|
7574
8944
|
packageName: channelServer(request.channel).packageName,
|
|
7575
|
-
version: "0.13.
|
|
8945
|
+
version: "0.13.1"
|
|
7576
8946
|
};
|
|
7577
8947
|
const product = {
|
|
7578
8948
|
packageName,
|
|
@@ -7610,14 +8980,6 @@ function serverPruneChange(core, env, request, own) {
|
|
|
7610
8980
|
})
|
|
7611
8981
|
};
|
|
7612
8982
|
}
|
|
7613
|
-
/** How a registered entry starts its server: the launcher `mcp install` wrote it with, or `other` for one it did not. */
|
|
7614
|
-
function launcherOf(server, facts) {
|
|
7615
|
-
const parts = [server.command, ...server.args];
|
|
7616
|
-
if (parts.some((part) => managedRuntimeVersion(part, facts.packageName) !== null)) return "managed";
|
|
7617
|
-
if (server.args.some((arg) => arg.startsWith(`${facts.npxPackage}@`))) return "npx";
|
|
7618
|
-
if (parts.some((part) => /[/\\]packages[/\\][^/\\]+[/\\](?:src[/\\]cli\.ts|dist[/\\]cli\.mjs)$/.test(part))) return "local";
|
|
7619
|
-
return "other";
|
|
7620
|
-
}
|
|
7621
8983
|
/**
|
|
7622
8984
|
* The daily update check, as every server and command reads it (design 2026-09-28): one file per machine,
|
|
7623
8985
|
* `update-check.json` in the state directory, saying when the registry was last asked, the latest release it named,
|
|
@@ -7821,40 +9183,52 @@ async function pendingUpdate(options) {
|
|
|
7821
9183
|
};
|
|
7822
9184
|
return verdict;
|
|
7823
9185
|
}
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
|
|
7828
|
-
|
|
7829
|
-
|
|
7830
|
-
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
}
|
|
9186
|
+
function updateCommands(core, platform) {
|
|
9187
|
+
const handoffs = requireHandoffs(core, platform);
|
|
9188
|
+
return {
|
|
9189
|
+
update: handoffs.core(["update"]),
|
|
9190
|
+
later: handoffs.core(["update", "--later"])
|
|
9191
|
+
};
|
|
9192
|
+
}
|
|
9193
|
+
/** One of them in a sentence `say` makes: in backticks, or, with no command here, the sentence saying why. */
|
|
9194
|
+
function updateCommandSaid(commands, which, say) {
|
|
9195
|
+
return handoffSentence(commands[which], say);
|
|
9196
|
+
}
|
|
9197
|
+
function updateWaysOf(commands) {
|
|
9198
|
+
return {
|
|
9199
|
+
update: {
|
|
9200
|
+
tool: "comms_update",
|
|
9201
|
+
command: commands.update
|
|
9202
|
+
},
|
|
9203
|
+
later: {
|
|
9204
|
+
tool: "comms_update",
|
|
9205
|
+
arguments: { later: true },
|
|
9206
|
+
command: commands.later
|
|
9207
|
+
}
|
|
9208
|
+
};
|
|
9209
|
+
}
|
|
9210
|
+
/** A channel's product name, from core's snapshot of the manifests: `Gmail`. */
|
|
9211
|
+
function labelOf(channel) {
|
|
9212
|
+
return CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel;
|
|
9213
|
+
}
|
|
7841
9214
|
/**
|
|
7842
9215
|
* What a stopped tool call says, in words an agent passes on: the owner's sentence first, then the versions, then the
|
|
7843
9216
|
* two ways on — the update, from chat or a terminal, or "not now", which the person approves like any other change.
|
|
7844
9217
|
*/
|
|
7845
|
-
function updateStopMessage(pending, where) {
|
|
9218
|
+
function updateStopMessage(pending, where, commands) {
|
|
9219
|
+
const server = where.channel === "core" ? "the core server" : `the ${labelOf(where.channel)} server`;
|
|
7846
9220
|
const didNotRun = `Nothing was done: ${where.tool} did not run.`;
|
|
7847
|
-
const later = `Not now: call comms_update with \`later: true\` — a change the person approves — and nothing stops again until midnight; the next request after it asks again. At a terminal:
|
|
9221
|
+
const later = `Not now: call comms_update with \`later: true\` — a change the person approves — and nothing stops again until midnight; the next request after it asks again. ${updateCommandSaid(commands, "later", (command) => `At a terminal: ${command}.`)}`;
|
|
7848
9222
|
if (pending.kind === "restart") return [
|
|
7849
9223
|
"Hang on a minute, the update is installed, but this server isn't running it yet. Restart the client first.",
|
|
7850
|
-
`This is ${
|
|
9224
|
+
`This is ${server}, ${pending.running}; ${pending.latest} is installed on this machine — every registration of this server names it — and a client starts it only once it is restarted. ${didNotRun}`,
|
|
7851
9225
|
`Ask the person to restart the MCP client — quit it and open it again — and carry on after. ${later}`
|
|
7852
9226
|
].join("\n");
|
|
7853
9227
|
return [
|
|
7854
9228
|
UPDATE_FIRST,
|
|
7855
|
-
`This is ${
|
|
9229
|
+
`This is ${server}, ${pending.running}; the latest release is ${pending.latest}. ${didNotRun}`,
|
|
7856
9230
|
"Ask the person which they want:",
|
|
7857
|
-
`- Update now: call comms_update on the
|
|
9231
|
+
`- Update now: call comms_update on the core server. It shows every step and asks before it changes anything; restart the client after. ${updateCommandSaid(commands, "update", (command) => `At a terminal: ${command}.`)} A server comms_update does not find registered here — a plugin's, an extension's — is updated where it was installed.`,
|
|
7858
9232
|
`- ${later}`
|
|
7859
9233
|
].join("\n");
|
|
7860
9234
|
}
|
|
@@ -7919,15 +9293,15 @@ async function latestReleases(names, latestVersion) {
|
|
|
7919
9293
|
return Object.fromEntries(answers.map((answer) => [answer.name, answer.version ?? ""]));
|
|
7920
9294
|
}
|
|
7921
9295
|
/**
|
|
7922
|
-
* How to register an entry again by hand: its channel's own `mcp install`, with every flag that decides its reach
|
|
9296
|
+
* How to register an entry again by hand: its channel's own `mcp install`, with every flag that decides its reach —
|
|
9297
|
+
* located as that channel's command among this machine's registrations, or why it is not locatable here (CUE-403).
|
|
7923
9298
|
*
|
|
7924
9299
|
* The entry's name and pins are read from the client's file, which may hold anything, so on Windows the command may
|
|
7925
|
-
* have no line to paste: the reasons below give it with `
|
|
9300
|
+
* have no line to paste: the reasons below give it with `handoffSentence`, which shows it as words then.
|
|
7926
9301
|
*/
|
|
7927
|
-
function
|
|
9302
|
+
function installHandoff(item, handoffs) {
|
|
7928
9303
|
const facts = channelServer(item.channel);
|
|
7929
9304
|
const words = [
|
|
7930
|
-
facts.binary,
|
|
7931
9305
|
"mcp",
|
|
7932
9306
|
"install",
|
|
7933
9307
|
"--client",
|
|
@@ -7935,7 +9309,7 @@ function installCommand(item, platform) {
|
|
|
7935
9309
|
];
|
|
7936
9310
|
if (item.name !== facts.defaultServerName) words.push("--name", item.name);
|
|
7937
9311
|
words.push(...item.narrowing, "--force");
|
|
7938
|
-
return
|
|
9312
|
+
return handoffs.of(item.channel, words);
|
|
7939
9313
|
}
|
|
7940
9314
|
/**
|
|
7941
9315
|
* Why a registration that is behind cannot be registered again from here, or undefined when it can.
|
|
@@ -7945,14 +9319,14 @@ function installCommand(item, platform) {
|
|
|
7945
9319
|
* whose CLI is not here are each left for a person, with what to run. So is one pinned to an account that has since
|
|
7946
9320
|
* been renamed or removed: registering it again would register a server that is refused when it starts.
|
|
7947
9321
|
*/
|
|
7948
|
-
async function whyNotUpdatable(context, config, item, narrowing) {
|
|
7949
|
-
if (item.launcher !== "managed" && item.launcher !== "npx") return `it was not written by \`mcp install\`, so how it starts cannot be carried over; register it again with ${
|
|
9322
|
+
async function whyNotUpdatable(context, config, item, narrowing, handoffs) {
|
|
9323
|
+
if (item.launcher !== "managed" && item.launcher !== "npx") return `it was not written by \`mcp install\`, so how it starts cannot be carried over; ${handoffSentence(installHandoff(item, handoffs), (command) => `register it again with ${command}`)}`;
|
|
7950
9324
|
if (item.scope !== "user") return `it is registered for one project (in ${item.path}), and \`mcp install\` registers at user scope only; register it again from that project with ${item.client}'s own command`;
|
|
7951
9325
|
const target = await installTarget(context, {
|
|
7952
9326
|
client: item.client,
|
|
7953
9327
|
apply: true
|
|
7954
9328
|
});
|
|
7955
|
-
if (!target.writes) return target.cliName ? `\`${target.cliName}\` is not ${clientCliSearch(context.env)}, so the entry cannot be replaced from here;
|
|
9329
|
+
if (!target.writes) return target.cliName ? `\`${target.cliName}\` is not ${clientCliSearch(context.env)}, so the entry cannot be replaced from here; ${handoffSentence(installHandoff(item, handoffs), (command) => `run ${command} where it is`)}` : `this environment names no ${item.client} configuration to write to`;
|
|
7956
9330
|
const own = pinOption(item.channel);
|
|
7957
9331
|
const pin = own === void 0 || own === "readOnly" ? void 0 : narrowing[own];
|
|
7958
9332
|
if (pin !== void 0) {
|
|
@@ -8000,6 +9374,7 @@ async function inspect(core, env, deps) {
|
|
|
8000
9374
|
platform: deps.platform
|
|
8001
9375
|
};
|
|
8002
9376
|
const scan = await scanRegisteredServers(env);
|
|
9377
|
+
const handoffs = requireHandoffs(core, deps.platform).withRegistrations(scan.servers);
|
|
8003
9378
|
const unreadable = [...scan.unreadable];
|
|
8004
9379
|
const found = registrations(scan.servers).map(({ channel, server }) => {
|
|
8005
9380
|
const facts = channelServer(channel);
|
|
@@ -8007,7 +9382,7 @@ async function inspect(core, env, deps) {
|
|
|
8007
9382
|
channel,
|
|
8008
9383
|
server,
|
|
8009
9384
|
narrowing: facts.narrowingOf(server.args),
|
|
8010
|
-
version: server
|
|
9385
|
+
version: registrationVersion(server, facts),
|
|
8011
9386
|
package: server.args.some((arg) => arg.startsWith(`${facts.npxPackage}@`)) ? facts.npxPackage : facts.packageName
|
|
8012
9387
|
};
|
|
8013
9388
|
});
|
|
@@ -8063,7 +9438,7 @@ async function inspect(core, env, deps) {
|
|
|
8063
9438
|
upToDate.push(item);
|
|
8064
9439
|
continue;
|
|
8065
9440
|
}
|
|
8066
|
-
const reason = await whyNotUpdatable(context, config, item, entry.narrowing);
|
|
9441
|
+
const reason = await whyNotUpdatable(context, config, item, entry.narrowing, handoffs);
|
|
8067
9442
|
const listed = {
|
|
8068
9443
|
...item,
|
|
8069
9444
|
updatable: reason === void 0,
|
|
@@ -8116,6 +9491,7 @@ async function inspect(core, env, deps) {
|
|
|
8116
9491
|
unpinned,
|
|
8117
9492
|
unreadable
|
|
8118
9493
|
},
|
|
9494
|
+
handoffs,
|
|
8119
9495
|
candidates,
|
|
8120
9496
|
manual,
|
|
8121
9497
|
globals
|
|
@@ -8274,7 +9650,7 @@ function counted(count, one, many) {
|
|
|
8274
9650
|
* has. When it is not — the client itself reports the entry otherwise than its file does, say — registering again
|
|
8275
9651
|
* would change what the server may reach, so the entry is left for a person rather than widened or narrowed.
|
|
8276
9652
|
*/
|
|
8277
|
-
async function planRegistrations(context, candidates, request) {
|
|
9653
|
+
async function planRegistrations(context, candidates, request, handoffs) {
|
|
8278
9654
|
const steps = [];
|
|
8279
9655
|
const manual = [];
|
|
8280
9656
|
const leave = (item, reason) => manual.push({
|
|
@@ -8306,7 +9682,7 @@ async function planRegistrations(context, candidates, request) {
|
|
|
8306
9682
|
}
|
|
8307
9683
|
const keeps = narrowingArgs(item.channel, preflight.effective);
|
|
8308
9684
|
if (JSON.stringify(keeps) !== JSON.stringify(item.narrowing)) {
|
|
8309
|
-
leave(item, `registering it again would not keep exactly ${item.narrowing.length > 0 ? item.narrowing.join(" ") : "no pin"}: ${item.client} itself has the entry as ${keeps.length > 0 ? keeps.join(" ") : "not pinned"}. Register it again yourself with the pins it should have: ${
|
|
9685
|
+
leave(item, `registering it again would not keep exactly ${item.narrowing.length > 0 ? item.narrowing.join(" ") : "no pin"}: ${item.client} itself has the entry as ${keeps.length > 0 ? keeps.join(" ") : "not pinned"}. ${handoffSentence(installHandoff(item, handoffs), (command) => `Register it again yourself with the pins it should have: ${command}`)}`);
|
|
8310
9686
|
continue;
|
|
8311
9687
|
}
|
|
8312
9688
|
steps.push({
|
|
@@ -8338,7 +9714,7 @@ function updateChange(core, env, request = {}, deps = {}) {
|
|
|
8338
9714
|
return {
|
|
8339
9715
|
plan: async (config) => {
|
|
8340
9716
|
const inspection = await inspect(core, env, deps);
|
|
8341
|
-
const { steps, manual } = await planRegistrations(context, inspection.candidates, request);
|
|
9717
|
+
const { steps, manual } = await planRegistrations(context, inspection.candidates, request, inspection.handoffs);
|
|
8342
9718
|
const runtimes = await runtimesNeeded(core.paths.dataDir, inspection.report.latest, steps.map((step) => step.item));
|
|
8343
9719
|
planned = {
|
|
8344
9720
|
report: inspection.report,
|
|
@@ -8346,6 +9722,7 @@ function updateChange(core, env, request = {}, deps = {}) {
|
|
|
8346
9722
|
runtimes,
|
|
8347
9723
|
registrations: steps,
|
|
8348
9724
|
globals: inspection.globals,
|
|
9725
|
+
handoffs: inspection.handoffs,
|
|
8349
9726
|
manual: [...inspection.manual, ...manual],
|
|
8350
9727
|
nothingBehind: inspection.report.behind.length === 0
|
|
8351
9728
|
};
|
|
@@ -8390,8 +9767,9 @@ function updateChange(core, env, request = {}, deps = {}) {
|
|
|
8390
9767
|
* id that is not one is said to be that.
|
|
8391
9768
|
*/
|
|
8392
9769
|
async function nothingToApply(core, approvalId, planned) {
|
|
9770
|
+
const check = (say) => handoffSentence(planned.handoffs.core(["update", "--check"]), say);
|
|
8393
9771
|
if (!APPROVAL_ID_PATTERN.test(approvalId)) return new CommsError("USAGE", `nothing was changed: "${approvalId}" is not an approval id`, {
|
|
8394
|
-
hint:
|
|
9772
|
+
hint: check((command) => `An approval id is the \`approvalId\` a call that needs approval returns, and nothing here needs one: a check — comms_update with \`check\`, or ${command} at a terminal — shows what is behind.`),
|
|
8395
9773
|
details: { approvalId }
|
|
8396
9774
|
});
|
|
8397
9775
|
const record = await core.approvals.get(approvalId);
|
|
@@ -8401,11 +9779,11 @@ async function nothingToApply(core, approvalId, planned) {
|
|
|
8401
9779
|
...record ? { state: record.state } : {}
|
|
8402
9780
|
};
|
|
8403
9781
|
if (planned.nothingBehind) return new CommsError("USAGE", `nothing was changed: nothing is behind the latest release, ${unused}`, {
|
|
8404
|
-
hint:
|
|
9782
|
+
hint: check((command) => `Nothing needs doing: a check — comms_update with \`check\`, or ${command} at a terminal — shows everything here up to date. A release published later is another update, with a preview and an approval of its own.`),
|
|
8405
9783
|
details
|
|
8406
9784
|
});
|
|
8407
9785
|
return new CommsError("USAGE", `nothing was changed: what is behind cannot be updated from here, ${unused}`, {
|
|
8408
|
-
hint:
|
|
9786
|
+
hint: check((command) => `Each is left for a person: a check — comms_update with \`check\`, or ${command} at a terminal — lists them, with why and what to run. The same call without the approval reports them too, and changes nothing.`),
|
|
8409
9787
|
details
|
|
8410
9788
|
});
|
|
8411
9789
|
}
|
|
@@ -8435,8 +9813,13 @@ function runtimeProduct(packageName, version) {
|
|
|
8435
9813
|
if (channel === void 0) throw new CommsError("UNEXPECTED", `${packageName} is not a channel's package`);
|
|
8436
9814
|
return channelProduct(channel, "managed", version);
|
|
8437
9815
|
}
|
|
8438
|
-
/**
|
|
8439
|
-
|
|
9816
|
+
/**
|
|
9817
|
+
* Every channel's `mcp prune`, as a person runs it at a terminal: each located among this machine's registrations, and
|
|
9818
|
+
* for one that is not, why — never a bare name (CUE-403).
|
|
9819
|
+
*/
|
|
9820
|
+
function pruneCommands(handoffs) {
|
|
9821
|
+
return handoffChoices(CHANNELS.map((channel) => handoffs.of(channel, ["mcp", "prune"])), "none of their commands is locatable here:", { conjunction: "and" });
|
|
9822
|
+
}
|
|
8440
9823
|
function message(error) {
|
|
8441
9824
|
return error instanceof CommsError ? error.message : error instanceof Error ? error.message : String(error);
|
|
8442
9825
|
}
|
|
@@ -8546,7 +9929,7 @@ async function applyUpdate(context, planned, deps) {
|
|
|
8546
9929
|
const ok = steps.every(worked);
|
|
8547
9930
|
const status = steps.length === 0 ? planned.nothingBehind ? "up-to-date" : "manual" : ok ? "updated" : "failed";
|
|
8548
9931
|
const clients = [...new Set(steps.flatMap((step) => step.kind === "registration" && step.outcome === "registered" ? [step.client] : []))];
|
|
8549
|
-
const next = clients.length > 0 ? `Restart ${clients.length === 1 ? clients[0] : `${clients.slice(0, -1).join(", ")} and ${clients.at(-1)}`} to load the new servers: no MCP client loads a new server into a session that is already running. Then, from the restarted server, prune the runtimes the old versions leave behind: comms_server_prune for each channel — at a terminal, ${
|
|
9932
|
+
const next = clients.length > 0 ? `Restart ${clients.length === 1 ? clients[0] : `${clients.slice(0, -1).join(", ")} and ${clients.at(-1)}`} to load the new servers: no MCP client loads a new server into a session that is already running. Then, from the restarted server, prune the runtimes the old versions leave behind: comms_server_prune for each channel — at a terminal, ${pruneCommands(planned.handoffs)}.` : null;
|
|
8550
9933
|
return {
|
|
8551
9934
|
status,
|
|
8552
9935
|
latest: planned.latest,
|
|
@@ -8664,8 +10047,9 @@ function updateToolGate(options) {
|
|
|
8664
10047
|
if (await claimsApproval(options.core, args[claim?.argument ?? "approvalId"], claim)) return null;
|
|
8665
10048
|
return stoppedCall(pending, {
|
|
8666
10049
|
server: options.server,
|
|
10050
|
+
channel: options.channel,
|
|
8667
10051
|
tool
|
|
8668
|
-
});
|
|
10052
|
+
}, updateCommands(options.core));
|
|
8669
10053
|
};
|
|
8670
10054
|
}
|
|
8671
10055
|
/**
|
|
@@ -8673,15 +10057,17 @@ function updateToolGate(options) {
|
|
|
8673
10057
|
* a client that shows the model only text blocks (Cursor) reads it there. Claude Code and Codex show only
|
|
8674
10058
|
* `structuredContent`, so the same words are its `message`, with the versions and the two ways on beside them.
|
|
8675
10059
|
*/
|
|
8676
|
-
function stoppedCall(pending, where) {
|
|
8677
|
-
const message = updateStopMessage(pending, where);
|
|
10060
|
+
function stoppedCall(pending, where, commands) {
|
|
10061
|
+
const message = updateStopMessage(pending, where, commands);
|
|
10062
|
+
const ways = updateWaysOf(commands);
|
|
10063
|
+
const leaving = (value) => JSON.parse(JSON.stringify(value));
|
|
8678
10064
|
return {
|
|
8679
10065
|
isError: true,
|
|
8680
10066
|
content: [{
|
|
8681
10067
|
type: "text",
|
|
8682
10068
|
text: message
|
|
8683
10069
|
}],
|
|
8684
|
-
structuredContent: { error: {
|
|
10070
|
+
structuredContent: leaving({ error: {
|
|
8685
10071
|
code: "UPDATE_REQUIRED",
|
|
8686
10072
|
message,
|
|
8687
10073
|
hint: pending.kind === "restart" ? "Restart the MCP client, or put the stop off until midnight with comms_update `later: true` once the person agrees." : "Update with comms_update (the person approves its preview), or put it off until midnight with comms_update `later: true` once the person agrees.",
|
|
@@ -8691,10 +10077,10 @@ function stoppedCall(pending, where) {
|
|
|
8691
10077
|
running: pending.running,
|
|
8692
10078
|
latest: pending.latest,
|
|
8693
10079
|
installed: pending.kind === "restart",
|
|
8694
|
-
update:
|
|
8695
|
-
later:
|
|
10080
|
+
update: ways.update,
|
|
10081
|
+
later: ways.later
|
|
8696
10082
|
}
|
|
8697
|
-
} }
|
|
10083
|
+
} })
|
|
8698
10084
|
};
|
|
8699
10085
|
}
|
|
8700
10086
|
/** The commands never stopped, by their first word; `mcp` alone runs a server, which stops each call itself. */
|
|
@@ -8790,19 +10176,21 @@ async function updateGateAtTerminal(options) {
|
|
|
8790
10176
|
...where
|
|
8791
10177
|
});
|
|
8792
10178
|
if (pending === null) return null;
|
|
8793
|
-
|
|
10179
|
+
const person = agentMarker(env) === null && canPrompt(env, streams, {
|
|
8794
10180
|
json: output.json,
|
|
8795
10181
|
noInput: options.noInput === true
|
|
8796
|
-
})
|
|
10182
|
+
});
|
|
10183
|
+
const commands = updateCommands(core, output.platform);
|
|
10184
|
+
if (!person) throw updateRequired(pending, cliOf(options.channel), commands);
|
|
8797
10185
|
const bold = (word) => paint(output.color, "bold", word);
|
|
8798
10186
|
const answer = (await askLine(streams, pending.kind === "restart" ? `${pending.latest} is installed (this is ${pending.running}). Switch ${bold("now")}, ${bold("later")} today, or ${bold("cancel")}? ` : `${pending.latest} is out (you have ${pending.running}). Update ${bold("now")}, ${bold("later")} today, or ${bold("cancel")}? `)).trim().toLowerCase();
|
|
8799
10187
|
if (answer === "now" || answer === "update") {
|
|
8800
10188
|
if (pending.kind === "restart") {
|
|
8801
|
-
streams.stdout.write(`${pending.latest} is installed globally; this ${options.
|
|
10189
|
+
streams.stdout.write(`${pending.latest} is installed globally; this ${cliOf(options.channel)} is ${pending.running}, started from somewhere else — npx's cache, a project's own install, a checkout. Run your command again from the installed one. The command did not run.\n`);
|
|
8802
10190
|
return EXIT_CODES.UPDATE;
|
|
8803
10191
|
}
|
|
8804
10192
|
if (!options.update) {
|
|
8805
|
-
streams.stdout.write(
|
|
10193
|
+
streams.stdout.write(`This ${cliOf(options.channel)} reads only this machine and cannot fetch the update itself. ${updateCommandSaid(commands, "update", (command) => `Run ${command}, then run your command again.`)}\n`);
|
|
8806
10194
|
return EXIT_CODES.UPDATE;
|
|
8807
10195
|
}
|
|
8808
10196
|
return afterUpdate(await options.update(), options, pending);
|
|
@@ -8811,39 +10199,50 @@ async function updateGateAtTerminal(options) {
|
|
|
8811
10199
|
const result = await gatedChangeAtTerminal(core, updateLaterChange(core, { now }), {
|
|
8812
10200
|
env,
|
|
8813
10201
|
output,
|
|
8814
|
-
|
|
8815
|
-
|
|
10202
|
+
rerun: ["update", "--later"],
|
|
10203
|
+
rerunOn: "core",
|
|
8816
10204
|
answered: true,
|
|
8817
10205
|
streams
|
|
8818
10206
|
});
|
|
8819
10207
|
streams.stderr.write(`Put off until midnight${result.latest ? `: ${result.latest} will be asked about again tomorrow` : ""}.\n`);
|
|
8820
10208
|
return null;
|
|
8821
10209
|
}
|
|
8822
|
-
throw new CommsError("USAGE",
|
|
10210
|
+
throw new CommsError("USAGE", "cancelled: the command did not run, and nothing was changed", { hint: bothSaid(commands, (update, later) => `Update with ${update}, or put it off until tomorrow with ${later}.`) });
|
|
10211
|
+
}
|
|
10212
|
+
/**
|
|
10213
|
+
* The CLI a gate stops, in product words — "Gmail CLI", "core CLI" — for its sentences: never its binary, which is no
|
|
10214
|
+
* command most people can run, and which a sentence would read as one (CUE-403).
|
|
10215
|
+
*/
|
|
10216
|
+
function cliOf(channel) {
|
|
10217
|
+
if (channel === "core") return "core CLI";
|
|
10218
|
+
return `${CHANNEL_SNAPSHOT.find((entry) => entry.manifest.channel === channel)?.manifest.label ?? channel} CLI`;
|
|
8823
10219
|
}
|
|
8824
10220
|
/**
|
|
8825
10221
|
* The stop where nobody can be asked: the command does not run. "Update" names the update and "not now"; "restart"
|
|
8826
10222
|
* — the update installed, and an older copy running — says to run the command from the installed one.
|
|
8827
10223
|
*/
|
|
8828
|
-
function updateRequired(pending,
|
|
8829
|
-
const
|
|
8830
|
-
const npx = `Where agentcomms is not installed: \`${UPDATE_WAYS.update.npx}\`, and \`${UPDATE_WAYS.later.npx}\`.`;
|
|
10224
|
+
function updateRequired(pending, cli, commands) {
|
|
10225
|
+
const ways = updateWaysOf(commands);
|
|
8831
10226
|
const details = {
|
|
8832
10227
|
running: pending.running,
|
|
8833
10228
|
latest: pending.latest,
|
|
8834
10229
|
installed: pending.kind === "restart",
|
|
8835
|
-
update:
|
|
8836
|
-
later:
|
|
10230
|
+
update: ways.update,
|
|
10231
|
+
later: ways.later
|
|
8837
10232
|
};
|
|
8838
|
-
if (pending.kind === "restart") return new CommsError("UPDATE_REQUIRED", `Hang on a minute, the update is installed, but this command isn't running it yet. ${pending.latest} is installed globally, and this ${
|
|
8839
|
-
hint: `Nothing was done. An older copy is running — npx's cache, a project's own install, a checkout. "Not now" is a change a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees
|
|
10233
|
+
if (pending.kind === "restart") return new CommsError("UPDATE_REQUIRED", `Hang on a minute, the update is installed, but this command isn't running it yet. ${pending.latest} is installed globally, and this ${cli} is ${pending.running}: ${handoffSentence(commands.later, (later) => `run the command again from the installed one, or ${later} to put it off until tomorrow`)}`, {
|
|
10234
|
+
hint: `Nothing was done. An older copy is running — npx's cache, a project's own install, a checkout. "Not now" is a change a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees.`,
|
|
8840
10235
|
details
|
|
8841
10236
|
});
|
|
8842
|
-
return new CommsError("UPDATE_REQUIRED", `${UPDATE_FIRST} ${pending.latest} is out (you have ${pending.running}): run
|
|
8843
|
-
hint: `Nothing was done. Both are changes a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees
|
|
10237
|
+
return new CommsError("UPDATE_REQUIRED", `${UPDATE_FIRST} ${pending.latest} is out (you have ${pending.running}): ${bothSaid(commands, (update, later) => `run ${update} first, or ${later} to put it off until tomorrow`)}`, {
|
|
10238
|
+
hint: `Nothing was done. Both are changes a person approves: an agent gets the preview and an approval id (exit 10), and runs the same command again with --approval <id> once the person agrees.`,
|
|
8844
10239
|
details
|
|
8845
10240
|
});
|
|
8846
10241
|
}
|
|
10242
|
+
/** The update and "not now" in one sentence; with no command here — the same core gives both or neither — why not. */
|
|
10243
|
+
function bothSaid(commands, say) {
|
|
10244
|
+
return handoffSentence(commands.update, (update) => handoffSentence(commands.later, (later) => say(update, later)));
|
|
10245
|
+
}
|
|
8847
10246
|
/**
|
|
8848
10247
|
* What the terminal says after the person's "now", and the exit status. "Updated. Run your command again." only when
|
|
8849
10248
|
* the update did everything and this command's release is now installed where it runs from — its global package at
|
|
@@ -8851,8 +10250,10 @@ function updateRequired(pending, binary) {
|
|
|
8851
10250
|
* script that goes on after `cmd && next` must not take it for the command's success.
|
|
8852
10251
|
*/
|
|
8853
10252
|
async function afterUpdate(outcome, options, before) {
|
|
8854
|
-
const { streams
|
|
8855
|
-
const
|
|
10253
|
+
const { streams } = options;
|
|
10254
|
+
const cli = cliOf(options.channel);
|
|
10255
|
+
const notRun = "The command did not run";
|
|
10256
|
+
const commands = updateCommands(options.core, options.output.platform);
|
|
8856
10257
|
if (outcome === "failed") {
|
|
8857
10258
|
streams.stdout.write(`${notRun}: the update did not finish — each step says how it went, above.\n`);
|
|
8858
10259
|
return EXIT_CODES.UNAVAILABLE;
|
|
@@ -8866,10 +10267,10 @@ async function afterUpdate(outcome, options, before) {
|
|
|
8866
10267
|
streams.stdout.write("Updated. Run your command again.\n");
|
|
8867
10268
|
return EXIT_CODES.OK;
|
|
8868
10269
|
}
|
|
8869
|
-
streams.stdout.write(`${before.latest} is installed here: run your command again from it.
|
|
10270
|
+
streams.stdout.write(`${before.latest} is installed here: run your command again from it. It did not run.\n`);
|
|
8870
10271
|
return EXIT_CODES.UPDATE;
|
|
8871
10272
|
}
|
|
8872
|
-
streams.stdout.write(`${notRun}: the update did not bring it to ${before.latest}.
|
|
10273
|
+
streams.stdout.write(`${notRun}: the update did not bring it to ${before.latest}. The update moves what this machine registers and has installed globally — what it found is said above — and this ${cli}, ${before.running}, is started from somewhere else: npx's cache, a project's own install, a checkout. ${handoffSentence(commands.later, (later) => `Run it from ${before.latest}, or put this off until tomorrow with ${later}.`)}\n`);
|
|
8873
10274
|
return EXIT_CODES.UPDATE;
|
|
8874
10275
|
}
|
|
8875
10276
|
async function askLine(streams, question) {
|
|
@@ -9143,18 +10544,20 @@ async function handUpdateCheckToChild(core, env, options) {
|
|
|
9143
10544
|
const claimedAt = await claimUpdateCheck(core, { now: options.now });
|
|
9144
10545
|
if (claimedAt === null) return;
|
|
9145
10546
|
const entry = options.entry === void 0 ? updateCheckChildEntry() : options.entry;
|
|
9146
|
-
if (!(entry !== null && await startUpdateCheckChild(entry, claimedAt, env, deadline))) await askUnderClaim(core, env, claimedAt, { now: options.now });
|
|
10547
|
+
if (!(entry !== null && await startUpdateCheckChild(entry, claimedAt, core.paths, env, deadline))) await askUnderClaim(core, env, claimedAt, { now: options.now });
|
|
9147
10548
|
} catch {}
|
|
9148
10549
|
}
|
|
9149
10550
|
/**
|
|
9150
10551
|
* Starts the child and waits, until `deadline` at most, for it to say the ask is over, or to end. False when it could
|
|
9151
10552
|
* not be started at all; then nothing was asked, and the claim is still this process's to ask under.
|
|
9152
10553
|
*/
|
|
9153
|
-
async function startUpdateCheckChild(entry, claimedAt, env, deadline) {
|
|
10554
|
+
async function startUpdateCheckChild(entry, claimedAt, paths, env, deadline) {
|
|
9154
10555
|
let child;
|
|
9155
10556
|
try {
|
|
10557
|
+
const pathArgs = PATH_OPTIONS.flatMap(({ key, flag }) => [flag, paths[key]]);
|
|
9156
10558
|
child = spawn(entry.command, [
|
|
9157
10559
|
...entry.args,
|
|
10560
|
+
...pathArgs,
|
|
9158
10561
|
UPDATE_CHECK_CHILD_COMMAND,
|
|
9159
10562
|
claimedAt
|
|
9160
10563
|
], {
|
|
@@ -9199,7 +10602,7 @@ async function startUpdateCheckChild(entry, claimedAt, env, deadline) {
|
|
|
9199
10602
|
* already gone on, and the ask is finished all the same. Results are written only while the claim is this child's.
|
|
9200
10603
|
*/
|
|
9201
10604
|
async function runUpdateCheckChild(core, env, claimedAt) {
|
|
9202
|
-
if (claimedAt === void 0 || !Number.isFinite(Date.parse(claimedAt))) throw new CommsError("USAGE", `${UPDATE_CHECK_CHILD_COMMAND} takes the time of the claim it asks under`, { hint: "Nothing runs this but a command finishing the day’s update check;
|
|
10605
|
+
if (claimedAt === void 0 || !Number.isFinite(Date.parse(claimedAt))) throw new CommsError("USAGE", `${UPDATE_CHECK_CHILD_COMMAND} takes the time of the claim it asks under`, { hint: handoffSentence(requireHandoffs(core).core(["update", "--check"]), (command) => `Nothing runs this but a command finishing the day’s update check; ${command} asks now.`) });
|
|
9203
10606
|
try {
|
|
9204
10607
|
if ((await readUpdateCheck(core.paths.stateDir)).checking === claimedAt) await askUnderClaim(core, env, claimedAt);
|
|
9205
10608
|
} finally {
|
|
@@ -9238,8 +10641,8 @@ function terminalUpdateHooks(core, env, options) {
|
|
|
9238
10641
|
const result = await gatedChangeAtTerminal(core, updateChange(core, env, {}, options.deps), {
|
|
9239
10642
|
env,
|
|
9240
10643
|
output: options.output,
|
|
9241
|
-
|
|
9242
|
-
|
|
10644
|
+
rerun: ["update"],
|
|
10645
|
+
rerunOn: "core",
|
|
9243
10646
|
streams: options.streams
|
|
9244
10647
|
});
|
|
9245
10648
|
writeResult(result, options.output, (r) => renderUpdate(r, options.output.color), options.streams);
|
|
@@ -9249,7 +10652,7 @@ function terminalUpdateHooks(core, env, options) {
|
|
|
9249
10652
|
};
|
|
9250
10653
|
}
|
|
9251
10654
|
//#endregion
|
|
9252
|
-
//#region ../core/dist/tool-arguments-
|
|
10655
|
+
//#region ../core/dist/tool-arguments-BcYu_EPp.mjs
|
|
9253
10656
|
const APPLIED = Symbol.for("agentcomms.strictToolArguments");
|
|
9254
10657
|
const TARGET = "draft-2020-12";
|
|
9255
10658
|
/**
|
|
@@ -19009,7 +20412,7 @@ strictObject({
|
|
|
19009
20412
|
}))) !== JSON.stringify(narrowing)) issue(["narrowing"], kept ? `${kept.label}'s server is pinned by ${narrowingWords(narrowing)}, as its entries have always been written` : `\`inbox\`, \`--read-only\` and \`workspace\` are Gmail's and Slack's; a channel after them is pinned by ${narrowingWords(narrowing)} and nothing else`);
|
|
19010
20413
|
}
|
|
19011
20414
|
if (isCore) {
|
|
19012
|
-
if (manifest.binary !== CORE_BINARY) issue(["binary"],
|
|
20415
|
+
if (manifest.binary !== CORE_BINARY) issue(["binary"], "is not the core's own command name");
|
|
19013
20416
|
} else if (!CHANNEL_BINARY.test(manifest.binary)) issue(["binary"], "a channel's command is `agent-<something>`");
|
|
19014
20417
|
(manifest.server.bins ?? []).forEach((bin, index) => {
|
|
19015
20418
|
if (!CHANNEL_BINARY.test(bin)) issue([
|
|
@@ -19033,7 +20436,7 @@ strictObject({
|
|
|
19033
20436
|
if (rivals.word === void 0 && rivals.packages === void 0) issue(["rivals"], "a word or packages");
|
|
19034
20437
|
if (rivals.word === void 0 !== (rivals.can === void 0)) issue(["rivals"], "`word` and `can` come together: what a server matching the word can do unapproved");
|
|
19035
20438
|
}
|
|
19036
|
-
if (manifest.approve !== void 0 && !manifest.approve.startsWith(`${manifest.binary} `)) issue(["approve"],
|
|
20439
|
+
if (manifest.approve !== void 0 && !manifest.approve.startsWith(`${manifest.binary} `)) issue(["approve"], "must start with the channel's own command, its `binary`, and a space");
|
|
19037
20440
|
if (manifest.skills) {
|
|
19038
20441
|
const family = manifest.skills.prefix.slice(0, -1);
|
|
19039
20442
|
if (manifest.skills.contract !== `skills/_shared/contract-${family}.md`) issue(["skills", "contract"], `skills/_shared/contract-${family}.md: a skill's contract is chosen by its prefix`);
|
|
@@ -20349,6 +21752,9 @@ function hiddenReason(element) {
|
|
|
20349
21752
|
* What stays here is what core cannot say about a Resend account: that its mode is one of the two words (a record
|
|
20350
21753
|
* with any other is refused, never read as something narrower), what its key can do, and that its secret reference
|
|
20351
21754
|
* is its own.
|
|
21755
|
+
*
|
|
21756
|
+
* A refusal here names the command that repairs it, made by `handoffs` — the context's, Resend's own CLI as this
|
|
21757
|
+
* installation runs it (CUE-403) — or says why there is none here.
|
|
20352
21758
|
*/
|
|
20353
21759
|
const PLATFORM = "resend";
|
|
20354
21760
|
const MODES = ["read", "send"];
|
|
@@ -20376,7 +21782,7 @@ function keyPermissionOf(account) {
|
|
|
20376
21782
|
* wrong. Core's schema reads any mode word so that one odd account cannot make the whole file unreadable; acting on
|
|
20377
21783
|
* one is refused here, as Slack refuses its own.
|
|
20378
21784
|
*/
|
|
20379
|
-
function checkedAccount(name, account,
|
|
21785
|
+
function checkedAccount(name, account, handoffs) {
|
|
20380
21786
|
const problem = (() => {
|
|
20381
21787
|
if (account.platform !== "resend") return `it is a ${account.platform} account`;
|
|
20382
21788
|
if (!MODES.includes(String(account.mode))) return `its mode is "${String(account.mode)}", and a Resend account is in read or send mode`;
|
|
@@ -20386,67 +21792,65 @@ function checkedAccount(name, account, platform = process.platform) {
|
|
|
20386
21792
|
if (lock !== void 0 && (typeof lock !== "string" || !DOMAIN.test(lock))) return "its domainLock is not a domain";
|
|
20387
21793
|
return null;
|
|
20388
21794
|
})();
|
|
20389
|
-
if (problem !== null) throw new CommsError("CONFIG", `"${name}" is not a Resend account this release can act on: ${problem}`, { hint:
|
|
20390
|
-
"agent-resend",
|
|
21795
|
+
if (problem !== null) throw new CommsError("CONFIG", `"${name}" is not a Resend account this release can act on: ${problem}`, { hint: handoffSentence(handoffs.own([
|
|
20391
21796
|
"account",
|
|
20392
21797
|
"remove",
|
|
20393
21798
|
name
|
|
20394
|
-
],
|
|
21799
|
+
]), (command) => `Remove it with ${command} and add it again, or fix it in the configuration file.`, { instead: "Fix it in the configuration file." }) });
|
|
20395
21800
|
return account;
|
|
20396
21801
|
}
|
|
20397
|
-
function notFound(name) {
|
|
20398
|
-
return () => new CommsError("NOT_FOUND", `there is no Resend account called "${name}"`, { hint: "List them with
|
|
21802
|
+
function notFound(name, handoffs) {
|
|
21803
|
+
return () => new CommsError("NOT_FOUND", `there is no Resend account called "${name}"`, { hint: handoffSentence(handoffs.own(["account", "list"]), (listed) => handoffSentenceToFill(handoffs.own(["account", "add"]), ["<org/resend>"], (adding) => `List them with ${listed}; a person adds one with ${adding}.`)) });
|
|
20399
21804
|
}
|
|
20400
21805
|
/** Every Resend account in `config`, checked, by name. */
|
|
20401
|
-
function resendAccounts(config,
|
|
21806
|
+
function resendAccounts(config, handoffs) {
|
|
20402
21807
|
return Object.entries(config.accounts).filter(([, account]) => account.platform === PLATFORM).map(([name, account]) => ({
|
|
20403
21808
|
name,
|
|
20404
|
-
account: checkedAccount(name, account,
|
|
21809
|
+
account: checkedAccount(name, account, handoffs)
|
|
20405
21810
|
})).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
20406
21811
|
}
|
|
20407
21812
|
/**
|
|
20408
21813
|
* The account by name, or a refusal that says what to do — through core's `resolveName`, so a name that was replaced
|
|
20409
21814
|
* is refused with the one it has now rather than reported as unknown.
|
|
20410
21815
|
*/
|
|
20411
|
-
function requireAccount(config, name,
|
|
20412
|
-
const { alias, account } = resolveName(config, "account", name, notFound(name));
|
|
20413
|
-
if (account.platform !== "resend") throw notFound(name)();
|
|
21816
|
+
function requireAccount(config, name, handoffs) {
|
|
21817
|
+
const { alias, account } = resolveName(config, "account", name, notFound(name, handoffs));
|
|
21818
|
+
if (account.platform !== "resend") throw notFound(name, handoffs)();
|
|
20414
21819
|
return {
|
|
20415
21820
|
name: alias,
|
|
20416
|
-
account: checkedAccount(alias, account,
|
|
21821
|
+
account: checkedAccount(alias, account, handoffs)
|
|
20417
21822
|
};
|
|
20418
21823
|
}
|
|
20419
21824
|
/** The account under exactly this name, or null: no former names, no refusal. */
|
|
20420
|
-
function lookupAccount(config, name,
|
|
21825
|
+
function lookupAccount(config, name, handoffs) {
|
|
20421
21826
|
const account = lookupName(config, "account", name);
|
|
20422
21827
|
if (!account || account.platform !== "resend") return null;
|
|
20423
21828
|
return {
|
|
20424
21829
|
name,
|
|
20425
|
-
account: checkedAccount(name, account,
|
|
21830
|
+
account: checkedAccount(name, account, handoffs)
|
|
20426
21831
|
};
|
|
20427
21832
|
}
|
|
20428
21833
|
/** The account by its immutable id, under whatever name it has now. */
|
|
20429
|
-
function accountById(config, id,
|
|
21834
|
+
function accountById(config, id, handoffs) {
|
|
20430
21835
|
const found = findById(config, "account", id);
|
|
20431
21836
|
if (!found || found.account.platform !== "resend") return null;
|
|
20432
21837
|
return {
|
|
20433
21838
|
name: found.alias,
|
|
20434
|
-
account: checkedAccount(found.alias, found.account,
|
|
21839
|
+
account: checkedAccount(found.alias, found.account, handoffs)
|
|
20435
21840
|
};
|
|
20436
21841
|
}
|
|
20437
21842
|
/**
|
|
20438
21843
|
* Refuses a name a new account cannot take — core's rule, for either config version: the grammar and the platform,
|
|
20439
21844
|
* free in both maps, and never a former name — with this package's own hint for one already connected.
|
|
20440
21845
|
*/
|
|
20441
|
-
function checkNewName(config, name,
|
|
21846
|
+
function checkNewName(config, name, handoffs) {
|
|
20442
21847
|
const check = nameAvailable(config, "account", name, PLATFORM);
|
|
20443
21848
|
if (check.ok) return;
|
|
20444
|
-
if (lookupName(config, "account", name) || lookupName(config, "inbox", name)) throw new CommsError("USAGE", `there is already an account called "${name}"`, { hint:
|
|
20445
|
-
"agent-resend",
|
|
21849
|
+
if (lookupName(config, "account", name) || lookupName(config, "inbox", name)) throw new CommsError("USAGE", `there is already an account called "${name}"`, { hint: handoffSentence(handoffs.own([
|
|
20446
21850
|
"account",
|
|
20447
21851
|
"remove",
|
|
20448
21852
|
name
|
|
20449
|
-
],
|
|
21853
|
+
]), (command) => `Choose another name, or remove that one first with ${command}.`, { instead: "Choose another name." }) });
|
|
20450
21854
|
throw check.error;
|
|
20451
21855
|
}
|
|
20452
21856
|
/** `config` with `account` under `name`. */
|
|
@@ -20482,22 +21886,22 @@ function changePolicyIn(config, account) {
|
|
|
20482
21886
|
*/
|
|
20483
21887
|
var AccountStore = class {
|
|
20484
21888
|
#load;
|
|
20485
|
-
#
|
|
20486
|
-
constructor(load,
|
|
21889
|
+
#handoffs;
|
|
21890
|
+
constructor(load, handoffs) {
|
|
20487
21891
|
this.#load = load;
|
|
20488
|
-
this.#
|
|
21892
|
+
this.#handoffs = handoffs;
|
|
20489
21893
|
}
|
|
20490
21894
|
async list() {
|
|
20491
|
-
return resendAccounts(await this.#load(), this.#
|
|
21895
|
+
return resendAccounts(await this.#load(), this.#handoffs);
|
|
20492
21896
|
}
|
|
20493
21897
|
async find(name) {
|
|
20494
|
-
return lookupAccount(await this.#load(), name, this.#
|
|
21898
|
+
return lookupAccount(await this.#load(), name, this.#handoffs);
|
|
20495
21899
|
}
|
|
20496
21900
|
async findById(id) {
|
|
20497
|
-
return accountById(await this.#load(), id, this.#
|
|
21901
|
+
return accountById(await this.#load(), id, this.#handoffs);
|
|
20498
21902
|
}
|
|
20499
21903
|
async require(name) {
|
|
20500
|
-
return requireAccount(await this.#load(), name, this.#
|
|
21904
|
+
return requireAccount(await this.#load(), name, this.#handoffs);
|
|
20501
21905
|
}
|
|
20502
21906
|
};
|
|
20503
21907
|
var Throttle = class {
|
|
@@ -20628,23 +22032,44 @@ var Throttle = class {
|
|
|
20628
22032
|
//#endregion
|
|
20629
22033
|
//#region src/context.ts
|
|
20630
22034
|
const TEAM_DOMAINS_TTL_MS = 3e5;
|
|
22035
|
+
/**
|
|
22036
|
+
* The commands this package prints, located from it (CUE-403): core has to have been opened with Resend as its caller.
|
|
22037
|
+
* A core opened without one — or as another package — would print bare names, or that package's commands for Resend's,
|
|
22038
|
+
* so it is a programming error here rather than a sentence a person reads.
|
|
22039
|
+
*/
|
|
22040
|
+
function resendHandoffs(core) {
|
|
22041
|
+
const handoffs = requireHandoffs(core);
|
|
22042
|
+
if (handoffs.caller.packageName !== "@agentcomms/resend") throw new TypeError(`core was opened as ${handoffs.caller.packageName}, so it would print that package's commands for Resend's: open it with RESEND_CALLER`);
|
|
22043
|
+
return handoffs;
|
|
22044
|
+
}
|
|
20631
22045
|
var ResendContext = class {
|
|
20632
22046
|
core;
|
|
20633
22047
|
env;
|
|
20634
22048
|
now;
|
|
20635
22049
|
platform;
|
|
20636
22050
|
surface;
|
|
22051
|
+
/**
|
|
22052
|
+
* What every result, refusal and hint names for a person to run: Resend's own CLI as this installation runs it, or
|
|
22053
|
+
* core's through its installed dependency (`own`, `core`), quoted for `platform` — or why there is none here.
|
|
22054
|
+
*/
|
|
22055
|
+
handoffs;
|
|
20637
22056
|
accounts;
|
|
20638
22057
|
#fetch;
|
|
20639
22058
|
#throttle;
|
|
20640
22059
|
#teamDomains = /* @__PURE__ */ new Map();
|
|
20641
22060
|
constructor(options = {}) {
|
|
20642
22061
|
this.env = options.env ?? process.env;
|
|
20643
|
-
this.core = options.core ?? openCore({
|
|
22062
|
+
this.core = options.core ?? openCore({
|
|
22063
|
+
env: this.env,
|
|
22064
|
+
platform: options.platform ?? process.platform,
|
|
22065
|
+
...options.pathOverrides ? { pathOverrides: options.pathOverrides } : {},
|
|
22066
|
+
caller: RESEND_CALLER
|
|
22067
|
+
});
|
|
20644
22068
|
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
20645
22069
|
this.platform = options.platform ?? process.platform;
|
|
20646
22070
|
this.surface = options.surface ?? "cli";
|
|
20647
|
-
this.
|
|
22071
|
+
this.handoffs = resendHandoffs(this.core).on(this.platform);
|
|
22072
|
+
this.accounts = new AccountStore(() => this.core.config.load(), this.handoffs);
|
|
20648
22073
|
this.#fetch = options.fetch;
|
|
20649
22074
|
this.#throttle = new Throttle(this.core.paths.stateDir, options.throttle);
|
|
20650
22075
|
}
|
|
@@ -20693,12 +22118,11 @@ var ResendContext = class {
|
|
|
20693
22118
|
/** A transport for a connected account: its key from the secret store, the machine's throttle, and any open permit. */
|
|
20694
22119
|
async transport(named, permit) {
|
|
20695
22120
|
const key = await (await this.secrets()).get(named.account.secretRef);
|
|
20696
|
-
if (key === null || key.trim() === "") throw new CommsError("AUTH_REQUIRED", `the key for "${named.name}" is not in the secret store`, { hint:
|
|
20697
|
-
"agent-resend",
|
|
22121
|
+
if (key === null || key.trim() === "") throw new CommsError("AUTH_REQUIRED", `the key for "${named.name}" is not in the secret store`, { hint: handoffSentence(this.handoffs.own([
|
|
20698
22122
|
"account",
|
|
20699
22123
|
"add",
|
|
20700
22124
|
named.name
|
|
20701
|
-
],
|
|
22125
|
+
]), (command) => `A person removes the account and adds it again: ${command}.`) });
|
|
20702
22126
|
return {
|
|
20703
22127
|
fetch: this.#fetch,
|
|
20704
22128
|
key,
|
|
@@ -20818,7 +22242,7 @@ function codeFor(status, name) {
|
|
|
20818
22242
|
if (name === "restricted_api_key" && status === 401) return {
|
|
20819
22243
|
code: "SCOPE_MISSING",
|
|
20820
22244
|
message: "this account’s key can only send email; Resend refuses everything else with it",
|
|
20821
|
-
hint: "Reads need a full-access key.
|
|
22245
|
+
hint: "Reads need a full-access key. A person adds one under another account name, at their own terminal."
|
|
20822
22246
|
};
|
|
20823
22247
|
if (status === 401 || name === "restricted_api_key" || name === "suspended_api_key") return {
|
|
20824
22248
|
code: "AUTH_REQUIRED",
|
|
@@ -21049,7 +22473,8 @@ async function readAttachment(path, policy) {
|
|
|
21049
22473
|
const real = await checkAttachable(path, {
|
|
21050
22474
|
roots: [...policy.roots],
|
|
21051
22475
|
deny: [...defaultAttachDeny(policy.configDir, policy.env), ...policy.deny],
|
|
21052
|
-
home
|
|
22476
|
+
home,
|
|
22477
|
+
handoffs: policy.handoffs
|
|
21053
22478
|
});
|
|
21054
22479
|
if ((await stat(real)).size > 41943040) throw bad(`${basename(real)} is larger than Resend accepts`);
|
|
21055
22480
|
const bytes = new Uint8Array(await readFile(real));
|
|
@@ -21251,8 +22676,8 @@ function keyFingerprint(key) {
|
|
|
21251
22676
|
}
|
|
21252
22677
|
/** The plain statement `account show` and `doctor` make about who enforces what. */
|
|
21253
22678
|
function guaranteeOf(account) {
|
|
21254
|
-
if (keyPermissionOf(account) === "sending_access") return `This key can only send: Resend enforces that, and refuses it every read.${account.domainLock ? ` It was declared restricted to ${account.domainLock} when it was added; that is not something
|
|
21255
|
-
return account.mode === "read" ? "Read-only is enforced by
|
|
22679
|
+
if (keyPermissionOf(account) === "sending_access") return `This key can only send: Resend enforces that, and refuses it every read.${account.domainLock ? ` It was declared restricted to ${account.domainLock} when it was added; that is not something this package can check without sending, so Resend enforces it, not this package.` : ""} This package still sends only after a person approves each email.`;
|
|
22680
|
+
return account.mode === "read" ? "Read-only is enforced by this package’s own code, not by the key. Resend has no read-only key: this full-access key could send mail, delete domains and create API keys in anything else that held it. This package refuses every send from this account while it is in read mode." : "This full-access key can do anything at Resend. This package sends only after a person approves each email, and never manages domains, keys or webhooks — but the key itself would not stop anything else that held it.";
|
|
21256
22681
|
}
|
|
21257
22682
|
function viewOf(named, config) {
|
|
21258
22683
|
const { account } = named;
|
|
@@ -21275,11 +22700,11 @@ function viewOf(named, config) {
|
|
|
21275
22700
|
}
|
|
21276
22701
|
async function listAccounts(context) {
|
|
21277
22702
|
const config = await context.config();
|
|
21278
|
-
return { accounts: resendAccounts(config, context.
|
|
22703
|
+
return { accounts: resendAccounts(config, context.handoffs).map((named) => viewOf(named, config)) };
|
|
21279
22704
|
}
|
|
21280
22705
|
async function showAccount(context, name) {
|
|
21281
22706
|
const config = await context.config();
|
|
21282
|
-
return viewOf(requireAccount(config, name, context.
|
|
22707
|
+
return viewOf(requireAccount(config, name, context.handoffs), config);
|
|
21283
22708
|
}
|
|
21284
22709
|
/** A mode word checked by the operation, so both surfaces refuse it in the same words. */
|
|
21285
22710
|
function modeOf(raw) {
|
|
@@ -21298,9 +22723,13 @@ function changePolicyOf(raw) {
|
|
|
21298
22723
|
throw new CommsError("USAGE", `"${String(raw)}" is not a change policy: use chat or confirm`);
|
|
21299
22724
|
}
|
|
21300
22725
|
/** Refuses a key that is already connected, under any name: one key, one account. */
|
|
21301
|
-
function refuseConnectedKey(config, fingerprint,
|
|
21302
|
-
const same = resendAccounts(config,
|
|
21303
|
-
if (same) throw new CommsError("USAGE", `this key is already connected as "${same.name}"`, { hint:
|
|
22726
|
+
function refuseConnectedKey(config, fingerprint, handoffs) {
|
|
22727
|
+
const same = resendAccounts(config, handoffs).find((named) => named.account.workspace === fingerprint);
|
|
22728
|
+
if (same) throw new CommsError("USAGE", `this key is already connected as "${same.name}"`, { hint: handoffSentence(handoffs.own([
|
|
22729
|
+
"account",
|
|
22730
|
+
"policy",
|
|
22731
|
+
same.name
|
|
22732
|
+
]), (command) => `One key, one account. Change that one with ${command}, adding --send, --mode or --change.`) });
|
|
21304
22733
|
}
|
|
21305
22734
|
/**
|
|
21306
22735
|
* Connects an account, once the key has been inspected — a `GatedChange`, so connecting one in `send` mode, or with
|
|
@@ -21334,8 +22763,8 @@ function addAccountChange(context, request, inspection) {
|
|
|
21334
22763
|
...domainLock ? { domainLock } : {}
|
|
21335
22764
|
};
|
|
21336
22765
|
const connect = (config) => {
|
|
21337
|
-
checkNewName(config, request.name, context.
|
|
21338
|
-
refuseConnectedKey(config, fingerprint, context.
|
|
22766
|
+
checkNewName(config, request.name, context.handoffs);
|
|
22767
|
+
refuseConnectedKey(config, fingerprint, context.handoffs);
|
|
21339
22768
|
return withAccount(config, request.name, account);
|
|
21340
22769
|
};
|
|
21341
22770
|
return {
|
|
@@ -21351,7 +22780,7 @@ function addAccountChange(context, request, inspection) {
|
|
|
21351
22780
|
};
|
|
21352
22781
|
},
|
|
21353
22782
|
apply: async (consent) => {
|
|
21354
|
-
const { store, choosing } = secretsStoreFor(await context.config(), void 0, context.
|
|
22783
|
+
const { store, choosing } = secretsStoreFor(await context.config(), void 0, context.handoffs);
|
|
21355
22784
|
const secrets = await context.core.secrets(store);
|
|
21356
22785
|
await secrets.set(account.secretRef, request.key.trim());
|
|
21357
22786
|
let written;
|
|
@@ -21392,7 +22821,7 @@ function addAccountChange(context, request, inspection) {
|
|
|
21392
22821
|
function removeAccountChange(context, name) {
|
|
21393
22822
|
return {
|
|
21394
22823
|
plan: (config) => {
|
|
21395
|
-
const found = requireAccount(config, name, context.
|
|
22824
|
+
const found = requireAccount(config, name, context.handoffs);
|
|
21396
22825
|
return {
|
|
21397
22826
|
account: found.name,
|
|
21398
22827
|
before: config,
|
|
@@ -21402,25 +22831,23 @@ function removeAccountChange(context, name) {
|
|
|
21402
22831
|
};
|
|
21403
22832
|
},
|
|
21404
22833
|
apply: async (_consent, request) => {
|
|
21405
|
-
const approved = requireAccount(request.before, name, context.
|
|
22834
|
+
const approved = requireAccount(request.before, name, context.handoffs).account;
|
|
21406
22835
|
const removed = await withCredentialsLock(context.core.paths.configDir, async () => {
|
|
21407
|
-
const found = requireAccount(await context.config(), name, context.
|
|
21408
|
-
if (found.account.id !== approved.id) throw new CommsError("CONFIG", `"${name}" changed after its removal was approved, so nothing was removed`, { hint:
|
|
21409
|
-
"agent-resend",
|
|
22836
|
+
const found = requireAccount(await context.config(), name, context.handoffs);
|
|
22837
|
+
if (found.account.id !== approved.id) throw new CommsError("CONFIG", `"${name}" changed after its removal was approved, so nothing was removed`, { hint: handoffSentence(context.handoffs.own([
|
|
21410
22838
|
"account",
|
|
21411
22839
|
"show",
|
|
21412
22840
|
name
|
|
21413
|
-
],
|
|
22841
|
+
]), (command) => `Look at it with ${command}, and remove it again if you still want it gone.`) });
|
|
21414
22842
|
const secrets = await context.secrets();
|
|
21415
22843
|
await secrets.delete(found.account.secretRef).catch(() => false);
|
|
21416
22844
|
await context.core.config.update((current) => {
|
|
21417
|
-
if (secretsStoreOf(current) !== secrets.kind) throw new CommsError("TRANSIENT", `the secret store changed while "${name}" was being removed`, { hint:
|
|
21418
|
-
"agent-resend",
|
|
22845
|
+
if (secretsStoreOf(current) !== secrets.kind) throw new CommsError("TRANSIENT", `the secret store changed while "${name}" was being removed`, { hint: handoffSentence(context.handoffs.own([
|
|
21419
22846
|
"account",
|
|
21420
22847
|
"remove",
|
|
21421
22848
|
name
|
|
21422
|
-
],
|
|
21423
|
-
const held = accountById(current, found.account.id, context.
|
|
22849
|
+
]), (command) => `Run ${command} again.`) });
|
|
22850
|
+
const held = accountById(current, found.account.id, context.handoffs);
|
|
21424
22851
|
if (!held) return current;
|
|
21425
22852
|
return withoutAccount(current, held.name);
|
|
21426
22853
|
});
|
|
@@ -21472,7 +22899,7 @@ function reportOf(named, config) {
|
|
|
21472
22899
|
}
|
|
21473
22900
|
async function policyReport(context, name) {
|
|
21474
22901
|
const config = await context.config();
|
|
21475
|
-
return reportOf(requireAccount(config, name, context.
|
|
22902
|
+
return reportOf(requireAccount(config, name, context.handoffs), config);
|
|
21476
22903
|
}
|
|
21477
22904
|
/**
|
|
21478
22905
|
* Why a policy report refuses the approval it is handed, in the words of the surface it came from, as core's
|
|
@@ -21514,7 +22941,7 @@ function policyChange(context, name, wanted) {
|
|
|
21514
22941
|
].filter(Boolean).join(", ");
|
|
21515
22942
|
return {
|
|
21516
22943
|
plan: (config) => {
|
|
21517
|
-
const found = checked(requireAccount(config, name, context.
|
|
22944
|
+
const found = checked(requireAccount(config, name, context.handoffs));
|
|
21518
22945
|
return {
|
|
21519
22946
|
account: found.name,
|
|
21520
22947
|
before: config,
|
|
@@ -21523,11 +22950,11 @@ function policyChange(context, name, wanted) {
|
|
|
21523
22950
|
};
|
|
21524
22951
|
},
|
|
21525
22952
|
apply: async (consent, request) => {
|
|
21526
|
-
const approved = requireAccount(request.before, name, context.
|
|
22953
|
+
const approved = requireAccount(request.before, name, context.handoffs).account;
|
|
21527
22954
|
let written;
|
|
21528
22955
|
const config = await context.core.config.update((current) => {
|
|
21529
|
-
const held = accountById(current, approved.id, context.
|
|
21530
|
-
if (!held) throw new CommsError("CONFIG", `"${name}" changed while its policy was being set, so nothing was set`, { hint: "Check it with
|
|
22956
|
+
const held = accountById(current, approved.id, context.handoffs);
|
|
22957
|
+
if (!held) throw new CommsError("CONFIG", `"${name}" changed while its policy was being set, so nothing was set`, { hint: handoffSentence(context.handoffs.own(["account", "list"]), (command) => `Check it with ${command}, then set the policy again.`) });
|
|
21531
22958
|
written = {
|
|
21532
22959
|
name: held.name,
|
|
21533
22960
|
account: next(checked(held).account)
|
|
@@ -21559,7 +22986,17 @@ function policyChange(context, name, wanted) {
|
|
|
21559
22986
|
* own promise rather than the key's: a person deciding how much to trust an agent with a full-access key should not
|
|
21560
22987
|
* have to find that out from the source.
|
|
21561
22988
|
*/
|
|
21562
|
-
const READ_ONLY_STATEMENT = "Read-only is enforced by
|
|
22989
|
+
const READ_ONLY_STATEMENT = "Read-only is enforced by this package’s own code, not by the key: Resend has no read-only API key, and a full-access key can also send, delete domains and create keys. A sending-only key can only send; Resend enforces that one.";
|
|
22990
|
+
/** Two commands to run one after the other, as a fix: both located, or the reason there is none — said once. */
|
|
22991
|
+
function thenFix(first, second) {
|
|
22992
|
+
if (!isCommand(first)) return remedy(first);
|
|
22993
|
+
if (!isCommand(second)) return remedy(second);
|
|
22994
|
+
return remedy([
|
|
22995
|
+
first,
|
|
22996
|
+
", then ",
|
|
22997
|
+
second
|
|
22998
|
+
]);
|
|
22999
|
+
}
|
|
21563
23000
|
async function checkAccount(context, named, offline) {
|
|
21564
23001
|
const checks = [];
|
|
21565
23002
|
let key = null;
|
|
@@ -21573,24 +23010,22 @@ async function checkAccount(context, named, offline) {
|
|
|
21573
23010
|
name: "key stored",
|
|
21574
23011
|
ok: false,
|
|
21575
23012
|
detail: "the key is missing from the secret store",
|
|
21576
|
-
fix:
|
|
21577
|
-
"agent-resend",
|
|
23013
|
+
fix: thenFix(context.handoffs.own([
|
|
21578
23014
|
"account",
|
|
21579
23015
|
"remove",
|
|
21580
23016
|
named.name
|
|
21581
|
-
], context.
|
|
21582
|
-
"agent-resend",
|
|
23017
|
+
]), context.handoffs.own([
|
|
21583
23018
|
"account",
|
|
21584
23019
|
"add",
|
|
21585
23020
|
named.name
|
|
21586
|
-
]
|
|
23021
|
+
]))
|
|
21587
23022
|
});
|
|
21588
23023
|
} catch (error) {
|
|
21589
23024
|
checks.push({
|
|
21590
23025
|
name: "key stored",
|
|
21591
23026
|
ok: false,
|
|
21592
23027
|
detail: toCommsError(error).message,
|
|
21593
|
-
fix: "
|
|
23028
|
+
fix: remedy(context.handoffs.core(["doctor"]))
|
|
21594
23029
|
});
|
|
21595
23030
|
}
|
|
21596
23031
|
const blocked = await context.throttle().blockedUntil();
|
|
@@ -21610,7 +23045,7 @@ async function checkAccount(context, named, offline) {
|
|
|
21610
23045
|
name: "key permission",
|
|
21611
23046
|
ok: false,
|
|
21612
23047
|
detail: "recorded as sending-only, but Resend let it read — it has full access",
|
|
21613
|
-
fix: `remove and add ${named.name} again so its permission is recorded as it is`
|
|
23048
|
+
fix: remedy(`remove and add ${named.name} again so its permission is recorded as it is`)
|
|
21614
23049
|
});
|
|
21615
23050
|
else checks.push({
|
|
21616
23051
|
name: "key works",
|
|
@@ -21627,13 +23062,13 @@ async function checkAccount(context, named, offline) {
|
|
|
21627
23062
|
name: "key permission",
|
|
21628
23063
|
ok: false,
|
|
21629
23064
|
detail: "recorded as full access, but Resend now refuses it every read",
|
|
21630
|
-
fix: `remove and add ${named.name} again with the key you mean`
|
|
23065
|
+
fix: remedy(`remove and add ${named.name} again with the key you mean`)
|
|
21631
23066
|
});
|
|
21632
23067
|
else checks.push({
|
|
21633
23068
|
name: "key works",
|
|
21634
23069
|
ok: false,
|
|
21635
23070
|
detail: comms.message,
|
|
21636
|
-
...comms.hint ? { fix: comms.hint } : {}
|
|
23071
|
+
...comms.hint ? { fix: remedy(comms.hint) } : {}
|
|
21637
23072
|
});
|
|
21638
23073
|
}
|
|
21639
23074
|
return {
|
|
@@ -21653,7 +23088,7 @@ async function runDoctor(context, options = {}) {
|
|
|
21653
23088
|
name: "node",
|
|
21654
23089
|
ok: nodeOk,
|
|
21655
23090
|
detail: `Node ${process.versions.node}`,
|
|
21656
|
-
...nodeOk ? {} : { fix: "Install Node 22.12 or newer." }
|
|
23091
|
+
...nodeOk ? {} : { fix: remedy("Install Node 22.12 or newer.") }
|
|
21657
23092
|
});
|
|
21658
23093
|
let store = "unknown";
|
|
21659
23094
|
try {
|
|
@@ -21668,7 +23103,7 @@ async function runDoctor(context, options = {}) {
|
|
|
21668
23103
|
name: "config",
|
|
21669
23104
|
ok: false,
|
|
21670
23105
|
detail: toCommsError(error).message,
|
|
21671
|
-
fix: "
|
|
23106
|
+
fix: remedy(context.handoffs.core(["doctor"]))
|
|
21672
23107
|
});
|
|
21673
23108
|
}
|
|
21674
23109
|
let named = [];
|
|
@@ -22086,12 +23521,16 @@ function domainRow(entry) {
|
|
|
22086
23521
|
}
|
|
22087
23522
|
/** The team's domains; with `domain`, that one with the DNS records it needs. */
|
|
22088
23523
|
async function listDomains(context, name, options = {}) {
|
|
22089
|
-
return whenReadable(context, name, async (
|
|
23524
|
+
return whenReadable(context, name, async (named, transport) => {
|
|
22090
23525
|
const domains = ((await resendRequest(transport, "GET", "/domains")).data ?? []).map(domainRow);
|
|
22091
23526
|
if (options.domain === void 0) return { domains };
|
|
22092
23527
|
const wanted = options.domain.trim().toLowerCase();
|
|
22093
23528
|
const found = domains.find((domain) => domain.name.toLowerCase() === wanted || domain.id === wanted);
|
|
22094
|
-
if (!found) throw new CommsError("NOT_FOUND", `"${options.domain}" is not one of this team’s domains`, { hint:
|
|
23529
|
+
if (!found) throw new CommsError("NOT_FOUND", `"${options.domain}" is not one of this team’s domains`, { hint: handoffSentence(context.handoffs.own([
|
|
23530
|
+
"domains",
|
|
23531
|
+
"--account",
|
|
23532
|
+
named.name
|
|
23533
|
+
]), (command) => `List them with ${command}.`) });
|
|
22095
23534
|
const detail = await resendRequest(transport, "GET", `/domains/${resendId(found.id, "domain id")}`);
|
|
22096
23535
|
const records = (Array.isArray(detail.records) ? detail.records : []).map((record) => ({
|
|
22097
23536
|
record: text(record.record),
|
|
@@ -22371,7 +23810,7 @@ const DAY = /^\d{4}-\d{2}-\d{2}/;
|
|
|
22371
23810
|
/** The downloads root: `~/Downloads/agent-communications` unless core's config says otherwise. */
|
|
22372
23811
|
async function downloadsRoot(context) {
|
|
22373
23812
|
const configured = (await context.config()).defaults.downloadsDir;
|
|
22374
|
-
const root = configured ? expandHome(configured, homeDirectory(context.env)) : context.core.paths.downloadsDir;
|
|
23813
|
+
const root = context.core.pathOverrides.downloadsDir ?? (configured ? expandHome(configured, homeDirectory(context.env)) : context.core.paths.downloadsDir);
|
|
22375
23814
|
await ensurePrivateDir(root);
|
|
22376
23815
|
return root;
|
|
22377
23816
|
}
|
|
@@ -22630,15 +24069,28 @@ async function ownRecord(context, named, approvalId) {
|
|
|
22630
24069
|
if (!record || approvalKind(record) !== "send" || record.inboxId !== named.account.id) throw new CommsError("NOT_FOUND", `there is no send approval ${approvalId} for "${named.name}"`, { hint: "Approvals last ten minutes. Prepare the send again." });
|
|
22631
24070
|
return record;
|
|
22632
24071
|
}
|
|
22633
|
-
function requireSendMode(named,
|
|
22634
|
-
if (named.account.mode !== "send") throw new CommsError("SCOPE_MISSING", `"${named.name}" is in read mode, so it sends nothing`, { hint:
|
|
22635
|
-
"agent-resend",
|
|
24072
|
+
function requireSendMode(named, handoffs) {
|
|
24073
|
+
if (named.account.mode !== "send") throw new CommsError("SCOPE_MISSING", `"${named.name}" is in read mode, so it sends nothing`, { hint: handoffSentence(handoffs.own([
|
|
22636
24074
|
"account",
|
|
22637
24075
|
"policy",
|
|
22638
24076
|
named.name,
|
|
22639
24077
|
"--mode",
|
|
22640
24078
|
"send"
|
|
22641
|
-
],
|
|
24079
|
+
]), (command) => `A person can allow sending with ${command}, which is a change they approve.`) });
|
|
24080
|
+
}
|
|
24081
|
+
/** Resend's own `approve` for this send, located: what a person runs at their own terminal under `confirm`. */
|
|
24082
|
+
function approveCommand(handoffs, approvalId) {
|
|
24083
|
+
return handoffs.own(["approve", approvalId]);
|
|
24084
|
+
}
|
|
24085
|
+
/** Resend's own `send status` for this send, located: how a send whose outcome is not known is checked. */
|
|
24086
|
+
function statusCommand(handoffs, approvalId, name) {
|
|
24087
|
+
return handoffs.own([
|
|
24088
|
+
"send",
|
|
24089
|
+
"status",
|
|
24090
|
+
approvalId,
|
|
24091
|
+
"--account",
|
|
24092
|
+
name
|
|
24093
|
+
]);
|
|
22642
24094
|
}
|
|
22643
24095
|
/**
|
|
22644
24096
|
* Whether the From domain may send, checked before an approval exists.
|
|
@@ -22653,8 +24105,18 @@ async function checkFromDomain(context, named, domain) {
|
|
|
22653
24105
|
return `From domain ${domain}: not checked — a sending-only key cannot read the domain list; Resend refuses an unverified domain`;
|
|
22654
24106
|
}
|
|
22655
24107
|
const found = ((await resendRequest(await context.transport(named), "GET", "/domains")).data ?? []).find((entry) => String(entry.name ?? "").toLowerCase() === domain);
|
|
22656
|
-
if (!found) throw new CommsError("BAD_DATA", `${domain} is not one of this Resend team's domains, so nothing can be sent from it`, { hint:
|
|
22657
|
-
|
|
24108
|
+
if (!found) throw new CommsError("BAD_DATA", `${domain} is not one of this Resend team's domains, so nothing can be sent from it`, { hint: handoffSentence(context.handoffs.own([
|
|
24109
|
+
"domains",
|
|
24110
|
+
"--account",
|
|
24111
|
+
named.name
|
|
24112
|
+
]), (command) => `Send from an address at a verified domain: see ${command}.`) });
|
|
24113
|
+
if (found.status !== "verified") throw new CommsError("BAD_DATA", `${domain} is not verified at Resend (status: ${String(found.status)})`, { hint: handoffSentence(context.handoffs.own([
|
|
24114
|
+
"domains",
|
|
24115
|
+
"--account",
|
|
24116
|
+
named.name,
|
|
24117
|
+
"--domain",
|
|
24118
|
+
domain
|
|
24119
|
+
]), (command) => `A person finishes the DNS records for it first: ${command} lists them.`) });
|
|
22658
24120
|
const sending = found.capabilities?.sending;
|
|
22659
24121
|
if (sending !== void 0 && sending !== "enabled") throw new CommsError("BAD_DATA", `${domain} is verified, but sending from it is ${String(sending)}`);
|
|
22660
24122
|
return `From domain ${domain}: verified for sending at Resend`;
|
|
@@ -22705,12 +24167,12 @@ function expectationOf(message) {
|
|
|
22705
24167
|
subject: message.subject
|
|
22706
24168
|
};
|
|
22707
24169
|
}
|
|
22708
|
-
|
|
22709
|
-
|
|
22710
|
-
|
|
22711
|
-
|
|
22712
|
-
|
|
22713
|
-
|
|
24170
|
+
/**
|
|
24171
|
+
* The preview's policy line. Under `confirm` it names Resend's own `approve`, located by the process that renders it —
|
|
24172
|
+
* the preparing one, then the approving one: a send's approval is bound to the message's digest, not to this line.
|
|
24173
|
+
*/
|
|
24174
|
+
function describePolicy(effective, approvalId, flags, handoffs) {
|
|
24175
|
+
if (effective === "confirm") return `Policy: confirm${flags.length > 0 ? ` (${flags.join(", ")})` : ""} — ${handoffSentence(approveCommand(handoffs, approvalId), (command) => `a person runs ${command} at their own terminal before this can go.`, { instead: "a person approves it at their own terminal before this can go." })}`;
|
|
22714
24176
|
return "Policy: chat — send only after the user approves this exact preview.";
|
|
22715
24177
|
}
|
|
22716
24178
|
function previewOf(options) {
|
|
@@ -22720,7 +24182,12 @@ function previewOf(options) {
|
|
|
22720
24182
|
`Reach: ${reach.length} unique recipient(s) — To ${message.to.length} · Cc ${message.cc.length} · Bcc ${message.bcc.length}${reach.length > 10 ? ` — more than 10, so a person approves it at a terminal` : ""}`,
|
|
22721
24183
|
options.domainNote,
|
|
22722
24184
|
...message.bcc.length > 0 ? [`${message.bcc.length} blind recipient(s) — the others will not see them`] : [],
|
|
22723
|
-
...message.scheduledAt ? [
|
|
24185
|
+
...message.scheduledAt ? [handoffSentenceToFill(options.handoffs.own([
|
|
24186
|
+
"scheduled",
|
|
24187
|
+
"cancel",
|
|
24188
|
+
"--account",
|
|
24189
|
+
options.name
|
|
24190
|
+
]), ["<id>"], (command) => `Scheduled for ${message.scheduledAt}; until then it can be cancelled with ${command}`, { instead: `Scheduled for ${message.scheduledAt}; until then it can be cancelled with resend_scheduled_cancel.` })] : ["Sends as soon as it is approved and executed"],
|
|
22724
24191
|
...options.study.warnings,
|
|
22725
24192
|
...options.built.warnings
|
|
22726
24193
|
];
|
|
@@ -22748,7 +24215,7 @@ function previewOf(options) {
|
|
|
22748
24215
|
thread: message.inReplyTo ? `reply to ${message.inReplyTo}${message.references.length > 1 ? ` (${message.references.length} references)` : ""}` : void 0,
|
|
22749
24216
|
links: options.built.links,
|
|
22750
24217
|
warnings,
|
|
22751
|
-
policy: describePolicy(options.effective, options.approvalId, options.flags, options.
|
|
24218
|
+
policy: describePolicy(options.effective, options.approvalId, options.flags, options.handoffs)
|
|
22752
24219
|
});
|
|
22753
24220
|
}
|
|
22754
24221
|
async function attachPolicy(context) {
|
|
@@ -22757,7 +24224,8 @@ async function attachPolicy(context) {
|
|
|
22757
24224
|
configDir: context.core.paths.configDir,
|
|
22758
24225
|
env: context.env,
|
|
22759
24226
|
roots: config.defaults.attachRoots,
|
|
22760
|
-
deny: config.defaults.attachDeny
|
|
24227
|
+
deny: config.defaults.attachDeny,
|
|
24228
|
+
handoffs: context.handoffs
|
|
22761
24229
|
};
|
|
22762
24230
|
}
|
|
22763
24231
|
/**
|
|
@@ -22766,17 +24234,16 @@ async function attachPolicy(context) {
|
|
|
22766
24234
|
*/
|
|
22767
24235
|
async function prepareSend(context, name, input) {
|
|
22768
24236
|
const named = await context.accounts.require(name);
|
|
22769
|
-
requireSendMode(named, context.
|
|
24237
|
+
requireSendMode(named, context.handoffs);
|
|
22770
24238
|
const config = await context.config();
|
|
22771
24239
|
const livePolicy = named.account.sendPolicy ?? config.defaults.sendPolicy;
|
|
22772
|
-
if (livePolicy === "never") throw new CommsError("POLICY_NEVER", `sending from ${name} is turned off (policy: never)`, { hint:
|
|
22773
|
-
"agent-resend",
|
|
24240
|
+
if (livePolicy === "never") throw new CommsError("POLICY_NEVER", `sending from ${name} is turned off (policy: never)`, { hint: handoffSentence(context.handoffs.own([
|
|
22774
24241
|
"account",
|
|
22775
24242
|
"policy",
|
|
22776
24243
|
name,
|
|
22777
24244
|
"--send",
|
|
22778
24245
|
"confirm"
|
|
22779
|
-
],
|
|
24246
|
+
]), (command) => `A person can change it with ${command}.`) });
|
|
22780
24247
|
const built = await buildMessage(input, {
|
|
22781
24248
|
now: context.now(),
|
|
22782
24249
|
attach: await attachPolicy(context)
|
|
@@ -22829,7 +24296,7 @@ async function prepareSend(context, name, input) {
|
|
|
22829
24296
|
domainNote,
|
|
22830
24297
|
effective,
|
|
22831
24298
|
flags: study.flags,
|
|
22832
|
-
|
|
24299
|
+
handoffs: context.handoffs
|
|
22833
24300
|
}),
|
|
22834
24301
|
policy: livePolicy,
|
|
22835
24302
|
effectivePolicy: effective,
|
|
@@ -22837,11 +24304,7 @@ async function prepareSend(context, name, input) {
|
|
|
22837
24304
|
reach,
|
|
22838
24305
|
expect: record.expect,
|
|
22839
24306
|
expiresAt: record.expiresAt,
|
|
22840
|
-
nextStep: effective === "confirm" ? `Show the preview to the user, then have them run ${
|
|
22841
|
-
"agent-resend",
|
|
22842
|
-
"approve",
|
|
22843
|
-
record.approvalId
|
|
22844
|
-
], context.platform))} in their own terminal. You cannot approve this yourself. Then execute it with the same approval id and the recipients and subject shown.` : "Show the preview to the user verbatim and wait for an explicit yes. Then execute it with the same approval id and the recipients and subject shown above."
|
|
24307
|
+
nextStep: effective === "confirm" ? `${handoffSentence(approveCommand(context.handoffs, record.approvalId), (command) => `Show the preview to the user, then have them run ${command} in their own terminal.`, { instead: "Show the preview to the user; they approve it at their own terminal." })} You cannot approve this yourself. Then execute it with the same approval id and the recipients and subject shown.` : "Show the preview to the user verbatim and wait for an explicit yes. Then execute it with the same approval id and the recipients and subject shown above."
|
|
22845
24308
|
};
|
|
22846
24309
|
}
|
|
22847
24310
|
/** The stored message and its attachments' bytes, checked again: a file changed since the preview voids the send. */
|
|
@@ -22970,17 +24433,10 @@ async function recordNoSend(context, records, options, error) {
|
|
|
22970
24433
|
*/
|
|
22971
24434
|
async function executeSend(context, name, options) {
|
|
22972
24435
|
const named = await context.accounts.require(name);
|
|
22973
|
-
requireSendMode(named, context.
|
|
24436
|
+
requireSendMode(named, context.handoffs);
|
|
22974
24437
|
const known = await ownRecord(context, named, options.approvalId);
|
|
22975
24438
|
if (known.state !== "pending" && known.state !== "approved") throw new CommsError("APPROVAL_VOID", `nothing was sent: ${describeState(known.state)}`, {
|
|
22976
|
-
hint: known.state === "unknown" ? `Check what happened with ${
|
|
22977
|
-
"agent-resend",
|
|
22978
|
-
"send",
|
|
22979
|
-
"status",
|
|
22980
|
-
options.approvalId,
|
|
22981
|
-
"--account",
|
|
22982
|
-
name
|
|
22983
|
-
], context.platform))} before anything else.` : "Prepare the send again if it should still go.",
|
|
24439
|
+
hint: known.state === "unknown" ? handoffSentence(statusCommand(context.handoffs, options.approvalId, name), (command) => `Check what happened with ${command} before anything else.`, { instead: "Check what happened with resend_send_status before anything else." }) : "Prepare the send again if it should still go.",
|
|
22984
24440
|
details: {
|
|
22985
24441
|
approvalId: options.approvalId,
|
|
22986
24442
|
state: known.state
|
|
@@ -23004,11 +24460,7 @@ async function executeSend(context, name, options) {
|
|
|
23004
24460
|
policy: livePolicy,
|
|
23005
24461
|
expect: options.expect
|
|
23006
24462
|
}, {
|
|
23007
|
-
pendingHint: `Ask the user to run ${
|
|
23008
|
-
"agent-resend",
|
|
23009
|
-
"approve",
|
|
23010
|
-
options.approvalId
|
|
23011
|
-
], context.platform))} in their own terminal, then execute it again with the same approval. You cannot approve it yourself.`,
|
|
24463
|
+
pendingHint: `${handoffSentence(approveCommand(context.handoffs, options.approvalId), (command) => `Ask the user to run ${command} in their own terminal, then execute it again with the same approval.`, { instead: "Ask the user to approve it at their own terminal, then execute it again with the same approval." })} You cannot approve it yourself.`,
|
|
23012
24464
|
platform: context.platform
|
|
23013
24465
|
});
|
|
23014
24466
|
const records = new SendRecords(context.core.paths.stateDir, context.now);
|
|
@@ -23090,14 +24542,7 @@ async function executeSend(context, name, options) {
|
|
|
23090
24542
|
unrecorded.push(`the audit log could not record this (${failure instanceof Error ? failure.message : String(failure)})`);
|
|
23091
24543
|
}
|
|
23092
24544
|
throw new CommsError("TRANSIENT", `whether the email was sent is not known: ${said}`, {
|
|
23093
|
-
hint: [`Do not send it again. Check the Resend dashboard or ask the recipient, and check with ${
|
|
23094
|
-
"agent-resend",
|
|
23095
|
-
"send",
|
|
23096
|
-
"status",
|
|
23097
|
-
options.approvalId,
|
|
23098
|
-
"--account",
|
|
23099
|
-
name
|
|
23100
|
-
], context.platform))}; this approval is not used again.`, ...unrecorded].join(" "),
|
|
24545
|
+
hint: [`Do not send it again. ${handoffSentence(statusCommand(context.handoffs, options.approvalId, name), (command) => `Check the Resend dashboard or ask the recipient, and check with ${command}; this approval is not used again.`, { instead: "Check the Resend dashboard or ask the recipient, and check with resend_send_status; this approval is not used again." })}`, ...unrecorded].join(" "),
|
|
23101
24546
|
details: {
|
|
23102
24547
|
...error instanceof CommsError ? error.details : {},
|
|
23103
24548
|
approvalId: options.approvalId,
|
|
@@ -23262,7 +24707,7 @@ async function beginSendApproval(context, approvalId) {
|
|
|
23262
24707
|
domainNote: prepared.warnings[0] ?? "",
|
|
23263
24708
|
effective,
|
|
23264
24709
|
flags: record.riskFlags,
|
|
23265
|
-
|
|
24710
|
+
handoffs: context.handoffs
|
|
23266
24711
|
}),
|
|
23267
24712
|
challenge
|
|
23268
24713
|
};
|
|
@@ -23299,4 +24744,4 @@ async function revokeSendApproval(context, approvalId) {
|
|
|
23299
24744
|
});
|
|
23300
24745
|
}
|
|
23301
24746
|
//#endregion
|
|
23302
|
-
export {
|
|
24747
|
+
export { gatedChange as $, CHANGE_POLICIES as A, UPDATE_CHECK_CHILD_COMMAND as B, policyChange as C, REFUSED as Ct, sendPolicyOf as D, VERSION$1 as Dt, removeAccountChange as E, ROUTES as Et, CHANNEL_SERVERS as F, canPrompt as G, approvalKind as H, CommsError as I, cliHandoffs as J, changeToolResult as K, EXIT_CODES as L, SEND_POLICIES as M, checkNewName as N, showAccount as O, strictToolArguments as P, exemptFromUpdateGate as Q, PATH_OPTIONS as R, policyApprovalRefusal as S, RESEND_CALLER as St, policyWanted as T, RESEND_DOWNLOAD_ORIGIN as Tt, approvalsOf as U, agentMarker as V, approveChangeAtTerminal as W, commandPathOf as X, colorEnabled as Y, escapeForDisplay as Z, runDoctor as _, truncateDisplay as _t, revokeSendApproval as a, paint as at, listAccounts as b, writeResult as bt, downloadReceived as c, renderInstall as ct, listReceived as d, runCommand as dt, gatedChangeAtTerminal as et, listScheduled as f, runUpdateCheckChild as ft, showSentEmail as g, toCommsError as gt, showReceived as h, terminalUpdateHooks as ht, prepareSend as i, openCore as it, MODES as j, ResendContext as k, getMetrics as l, renderPrune as lt, listSuppressions as m, serverPruneChange as mt, executeSend as n, handoffSentenceToFill as nt, sendStatus as o, pathOverridesFromCliOptions as ot, listSentEmails as p, serverInstallChange as pt, checkForUpdates as q, finishSendApproval as r, installExitStatus as rt, cancelScheduledChange as s, refuseUnclaimedApproval as st, beginSendApproval as t, handoffSentence as tt, listDomains as u, resolvePaths as ut, addAccountChange as v, updateGateAtTerminal as vt, policyReport as w, RESEND_API_ORIGIN as wt, modeOf as x, PACKAGE_NAME as xt, inspectKey as y, updateToolGate as yt, SEND_LOOKUP as z };
|