@topolo/cli 0.13.13 → 0.13.15
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 +153 -0
- package/agent-packs/templates/career/skills/topolo/SKILL.md +7 -0
- package/agent-packs/templates/clients/skills/topolo/SKILL.md +7 -0
- package/agent-packs/templates/creative/skills/topolo/SKILL.md +7 -0
- package/agent-packs/templates/founder/skills/topolo/SKILL.md +7 -0
- package/agent-packs/templates/learning/skills/topolo/SKILL.md +7 -0
- package/agent-packs/templates/personal/skills/topolo/SKILL.md +7 -0
- package/agent-packs/templates/research/skills/topolo/SKILL.md +7 -0
- package/agent-packs/templates/ventures/skills/topolo/SKILL.md +7 -0
- package/dist/index.js +1398 -135
- package/package.json +3 -3
- package/skills/topolo/SKILL.md +7 -0
package/dist/index.js
CHANGED
|
@@ -269,8 +269,8 @@ function generateRequestId() {
|
|
|
269
269
|
}
|
|
270
270
|
var TopoloSdkError = class extends Error {
|
|
271
271
|
code;
|
|
272
|
-
constructor(code,
|
|
273
|
-
super(
|
|
272
|
+
constructor(code, message3) {
|
|
273
|
+
super(message3);
|
|
274
274
|
this.name = "TopoloSdkError";
|
|
275
275
|
this.code = code;
|
|
276
276
|
}
|
|
@@ -391,8 +391,8 @@ var TopoloAuthError = class extends TopoloSdkError {
|
|
|
391
391
|
requestId;
|
|
392
392
|
retryAfter;
|
|
393
393
|
details;
|
|
394
|
-
constructor(
|
|
395
|
-
super(canonicalTopoloServiceErrorCode(code) ?? "auth_error",
|
|
394
|
+
constructor(message3, code = "auth_error", options = {}) {
|
|
395
|
+
super(canonicalTopoloServiceErrorCode(code) ?? "auth_error", message3);
|
|
396
396
|
this.name = "TopoloAuthError";
|
|
397
397
|
this.status = options.status ?? 401;
|
|
398
398
|
this.body = options.body;
|
|
@@ -414,10 +414,10 @@ var TopoloPermissionError = class extends TopoloSdkError {
|
|
|
414
414
|
requestId;
|
|
415
415
|
retryAfter;
|
|
416
416
|
details;
|
|
417
|
-
constructor(
|
|
417
|
+
constructor(message3, required, options = {}) {
|
|
418
418
|
super(
|
|
419
419
|
canonicalTopoloServiceErrorCode(options.code) ?? serviceErrorCode(options.body, "permission_denied"),
|
|
420
|
-
|
|
420
|
+
message3
|
|
421
421
|
);
|
|
422
422
|
this.name = "TopoloPermissionError";
|
|
423
423
|
this.required = canonicalTopoloRequired(required);
|
|
@@ -458,8 +458,8 @@ function extractTopoloServiceErrorMessage(body) {
|
|
|
458
458
|
candidates.push(record4.message, record4.error_description);
|
|
459
459
|
if (typeof nested === "string" && !canonicalTopoloServiceErrorCode(nested)) candidates.push(nested);
|
|
460
460
|
for (const candidate of candidates) {
|
|
461
|
-
const
|
|
462
|
-
if (
|
|
461
|
+
const message3 = sanitizeTopoloServiceMessage(candidate);
|
|
462
|
+
if (message3) return message3;
|
|
463
463
|
}
|
|
464
464
|
return void 0;
|
|
465
465
|
}
|
|
@@ -533,8 +533,8 @@ function validationIssueDetails(issues) {
|
|
|
533
533
|
}
|
|
534
534
|
return paths.length ? { paths, issues } : { issues };
|
|
535
535
|
}
|
|
536
|
-
function issue(path,
|
|
537
|
-
const text3 = sanitizeTopoloServiceMessage(
|
|
536
|
+
function issue(path, message3) {
|
|
537
|
+
const text3 = sanitizeTopoloServiceMessage(message3);
|
|
538
538
|
if (!path && !text3) return null;
|
|
539
539
|
return { ...path ? { path } : {}, ...text3 ? { message: text3 } : {} };
|
|
540
540
|
}
|
|
@@ -554,8 +554,8 @@ function canonicalTopoloValidationIssues(values) {
|
|
|
554
554
|
function flattenedValidationIssues(formErrors, fieldErrors) {
|
|
555
555
|
const issues = [];
|
|
556
556
|
if (Array.isArray(formErrors)) {
|
|
557
|
-
for (const
|
|
558
|
-
const projected = issue(void 0,
|
|
557
|
+
for (const message3 of formErrors) {
|
|
558
|
+
const projected = issue(void 0, message3);
|
|
559
559
|
if (projected) issues.push(projected);
|
|
560
560
|
}
|
|
561
561
|
}
|
|
@@ -564,8 +564,8 @@ function flattenedValidationIssues(formErrors, fieldErrors) {
|
|
|
564
564
|
const path = canonicalTopoloValidationPath(key);
|
|
565
565
|
if (!path) continue;
|
|
566
566
|
const list = Array.isArray(messages) && messages.length ? messages : [void 0];
|
|
567
|
-
for (const
|
|
568
|
-
const projected = issue(path,
|
|
567
|
+
for (const message3 of list) {
|
|
568
|
+
const projected = issue(path, message3);
|
|
569
569
|
if (projected) issues.push(projected);
|
|
570
570
|
}
|
|
571
571
|
}
|
|
@@ -645,8 +645,8 @@ function topoloErrorHint(error) {
|
|
|
645
645
|
return void 0;
|
|
646
646
|
}
|
|
647
647
|
var TopoloConfirmationRequiredError = class extends TopoloSdkError {
|
|
648
|
-
constructor(
|
|
649
|
-
super("confirmation_required",
|
|
648
|
+
constructor(message3) {
|
|
649
|
+
super("confirmation_required", message3);
|
|
650
650
|
this.name = "TopoloConfirmationRequiredError";
|
|
651
651
|
}
|
|
652
652
|
};
|
|
@@ -658,8 +658,8 @@ var TopoloHttpError = class extends TopoloSdkError {
|
|
|
658
658
|
requestId;
|
|
659
659
|
retryAfter;
|
|
660
660
|
details;
|
|
661
|
-
constructor(service, path, status, body,
|
|
662
|
-
super(serviceErrorCode(body, "http_error"),
|
|
661
|
+
constructor(service, path, status, body, message3, options = {}) {
|
|
662
|
+
super(serviceErrorCode(body, "http_error"), message3 ?? `HTTP ${status} from ${service}${path}`);
|
|
663
663
|
this.name = "TopoloHttpError";
|
|
664
664
|
this.status = status;
|
|
665
665
|
this.body = body;
|
|
@@ -681,8 +681,8 @@ function topoloRetryAfterSeconds(value, now = Date.now()) {
|
|
|
681
681
|
var TopoloRateLimitError = class extends TopoloHttpError {
|
|
682
682
|
retryable = true;
|
|
683
683
|
retryAfterSeconds;
|
|
684
|
-
constructor(service, path, body,
|
|
685
|
-
super(service, path, 429, body,
|
|
684
|
+
constructor(service, path, body, message3, options = {}) {
|
|
685
|
+
super(service, path, 429, body, message3, options);
|
|
686
686
|
this.name = "TopoloRateLimitError";
|
|
687
687
|
if (this.code === "http_error") this.code = "rate_limited";
|
|
688
688
|
this.retryAfterSeconds = topoloRetryAfterSeconds(this.retryAfter);
|
|
@@ -1262,8 +1262,8 @@ var ASSURANCE_LEVELS = /* @__PURE__ */ new Set(["authenticated", "workload_ident
|
|
|
1262
1262
|
var HASH_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
1263
1263
|
var ActorEnvelopeValidationError = class extends Error {
|
|
1264
1264
|
path;
|
|
1265
|
-
constructor(path,
|
|
1266
|
-
super(`${path}: ${
|
|
1265
|
+
constructor(path, message3) {
|
|
1266
|
+
super(`${path}: ${message3}`);
|
|
1267
1267
|
this.name = "ActorEnvelopeValidationError";
|
|
1268
1268
|
this.path = path;
|
|
1269
1269
|
}
|
|
@@ -1380,8 +1380,8 @@ function parseAccountableSubject(value) {
|
|
|
1380
1380
|
}
|
|
1381
1381
|
async function sha256CanonicalValue(value) {
|
|
1382
1382
|
const bytes = new TextEncoder().encode(canonicalJson(value));
|
|
1383
|
-
const
|
|
1384
|
-
return `sha256:${Array.from(new Uint8Array(
|
|
1383
|
+
const digest2 = await crypto.subtle.digest("SHA-256", bytes);
|
|
1384
|
+
return `sha256:${Array.from(new Uint8Array(digest2), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1385
1385
|
}
|
|
1386
1386
|
function parseIdentity(value, path) {
|
|
1387
1387
|
const identity = record2(value, path);
|
|
@@ -1460,8 +1460,8 @@ function textArray(value, path) {
|
|
|
1460
1460
|
function optionalTextFields(value, path, keys) {
|
|
1461
1461
|
return Object.fromEntries(keys.flatMap((key) => value[key] === void 0 ? [] : [[key, text(value[key], `${path}.${key}`)]]));
|
|
1462
1462
|
}
|
|
1463
|
-
function fail(path,
|
|
1464
|
-
throw new ActorEnvelopeValidationError(path,
|
|
1463
|
+
function fail(path, message3) {
|
|
1464
|
+
throw new ActorEnvelopeValidationError(path, message3);
|
|
1465
1465
|
}
|
|
1466
1466
|
function parseTopoloActionUploadContract(value) {
|
|
1467
1467
|
if (!isRecord3(value) || value.profile !== "inline-base64" && value.profile !== "url-session") {
|
|
@@ -1897,7 +1897,7 @@ var TopoloClient = class _TopoloClient {
|
|
|
1897
1897
|
}
|
|
1898
1898
|
if (!res.ok) {
|
|
1899
1899
|
const durationMs = Date.now() - startedAt;
|
|
1900
|
-
const
|
|
1900
|
+
const message3 = describeError(parsed, `HTTP ${res.status}`);
|
|
1901
1901
|
const publicCode = extractTopoloServiceErrorCode(parsed) ?? "http_error";
|
|
1902
1902
|
this.emit({
|
|
1903
1903
|
phase: "error",
|
|
@@ -1937,7 +1937,7 @@ var TopoloClient = class _TopoloClient {
|
|
|
1937
1937
|
resolvedService.serviceKey,
|
|
1938
1938
|
opts.path,
|
|
1939
1939
|
parsed,
|
|
1940
|
-
|
|
1940
|
+
message3,
|
|
1941
1941
|
errorContext
|
|
1942
1942
|
);
|
|
1943
1943
|
}
|
|
@@ -1946,7 +1946,7 @@ var TopoloClient = class _TopoloClient {
|
|
|
1946
1946
|
opts.path,
|
|
1947
1947
|
res.status,
|
|
1948
1948
|
parsed,
|
|
1949
|
-
|
|
1949
|
+
message3,
|
|
1950
1950
|
errorContext
|
|
1951
1951
|
);
|
|
1952
1952
|
}
|
|
@@ -3902,8 +3902,8 @@ import { homedir as homedir3, platform as hostPlatform } from "os";
|
|
|
3902
3902
|
import { dirname as dirname3, join } from "path";
|
|
3903
3903
|
var TopoloSdkError2 = class extends Error {
|
|
3904
3904
|
code;
|
|
3905
|
-
constructor(code,
|
|
3906
|
-
super(
|
|
3905
|
+
constructor(code, message3) {
|
|
3906
|
+
super(message3);
|
|
3907
3907
|
this.name = "TopoloSdkError";
|
|
3908
3908
|
this.code = code;
|
|
3909
3909
|
}
|
|
@@ -4024,8 +4024,8 @@ var TopoloAuthError2 = class extends TopoloSdkError2 {
|
|
|
4024
4024
|
requestId;
|
|
4025
4025
|
retryAfter;
|
|
4026
4026
|
details;
|
|
4027
|
-
constructor(
|
|
4028
|
-
super(canonicalTopoloServiceErrorCode2(code) ?? "auth_error",
|
|
4027
|
+
constructor(message3, code = "auth_error", options = {}) {
|
|
4028
|
+
super(canonicalTopoloServiceErrorCode2(code) ?? "auth_error", message3);
|
|
4029
4029
|
this.name = "TopoloAuthError";
|
|
4030
4030
|
this.status = options.status ?? 401;
|
|
4031
4031
|
this.body = options.body;
|
|
@@ -4123,8 +4123,8 @@ function validationIssueDetails2(issues) {
|
|
|
4123
4123
|
}
|
|
4124
4124
|
return paths.length ? { paths, issues } : { issues };
|
|
4125
4125
|
}
|
|
4126
|
-
function issue2(path,
|
|
4127
|
-
const text2 = sanitizeTopoloServiceMessage2(
|
|
4126
|
+
function issue2(path, message3) {
|
|
4127
|
+
const text2 = sanitizeTopoloServiceMessage2(message3);
|
|
4128
4128
|
if (!path && !text2) return null;
|
|
4129
4129
|
return { ...path ? { path } : {}, ...text2 ? { message: text2 } : {} };
|
|
4130
4130
|
}
|
|
@@ -4144,8 +4144,8 @@ function canonicalTopoloValidationIssues2(values) {
|
|
|
4144
4144
|
function flattenedValidationIssues2(formErrors, fieldErrors) {
|
|
4145
4145
|
const issues = [];
|
|
4146
4146
|
if (Array.isArray(formErrors)) {
|
|
4147
|
-
for (const
|
|
4148
|
-
const projected = issue2(void 0,
|
|
4147
|
+
for (const message3 of formErrors) {
|
|
4148
|
+
const projected = issue2(void 0, message3);
|
|
4149
4149
|
if (projected) issues.push(projected);
|
|
4150
4150
|
}
|
|
4151
4151
|
}
|
|
@@ -4154,8 +4154,8 @@ function flattenedValidationIssues2(formErrors, fieldErrors) {
|
|
|
4154
4154
|
const path = canonicalTopoloValidationPath2(key);
|
|
4155
4155
|
if (!path) continue;
|
|
4156
4156
|
const list = Array.isArray(messages) && messages.length ? messages : [void 0];
|
|
4157
|
-
for (const
|
|
4158
|
-
const projected = issue2(path,
|
|
4157
|
+
for (const message3 of list) {
|
|
4158
|
+
const projected = issue2(path, message3);
|
|
4159
4159
|
if (projected) issues.push(projected);
|
|
4160
4160
|
}
|
|
4161
4161
|
}
|
|
@@ -4189,8 +4189,8 @@ var TopoloHttpError2 = class extends TopoloSdkError2 {
|
|
|
4189
4189
|
requestId;
|
|
4190
4190
|
retryAfter;
|
|
4191
4191
|
details;
|
|
4192
|
-
constructor(service, path, status, body,
|
|
4193
|
-
super(serviceErrorCode2(body, "http_error"),
|
|
4192
|
+
constructor(service, path, status, body, message3, options = {}) {
|
|
4193
|
+
super(serviceErrorCode2(body, "http_error"), message3 ?? `HTTP ${status} from ${service}${path}`);
|
|
4194
4194
|
this.name = "TopoloHttpError";
|
|
4195
4195
|
this.status = status;
|
|
4196
4196
|
this.body = body;
|
|
@@ -4212,8 +4212,8 @@ function topoloRetryAfterSeconds2(value, now = Date.now()) {
|
|
|
4212
4212
|
var TopoloRateLimitError2 = class extends TopoloHttpError2 {
|
|
4213
4213
|
retryable = true;
|
|
4214
4214
|
retryAfterSeconds;
|
|
4215
|
-
constructor(service, path, body,
|
|
4216
|
-
super(service, path, 429, body,
|
|
4215
|
+
constructor(service, path, body, message3, options = {}) {
|
|
4216
|
+
super(service, path, 429, body, message3, options);
|
|
4217
4217
|
this.name = "TopoloRateLimitError";
|
|
4218
4218
|
if (this.code === "http_error") this.code = "rate_limited";
|
|
4219
4219
|
this.retryAfterSeconds = topoloRetryAfterSeconds2(this.retryAfter);
|
|
@@ -4221,8 +4221,8 @@ var TopoloRateLimitError2 = class extends TopoloHttpError2 {
|
|
|
4221
4221
|
};
|
|
4222
4222
|
var TopoloReauthRequiredError = class extends TopoloAuthError2 {
|
|
4223
4223
|
reauthorization;
|
|
4224
|
-
constructor(
|
|
4225
|
-
super(
|
|
4224
|
+
constructor(message3, reauthorization, options = {}) {
|
|
4225
|
+
super(message3, "reauth_required", { status: 401, service: "auth", path: "/api/developer-oauth/token", ...options });
|
|
4226
4226
|
this.name = "TopoloReauthRequiredError";
|
|
4227
4227
|
this.reauthorization = Object.freeze({ ...reauthorization });
|
|
4228
4228
|
}
|
|
@@ -5228,12 +5228,12 @@ function renewalFailure(error) {
|
|
|
5228
5228
|
if (error instanceof ConfigOwnershipError) return new ConfigOwnershipError();
|
|
5229
5229
|
if (error instanceof TopoloReauthRequiredError) return error;
|
|
5230
5230
|
if ((error instanceof TopoloAuthError2 || error instanceof TopoloHttpError2) && error.status === 429) {
|
|
5231
|
-
const
|
|
5231
|
+
const message3 = "Topolo sign-in renewal is temporarily rate limited by Auth. Retry after the Retry-After interval; the saved sign-in has been retained.";
|
|
5232
5232
|
return new TopoloRateLimitError2(
|
|
5233
5233
|
"auth",
|
|
5234
5234
|
path,
|
|
5235
|
-
{ code: "rate_limited", retryable: true, message:
|
|
5236
|
-
|
|
5235
|
+
{ code: "rate_limited", retryable: true, message: message3 },
|
|
5236
|
+
message3,
|
|
5237
5237
|
{ requestId: error.requestId, retryAfter: error.retryAfter }
|
|
5238
5238
|
);
|
|
5239
5239
|
}
|
|
@@ -6520,6 +6520,363 @@ function formatActionPlan(plan) {
|
|
|
6520
6520
|
return lines.join("\n");
|
|
6521
6521
|
}
|
|
6522
6522
|
|
|
6523
|
+
// src/lib/credential-custody.ts
|
|
6524
|
+
import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
|
|
6525
|
+
import { constants as constants2 } from "fs";
|
|
6526
|
+
import { chmod as chmod3, lstat as lstat2, mkdir as mkdir3, open, rename as rename2, rm as rm2 } from "fs/promises";
|
|
6527
|
+
import { basename as basename2, isAbsolute, join as join5, normalize, sep } from "path";
|
|
6528
|
+
var CUSTODY_SCHEMA_VERSION = 1;
|
|
6529
|
+
var CUSTODY_API_KEY_FILE = "api-key.json";
|
|
6530
|
+
var CUSTODY_METADATA_FILE = "custody.json";
|
|
6531
|
+
var API_KEYS_LIST_ACTION = "app_topolo_auth.api_keys.list";
|
|
6532
|
+
var CUSTODY_ENVIRONMENT_NAMES = ["production", "staging", "development", "local"];
|
|
6533
|
+
var CustodyError = class extends Error {
|
|
6534
|
+
code;
|
|
6535
|
+
constructor(code, message3) {
|
|
6536
|
+
super(message3);
|
|
6537
|
+
this.name = "CustodyError";
|
|
6538
|
+
this.code = code;
|
|
6539
|
+
}
|
|
6540
|
+
};
|
|
6541
|
+
async function writeCredentialCustody(request, deps) {
|
|
6542
|
+
if (request.confirm !== true) {
|
|
6543
|
+
throw new CustodyError(
|
|
6544
|
+
"custody_confirmation_required",
|
|
6545
|
+
"Writing a credential into custody requires --confirm. The custody file will hold the plaintext key."
|
|
6546
|
+
);
|
|
6547
|
+
}
|
|
6548
|
+
const appId = requiredText(request.appId, "--app");
|
|
6549
|
+
const apiKeyId = requiredText(request.apiKeyId, "--api-key-id");
|
|
6550
|
+
const platform4 = deps.platform ?? process.platform;
|
|
6551
|
+
const uid = deps.uid === void 0 ? currentUid() : deps.uid;
|
|
6552
|
+
if (platform4 === "win32" || uid === null) {
|
|
6553
|
+
throw new CustodyError("custody_platform_unsupported", "Credential custody files require POSIX ownership and permissions.");
|
|
6554
|
+
}
|
|
6555
|
+
const directory = custodyDirectory(request.outDir);
|
|
6556
|
+
const leaf = basename2(directory);
|
|
6557
|
+
if (CUSTODY_ENVIRONMENT_NAMES.includes(leaf) && leaf !== deps.environment) {
|
|
6558
|
+
throw new CustodyError(
|
|
6559
|
+
"custody_environment_mismatch",
|
|
6560
|
+
`--out names the ${leaf} environment but the credential is for ${deps.environment}.`
|
|
6561
|
+
);
|
|
6562
|
+
}
|
|
6563
|
+
await ensureCustodyDirectory(directory, uid);
|
|
6564
|
+
const apiKeyPath = join5(directory, CUSTODY_API_KEY_FILE);
|
|
6565
|
+
const custodyPath = join5(directory, CUSTODY_METADATA_FILE);
|
|
6566
|
+
const existing = [await destinationExists(apiKeyPath), await destinationExists(custodyPath)];
|
|
6567
|
+
if (existing.some(Boolean) && request.replace !== true) {
|
|
6568
|
+
throw new CustodyError(
|
|
6569
|
+
"custody_file_exists",
|
|
6570
|
+
`Custody files already exist in ${directory}. Pass --replace to overwrite them.`
|
|
6571
|
+
);
|
|
6572
|
+
}
|
|
6573
|
+
const identity = await deps.identity();
|
|
6574
|
+
const organizationId = identity.organization?.id;
|
|
6575
|
+
if (!organizationId) {
|
|
6576
|
+
throw new CustodyError(
|
|
6577
|
+
"custody_organization_required",
|
|
6578
|
+
"Credential custody requires an organization context. Pin one with TOPOLO_CONTEXT=org:<organizationId>."
|
|
6579
|
+
);
|
|
6580
|
+
}
|
|
6581
|
+
const key = findListedKey(await deps.listApiKeys(appId), apiKeyId);
|
|
6582
|
+
assertKeyUsable(key, appId, organizationId, (deps.now ?? Date.now)());
|
|
6583
|
+
const secret = await deps.store.load(request.reference);
|
|
6584
|
+
if (typeof secret !== "string" || !secret || /\s/.test(secret)) {
|
|
6585
|
+
throw new CustodyError(
|
|
6586
|
+
"custody_secret_unsupported_type",
|
|
6587
|
+
"The protected reference does not hold a single API key string."
|
|
6588
|
+
);
|
|
6589
|
+
}
|
|
6590
|
+
try {
|
|
6591
|
+
await assertSecretIsKey(deps.introspectKey, secret, key);
|
|
6592
|
+
const custody = {
|
|
6593
|
+
schemaVersion: CUSTODY_SCHEMA_VERSION,
|
|
6594
|
+
appId: key.appId,
|
|
6595
|
+
scopes: [...key.scopes],
|
|
6596
|
+
organizationId: key.organizationId,
|
|
6597
|
+
environment: deps.environment,
|
|
6598
|
+
apiKeyId: key.id,
|
|
6599
|
+
expiresAt: key.expiresAt,
|
|
6600
|
+
keyDigest: `sha256:${createHash3("sha256").update(secret, "utf8").digest("hex")}`,
|
|
6601
|
+
createdAt: key.createdAt
|
|
6602
|
+
};
|
|
6603
|
+
await writeCustodyFiles(directory, [
|
|
6604
|
+
[CUSTODY_API_KEY_FILE, `${JSON.stringify({ schemaVersion: CUSTODY_SCHEMA_VERSION, apiKey: secret })}
|
|
6605
|
+
`],
|
|
6606
|
+
[CUSTODY_METADATA_FILE, `${JSON.stringify(custody, null, 2)}
|
|
6607
|
+
`]
|
|
6608
|
+
]);
|
|
6609
|
+
try {
|
|
6610
|
+
await deps.store.delete(request.reference);
|
|
6611
|
+
} catch {
|
|
6612
|
+
throw new CustodyError(
|
|
6613
|
+
"custody_reference_release_failed",
|
|
6614
|
+
`Custody files were written to ${directory}, but the local protected reference could not be deleted. Run \`topolo actions release-secret <reference> --confirm\`.`
|
|
6615
|
+
);
|
|
6616
|
+
}
|
|
6617
|
+
return {
|
|
6618
|
+
written: true,
|
|
6619
|
+
replaced: existing.some(Boolean),
|
|
6620
|
+
directory,
|
|
6621
|
+
files: { apiKey: apiKeyPath, custody: custodyPath },
|
|
6622
|
+
custody,
|
|
6623
|
+
reference: { released: true, upstreamRevoked: false }
|
|
6624
|
+
};
|
|
6625
|
+
} catch (error) {
|
|
6626
|
+
throw withheldIfSecretLeaks(error, secret);
|
|
6627
|
+
}
|
|
6628
|
+
}
|
|
6629
|
+
function requiredText(value, flag) {
|
|
6630
|
+
const text2 = value?.trim();
|
|
6631
|
+
if (!text2) throw new CustodyError("custody_invalid_input", `${flag} is required.`);
|
|
6632
|
+
return text2;
|
|
6633
|
+
}
|
|
6634
|
+
function currentUid() {
|
|
6635
|
+
return typeof process.getuid === "function" ? process.getuid() : null;
|
|
6636
|
+
}
|
|
6637
|
+
function custodyDirectory(value) {
|
|
6638
|
+
const raw = value?.trim();
|
|
6639
|
+
if (!raw || !isAbsolute(raw)) {
|
|
6640
|
+
throw new CustodyError("custody_path_not_absolute", "--out must be an absolute directory path.");
|
|
6641
|
+
}
|
|
6642
|
+
const normalized = normalize(raw);
|
|
6643
|
+
const directory = normalized.endsWith(sep) ? normalized.slice(0, -1) : normalized;
|
|
6644
|
+
if (!directory) {
|
|
6645
|
+
throw new CustodyError("custody_dir_insecure", "--out must name a dedicated directory, not the filesystem root.");
|
|
6646
|
+
}
|
|
6647
|
+
return directory;
|
|
6648
|
+
}
|
|
6649
|
+
async function lstatOrNull(path) {
|
|
6650
|
+
try {
|
|
6651
|
+
return await lstat2(path);
|
|
6652
|
+
} catch (error) {
|
|
6653
|
+
if (error.code === "ENOENT") return null;
|
|
6654
|
+
throw error;
|
|
6655
|
+
}
|
|
6656
|
+
}
|
|
6657
|
+
async function ensureCustodyDirectory(directory, uid) {
|
|
6658
|
+
const components = directory.split(sep).filter(Boolean);
|
|
6659
|
+
let current = sep;
|
|
6660
|
+
assertSecureAncestor(current, await lstat2(current), uid);
|
|
6661
|
+
for (let index = 0; index < components.length; index += 1) {
|
|
6662
|
+
current = join5(current, components[index]);
|
|
6663
|
+
const last = index === components.length - 1;
|
|
6664
|
+
let stats = await lstatOrNull(current);
|
|
6665
|
+
if (!stats) {
|
|
6666
|
+
await mkdir3(current, { mode: 448 });
|
|
6667
|
+
await chmod3(current, 448);
|
|
6668
|
+
stats = await lstat2(current);
|
|
6669
|
+
}
|
|
6670
|
+
if (stats.isSymbolicLink()) {
|
|
6671
|
+
throw new CustodyError("custody_path_symlink", `Custody path component ${current} is a symbolic link.`);
|
|
6672
|
+
}
|
|
6673
|
+
if (!stats.isDirectory()) {
|
|
6674
|
+
throw new CustodyError("custody_path_not_directory", `Custody path component ${current} is not a directory.`);
|
|
6675
|
+
}
|
|
6676
|
+
if (last) assertPrivateDirectory(current, stats, uid);
|
|
6677
|
+
else assertSecureAncestor(current, stats, uid);
|
|
6678
|
+
}
|
|
6679
|
+
}
|
|
6680
|
+
function assertPrivateDirectory(path, stats, uid) {
|
|
6681
|
+
if (stats.uid !== uid || (stats.mode & 511) !== 448) {
|
|
6682
|
+
throw new CustodyError(
|
|
6683
|
+
"custody_dir_insecure",
|
|
6684
|
+
`Custody directory ${path} must be owned by the current user with mode 0700.`
|
|
6685
|
+
);
|
|
6686
|
+
}
|
|
6687
|
+
}
|
|
6688
|
+
function assertSecureAncestor(path, stats, uid) {
|
|
6689
|
+
const rootSticky = stats.uid === 0 && (stats.mode & 512) !== 0;
|
|
6690
|
+
if (stats.uid !== 0 && stats.uid !== uid || (stats.mode & 18) !== 0 && !rootSticky) {
|
|
6691
|
+
throw new CustodyError(
|
|
6692
|
+
"custody_parent_insecure",
|
|
6693
|
+
`Custody parent ${path} must be owned by root or the current user and must not be group- or world-writable.`
|
|
6694
|
+
);
|
|
6695
|
+
}
|
|
6696
|
+
}
|
|
6697
|
+
async function destinationExists(path) {
|
|
6698
|
+
const stats = await lstatOrNull(path);
|
|
6699
|
+
if (!stats) return false;
|
|
6700
|
+
if (stats.isSymbolicLink()) {
|
|
6701
|
+
throw new CustodyError("custody_path_symlink", `Custody file ${path} is a symbolic link.`);
|
|
6702
|
+
}
|
|
6703
|
+
if (!stats.isFile()) {
|
|
6704
|
+
throw new CustodyError("custody_path_not_regular", `Custody file ${path} is not a regular file.`);
|
|
6705
|
+
}
|
|
6706
|
+
return true;
|
|
6707
|
+
}
|
|
6708
|
+
function isRecord5(value) {
|
|
6709
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6710
|
+
}
|
|
6711
|
+
function findListedKey(result, apiKeyId) {
|
|
6712
|
+
const data = isRecord5(result) && isRecord5(result.data) ? result.data : result;
|
|
6713
|
+
const keys = isRecord5(data) ? data.keys : void 0;
|
|
6714
|
+
if (!Array.isArray(keys)) {
|
|
6715
|
+
throw new CustodyError("custody_metadata_unavailable", "Auth api_keys.list did not return a key inventory.");
|
|
6716
|
+
}
|
|
6717
|
+
const match = keys.find((key) => isRecord5(key) && key.id === apiKeyId);
|
|
6718
|
+
if (!match) {
|
|
6719
|
+
throw new CustodyError(
|
|
6720
|
+
"custody_key_not_found",
|
|
6721
|
+
`API key ${apiKeyId} is not visible to this credential in api_keys.list.`
|
|
6722
|
+
);
|
|
6723
|
+
}
|
|
6724
|
+
const { id, appId, organizationId, scopes, expiresAt, createdAt, status } = match;
|
|
6725
|
+
if (typeof id !== "string" || typeof appId !== "string" || typeof organizationId !== "string" || !Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string") || !(expiresAt === null || typeof expiresAt === "number") || typeof createdAt !== "number" || typeof status !== "string") {
|
|
6726
|
+
throw new CustodyError("custody_metadata_unavailable", `Auth returned incomplete metadata for API key ${apiKeyId}.`);
|
|
6727
|
+
}
|
|
6728
|
+
return { id, appId, organizationId, scopes, expiresAt, createdAt, status };
|
|
6729
|
+
}
|
|
6730
|
+
function assertKeyUsable(key, appId, organizationId, nowMs) {
|
|
6731
|
+
if (key.appId !== appId) {
|
|
6732
|
+
throw new CustodyError("custody_app_mismatch", `API key ${key.id} belongs to ${key.appId}, not ${appId}.`);
|
|
6733
|
+
}
|
|
6734
|
+
if (key.organizationId !== organizationId) {
|
|
6735
|
+
throw new CustodyError(
|
|
6736
|
+
"custody_organization_mismatch",
|
|
6737
|
+
`API key ${key.id} belongs to another organization than the verified credential.`
|
|
6738
|
+
);
|
|
6739
|
+
}
|
|
6740
|
+
if (key.status !== "active") {
|
|
6741
|
+
throw new CustodyError("custody_key_inactive", `API key ${key.id} is ${key.status}, not active.`);
|
|
6742
|
+
}
|
|
6743
|
+
if (key.expiresAt !== null && key.expiresAt * 1e3 <= nowMs) {
|
|
6744
|
+
throw new CustodyError("custody_key_expired", `API key ${key.id} has expired.`);
|
|
6745
|
+
}
|
|
6746
|
+
}
|
|
6747
|
+
async function assertSecretIsKey(introspectKey, secret, key) {
|
|
6748
|
+
let identity;
|
|
6749
|
+
try {
|
|
6750
|
+
identity = await introspectKey(secret);
|
|
6751
|
+
} catch (error) {
|
|
6752
|
+
const code = error.code;
|
|
6753
|
+
const status = error.status;
|
|
6754
|
+
const detail = [typeof code === "string" ? code : null, typeof status === "number" ? `HTTP ${status}` : null].filter(Boolean).join(", ");
|
|
6755
|
+
throw new CustodyError(
|
|
6756
|
+
"custody_key_verification_failed",
|
|
6757
|
+
`Auth could not verify the protected key${detail ? ` (${detail})` : ""}. Nothing was written.`
|
|
6758
|
+
);
|
|
6759
|
+
}
|
|
6760
|
+
if (identity.kind !== "api_key" || identity.apiKey?.id !== key.id || identity.apiKey.appId !== key.appId || identity.organization?.id !== key.organizationId) {
|
|
6761
|
+
throw new CustodyError(
|
|
6762
|
+
"custody_key_mismatch",
|
|
6763
|
+
`The protected reference does not hold API key ${key.id} for ${key.appId}. Nothing was written.`
|
|
6764
|
+
);
|
|
6765
|
+
}
|
|
6766
|
+
const introspected = [...identity.apiKey.scopes].sort();
|
|
6767
|
+
const listed = [...key.scopes].sort();
|
|
6768
|
+
if (introspected.length !== listed.length || introspected.some((scope, index) => scope !== listed[index])) {
|
|
6769
|
+
throw new CustodyError(
|
|
6770
|
+
"custody_metadata_mismatch",
|
|
6771
|
+
`Auth introspection and api_keys.list disagree on the scopes of API key ${key.id}. Nothing was written.`
|
|
6772
|
+
);
|
|
6773
|
+
}
|
|
6774
|
+
}
|
|
6775
|
+
async function writeCustodyFiles(directory, files) {
|
|
6776
|
+
const staged = [];
|
|
6777
|
+
try {
|
|
6778
|
+
for (const [name, content] of files) {
|
|
6779
|
+
const temporary = join5(directory, `.${name}.${process.pid}.${randomUUID3()}.tmp`);
|
|
6780
|
+
staged.push([temporary, join5(directory, name)]);
|
|
6781
|
+
const handle = await open(
|
|
6782
|
+
temporary,
|
|
6783
|
+
constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW,
|
|
6784
|
+
384
|
|
6785
|
+
);
|
|
6786
|
+
try {
|
|
6787
|
+
await handle.chmod(384);
|
|
6788
|
+
await handle.writeFile(content, "utf8");
|
|
6789
|
+
await handle.sync();
|
|
6790
|
+
} finally {
|
|
6791
|
+
await handle.close();
|
|
6792
|
+
}
|
|
6793
|
+
}
|
|
6794
|
+
for (const [temporary, destination] of staged) await rename2(temporary, destination);
|
|
6795
|
+
} finally {
|
|
6796
|
+
await Promise.all(staged.map(([temporary]) => rm2(temporary, { force: true })));
|
|
6797
|
+
}
|
|
6798
|
+
}
|
|
6799
|
+
function withheldIfSecretLeaks(error, secret) {
|
|
6800
|
+
const variants = [secret, encodeURIComponent(secret)];
|
|
6801
|
+
const record3 = error;
|
|
6802
|
+
const parts = [String(error), String(record3?.message), String(record3?.stack)];
|
|
6803
|
+
for (const value of [record3?.body, record3?.details]) {
|
|
6804
|
+
try {
|
|
6805
|
+
parts.push(String(value), JSON.stringify(value ?? null));
|
|
6806
|
+
} catch {
|
|
6807
|
+
}
|
|
6808
|
+
}
|
|
6809
|
+
if (!parts.some((part) => variants.some((variant) => part.includes(variant)))) return error;
|
|
6810
|
+
return new CustodyError(
|
|
6811
|
+
"custody_error_withheld",
|
|
6812
|
+
"Credential custody failed; the underlying error was withheld because it referenced the protected credential."
|
|
6813
|
+
);
|
|
6814
|
+
}
|
|
6815
|
+
|
|
6816
|
+
// src/commands/credentials.ts
|
|
6817
|
+
function registerCredentialsCommand(program2) {
|
|
6818
|
+
const credentials = program2.command("credentials").description("Move protected credentials into operator-owned custody without displaying them");
|
|
6819
|
+
credentials.command("write-custody <reference>").description(
|
|
6820
|
+
"Write the API key behind a protected topolo-secret reference into a 0700 custody directory, then delete the reference"
|
|
6821
|
+
).option("--out <dir>", "Absolute custody directory (created 0700, or an existing 0700 directory you own)").option("--app <appId>", "Application id the key must belong to").option("--api-key-id <id>", "Auth API key id the reference must hold (data.key.id from api_keys.create)").option("--confirm", "Acknowledge writing the plaintext key to disk").option("--replace", "Atomically replace existing custody files").option("--json", "Output JSON").option("--no-json", "Output human-readable text").addHelpText(
|
|
6822
|
+
"after",
|
|
6823
|
+
'\nThe reference is resolved through the same OS protected store that `actions call` and the stdio MCP use,\nbound to the verified principal, organization, API key identity and environment. Key metadata is read back\nfrom Auth api_keys.list and the key is verified by Auth introspection before anything is written.\nWrites api-key.json ({"schemaVersion":1,"apiKey":...}) and custody.json (metadata plus sha256 keyDigest),\nboth 0600 via temporary file and rename. Output never contains the key. Deleting the local reference does\nnot revoke the upstream key; use app_topolo_auth.api_keys.revoke for that.\nKeep one key per environment with --out <dir>/<environment>; a directory named after an environment must\nmatch the credential environment (topolo --env <environment>).'
|
|
6824
|
+
).action(async (reference, opts) => {
|
|
6825
|
+
const mode = resolveOutputMode(opts.json);
|
|
6826
|
+
try {
|
|
6827
|
+
const environment = selectedEnvironment();
|
|
6828
|
+
let client;
|
|
6829
|
+
const topolo = () => client ??= buildClient({ environment }).then((built) => {
|
|
6830
|
+
if (!built) throw new Error("No credential configured. Run `topolo auth login`.");
|
|
6831
|
+
return built;
|
|
6832
|
+
});
|
|
6833
|
+
let verified;
|
|
6834
|
+
const identity = () => verified ??= topolo().then((built) => built.client.introspect());
|
|
6835
|
+
const result = await writeCredentialCustody(
|
|
6836
|
+
{
|
|
6837
|
+
reference,
|
|
6838
|
+
outDir: opts.out,
|
|
6839
|
+
appId: opts.app,
|
|
6840
|
+
apiKeyId: opts.apiKeyId,
|
|
6841
|
+
confirm: opts.confirm === true,
|
|
6842
|
+
replace: opts.replace === true
|
|
6843
|
+
},
|
|
6844
|
+
{
|
|
6845
|
+
environment,
|
|
6846
|
+
identity,
|
|
6847
|
+
store: createProtectedActionSecretStore({ environment, identity }),
|
|
6848
|
+
listApiKeys: async (appId) => (await topolo()).client.readAction(API_KEYS_LIST_ACTION, { appId }),
|
|
6849
|
+
introspectKey: async (secret) => {
|
|
6850
|
+
const profile = resolvedProfileConfig(await readConfig(), environment);
|
|
6851
|
+
return createTopolo({
|
|
6852
|
+
credential: { kind: "api_key", apiKey: secret },
|
|
6853
|
+
environment,
|
|
6854
|
+
agent: { clientName: "topolo-cli", clientVersion: CLI_VERSION },
|
|
6855
|
+
serviceUrls: profile.serviceUrls
|
|
6856
|
+
}).client.introspect();
|
|
6857
|
+
}
|
|
6858
|
+
}
|
|
6859
|
+
);
|
|
6860
|
+
emit(result, mode, () => formatCustodyResult(result));
|
|
6861
|
+
} catch (error) {
|
|
6862
|
+
fail2(error, mode);
|
|
6863
|
+
}
|
|
6864
|
+
});
|
|
6865
|
+
}
|
|
6866
|
+
function formatCustodyResult(result) {
|
|
6867
|
+
return [
|
|
6868
|
+
`${result.replaced ? "Replaced" : "Wrote"} credential custody in ${result.directory}`,
|
|
6869
|
+
`API key file: ${result.files.apiKey} (0600)`,
|
|
6870
|
+
`Custody file: ${result.files.custody} (0600)`,
|
|
6871
|
+
`Key: ${result.custody.apiKeyId} (${result.custody.appId}, ${result.custody.environment})`,
|
|
6872
|
+
`Organization: ${result.custody.organizationId}`,
|
|
6873
|
+
`Scopes: ${result.custody.scopes.join(", ") || "-"}`,
|
|
6874
|
+
`Expires at: ${result.custody.expiresAt === null ? "never" : new Date(result.custody.expiresAt * 1e3).toISOString()}`,
|
|
6875
|
+
`Digest: ${result.custody.keyDigest}`,
|
|
6876
|
+
"Local protected reference released. The upstream key is NOT revoked; use app_topolo_auth.api_keys.revoke for that."
|
|
6877
|
+
].join("\n");
|
|
6878
|
+
}
|
|
6879
|
+
|
|
6523
6880
|
// src/commands/apps-public.ts
|
|
6524
6881
|
function publicApplicationRows(catalog) {
|
|
6525
6882
|
return catalog.filter((entry) => entry.launchable || entry.surfaceKind === "application").map((entry) => ({
|
|
@@ -6809,7 +7166,7 @@ function normalizeList(values, fallback = []) {
|
|
|
6809
7166
|
|
|
6810
7167
|
// src/commands/integrations.ts
|
|
6811
7168
|
import { readdir as readdir3, readFile as readFile3, stat } from "fs/promises";
|
|
6812
|
-
import { join as
|
|
7169
|
+
import { join as join6, relative } from "path";
|
|
6813
7170
|
function registerIntegrationsCommand(program2) {
|
|
6814
7171
|
const integrations = program2.command("integrations").description("Generate and validate Topolo integration handoffs.");
|
|
6815
7172
|
integrations.command("prompt").description("Print an agent prompt for adding a Topolo integration.").argument("<integration>", "Integration prompt id, currently login-with-topolo").option("--framework <framework>", "auto, authjs, express, or generic", "auto").option("--json", "Emit JSON output").action((integration, options) => {
|
|
@@ -6952,7 +7309,7 @@ function detectFramework(packageJson, haystack) {
|
|
|
6952
7309
|
}
|
|
6953
7310
|
async function readPackageJson(repoRoot) {
|
|
6954
7311
|
try {
|
|
6955
|
-
return JSON.parse(await readFile3(
|
|
7312
|
+
return JSON.parse(await readFile3(join6(repoRoot, "package.json"), "utf8"));
|
|
6956
7313
|
} catch {
|
|
6957
7314
|
return null;
|
|
6958
7315
|
}
|
|
@@ -6964,7 +7321,7 @@ async function collectFiles(repoRoot) {
|
|
|
6964
7321
|
if (result.length > 2e3) return;
|
|
6965
7322
|
const entries = await readdir3(dir, { withFileTypes: true });
|
|
6966
7323
|
for (const entry of entries) {
|
|
6967
|
-
const fullPath =
|
|
7324
|
+
const fullPath = join6(dir, entry.name);
|
|
6968
7325
|
if (entry.isDirectory()) {
|
|
6969
7326
|
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue;
|
|
6970
7327
|
await walk(fullPath);
|
|
@@ -6991,11 +7348,11 @@ function formatCheckResult(result) {
|
|
|
6991
7348
|
}
|
|
6992
7349
|
|
|
6993
7350
|
// src/commands/skills.ts
|
|
6994
|
-
import { spawnSync as
|
|
7351
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
6995
7352
|
import { existsSync as existsSync6 } from "fs";
|
|
6996
|
-
import { readFile as
|
|
7353
|
+
import { readFile as readFile9, rm as rm3 } from "fs/promises";
|
|
6997
7354
|
import { homedir as homedir5 } from "os";
|
|
6998
|
-
import { dirname as
|
|
7355
|
+
import { dirname as dirname7, join as join11, resolve as resolve3 } from "path";
|
|
6999
7356
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
7000
7357
|
import { createRequire } from "module";
|
|
7001
7358
|
|
|
@@ -7004,7 +7361,7 @@ import { spawnSync as spawnSync3 } from "child_process";
|
|
|
7004
7361
|
import { existsSync as existsSync4 } from "fs";
|
|
7005
7362
|
import { readFile as readFile4 } from "fs/promises";
|
|
7006
7363
|
import { homedir as homedir4 } from "os";
|
|
7007
|
-
import { isAbsolute, join as
|
|
7364
|
+
import { isAbsolute as isAbsolute2, join as join7, resolve as resolve2 } from "path";
|
|
7008
7365
|
var SUPPORTED_AGENT_IDS = [
|
|
7009
7366
|
"claude",
|
|
7010
7367
|
"claude-desktop",
|
|
@@ -7047,13 +7404,13 @@ var AGENT_NAME_FOR = {
|
|
|
7047
7404
|
function resolveAgentHostTarget(options) {
|
|
7048
7405
|
const homeDir = resolve2(options.homeDir ?? homedir4());
|
|
7049
7406
|
const codexHome = options.codexHome ?? (options.homeDir === void 0 ? process.env.CODEX_HOME || void 0 : void 0);
|
|
7050
|
-
if (codexHome !== void 0 && (!codexHome.trim() || !
|
|
7407
|
+
if (codexHome !== void 0 && (!codexHome.trim() || !isAbsolute2(codexHome))) {
|
|
7051
7408
|
throw new Error("Codex home must be an absolute nonempty directory.");
|
|
7052
7409
|
}
|
|
7053
7410
|
return Object.freeze({
|
|
7054
7411
|
homeDir,
|
|
7055
7412
|
...options.projectDir ? { projectDir: resolve2(options.projectDir) } : {},
|
|
7056
|
-
codexHome: codexHome ??
|
|
7413
|
+
codexHome: codexHome ?? join7(homeDir, ".codex")
|
|
7057
7414
|
});
|
|
7058
7415
|
}
|
|
7059
7416
|
var RESERVED_OPERATOR_REGISTRATION = ["topolo", "platform"].join("-");
|
|
@@ -7062,7 +7419,7 @@ function isPublicRegistrationName(name) {
|
|
|
7062
7419
|
}
|
|
7063
7420
|
function selectStableNodeCommand(whichOutput, fallback = process.execPath) {
|
|
7064
7421
|
const candidate = whichOutput?.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
7065
|
-
return candidate &&
|
|
7422
|
+
return candidate && isAbsolute2(candidate) ? candidate : fallback;
|
|
7066
7423
|
}
|
|
7067
7424
|
function stableNodeCommand() {
|
|
7068
7425
|
const probe = spawnSync3(process.platform === "win32" ? "where" : "which", ["node"], {
|
|
@@ -7074,23 +7431,23 @@ function stableNodeCommand() {
|
|
|
7074
7431
|
function agentSkillPath(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
7075
7432
|
switch (agent) {
|
|
7076
7433
|
case "claude":
|
|
7077
|
-
return projectDir ?
|
|
7434
|
+
return projectDir ? join7(projectDir, ".claude", "skills", "topolo", "SKILL.md") : join7(homeDir, ".claude", "skills", "topolo", "SKILL.md");
|
|
7078
7435
|
case "claude-desktop":
|
|
7079
|
-
return
|
|
7436
|
+
return join7(homeDir, ".claude", "skills", "topolo", "SKILL.md");
|
|
7080
7437
|
case "codex":
|
|
7081
|
-
return
|
|
7438
|
+
return join7(codexHome ?? join7(homeDir, ".codex"), "skills", "topolo", "SKILL.md");
|
|
7082
7439
|
case "cursor":
|
|
7083
|
-
return
|
|
7440
|
+
return join7(projectDir ?? homeDir, ".cursor", "rules", "topolo.mdc");
|
|
7084
7441
|
case "grok":
|
|
7085
|
-
return
|
|
7442
|
+
return join7(projectDir ?? homeDir, ".grok", "skills", "topolo", "SKILL.md");
|
|
7086
7443
|
case "gemini":
|
|
7087
|
-
return
|
|
7444
|
+
return join7(projectDir ?? homeDir, ".gemini", "skills", "topolo", "SKILL.md");
|
|
7088
7445
|
case "copilot":
|
|
7089
|
-
return projectDir ?
|
|
7446
|
+
return projectDir ? join7(projectDir, ".github", "skills", "topolo", "SKILL.md") : join7(homeDir, ".copilot", "skills", "topolo", "SKILL.md");
|
|
7090
7447
|
case "windsurf":
|
|
7091
|
-
return projectDir ?
|
|
7448
|
+
return projectDir ? join7(projectDir, ".windsurf", "rules", "topolo.md") : void 0;
|
|
7092
7449
|
case "cline":
|
|
7093
|
-
return
|
|
7450
|
+
return join7(projectDir ?? homeDir, ".cline", "skills", "topolo", "SKILL.md");
|
|
7094
7451
|
case "goose":
|
|
7095
7452
|
case "amazon-q":
|
|
7096
7453
|
return void 0;
|
|
@@ -7099,7 +7456,7 @@ function agentSkillPath(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
|
7099
7456
|
function agentConfigPaths(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
7100
7457
|
switch (agent) {
|
|
7101
7458
|
case "claude":
|
|
7102
|
-
return [{ path: projectDir ?
|
|
7459
|
+
return [{ path: projectDir ? join7(projectDir, ".mcp.json") : join7(homeDir, ".claude.json"), format: "json", rootKey: "mcpServers" }];
|
|
7103
7460
|
case "claude-desktop":
|
|
7104
7461
|
return [{
|
|
7105
7462
|
path: claudeDesktopConfigPath(homeDir),
|
|
@@ -7107,31 +7464,31 @@ function agentConfigPaths(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
|
7107
7464
|
rootKey: "mcpServers"
|
|
7108
7465
|
}];
|
|
7109
7466
|
case "codex":
|
|
7110
|
-
return [{ path:
|
|
7467
|
+
return [{ path: join7(codexHome ?? join7(homeDir, ".codex"), "config.toml"), format: "toml" }];
|
|
7111
7468
|
case "cursor":
|
|
7112
7469
|
return [{
|
|
7113
|
-
path: projectDir ?
|
|
7470
|
+
path: projectDir ? join7(projectDir, ".cursor", "mcp.json") : join7(homeDir, ".cursor", "mcp.json"),
|
|
7114
7471
|
format: "json",
|
|
7115
7472
|
rootKey: "mcpServers"
|
|
7116
7473
|
}];
|
|
7117
7474
|
case "grok":
|
|
7118
7475
|
return [{
|
|
7119
|
-
path: projectDir ?
|
|
7476
|
+
path: projectDir ? join7(projectDir, ".grok", "config.toml") : join7(homeDir, ".grok", "config.toml"),
|
|
7120
7477
|
format: "toml"
|
|
7121
7478
|
}];
|
|
7122
7479
|
case "gemini":
|
|
7123
7480
|
return [{
|
|
7124
|
-
path: projectDir ?
|
|
7481
|
+
path: projectDir ? join7(projectDir, ".gemini", "settings.json") : join7(homeDir, ".gemini", "settings.json"),
|
|
7125
7482
|
format: "json",
|
|
7126
7483
|
rootKey: "mcpServers"
|
|
7127
7484
|
}];
|
|
7128
7485
|
case "copilot":
|
|
7129
7486
|
return projectDir ? [
|
|
7130
|
-
{ path:
|
|
7131
|
-
{ path:
|
|
7487
|
+
{ path: join7(projectDir, ".mcp.json"), format: "json", rootKey: "mcpServers" },
|
|
7488
|
+
{ path: join7(projectDir, ".vscode", "mcp.json"), format: "json", rootKey: "servers" }
|
|
7132
7489
|
] : [
|
|
7133
7490
|
{
|
|
7134
|
-
path:
|
|
7491
|
+
path: join7(homeDir, ".copilot", "mcp-config.json"),
|
|
7135
7492
|
format: "json",
|
|
7136
7493
|
rootKey: "mcpServers"
|
|
7137
7494
|
},
|
|
@@ -7139,24 +7496,24 @@ function agentConfigPaths(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
|
7139
7496
|
];
|
|
7140
7497
|
case "windsurf":
|
|
7141
7498
|
return [{
|
|
7142
|
-
path:
|
|
7499
|
+
path: join7(homeDir, ".codeium", "windsurf", "mcp_config.json"),
|
|
7143
7500
|
format: "json",
|
|
7144
7501
|
rootKey: "mcpServers"
|
|
7145
7502
|
}];
|
|
7146
7503
|
case "cline":
|
|
7147
7504
|
return [{
|
|
7148
|
-
path: projectDir ?
|
|
7505
|
+
path: projectDir ? join7(projectDir, ".cline", "mcp.json") : join7(homeDir, ".cline", "mcp.json"),
|
|
7149
7506
|
format: "json",
|
|
7150
7507
|
rootKey: "mcpServers"
|
|
7151
7508
|
}];
|
|
7152
7509
|
case "goose":
|
|
7153
|
-
return [{ path:
|
|
7510
|
+
return [{ path: join7(homeDir, ".config", "goose", "config.yaml"), format: "yaml" }];
|
|
7154
7511
|
case "amazon-q":
|
|
7155
7512
|
if (projectDir) {
|
|
7156
|
-
return [{ path:
|
|
7513
|
+
return [{ path: join7(projectDir, ".amazonq", "mcp.json"), format: "json", rootKey: "mcpServers" }];
|
|
7157
7514
|
}
|
|
7158
7515
|
return [{
|
|
7159
|
-
path: existsSync4(
|
|
7516
|
+
path: existsSync4(join7(homeDir, ".aws", "amazonq", "mcp.json")) ? join7(homeDir, ".aws", "amazonq", "mcp.json") : join7(homeDir, ".aws", "amazonq", "default.json"),
|
|
7160
7517
|
format: "json",
|
|
7161
7518
|
rootKey: "mcpServers"
|
|
7162
7519
|
}];
|
|
@@ -7192,7 +7549,7 @@ function agentConfigExists(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
|
7192
7549
|
return agentConfigPaths(agent, homeDir, projectDir, codexHome).some((config) => existsSync4(config.path));
|
|
7193
7550
|
}
|
|
7194
7551
|
async function assertClaudeProjectScope(homeDir, projectDir, name) {
|
|
7195
|
-
const path =
|
|
7552
|
+
const path = join7(homeDir, ".claude.json");
|
|
7196
7553
|
let contents;
|
|
7197
7554
|
try {
|
|
7198
7555
|
contents = await readFile4(path, "utf8");
|
|
@@ -7220,14 +7577,14 @@ async function assertClaudeProjectScope(homeDir, projectDir, name) {
|
|
|
7220
7577
|
}
|
|
7221
7578
|
}
|
|
7222
7579
|
function claudeDesktopConfigPath(homeDir) {
|
|
7223
|
-
if (process.platform === "darwin") return
|
|
7224
|
-
if (process.platform === "win32") return
|
|
7225
|
-
return
|
|
7580
|
+
if (process.platform === "darwin") return join7(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
7581
|
+
if (process.platform === "win32") return join7(process.env.APPDATA ?? join7(homeDir, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
7582
|
+
return join7(process.env.XDG_CONFIG_HOME ?? join7(homeDir, ".config"), "Claude", "claude_desktop_config.json");
|
|
7226
7583
|
}
|
|
7227
7584
|
function vscodeUserConfigPath(homeDir) {
|
|
7228
|
-
if (process.platform === "darwin") return
|
|
7229
|
-
if (process.platform === "win32") return
|
|
7230
|
-
return
|
|
7585
|
+
if (process.platform === "darwin") return join7(homeDir, "Library", "Application Support", "Code", "User", "mcp.json");
|
|
7586
|
+
if (process.platform === "win32") return join7(process.env.APPDATA ?? join7(homeDir, "AppData", "Roaming"), "Code", "User", "mcp.json");
|
|
7587
|
+
return join7(process.env.XDG_CONFIG_HOME ?? join7(homeDir, ".config"), "Code", "User", "mcp.json");
|
|
7231
7588
|
}
|
|
7232
7589
|
async function readAgentRegistrations(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
7233
7590
|
const registrations = [];
|
|
@@ -7286,7 +7643,7 @@ async function readTomlRegistrations(path, agent) {
|
|
|
7286
7643
|
if (!section || !isPublicRegistrationName(section.name) || !line || line.startsWith("#")) continue;
|
|
7287
7644
|
const assignment = line.match(/^([A-Za-z0-9_]+)\s*=\s*(.+)$/);
|
|
7288
7645
|
if (!assignment) continue;
|
|
7289
|
-
if (!section.env &&
|
|
7646
|
+
if (!section.env && !["command", "args", "env", "url", "http_headers"].includes(assignment[1])) continue;
|
|
7290
7647
|
pending = { key: assignment[1], value: assignment[2] };
|
|
7291
7648
|
if (isTomlValueComplete(pending.value)) {
|
|
7292
7649
|
applyTomlAssignment(path, agent, section, pending, registrations);
|
|
@@ -7294,7 +7651,7 @@ async function readTomlRegistrations(path, agent) {
|
|
|
7294
7651
|
}
|
|
7295
7652
|
}
|
|
7296
7653
|
if (pending) throw new Error("Cannot read unsupported MCP value in " + AGENT_LABELS[agent] + " configuration at " + path + ".");
|
|
7297
|
-
return [...registrations.values()].filter((registration) => registration.command);
|
|
7654
|
+
return [...registrations.values()].filter((registration) => registration.command || registration.url);
|
|
7298
7655
|
}
|
|
7299
7656
|
async function readGooseRegistrations(path) {
|
|
7300
7657
|
const text2 = await readFile4(path, "utf8");
|
|
@@ -7368,11 +7725,18 @@ function applyTomlAssignment(path, agent, section, assignment, registrations) {
|
|
|
7368
7725
|
registration.args = value;
|
|
7369
7726
|
}
|
|
7370
7727
|
if (!section.env && assignment.key === "env" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
7371
|
-
registration.env =
|
|
7372
|
-
|
|
7373
|
-
|
|
7728
|
+
registration.env = stringEntries(value);
|
|
7729
|
+
}
|
|
7730
|
+
if (!section.env && assignment.key === "url" && typeof value === "string") registration.url = value;
|
|
7731
|
+
if (!section.env && assignment.key === "http_headers" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
7732
|
+
registration.headers = stringEntries(value);
|
|
7374
7733
|
}
|
|
7375
7734
|
}
|
|
7735
|
+
function stringEntries(value) {
|
|
7736
|
+
return Object.fromEntries(Object.entries(value).filter(
|
|
7737
|
+
(entry) => typeof entry[1] === "string"
|
|
7738
|
+
));
|
|
7739
|
+
}
|
|
7376
7740
|
function stripTomlComment(line) {
|
|
7377
7741
|
let quote;
|
|
7378
7742
|
let escaped = false;
|
|
@@ -7485,9 +7849,13 @@ function splitTomlArray(value) {
|
|
|
7485
7849
|
function normalizeRegistration(name, value) {
|
|
7486
7850
|
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
7487
7851
|
const candidate = value;
|
|
7852
|
+
if (typeof candidate.command !== "string" && typeof candidate.url === "string") {
|
|
7853
|
+
const headers = candidate.headers && typeof candidate.headers === "object" && !Array.isArray(candidate.headers) ? stringEntries(candidate.headers) : {};
|
|
7854
|
+
return [{ name, command: "", args: [], env: {}, url: candidate.url, headers }];
|
|
7855
|
+
}
|
|
7488
7856
|
if (typeof candidate.command !== "string") return [];
|
|
7489
7857
|
const args = Array.isArray(candidate.args) && candidate.args.every((arg) => typeof arg === "string") ? candidate.args : [];
|
|
7490
|
-
const env = candidate.env && typeof candidate.env === "object" && !Array.isArray(candidate.env) ?
|
|
7858
|
+
const env = candidate.env && typeof candidate.env === "object" && !Array.isArray(candidate.env) ? stringEntries(candidate.env) : {};
|
|
7491
7859
|
return [{ name, command: candidate.command, args, env }];
|
|
7492
7860
|
}
|
|
7493
7861
|
function message(error) {
|
|
@@ -7496,8 +7864,8 @@ function message(error) {
|
|
|
7496
7864
|
|
|
7497
7865
|
// src/commands/agent-config.ts
|
|
7498
7866
|
import { existsSync as existsSync5 } from "fs";
|
|
7499
|
-
import { lstat as
|
|
7500
|
-
import { randomUUID as
|
|
7867
|
+
import { lstat as lstat3, mkdir as mkdir4, open as open2, readFile as readFile5, rename as rename3, unlink, writeFile as writeFile2 } from "fs/promises";
|
|
7868
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
7501
7869
|
import { dirname as dirname5 } from "path";
|
|
7502
7870
|
async function installAgentSkill(agent, source, homeDir, projectDir, codexHome) {
|
|
7503
7871
|
const path = agentSkillPath(agent, homeDir, projectDir, codexHome);
|
|
@@ -7518,6 +7886,23 @@ async function registerAgentConfig(agent, mcp, homeDir, registration, projectDir
|
|
|
7518
7886
|
}
|
|
7519
7887
|
}
|
|
7520
7888
|
}
|
|
7889
|
+
var SHARED_HUB_AGENTS = ["claude", "codex"];
|
|
7890
|
+
async function registerAgentHttpConfig(agent, homeDir, registration, codexHome) {
|
|
7891
|
+
const [config] = agentConfigPaths(agent, homeDir, void 0, codexHome);
|
|
7892
|
+
if (agent === "claude") {
|
|
7893
|
+
await writeJsonServer(config, agent, registration.name, {
|
|
7894
|
+
type: "http",
|
|
7895
|
+
url: registration.url,
|
|
7896
|
+
headers: registration.headers
|
|
7897
|
+
});
|
|
7898
|
+
return;
|
|
7899
|
+
}
|
|
7900
|
+
const before = await fileSnapshot(config.path);
|
|
7901
|
+
const text2 = withoutMcpServerSections(before.contents ?? "", registration.name);
|
|
7902
|
+
const headers = Object.entries(registration.headers).map(([key, value]) => JSON.stringify(key) + " = " + JSON.stringify(value)).join(", ");
|
|
7903
|
+
const block = "\n\n[mcp_servers." + registration.name + "]\nurl = " + JSON.stringify(registration.url) + "\nhttp_headers = { " + headers + " }\n";
|
|
7904
|
+
await atomicWrite(config.path, text2 + block, before);
|
|
7905
|
+
}
|
|
7521
7906
|
async function unregisterAgentConfig(agent, homeDir, name, projectDir, codexHome) {
|
|
7522
7907
|
for (const config of agentConfigPaths(agent, homeDir, projectDir, codexHome)) {
|
|
7523
7908
|
if (!existsSync5(config.path)) continue;
|
|
@@ -7559,6 +7944,15 @@ function registrationEnvironment(agent, registration) {
|
|
|
7559
7944
|
};
|
|
7560
7945
|
}
|
|
7561
7946
|
async function registerJson(config, agent, mcp, registration) {
|
|
7947
|
+
const entry = {
|
|
7948
|
+
command: mcp.command,
|
|
7949
|
+
args: mcp.args,
|
|
7950
|
+
env: registrationEnvironment(agent, registration)
|
|
7951
|
+
};
|
|
7952
|
+
if (agent === "claude" || agent === "claude-desktop" || (config.rootKey ?? "mcpServers") === "servers") entry.type = "stdio";
|
|
7953
|
+
await writeJsonServer(config, agent, registration.name, entry);
|
|
7954
|
+
}
|
|
7955
|
+
async function writeJsonServer(config, agent, name, entry) {
|
|
7562
7956
|
const before = await fileSnapshot(config.path);
|
|
7563
7957
|
let parsed = {};
|
|
7564
7958
|
if (before.contents !== void 0) {
|
|
@@ -7577,13 +7971,7 @@ async function registerJson(config, agent, mcp, registration) {
|
|
|
7577
7971
|
throw new Error("Cannot update malformed " + AGENT_LABELS[agent] + " MCP configuration at " + config.path + ".");
|
|
7578
7972
|
}
|
|
7579
7973
|
const servers = existingServers ?? {};
|
|
7580
|
-
|
|
7581
|
-
command: mcp.command,
|
|
7582
|
-
args: mcp.args,
|
|
7583
|
-
env: registrationEnvironment(agent, registration)
|
|
7584
|
-
};
|
|
7585
|
-
if (agent === "claude" || agent === "claude-desktop" || rootKey === "servers") entry.type = "stdio";
|
|
7586
|
-
servers[registration.name] = entry;
|
|
7974
|
+
servers[name] = entry;
|
|
7587
7975
|
parsed[rootKey] = servers;
|
|
7588
7976
|
await atomicWrite(config.path, JSON.stringify(parsed, null, 2) + "\n", before);
|
|
7589
7977
|
}
|
|
@@ -7684,27 +8072,27 @@ function formatSkillForAgent(agent, source) {
|
|
|
7684
8072
|
}
|
|
7685
8073
|
return "---\ntrigger: model_decision\ndescription: Use Topolo MCP for Topolo platform operations.\n---\n\n" + body;
|
|
7686
8074
|
}
|
|
7687
|
-
function fileIdentity(
|
|
7688
|
-
return [
|
|
8075
|
+
function fileIdentity(stat3) {
|
|
8076
|
+
return [stat3.dev, stat3.ino, stat3.size, stat3.mtimeMs, stat3.ctimeMs, stat3.mode, stat3.nlink].join(":");
|
|
7689
8077
|
}
|
|
7690
8078
|
async function fileSnapshot(path) {
|
|
7691
8079
|
const parents = [];
|
|
7692
8080
|
for (let parent = dirname5(path); ; parent = dirname5(parent)) {
|
|
7693
8081
|
try {
|
|
7694
|
-
const
|
|
7695
|
-
if (!
|
|
7696
|
-
parents.push({ path: parent, identity: [
|
|
8082
|
+
const stat3 = await lstat3(parent);
|
|
8083
|
+
if (!stat3.isDirectory() || stat3.isSymbolicLink()) throw new Error("Host destination contains a symbolic link or non-directory at " + parent + ".");
|
|
8084
|
+
parents.push({ path: parent, identity: [stat3.dev, stat3.ino].join(":") });
|
|
7697
8085
|
} catch (error) {
|
|
7698
8086
|
if (error.code !== "ENOENT") throw error;
|
|
7699
8087
|
}
|
|
7700
8088
|
if (dirname5(parent) === parent) break;
|
|
7701
8089
|
}
|
|
7702
8090
|
try {
|
|
7703
|
-
const
|
|
7704
|
-
if (!
|
|
8091
|
+
const stat3 = await lstat3(path);
|
|
8092
|
+
if (!stat3.isFile() || stat3.isSymbolicLink() || stat3.nlink !== 1) throw new Error("Host configuration must be a single regular file at " + path + ".");
|
|
7705
8093
|
const contents = await readFile5(path, "utf8");
|
|
7706
|
-
if (fileIdentity(await
|
|
7707
|
-
return { contents, identity: fileIdentity(
|
|
8094
|
+
if (fileIdentity(await lstat3(path)) !== fileIdentity(stat3)) throw new Error("Host configuration changed while reading at " + path + ".");
|
|
8095
|
+
return { contents, identity: fileIdentity(stat3), parents };
|
|
7708
8096
|
} catch (error) {
|
|
7709
8097
|
if (error.code === "ENOENT") return { parents };
|
|
7710
8098
|
throw error;
|
|
@@ -7720,11 +8108,11 @@ async function atomicWrite(path, contents, before) {
|
|
|
7720
8108
|
before ??= await fileSnapshot(path);
|
|
7721
8109
|
await assertSnapshot(path, before);
|
|
7722
8110
|
if (before.contents === contents) return;
|
|
7723
|
-
await
|
|
8111
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
7724
8112
|
const destination = await fileSnapshot(path);
|
|
7725
8113
|
await assertSnapshot(path, before);
|
|
7726
|
-
const temporaryPath = path + ".topolo-" +
|
|
7727
|
-
const handle = await
|
|
8114
|
+
const temporaryPath = path + ".topolo-" + randomUUID4() + ".tmp";
|
|
8115
|
+
const handle = await open2(temporaryPath, "wx", 384);
|
|
7728
8116
|
let temporaryIdentity;
|
|
7729
8117
|
let handleClosed = false;
|
|
7730
8118
|
let temporaryPending = true;
|
|
@@ -7736,11 +8124,11 @@ async function atomicWrite(path, contents, before) {
|
|
|
7736
8124
|
await handle.close();
|
|
7737
8125
|
handleClosed = true;
|
|
7738
8126
|
await assertSnapshot(path, destination);
|
|
7739
|
-
const temporary = await
|
|
8127
|
+
const temporary = await lstat3(temporaryPath);
|
|
7740
8128
|
if (!temporary.isFile() || temporary.isSymbolicLink() || temporary.nlink !== 1 || [temporary.dev, temporary.ino].join(":") !== temporaryIdentity) {
|
|
7741
8129
|
throw new Error("Host temporary file custody changed before rename at " + path + ".");
|
|
7742
8130
|
}
|
|
7743
|
-
await
|
|
8131
|
+
await rename3(temporaryPath, path);
|
|
7744
8132
|
temporaryPending = false;
|
|
7745
8133
|
const installed = await fileSnapshot(path);
|
|
7746
8134
|
if (installed.contents !== contents) throw new Error("Host configuration read-back failed at " + path + ".");
|
|
@@ -7750,10 +8138,10 @@ async function atomicWrite(path, contents, before) {
|
|
|
7750
8138
|
let cleanupOwned = false;
|
|
7751
8139
|
try {
|
|
7752
8140
|
const parentsOwned = await Promise.all(destination.parents.map(async (parent) => {
|
|
7753
|
-
const current2 = await
|
|
8141
|
+
const current2 = await lstat3(parent.path);
|
|
7754
8142
|
return current2.isDirectory() && !current2.isSymbolicLink() && [current2.dev, current2.ino].join(":") === parent.identity;
|
|
7755
8143
|
}));
|
|
7756
|
-
const current = await
|
|
8144
|
+
const current = await lstat3(temporaryPath);
|
|
7757
8145
|
cleanupOwned = parentsOwned.every(Boolean) && current.isFile() && !current.isSymbolicLink() && current.nlink === 1 && [current.dev, current.ino].join(":") === temporaryIdentity;
|
|
7758
8146
|
} catch {
|
|
7759
8147
|
}
|
|
@@ -7763,11 +8151,11 @@ async function atomicWrite(path, contents, before) {
|
|
|
7763
8151
|
}
|
|
7764
8152
|
function withoutMcpServerSections(text2, name) {
|
|
7765
8153
|
const section = "[mcp_servers." + name + "]";
|
|
7766
|
-
const
|
|
8154
|
+
const subsectionPrefix = "[mcp_servers." + name + ".";
|
|
7767
8155
|
let skipping = false;
|
|
7768
8156
|
return text2.split(/\r?\n/).filter((line) => {
|
|
7769
8157
|
const trimmed = line.trim();
|
|
7770
|
-
if (trimmed === section || trimmed
|
|
8158
|
+
if (trimmed === section || trimmed.startsWith(subsectionPrefix) && trimmed.endsWith("]")) {
|
|
7771
8159
|
skipping = true;
|
|
7772
8160
|
return false;
|
|
7773
8161
|
}
|
|
@@ -7776,14 +8164,505 @@ function withoutMcpServerSections(text2, name) {
|
|
|
7776
8164
|
}).join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
7777
8165
|
}
|
|
7778
8166
|
|
|
8167
|
+
// src/commands/mcp-hub-setup.ts
|
|
8168
|
+
import { realpathSync } from "fs";
|
|
8169
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
8170
|
+
import { join as join10 } from "path";
|
|
8171
|
+
|
|
8172
|
+
// src/lib/mcp-hub-config.ts
|
|
8173
|
+
import { randomBytes } from "crypto";
|
|
8174
|
+
import { lstat as lstat4, mkdir as mkdir5, open as open3, readFile as readFile6, rename as rename4, unlink as unlink2 } from "fs/promises";
|
|
8175
|
+
import { dirname as dirname6, join as join8 } from "path";
|
|
8176
|
+
var MCP_HUB_DEFAULT_PORT = 7337;
|
|
8177
|
+
var MCP_HUB_ENVIRONMENTS = ["production", "staging", "development", "local"];
|
|
8178
|
+
var RESERVED_NAME = ["topolo", "platform"].join("-");
|
|
8179
|
+
var NAME_PATTERN = /^topolo(-[a-z0-9][a-z0-9_-]*)?$/;
|
|
8180
|
+
var TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
8181
|
+
function mcpHubPaths(credentialStorePath = configPath()) {
|
|
8182
|
+
const directory = dirname6(credentialStorePath);
|
|
8183
|
+
return { config: join8(directory, "mcp-hub.json"), token: join8(directory, "mcp-hub.token") };
|
|
8184
|
+
}
|
|
8185
|
+
function mcpHubUrl(port, name) {
|
|
8186
|
+
return `http://127.0.0.1:${port}/mcp/${name}`;
|
|
8187
|
+
}
|
|
8188
|
+
function emptyMcpHubConfig() {
|
|
8189
|
+
return { port: MCP_HUB_DEFAULT_PORT, identities: [] };
|
|
8190
|
+
}
|
|
8191
|
+
function validateMcpHubPort(value) {
|
|
8192
|
+
const port = typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value.trim()) : value;
|
|
8193
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
8194
|
+
throw new Error("MCP hub port must be an integer from 1024 to 65535.");
|
|
8195
|
+
}
|
|
8196
|
+
return port;
|
|
8197
|
+
}
|
|
8198
|
+
function validateMcpHubIdentity(value, where = "identity") {
|
|
8199
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
8200
|
+
throw new Error(`MCP hub ${where} must be an object.`);
|
|
8201
|
+
}
|
|
8202
|
+
const record3 = value;
|
|
8203
|
+
const unknown = Object.keys(record3).filter((key) => !["name", "env", "context"].includes(key));
|
|
8204
|
+
if (unknown.length) throw new Error(`MCP hub ${where} has unsupported field(s): ${unknown.join(", ")}.`);
|
|
8205
|
+
const name = record3.name;
|
|
8206
|
+
if (typeof name !== "string" || !NAME_PATTERN.test(name) || name === RESERVED_NAME) {
|
|
8207
|
+
throw new Error(`MCP hub ${where} name must be topolo or topolo-<name> (and not reserved).`);
|
|
8208
|
+
}
|
|
8209
|
+
const environment = record3.env;
|
|
8210
|
+
if (typeof environment !== "string" || !MCP_HUB_ENVIRONMENTS.includes(environment)) {
|
|
8211
|
+
throw new Error(`MCP hub identity ${name} env must be one of ${MCP_HUB_ENVIRONMENTS.join(", ")}.`);
|
|
8212
|
+
}
|
|
8213
|
+
const context = record3.context;
|
|
8214
|
+
if (context !== void 0 && (typeof context !== "string" || !context.trim() || /[\r\n]/.test(context))) {
|
|
8215
|
+
throw new Error(`MCP hub identity ${name} context must be a non-empty single-line selector.`);
|
|
8216
|
+
}
|
|
8217
|
+
return {
|
|
8218
|
+
name,
|
|
8219
|
+
environment,
|
|
8220
|
+
...context !== void 0 ? { context: context.trim() } : {}
|
|
8221
|
+
};
|
|
8222
|
+
}
|
|
8223
|
+
function parseMcpHubConfig(text2, source) {
|
|
8224
|
+
let parsed;
|
|
8225
|
+
try {
|
|
8226
|
+
parsed = JSON.parse(text2);
|
|
8227
|
+
} catch (error) {
|
|
8228
|
+
throw new Error(`Cannot read malformed MCP hub configuration at ${source}: ${error.message}`);
|
|
8229
|
+
}
|
|
8230
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8231
|
+
throw new Error(`MCP hub configuration at ${source} must be a JSON object.`);
|
|
8232
|
+
}
|
|
8233
|
+
const record3 = parsed;
|
|
8234
|
+
const unknown = Object.keys(record3).filter((key) => !["version", "port", "identities"].includes(key));
|
|
8235
|
+
if (unknown.length) {
|
|
8236
|
+
throw new Error(`MCP hub configuration at ${source} has unsupported field(s): ${unknown.join(", ")}.`);
|
|
8237
|
+
}
|
|
8238
|
+
if (record3.version !== 1) throw new Error(`MCP hub configuration at ${source} must declare "version": 1.`);
|
|
8239
|
+
const port = record3.port === void 0 ? MCP_HUB_DEFAULT_PORT : validateMcpHubPort(record3.port);
|
|
8240
|
+
if (record3.identities !== void 0 && !Array.isArray(record3.identities)) {
|
|
8241
|
+
throw new Error(`MCP hub configuration at ${source} identities must be an array.`);
|
|
8242
|
+
}
|
|
8243
|
+
const identities = (record3.identities ?? []).map((entry, index) => validateMcpHubIdentity(entry, `identity #${index + 1}`));
|
|
8244
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8245
|
+
for (const identity of identities) {
|
|
8246
|
+
if (seen.has(identity.name)) {
|
|
8247
|
+
throw new Error(`MCP hub configuration at ${source} declares ${identity.name} more than once.`);
|
|
8248
|
+
}
|
|
8249
|
+
seen.add(identity.name);
|
|
8250
|
+
}
|
|
8251
|
+
return { port, identities };
|
|
8252
|
+
}
|
|
8253
|
+
function serializeMcpHubConfig(config) {
|
|
8254
|
+
return JSON.stringify({
|
|
8255
|
+
version: 1,
|
|
8256
|
+
port: config.port,
|
|
8257
|
+
identities: config.identities.map((identity) => ({
|
|
8258
|
+
name: identity.name,
|
|
8259
|
+
env: identity.environment,
|
|
8260
|
+
...identity.context ? { context: identity.context } : {}
|
|
8261
|
+
}))
|
|
8262
|
+
}, null, 2) + "\n";
|
|
8263
|
+
}
|
|
8264
|
+
async function readMcpHubConfig(path) {
|
|
8265
|
+
let text2;
|
|
8266
|
+
try {
|
|
8267
|
+
text2 = await readFile6(path, "utf8");
|
|
8268
|
+
} catch (error) {
|
|
8269
|
+
if (error.code === "ENOENT") return emptyMcpHubConfig();
|
|
8270
|
+
throw error;
|
|
8271
|
+
}
|
|
8272
|
+
return parseMcpHubConfig(text2, path);
|
|
8273
|
+
}
|
|
8274
|
+
function upsertMcpHubIdentity(config, identity) {
|
|
8275
|
+
const identities = config.identities.filter((entry) => entry.name !== identity.name);
|
|
8276
|
+
identities.push(identity);
|
|
8277
|
+
identities.sort((left, right) => left.name.localeCompare(right.name));
|
|
8278
|
+
return { ...config, identities };
|
|
8279
|
+
}
|
|
8280
|
+
async function writePrivateFile(path, contents) {
|
|
8281
|
+
await mkdir5(dirname6(path), { recursive: true, mode: 448 });
|
|
8282
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
8283
|
+
const handle = await open3(temporary, "wx", 384);
|
|
8284
|
+
try {
|
|
8285
|
+
await handle.writeFile(contents, "utf8");
|
|
8286
|
+
} finally {
|
|
8287
|
+
await handle.close();
|
|
8288
|
+
}
|
|
8289
|
+
try {
|
|
8290
|
+
await rename4(temporary, path);
|
|
8291
|
+
} catch (error) {
|
|
8292
|
+
await unlink2(temporary).catch(() => void 0);
|
|
8293
|
+
throw error;
|
|
8294
|
+
}
|
|
8295
|
+
}
|
|
8296
|
+
async function writeMcpHubConfig(path, config) {
|
|
8297
|
+
await writePrivateFile(path, serializeMcpHubConfig(config));
|
|
8298
|
+
}
|
|
8299
|
+
async function assertPrivateTokenFile(path) {
|
|
8300
|
+
const stat3 = await lstat4(path);
|
|
8301
|
+
if (!stat3.isFile() || stat3.isSymbolicLink() || stat3.nlink !== 1) {
|
|
8302
|
+
throw new Error(`MCP hub token at ${path} must be a single regular file.`);
|
|
8303
|
+
}
|
|
8304
|
+
if ((stat3.mode & 63) !== 0) {
|
|
8305
|
+
throw new Error(`MCP hub token at ${path} is readable by other users. Delete it and rerun \`topolo setup --shared\` to issue a new one.`);
|
|
8306
|
+
}
|
|
8307
|
+
}
|
|
8308
|
+
async function readMcpHubToken(path) {
|
|
8309
|
+
try {
|
|
8310
|
+
await assertPrivateTokenFile(path);
|
|
8311
|
+
} catch (error) {
|
|
8312
|
+
if (error.code === "ENOENT") {
|
|
8313
|
+
throw new Error(`MCP hub token is missing at ${path}. Run \`topolo setup --shared\`.`);
|
|
8314
|
+
}
|
|
8315
|
+
throw error;
|
|
8316
|
+
}
|
|
8317
|
+
const token = (await readFile6(path, "utf8")).trim();
|
|
8318
|
+
if (!TOKEN_PATTERN.test(token)) {
|
|
8319
|
+
throw new Error(`MCP hub token at ${path} is malformed. Delete it and rerun \`topolo setup --shared\`.`);
|
|
8320
|
+
}
|
|
8321
|
+
return token;
|
|
8322
|
+
}
|
|
8323
|
+
async function ensureMcpHubToken(path) {
|
|
8324
|
+
try {
|
|
8325
|
+
return { token: await readMcpHubToken(path), created: false };
|
|
8326
|
+
} catch (error) {
|
|
8327
|
+
if (!/is missing at/.test(error.message)) throw error;
|
|
8328
|
+
}
|
|
8329
|
+
await mkdir5(dirname6(path), { recursive: true, mode: 448 });
|
|
8330
|
+
const token = randomBytes(32).toString("base64url");
|
|
8331
|
+
const handle = await open3(path, "wx", 384);
|
|
8332
|
+
try {
|
|
8333
|
+
await handle.writeFile(token + "\n", "utf8");
|
|
8334
|
+
} finally {
|
|
8335
|
+
await handle.close();
|
|
8336
|
+
}
|
|
8337
|
+
return { token, created: true };
|
|
8338
|
+
}
|
|
8339
|
+
|
|
8340
|
+
// src/lib/mcp-hub-service.ts
|
|
8341
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
8342
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
8343
|
+
import { join as join9 } from "path";
|
|
8344
|
+
var MCP_HUB_LAUNCHD_LABEL = "io.topolo.mcp-hub";
|
|
8345
|
+
var MCP_HUB_SYSTEMD_UNIT = "topolo-mcp-hub.service";
|
|
8346
|
+
var PASSTHROUGH_ENVIRONMENT = ["TOPOLO_CONFIG_PATH", "XDG_CONFIG_HOME"];
|
|
8347
|
+
var runCommand = (command, args) => {
|
|
8348
|
+
const result = spawnSync4(command, args, { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] });
|
|
8349
|
+
return { status: result.status, stderr: result.stderr ?? "" };
|
|
8350
|
+
};
|
|
8351
|
+
function serviceKindFor(platform4) {
|
|
8352
|
+
if (platform4 === "darwin") return "launchd";
|
|
8353
|
+
if (platform4 === "linux") return "systemd";
|
|
8354
|
+
return void 0;
|
|
8355
|
+
}
|
|
8356
|
+
function mcpHubServicePath(kind, homeDir, xdgConfigHome) {
|
|
8357
|
+
return kind === "launchd" ? join9(homeDir, "Library", "LaunchAgents", `${MCP_HUB_LAUNCHD_LABEL}.plist`) : join9(xdgConfigHome || join9(homeDir, ".config"), "systemd", "user", MCP_HUB_SYSTEMD_UNIT);
|
|
8358
|
+
}
|
|
8359
|
+
function serviceEnvironment(environment) {
|
|
8360
|
+
const selected = {};
|
|
8361
|
+
for (const key of PASSTHROUGH_ENVIRONMENT) {
|
|
8362
|
+
const value = environment[key];
|
|
8363
|
+
if (value) selected[key] = value;
|
|
8364
|
+
}
|
|
8365
|
+
return selected;
|
|
8366
|
+
}
|
|
8367
|
+
function xml(value) {
|
|
8368
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
8369
|
+
}
|
|
8370
|
+
function serviceArguments(spec) {
|
|
8371
|
+
return [spec.nodeCommand, spec.cliEntry, "mcp", "serve", "--port", String(spec.port)];
|
|
8372
|
+
}
|
|
8373
|
+
function renderLaunchdPlist(spec) {
|
|
8374
|
+
const environment = Object.entries(spec.environment);
|
|
8375
|
+
return [
|
|
8376
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
8377
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
8378
|
+
'<plist version="1.0">',
|
|
8379
|
+
"<dict>",
|
|
8380
|
+
" <key>Label</key>",
|
|
8381
|
+
` <string>${MCP_HUB_LAUNCHD_LABEL}</string>`,
|
|
8382
|
+
" <key>ProgramArguments</key>",
|
|
8383
|
+
" <array>",
|
|
8384
|
+
...serviceArguments(spec).map((argument) => ` <string>${xml(argument)}</string>`),
|
|
8385
|
+
" </array>",
|
|
8386
|
+
...environment.length ? [
|
|
8387
|
+
" <key>EnvironmentVariables</key>",
|
|
8388
|
+
" <dict>",
|
|
8389
|
+
...environment.flatMap(([key, value]) => [` <key>${xml(key)}</key>`, ` <string>${xml(value)}</string>`]),
|
|
8390
|
+
" </dict>"
|
|
8391
|
+
] : [],
|
|
8392
|
+
" <key>RunAtLoad</key>",
|
|
8393
|
+
" <true/>",
|
|
8394
|
+
" <key>KeepAlive</key>",
|
|
8395
|
+
" <true/>",
|
|
8396
|
+
" <key>ProcessType</key>",
|
|
8397
|
+
" <string>Background</string>",
|
|
8398
|
+
...spec.logPath ? [
|
|
8399
|
+
" <key>StandardOutPath</key>",
|
|
8400
|
+
` <string>${xml(spec.logPath)}</string>`,
|
|
8401
|
+
" <key>StandardErrorPath</key>",
|
|
8402
|
+
` <string>${xml(spec.logPath)}</string>`
|
|
8403
|
+
] : [],
|
|
8404
|
+
"</dict>",
|
|
8405
|
+
"</plist>",
|
|
8406
|
+
""
|
|
8407
|
+
].join("\n");
|
|
8408
|
+
}
|
|
8409
|
+
function systemdQuote(value) {
|
|
8410
|
+
return '"' + value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%") + '"';
|
|
8411
|
+
}
|
|
8412
|
+
function renderSystemdUnit(spec) {
|
|
8413
|
+
return [
|
|
8414
|
+
"[Unit]",
|
|
8415
|
+
"Description=Topolo shared local MCP hub",
|
|
8416
|
+
"After=network-online.target",
|
|
8417
|
+
"",
|
|
8418
|
+
"[Service]",
|
|
8419
|
+
"Type=simple",
|
|
8420
|
+
`ExecStart=${serviceArguments(spec).map(systemdQuote).join(" ")}`,
|
|
8421
|
+
...Object.entries(spec.environment).map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`),
|
|
8422
|
+
"Restart=always",
|
|
8423
|
+
"RestartSec=2",
|
|
8424
|
+
"",
|
|
8425
|
+
"[Install]",
|
|
8426
|
+
"WantedBy=default.target",
|
|
8427
|
+
""
|
|
8428
|
+
].join("\n");
|
|
8429
|
+
}
|
|
8430
|
+
async function readExisting(path) {
|
|
8431
|
+
try {
|
|
8432
|
+
return await readFile7(path, "utf8");
|
|
8433
|
+
} catch (error) {
|
|
8434
|
+
if (error.code === "ENOENT") return void 0;
|
|
8435
|
+
throw error;
|
|
8436
|
+
}
|
|
8437
|
+
}
|
|
8438
|
+
function require0(result, what) {
|
|
8439
|
+
if (result.status !== 0) throw new Error(`${what} failed: ${result.stderr.trim() || `exit ${result.status}`}`);
|
|
8440
|
+
}
|
|
8441
|
+
async function installMcpHubService(options) {
|
|
8442
|
+
const kind = serviceKindFor(options.platform);
|
|
8443
|
+
if (!kind) {
|
|
8444
|
+
return {
|
|
8445
|
+
kind: "unsupported",
|
|
8446
|
+
changed: false,
|
|
8447
|
+
activated: false,
|
|
8448
|
+
detail: `No user service manager is supported on ${options.platform}; run \`topolo mcp serve\` yourself.`
|
|
8449
|
+
};
|
|
8450
|
+
}
|
|
8451
|
+
const path = mcpHubServicePath(kind, options.homeDir, options.xdgConfigHome);
|
|
8452
|
+
const spec = kind === "launchd" ? { ...options.spec, logPath: options.spec.logPath ?? join9(options.homeDir, "Library", "Logs", "topolo-mcp-hub.log") } : options.spec;
|
|
8453
|
+
const contents = kind === "launchd" ? renderLaunchdPlist(spec) : renderSystemdUnit(spec);
|
|
8454
|
+
const changed = await readExisting(path) !== contents;
|
|
8455
|
+
if (changed) await writePrivateFile(path, contents);
|
|
8456
|
+
if (!options.activate) {
|
|
8457
|
+
return { kind, path, changed, activated: false, detail: `${kind} unit written; not activated for an alternate agent home.` };
|
|
8458
|
+
}
|
|
8459
|
+
const run = options.run;
|
|
8460
|
+
if (kind === "launchd") {
|
|
8461
|
+
const domain = `gui/${options.uid ?? process.getuid()}`;
|
|
8462
|
+
const target = `${domain}/${MCP_HUB_LAUNCHD_LABEL}`;
|
|
8463
|
+
const loaded = run("launchctl", ["print", target]).status === 0;
|
|
8464
|
+
if (loaded && !changed) {
|
|
8465
|
+
return { kind, path, changed, activated: true, detail: `${MCP_HUB_LAUNCHD_LABEL} already loaded with this definition.` };
|
|
8466
|
+
}
|
|
8467
|
+
if (loaded) run("launchctl", ["bootout", target]);
|
|
8468
|
+
require0(run("launchctl", ["bootstrap", domain, path]), "launchctl bootstrap");
|
|
8469
|
+
return { kind, path, changed, activated: true, detail: `${MCP_HUB_LAUNCHD_LABEL} ${loaded ? "reloaded" : "loaded"} (KeepAlive).` };
|
|
8470
|
+
}
|
|
8471
|
+
require0(run("systemctl", ["--user", "daemon-reload"]), "systemctl --user daemon-reload");
|
|
8472
|
+
require0(run("systemctl", ["--user", "enable", MCP_HUB_SYSTEMD_UNIT]), "systemctl --user enable");
|
|
8473
|
+
require0(run("systemctl", ["--user", changed ? "restart" : "start", MCP_HUB_SYSTEMD_UNIT]), `systemctl --user ${changed ? "restart" : "start"}`);
|
|
8474
|
+
return { kind, path, changed, activated: true, detail: `${MCP_HUB_SYSTEMD_UNIT} enabled and ${changed ? "restarted" : "running"} (Restart=always).` };
|
|
8475
|
+
}
|
|
8476
|
+
|
|
8477
|
+
// src/commands/mcp-hub-setup.ts
|
|
8478
|
+
function resolveCliEntry(argvEntry = process.argv[1]) {
|
|
8479
|
+
if (!argvEntry) throw new Error("Cannot locate the running topolo CLI entry for the hub service.");
|
|
8480
|
+
return realpathSync(argvEntry);
|
|
8481
|
+
}
|
|
8482
|
+
function resolveSharedTargets(options) {
|
|
8483
|
+
const explicit = [...options.agent ?? [], ...options.claude ? ["claude"] : [], ...options.codex ? ["codex"] : []];
|
|
8484
|
+
if (!explicit.length) return [...SHARED_HUB_AGENTS];
|
|
8485
|
+
const unsupported = explicit.filter((agent) => !SHARED_HUB_AGENTS.includes(agent));
|
|
8486
|
+
if (unsupported.length) {
|
|
8487
|
+
throw new Error(
|
|
8488
|
+
`The shared MCP hub supports Claude Code and Codex only (not ${unsupported.join(", ")}). Register other hosts with \`topolo setup\` (stdio).`
|
|
8489
|
+
);
|
|
8490
|
+
}
|
|
8491
|
+
return [...new Set(explicit)];
|
|
8492
|
+
}
|
|
8493
|
+
async function importStdioIdentities(agents, homeDir, codexHome) {
|
|
8494
|
+
const found = /* @__PURE__ */ new Map();
|
|
8495
|
+
const skipped = [];
|
|
8496
|
+
for (const agent of agents) {
|
|
8497
|
+
for (const registration of await readAgentRegistrations(agent, homeDir, void 0, codexHome)) {
|
|
8498
|
+
if (registration.url) continue;
|
|
8499
|
+
if (registration.env.TOPOLO_CONFIG_PATH || registration.env.TOPOLO_AGENT_INSTALLATION_ID) {
|
|
8500
|
+
skipped.push({ agent, name: registration.name, reason: "uses a private config path or agent installation; keep it on stdio" });
|
|
8501
|
+
continue;
|
|
8502
|
+
}
|
|
8503
|
+
let identity;
|
|
8504
|
+
try {
|
|
8505
|
+
identity = validateMcpHubIdentity({
|
|
8506
|
+
name: registration.name,
|
|
8507
|
+
env: registration.env.TOPOLO_ENV || "production",
|
|
8508
|
+
...registration.env.TOPOLO_CONTEXT ? { context: registration.env.TOPOLO_CONTEXT } : {}
|
|
8509
|
+
});
|
|
8510
|
+
} catch (error) {
|
|
8511
|
+
skipped.push({ agent, name: registration.name, reason: error.message });
|
|
8512
|
+
continue;
|
|
8513
|
+
}
|
|
8514
|
+
const previous = found.get(identity.name);
|
|
8515
|
+
if (previous && JSON.stringify(previous.identity) !== JSON.stringify(identity)) {
|
|
8516
|
+
skipped.push({ agent, name: identity.name, reason: `conflicts with the ${AGENT_LABELS[previous.agent]} registration of the same name` });
|
|
8517
|
+
continue;
|
|
8518
|
+
}
|
|
8519
|
+
found.set(identity.name, { identity, agent });
|
|
8520
|
+
}
|
|
8521
|
+
}
|
|
8522
|
+
return { identities: [...found.values()].map((entry) => entry.identity), skipped };
|
|
8523
|
+
}
|
|
8524
|
+
function sameHeaders(left, right) {
|
|
8525
|
+
return JSON.stringify(Object.entries(left ?? {}).sort()) === JSON.stringify(Object.entries(right).sort());
|
|
8526
|
+
}
|
|
8527
|
+
function hubHeaders(token, agent) {
|
|
8528
|
+
return { Authorization: `Bearer ${token}`, "X-Topolo-Agent": AGENT_NAME_FOR[agent] };
|
|
8529
|
+
}
|
|
8530
|
+
async function installSharedAgentSurfaces(options, dependencies) {
|
|
8531
|
+
if (options.project) {
|
|
8532
|
+
throw new Error("--shared registers user-scope servers only: the hub token must never be written into project files.");
|
|
8533
|
+
}
|
|
8534
|
+
if (options.installationId) {
|
|
8535
|
+
throw new Error("--shared cannot bind an agent installation; keep installation-bound MCPs on stdio.");
|
|
8536
|
+
}
|
|
8537
|
+
const agents = resolveSharedTargets(options);
|
|
8538
|
+
const target = resolveAgentHostTarget({ homeDir: options.homeDir, codexHome: options.codexHome });
|
|
8539
|
+
const paths = dependencies.paths();
|
|
8540
|
+
let config = await readMcpHubConfig(paths.config);
|
|
8541
|
+
if (options.port !== void 0) config = { ...config, port: validateMcpHubPort(options.port) };
|
|
8542
|
+
const skipped = [];
|
|
8543
|
+
if (options.importStdio) {
|
|
8544
|
+
const imported = await importStdioIdentities(agents, target.homeDir, target.codexHome);
|
|
8545
|
+
skipped.push(...imported.skipped);
|
|
8546
|
+
for (const identity of imported.identities) config = upsertMcpHubIdentity(config, identity);
|
|
8547
|
+
}
|
|
8548
|
+
if (!options.importStdio || options.name) {
|
|
8549
|
+
const name = options.name?.trim() || "topolo";
|
|
8550
|
+
const context = options.context?.trim();
|
|
8551
|
+
if (name !== "topolo" && !context) throw new Error("Named registrations require --context <context>.");
|
|
8552
|
+
if (name === "topolo" && context) {
|
|
8553
|
+
throw new Error("Pinned contexts require --name topolo-<name> so the active topolo registration remains independent.");
|
|
8554
|
+
}
|
|
8555
|
+
config = upsertMcpHubIdentity(config, validateMcpHubIdentity({
|
|
8556
|
+
name,
|
|
8557
|
+
env: options.environment?.trim() || dependencies.environment.TOPOLO_ENV || "production",
|
|
8558
|
+
...context ? { context } : {}
|
|
8559
|
+
}));
|
|
8560
|
+
}
|
|
8561
|
+
await writeMcpHubConfig(paths.config, config);
|
|
8562
|
+
const { token, created } = await ensureMcpHubToken(paths.token);
|
|
8563
|
+
const source = await dependencies.skillSource();
|
|
8564
|
+
const installed = [];
|
|
8565
|
+
for (const agent of agents) {
|
|
8566
|
+
const skill = await installAgentSkill(agent, source, target.homeDir, void 0, target.codexHome);
|
|
8567
|
+
try {
|
|
8568
|
+
const headers = hubHeaders(token, agent);
|
|
8569
|
+
for (const identity of config.identities) {
|
|
8570
|
+
await registerAgentHttpConfig(agent, target.homeDir, {
|
|
8571
|
+
name: identity.name,
|
|
8572
|
+
url: mcpHubUrl(config.port, identity.name),
|
|
8573
|
+
headers
|
|
8574
|
+
}, target.codexHome);
|
|
8575
|
+
}
|
|
8576
|
+
const registrations = await readAgentRegistrations(agent, target.homeDir, void 0, target.codexHome);
|
|
8577
|
+
const missing = config.identities.filter((identity) => {
|
|
8578
|
+
const entry = registrations.find((registration) => registration.name === identity.name);
|
|
8579
|
+
return !entry || entry.url !== mcpHubUrl(config.port, identity.name) || !sameHeaders(entry.headers, headers);
|
|
8580
|
+
});
|
|
8581
|
+
if (missing.length) {
|
|
8582
|
+
throw new Error(`Host configuration did not retain the shared hub registration for ${missing.map((identity) => identity.name).join(", ")}.`);
|
|
8583
|
+
}
|
|
8584
|
+
installed.push({
|
|
8585
|
+
agent,
|
|
8586
|
+
skillPath: skill.path,
|
|
8587
|
+
skillInstalled: skill.installed,
|
|
8588
|
+
mcp: "config-file",
|
|
8589
|
+
registrations: config.identities.map((identity) => identity.name)
|
|
8590
|
+
});
|
|
8591
|
+
} catch (error) {
|
|
8592
|
+
installed.push({
|
|
8593
|
+
agent,
|
|
8594
|
+
skillPath: skill.path,
|
|
8595
|
+
skillInstalled: skill.installed,
|
|
8596
|
+
mcp: "failed",
|
|
8597
|
+
registrations: [],
|
|
8598
|
+
detail: error.message
|
|
8599
|
+
});
|
|
8600
|
+
}
|
|
8601
|
+
}
|
|
8602
|
+
const service = options.service === false ? void 0 : await installMcpHubService({
|
|
8603
|
+
platform: dependencies.platform,
|
|
8604
|
+
homeDir: target.homeDir,
|
|
8605
|
+
xdgConfigHome: target.homeDir === dependencies.realHome ? dependencies.environment.XDG_CONFIG_HOME : void 0,
|
|
8606
|
+
spec: {
|
|
8607
|
+
nodeCommand: dependencies.nodeCommand(),
|
|
8608
|
+
cliEntry: dependencies.cliEntry(),
|
|
8609
|
+
port: config.port,
|
|
8610
|
+
environment: serviceEnvironment(dependencies.environment)
|
|
8611
|
+
},
|
|
8612
|
+
activate: target.homeDir === dependencies.realHome,
|
|
8613
|
+
run: dependencies.run,
|
|
8614
|
+
...dependencies.uid !== void 0 ? { uid: dependencies.uid } : {}
|
|
8615
|
+
});
|
|
8616
|
+
return {
|
|
8617
|
+
hub: {
|
|
8618
|
+
port: config.port,
|
|
8619
|
+
configPath: paths.config,
|
|
8620
|
+
tokenPath: paths.token,
|
|
8621
|
+
tokenCreated: created,
|
|
8622
|
+
identities: config.identities
|
|
8623
|
+
},
|
|
8624
|
+
installed,
|
|
8625
|
+
skipped,
|
|
8626
|
+
...service ? { service } : {}
|
|
8627
|
+
};
|
|
8628
|
+
}
|
|
8629
|
+
function defaultSharedSetupDependencies(skillsDir, realHome) {
|
|
8630
|
+
return {
|
|
8631
|
+
paths: () => mcpHubPaths(),
|
|
8632
|
+
skillSource: () => readFile8(join10(skillsDir, "topolo", "SKILL.md"), "utf8"),
|
|
8633
|
+
platform: process.platform,
|
|
8634
|
+
realHome,
|
|
8635
|
+
nodeCommand: stableNodeCommand,
|
|
8636
|
+
cliEntry: () => resolveCliEntry(),
|
|
8637
|
+
environment: process.env,
|
|
8638
|
+
run: runCommand
|
|
8639
|
+
};
|
|
8640
|
+
}
|
|
8641
|
+
function formatSharedSetup(result) {
|
|
8642
|
+
const lines = [
|
|
8643
|
+
`Shared MCP hub: http://127.0.0.1:${result.hub.port}/mcp/<name> (${result.hub.identities.length} identities)`,
|
|
8644
|
+
` config -> ${result.hub.configPath}`,
|
|
8645
|
+
` token -> ${result.hub.tokenPath}${result.hub.tokenCreated ? " (new)" : ""}`
|
|
8646
|
+
];
|
|
8647
|
+
for (const row of result.installed) {
|
|
8648
|
+
lines.push(`${AGENT_LABELS[row.agent]}:`);
|
|
8649
|
+
lines.push(` skill -> ${row.skillPath}`);
|
|
8650
|
+
lines.push(row.mcp === "config-file" ? ` HTTP MCP registered: ${row.registrations.join(", ")}` : ` HTTP MCP registration FAILED: ${row.detail}`);
|
|
8651
|
+
}
|
|
8652
|
+
for (const entry of result.skipped) lines.push(`Skipped ${entry.name} (${AGENT_LABELS[entry.agent]}): ${entry.reason}`);
|
|
8653
|
+
if (result.service) lines.push(`Service (${result.service.kind}): ${result.service.detail}`);
|
|
8654
|
+
lines.push("", "Next: restart the agents, then run `topolo doctor --shared`.");
|
|
8655
|
+
return lines.join("\n");
|
|
8656
|
+
}
|
|
8657
|
+
|
|
7779
8658
|
// src/commands/skills.ts
|
|
7780
8659
|
var AGENT_LABELS2 = AGENT_LABELS;
|
|
7781
8660
|
function skillsSourceDir() {
|
|
7782
|
-
const here =
|
|
8661
|
+
const here = dirname7(fileURLToPath3(import.meta.url));
|
|
7783
8662
|
const distCandidate = resolve3(here, "../skills");
|
|
7784
8663
|
const srcCandidate = resolve3(here, "../../skills");
|
|
7785
8664
|
for (const candidate of [distCandidate, srcCandidate]) {
|
|
7786
|
-
if (existsSync6(
|
|
8665
|
+
if (existsSync6(join11(candidate, "topolo", "SKILL.md"))) return candidate;
|
|
7787
8666
|
}
|
|
7788
8667
|
return distCandidate;
|
|
7789
8668
|
}
|
|
@@ -7793,7 +8672,7 @@ function resolveMcpCommand() {
|
|
|
7793
8672
|
}
|
|
7794
8673
|
function agentBinaryExists(agent) {
|
|
7795
8674
|
return agentCliNames(agent).some((bin) => {
|
|
7796
|
-
const probe =
|
|
8675
|
+
const probe = spawnSync5(process.platform === "win32" ? "where" : "which", [bin], {
|
|
7797
8676
|
stdio: "ignore"
|
|
7798
8677
|
});
|
|
7799
8678
|
return probe.status === 0;
|
|
@@ -7841,7 +8720,7 @@ async function installForAgent(agent, sourceDir, mcp, opts) {
|
|
|
7841
8720
|
return { agent, skillInstalled: false, mcp: "failed", detail: err instanceof Error ? err.message : String(err) };
|
|
7842
8721
|
}
|
|
7843
8722
|
}
|
|
7844
|
-
const source = await
|
|
8723
|
+
const source = await readFile9(join11(sourceDir, "topolo", "SKILL.md"), "utf8");
|
|
7845
8724
|
const skill = await installAgentSkill(agent, source, homeDir, projectDir, codexHome);
|
|
7846
8725
|
if (!opts.withMcp) {
|
|
7847
8726
|
return { agent, skillPath: skill.path, skillInstalled: skill.installed, mcp: "skipped" };
|
|
@@ -7938,7 +8817,7 @@ function registerSkillsCommand(program2) {
|
|
|
7938
8817
|
}
|
|
7939
8818
|
async function installAgentSurfaces(opts) {
|
|
7940
8819
|
const sourceDir = skillsSourceDir();
|
|
7941
|
-
if (!existsSync6(
|
|
8820
|
+
if (!existsSync6(join11(sourceDir, "topolo", "SKILL.md"))) {
|
|
7942
8821
|
throw new Error("Bundled skill file not found. Reinstall the CLI: npm install -g @topolo/cli");
|
|
7943
8822
|
}
|
|
7944
8823
|
const mcp = resolveMcpCommand();
|
|
@@ -7956,9 +8835,22 @@ async function installAgentSurfaces(opts) {
|
|
|
7956
8835
|
return { results, mcp };
|
|
7957
8836
|
}
|
|
7958
8837
|
function registerSetupCommand(program2) {
|
|
7959
|
-
program2.command("setup").description("Install the Topolo skill and deterministic local MCP server for supported agent hosts").option("--claude", "Target Claude Code only").option("--codex", "Target Codex only").option("--agent <host...>", "Target one or more hosts by id").option("--all", "Configure every supported local host").option("--project <dir>", "Write project-scoped host files under <dir> where supported").option("--codex-home <dir>", "Use an explicit native Codex home instead of <agent-home>/.codex").option("--agent-home <dir>", "Target an alternate agent home (managed installs and clean-room verification)").option("--name <name>", "Register a named pinned MCP, such as topolo-acme").option("--context <context>", "Pin the named MCP to an exact context returned by topolo context ls").option("--installation <id>", "Bind the MCP runtime to a registered external-agent installation").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
|
|
8838
|
+
program2.command("setup").description("Install the Topolo skill and deterministic local MCP server for supported agent hosts").option("--claude", "Target Claude Code only").option("--codex", "Target Codex only").option("--agent <host...>", "Target one or more hosts by id").option("--all", "Configure every supported local host").option("--project <dir>", "Write project-scoped host files under <dir> where supported").option("--codex-home <dir>", "Use an explicit native Codex home instead of <agent-home>/.codex").option("--agent-home <dir>", "Target an alternate agent home (managed installs and clean-room verification)").option("--name <name>", "Register a named pinned MCP, such as topolo-acme").option("--context <context>", "Pin the named MCP to an exact context returned by topolo context ls").option("--installation <id>", "Bind the MCP runtime to a registered external-agent installation").option("--shared", "Opt in: register Claude Code and Codex against the shared local MCP hub (one process) instead of stdio").option("--port <port>", "With --shared: hub port on 127.0.0.1 (default 7337)").option("--import-stdio", "With --shared: also move every existing stdio topolo/topolo-* registration onto the hub").option("--no-service", "With --shared: do not install the launchd/systemd keep-alive unit").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
|
|
7960
8839
|
const mode = resolveOutputMode(opts.json);
|
|
7961
8840
|
try {
|
|
8841
|
+
if (opts.shared) {
|
|
8842
|
+
const shared = await installSharedAgentSurfaces({
|
|
8843
|
+
...opts,
|
|
8844
|
+
installationId: opts.installation,
|
|
8845
|
+
homeDir: opts.agentHome
|
|
8846
|
+
}, defaultSharedSetupDependencies(skillsSourceDir(), homedir5()));
|
|
8847
|
+
assertSharedSetupSucceeded(shared);
|
|
8848
|
+
emit(shared, mode, () => formatSharedSetup(shared));
|
|
8849
|
+
return;
|
|
8850
|
+
}
|
|
8851
|
+
if (opts.port !== void 0 || opts.importStdio || opts.service === false) {
|
|
8852
|
+
throw new Error("--port, --import-stdio and --no-service apply only with --shared.");
|
|
8853
|
+
}
|
|
7962
8854
|
const { results, mcp } = await installAgentSurfaces({
|
|
7963
8855
|
...opts,
|
|
7964
8856
|
installationId: opts.installation,
|
|
@@ -7971,6 +8863,12 @@ function registerSetupCommand(program2) {
|
|
|
7971
8863
|
}
|
|
7972
8864
|
});
|
|
7973
8865
|
}
|
|
8866
|
+
function assertSharedSetupSucceeded(result) {
|
|
8867
|
+
const failed = result.installed.filter((row) => row.mcp === "failed");
|
|
8868
|
+
if (failed.length) {
|
|
8869
|
+
throw new Error(failed.map((row) => `${AGENT_LABELS2[row.agent]}: ${row.detail}`).join("; "));
|
|
8870
|
+
}
|
|
8871
|
+
}
|
|
7974
8872
|
function assertAgentSetupSucceeded(results) {
|
|
7975
8873
|
const failed = results.filter((result) => result.mcp === "failed");
|
|
7976
8874
|
if (failed.length) {
|
|
@@ -7980,7 +8878,7 @@ function assertAgentSetupSucceeded(results) {
|
|
|
7980
8878
|
async function agentSurfaceStatus(options = {}) {
|
|
7981
8879
|
const target = resolveAgentHostTarget({ homeDir: options.homeDir, projectDir: options.project, codexHome: options.codexHome });
|
|
7982
8880
|
const { homeDir, projectDir, codexHome } = target;
|
|
7983
|
-
const sourceSkill = await
|
|
8881
|
+
const sourceSkill = await readFile9(join11(skillsSourceDir(), "topolo", "SKILL.md"), "utf8");
|
|
7984
8882
|
const agents = resolveTargets({
|
|
7985
8883
|
agent: options.agent,
|
|
7986
8884
|
all: options.all,
|
|
@@ -7990,7 +8888,7 @@ async function agentSurfaceStatus(options = {}) {
|
|
|
7990
8888
|
});
|
|
7991
8889
|
return Promise.all(agents.map(async (agent) => {
|
|
7992
8890
|
const skillPath = agentSkillPath(agent, homeDir, projectDir, codexHome);
|
|
7993
|
-
const installedSkill = skillPath && existsSync6(skillPath) ? await
|
|
8891
|
+
const installedSkill = skillPath && existsSync6(skillPath) ? await readFile9(skillPath, "utf8") : void 0;
|
|
7994
8892
|
const registrations = (await readAgentRegistrations(agent, homeDir, projectDir, codexHome)).filter((registration) => !options.name || registration.name === options.name);
|
|
7995
8893
|
return {
|
|
7996
8894
|
agent,
|
|
@@ -8016,7 +8914,7 @@ async function uninstallAgentSurfaces(options) {
|
|
|
8016
8914
|
const remaining = await readAgentRegistrations(agent, homeDir, projectDir, codexHome);
|
|
8017
8915
|
const skillPath = agentSkillPath(agent, homeDir, projectDir, codexHome);
|
|
8018
8916
|
const skillRemoved = remaining.length === 0 && Boolean(skillPath && existsSync6(skillPath));
|
|
8019
|
-
if (skillRemoved && skillPath) await
|
|
8917
|
+
if (skillRemoved && skillPath) await rm3(dirname7(skillPath), { recursive: true, force: true });
|
|
8020
8918
|
removed.push({ agent, name, registrationRemoved, skillRemoved });
|
|
8021
8919
|
}
|
|
8022
8920
|
return removed;
|
|
@@ -8045,14 +8943,128 @@ function formatStatus(rows) {
|
|
|
8045
8943
|
}
|
|
8046
8944
|
|
|
8047
8945
|
// src/commands/doctor.ts
|
|
8048
|
-
import { access } from "fs/promises";
|
|
8946
|
+
import { access as access2 } from "fs/promises";
|
|
8049
8947
|
import { homedir as homedir6 } from "os";
|
|
8050
8948
|
import { resolve as resolve4 } from "path";
|
|
8051
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8949
|
+
import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8052
8950
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
8951
|
+
|
|
8952
|
+
// src/commands/mcp-hub-doctor.ts
|
|
8953
|
+
import { access } from "fs/promises";
|
|
8954
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8955
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8956
|
+
async function verifyHubIdentity(url, headers, fetchImplementation = fetch) {
|
|
8957
|
+
const client = new Client({ name: "topolo-cli-doctor", version: CLI_VERSION });
|
|
8958
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
8959
|
+
requestInit: { headers },
|
|
8960
|
+
fetch: fetchImplementation
|
|
8961
|
+
});
|
|
8962
|
+
try {
|
|
8963
|
+
await client.connect(transport);
|
|
8964
|
+
const tools = await client.listTools();
|
|
8965
|
+
if (!tools.tools.some((tool) => tool.name === "topolo_whoami")) {
|
|
8966
|
+
throw new Error("hub identity connected but did not advertise topolo_whoami.");
|
|
8967
|
+
}
|
|
8968
|
+
const identity = await client.callTool({ name: "topolo_whoami", arguments: {} });
|
|
8969
|
+
if (identity.isError) throw new Error("topolo_whoami returned an error through the hub.");
|
|
8970
|
+
return `handshake and topolo_whoami succeeded; ${tools.tools.length} tools advertised`;
|
|
8971
|
+
} finally {
|
|
8972
|
+
await client.close();
|
|
8973
|
+
}
|
|
8974
|
+
}
|
|
8975
|
+
var defaultSharedDoctorDependencies = () => ({
|
|
8976
|
+
paths: () => mcpHubPaths(),
|
|
8977
|
+
platform: process.platform,
|
|
8978
|
+
fetch,
|
|
8979
|
+
verifyIdentity: (url, headers) => verifyHubIdentity(url, headers),
|
|
8980
|
+
fileExists: async (path) => access(path).then(() => true, () => false),
|
|
8981
|
+
environment: process.env
|
|
8982
|
+
});
|
|
8983
|
+
function message2(error) {
|
|
8984
|
+
return error instanceof Error ? error.message : String(error);
|
|
8985
|
+
}
|
|
8986
|
+
async function runSharedDoctor(options, dependencies) {
|
|
8987
|
+
const checks = [];
|
|
8988
|
+
const done = () => ({ ok: checks.every((check) => check.ok), cliVersion: CLI_VERSION, checks });
|
|
8989
|
+
const paths = dependencies.paths();
|
|
8990
|
+
let config;
|
|
8991
|
+
try {
|
|
8992
|
+
config = await readMcpHubConfig(paths.config);
|
|
8993
|
+
const selected = options.name ? config.identities.filter((identity) => identity.name === options.name) : config.identities;
|
|
8994
|
+
if (!selected.length) {
|
|
8995
|
+
throw new Error(options.name ? `${options.name} is not a hub identity in ${paths.config}.` : `No identities are configured in ${paths.config}. Run \`topolo setup --shared\`.`);
|
|
8996
|
+
}
|
|
8997
|
+
config = { ...config, identities: selected };
|
|
8998
|
+
checks.push({ name: "hub-config", ok: true, detail: `${paths.config}: port ${config.port}, ${selected.length} identities` });
|
|
8999
|
+
} catch (error) {
|
|
9000
|
+
checks.push({ name: "hub-config", ok: false, detail: message2(error) });
|
|
9001
|
+
return done();
|
|
9002
|
+
}
|
|
9003
|
+
let token;
|
|
9004
|
+
try {
|
|
9005
|
+
token = await readMcpHubToken(paths.token);
|
|
9006
|
+
checks.push({ name: "hub-token", ok: true, detail: `${paths.token} is private (0600)` });
|
|
9007
|
+
} catch (error) {
|
|
9008
|
+
checks.push({ name: "hub-token", ok: false, detail: message2(error) });
|
|
9009
|
+
return done();
|
|
9010
|
+
}
|
|
9011
|
+
const target = resolveAgentHostTarget({ homeDir: options.agentHome, codexHome: options.codexHome });
|
|
9012
|
+
const kind = serviceKindFor(dependencies.platform);
|
|
9013
|
+
if (kind) {
|
|
9014
|
+
const path = mcpHubServicePath(kind, target.homeDir, options.agentHome ? void 0 : dependencies.environment.XDG_CONFIG_HOME);
|
|
9015
|
+
const present = await dependencies.fileExists(path);
|
|
9016
|
+
checks.push({ name: "service", ok: present, detail: present ? `${kind} unit at ${path}` : `${kind} unit missing at ${path}` });
|
|
9017
|
+
}
|
|
9018
|
+
try {
|
|
9019
|
+
const response = await dependencies.fetch(`http://127.0.0.1:${config.port}/health`, {
|
|
9020
|
+
headers: { authorization: `Bearer ${token}` }
|
|
9021
|
+
});
|
|
9022
|
+
if (response.status !== 200) throw new Error(`hub /health answered ${response.status}`);
|
|
9023
|
+
checks.push({ name: "hub", ok: true, detail: `listening on 127.0.0.1:${config.port}` });
|
|
9024
|
+
} catch (error) {
|
|
9025
|
+
checks.push({ name: "hub", ok: false, detail: `hub is not reachable on 127.0.0.1:${config.port}: ${message2(error)}` });
|
|
9026
|
+
return done();
|
|
9027
|
+
}
|
|
9028
|
+
const explicit = [...options.agent ?? [], ...options.claude ? ["claude"] : [], ...options.codex ? ["codex"] : []];
|
|
9029
|
+
const agents = (explicit.length ? explicit : [...SHARED_HUB_AGENTS]).filter((agent) => SHARED_HUB_AGENTS.includes(agent));
|
|
9030
|
+
for (const agent of agents) {
|
|
9031
|
+
const registrations = await readAgentRegistrations(agent, target.homeDir, void 0, target.codexHome).catch((error) => {
|
|
9032
|
+
checks.push({ name: "registration", ok: false, detail: `${agent}: ${message2(error)}` });
|
|
9033
|
+
return void 0;
|
|
9034
|
+
});
|
|
9035
|
+
if (!registrations) continue;
|
|
9036
|
+
const headers = hubHeaders(token, agent);
|
|
9037
|
+
for (const identity of config.identities) {
|
|
9038
|
+
const entry = registrations.find((registration) => registration.name === identity.name);
|
|
9039
|
+
const exact = entry?.url === mcpHubUrl(config.port, identity.name) && entry.headers?.Authorization === headers.Authorization;
|
|
9040
|
+
checks.push({
|
|
9041
|
+
name: "registration",
|
|
9042
|
+
ok: exact,
|
|
9043
|
+
detail: `${agent}: ${identity.name} ${exact ? "points at the hub with the current token" : "is missing, stdio, or uses a stale hub URL/token"}`
|
|
9044
|
+
});
|
|
9045
|
+
}
|
|
9046
|
+
}
|
|
9047
|
+
for (const identity of config.identities) {
|
|
9048
|
+
try {
|
|
9049
|
+
const detail = await dependencies.verifyIdentity(
|
|
9050
|
+
mcpHubUrl(config.port, identity.name),
|
|
9051
|
+
{ Authorization: `Bearer ${token}`, "X-Topolo-Agent": "topolo-doctor" }
|
|
9052
|
+
);
|
|
9053
|
+
checks.push({ name: "mcp", ok: true, detail: `${identity.name}: ${detail}` });
|
|
9054
|
+
} catch (error) {
|
|
9055
|
+
checks.push({ name: "mcp", ok: false, detail: `${identity.name}: ${message2(error)}` });
|
|
9056
|
+
}
|
|
9057
|
+
}
|
|
9058
|
+
return done();
|
|
9059
|
+
}
|
|
9060
|
+
function formatSharedDoctor(result) {
|
|
9061
|
+
return result.checks.map((check) => `${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}`).join("\n");
|
|
9062
|
+
}
|
|
9063
|
+
|
|
9064
|
+
// src/commands/doctor.ts
|
|
8053
9065
|
var defaultDependencies = {
|
|
8054
9066
|
resolveMcp: resolveMcpCommand,
|
|
8055
|
-
verifyFile: (path) =>
|
|
9067
|
+
verifyFile: (path) => access2(path),
|
|
8056
9068
|
verifyAuthentication: async (context, environment) => {
|
|
8057
9069
|
const topolo = await buildClient({ context, environment });
|
|
8058
9070
|
if (!topolo) throw new Error(context ? `No credential configured for ${context}. Run \`topolo auth login\` for that context.` : "No credential configured. Run `topolo auth login`.");
|
|
@@ -8092,7 +9104,7 @@ async function runDoctor(dependencies = defaultDependencies, target) {
|
|
|
8092
9104
|
return { ok: checks.every((check) => check.ok), cliVersion: CLI_VERSION, checks };
|
|
8093
9105
|
}
|
|
8094
9106
|
async function verifyMcpProtocol(command, args, env = definedEnvironment(process.env)) {
|
|
8095
|
-
const client = new
|
|
9107
|
+
const client = new Client2({ name: "topolo-cli-doctor", version: CLI_VERSION });
|
|
8096
9108
|
const transport = new StdioClientTransport({ command, args, stderr: "pipe", env });
|
|
8097
9109
|
try {
|
|
8098
9110
|
await client.connect(transport);
|
|
@@ -8132,7 +9144,7 @@ async function resolveInstalledDoctorTarget(options) {
|
|
|
8132
9144
|
preflightChecks.push({
|
|
8133
9145
|
name: "registration",
|
|
8134
9146
|
ok: exact,
|
|
8135
|
-
detail: `${row.agent}: ${exact ? `${name} uses the bundled launcher` : `${name} is missing or does not use the bundled launcher`}`
|
|
9147
|
+
detail: `${row.agent}: ${exact ? `${name} uses the bundled launcher` : registration?.url ? `${name} is registered with the shared MCP hub; run \`topolo doctor --shared\`` : `${name} is missing or does not use the bundled launcher`}`
|
|
8136
9148
|
});
|
|
8137
9149
|
}
|
|
8138
9150
|
const context = options.context !== void 0 ? normalizeDoctorContext(options.context) : selected?.env.TOPOLO_CONTEXT;
|
|
@@ -8150,9 +9162,15 @@ function definedEnvironment(environment) {
|
|
|
8150
9162
|
return Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== void 0));
|
|
8151
9163
|
}
|
|
8152
9164
|
function registerDoctorCommand(program2) {
|
|
8153
|
-
program2.command("doctor").description("Verify installed agent surfaces, exact credential, and MCP protocol end to end").option("--name <name>", "Verify one installed MCP registration (defaults to topolo)").option("--context <context>", "Verify one exact personal or organization context").option("--environment <env>", "Override the context environment").option("--claude", "Verify Claude Code only").option("--codex", "Verify Codex only").option("--agent <host...>", "Verify one or more hosts by id").option("--all", "Verify every supported local host").option("--project <dir>", "Inspect project-scoped host files under <dir>").option("--agent-home <dir>", "Inspect an alternate agent home").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
|
|
9165
|
+
program2.command("doctor").description("Verify installed agent surfaces, exact credential, and MCP protocol end to end").option("--name <name>", "Verify one installed MCP registration (defaults to topolo)").option("--context <context>", "Verify one exact personal or organization context").option("--environment <env>", "Override the context environment").option("--claude", "Verify Claude Code only").option("--codex", "Verify Codex only").option("--agent <host...>", "Verify one or more hosts by id").option("--all", "Verify every supported local host").option("--project <dir>", "Inspect project-scoped host files under <dir>").option("--agent-home <dir>", "Inspect an alternate agent home").option("--shared", "Verify the shared local MCP hub end to end for every hub identity (or --name)").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
|
|
8154
9166
|
const mode = resolveOutputMode(opts.json);
|
|
8155
9167
|
try {
|
|
9168
|
+
if (opts.shared) {
|
|
9169
|
+
const shared = await runSharedDoctor(opts, defaultSharedDoctorDependencies());
|
|
9170
|
+
if (!shared.ok) throw new Error(formatSharedDoctor(shared));
|
|
9171
|
+
emit(shared, mode, () => formatSharedDoctor(shared));
|
|
9172
|
+
return;
|
|
9173
|
+
}
|
|
8156
9174
|
const target = await resolveInstalledDoctorTarget(opts);
|
|
8157
9175
|
const result = await runDoctor(defaultDependencies, target);
|
|
8158
9176
|
if (!result.ok) throw new Error(formatDoctor(result));
|
|
@@ -8749,6 +9767,248 @@ function registerInstallationsCommand(program2) {
|
|
|
8749
9767
|
});
|
|
8750
9768
|
}
|
|
8751
9769
|
|
|
9770
|
+
// src/commands/mcp.ts
|
|
9771
|
+
import { createTopoloMcpLocalHandler } from "@topolo/mcp/local";
|
|
9772
|
+
|
|
9773
|
+
// src/lib/mcp-hub-server.ts
|
|
9774
|
+
import { createHash as createHash4, timingSafeEqual } from "crypto";
|
|
9775
|
+
import { stat as stat2 } from "fs/promises";
|
|
9776
|
+
import { createServer } from "http";
|
|
9777
|
+
var MCP_HUB_HOST = "127.0.0.1";
|
|
9778
|
+
var MCP_HUB_MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
9779
|
+
var DEFAULT_AGENT_LABEL = "topolo-mcp-hub";
|
|
9780
|
+
var AGENT_LABELS3 = /* @__PURE__ */ new Set([...Object.values(AGENT_NAME_FOR), "topolo-doctor"]);
|
|
9781
|
+
var STRIPPED_HEADERS = /* @__PURE__ */ new Set(["authorization", "x-api-key", "cookie", "host", "origin"]);
|
|
9782
|
+
function json(status, body, headers = {}) {
|
|
9783
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
|
|
9784
|
+
}
|
|
9785
|
+
function refusal(status, message3, headers = {}) {
|
|
9786
|
+
return json(status, { jsonrpc: "2.0", error: { code: -32e3, message: message3 }, id: null }, headers);
|
|
9787
|
+
}
|
|
9788
|
+
function digest(value) {
|
|
9789
|
+
return createHash4("sha256").update(value, "utf8").digest();
|
|
9790
|
+
}
|
|
9791
|
+
function bearerTokenMatches(header, token) {
|
|
9792
|
+
const match = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(header ?? "");
|
|
9793
|
+
if (!match) return false;
|
|
9794
|
+
return timingSafeEqual(digest(match[1]), digest(token));
|
|
9795
|
+
}
|
|
9796
|
+
function loopbackHosts(port) {
|
|
9797
|
+
return /* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
9798
|
+
}
|
|
9799
|
+
function loopbackOrigins(port) {
|
|
9800
|
+
return new Set([...loopbackHosts(port)].map((host) => `http://${host}`));
|
|
9801
|
+
}
|
|
9802
|
+
function agentLabel(value) {
|
|
9803
|
+
const label = value?.trim().toLowerCase() ?? "";
|
|
9804
|
+
return AGENT_LABELS3.has(label) ? label : DEFAULT_AGENT_LABEL;
|
|
9805
|
+
}
|
|
9806
|
+
function createMcpHubHandler(options) {
|
|
9807
|
+
const hosts = loopbackHosts(options.port);
|
|
9808
|
+
const origins = loopbackOrigins(options.port);
|
|
9809
|
+
const now = options.now ?? Date.now;
|
|
9810
|
+
const log = options.log ?? (() => void 0);
|
|
9811
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
9812
|
+
function handlerFor(identity, agentName) {
|
|
9813
|
+
const key = JSON.stringify([identity.name, identity.environment, identity.context ?? null, agentName]);
|
|
9814
|
+
let handler = handlers.get(key);
|
|
9815
|
+
if (!handler) {
|
|
9816
|
+
handler = options.createIdentityHandler(identity, agentName);
|
|
9817
|
+
handlers.set(key, handler);
|
|
9818
|
+
}
|
|
9819
|
+
return handler;
|
|
9820
|
+
}
|
|
9821
|
+
async function forward(request, url, identity) {
|
|
9822
|
+
const headers = new Headers();
|
|
9823
|
+
request.headers.forEach((value, name) => {
|
|
9824
|
+
if (!STRIPPED_HEADERS.has(name.toLowerCase())) headers.set(name, value);
|
|
9825
|
+
});
|
|
9826
|
+
const body = request.method === "GET" || request.method === "HEAD" ? void 0 : await request.arrayBuffer();
|
|
9827
|
+
const forwarded = new Request(`http://${MCP_HUB_HOST}:${options.port}${url.pathname}`, {
|
|
9828
|
+
method: request.method,
|
|
9829
|
+
headers,
|
|
9830
|
+
...body ? { body } : {}
|
|
9831
|
+
});
|
|
9832
|
+
return handlerFor(identity, agentLabel(request.headers.get("x-topolo-agent"))).fetch(forwarded);
|
|
9833
|
+
}
|
|
9834
|
+
async function route(request) {
|
|
9835
|
+
const host = request.headers.get("host")?.toLowerCase();
|
|
9836
|
+
if (!host || !hosts.has(host)) return refusal(403, "Host is not a loopback address for this hub.");
|
|
9837
|
+
const origin = request.headers.get("origin");
|
|
9838
|
+
if (origin !== null && !origins.has(origin.toLowerCase())) {
|
|
9839
|
+
return refusal(403, "Cross-origin requests to the Topolo MCP hub are refused.");
|
|
9840
|
+
}
|
|
9841
|
+
if (!bearerTokenMatches(request.headers.get("authorization"), options.token)) {
|
|
9842
|
+
return refusal(401, "Missing or invalid Topolo MCP hub token.", { "www-authenticate": 'Bearer realm="topolo-mcp-hub"' });
|
|
9843
|
+
}
|
|
9844
|
+
const url = new URL(request.url);
|
|
9845
|
+
let config;
|
|
9846
|
+
try {
|
|
9847
|
+
config = await options.loadConfig();
|
|
9848
|
+
} catch {
|
|
9849
|
+
return refusal(500, "The Topolo MCP hub configuration is invalid. Run `topolo doctor --shared`.");
|
|
9850
|
+
}
|
|
9851
|
+
if (url.pathname === "/health") {
|
|
9852
|
+
if (request.method !== "GET") return refusal(405, "Use GET for /health.", { allow: "GET" });
|
|
9853
|
+
return json(200, { ok: true, identities: config.identities.map((identity2) => identity2.name) });
|
|
9854
|
+
}
|
|
9855
|
+
const match = /^\/mcp\/([^/]+)$/.exec(url.pathname);
|
|
9856
|
+
const identity = match ? config.identities.find((entry) => entry.name === match[1]) : void 0;
|
|
9857
|
+
if (!identity) return refusal(404, "No Topolo MCP identity is mounted at this path.");
|
|
9858
|
+
try {
|
|
9859
|
+
return await forward(request, url, identity);
|
|
9860
|
+
} catch {
|
|
9861
|
+
return refusal(500, "The Topolo MCP identity failed unexpectedly.");
|
|
9862
|
+
}
|
|
9863
|
+
}
|
|
9864
|
+
return async (request) => {
|
|
9865
|
+
const started = now();
|
|
9866
|
+
const response = await route(request);
|
|
9867
|
+
log(`${request.method} ${new URL(request.url).pathname} ${response.status} ${now() - started}ms`);
|
|
9868
|
+
return response;
|
|
9869
|
+
};
|
|
9870
|
+
}
|
|
9871
|
+
function createMcpHubConfigLoader(path) {
|
|
9872
|
+
let cached;
|
|
9873
|
+
return async () => {
|
|
9874
|
+
let signature = "missing";
|
|
9875
|
+
try {
|
|
9876
|
+
const info = await stat2(path);
|
|
9877
|
+
signature = [info.ino, info.size, info.mtimeMs].join(":");
|
|
9878
|
+
} catch (error) {
|
|
9879
|
+
if (error.code !== "ENOENT") throw error;
|
|
9880
|
+
}
|
|
9881
|
+
if (cached?.signature === signature) return cached.config;
|
|
9882
|
+
const config = await readMcpHubConfig(path);
|
|
9883
|
+
cached = { signature, config };
|
|
9884
|
+
return config;
|
|
9885
|
+
};
|
|
9886
|
+
}
|
|
9887
|
+
async function readBody(request, limit) {
|
|
9888
|
+
const chunks = [];
|
|
9889
|
+
let size = 0;
|
|
9890
|
+
for await (const chunk of request) {
|
|
9891
|
+
size += chunk.length;
|
|
9892
|
+
if (size > limit) throw new Error("Request body exceeds the hub limit.");
|
|
9893
|
+
chunks.push(chunk);
|
|
9894
|
+
}
|
|
9895
|
+
return Buffer.concat(chunks);
|
|
9896
|
+
}
|
|
9897
|
+
function toWebRequest(request, body, port) {
|
|
9898
|
+
const headers = new Headers();
|
|
9899
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
9900
|
+
if (value === void 0) continue;
|
|
9901
|
+
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
9902
|
+
}
|
|
9903
|
+
return new Request(`http://${MCP_HUB_HOST}:${port}${request.url ?? "/"}`, {
|
|
9904
|
+
method: request.method ?? "GET",
|
|
9905
|
+
headers,
|
|
9906
|
+
...body && body.length ? { body } : {}
|
|
9907
|
+
});
|
|
9908
|
+
}
|
|
9909
|
+
async function writeWebResponse(response, target) {
|
|
9910
|
+
const headers = {};
|
|
9911
|
+
response.headers.forEach((value, name) => {
|
|
9912
|
+
headers[name] = value;
|
|
9913
|
+
});
|
|
9914
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
9915
|
+
target.writeHead(response.status, headers);
|
|
9916
|
+
target.end(body);
|
|
9917
|
+
}
|
|
9918
|
+
async function startMcpHub(options) {
|
|
9919
|
+
const server = createServer();
|
|
9920
|
+
await new Promise((resolve5, reject) => {
|
|
9921
|
+
server.once("error", reject);
|
|
9922
|
+
server.listen(options.port, MCP_HUB_HOST, () => {
|
|
9923
|
+
server.off("error", reject);
|
|
9924
|
+
resolve5();
|
|
9925
|
+
});
|
|
9926
|
+
});
|
|
9927
|
+
const port = server.address().port;
|
|
9928
|
+
const handle = createMcpHubHandler({ ...options, port });
|
|
9929
|
+
const limit = options.maxBodyBytes ?? MCP_HUB_MAX_BODY_BYTES;
|
|
9930
|
+
server.on("request", (request, response) => {
|
|
9931
|
+
void (async () => {
|
|
9932
|
+
let body;
|
|
9933
|
+
try {
|
|
9934
|
+
body = request.method === "GET" || request.method === "HEAD" ? void 0 : await readBody(request, limit);
|
|
9935
|
+
} catch {
|
|
9936
|
+
await writeWebResponse(refusal(413, "Request body is too large for the Topolo MCP hub."), response);
|
|
9937
|
+
request.resume();
|
|
9938
|
+
return;
|
|
9939
|
+
}
|
|
9940
|
+
let webRequest;
|
|
9941
|
+
try {
|
|
9942
|
+
webRequest = toWebRequest(request, body, port);
|
|
9943
|
+
} catch {
|
|
9944
|
+
await writeWebResponse(refusal(400, "Malformed request."), response);
|
|
9945
|
+
return;
|
|
9946
|
+
}
|
|
9947
|
+
await writeWebResponse(await handle(webRequest), response);
|
|
9948
|
+
})().catch(() => {
|
|
9949
|
+
response.destroy();
|
|
9950
|
+
});
|
|
9951
|
+
});
|
|
9952
|
+
return {
|
|
9953
|
+
port,
|
|
9954
|
+
server,
|
|
9955
|
+
close: () => new Promise((resolve5) => {
|
|
9956
|
+
server.close(() => resolve5());
|
|
9957
|
+
server.closeAllConnections();
|
|
9958
|
+
})
|
|
9959
|
+
};
|
|
9960
|
+
}
|
|
9961
|
+
|
|
9962
|
+
// src/commands/mcp.ts
|
|
9963
|
+
var createLocalIdentityHandler = (identity, agentName) => createTopoloMcpLocalHandler({
|
|
9964
|
+
identity: { environment: identity.environment, context: identity.context ?? null },
|
|
9965
|
+
agentName
|
|
9966
|
+
});
|
|
9967
|
+
var defaultServeDependencies = () => ({
|
|
9968
|
+
paths: () => mcpHubPaths(),
|
|
9969
|
+
start: startMcpHub,
|
|
9970
|
+
createIdentityHandler: createLocalIdentityHandler,
|
|
9971
|
+
log: (line) => process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} topolo mcp hub: ${line}
|
|
9972
|
+
`),
|
|
9973
|
+
onSignal: (signal, listener) => {
|
|
9974
|
+
process.once(signal, listener);
|
|
9975
|
+
},
|
|
9976
|
+
exit: (code) => process.exit(code)
|
|
9977
|
+
});
|
|
9978
|
+
async function serveMcpHub(options, dependencies) {
|
|
9979
|
+
const paths = dependencies.paths();
|
|
9980
|
+
const config = await readMcpHubConfig(paths.config);
|
|
9981
|
+
const port = options.port !== void 0 ? validateMcpHubPort(options.port) : config.port;
|
|
9982
|
+
const { token } = await ensureMcpHubToken(paths.token);
|
|
9983
|
+
const hub = await dependencies.start({
|
|
9984
|
+
port,
|
|
9985
|
+
token,
|
|
9986
|
+
loadConfig: createMcpHubConfigLoader(paths.config),
|
|
9987
|
+
createIdentityHandler: dependencies.createIdentityHandler,
|
|
9988
|
+
log: dependencies.log
|
|
9989
|
+
});
|
|
9990
|
+
dependencies.log(
|
|
9991
|
+
`listening on http://127.0.0.1:${hub.port}/mcp/<name> with ${config.identities.length} identities (${config.identities.map((identity) => identity.name).join(", ") || "none yet"}); config ${paths.config}`
|
|
9992
|
+
);
|
|
9993
|
+
const stop = () => {
|
|
9994
|
+
dependencies.log("stopping");
|
|
9995
|
+
void hub.close().then(() => dependencies.exit(0));
|
|
9996
|
+
};
|
|
9997
|
+
dependencies.onSignal("SIGINT", stop);
|
|
9998
|
+
dependencies.onSignal("SIGTERM", stop);
|
|
9999
|
+
return hub;
|
|
10000
|
+
}
|
|
10001
|
+
function registerMcpCommand(program2, dependencies = defaultServeDependencies) {
|
|
10002
|
+
const mcp = program2.command("mcp").description("Run the shared local Topolo MCP hub (one process for every agent thread and identity)");
|
|
10003
|
+
mcp.command("serve").description("Serve every identity in mcp-hub.json at http://127.0.0.1:<port>/mcp/<name> (loopback only, bearer token required)").option("--port <port>", "Port on 127.0.0.1 (defaults to the port in mcp-hub.json, 7337)").action(async (opts) => {
|
|
10004
|
+
try {
|
|
10005
|
+
await serveMcpHub(opts, dependencies());
|
|
10006
|
+
} catch (error) {
|
|
10007
|
+
fail2(error, resolveOutputMode(false));
|
|
10008
|
+
}
|
|
10009
|
+
});
|
|
10010
|
+
}
|
|
10011
|
+
|
|
8752
10012
|
// src/index.ts
|
|
8753
10013
|
import { Agent, setGlobalDispatcher } from "undici";
|
|
8754
10014
|
setGlobalDispatcher(new Agent({ connect: { timeout: 3e4 } }));
|
|
@@ -8774,6 +10034,7 @@ program.hook("preAction", async (thisCommand, actionCommand) => {
|
|
|
8774
10034
|
if (opts.env || environmentWasStated) return;
|
|
8775
10035
|
for (let node = actionCommand; node; node = node.parent) {
|
|
8776
10036
|
if (node.name() === "agent-packs") return;
|
|
10037
|
+
if (node.name() === "mcp" && node.parent === program) return;
|
|
8777
10038
|
}
|
|
8778
10039
|
const authenticated = await authenticatedEnvironments();
|
|
8779
10040
|
if (authenticated.length < 2) return;
|
|
@@ -8796,6 +10057,7 @@ Or set it once for the whole session:
|
|
|
8796
10057
|
registerAuthCommands(program);
|
|
8797
10058
|
registerWhoamiCommand(program);
|
|
8798
10059
|
registerActionsCommand(program);
|
|
10060
|
+
registerCredentialsCommand(program);
|
|
8799
10061
|
registerApiCommand(program);
|
|
8800
10062
|
registerAppsCommand(program);
|
|
8801
10063
|
registerOAuthCommands(program);
|
|
@@ -8809,6 +10071,7 @@ registerResourcesCommand(program);
|
|
|
8809
10071
|
registerWorkspacesCommand(program);
|
|
8810
10072
|
registerInstallationsCommand(program);
|
|
8811
10073
|
registerAgentPacksCommand(program);
|
|
10074
|
+
registerMcpCommand(program);
|
|
8812
10075
|
var helpCommands = [program];
|
|
8813
10076
|
for (const command of helpCommands) {
|
|
8814
10077
|
command.configureHelp({
|