@topolo/cli 0.13.13 → 0.13.16
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 +187 -8
- 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 +1586 -175
- package/package.json +3 -3
- package/skills/topolo/SKILL.md +19 -2
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
|
}
|
|
@@ -5599,6 +5599,70 @@ function resolveOAuthClientId(config, environment = selectedEnvironment()) {
|
|
|
5599
5599
|
if (process.env.TOPOLO_CLI_CLIENT_ID) return process.env.TOPOLO_CLI_CLIENT_ID;
|
|
5600
5600
|
return DEFAULT_CLI_CLIENT_IDS[environment] ?? profileForEnvironment(config, environment).oauthClientId;
|
|
5601
5601
|
}
|
|
5602
|
+
function signedInContextKeys(config, environment) {
|
|
5603
|
+
return Object.keys(profileForEnvironment(config, environment).contexts ?? {});
|
|
5604
|
+
}
|
|
5605
|
+
function matchContextSelector(config, environment, selector) {
|
|
5606
|
+
const normalized = selector.trim().toLowerCase();
|
|
5607
|
+
if (!normalized || /[\r\n]/.test(selector)) {
|
|
5608
|
+
throw new Error("Context must be a non-empty single-line identifier.");
|
|
5609
|
+
}
|
|
5610
|
+
const contexts = Object.entries(profileForEnvironment(config, environment).contexts ?? {});
|
|
5611
|
+
const exact = contexts.find(([key]) => key.toLowerCase() === normalized);
|
|
5612
|
+
if (exact) return exact[0];
|
|
5613
|
+
const matches = contexts.filter(([, context]) => [
|
|
5614
|
+
context.organizationId,
|
|
5615
|
+
context.organizationSlug,
|
|
5616
|
+
context.organizationName
|
|
5617
|
+
].some((value) => String(value || "").trim().toLowerCase() === normalized));
|
|
5618
|
+
if (matches.length > 1) {
|
|
5619
|
+
throw new Error(`Context "${selector.trim()}" is ambiguous in ${environment}; use an exact key from \`topolo --env ${environment} context ls\`.`);
|
|
5620
|
+
}
|
|
5621
|
+
return matches[0]?.[0] ?? null;
|
|
5622
|
+
}
|
|
5623
|
+
function commandContextKey(config, environment, explicit = process.env.TOPOLO_CONTEXT) {
|
|
5624
|
+
const selector = explicit?.trim();
|
|
5625
|
+
if (selector) return matchContextSelector(config, environment, selector) ?? selector;
|
|
5626
|
+
return implicitContextKey(config, environment) ?? void 0;
|
|
5627
|
+
}
|
|
5628
|
+
function implicitContextKey(config, environment) {
|
|
5629
|
+
if (credentialSourceFromConfig(config, environment).startsWith("env:")) return null;
|
|
5630
|
+
const keys = signedInContextKeys(config, environment);
|
|
5631
|
+
if (keys.length > 1) throw new ContextNotSafeToAssumeError(environment, keys);
|
|
5632
|
+
return keys[0] ?? null;
|
|
5633
|
+
}
|
|
5634
|
+
var ContextNotSafeToAssumeError = class extends Error {
|
|
5635
|
+
constructor(environment, contexts) {
|
|
5636
|
+
super(
|
|
5637
|
+
`This machine is signed in to ${contexts.length} Topolo contexts in ${environment} (${contexts.join(", ")}), so the context is no longer safe to assume.
|
|
5638
|
+
|
|
5639
|
+
State it explicitly:
|
|
5640
|
+
topolo --env ${environment} --context <${contexts.join("|")}> ...
|
|
5641
|
+
|
|
5642
|
+
Or set it once for the whole session:
|
|
5643
|
+
export TOPOLO_CONTEXT=<${contexts.join("|")}>`
|
|
5644
|
+
);
|
|
5645
|
+
this.environment = environment;
|
|
5646
|
+
this.contexts = contexts;
|
|
5647
|
+
this.name = "ContextNotSafeToAssumeError";
|
|
5648
|
+
}
|
|
5649
|
+
environment;
|
|
5650
|
+
contexts;
|
|
5651
|
+
};
|
|
5652
|
+
async function withPinnedContext(options, read = () => readConfig()) {
|
|
5653
|
+
if (options.context?.trim() || options.installationId?.trim()) return options;
|
|
5654
|
+
const environment = normalizeEnvironment(options.environment?.trim() || process.env.TOPOLO_ENV || "production");
|
|
5655
|
+
const keys = signedInContextKeys(await read(), environment);
|
|
5656
|
+
if (keys.length === 1) return { ...options, context: keys[0] };
|
|
5657
|
+
if (!keys.length) {
|
|
5658
|
+
throw new Error(
|
|
5659
|
+
`No Topolo context is signed in for ${environment}, so there is nothing to pin. Run \`topolo --env ${environment} auth login\` first, or pass --context <context>.`
|
|
5660
|
+
);
|
|
5661
|
+
}
|
|
5662
|
+
throw new Error(
|
|
5663
|
+
`${keys.length} Topolo contexts are signed in for ${environment} (${keys.join(", ")}). Pass --context <context> to choose the one this MCP registration pins.`
|
|
5664
|
+
);
|
|
5665
|
+
}
|
|
5602
5666
|
|
|
5603
5667
|
// src/output.ts
|
|
5604
5668
|
function resolveOutputMode(override) {
|
|
@@ -5705,7 +5769,14 @@ var CLI_VERSION = readCliVersion();
|
|
|
5705
5769
|
// src/commands/auth.ts
|
|
5706
5770
|
function registerAuthCommands(program2) {
|
|
5707
5771
|
const auth = program2.command("auth").description("Manage Topolo credentials");
|
|
5708
|
-
auth.command("login").description("Authenticate with Topolo via OAuth device flow (default) or store an API key").option("--api-key <key>", "Store an API key directly, skipping the OAuth flow").option("--client-id <id>", "OAuth client_id (or set TOPOLO_CLI_CLIENT_ID)").option("--scope <scope>", "Scopes to request (space-separated)").option("--no-browser", "Print the approval URL without opening a browser").option("--json", "Output JSON").option("--no-json", "Output human-readable text").
|
|
5772
|
+
auth.command("login").description("Authenticate with Topolo via OAuth device flow (default) or store an API key").option("--api-key <key>", "Store an API key directly, skipping the OAuth flow").option("--client-id <id>", "OAuth client_id (or set TOPOLO_CLI_CLIENT_ID)").option("--scope <scope>", "Scopes to request (space-separated)").option("--no-browser", "Print the approval URL without opening a browser").option("--use", "Also make the approved context this environment's stored default (as `topolo context use`)").option("--json", "Output JSON").option("--no-json", "Output human-readable text").addHelpText("after", [
|
|
5773
|
+
"",
|
|
5774
|
+
"The approved sign-in is stored under its own context key (personal or org:<id>).",
|
|
5775
|
+
"It never changes the stored default context unless you pass --use, or it is the",
|
|
5776
|
+
"first context signed in for the environment. To re-authorise one context, name it:",
|
|
5777
|
+
" topolo --env <env> --context <context> auth login",
|
|
5778
|
+
"and the CLI warns (and exits 1) if the browser approves a different user or org."
|
|
5779
|
+
].join("\n")).action(
|
|
5709
5780
|
async (opts) => {
|
|
5710
5781
|
const mode = resolveOutputMode(opts.json);
|
|
5711
5782
|
const environment = selectedEnvironment();
|
|
@@ -5739,7 +5810,9 @@ Visit ${apiKeyIssuanceUrl(environment)} to mint one, or configure an OAuth clien
|
|
|
5739
5810
|
environment,
|
|
5740
5811
|
scope: opts.scope,
|
|
5741
5812
|
mode,
|
|
5742
|
-
openBrowser: opts.browser !== false
|
|
5813
|
+
openBrowser: opts.browser !== false,
|
|
5814
|
+
makeDefault: opts.use === true,
|
|
5815
|
+
requestedContext: process.env.TOPOLO_CONTEXT?.trim() || void 0
|
|
5743
5816
|
});
|
|
5744
5817
|
} catch (err) {
|
|
5745
5818
|
fail2(err, mode);
|
|
@@ -5856,18 +5929,44 @@ async function runDeviceFlow(options) {
|
|
|
5856
5929
|
...token.refresh_token ? { refreshToken: token.refresh_token } : {},
|
|
5857
5930
|
expiresAt: Math.floor(Date.now() / 1e3) + token.expires_in
|
|
5858
5931
|
};
|
|
5859
|
-
const context = await resolveLoginContext(
|
|
5932
|
+
const { context, identity } = await resolveLoginContext(
|
|
5860
5933
|
credential,
|
|
5861
5934
|
resolved.serviceUrls,
|
|
5862
5935
|
options.clientId,
|
|
5863
5936
|
options.environment
|
|
5864
5937
|
);
|
|
5938
|
+
const approvedKey = contextKey(context.contextType, context.organizationId);
|
|
5865
5939
|
const updated = await readConfig();
|
|
5866
|
-
|
|
5940
|
+
const previousDefault = profileDefault(updated, options.environment);
|
|
5941
|
+
const makeDefault = options.makeDefault === true || signedInContextKeys(updated, options.environment).length === 0;
|
|
5942
|
+
await writeConfig(writeContextCredential(updated, options.environment, context, makeDefault));
|
|
5943
|
+
const defaultChanged = makeDefault && previousDefault !== approvedKey;
|
|
5944
|
+
const requested = options.requestedContext ? requestedContextKey(updated, options.environment, options.requestedContext) : void 0;
|
|
5945
|
+
const requestedRenewed = requested === void 0 || identity !== null && loginMatches(requested, context);
|
|
5946
|
+
const approved = {
|
|
5947
|
+
contextKey: approvedKey,
|
|
5948
|
+
user: identity ? { email: identity.user.email, name: identity.user.name } : null,
|
|
5949
|
+
organization: context.contextType === "organization" ? { id: context.organizationId, slug: context.organizationSlug ?? null, name: context.organizationName ?? null } : null
|
|
5950
|
+
};
|
|
5951
|
+
const who = identity ? identity.user.email : "an identity that could not be verified";
|
|
5952
|
+
const where = context.contextType === "organization" ? `${context.organizationName || context.organizationSlug || context.organizationId} (${approvedKey})` : `personal context (${approvedKey})`;
|
|
5953
|
+
if (!requestedRenewed) {
|
|
5954
|
+
process.stderr.write(
|
|
5955
|
+
`
|
|
5956
|
+
\u26A0 You asked to re-authorise "${options.requestedContext}", but the browser approved ${who} in ${where}.
|
|
5957
|
+
"${options.requestedContext}" was NOT renewed. The approval was stored under ${approvedKey} only.
|
|
5958
|
+
Sign the browser in as the user for "${options.requestedContext}" and run this again.
|
|
5959
|
+
`
|
|
5960
|
+
);
|
|
5961
|
+
process.exitCode = 1;
|
|
5962
|
+
}
|
|
5867
5963
|
emit(
|
|
5868
5964
|
{
|
|
5869
|
-
ok:
|
|
5965
|
+
ok: requestedRenewed,
|
|
5870
5966
|
environment: options.environment,
|
|
5967
|
+
approved,
|
|
5968
|
+
...options.requestedContext ? { requestedContext: options.requestedContext, requestedContextRenewed: requestedRenewed } : {},
|
|
5969
|
+
defaultContext: { changed: defaultChanged, key: makeDefault ? approvedKey : previousDefault ?? null },
|
|
5871
5970
|
credential: {
|
|
5872
5971
|
kind: "access_token",
|
|
5873
5972
|
expiresIn: token.expires_in,
|
|
@@ -5875,10 +5974,34 @@ async function runDeviceFlow(options) {
|
|
|
5875
5974
|
}
|
|
5876
5975
|
},
|
|
5877
5976
|
options.mode,
|
|
5878
|
-
() =>
|
|
5977
|
+
() => [
|
|
5978
|
+
"",
|
|
5979
|
+
` \u2713 Signed in to Topolo ${environmentLabel(options.environment)}.`,
|
|
5980
|
+
` Approved as: ${who}${identity?.user.name ? ` (${identity.user.name})` : ""}`,
|
|
5981
|
+
` Context: ${where}`,
|
|
5982
|
+
defaultChanged ? ` Default: ${approvedKey} is now this environment's stored default context.` : ` Default: unchanged${previousDefault ? ` (${previousDefault})` : ""}. Nothing acts as ${approvedKey} unless pinned.`,
|
|
5983
|
+
` Use it with: topolo --env ${options.environment} --context ${approvedKey} <command>`
|
|
5984
|
+
].join("\n")
|
|
5879
5985
|
);
|
|
5880
5986
|
}
|
|
5987
|
+
function profileDefault(config, environment) {
|
|
5988
|
+
return config.profiles?.[environment]?.activeContext;
|
|
5989
|
+
}
|
|
5990
|
+
function requestedContextKey(config, environment, selector) {
|
|
5991
|
+
try {
|
|
5992
|
+
return matchContextSelector(config, environment, selector) ?? selector.trim();
|
|
5993
|
+
} catch {
|
|
5994
|
+
return selector.trim();
|
|
5995
|
+
}
|
|
5996
|
+
}
|
|
5997
|
+
function loginMatches(requested, context) {
|
|
5998
|
+
const wanted = requested.toLowerCase();
|
|
5999
|
+
if (wanted === contextKey(context.contextType, context.organizationId).toLowerCase()) return true;
|
|
6000
|
+
if (context.contextType !== "organization") return false;
|
|
6001
|
+
return [context.organizationId, context.organizationSlug, context.organizationName].some((value) => String(value || "").trim().toLowerCase() === wanted);
|
|
6002
|
+
}
|
|
5881
6003
|
async function resolveLoginContext(credential, serviceUrls, clientId, environment) {
|
|
6004
|
+
let identity = null;
|
|
5882
6005
|
try {
|
|
5883
6006
|
const topolo = createTopolo({
|
|
5884
6007
|
credential,
|
|
@@ -5886,21 +6009,24 @@ async function resolveLoginContext(credential, serviceUrls, clientId, environmen
|
|
|
5886
6009
|
agent: { clientName: "topolo-cli", clientVersion: CLI_VERSION },
|
|
5887
6010
|
serviceUrls
|
|
5888
6011
|
});
|
|
5889
|
-
|
|
5890
|
-
const org =
|
|
6012
|
+
identity = await topolo.identity.whoami();
|
|
6013
|
+
const org = identity.organization;
|
|
5891
6014
|
if (org?.id) {
|
|
5892
6015
|
return {
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
6016
|
+
identity,
|
|
6017
|
+
context: {
|
|
6018
|
+
contextType: "organization",
|
|
6019
|
+
organizationId: org.id,
|
|
6020
|
+
organizationSlug: org.slug,
|
|
6021
|
+
organizationName: org.name,
|
|
6022
|
+
credential,
|
|
6023
|
+
oauthClientId: clientId
|
|
6024
|
+
}
|
|
5899
6025
|
};
|
|
5900
6026
|
}
|
|
5901
6027
|
} catch {
|
|
5902
6028
|
}
|
|
5903
|
-
return { contextType: "personal", credential, oauthClientId: clientId };
|
|
6029
|
+
return { identity, context: { contextType: "personal", credential, oauthClientId: clientId } };
|
|
5904
6030
|
}
|
|
5905
6031
|
function tryOpenBrowser(url) {
|
|
5906
6032
|
try {
|
|
@@ -5937,10 +6063,10 @@ function startSpinner() {
|
|
|
5937
6063
|
// src/client.ts
|
|
5938
6064
|
async function buildClient(options) {
|
|
5939
6065
|
const environment = options?.environment ?? selectedEnvironment();
|
|
5940
|
-
const
|
|
6066
|
+
const config = await readConfig();
|
|
6067
|
+
const context = commandContextKey(config, environment, options?.context?.trim() || process.env.TOPOLO_CONTEXT);
|
|
5941
6068
|
const credential = context ? await refreshContextCredentialIfNeeded(environment, context) : await refreshCredentialIfNeeded(environment);
|
|
5942
6069
|
if (!credential) return null;
|
|
5943
|
-
const config = await readConfig();
|
|
5944
6070
|
const profile = resolvedProfileConfig(config, environment);
|
|
5945
6071
|
const topolo = createTopolo({
|
|
5946
6072
|
credential,
|
|
@@ -6384,11 +6510,10 @@ function tryOpenApproval(url) {
|
|
|
6384
6510
|
async function actionExecutionContext(topolo) {
|
|
6385
6511
|
const environment = selectedEnvironment();
|
|
6386
6512
|
const config = await readConfig();
|
|
6387
|
-
const profile = profileForEnvironment(config, environment);
|
|
6388
6513
|
const source = credentialSourceFromConfig(config, environment);
|
|
6389
6514
|
return createTopoloAgentExecutionContext({
|
|
6390
6515
|
environment,
|
|
6391
|
-
contextKey: source.startsWith("env:") ? null :
|
|
6516
|
+
contextKey: source.startsWith("env:") ? null : commandContextKey(config, environment) ?? null,
|
|
6392
6517
|
identity: await topolo.identity.whoami(),
|
|
6393
6518
|
serviceUrls: resolvedProfileConfig(config, environment).serviceUrls
|
|
6394
6519
|
});
|
|
@@ -6520,6 +6645,363 @@ function formatActionPlan(plan) {
|
|
|
6520
6645
|
return lines.join("\n");
|
|
6521
6646
|
}
|
|
6522
6647
|
|
|
6648
|
+
// src/lib/credential-custody.ts
|
|
6649
|
+
import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
|
|
6650
|
+
import { constants as constants2 } from "fs";
|
|
6651
|
+
import { chmod as chmod3, lstat as lstat2, mkdir as mkdir3, open, rename as rename2, rm as rm2 } from "fs/promises";
|
|
6652
|
+
import { basename as basename2, isAbsolute, join as join5, normalize, sep } from "path";
|
|
6653
|
+
var CUSTODY_SCHEMA_VERSION = 1;
|
|
6654
|
+
var CUSTODY_API_KEY_FILE = "api-key.json";
|
|
6655
|
+
var CUSTODY_METADATA_FILE = "custody.json";
|
|
6656
|
+
var API_KEYS_LIST_ACTION = "app_topolo_auth.api_keys.list";
|
|
6657
|
+
var CUSTODY_ENVIRONMENT_NAMES = ["production", "staging", "development", "local"];
|
|
6658
|
+
var CustodyError = class extends Error {
|
|
6659
|
+
code;
|
|
6660
|
+
constructor(code, message3) {
|
|
6661
|
+
super(message3);
|
|
6662
|
+
this.name = "CustodyError";
|
|
6663
|
+
this.code = code;
|
|
6664
|
+
}
|
|
6665
|
+
};
|
|
6666
|
+
async function writeCredentialCustody(request, deps) {
|
|
6667
|
+
if (request.confirm !== true) {
|
|
6668
|
+
throw new CustodyError(
|
|
6669
|
+
"custody_confirmation_required",
|
|
6670
|
+
"Writing a credential into custody requires --confirm. The custody file will hold the plaintext key."
|
|
6671
|
+
);
|
|
6672
|
+
}
|
|
6673
|
+
const appId = requiredText(request.appId, "--app");
|
|
6674
|
+
const apiKeyId = requiredText(request.apiKeyId, "--api-key-id");
|
|
6675
|
+
const platform4 = deps.platform ?? process.platform;
|
|
6676
|
+
const uid = deps.uid === void 0 ? currentUid() : deps.uid;
|
|
6677
|
+
if (platform4 === "win32" || uid === null) {
|
|
6678
|
+
throw new CustodyError("custody_platform_unsupported", "Credential custody files require POSIX ownership and permissions.");
|
|
6679
|
+
}
|
|
6680
|
+
const directory = custodyDirectory(request.outDir);
|
|
6681
|
+
const leaf = basename2(directory);
|
|
6682
|
+
if (CUSTODY_ENVIRONMENT_NAMES.includes(leaf) && leaf !== deps.environment) {
|
|
6683
|
+
throw new CustodyError(
|
|
6684
|
+
"custody_environment_mismatch",
|
|
6685
|
+
`--out names the ${leaf} environment but the credential is for ${deps.environment}.`
|
|
6686
|
+
);
|
|
6687
|
+
}
|
|
6688
|
+
await ensureCustodyDirectory(directory, uid);
|
|
6689
|
+
const apiKeyPath = join5(directory, CUSTODY_API_KEY_FILE);
|
|
6690
|
+
const custodyPath = join5(directory, CUSTODY_METADATA_FILE);
|
|
6691
|
+
const existing = [await destinationExists(apiKeyPath), await destinationExists(custodyPath)];
|
|
6692
|
+
if (existing.some(Boolean) && request.replace !== true) {
|
|
6693
|
+
throw new CustodyError(
|
|
6694
|
+
"custody_file_exists",
|
|
6695
|
+
`Custody files already exist in ${directory}. Pass --replace to overwrite them.`
|
|
6696
|
+
);
|
|
6697
|
+
}
|
|
6698
|
+
const identity = await deps.identity();
|
|
6699
|
+
const organizationId = identity.organization?.id;
|
|
6700
|
+
if (!organizationId) {
|
|
6701
|
+
throw new CustodyError(
|
|
6702
|
+
"custody_organization_required",
|
|
6703
|
+
"Credential custody requires an organization context. Pin one with TOPOLO_CONTEXT=org:<organizationId>."
|
|
6704
|
+
);
|
|
6705
|
+
}
|
|
6706
|
+
const key = findListedKey(await deps.listApiKeys(appId), apiKeyId);
|
|
6707
|
+
assertKeyUsable(key, appId, organizationId, (deps.now ?? Date.now)());
|
|
6708
|
+
const secret = await deps.store.load(request.reference);
|
|
6709
|
+
if (typeof secret !== "string" || !secret || /\s/.test(secret)) {
|
|
6710
|
+
throw new CustodyError(
|
|
6711
|
+
"custody_secret_unsupported_type",
|
|
6712
|
+
"The protected reference does not hold a single API key string."
|
|
6713
|
+
);
|
|
6714
|
+
}
|
|
6715
|
+
try {
|
|
6716
|
+
await assertSecretIsKey(deps.introspectKey, secret, key);
|
|
6717
|
+
const custody = {
|
|
6718
|
+
schemaVersion: CUSTODY_SCHEMA_VERSION,
|
|
6719
|
+
appId: key.appId,
|
|
6720
|
+
scopes: [...key.scopes],
|
|
6721
|
+
organizationId: key.organizationId,
|
|
6722
|
+
environment: deps.environment,
|
|
6723
|
+
apiKeyId: key.id,
|
|
6724
|
+
expiresAt: key.expiresAt,
|
|
6725
|
+
keyDigest: `sha256:${createHash3("sha256").update(secret, "utf8").digest("hex")}`,
|
|
6726
|
+
createdAt: key.createdAt
|
|
6727
|
+
};
|
|
6728
|
+
await writeCustodyFiles(directory, [
|
|
6729
|
+
[CUSTODY_API_KEY_FILE, `${JSON.stringify({ schemaVersion: CUSTODY_SCHEMA_VERSION, apiKey: secret })}
|
|
6730
|
+
`],
|
|
6731
|
+
[CUSTODY_METADATA_FILE, `${JSON.stringify(custody, null, 2)}
|
|
6732
|
+
`]
|
|
6733
|
+
]);
|
|
6734
|
+
try {
|
|
6735
|
+
await deps.store.delete(request.reference);
|
|
6736
|
+
} catch {
|
|
6737
|
+
throw new CustodyError(
|
|
6738
|
+
"custody_reference_release_failed",
|
|
6739
|
+
`Custody files were written to ${directory}, but the local protected reference could not be deleted. Run \`topolo actions release-secret <reference> --confirm\`.`
|
|
6740
|
+
);
|
|
6741
|
+
}
|
|
6742
|
+
return {
|
|
6743
|
+
written: true,
|
|
6744
|
+
replaced: existing.some(Boolean),
|
|
6745
|
+
directory,
|
|
6746
|
+
files: { apiKey: apiKeyPath, custody: custodyPath },
|
|
6747
|
+
custody,
|
|
6748
|
+
reference: { released: true, upstreamRevoked: false }
|
|
6749
|
+
};
|
|
6750
|
+
} catch (error) {
|
|
6751
|
+
throw withheldIfSecretLeaks(error, secret);
|
|
6752
|
+
}
|
|
6753
|
+
}
|
|
6754
|
+
function requiredText(value, flag) {
|
|
6755
|
+
const text2 = value?.trim();
|
|
6756
|
+
if (!text2) throw new CustodyError("custody_invalid_input", `${flag} is required.`);
|
|
6757
|
+
return text2;
|
|
6758
|
+
}
|
|
6759
|
+
function currentUid() {
|
|
6760
|
+
return typeof process.getuid === "function" ? process.getuid() : null;
|
|
6761
|
+
}
|
|
6762
|
+
function custodyDirectory(value) {
|
|
6763
|
+
const raw = value?.trim();
|
|
6764
|
+
if (!raw || !isAbsolute(raw)) {
|
|
6765
|
+
throw new CustodyError("custody_path_not_absolute", "--out must be an absolute directory path.");
|
|
6766
|
+
}
|
|
6767
|
+
const normalized = normalize(raw);
|
|
6768
|
+
const directory = normalized.endsWith(sep) ? normalized.slice(0, -1) : normalized;
|
|
6769
|
+
if (!directory) {
|
|
6770
|
+
throw new CustodyError("custody_dir_insecure", "--out must name a dedicated directory, not the filesystem root.");
|
|
6771
|
+
}
|
|
6772
|
+
return directory;
|
|
6773
|
+
}
|
|
6774
|
+
async function lstatOrNull(path) {
|
|
6775
|
+
try {
|
|
6776
|
+
return await lstat2(path);
|
|
6777
|
+
} catch (error) {
|
|
6778
|
+
if (error.code === "ENOENT") return null;
|
|
6779
|
+
throw error;
|
|
6780
|
+
}
|
|
6781
|
+
}
|
|
6782
|
+
async function ensureCustodyDirectory(directory, uid) {
|
|
6783
|
+
const components = directory.split(sep).filter(Boolean);
|
|
6784
|
+
let current = sep;
|
|
6785
|
+
assertSecureAncestor(current, await lstat2(current), uid);
|
|
6786
|
+
for (let index = 0; index < components.length; index += 1) {
|
|
6787
|
+
current = join5(current, components[index]);
|
|
6788
|
+
const last = index === components.length - 1;
|
|
6789
|
+
let stats = await lstatOrNull(current);
|
|
6790
|
+
if (!stats) {
|
|
6791
|
+
await mkdir3(current, { mode: 448 });
|
|
6792
|
+
await chmod3(current, 448);
|
|
6793
|
+
stats = await lstat2(current);
|
|
6794
|
+
}
|
|
6795
|
+
if (stats.isSymbolicLink()) {
|
|
6796
|
+
throw new CustodyError("custody_path_symlink", `Custody path component ${current} is a symbolic link.`);
|
|
6797
|
+
}
|
|
6798
|
+
if (!stats.isDirectory()) {
|
|
6799
|
+
throw new CustodyError("custody_path_not_directory", `Custody path component ${current} is not a directory.`);
|
|
6800
|
+
}
|
|
6801
|
+
if (last) assertPrivateDirectory(current, stats, uid);
|
|
6802
|
+
else assertSecureAncestor(current, stats, uid);
|
|
6803
|
+
}
|
|
6804
|
+
}
|
|
6805
|
+
function assertPrivateDirectory(path, stats, uid) {
|
|
6806
|
+
if (stats.uid !== uid || (stats.mode & 511) !== 448) {
|
|
6807
|
+
throw new CustodyError(
|
|
6808
|
+
"custody_dir_insecure",
|
|
6809
|
+
`Custody directory ${path} must be owned by the current user with mode 0700.`
|
|
6810
|
+
);
|
|
6811
|
+
}
|
|
6812
|
+
}
|
|
6813
|
+
function assertSecureAncestor(path, stats, uid) {
|
|
6814
|
+
const rootSticky = stats.uid === 0 && (stats.mode & 512) !== 0;
|
|
6815
|
+
if (stats.uid !== 0 && stats.uid !== uid || (stats.mode & 18) !== 0 && !rootSticky) {
|
|
6816
|
+
throw new CustodyError(
|
|
6817
|
+
"custody_parent_insecure",
|
|
6818
|
+
`Custody parent ${path} must be owned by root or the current user and must not be group- or world-writable.`
|
|
6819
|
+
);
|
|
6820
|
+
}
|
|
6821
|
+
}
|
|
6822
|
+
async function destinationExists(path) {
|
|
6823
|
+
const stats = await lstatOrNull(path);
|
|
6824
|
+
if (!stats) return false;
|
|
6825
|
+
if (stats.isSymbolicLink()) {
|
|
6826
|
+
throw new CustodyError("custody_path_symlink", `Custody file ${path} is a symbolic link.`);
|
|
6827
|
+
}
|
|
6828
|
+
if (!stats.isFile()) {
|
|
6829
|
+
throw new CustodyError("custody_path_not_regular", `Custody file ${path} is not a regular file.`);
|
|
6830
|
+
}
|
|
6831
|
+
return true;
|
|
6832
|
+
}
|
|
6833
|
+
function isRecord5(value) {
|
|
6834
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6835
|
+
}
|
|
6836
|
+
function findListedKey(result, apiKeyId) {
|
|
6837
|
+
const data = isRecord5(result) && isRecord5(result.data) ? result.data : result;
|
|
6838
|
+
const keys = isRecord5(data) ? data.keys : void 0;
|
|
6839
|
+
if (!Array.isArray(keys)) {
|
|
6840
|
+
throw new CustodyError("custody_metadata_unavailable", "Auth api_keys.list did not return a key inventory.");
|
|
6841
|
+
}
|
|
6842
|
+
const match = keys.find((key) => isRecord5(key) && key.id === apiKeyId);
|
|
6843
|
+
if (!match) {
|
|
6844
|
+
throw new CustodyError(
|
|
6845
|
+
"custody_key_not_found",
|
|
6846
|
+
`API key ${apiKeyId} is not visible to this credential in api_keys.list.`
|
|
6847
|
+
);
|
|
6848
|
+
}
|
|
6849
|
+
const { id, appId, organizationId, scopes, expiresAt, createdAt, status } = match;
|
|
6850
|
+
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") {
|
|
6851
|
+
throw new CustodyError("custody_metadata_unavailable", `Auth returned incomplete metadata for API key ${apiKeyId}.`);
|
|
6852
|
+
}
|
|
6853
|
+
return { id, appId, organizationId, scopes, expiresAt, createdAt, status };
|
|
6854
|
+
}
|
|
6855
|
+
function assertKeyUsable(key, appId, organizationId, nowMs) {
|
|
6856
|
+
if (key.appId !== appId) {
|
|
6857
|
+
throw new CustodyError("custody_app_mismatch", `API key ${key.id} belongs to ${key.appId}, not ${appId}.`);
|
|
6858
|
+
}
|
|
6859
|
+
if (key.organizationId !== organizationId) {
|
|
6860
|
+
throw new CustodyError(
|
|
6861
|
+
"custody_organization_mismatch",
|
|
6862
|
+
`API key ${key.id} belongs to another organization than the verified credential.`
|
|
6863
|
+
);
|
|
6864
|
+
}
|
|
6865
|
+
if (key.status !== "active") {
|
|
6866
|
+
throw new CustodyError("custody_key_inactive", `API key ${key.id} is ${key.status}, not active.`);
|
|
6867
|
+
}
|
|
6868
|
+
if (key.expiresAt !== null && key.expiresAt * 1e3 <= nowMs) {
|
|
6869
|
+
throw new CustodyError("custody_key_expired", `API key ${key.id} has expired.`);
|
|
6870
|
+
}
|
|
6871
|
+
}
|
|
6872
|
+
async function assertSecretIsKey(introspectKey, secret, key) {
|
|
6873
|
+
let identity;
|
|
6874
|
+
try {
|
|
6875
|
+
identity = await introspectKey(secret);
|
|
6876
|
+
} catch (error) {
|
|
6877
|
+
const code = error.code;
|
|
6878
|
+
const status = error.status;
|
|
6879
|
+
const detail = [typeof code === "string" ? code : null, typeof status === "number" ? `HTTP ${status}` : null].filter(Boolean).join(", ");
|
|
6880
|
+
throw new CustodyError(
|
|
6881
|
+
"custody_key_verification_failed",
|
|
6882
|
+
`Auth could not verify the protected key${detail ? ` (${detail})` : ""}. Nothing was written.`
|
|
6883
|
+
);
|
|
6884
|
+
}
|
|
6885
|
+
if (identity.kind !== "api_key" || identity.apiKey?.id !== key.id || identity.apiKey.appId !== key.appId || identity.organization?.id !== key.organizationId) {
|
|
6886
|
+
throw new CustodyError(
|
|
6887
|
+
"custody_key_mismatch",
|
|
6888
|
+
`The protected reference does not hold API key ${key.id} for ${key.appId}. Nothing was written.`
|
|
6889
|
+
);
|
|
6890
|
+
}
|
|
6891
|
+
const introspected = [...identity.apiKey.scopes].sort();
|
|
6892
|
+
const listed = [...key.scopes].sort();
|
|
6893
|
+
if (introspected.length !== listed.length || introspected.some((scope, index) => scope !== listed[index])) {
|
|
6894
|
+
throw new CustodyError(
|
|
6895
|
+
"custody_metadata_mismatch",
|
|
6896
|
+
`Auth introspection and api_keys.list disagree on the scopes of API key ${key.id}. Nothing was written.`
|
|
6897
|
+
);
|
|
6898
|
+
}
|
|
6899
|
+
}
|
|
6900
|
+
async function writeCustodyFiles(directory, files) {
|
|
6901
|
+
const staged = [];
|
|
6902
|
+
try {
|
|
6903
|
+
for (const [name, content] of files) {
|
|
6904
|
+
const temporary = join5(directory, `.${name}.${process.pid}.${randomUUID3()}.tmp`);
|
|
6905
|
+
staged.push([temporary, join5(directory, name)]);
|
|
6906
|
+
const handle = await open(
|
|
6907
|
+
temporary,
|
|
6908
|
+
constants2.O_WRONLY | constants2.O_CREAT | constants2.O_EXCL | constants2.O_NOFOLLOW,
|
|
6909
|
+
384
|
|
6910
|
+
);
|
|
6911
|
+
try {
|
|
6912
|
+
await handle.chmod(384);
|
|
6913
|
+
await handle.writeFile(content, "utf8");
|
|
6914
|
+
await handle.sync();
|
|
6915
|
+
} finally {
|
|
6916
|
+
await handle.close();
|
|
6917
|
+
}
|
|
6918
|
+
}
|
|
6919
|
+
for (const [temporary, destination] of staged) await rename2(temporary, destination);
|
|
6920
|
+
} finally {
|
|
6921
|
+
await Promise.all(staged.map(([temporary]) => rm2(temporary, { force: true })));
|
|
6922
|
+
}
|
|
6923
|
+
}
|
|
6924
|
+
function withheldIfSecretLeaks(error, secret) {
|
|
6925
|
+
const variants = [secret, encodeURIComponent(secret)];
|
|
6926
|
+
const record3 = error;
|
|
6927
|
+
const parts = [String(error), String(record3?.message), String(record3?.stack)];
|
|
6928
|
+
for (const value of [record3?.body, record3?.details]) {
|
|
6929
|
+
try {
|
|
6930
|
+
parts.push(String(value), JSON.stringify(value ?? null));
|
|
6931
|
+
} catch {
|
|
6932
|
+
}
|
|
6933
|
+
}
|
|
6934
|
+
if (!parts.some((part) => variants.some((variant) => part.includes(variant)))) return error;
|
|
6935
|
+
return new CustodyError(
|
|
6936
|
+
"custody_error_withheld",
|
|
6937
|
+
"Credential custody failed; the underlying error was withheld because it referenced the protected credential."
|
|
6938
|
+
);
|
|
6939
|
+
}
|
|
6940
|
+
|
|
6941
|
+
// src/commands/credentials.ts
|
|
6942
|
+
function registerCredentialsCommand(program2) {
|
|
6943
|
+
const credentials = program2.command("credentials").description("Move protected credentials into operator-owned custody without displaying them");
|
|
6944
|
+
credentials.command("write-custody <reference>").description(
|
|
6945
|
+
"Write the API key behind a protected topolo-secret reference into a 0700 custody directory, then delete the reference"
|
|
6946
|
+
).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(
|
|
6947
|
+
"after",
|
|
6948
|
+
'\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>).'
|
|
6949
|
+
).action(async (reference, opts) => {
|
|
6950
|
+
const mode = resolveOutputMode(opts.json);
|
|
6951
|
+
try {
|
|
6952
|
+
const environment = selectedEnvironment();
|
|
6953
|
+
let client;
|
|
6954
|
+
const topolo = () => client ??= buildClient({ environment }).then((built) => {
|
|
6955
|
+
if (!built) throw new Error("No credential configured. Run `topolo auth login`.");
|
|
6956
|
+
return built;
|
|
6957
|
+
});
|
|
6958
|
+
let verified;
|
|
6959
|
+
const identity = () => verified ??= topolo().then((built) => built.client.introspect());
|
|
6960
|
+
const result = await writeCredentialCustody(
|
|
6961
|
+
{
|
|
6962
|
+
reference,
|
|
6963
|
+
outDir: opts.out,
|
|
6964
|
+
appId: opts.app,
|
|
6965
|
+
apiKeyId: opts.apiKeyId,
|
|
6966
|
+
confirm: opts.confirm === true,
|
|
6967
|
+
replace: opts.replace === true
|
|
6968
|
+
},
|
|
6969
|
+
{
|
|
6970
|
+
environment,
|
|
6971
|
+
identity,
|
|
6972
|
+
store: createProtectedActionSecretStore({ environment, identity }),
|
|
6973
|
+
listApiKeys: async (appId) => (await topolo()).client.readAction(API_KEYS_LIST_ACTION, { appId }),
|
|
6974
|
+
introspectKey: async (secret) => {
|
|
6975
|
+
const profile = resolvedProfileConfig(await readConfig(), environment);
|
|
6976
|
+
return createTopolo({
|
|
6977
|
+
credential: { kind: "api_key", apiKey: secret },
|
|
6978
|
+
environment,
|
|
6979
|
+
agent: { clientName: "topolo-cli", clientVersion: CLI_VERSION },
|
|
6980
|
+
serviceUrls: profile.serviceUrls
|
|
6981
|
+
}).client.introspect();
|
|
6982
|
+
}
|
|
6983
|
+
}
|
|
6984
|
+
);
|
|
6985
|
+
emit(result, mode, () => formatCustodyResult(result));
|
|
6986
|
+
} catch (error) {
|
|
6987
|
+
fail2(error, mode);
|
|
6988
|
+
}
|
|
6989
|
+
});
|
|
6990
|
+
}
|
|
6991
|
+
function formatCustodyResult(result) {
|
|
6992
|
+
return [
|
|
6993
|
+
`${result.replaced ? "Replaced" : "Wrote"} credential custody in ${result.directory}`,
|
|
6994
|
+
`API key file: ${result.files.apiKey} (0600)`,
|
|
6995
|
+
`Custody file: ${result.files.custody} (0600)`,
|
|
6996
|
+
`Key: ${result.custody.apiKeyId} (${result.custody.appId}, ${result.custody.environment})`,
|
|
6997
|
+
`Organization: ${result.custody.organizationId}`,
|
|
6998
|
+
`Scopes: ${result.custody.scopes.join(", ") || "-"}`,
|
|
6999
|
+
`Expires at: ${result.custody.expiresAt === null ? "never" : new Date(result.custody.expiresAt * 1e3).toISOString()}`,
|
|
7000
|
+
`Digest: ${result.custody.keyDigest}`,
|
|
7001
|
+
"Local protected reference released. The upstream key is NOT revoked; use app_topolo_auth.api_keys.revoke for that."
|
|
7002
|
+
].join("\n");
|
|
7003
|
+
}
|
|
7004
|
+
|
|
6523
7005
|
// src/commands/apps-public.ts
|
|
6524
7006
|
function publicApplicationRows(catalog) {
|
|
6525
7007
|
return catalog.filter((entry) => entry.launchable || entry.surfaceKind === "application").map((entry) => ({
|
|
@@ -6772,13 +7254,14 @@ function createOAuthClientPayload(options) {
|
|
|
6772
7254
|
}
|
|
6773
7255
|
async function authFetchJson(path, init = {}) {
|
|
6774
7256
|
const environment = selectedEnvironment();
|
|
6775
|
-
const
|
|
7257
|
+
const config = await readConfig();
|
|
7258
|
+
const context = commandContextKey(config, environment);
|
|
7259
|
+
const credential = context ? await refreshContextCredentialIfNeeded(environment, context) : await refreshCredentialIfNeeded(environment);
|
|
6776
7260
|
if (!credential) {
|
|
6777
7261
|
const err = new Error("Topolo credentials are required.");
|
|
6778
7262
|
err.code = "auth_error";
|
|
6779
7263
|
throw err;
|
|
6780
7264
|
}
|
|
6781
|
-
const config = await readConfig();
|
|
6782
7265
|
const profile = resolvedProfileConfig(config, environment);
|
|
6783
7266
|
const authBaseUrl = (profile.serviceUrls?.auth ?? "https://auth.topolo.app").replace(/\/+$/, "");
|
|
6784
7267
|
const headers = new Headers(init.headers);
|
|
@@ -6809,7 +7292,7 @@ function normalizeList(values, fallback = []) {
|
|
|
6809
7292
|
|
|
6810
7293
|
// src/commands/integrations.ts
|
|
6811
7294
|
import { readdir as readdir3, readFile as readFile3, stat } from "fs/promises";
|
|
6812
|
-
import { join as
|
|
7295
|
+
import { join as join6, relative } from "path";
|
|
6813
7296
|
function registerIntegrationsCommand(program2) {
|
|
6814
7297
|
const integrations = program2.command("integrations").description("Generate and validate Topolo integration handoffs.");
|
|
6815
7298
|
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 +7435,7 @@ function detectFramework(packageJson, haystack) {
|
|
|
6952
7435
|
}
|
|
6953
7436
|
async function readPackageJson(repoRoot) {
|
|
6954
7437
|
try {
|
|
6955
|
-
return JSON.parse(await readFile3(
|
|
7438
|
+
return JSON.parse(await readFile3(join6(repoRoot, "package.json"), "utf8"));
|
|
6956
7439
|
} catch {
|
|
6957
7440
|
return null;
|
|
6958
7441
|
}
|
|
@@ -6964,7 +7447,7 @@ async function collectFiles(repoRoot) {
|
|
|
6964
7447
|
if (result.length > 2e3) return;
|
|
6965
7448
|
const entries = await readdir3(dir, { withFileTypes: true });
|
|
6966
7449
|
for (const entry of entries) {
|
|
6967
|
-
const fullPath =
|
|
7450
|
+
const fullPath = join6(dir, entry.name);
|
|
6968
7451
|
if (entry.isDirectory()) {
|
|
6969
7452
|
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue;
|
|
6970
7453
|
await walk(fullPath);
|
|
@@ -6991,20 +7474,26 @@ function formatCheckResult(result) {
|
|
|
6991
7474
|
}
|
|
6992
7475
|
|
|
6993
7476
|
// src/commands/skills.ts
|
|
6994
|
-
import { spawnSync as
|
|
7477
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
6995
7478
|
import { existsSync as existsSync6 } from "fs";
|
|
6996
|
-
import { readFile as
|
|
7479
|
+
import { readFile as readFile9, rm as rm3 } from "fs/promises";
|
|
6997
7480
|
import { homedir as homedir5 } from "os";
|
|
6998
|
-
import { dirname as
|
|
7481
|
+
import { dirname as dirname7, join as join11, resolve as resolve3 } from "path";
|
|
6999
7482
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
7000
7483
|
import { createRequire } from "module";
|
|
7001
7484
|
|
|
7485
|
+
// src/lib/command-context.ts
|
|
7486
|
+
function withProgramContext(opts, command) {
|
|
7487
|
+
const context = opts.context ?? command.optsWithGlobals().context;
|
|
7488
|
+
return context === void 0 ? opts : { ...opts, context };
|
|
7489
|
+
}
|
|
7490
|
+
|
|
7002
7491
|
// src/commands/agent-host.ts
|
|
7003
7492
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
7004
7493
|
import { existsSync as existsSync4 } from "fs";
|
|
7005
7494
|
import { readFile as readFile4 } from "fs/promises";
|
|
7006
7495
|
import { homedir as homedir4 } from "os";
|
|
7007
|
-
import { isAbsolute, join as
|
|
7496
|
+
import { isAbsolute as isAbsolute2, join as join7, resolve as resolve2 } from "path";
|
|
7008
7497
|
var SUPPORTED_AGENT_IDS = [
|
|
7009
7498
|
"claude",
|
|
7010
7499
|
"claude-desktop",
|
|
@@ -7047,13 +7536,13 @@ var AGENT_NAME_FOR = {
|
|
|
7047
7536
|
function resolveAgentHostTarget(options) {
|
|
7048
7537
|
const homeDir = resolve2(options.homeDir ?? homedir4());
|
|
7049
7538
|
const codexHome = options.codexHome ?? (options.homeDir === void 0 ? process.env.CODEX_HOME || void 0 : void 0);
|
|
7050
|
-
if (codexHome !== void 0 && (!codexHome.trim() || !
|
|
7539
|
+
if (codexHome !== void 0 && (!codexHome.trim() || !isAbsolute2(codexHome))) {
|
|
7051
7540
|
throw new Error("Codex home must be an absolute nonempty directory.");
|
|
7052
7541
|
}
|
|
7053
7542
|
return Object.freeze({
|
|
7054
7543
|
homeDir,
|
|
7055
7544
|
...options.projectDir ? { projectDir: resolve2(options.projectDir) } : {},
|
|
7056
|
-
codexHome: codexHome ??
|
|
7545
|
+
codexHome: codexHome ?? join7(homeDir, ".codex")
|
|
7057
7546
|
});
|
|
7058
7547
|
}
|
|
7059
7548
|
var RESERVED_OPERATOR_REGISTRATION = ["topolo", "platform"].join("-");
|
|
@@ -7062,7 +7551,7 @@ function isPublicRegistrationName(name) {
|
|
|
7062
7551
|
}
|
|
7063
7552
|
function selectStableNodeCommand(whichOutput, fallback = process.execPath) {
|
|
7064
7553
|
const candidate = whichOutput?.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
7065
|
-
return candidate &&
|
|
7554
|
+
return candidate && isAbsolute2(candidate) ? candidate : fallback;
|
|
7066
7555
|
}
|
|
7067
7556
|
function stableNodeCommand() {
|
|
7068
7557
|
const probe = spawnSync3(process.platform === "win32" ? "where" : "which", ["node"], {
|
|
@@ -7074,23 +7563,23 @@ function stableNodeCommand() {
|
|
|
7074
7563
|
function agentSkillPath(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
7075
7564
|
switch (agent) {
|
|
7076
7565
|
case "claude":
|
|
7077
|
-
return projectDir ?
|
|
7566
|
+
return projectDir ? join7(projectDir, ".claude", "skills", "topolo", "SKILL.md") : join7(homeDir, ".claude", "skills", "topolo", "SKILL.md");
|
|
7078
7567
|
case "claude-desktop":
|
|
7079
|
-
return
|
|
7568
|
+
return join7(homeDir, ".claude", "skills", "topolo", "SKILL.md");
|
|
7080
7569
|
case "codex":
|
|
7081
|
-
return
|
|
7570
|
+
return join7(codexHome ?? join7(homeDir, ".codex"), "skills", "topolo", "SKILL.md");
|
|
7082
7571
|
case "cursor":
|
|
7083
|
-
return
|
|
7572
|
+
return join7(projectDir ?? homeDir, ".cursor", "rules", "topolo.mdc");
|
|
7084
7573
|
case "grok":
|
|
7085
|
-
return
|
|
7574
|
+
return join7(projectDir ?? homeDir, ".grok", "skills", "topolo", "SKILL.md");
|
|
7086
7575
|
case "gemini":
|
|
7087
|
-
return
|
|
7576
|
+
return join7(projectDir ?? homeDir, ".gemini", "skills", "topolo", "SKILL.md");
|
|
7088
7577
|
case "copilot":
|
|
7089
|
-
return projectDir ?
|
|
7578
|
+
return projectDir ? join7(projectDir, ".github", "skills", "topolo", "SKILL.md") : join7(homeDir, ".copilot", "skills", "topolo", "SKILL.md");
|
|
7090
7579
|
case "windsurf":
|
|
7091
|
-
return projectDir ?
|
|
7580
|
+
return projectDir ? join7(projectDir, ".windsurf", "rules", "topolo.md") : void 0;
|
|
7092
7581
|
case "cline":
|
|
7093
|
-
return
|
|
7582
|
+
return join7(projectDir ?? homeDir, ".cline", "skills", "topolo", "SKILL.md");
|
|
7094
7583
|
case "goose":
|
|
7095
7584
|
case "amazon-q":
|
|
7096
7585
|
return void 0;
|
|
@@ -7099,7 +7588,7 @@ function agentSkillPath(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
|
7099
7588
|
function agentConfigPaths(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
7100
7589
|
switch (agent) {
|
|
7101
7590
|
case "claude":
|
|
7102
|
-
return [{ path: projectDir ?
|
|
7591
|
+
return [{ path: projectDir ? join7(projectDir, ".mcp.json") : join7(homeDir, ".claude.json"), format: "json", rootKey: "mcpServers" }];
|
|
7103
7592
|
case "claude-desktop":
|
|
7104
7593
|
return [{
|
|
7105
7594
|
path: claudeDesktopConfigPath(homeDir),
|
|
@@ -7107,31 +7596,31 @@ function agentConfigPaths(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
|
7107
7596
|
rootKey: "mcpServers"
|
|
7108
7597
|
}];
|
|
7109
7598
|
case "codex":
|
|
7110
|
-
return [{ path:
|
|
7599
|
+
return [{ path: join7(codexHome ?? join7(homeDir, ".codex"), "config.toml"), format: "toml" }];
|
|
7111
7600
|
case "cursor":
|
|
7112
7601
|
return [{
|
|
7113
|
-
path: projectDir ?
|
|
7602
|
+
path: projectDir ? join7(projectDir, ".cursor", "mcp.json") : join7(homeDir, ".cursor", "mcp.json"),
|
|
7114
7603
|
format: "json",
|
|
7115
7604
|
rootKey: "mcpServers"
|
|
7116
7605
|
}];
|
|
7117
7606
|
case "grok":
|
|
7118
7607
|
return [{
|
|
7119
|
-
path: projectDir ?
|
|
7608
|
+
path: projectDir ? join7(projectDir, ".grok", "config.toml") : join7(homeDir, ".grok", "config.toml"),
|
|
7120
7609
|
format: "toml"
|
|
7121
7610
|
}];
|
|
7122
7611
|
case "gemini":
|
|
7123
7612
|
return [{
|
|
7124
|
-
path: projectDir ?
|
|
7613
|
+
path: projectDir ? join7(projectDir, ".gemini", "settings.json") : join7(homeDir, ".gemini", "settings.json"),
|
|
7125
7614
|
format: "json",
|
|
7126
7615
|
rootKey: "mcpServers"
|
|
7127
7616
|
}];
|
|
7128
7617
|
case "copilot":
|
|
7129
7618
|
return projectDir ? [
|
|
7130
|
-
{ path:
|
|
7131
|
-
{ path:
|
|
7619
|
+
{ path: join7(projectDir, ".mcp.json"), format: "json", rootKey: "mcpServers" },
|
|
7620
|
+
{ path: join7(projectDir, ".vscode", "mcp.json"), format: "json", rootKey: "servers" }
|
|
7132
7621
|
] : [
|
|
7133
7622
|
{
|
|
7134
|
-
path:
|
|
7623
|
+
path: join7(homeDir, ".copilot", "mcp-config.json"),
|
|
7135
7624
|
format: "json",
|
|
7136
7625
|
rootKey: "mcpServers"
|
|
7137
7626
|
},
|
|
@@ -7139,24 +7628,24 @@ function agentConfigPaths(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
|
7139
7628
|
];
|
|
7140
7629
|
case "windsurf":
|
|
7141
7630
|
return [{
|
|
7142
|
-
path:
|
|
7631
|
+
path: join7(homeDir, ".codeium", "windsurf", "mcp_config.json"),
|
|
7143
7632
|
format: "json",
|
|
7144
7633
|
rootKey: "mcpServers"
|
|
7145
7634
|
}];
|
|
7146
7635
|
case "cline":
|
|
7147
7636
|
return [{
|
|
7148
|
-
path: projectDir ?
|
|
7637
|
+
path: projectDir ? join7(projectDir, ".cline", "mcp.json") : join7(homeDir, ".cline", "mcp.json"),
|
|
7149
7638
|
format: "json",
|
|
7150
7639
|
rootKey: "mcpServers"
|
|
7151
7640
|
}];
|
|
7152
7641
|
case "goose":
|
|
7153
|
-
return [{ path:
|
|
7642
|
+
return [{ path: join7(homeDir, ".config", "goose", "config.yaml"), format: "yaml" }];
|
|
7154
7643
|
case "amazon-q":
|
|
7155
7644
|
if (projectDir) {
|
|
7156
|
-
return [{ path:
|
|
7645
|
+
return [{ path: join7(projectDir, ".amazonq", "mcp.json"), format: "json", rootKey: "mcpServers" }];
|
|
7157
7646
|
}
|
|
7158
7647
|
return [{
|
|
7159
|
-
path: existsSync4(
|
|
7648
|
+
path: existsSync4(join7(homeDir, ".aws", "amazonq", "mcp.json")) ? join7(homeDir, ".aws", "amazonq", "mcp.json") : join7(homeDir, ".aws", "amazonq", "default.json"),
|
|
7160
7649
|
format: "json",
|
|
7161
7650
|
rootKey: "mcpServers"
|
|
7162
7651
|
}];
|
|
@@ -7192,7 +7681,7 @@ function agentConfigExists(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
|
7192
7681
|
return agentConfigPaths(agent, homeDir, projectDir, codexHome).some((config) => existsSync4(config.path));
|
|
7193
7682
|
}
|
|
7194
7683
|
async function assertClaudeProjectScope(homeDir, projectDir, name) {
|
|
7195
|
-
const path =
|
|
7684
|
+
const path = join7(homeDir, ".claude.json");
|
|
7196
7685
|
let contents;
|
|
7197
7686
|
try {
|
|
7198
7687
|
contents = await readFile4(path, "utf8");
|
|
@@ -7220,14 +7709,14 @@ async function assertClaudeProjectScope(homeDir, projectDir, name) {
|
|
|
7220
7709
|
}
|
|
7221
7710
|
}
|
|
7222
7711
|
function claudeDesktopConfigPath(homeDir) {
|
|
7223
|
-
if (process.platform === "darwin") return
|
|
7224
|
-
if (process.platform === "win32") return
|
|
7225
|
-
return
|
|
7712
|
+
if (process.platform === "darwin") return join7(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
7713
|
+
if (process.platform === "win32") return join7(process.env.APPDATA ?? join7(homeDir, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
7714
|
+
return join7(process.env.XDG_CONFIG_HOME ?? join7(homeDir, ".config"), "Claude", "claude_desktop_config.json");
|
|
7226
7715
|
}
|
|
7227
7716
|
function vscodeUserConfigPath(homeDir) {
|
|
7228
|
-
if (process.platform === "darwin") return
|
|
7229
|
-
if (process.platform === "win32") return
|
|
7230
|
-
return
|
|
7717
|
+
if (process.platform === "darwin") return join7(homeDir, "Library", "Application Support", "Code", "User", "mcp.json");
|
|
7718
|
+
if (process.platform === "win32") return join7(process.env.APPDATA ?? join7(homeDir, "AppData", "Roaming"), "Code", "User", "mcp.json");
|
|
7719
|
+
return join7(process.env.XDG_CONFIG_HOME ?? join7(homeDir, ".config"), "Code", "User", "mcp.json");
|
|
7231
7720
|
}
|
|
7232
7721
|
async function readAgentRegistrations(agent, homeDir = homedir4(), projectDir, codexHome) {
|
|
7233
7722
|
const registrations = [];
|
|
@@ -7286,7 +7775,7 @@ async function readTomlRegistrations(path, agent) {
|
|
|
7286
7775
|
if (!section || !isPublicRegistrationName(section.name) || !line || line.startsWith("#")) continue;
|
|
7287
7776
|
const assignment = line.match(/^([A-Za-z0-9_]+)\s*=\s*(.+)$/);
|
|
7288
7777
|
if (!assignment) continue;
|
|
7289
|
-
if (!section.env &&
|
|
7778
|
+
if (!section.env && !["command", "args", "env", "url", "http_headers"].includes(assignment[1])) continue;
|
|
7290
7779
|
pending = { key: assignment[1], value: assignment[2] };
|
|
7291
7780
|
if (isTomlValueComplete(pending.value)) {
|
|
7292
7781
|
applyTomlAssignment(path, agent, section, pending, registrations);
|
|
@@ -7294,7 +7783,7 @@ async function readTomlRegistrations(path, agent) {
|
|
|
7294
7783
|
}
|
|
7295
7784
|
}
|
|
7296
7785
|
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);
|
|
7786
|
+
return [...registrations.values()].filter((registration) => registration.command || registration.url);
|
|
7298
7787
|
}
|
|
7299
7788
|
async function readGooseRegistrations(path) {
|
|
7300
7789
|
const text2 = await readFile4(path, "utf8");
|
|
@@ -7368,11 +7857,18 @@ function applyTomlAssignment(path, agent, section, assignment, registrations) {
|
|
|
7368
7857
|
registration.args = value;
|
|
7369
7858
|
}
|
|
7370
7859
|
if (!section.env && assignment.key === "env" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
7371
|
-
registration.env =
|
|
7372
|
-
|
|
7373
|
-
|
|
7860
|
+
registration.env = stringEntries(value);
|
|
7861
|
+
}
|
|
7862
|
+
if (!section.env && assignment.key === "url" && typeof value === "string") registration.url = value;
|
|
7863
|
+
if (!section.env && assignment.key === "http_headers" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
7864
|
+
registration.headers = stringEntries(value);
|
|
7374
7865
|
}
|
|
7375
7866
|
}
|
|
7867
|
+
function stringEntries(value) {
|
|
7868
|
+
return Object.fromEntries(Object.entries(value).filter(
|
|
7869
|
+
(entry) => typeof entry[1] === "string"
|
|
7870
|
+
));
|
|
7871
|
+
}
|
|
7376
7872
|
function stripTomlComment(line) {
|
|
7377
7873
|
let quote;
|
|
7378
7874
|
let escaped = false;
|
|
@@ -7485,9 +7981,13 @@ function splitTomlArray(value) {
|
|
|
7485
7981
|
function normalizeRegistration(name, value) {
|
|
7486
7982
|
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
7487
7983
|
const candidate = value;
|
|
7984
|
+
if (typeof candidate.command !== "string" && typeof candidate.url === "string") {
|
|
7985
|
+
const headers = candidate.headers && typeof candidate.headers === "object" && !Array.isArray(candidate.headers) ? stringEntries(candidate.headers) : {};
|
|
7986
|
+
return [{ name, command: "", args: [], env: {}, url: candidate.url, headers }];
|
|
7987
|
+
}
|
|
7488
7988
|
if (typeof candidate.command !== "string") return [];
|
|
7489
7989
|
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) ?
|
|
7990
|
+
const env = candidate.env && typeof candidate.env === "object" && !Array.isArray(candidate.env) ? stringEntries(candidate.env) : {};
|
|
7491
7991
|
return [{ name, command: candidate.command, args, env }];
|
|
7492
7992
|
}
|
|
7493
7993
|
function message(error) {
|
|
@@ -7496,8 +7996,8 @@ function message(error) {
|
|
|
7496
7996
|
|
|
7497
7997
|
// src/commands/agent-config.ts
|
|
7498
7998
|
import { existsSync as existsSync5 } from "fs";
|
|
7499
|
-
import { lstat as
|
|
7500
|
-
import { randomUUID as
|
|
7999
|
+
import { lstat as lstat3, mkdir as mkdir4, open as open2, readFile as readFile5, rename as rename3, unlink, writeFile as writeFile2 } from "fs/promises";
|
|
8000
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
7501
8001
|
import { dirname as dirname5 } from "path";
|
|
7502
8002
|
async function installAgentSkill(agent, source, homeDir, projectDir, codexHome) {
|
|
7503
8003
|
const path = agentSkillPath(agent, homeDir, projectDir, codexHome);
|
|
@@ -7518,6 +8018,23 @@ async function registerAgentConfig(agent, mcp, homeDir, registration, projectDir
|
|
|
7518
8018
|
}
|
|
7519
8019
|
}
|
|
7520
8020
|
}
|
|
8021
|
+
var SHARED_HUB_AGENTS = ["claude", "codex"];
|
|
8022
|
+
async function registerAgentHttpConfig(agent, homeDir, registration, codexHome) {
|
|
8023
|
+
const [config] = agentConfigPaths(agent, homeDir, void 0, codexHome);
|
|
8024
|
+
if (agent === "claude") {
|
|
8025
|
+
await writeJsonServer(config, agent, registration.name, {
|
|
8026
|
+
type: "http",
|
|
8027
|
+
url: registration.url,
|
|
8028
|
+
headers: registration.headers
|
|
8029
|
+
});
|
|
8030
|
+
return;
|
|
8031
|
+
}
|
|
8032
|
+
const before = await fileSnapshot(config.path);
|
|
8033
|
+
const text2 = withoutMcpServerSections(before.contents ?? "", registration.name);
|
|
8034
|
+
const headers = Object.entries(registration.headers).map(([key, value]) => JSON.stringify(key) + " = " + JSON.stringify(value)).join(", ");
|
|
8035
|
+
const block = "\n\n[mcp_servers." + registration.name + "]\nurl = " + JSON.stringify(registration.url) + "\nhttp_headers = { " + headers + " }\n";
|
|
8036
|
+
await atomicWrite(config.path, text2 + block, before);
|
|
8037
|
+
}
|
|
7521
8038
|
async function unregisterAgentConfig(agent, homeDir, name, projectDir, codexHome) {
|
|
7522
8039
|
for (const config of agentConfigPaths(agent, homeDir, projectDir, codexHome)) {
|
|
7523
8040
|
if (!existsSync5(config.path)) continue;
|
|
@@ -7554,11 +8071,23 @@ function registrationEnvironment(agent, registration) {
|
|
|
7554
8071
|
TOPOLO_AGENT_NAME: AGENT_NAME_FOR[agent],
|
|
7555
8072
|
...registration.configPath ? { TOPOLO_CONFIG_PATH: registration.configPath } : {},
|
|
7556
8073
|
...registration.installationId ? { TOPOLO_AGENT_INSTALLATION_ID: registration.installationId } : {},
|
|
7557
|
-
|
|
8074
|
+
// Every registration pins its environment, and its context unless it is
|
|
8075
|
+
// bound to an installation, so no MCP ever follows a stored default.
|
|
8076
|
+
TOPOLO_ENV: registration.environment,
|
|
8077
|
+
...registration.context ? { TOPOLO_CONTEXT: registration.context } : {},
|
|
7558
8078
|
...privateAliases
|
|
7559
8079
|
};
|
|
7560
8080
|
}
|
|
7561
8081
|
async function registerJson(config, agent, mcp, registration) {
|
|
8082
|
+
const entry = {
|
|
8083
|
+
command: mcp.command,
|
|
8084
|
+
args: mcp.args,
|
|
8085
|
+
env: registrationEnvironment(agent, registration)
|
|
8086
|
+
};
|
|
8087
|
+
if (agent === "claude" || agent === "claude-desktop" || (config.rootKey ?? "mcpServers") === "servers") entry.type = "stdio";
|
|
8088
|
+
await writeJsonServer(config, agent, registration.name, entry);
|
|
8089
|
+
}
|
|
8090
|
+
async function writeJsonServer(config, agent, name, entry) {
|
|
7562
8091
|
const before = await fileSnapshot(config.path);
|
|
7563
8092
|
let parsed = {};
|
|
7564
8093
|
if (before.contents !== void 0) {
|
|
@@ -7577,13 +8106,7 @@ async function registerJson(config, agent, mcp, registration) {
|
|
|
7577
8106
|
throw new Error("Cannot update malformed " + AGENT_LABELS[agent] + " MCP configuration at " + config.path + ".");
|
|
7578
8107
|
}
|
|
7579
8108
|
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;
|
|
8109
|
+
servers[name] = entry;
|
|
7587
8110
|
parsed[rootKey] = servers;
|
|
7588
8111
|
await atomicWrite(config.path, JSON.stringify(parsed, null, 2) + "\n", before);
|
|
7589
8112
|
}
|
|
@@ -7684,27 +8207,27 @@ function formatSkillForAgent(agent, source) {
|
|
|
7684
8207
|
}
|
|
7685
8208
|
return "---\ntrigger: model_decision\ndescription: Use Topolo MCP for Topolo platform operations.\n---\n\n" + body;
|
|
7686
8209
|
}
|
|
7687
|
-
function fileIdentity(
|
|
7688
|
-
return [
|
|
8210
|
+
function fileIdentity(stat3) {
|
|
8211
|
+
return [stat3.dev, stat3.ino, stat3.size, stat3.mtimeMs, stat3.ctimeMs, stat3.mode, stat3.nlink].join(":");
|
|
7689
8212
|
}
|
|
7690
8213
|
async function fileSnapshot(path) {
|
|
7691
8214
|
const parents = [];
|
|
7692
8215
|
for (let parent = dirname5(path); ; parent = dirname5(parent)) {
|
|
7693
8216
|
try {
|
|
7694
|
-
const
|
|
7695
|
-
if (!
|
|
7696
|
-
parents.push({ path: parent, identity: [
|
|
8217
|
+
const stat3 = await lstat3(parent);
|
|
8218
|
+
if (!stat3.isDirectory() || stat3.isSymbolicLink()) throw new Error("Host destination contains a symbolic link or non-directory at " + parent + ".");
|
|
8219
|
+
parents.push({ path: parent, identity: [stat3.dev, stat3.ino].join(":") });
|
|
7697
8220
|
} catch (error) {
|
|
7698
8221
|
if (error.code !== "ENOENT") throw error;
|
|
7699
8222
|
}
|
|
7700
8223
|
if (dirname5(parent) === parent) break;
|
|
7701
8224
|
}
|
|
7702
8225
|
try {
|
|
7703
|
-
const
|
|
7704
|
-
if (!
|
|
8226
|
+
const stat3 = await lstat3(path);
|
|
8227
|
+
if (!stat3.isFile() || stat3.isSymbolicLink() || stat3.nlink !== 1) throw new Error("Host configuration must be a single regular file at " + path + ".");
|
|
7705
8228
|
const contents = await readFile5(path, "utf8");
|
|
7706
|
-
if (fileIdentity(await
|
|
7707
|
-
return { contents, identity: fileIdentity(
|
|
8229
|
+
if (fileIdentity(await lstat3(path)) !== fileIdentity(stat3)) throw new Error("Host configuration changed while reading at " + path + ".");
|
|
8230
|
+
return { contents, identity: fileIdentity(stat3), parents };
|
|
7708
8231
|
} catch (error) {
|
|
7709
8232
|
if (error.code === "ENOENT") return { parents };
|
|
7710
8233
|
throw error;
|
|
@@ -7720,11 +8243,11 @@ async function atomicWrite(path, contents, before) {
|
|
|
7720
8243
|
before ??= await fileSnapshot(path);
|
|
7721
8244
|
await assertSnapshot(path, before);
|
|
7722
8245
|
if (before.contents === contents) return;
|
|
7723
|
-
await
|
|
8246
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
7724
8247
|
const destination = await fileSnapshot(path);
|
|
7725
8248
|
await assertSnapshot(path, before);
|
|
7726
|
-
const temporaryPath = path + ".topolo-" +
|
|
7727
|
-
const handle = await
|
|
8249
|
+
const temporaryPath = path + ".topolo-" + randomUUID4() + ".tmp";
|
|
8250
|
+
const handle = await open2(temporaryPath, "wx", 384);
|
|
7728
8251
|
let temporaryIdentity;
|
|
7729
8252
|
let handleClosed = false;
|
|
7730
8253
|
let temporaryPending = true;
|
|
@@ -7736,11 +8259,11 @@ async function atomicWrite(path, contents, before) {
|
|
|
7736
8259
|
await handle.close();
|
|
7737
8260
|
handleClosed = true;
|
|
7738
8261
|
await assertSnapshot(path, destination);
|
|
7739
|
-
const temporary = await
|
|
8262
|
+
const temporary = await lstat3(temporaryPath);
|
|
7740
8263
|
if (!temporary.isFile() || temporary.isSymbolicLink() || temporary.nlink !== 1 || [temporary.dev, temporary.ino].join(":") !== temporaryIdentity) {
|
|
7741
8264
|
throw new Error("Host temporary file custody changed before rename at " + path + ".");
|
|
7742
8265
|
}
|
|
7743
|
-
await
|
|
8266
|
+
await rename3(temporaryPath, path);
|
|
7744
8267
|
temporaryPending = false;
|
|
7745
8268
|
const installed = await fileSnapshot(path);
|
|
7746
8269
|
if (installed.contents !== contents) throw new Error("Host configuration read-back failed at " + path + ".");
|
|
@@ -7750,10 +8273,10 @@ async function atomicWrite(path, contents, before) {
|
|
|
7750
8273
|
let cleanupOwned = false;
|
|
7751
8274
|
try {
|
|
7752
8275
|
const parentsOwned = await Promise.all(destination.parents.map(async (parent) => {
|
|
7753
|
-
const current2 = await
|
|
8276
|
+
const current2 = await lstat3(parent.path);
|
|
7754
8277
|
return current2.isDirectory() && !current2.isSymbolicLink() && [current2.dev, current2.ino].join(":") === parent.identity;
|
|
7755
8278
|
}));
|
|
7756
|
-
const current = await
|
|
8279
|
+
const current = await lstat3(temporaryPath);
|
|
7757
8280
|
cleanupOwned = parentsOwned.every(Boolean) && current.isFile() && !current.isSymbolicLink() && current.nlink === 1 && [current.dev, current.ino].join(":") === temporaryIdentity;
|
|
7758
8281
|
} catch {
|
|
7759
8282
|
}
|
|
@@ -7763,11 +8286,11 @@ async function atomicWrite(path, contents, before) {
|
|
|
7763
8286
|
}
|
|
7764
8287
|
function withoutMcpServerSections(text2, name) {
|
|
7765
8288
|
const section = "[mcp_servers." + name + "]";
|
|
7766
|
-
const
|
|
8289
|
+
const subsectionPrefix = "[mcp_servers." + name + ".";
|
|
7767
8290
|
let skipping = false;
|
|
7768
8291
|
return text2.split(/\r?\n/).filter((line) => {
|
|
7769
8292
|
const trimmed = line.trim();
|
|
7770
|
-
if (trimmed === section || trimmed
|
|
8293
|
+
if (trimmed === section || trimmed.startsWith(subsectionPrefix) && trimmed.endsWith("]")) {
|
|
7771
8294
|
skipping = true;
|
|
7772
8295
|
return false;
|
|
7773
8296
|
}
|
|
@@ -7776,14 +8299,507 @@ function withoutMcpServerSections(text2, name) {
|
|
|
7776
8299
|
}).join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
7777
8300
|
}
|
|
7778
8301
|
|
|
8302
|
+
// src/commands/mcp-hub-setup.ts
|
|
8303
|
+
import { realpathSync } from "fs";
|
|
8304
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
8305
|
+
import { join as join10 } from "path";
|
|
8306
|
+
|
|
8307
|
+
// src/lib/mcp-hub-config.ts
|
|
8308
|
+
import { randomBytes } from "crypto";
|
|
8309
|
+
import { lstat as lstat4, mkdir as mkdir5, open as open3, readFile as readFile6, rename as rename4, unlink as unlink2 } from "fs/promises";
|
|
8310
|
+
import { dirname as dirname6, join as join8 } from "path";
|
|
8311
|
+
var MCP_HUB_DEFAULT_PORT = 7337;
|
|
8312
|
+
var MCP_HUB_ENVIRONMENTS = ["production", "staging", "development", "local"];
|
|
8313
|
+
var RESERVED_NAME = ["topolo", "platform"].join("-");
|
|
8314
|
+
var NAME_PATTERN = /^topolo(-[a-z0-9][a-z0-9_-]*)?$/;
|
|
8315
|
+
var TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
8316
|
+
function mcpHubPaths(credentialStorePath = configPath()) {
|
|
8317
|
+
const directory = dirname6(credentialStorePath);
|
|
8318
|
+
return { config: join8(directory, "mcp-hub.json"), token: join8(directory, "mcp-hub.token") };
|
|
8319
|
+
}
|
|
8320
|
+
function mcpHubUrl(port, name) {
|
|
8321
|
+
return `http://127.0.0.1:${port}/mcp/${name}`;
|
|
8322
|
+
}
|
|
8323
|
+
function emptyMcpHubConfig() {
|
|
8324
|
+
return { port: MCP_HUB_DEFAULT_PORT, identities: [] };
|
|
8325
|
+
}
|
|
8326
|
+
function validateMcpHubPort(value) {
|
|
8327
|
+
const port = typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value.trim()) : value;
|
|
8328
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
8329
|
+
throw new Error("MCP hub port must be an integer from 1024 to 65535.");
|
|
8330
|
+
}
|
|
8331
|
+
return port;
|
|
8332
|
+
}
|
|
8333
|
+
function validateMcpHubIdentity(value, where = "identity") {
|
|
8334
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
8335
|
+
throw new Error(`MCP hub ${where} must be an object.`);
|
|
8336
|
+
}
|
|
8337
|
+
const record3 = value;
|
|
8338
|
+
const unknown = Object.keys(record3).filter((key) => !["name", "env", "context"].includes(key));
|
|
8339
|
+
if (unknown.length) throw new Error(`MCP hub ${where} has unsupported field(s): ${unknown.join(", ")}.`);
|
|
8340
|
+
const name = record3.name;
|
|
8341
|
+
if (typeof name !== "string" || !NAME_PATTERN.test(name) || name === RESERVED_NAME) {
|
|
8342
|
+
throw new Error(`MCP hub ${where} name must be topolo or topolo-<name> (and not reserved).`);
|
|
8343
|
+
}
|
|
8344
|
+
const environment = record3.env;
|
|
8345
|
+
if (typeof environment !== "string" || !MCP_HUB_ENVIRONMENTS.includes(environment)) {
|
|
8346
|
+
throw new Error(`MCP hub identity ${name} env must be one of ${MCP_HUB_ENVIRONMENTS.join(", ")}.`);
|
|
8347
|
+
}
|
|
8348
|
+
const context = record3.context;
|
|
8349
|
+
if (context === void 0) {
|
|
8350
|
+
throw new Error(`MCP hub identity ${name} must pin a context (a key from \`topolo context ls\`).`);
|
|
8351
|
+
}
|
|
8352
|
+
if (typeof context !== "string" || !context.trim() || /[\r\n]/.test(context)) {
|
|
8353
|
+
throw new Error(`MCP hub identity ${name} context must be a non-empty single-line selector.`);
|
|
8354
|
+
}
|
|
8355
|
+
return { name, environment, context: context.trim() };
|
|
8356
|
+
}
|
|
8357
|
+
function parseMcpHubConfig(text2, source) {
|
|
8358
|
+
let parsed;
|
|
8359
|
+
try {
|
|
8360
|
+
parsed = JSON.parse(text2);
|
|
8361
|
+
} catch (error) {
|
|
8362
|
+
throw new Error(`Cannot read malformed MCP hub configuration at ${source}: ${error.message}`);
|
|
8363
|
+
}
|
|
8364
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8365
|
+
throw new Error(`MCP hub configuration at ${source} must be a JSON object.`);
|
|
8366
|
+
}
|
|
8367
|
+
const record3 = parsed;
|
|
8368
|
+
const unknown = Object.keys(record3).filter((key) => !["version", "port", "identities"].includes(key));
|
|
8369
|
+
if (unknown.length) {
|
|
8370
|
+
throw new Error(`MCP hub configuration at ${source} has unsupported field(s): ${unknown.join(", ")}.`);
|
|
8371
|
+
}
|
|
8372
|
+
if (record3.version !== 1) throw new Error(`MCP hub configuration at ${source} must declare "version": 1.`);
|
|
8373
|
+
const port = record3.port === void 0 ? MCP_HUB_DEFAULT_PORT : validateMcpHubPort(record3.port);
|
|
8374
|
+
if (record3.identities !== void 0 && !Array.isArray(record3.identities)) {
|
|
8375
|
+
throw new Error(`MCP hub configuration at ${source} identities must be an array.`);
|
|
8376
|
+
}
|
|
8377
|
+
const identities = (record3.identities ?? []).map((entry, index) => validateMcpHubIdentity(entry, `identity #${index + 1}`));
|
|
8378
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8379
|
+
for (const identity of identities) {
|
|
8380
|
+
if (seen.has(identity.name)) {
|
|
8381
|
+
throw new Error(`MCP hub configuration at ${source} declares ${identity.name} more than once.`);
|
|
8382
|
+
}
|
|
8383
|
+
seen.add(identity.name);
|
|
8384
|
+
}
|
|
8385
|
+
return { port, identities };
|
|
8386
|
+
}
|
|
8387
|
+
function serializeMcpHubConfig(config) {
|
|
8388
|
+
return JSON.stringify({
|
|
8389
|
+
version: 1,
|
|
8390
|
+
port: config.port,
|
|
8391
|
+
identities: config.identities.map((identity) => ({
|
|
8392
|
+
name: identity.name,
|
|
8393
|
+
env: identity.environment,
|
|
8394
|
+
context: identity.context
|
|
8395
|
+
}))
|
|
8396
|
+
}, null, 2) + "\n";
|
|
8397
|
+
}
|
|
8398
|
+
async function readMcpHubConfig(path) {
|
|
8399
|
+
let text2;
|
|
8400
|
+
try {
|
|
8401
|
+
text2 = await readFile6(path, "utf8");
|
|
8402
|
+
} catch (error) {
|
|
8403
|
+
if (error.code === "ENOENT") return emptyMcpHubConfig();
|
|
8404
|
+
throw error;
|
|
8405
|
+
}
|
|
8406
|
+
return parseMcpHubConfig(text2, path);
|
|
8407
|
+
}
|
|
8408
|
+
function upsertMcpHubIdentity(config, identity) {
|
|
8409
|
+
const identities = config.identities.filter((entry) => entry.name !== identity.name);
|
|
8410
|
+
identities.push(identity);
|
|
8411
|
+
identities.sort((left, right) => left.name.localeCompare(right.name));
|
|
8412
|
+
return { ...config, identities };
|
|
8413
|
+
}
|
|
8414
|
+
async function writePrivateFile(path, contents) {
|
|
8415
|
+
await mkdir5(dirname6(path), { recursive: true, mode: 448 });
|
|
8416
|
+
const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
8417
|
+
const handle = await open3(temporary, "wx", 384);
|
|
8418
|
+
try {
|
|
8419
|
+
await handle.writeFile(contents, "utf8");
|
|
8420
|
+
} finally {
|
|
8421
|
+
await handle.close();
|
|
8422
|
+
}
|
|
8423
|
+
try {
|
|
8424
|
+
await rename4(temporary, path);
|
|
8425
|
+
} catch (error) {
|
|
8426
|
+
await unlink2(temporary).catch(() => void 0);
|
|
8427
|
+
throw error;
|
|
8428
|
+
}
|
|
8429
|
+
}
|
|
8430
|
+
async function writeMcpHubConfig(path, config) {
|
|
8431
|
+
await writePrivateFile(path, serializeMcpHubConfig(config));
|
|
8432
|
+
}
|
|
8433
|
+
async function assertPrivateTokenFile(path) {
|
|
8434
|
+
const stat3 = await lstat4(path);
|
|
8435
|
+
if (!stat3.isFile() || stat3.isSymbolicLink() || stat3.nlink !== 1) {
|
|
8436
|
+
throw new Error(`MCP hub token at ${path} must be a single regular file.`);
|
|
8437
|
+
}
|
|
8438
|
+
if ((stat3.mode & 63) !== 0) {
|
|
8439
|
+
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.`);
|
|
8440
|
+
}
|
|
8441
|
+
}
|
|
8442
|
+
async function readMcpHubToken(path) {
|
|
8443
|
+
try {
|
|
8444
|
+
await assertPrivateTokenFile(path);
|
|
8445
|
+
} catch (error) {
|
|
8446
|
+
if (error.code === "ENOENT") {
|
|
8447
|
+
throw new Error(`MCP hub token is missing at ${path}. Run \`topolo setup --shared\`.`);
|
|
8448
|
+
}
|
|
8449
|
+
throw error;
|
|
8450
|
+
}
|
|
8451
|
+
const token = (await readFile6(path, "utf8")).trim();
|
|
8452
|
+
if (!TOKEN_PATTERN.test(token)) {
|
|
8453
|
+
throw new Error(`MCP hub token at ${path} is malformed. Delete it and rerun \`topolo setup --shared\`.`);
|
|
8454
|
+
}
|
|
8455
|
+
return token;
|
|
8456
|
+
}
|
|
8457
|
+
async function ensureMcpHubToken(path) {
|
|
8458
|
+
try {
|
|
8459
|
+
return { token: await readMcpHubToken(path), created: false };
|
|
8460
|
+
} catch (error) {
|
|
8461
|
+
if (!/is missing at/.test(error.message)) throw error;
|
|
8462
|
+
}
|
|
8463
|
+
await mkdir5(dirname6(path), { recursive: true, mode: 448 });
|
|
8464
|
+
const token = randomBytes(32).toString("base64url");
|
|
8465
|
+
const handle = await open3(path, "wx", 384);
|
|
8466
|
+
try {
|
|
8467
|
+
await handle.writeFile(token + "\n", "utf8");
|
|
8468
|
+
} finally {
|
|
8469
|
+
await handle.close();
|
|
8470
|
+
}
|
|
8471
|
+
return { token, created: true };
|
|
8472
|
+
}
|
|
8473
|
+
|
|
8474
|
+
// src/lib/mcp-hub-service.ts
|
|
8475
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
8476
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
8477
|
+
import { join as join9 } from "path";
|
|
8478
|
+
var MCP_HUB_LAUNCHD_LABEL = "io.topolo.mcp-hub";
|
|
8479
|
+
var MCP_HUB_SYSTEMD_UNIT = "topolo-mcp-hub.service";
|
|
8480
|
+
var PASSTHROUGH_ENVIRONMENT = ["TOPOLO_CONFIG_PATH", "XDG_CONFIG_HOME"];
|
|
8481
|
+
var runCommand = (command, args) => {
|
|
8482
|
+
const result = spawnSync4(command, args, { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] });
|
|
8483
|
+
return { status: result.status, stderr: result.stderr ?? "" };
|
|
8484
|
+
};
|
|
8485
|
+
function serviceKindFor(platform4) {
|
|
8486
|
+
if (platform4 === "darwin") return "launchd";
|
|
8487
|
+
if (platform4 === "linux") return "systemd";
|
|
8488
|
+
return void 0;
|
|
8489
|
+
}
|
|
8490
|
+
function mcpHubServicePath(kind, homeDir, xdgConfigHome) {
|
|
8491
|
+
return kind === "launchd" ? join9(homeDir, "Library", "LaunchAgents", `${MCP_HUB_LAUNCHD_LABEL}.plist`) : join9(xdgConfigHome || join9(homeDir, ".config"), "systemd", "user", MCP_HUB_SYSTEMD_UNIT);
|
|
8492
|
+
}
|
|
8493
|
+
function serviceEnvironment(environment) {
|
|
8494
|
+
const selected = {};
|
|
8495
|
+
for (const key of PASSTHROUGH_ENVIRONMENT) {
|
|
8496
|
+
const value = environment[key];
|
|
8497
|
+
if (value) selected[key] = value;
|
|
8498
|
+
}
|
|
8499
|
+
return selected;
|
|
8500
|
+
}
|
|
8501
|
+
function xml(value) {
|
|
8502
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
8503
|
+
}
|
|
8504
|
+
function serviceArguments(spec) {
|
|
8505
|
+
return [spec.nodeCommand, spec.cliEntry, "mcp", "serve", "--port", String(spec.port)];
|
|
8506
|
+
}
|
|
8507
|
+
function renderLaunchdPlist(spec) {
|
|
8508
|
+
const environment = Object.entries(spec.environment);
|
|
8509
|
+
return [
|
|
8510
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
8511
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
8512
|
+
'<plist version="1.0">',
|
|
8513
|
+
"<dict>",
|
|
8514
|
+
" <key>Label</key>",
|
|
8515
|
+
` <string>${MCP_HUB_LAUNCHD_LABEL}</string>`,
|
|
8516
|
+
" <key>ProgramArguments</key>",
|
|
8517
|
+
" <array>",
|
|
8518
|
+
...serviceArguments(spec).map((argument) => ` <string>${xml(argument)}</string>`),
|
|
8519
|
+
" </array>",
|
|
8520
|
+
...environment.length ? [
|
|
8521
|
+
" <key>EnvironmentVariables</key>",
|
|
8522
|
+
" <dict>",
|
|
8523
|
+
...environment.flatMap(([key, value]) => [` <key>${xml(key)}</key>`, ` <string>${xml(value)}</string>`]),
|
|
8524
|
+
" </dict>"
|
|
8525
|
+
] : [],
|
|
8526
|
+
" <key>RunAtLoad</key>",
|
|
8527
|
+
" <true/>",
|
|
8528
|
+
" <key>KeepAlive</key>",
|
|
8529
|
+
" <true/>",
|
|
8530
|
+
" <key>ProcessType</key>",
|
|
8531
|
+
" <string>Background</string>",
|
|
8532
|
+
...spec.logPath ? [
|
|
8533
|
+
" <key>StandardOutPath</key>",
|
|
8534
|
+
` <string>${xml(spec.logPath)}</string>`,
|
|
8535
|
+
" <key>StandardErrorPath</key>",
|
|
8536
|
+
` <string>${xml(spec.logPath)}</string>`
|
|
8537
|
+
] : [],
|
|
8538
|
+
"</dict>",
|
|
8539
|
+
"</plist>",
|
|
8540
|
+
""
|
|
8541
|
+
].join("\n");
|
|
8542
|
+
}
|
|
8543
|
+
function systemdQuote(value) {
|
|
8544
|
+
return '"' + value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%") + '"';
|
|
8545
|
+
}
|
|
8546
|
+
function renderSystemdUnit(spec) {
|
|
8547
|
+
return [
|
|
8548
|
+
"[Unit]",
|
|
8549
|
+
"Description=Topolo shared local MCP hub",
|
|
8550
|
+
"After=network-online.target",
|
|
8551
|
+
"",
|
|
8552
|
+
"[Service]",
|
|
8553
|
+
"Type=simple",
|
|
8554
|
+
`ExecStart=${serviceArguments(spec).map(systemdQuote).join(" ")}`,
|
|
8555
|
+
...Object.entries(spec.environment).map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`),
|
|
8556
|
+
"Restart=always",
|
|
8557
|
+
"RestartSec=2",
|
|
8558
|
+
"",
|
|
8559
|
+
"[Install]",
|
|
8560
|
+
"WantedBy=default.target",
|
|
8561
|
+
""
|
|
8562
|
+
].join("\n");
|
|
8563
|
+
}
|
|
8564
|
+
async function readExisting(path) {
|
|
8565
|
+
try {
|
|
8566
|
+
return await readFile7(path, "utf8");
|
|
8567
|
+
} catch (error) {
|
|
8568
|
+
if (error.code === "ENOENT") return void 0;
|
|
8569
|
+
throw error;
|
|
8570
|
+
}
|
|
8571
|
+
}
|
|
8572
|
+
function require0(result, what) {
|
|
8573
|
+
if (result.status !== 0) throw new Error(`${what} failed: ${result.stderr.trim() || `exit ${result.status}`}`);
|
|
8574
|
+
}
|
|
8575
|
+
async function installMcpHubService(options) {
|
|
8576
|
+
const kind = serviceKindFor(options.platform);
|
|
8577
|
+
if (!kind) {
|
|
8578
|
+
return {
|
|
8579
|
+
kind: "unsupported",
|
|
8580
|
+
changed: false,
|
|
8581
|
+
activated: false,
|
|
8582
|
+
detail: `No user service manager is supported on ${options.platform}; run \`topolo mcp serve\` yourself.`
|
|
8583
|
+
};
|
|
8584
|
+
}
|
|
8585
|
+
const path = mcpHubServicePath(kind, options.homeDir, options.xdgConfigHome);
|
|
8586
|
+
const spec = kind === "launchd" ? { ...options.spec, logPath: options.spec.logPath ?? join9(options.homeDir, "Library", "Logs", "topolo-mcp-hub.log") } : options.spec;
|
|
8587
|
+
const contents = kind === "launchd" ? renderLaunchdPlist(spec) : renderSystemdUnit(spec);
|
|
8588
|
+
const changed = await readExisting(path) !== contents;
|
|
8589
|
+
if (changed) await writePrivateFile(path, contents);
|
|
8590
|
+
if (!options.activate) {
|
|
8591
|
+
return { kind, path, changed, activated: false, detail: `${kind} unit written; not activated for an alternate agent home.` };
|
|
8592
|
+
}
|
|
8593
|
+
const run = options.run;
|
|
8594
|
+
if (kind === "launchd") {
|
|
8595
|
+
const domain = `gui/${options.uid ?? process.getuid()}`;
|
|
8596
|
+
const target = `${domain}/${MCP_HUB_LAUNCHD_LABEL}`;
|
|
8597
|
+
const loaded = run("launchctl", ["print", target]).status === 0;
|
|
8598
|
+
if (loaded && !changed) {
|
|
8599
|
+
return { kind, path, changed, activated: true, detail: `${MCP_HUB_LAUNCHD_LABEL} already loaded with this definition.` };
|
|
8600
|
+
}
|
|
8601
|
+
if (loaded) run("launchctl", ["bootout", target]);
|
|
8602
|
+
require0(run("launchctl", ["bootstrap", domain, path]), "launchctl bootstrap");
|
|
8603
|
+
return { kind, path, changed, activated: true, detail: `${MCP_HUB_LAUNCHD_LABEL} ${loaded ? "reloaded" : "loaded"} (KeepAlive).` };
|
|
8604
|
+
}
|
|
8605
|
+
require0(run("systemctl", ["--user", "daemon-reload"]), "systemctl --user daemon-reload");
|
|
8606
|
+
require0(run("systemctl", ["--user", "enable", MCP_HUB_SYSTEMD_UNIT]), "systemctl --user enable");
|
|
8607
|
+
require0(run("systemctl", ["--user", changed ? "restart" : "start", MCP_HUB_SYSTEMD_UNIT]), `systemctl --user ${changed ? "restart" : "start"}`);
|
|
8608
|
+
return { kind, path, changed, activated: true, detail: `${MCP_HUB_SYSTEMD_UNIT} enabled and ${changed ? "restarted" : "running"} (Restart=always).` };
|
|
8609
|
+
}
|
|
8610
|
+
|
|
8611
|
+
// src/commands/mcp-hub-setup.ts
|
|
8612
|
+
function resolveCliEntry(argvEntry = process.argv[1]) {
|
|
8613
|
+
if (!argvEntry) throw new Error("Cannot locate the running topolo CLI entry for the hub service.");
|
|
8614
|
+
return realpathSync(argvEntry);
|
|
8615
|
+
}
|
|
8616
|
+
function resolveSharedTargets(options) {
|
|
8617
|
+
const explicit = [...options.agent ?? [], ...options.claude ? ["claude"] : [], ...options.codex ? ["codex"] : []];
|
|
8618
|
+
if (!explicit.length) return [...SHARED_HUB_AGENTS];
|
|
8619
|
+
const unsupported = explicit.filter((agent) => !SHARED_HUB_AGENTS.includes(agent));
|
|
8620
|
+
if (unsupported.length) {
|
|
8621
|
+
throw new Error(
|
|
8622
|
+
`The shared MCP hub supports Claude Code and Codex only (not ${unsupported.join(", ")}). Register other hosts with \`topolo setup\` (stdio).`
|
|
8623
|
+
);
|
|
8624
|
+
}
|
|
8625
|
+
return [...new Set(explicit)];
|
|
8626
|
+
}
|
|
8627
|
+
async function importStdioIdentities(agents, homeDir, codexHome) {
|
|
8628
|
+
const found = /* @__PURE__ */ new Map();
|
|
8629
|
+
const skipped = [];
|
|
8630
|
+
for (const agent of agents) {
|
|
8631
|
+
for (const registration of await readAgentRegistrations(agent, homeDir, void 0, codexHome)) {
|
|
8632
|
+
if (registration.url) continue;
|
|
8633
|
+
if (registration.env.TOPOLO_CONFIG_PATH || registration.env.TOPOLO_AGENT_INSTALLATION_ID) {
|
|
8634
|
+
skipped.push({ agent, name: registration.name, reason: "uses a private config path or agent installation; keep it on stdio" });
|
|
8635
|
+
continue;
|
|
8636
|
+
}
|
|
8637
|
+
if (!registration.env.TOPOLO_CONTEXT) {
|
|
8638
|
+
skipped.push({
|
|
8639
|
+
agent,
|
|
8640
|
+
name: registration.name,
|
|
8641
|
+
reason: "is not pinned to a context; add it with `topolo setup --shared --name <name> --context <context>`"
|
|
8642
|
+
});
|
|
8643
|
+
continue;
|
|
8644
|
+
}
|
|
8645
|
+
let identity;
|
|
8646
|
+
try {
|
|
8647
|
+
identity = validateMcpHubIdentity({
|
|
8648
|
+
name: registration.name,
|
|
8649
|
+
// A stdio entry without TOPOLO_ENV has always meant production.
|
|
8650
|
+
env: registration.env.TOPOLO_ENV || "production",
|
|
8651
|
+
context: registration.env.TOPOLO_CONTEXT
|
|
8652
|
+
});
|
|
8653
|
+
} catch (error) {
|
|
8654
|
+
skipped.push({ agent, name: registration.name, reason: error.message });
|
|
8655
|
+
continue;
|
|
8656
|
+
}
|
|
8657
|
+
const previous = found.get(identity.name);
|
|
8658
|
+
if (previous && JSON.stringify(previous.identity) !== JSON.stringify(identity)) {
|
|
8659
|
+
skipped.push({ agent, name: identity.name, reason: `conflicts with the ${AGENT_LABELS[previous.agent]} registration of the same name` });
|
|
8660
|
+
continue;
|
|
8661
|
+
}
|
|
8662
|
+
found.set(identity.name, { identity, agent });
|
|
8663
|
+
}
|
|
8664
|
+
}
|
|
8665
|
+
return { identities: [...found.values()].map((entry) => entry.identity), skipped };
|
|
8666
|
+
}
|
|
8667
|
+
function sameHeaders(left, right) {
|
|
8668
|
+
return JSON.stringify(Object.entries(left ?? {}).sort()) === JSON.stringify(Object.entries(right).sort());
|
|
8669
|
+
}
|
|
8670
|
+
function hubHeaders(token, agent) {
|
|
8671
|
+
return { Authorization: `Bearer ${token}`, "X-Topolo-Agent": AGENT_NAME_FOR[agent] };
|
|
8672
|
+
}
|
|
8673
|
+
async function installSharedAgentSurfaces(options, dependencies) {
|
|
8674
|
+
if (options.project) {
|
|
8675
|
+
throw new Error("--shared registers user-scope servers only: the hub token must never be written into project files.");
|
|
8676
|
+
}
|
|
8677
|
+
if (options.installationId) {
|
|
8678
|
+
throw new Error("--shared cannot bind an agent installation; keep installation-bound MCPs on stdio.");
|
|
8679
|
+
}
|
|
8680
|
+
const agents = resolveSharedTargets(options);
|
|
8681
|
+
const target = resolveAgentHostTarget({ homeDir: options.homeDir, codexHome: options.codexHome });
|
|
8682
|
+
const paths = dependencies.paths();
|
|
8683
|
+
let config = await readMcpHubConfig(paths.config);
|
|
8684
|
+
if (options.port !== void 0) config = { ...config, port: validateMcpHubPort(options.port) };
|
|
8685
|
+
const skipped = [];
|
|
8686
|
+
if (options.importStdio) {
|
|
8687
|
+
const imported = await importStdioIdentities(agents, target.homeDir, target.codexHome);
|
|
8688
|
+
skipped.push(...imported.skipped);
|
|
8689
|
+
for (const identity of imported.identities) config = upsertMcpHubIdentity(config, identity);
|
|
8690
|
+
}
|
|
8691
|
+
if (!options.importStdio || options.name) {
|
|
8692
|
+
const name = options.name?.trim() || "topolo";
|
|
8693
|
+
const env = normalizeEnvironment(options.environment?.trim() || dependencies.environment.TOPOLO_ENV || "production");
|
|
8694
|
+
const { context } = await withPinnedContext({ context: options.context, environment: env }, dependencies.readCredentialStore);
|
|
8695
|
+
config = upsertMcpHubIdentity(config, validateMcpHubIdentity({ name, env, context }));
|
|
8696
|
+
}
|
|
8697
|
+
await writeMcpHubConfig(paths.config, config);
|
|
8698
|
+
const { token, created } = await ensureMcpHubToken(paths.token);
|
|
8699
|
+
const source = await dependencies.skillSource();
|
|
8700
|
+
const installed = [];
|
|
8701
|
+
for (const agent of agents) {
|
|
8702
|
+
const skill = await installAgentSkill(agent, source, target.homeDir, void 0, target.codexHome);
|
|
8703
|
+
try {
|
|
8704
|
+
const headers = hubHeaders(token, agent);
|
|
8705
|
+
for (const identity of config.identities) {
|
|
8706
|
+
await registerAgentHttpConfig(agent, target.homeDir, {
|
|
8707
|
+
name: identity.name,
|
|
8708
|
+
url: mcpHubUrl(config.port, identity.name),
|
|
8709
|
+
headers
|
|
8710
|
+
}, target.codexHome);
|
|
8711
|
+
}
|
|
8712
|
+
const registrations = await readAgentRegistrations(agent, target.homeDir, void 0, target.codexHome);
|
|
8713
|
+
const missing = config.identities.filter((identity) => {
|
|
8714
|
+
const entry = registrations.find((registration) => registration.name === identity.name);
|
|
8715
|
+
return !entry || entry.url !== mcpHubUrl(config.port, identity.name) || !sameHeaders(entry.headers, headers);
|
|
8716
|
+
});
|
|
8717
|
+
if (missing.length) {
|
|
8718
|
+
throw new Error(`Host configuration did not retain the shared hub registration for ${missing.map((identity) => identity.name).join(", ")}.`);
|
|
8719
|
+
}
|
|
8720
|
+
installed.push({
|
|
8721
|
+
agent,
|
|
8722
|
+
skillPath: skill.path,
|
|
8723
|
+
skillInstalled: skill.installed,
|
|
8724
|
+
mcp: "config-file",
|
|
8725
|
+
registrations: config.identities.map((identity) => identity.name)
|
|
8726
|
+
});
|
|
8727
|
+
} catch (error) {
|
|
8728
|
+
installed.push({
|
|
8729
|
+
agent,
|
|
8730
|
+
skillPath: skill.path,
|
|
8731
|
+
skillInstalled: skill.installed,
|
|
8732
|
+
mcp: "failed",
|
|
8733
|
+
registrations: [],
|
|
8734
|
+
detail: error.message
|
|
8735
|
+
});
|
|
8736
|
+
}
|
|
8737
|
+
}
|
|
8738
|
+
const service = options.service === false ? void 0 : await installMcpHubService({
|
|
8739
|
+
platform: dependencies.platform,
|
|
8740
|
+
homeDir: target.homeDir,
|
|
8741
|
+
xdgConfigHome: target.homeDir === dependencies.realHome ? dependencies.environment.XDG_CONFIG_HOME : void 0,
|
|
8742
|
+
spec: {
|
|
8743
|
+
nodeCommand: dependencies.nodeCommand(),
|
|
8744
|
+
cliEntry: dependencies.cliEntry(),
|
|
8745
|
+
port: config.port,
|
|
8746
|
+
environment: serviceEnvironment(dependencies.environment)
|
|
8747
|
+
},
|
|
8748
|
+
activate: target.homeDir === dependencies.realHome,
|
|
8749
|
+
run: dependencies.run,
|
|
8750
|
+
...dependencies.uid !== void 0 ? { uid: dependencies.uid } : {}
|
|
8751
|
+
});
|
|
8752
|
+
return {
|
|
8753
|
+
hub: {
|
|
8754
|
+
port: config.port,
|
|
8755
|
+
configPath: paths.config,
|
|
8756
|
+
tokenPath: paths.token,
|
|
8757
|
+
tokenCreated: created,
|
|
8758
|
+
identities: config.identities
|
|
8759
|
+
},
|
|
8760
|
+
installed,
|
|
8761
|
+
skipped,
|
|
8762
|
+
...service ? { service } : {}
|
|
8763
|
+
};
|
|
8764
|
+
}
|
|
8765
|
+
function defaultSharedSetupDependencies(skillsDir, realHome) {
|
|
8766
|
+
return {
|
|
8767
|
+
paths: () => mcpHubPaths(),
|
|
8768
|
+
skillSource: () => readFile8(join10(skillsDir, "topolo", "SKILL.md"), "utf8"),
|
|
8769
|
+
platform: process.platform,
|
|
8770
|
+
realHome,
|
|
8771
|
+
nodeCommand: stableNodeCommand,
|
|
8772
|
+
cliEntry: () => resolveCliEntry(),
|
|
8773
|
+
environment: process.env,
|
|
8774
|
+
run: runCommand,
|
|
8775
|
+
readCredentialStore: readConfig
|
|
8776
|
+
};
|
|
8777
|
+
}
|
|
8778
|
+
function formatSharedSetup(result) {
|
|
8779
|
+
const lines = [
|
|
8780
|
+
`Shared MCP hub: http://127.0.0.1:${result.hub.port}/mcp/<name> (${result.hub.identities.length} identities)`,
|
|
8781
|
+
` config -> ${result.hub.configPath}`,
|
|
8782
|
+
` token -> ${result.hub.tokenPath}${result.hub.tokenCreated ? " (new)" : ""}`
|
|
8783
|
+
];
|
|
8784
|
+
for (const row of result.installed) {
|
|
8785
|
+
lines.push(`${AGENT_LABELS[row.agent]}:`);
|
|
8786
|
+
lines.push(` skill -> ${row.skillPath}`);
|
|
8787
|
+
lines.push(row.mcp === "config-file" ? ` HTTP MCP registered: ${row.registrations.join(", ")}` : ` HTTP MCP registration FAILED: ${row.detail}`);
|
|
8788
|
+
}
|
|
8789
|
+
for (const entry of result.skipped) lines.push(`Skipped ${entry.name} (${AGENT_LABELS[entry.agent]}): ${entry.reason}`);
|
|
8790
|
+
if (result.service) lines.push(`Service (${result.service.kind}): ${result.service.detail}`);
|
|
8791
|
+
lines.push("", "Next: restart the agents, then run `topolo doctor --shared`.");
|
|
8792
|
+
return lines.join("\n");
|
|
8793
|
+
}
|
|
8794
|
+
|
|
7779
8795
|
// src/commands/skills.ts
|
|
7780
8796
|
var AGENT_LABELS2 = AGENT_LABELS;
|
|
7781
8797
|
function skillsSourceDir() {
|
|
7782
|
-
const here =
|
|
8798
|
+
const here = dirname7(fileURLToPath3(import.meta.url));
|
|
7783
8799
|
const distCandidate = resolve3(here, "../skills");
|
|
7784
8800
|
const srcCandidate = resolve3(here, "../../skills");
|
|
7785
8801
|
for (const candidate of [distCandidate, srcCandidate]) {
|
|
7786
|
-
if (existsSync6(
|
|
8802
|
+
if (existsSync6(join11(candidate, "topolo", "SKILL.md"))) return candidate;
|
|
7787
8803
|
}
|
|
7788
8804
|
return distCandidate;
|
|
7789
8805
|
}
|
|
@@ -7793,7 +8809,7 @@ function resolveMcpCommand() {
|
|
|
7793
8809
|
}
|
|
7794
8810
|
function agentBinaryExists(agent) {
|
|
7795
8811
|
return agentCliNames(agent).some((bin) => {
|
|
7796
|
-
const probe =
|
|
8812
|
+
const probe = spawnSync5(process.platform === "win32" ? "where" : "which", [bin], {
|
|
7797
8813
|
stdio: "ignore"
|
|
7798
8814
|
});
|
|
7799
8815
|
return probe.status === 0;
|
|
@@ -7809,11 +8825,10 @@ function resolveMcpRegistration(options) {
|
|
|
7809
8825
|
if (name !== "topolo" && !/^topolo-[a-z0-9][a-z0-9_-]*$/.test(name)) {
|
|
7810
8826
|
throw new Error("Named registrations must use a topolo-<name> identifier.");
|
|
7811
8827
|
}
|
|
7812
|
-
if (context &&
|
|
7813
|
-
throw new Error(
|
|
7814
|
-
|
|
7815
|
-
|
|
7816
|
-
throw new Error("Named registrations require --context <context>.");
|
|
8828
|
+
if (!context && !options.installationId?.trim()) {
|
|
8829
|
+
throw new Error(
|
|
8830
|
+
"MCP registrations must pin a context: pass --context <context> (see `topolo context ls`), or --installation <id>."
|
|
8831
|
+
);
|
|
7817
8832
|
}
|
|
7818
8833
|
if (context && /[\r\n]/.test(context)) throw new Error("Context must be a single-line identifier.");
|
|
7819
8834
|
if (!["production", "staging", "development", "local"].includes(environment)) {
|
|
@@ -7834,24 +8849,25 @@ function resolveMcpRegistration(options) {
|
|
|
7834
8849
|
}
|
|
7835
8850
|
async function installForAgent(agent, sourceDir, mcp, opts) {
|
|
7836
8851
|
const { homeDir, projectDir, codexHome } = opts.target;
|
|
7837
|
-
|
|
8852
|
+
const registration = opts.registration;
|
|
8853
|
+
if (registration && agent === "claude" && projectDir) {
|
|
7838
8854
|
try {
|
|
7839
|
-
await assertClaudeProjectScope(homeDir, projectDir,
|
|
8855
|
+
await assertClaudeProjectScope(homeDir, projectDir, registration.name);
|
|
7840
8856
|
} catch (err) {
|
|
7841
8857
|
return { agent, skillInstalled: false, mcp: "failed", detail: err instanceof Error ? err.message : String(err) };
|
|
7842
8858
|
}
|
|
7843
8859
|
}
|
|
7844
|
-
const source = await
|
|
8860
|
+
const source = await readFile9(join11(sourceDir, "topolo", "SKILL.md"), "utf8");
|
|
7845
8861
|
const skill = await installAgentSkill(agent, source, homeDir, projectDir, codexHome);
|
|
7846
|
-
if (!
|
|
8862
|
+
if (!registration) {
|
|
7847
8863
|
return { agent, skillPath: skill.path, skillInstalled: skill.installed, mcp: "skipped" };
|
|
7848
8864
|
}
|
|
7849
8865
|
try {
|
|
7850
|
-
await registerAgentConfig(agent, mcp, homeDir,
|
|
7851
|
-
const installed = (await readAgentRegistrations(agent, homeDir, projectDir, codexHome)).find((
|
|
7852
|
-
const expectedEnvironment = registrationEnvironment(agent,
|
|
8866
|
+
await registerAgentConfig(agent, mcp, homeDir, registration, projectDir, codexHome);
|
|
8867
|
+
const installed = (await readAgentRegistrations(agent, homeDir, projectDir, codexHome)).find((entry) => entry.name === registration.name);
|
|
8868
|
+
const expectedEnvironment = registrationEnvironment(agent, registration);
|
|
7853
8869
|
if (!installed || installed.command !== mcp.command || JSON.stringify(installed.args) !== JSON.stringify(mcp.args) || Object.entries(expectedEnvironment).some(([key, value]) => installed.env[key] !== value)) {
|
|
7854
|
-
throw new Error(`Host configuration did not retain the ${
|
|
8870
|
+
throw new Error(`Host configuration did not retain the ${registration.name} MCP registration.`);
|
|
7855
8871
|
}
|
|
7856
8872
|
return { agent, skillPath: skill.path, skillInstalled: skill.installed, mcp: "config-file" };
|
|
7857
8873
|
} catch (err) {
|
|
@@ -7888,7 +8904,8 @@ function resolveTargets(opts) {
|
|
|
7888
8904
|
function registerSkillsCommand(program2) {
|
|
7889
8905
|
const skills = program2.command("skills").description("Install the Topolo agent skill + MCP server into supported agent hosts");
|
|
7890
8906
|
skills.command("install").description("Copy the skill file and register the topolo MCP server (credential-free)").option("--claude", "Target Claude Code only").option("--codex", "Target Codex only").option("--agent <host...>", "Target one or more hosts by id (for example cursor gemini)").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("--no-mcp", "Copy the skill file but do not register the MCP server").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(
|
|
7891
|
-
async (opts) => {
|
|
8907
|
+
async (opts, command) => {
|
|
8908
|
+
opts = withProgramContext(opts, command);
|
|
7892
8909
|
const mode = resolveOutputMode(opts.json);
|
|
7893
8910
|
try {
|
|
7894
8911
|
const { results, mcp } = await installAgentSurfaces({
|
|
@@ -7938,17 +8955,17 @@ function registerSkillsCommand(program2) {
|
|
|
7938
8955
|
}
|
|
7939
8956
|
async function installAgentSurfaces(opts) {
|
|
7940
8957
|
const sourceDir = skillsSourceDir();
|
|
7941
|
-
if (!existsSync6(
|
|
8958
|
+
if (!existsSync6(join11(sourceDir, "topolo", "SKILL.md"))) {
|
|
7942
8959
|
throw new Error("Bundled skill file not found. Reinstall the CLI: npm install -g @topolo/cli");
|
|
7943
8960
|
}
|
|
7944
8961
|
const mcp = resolveMcpCommand();
|
|
7945
|
-
const registration = resolveMcpRegistration(opts);
|
|
7946
8962
|
const target = resolveAgentHostTarget({ homeDir: opts.homeDir, projectDir: opts.project, codexHome: opts.codexHome });
|
|
7947
8963
|
const { homeDir, projectDir, codexHome } = target;
|
|
8964
|
+
const agents = resolveTargets({ ...opts, homeDir, project: projectDir, codexHome });
|
|
8965
|
+
const registration = opts.mcp === false ? null : resolveMcpRegistration(await withPinnedContext({ ...opts }, opts.readCredentialStore));
|
|
7948
8966
|
const results = [];
|
|
7949
|
-
for (const agent of
|
|
8967
|
+
for (const agent of agents) {
|
|
7950
8968
|
results.push(await installForAgent(agent, sourceDir, mcp, {
|
|
7951
|
-
withMcp: opts.mcp !== false,
|
|
7952
8969
|
target,
|
|
7953
8970
|
registration
|
|
7954
8971
|
}));
|
|
@@ -7956,9 +8973,23 @@ async function installAgentSurfaces(opts) {
|
|
|
7956
8973
|
return { results, mcp };
|
|
7957
8974
|
}
|
|
7958
8975
|
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) => {
|
|
8976
|
+
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, command) => {
|
|
8977
|
+
opts = withProgramContext(opts, command);
|
|
7960
8978
|
const mode = resolveOutputMode(opts.json);
|
|
7961
8979
|
try {
|
|
8980
|
+
if (opts.shared) {
|
|
8981
|
+
const shared = await installSharedAgentSurfaces({
|
|
8982
|
+
...opts,
|
|
8983
|
+
installationId: opts.installation,
|
|
8984
|
+
homeDir: opts.agentHome
|
|
8985
|
+
}, defaultSharedSetupDependencies(skillsSourceDir(), homedir5()));
|
|
8986
|
+
assertSharedSetupSucceeded(shared);
|
|
8987
|
+
emit(shared, mode, () => formatSharedSetup(shared));
|
|
8988
|
+
return;
|
|
8989
|
+
}
|
|
8990
|
+
if (opts.port !== void 0 || opts.importStdio || opts.service === false) {
|
|
8991
|
+
throw new Error("--port, --import-stdio and --no-service apply only with --shared.");
|
|
8992
|
+
}
|
|
7962
8993
|
const { results, mcp } = await installAgentSurfaces({
|
|
7963
8994
|
...opts,
|
|
7964
8995
|
installationId: opts.installation,
|
|
@@ -7971,6 +9002,12 @@ function registerSetupCommand(program2) {
|
|
|
7971
9002
|
}
|
|
7972
9003
|
});
|
|
7973
9004
|
}
|
|
9005
|
+
function assertSharedSetupSucceeded(result) {
|
|
9006
|
+
const failed = result.installed.filter((row) => row.mcp === "failed");
|
|
9007
|
+
if (failed.length) {
|
|
9008
|
+
throw new Error(failed.map((row) => `${AGENT_LABELS2[row.agent]}: ${row.detail}`).join("; "));
|
|
9009
|
+
}
|
|
9010
|
+
}
|
|
7974
9011
|
function assertAgentSetupSucceeded(results) {
|
|
7975
9012
|
const failed = results.filter((result) => result.mcp === "failed");
|
|
7976
9013
|
if (failed.length) {
|
|
@@ -7980,7 +9017,7 @@ function assertAgentSetupSucceeded(results) {
|
|
|
7980
9017
|
async function agentSurfaceStatus(options = {}) {
|
|
7981
9018
|
const target = resolveAgentHostTarget({ homeDir: options.homeDir, projectDir: options.project, codexHome: options.codexHome });
|
|
7982
9019
|
const { homeDir, projectDir, codexHome } = target;
|
|
7983
|
-
const sourceSkill = await
|
|
9020
|
+
const sourceSkill = await readFile9(join11(skillsSourceDir(), "topolo", "SKILL.md"), "utf8");
|
|
7984
9021
|
const agents = resolveTargets({
|
|
7985
9022
|
agent: options.agent,
|
|
7986
9023
|
all: options.all,
|
|
@@ -7990,7 +9027,7 @@ async function agentSurfaceStatus(options = {}) {
|
|
|
7990
9027
|
});
|
|
7991
9028
|
return Promise.all(agents.map(async (agent) => {
|
|
7992
9029
|
const skillPath = agentSkillPath(agent, homeDir, projectDir, codexHome);
|
|
7993
|
-
const installedSkill = skillPath && existsSync6(skillPath) ? await
|
|
9030
|
+
const installedSkill = skillPath && existsSync6(skillPath) ? await readFile9(skillPath, "utf8") : void 0;
|
|
7994
9031
|
const registrations = (await readAgentRegistrations(agent, homeDir, projectDir, codexHome)).filter((registration) => !options.name || registration.name === options.name);
|
|
7995
9032
|
return {
|
|
7996
9033
|
agent,
|
|
@@ -8007,7 +9044,7 @@ async function uninstallAgentSurfaces(options) {
|
|
|
8007
9044
|
const target = resolveAgentHostTarget({ homeDir: options.homeDir, projectDir: options.project, codexHome: options.codexHome });
|
|
8008
9045
|
const { homeDir, projectDir, codexHome } = target;
|
|
8009
9046
|
const name = options.name?.trim() || "topolo";
|
|
8010
|
-
resolveMcpRegistration({ name,
|
|
9047
|
+
resolveMcpRegistration({ name, context: "uninstall-validation" });
|
|
8011
9048
|
const removed = [];
|
|
8012
9049
|
for (const agent of resolveTargets({ ...options, homeDir, project: projectDir, codexHome })) {
|
|
8013
9050
|
const before = await readAgentRegistrations(agent, homeDir, projectDir, codexHome);
|
|
@@ -8016,7 +9053,7 @@ async function uninstallAgentSurfaces(options) {
|
|
|
8016
9053
|
const remaining = await readAgentRegistrations(agent, homeDir, projectDir, codexHome);
|
|
8017
9054
|
const skillPath = agentSkillPath(agent, homeDir, projectDir, codexHome);
|
|
8018
9055
|
const skillRemoved = remaining.length === 0 && Boolean(skillPath && existsSync6(skillPath));
|
|
8019
|
-
if (skillRemoved && skillPath) await
|
|
9056
|
+
if (skillRemoved && skillPath) await rm3(dirname7(skillPath), { recursive: true, force: true });
|
|
8020
9057
|
removed.push({ agent, name, registrationRemoved, skillRemoved });
|
|
8021
9058
|
}
|
|
8022
9059
|
return removed;
|
|
@@ -8045,14 +9082,128 @@ function formatStatus(rows) {
|
|
|
8045
9082
|
}
|
|
8046
9083
|
|
|
8047
9084
|
// src/commands/doctor.ts
|
|
8048
|
-
import { access } from "fs/promises";
|
|
9085
|
+
import { access as access2 } from "fs/promises";
|
|
8049
9086
|
import { homedir as homedir6 } from "os";
|
|
8050
9087
|
import { resolve as resolve4 } from "path";
|
|
8051
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9088
|
+
import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8052
9089
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9090
|
+
|
|
9091
|
+
// src/commands/mcp-hub-doctor.ts
|
|
9092
|
+
import { access } from "fs/promises";
|
|
9093
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9094
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
9095
|
+
async function verifyHubIdentity(url, headers, fetchImplementation = fetch) {
|
|
9096
|
+
const client = new Client({ name: "topolo-cli-doctor", version: CLI_VERSION });
|
|
9097
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
9098
|
+
requestInit: { headers },
|
|
9099
|
+
fetch: fetchImplementation
|
|
9100
|
+
});
|
|
9101
|
+
try {
|
|
9102
|
+
await client.connect(transport);
|
|
9103
|
+
const tools = await client.listTools();
|
|
9104
|
+
if (!tools.tools.some((tool) => tool.name === "topolo_whoami")) {
|
|
9105
|
+
throw new Error("hub identity connected but did not advertise topolo_whoami.");
|
|
9106
|
+
}
|
|
9107
|
+
const identity = await client.callTool({ name: "topolo_whoami", arguments: {} });
|
|
9108
|
+
if (identity.isError) throw new Error("topolo_whoami returned an error through the hub.");
|
|
9109
|
+
return `handshake and topolo_whoami succeeded; ${tools.tools.length} tools advertised`;
|
|
9110
|
+
} finally {
|
|
9111
|
+
await client.close();
|
|
9112
|
+
}
|
|
9113
|
+
}
|
|
9114
|
+
var defaultSharedDoctorDependencies = () => ({
|
|
9115
|
+
paths: () => mcpHubPaths(),
|
|
9116
|
+
platform: process.platform,
|
|
9117
|
+
fetch,
|
|
9118
|
+
verifyIdentity: (url, headers) => verifyHubIdentity(url, headers),
|
|
9119
|
+
fileExists: async (path) => access(path).then(() => true, () => false),
|
|
9120
|
+
environment: process.env
|
|
9121
|
+
});
|
|
9122
|
+
function message2(error) {
|
|
9123
|
+
return error instanceof Error ? error.message : String(error);
|
|
9124
|
+
}
|
|
9125
|
+
async function runSharedDoctor(options, dependencies) {
|
|
9126
|
+
const checks = [];
|
|
9127
|
+
const done = () => ({ ok: checks.every((check) => check.ok), cliVersion: CLI_VERSION, checks });
|
|
9128
|
+
const paths = dependencies.paths();
|
|
9129
|
+
let config;
|
|
9130
|
+
try {
|
|
9131
|
+
config = await readMcpHubConfig(paths.config);
|
|
9132
|
+
const selected = options.name ? config.identities.filter((identity) => identity.name === options.name) : config.identities;
|
|
9133
|
+
if (!selected.length) {
|
|
9134
|
+
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\`.`);
|
|
9135
|
+
}
|
|
9136
|
+
config = { ...config, identities: selected };
|
|
9137
|
+
checks.push({ name: "hub-config", ok: true, detail: `${paths.config}: port ${config.port}, ${selected.length} identities` });
|
|
9138
|
+
} catch (error) {
|
|
9139
|
+
checks.push({ name: "hub-config", ok: false, detail: message2(error) });
|
|
9140
|
+
return done();
|
|
9141
|
+
}
|
|
9142
|
+
let token;
|
|
9143
|
+
try {
|
|
9144
|
+
token = await readMcpHubToken(paths.token);
|
|
9145
|
+
checks.push({ name: "hub-token", ok: true, detail: `${paths.token} is private (0600)` });
|
|
9146
|
+
} catch (error) {
|
|
9147
|
+
checks.push({ name: "hub-token", ok: false, detail: message2(error) });
|
|
9148
|
+
return done();
|
|
9149
|
+
}
|
|
9150
|
+
const target = resolveAgentHostTarget({ homeDir: options.agentHome, codexHome: options.codexHome });
|
|
9151
|
+
const kind = serviceKindFor(dependencies.platform);
|
|
9152
|
+
if (kind) {
|
|
9153
|
+
const path = mcpHubServicePath(kind, target.homeDir, options.agentHome ? void 0 : dependencies.environment.XDG_CONFIG_HOME);
|
|
9154
|
+
const present = await dependencies.fileExists(path);
|
|
9155
|
+
checks.push({ name: "service", ok: present, detail: present ? `${kind} unit at ${path}` : `${kind} unit missing at ${path}` });
|
|
9156
|
+
}
|
|
9157
|
+
try {
|
|
9158
|
+
const response = await dependencies.fetch(`http://127.0.0.1:${config.port}/health`, {
|
|
9159
|
+
headers: { authorization: `Bearer ${token}` }
|
|
9160
|
+
});
|
|
9161
|
+
if (response.status !== 200) throw new Error(`hub /health answered ${response.status}`);
|
|
9162
|
+
checks.push({ name: "hub", ok: true, detail: `listening on 127.0.0.1:${config.port}` });
|
|
9163
|
+
} catch (error) {
|
|
9164
|
+
checks.push({ name: "hub", ok: false, detail: `hub is not reachable on 127.0.0.1:${config.port}: ${message2(error)}` });
|
|
9165
|
+
return done();
|
|
9166
|
+
}
|
|
9167
|
+
const explicit = [...options.agent ?? [], ...options.claude ? ["claude"] : [], ...options.codex ? ["codex"] : []];
|
|
9168
|
+
const agents = (explicit.length ? explicit : [...SHARED_HUB_AGENTS]).filter((agent) => SHARED_HUB_AGENTS.includes(agent));
|
|
9169
|
+
for (const agent of agents) {
|
|
9170
|
+
const registrations = await readAgentRegistrations(agent, target.homeDir, void 0, target.codexHome).catch((error) => {
|
|
9171
|
+
checks.push({ name: "registration", ok: false, detail: `${agent}: ${message2(error)}` });
|
|
9172
|
+
return void 0;
|
|
9173
|
+
});
|
|
9174
|
+
if (!registrations) continue;
|
|
9175
|
+
const headers = hubHeaders(token, agent);
|
|
9176
|
+
for (const identity of config.identities) {
|
|
9177
|
+
const entry = registrations.find((registration) => registration.name === identity.name);
|
|
9178
|
+
const exact = entry?.url === mcpHubUrl(config.port, identity.name) && entry.headers?.Authorization === headers.Authorization;
|
|
9179
|
+
checks.push({
|
|
9180
|
+
name: "registration",
|
|
9181
|
+
ok: exact,
|
|
9182
|
+
detail: `${agent}: ${identity.name} ${exact ? "points at the hub with the current token" : "is missing, stdio, or uses a stale hub URL/token"}`
|
|
9183
|
+
});
|
|
9184
|
+
}
|
|
9185
|
+
}
|
|
9186
|
+
for (const identity of config.identities) {
|
|
9187
|
+
try {
|
|
9188
|
+
const detail = await dependencies.verifyIdentity(
|
|
9189
|
+
mcpHubUrl(config.port, identity.name),
|
|
9190
|
+
{ Authorization: `Bearer ${token}`, "X-Topolo-Agent": "topolo-doctor" }
|
|
9191
|
+
);
|
|
9192
|
+
checks.push({ name: "mcp", ok: true, detail: `${identity.name}: ${detail}` });
|
|
9193
|
+
} catch (error) {
|
|
9194
|
+
checks.push({ name: "mcp", ok: false, detail: `${identity.name}: ${message2(error)}` });
|
|
9195
|
+
}
|
|
9196
|
+
}
|
|
9197
|
+
return done();
|
|
9198
|
+
}
|
|
9199
|
+
function formatSharedDoctor(result) {
|
|
9200
|
+
return result.checks.map((check) => `${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}`).join("\n");
|
|
9201
|
+
}
|
|
9202
|
+
|
|
9203
|
+
// src/commands/doctor.ts
|
|
8053
9204
|
var defaultDependencies = {
|
|
8054
9205
|
resolveMcp: resolveMcpCommand,
|
|
8055
|
-
verifyFile: (path) =>
|
|
9206
|
+
verifyFile: (path) => access2(path),
|
|
8056
9207
|
verifyAuthentication: async (context, environment) => {
|
|
8057
9208
|
const topolo = await buildClient({ context, environment });
|
|
8058
9209
|
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 +9243,7 @@ async function runDoctor(dependencies = defaultDependencies, target) {
|
|
|
8092
9243
|
return { ok: checks.every((check) => check.ok), cliVersion: CLI_VERSION, checks };
|
|
8093
9244
|
}
|
|
8094
9245
|
async function verifyMcpProtocol(command, args, env = definedEnvironment(process.env)) {
|
|
8095
|
-
const client = new
|
|
9246
|
+
const client = new Client2({ name: "topolo-cli-doctor", version: CLI_VERSION });
|
|
8096
9247
|
const transport = new StdioClientTransport({ command, args, stderr: "pipe", env });
|
|
8097
9248
|
try {
|
|
8098
9249
|
await client.connect(transport);
|
|
@@ -8132,7 +9283,7 @@ async function resolveInstalledDoctorTarget(options) {
|
|
|
8132
9283
|
preflightChecks.push({
|
|
8133
9284
|
name: "registration",
|
|
8134
9285
|
ok: exact,
|
|
8135
|
-
detail: `${row.agent}: ${exact ? `${name} uses the bundled launcher` : `${name} is missing or does not use the bundled launcher`}`
|
|
9286
|
+
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
9287
|
});
|
|
8137
9288
|
}
|
|
8138
9289
|
const context = options.context !== void 0 ? normalizeDoctorContext(options.context) : selected?.env.TOPOLO_CONTEXT;
|
|
@@ -8150,9 +9301,16 @@ function definedEnvironment(environment) {
|
|
|
8150
9301
|
return Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== void 0));
|
|
8151
9302
|
}
|
|
8152
9303
|
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) => {
|
|
9304
|
+
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, command) => {
|
|
9305
|
+
opts = withProgramContext(opts, command);
|
|
8154
9306
|
const mode = resolveOutputMode(opts.json);
|
|
8155
9307
|
try {
|
|
9308
|
+
if (opts.shared) {
|
|
9309
|
+
const shared = await runSharedDoctor(opts, defaultSharedDoctorDependencies());
|
|
9310
|
+
if (!shared.ok) throw new Error(formatSharedDoctor(shared));
|
|
9311
|
+
emit(shared, mode, () => formatSharedDoctor(shared));
|
|
9312
|
+
return;
|
|
9313
|
+
}
|
|
8156
9314
|
const target = await resolveInstalledDoctorTarget(opts);
|
|
8157
9315
|
const result = await runDoctor(defaultDependencies, target);
|
|
8158
9316
|
if (!result.ok) throw new Error(formatDoctor(result));
|
|
@@ -8749,6 +9907,248 @@ function registerInstallationsCommand(program2) {
|
|
|
8749
9907
|
});
|
|
8750
9908
|
}
|
|
8751
9909
|
|
|
9910
|
+
// src/commands/mcp.ts
|
|
9911
|
+
import { createTopoloMcpLocalHandler } from "@topolo/mcp/local";
|
|
9912
|
+
|
|
9913
|
+
// src/lib/mcp-hub-server.ts
|
|
9914
|
+
import { createHash as createHash4, timingSafeEqual } from "crypto";
|
|
9915
|
+
import { stat as stat2 } from "fs/promises";
|
|
9916
|
+
import { createServer } from "http";
|
|
9917
|
+
var MCP_HUB_HOST = "127.0.0.1";
|
|
9918
|
+
var MCP_HUB_MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
9919
|
+
var DEFAULT_AGENT_LABEL = "topolo-mcp-hub";
|
|
9920
|
+
var AGENT_LABELS3 = /* @__PURE__ */ new Set([...Object.values(AGENT_NAME_FOR), "topolo-doctor"]);
|
|
9921
|
+
var STRIPPED_HEADERS = /* @__PURE__ */ new Set(["authorization", "x-api-key", "cookie", "host", "origin"]);
|
|
9922
|
+
function json(status, body, headers = {}) {
|
|
9923
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
|
|
9924
|
+
}
|
|
9925
|
+
function refusal(status, message3, headers = {}) {
|
|
9926
|
+
return json(status, { jsonrpc: "2.0", error: { code: -32e3, message: message3 }, id: null }, headers);
|
|
9927
|
+
}
|
|
9928
|
+
function digest(value) {
|
|
9929
|
+
return createHash4("sha256").update(value, "utf8").digest();
|
|
9930
|
+
}
|
|
9931
|
+
function bearerTokenMatches(header, token) {
|
|
9932
|
+
const match = /^Bearer[ \t]+(\S+)[ \t]*$/i.exec(header ?? "");
|
|
9933
|
+
if (!match) return false;
|
|
9934
|
+
return timingSafeEqual(digest(match[1]), digest(token));
|
|
9935
|
+
}
|
|
9936
|
+
function loopbackHosts(port) {
|
|
9937
|
+
return /* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
9938
|
+
}
|
|
9939
|
+
function loopbackOrigins(port) {
|
|
9940
|
+
return new Set([...loopbackHosts(port)].map((host) => `http://${host}`));
|
|
9941
|
+
}
|
|
9942
|
+
function agentLabel(value) {
|
|
9943
|
+
const label = value?.trim().toLowerCase() ?? "";
|
|
9944
|
+
return AGENT_LABELS3.has(label) ? label : DEFAULT_AGENT_LABEL;
|
|
9945
|
+
}
|
|
9946
|
+
function createMcpHubHandler(options) {
|
|
9947
|
+
const hosts = loopbackHosts(options.port);
|
|
9948
|
+
const origins = loopbackOrigins(options.port);
|
|
9949
|
+
const now = options.now ?? Date.now;
|
|
9950
|
+
const log = options.log ?? (() => void 0);
|
|
9951
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
9952
|
+
function handlerFor(identity, agentName) {
|
|
9953
|
+
const key = JSON.stringify([identity.name, identity.environment, identity.context, agentName]);
|
|
9954
|
+
let handler = handlers.get(key);
|
|
9955
|
+
if (!handler) {
|
|
9956
|
+
handler = options.createIdentityHandler(identity, agentName);
|
|
9957
|
+
handlers.set(key, handler);
|
|
9958
|
+
}
|
|
9959
|
+
return handler;
|
|
9960
|
+
}
|
|
9961
|
+
async function forward(request, url, identity) {
|
|
9962
|
+
const headers = new Headers();
|
|
9963
|
+
request.headers.forEach((value, name) => {
|
|
9964
|
+
if (!STRIPPED_HEADERS.has(name.toLowerCase())) headers.set(name, value);
|
|
9965
|
+
});
|
|
9966
|
+
const body = request.method === "GET" || request.method === "HEAD" ? void 0 : await request.arrayBuffer();
|
|
9967
|
+
const forwarded = new Request(`http://${MCP_HUB_HOST}:${options.port}${url.pathname}`, {
|
|
9968
|
+
method: request.method,
|
|
9969
|
+
headers,
|
|
9970
|
+
...body ? { body } : {}
|
|
9971
|
+
});
|
|
9972
|
+
return handlerFor(identity, agentLabel(request.headers.get("x-topolo-agent"))).fetch(forwarded);
|
|
9973
|
+
}
|
|
9974
|
+
async function route(request) {
|
|
9975
|
+
const host = request.headers.get("host")?.toLowerCase();
|
|
9976
|
+
if (!host || !hosts.has(host)) return refusal(403, "Host is not a loopback address for this hub.");
|
|
9977
|
+
const origin = request.headers.get("origin");
|
|
9978
|
+
if (origin !== null && !origins.has(origin.toLowerCase())) {
|
|
9979
|
+
return refusal(403, "Cross-origin requests to the Topolo MCP hub are refused.");
|
|
9980
|
+
}
|
|
9981
|
+
if (!bearerTokenMatches(request.headers.get("authorization"), options.token)) {
|
|
9982
|
+
return refusal(401, "Missing or invalid Topolo MCP hub token.", { "www-authenticate": 'Bearer realm="topolo-mcp-hub"' });
|
|
9983
|
+
}
|
|
9984
|
+
const url = new URL(request.url);
|
|
9985
|
+
let config;
|
|
9986
|
+
try {
|
|
9987
|
+
config = await options.loadConfig();
|
|
9988
|
+
} catch {
|
|
9989
|
+
return refusal(500, "The Topolo MCP hub configuration is invalid. Run `topolo doctor --shared`.");
|
|
9990
|
+
}
|
|
9991
|
+
if (url.pathname === "/health") {
|
|
9992
|
+
if (request.method !== "GET") return refusal(405, "Use GET for /health.", { allow: "GET" });
|
|
9993
|
+
return json(200, { ok: true, identities: config.identities.map((identity2) => identity2.name) });
|
|
9994
|
+
}
|
|
9995
|
+
const match = /^\/mcp\/([^/]+)$/.exec(url.pathname);
|
|
9996
|
+
const identity = match ? config.identities.find((entry) => entry.name === match[1]) : void 0;
|
|
9997
|
+
if (!identity) return refusal(404, "No Topolo MCP identity is mounted at this path.");
|
|
9998
|
+
try {
|
|
9999
|
+
return await forward(request, url, identity);
|
|
10000
|
+
} catch {
|
|
10001
|
+
return refusal(500, "The Topolo MCP identity failed unexpectedly.");
|
|
10002
|
+
}
|
|
10003
|
+
}
|
|
10004
|
+
return async (request) => {
|
|
10005
|
+
const started = now();
|
|
10006
|
+
const response = await route(request);
|
|
10007
|
+
log(`${request.method} ${new URL(request.url).pathname} ${response.status} ${now() - started}ms`);
|
|
10008
|
+
return response;
|
|
10009
|
+
};
|
|
10010
|
+
}
|
|
10011
|
+
function createMcpHubConfigLoader(path) {
|
|
10012
|
+
let cached;
|
|
10013
|
+
return async () => {
|
|
10014
|
+
let signature = "missing";
|
|
10015
|
+
try {
|
|
10016
|
+
const info = await stat2(path);
|
|
10017
|
+
signature = [info.ino, info.size, info.mtimeMs].join(":");
|
|
10018
|
+
} catch (error) {
|
|
10019
|
+
if (error.code !== "ENOENT") throw error;
|
|
10020
|
+
}
|
|
10021
|
+
if (cached?.signature === signature) return cached.config;
|
|
10022
|
+
const config = await readMcpHubConfig(path);
|
|
10023
|
+
cached = { signature, config };
|
|
10024
|
+
return config;
|
|
10025
|
+
};
|
|
10026
|
+
}
|
|
10027
|
+
async function readBody(request, limit) {
|
|
10028
|
+
const chunks = [];
|
|
10029
|
+
let size = 0;
|
|
10030
|
+
for await (const chunk of request) {
|
|
10031
|
+
size += chunk.length;
|
|
10032
|
+
if (size > limit) throw new Error("Request body exceeds the hub limit.");
|
|
10033
|
+
chunks.push(chunk);
|
|
10034
|
+
}
|
|
10035
|
+
return Buffer.concat(chunks);
|
|
10036
|
+
}
|
|
10037
|
+
function toWebRequest(request, body, port) {
|
|
10038
|
+
const headers = new Headers();
|
|
10039
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
10040
|
+
if (value === void 0) continue;
|
|
10041
|
+
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
10042
|
+
}
|
|
10043
|
+
return new Request(`http://${MCP_HUB_HOST}:${port}${request.url ?? "/"}`, {
|
|
10044
|
+
method: request.method ?? "GET",
|
|
10045
|
+
headers,
|
|
10046
|
+
...body && body.length ? { body } : {}
|
|
10047
|
+
});
|
|
10048
|
+
}
|
|
10049
|
+
async function writeWebResponse(response, target) {
|
|
10050
|
+
const headers = {};
|
|
10051
|
+
response.headers.forEach((value, name) => {
|
|
10052
|
+
headers[name] = value;
|
|
10053
|
+
});
|
|
10054
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
10055
|
+
target.writeHead(response.status, headers);
|
|
10056
|
+
target.end(body);
|
|
10057
|
+
}
|
|
10058
|
+
async function startMcpHub(options) {
|
|
10059
|
+
const server = createServer();
|
|
10060
|
+
await new Promise((resolve5, reject) => {
|
|
10061
|
+
server.once("error", reject);
|
|
10062
|
+
server.listen(options.port, MCP_HUB_HOST, () => {
|
|
10063
|
+
server.off("error", reject);
|
|
10064
|
+
resolve5();
|
|
10065
|
+
});
|
|
10066
|
+
});
|
|
10067
|
+
const port = server.address().port;
|
|
10068
|
+
const handle = createMcpHubHandler({ ...options, port });
|
|
10069
|
+
const limit = options.maxBodyBytes ?? MCP_HUB_MAX_BODY_BYTES;
|
|
10070
|
+
server.on("request", (request, response) => {
|
|
10071
|
+
void (async () => {
|
|
10072
|
+
let body;
|
|
10073
|
+
try {
|
|
10074
|
+
body = request.method === "GET" || request.method === "HEAD" ? void 0 : await readBody(request, limit);
|
|
10075
|
+
} catch {
|
|
10076
|
+
await writeWebResponse(refusal(413, "Request body is too large for the Topolo MCP hub."), response);
|
|
10077
|
+
request.resume();
|
|
10078
|
+
return;
|
|
10079
|
+
}
|
|
10080
|
+
let webRequest;
|
|
10081
|
+
try {
|
|
10082
|
+
webRequest = toWebRequest(request, body, port);
|
|
10083
|
+
} catch {
|
|
10084
|
+
await writeWebResponse(refusal(400, "Malformed request."), response);
|
|
10085
|
+
return;
|
|
10086
|
+
}
|
|
10087
|
+
await writeWebResponse(await handle(webRequest), response);
|
|
10088
|
+
})().catch(() => {
|
|
10089
|
+
response.destroy();
|
|
10090
|
+
});
|
|
10091
|
+
});
|
|
10092
|
+
return {
|
|
10093
|
+
port,
|
|
10094
|
+
server,
|
|
10095
|
+
close: () => new Promise((resolve5) => {
|
|
10096
|
+
server.close(() => resolve5());
|
|
10097
|
+
server.closeAllConnections();
|
|
10098
|
+
})
|
|
10099
|
+
};
|
|
10100
|
+
}
|
|
10101
|
+
|
|
10102
|
+
// src/commands/mcp.ts
|
|
10103
|
+
var createLocalIdentityHandler = (identity, agentName) => createTopoloMcpLocalHandler({
|
|
10104
|
+
identity: { environment: identity.environment, context: identity.context },
|
|
10105
|
+
agentName
|
|
10106
|
+
});
|
|
10107
|
+
var defaultServeDependencies = () => ({
|
|
10108
|
+
paths: () => mcpHubPaths(),
|
|
10109
|
+
start: startMcpHub,
|
|
10110
|
+
createIdentityHandler: createLocalIdentityHandler,
|
|
10111
|
+
log: (line) => process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} topolo mcp hub: ${line}
|
|
10112
|
+
`),
|
|
10113
|
+
onSignal: (signal, listener) => {
|
|
10114
|
+
process.once(signal, listener);
|
|
10115
|
+
},
|
|
10116
|
+
exit: (code) => process.exit(code)
|
|
10117
|
+
});
|
|
10118
|
+
async function serveMcpHub(options, dependencies) {
|
|
10119
|
+
const paths = dependencies.paths();
|
|
10120
|
+
const config = await readMcpHubConfig(paths.config);
|
|
10121
|
+
const port = options.port !== void 0 ? validateMcpHubPort(options.port) : config.port;
|
|
10122
|
+
const { token } = await ensureMcpHubToken(paths.token);
|
|
10123
|
+
const hub = await dependencies.start({
|
|
10124
|
+
port,
|
|
10125
|
+
token,
|
|
10126
|
+
loadConfig: createMcpHubConfigLoader(paths.config),
|
|
10127
|
+
createIdentityHandler: dependencies.createIdentityHandler,
|
|
10128
|
+
log: dependencies.log
|
|
10129
|
+
});
|
|
10130
|
+
dependencies.log(
|
|
10131
|
+
`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}`
|
|
10132
|
+
);
|
|
10133
|
+
const stop = () => {
|
|
10134
|
+
dependencies.log("stopping");
|
|
10135
|
+
void hub.close().then(() => dependencies.exit(0));
|
|
10136
|
+
};
|
|
10137
|
+
dependencies.onSignal("SIGINT", stop);
|
|
10138
|
+
dependencies.onSignal("SIGTERM", stop);
|
|
10139
|
+
return hub;
|
|
10140
|
+
}
|
|
10141
|
+
function registerMcpCommand(program2, dependencies = defaultServeDependencies) {
|
|
10142
|
+
const mcp = program2.command("mcp").description("Run the shared local Topolo MCP hub (one process for every agent thread and identity)");
|
|
10143
|
+
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) => {
|
|
10144
|
+
try {
|
|
10145
|
+
await serveMcpHub(opts, dependencies());
|
|
10146
|
+
} catch (error) {
|
|
10147
|
+
fail2(error, resolveOutputMode(false));
|
|
10148
|
+
}
|
|
10149
|
+
});
|
|
10150
|
+
}
|
|
10151
|
+
|
|
8752
10152
|
// src/index.ts
|
|
8753
10153
|
import { Agent, setGlobalDispatcher } from "undici";
|
|
8754
10154
|
setGlobalDispatcher(new Agent({ connect: { timeout: 3e4 } }));
|
|
@@ -8767,13 +10167,22 @@ try {
|
|
|
8767
10167
|
var environmentWasStated = Boolean(inheritedEnvironment) || environmentArgSeen;
|
|
8768
10168
|
program.name("topolo").description(
|
|
8769
10169
|
"Use Topolo from your terminal: sign in, find your apps, and run actions.\n\nQuick start (copy a command, then press Enter):\n topolo auth login # Sign in with your browser\n topolo whoami # See who you are signed in as\n topolo apps --no-json # List your apps as a readable table\n topolo auth --help # See sign-in commands\n topolo help apps # See app commands and options"
|
|
8770
|
-
).version(CLI_VERSION).option("--env <environment>", "Topolo environment: production, staging, development, or local").addHelpText("beforeAll", '\nEvery command starts with "topolo". Running "topolo" alone prints help; it does not open an interactive prompt.\n').addHelpText("afterAll", "\nHelp notation: [brackets] mean optional; <angle brackets> mark a value to replace. Do not type the brackets.");
|
|
10170
|
+
).version(CLI_VERSION).option("--env <environment>", "Topolo environment: production, staging, development, or local").option("--context <context>", "Topolo context to act as: personal, org:<id>, or a signed-in organization slug or name (or set TOPOLO_CONTEXT)").addHelpText("beforeAll", '\nEvery command starts with "topolo". Running "topolo" alone prints help; it does not open an interactive prompt.\n').addHelpText("afterAll", "\nHelp notation: [brackets] mean optional; <angle brackets> mark a value to replace. Do not type the brackets.");
|
|
8771
10171
|
program.hook("preAction", async (thisCommand, actionCommand) => {
|
|
8772
10172
|
const opts = thisCommand.opts();
|
|
8773
10173
|
if (opts.env) process.env.TOPOLO_ENV = normalizeEnvironment(opts.env);
|
|
10174
|
+
if (opts.context !== void 0) {
|
|
10175
|
+
const context = opts.context.trim();
|
|
10176
|
+
if (!context || /[\r\n]/.test(context)) {
|
|
10177
|
+
process.stderr.write("--context must be a non-empty single-line identifier.\n");
|
|
10178
|
+
process.exit(2);
|
|
10179
|
+
}
|
|
10180
|
+
process.env.TOPOLO_CONTEXT = context;
|
|
10181
|
+
}
|
|
8774
10182
|
if (opts.env || environmentWasStated) return;
|
|
8775
10183
|
for (let node = actionCommand; node; node = node.parent) {
|
|
8776
10184
|
if (node.name() === "agent-packs") return;
|
|
10185
|
+
if (node.name() === "mcp" && node.parent === program) return;
|
|
8777
10186
|
}
|
|
8778
10187
|
const authenticated = await authenticatedEnvironments();
|
|
8779
10188
|
if (authenticated.length < 2) return;
|
|
@@ -8796,6 +10205,7 @@ Or set it once for the whole session:
|
|
|
8796
10205
|
registerAuthCommands(program);
|
|
8797
10206
|
registerWhoamiCommand(program);
|
|
8798
10207
|
registerActionsCommand(program);
|
|
10208
|
+
registerCredentialsCommand(program);
|
|
8799
10209
|
registerApiCommand(program);
|
|
8800
10210
|
registerAppsCommand(program);
|
|
8801
10211
|
registerOAuthCommands(program);
|
|
@@ -8809,6 +10219,7 @@ registerResourcesCommand(program);
|
|
|
8809
10219
|
registerWorkspacesCommand(program);
|
|
8810
10220
|
registerInstallationsCommand(program);
|
|
8811
10221
|
registerAgentPacksCommand(program);
|
|
10222
|
+
registerMcpCommand(program);
|
|
8812
10223
|
var helpCommands = [program];
|
|
8813
10224
|
for (const command of helpCommands) {
|
|
8814
10225
|
command.configureHelp({
|