@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/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, message2) {
273
- super(message2);
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(message2, code = "auth_error", options = {}) {
395
- super(canonicalTopoloServiceErrorCode(code) ?? "auth_error", message2);
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(message2, required, options = {}) {
417
+ constructor(message3, required, options = {}) {
418
418
  super(
419
419
  canonicalTopoloServiceErrorCode(options.code) ?? serviceErrorCode(options.body, "permission_denied"),
420
- message2
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 message2 = sanitizeTopoloServiceMessage(candidate);
462
- if (message2) return message2;
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, message2) {
537
- const text3 = sanitizeTopoloServiceMessage(message2);
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 message2 of formErrors) {
558
- const projected = issue(void 0, message2);
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 message2 of list) {
568
- const projected = issue(path, message2);
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(message2) {
649
- super("confirmation_required", message2);
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, message2, options = {}) {
662
- super(serviceErrorCode(body, "http_error"), message2 ?? `HTTP ${status} from ${service}${path}`);
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, message2, options = {}) {
685
- super(service, path, 429, body, message2, options);
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, message2) {
1266
- super(`${path}: ${message2}`);
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 digest = await crypto.subtle.digest("SHA-256", bytes);
1384
- return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
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, message2) {
1464
- throw new ActorEnvelopeValidationError(path, message2);
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 message2 = describeError(parsed, `HTTP ${res.status}`);
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
- message2,
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
- message2,
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, message2) {
3906
- super(message2);
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(message2, code = "auth_error", options = {}) {
4028
- super(canonicalTopoloServiceErrorCode2(code) ?? "auth_error", message2);
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, message2) {
4127
- const text2 = sanitizeTopoloServiceMessage2(message2);
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 message2 of formErrors) {
4148
- const projected = issue2(void 0, message2);
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 message2 of list) {
4158
- const projected = issue2(path, message2);
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, message2, options = {}) {
4193
- super(serviceErrorCode2(body, "http_error"), message2 ?? `HTTP ${status} from ${service}${path}`);
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, message2, options = {}) {
4216
- super(service, path, 429, body, message2, options);
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(message2, reauthorization, options = {}) {
4225
- super(message2, "reauth_required", { status: 401, service: "auth", path: "/api/developer-oauth/token", ...options });
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 message2 = "Topolo sign-in renewal is temporarily rate limited by Auth. Retry after the Retry-After interval; the saved sign-in has been retained.";
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: message2 },
5236
- message2,
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").action(
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
- await writeConfig(writeContextCredential(updated, options.environment, context));
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: true,
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
- () => "\n \u2713 Signed in. Run `topolo whoami` to see your identity."
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
- const me = await topolo.identity.whoami();
5890
- const org = me.organization;
6012
+ identity = await topolo.identity.whoami();
6013
+ const org = identity.organization;
5891
6014
  if (org?.id) {
5892
6015
  return {
5893
- contextType: "organization",
5894
- organizationId: org.id,
5895
- organizationSlug: org.slug,
5896
- organizationName: org.name,
5897
- credential,
5898
- oauthClientId: clientId
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 context = options?.context ?? process.env.TOPOLO_CONTEXT?.trim();
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 : process.env.TOPOLO_CONTEXT?.trim() || profile.activeContext || 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 credential = await refreshCredentialIfNeeded(environment);
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 join5, relative } from "path";
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(join5(repoRoot, "package.json"), "utf8"));
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 = join5(dir, entry.name);
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 spawnSync4 } from "child_process";
7477
+ import { spawnSync as spawnSync5 } from "child_process";
6995
7478
  import { existsSync as existsSync6 } from "fs";
6996
- import { readFile as readFile6, rm as rm2 } from "fs/promises";
7479
+ import { readFile as readFile9, rm as rm3 } from "fs/promises";
6997
7480
  import { homedir as homedir5 } from "os";
6998
- import { dirname as dirname6, join as join7, resolve as resolve3 } from "path";
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 join6, resolve as resolve2 } from "path";
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() || !isAbsolute(codexHome))) {
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 ?? join6(homeDir, ".codex")
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 && isAbsolute(candidate) ? candidate : fallback;
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 ? join6(projectDir, ".claude", "skills", "topolo", "SKILL.md") : join6(homeDir, ".claude", "skills", "topolo", "SKILL.md");
7566
+ return projectDir ? join7(projectDir, ".claude", "skills", "topolo", "SKILL.md") : join7(homeDir, ".claude", "skills", "topolo", "SKILL.md");
7078
7567
  case "claude-desktop":
7079
- return join6(homeDir, ".claude", "skills", "topolo", "SKILL.md");
7568
+ return join7(homeDir, ".claude", "skills", "topolo", "SKILL.md");
7080
7569
  case "codex":
7081
- return join6(codexHome ?? join6(homeDir, ".codex"), "skills", "topolo", "SKILL.md");
7570
+ return join7(codexHome ?? join7(homeDir, ".codex"), "skills", "topolo", "SKILL.md");
7082
7571
  case "cursor":
7083
- return join6(projectDir ?? homeDir, ".cursor", "rules", "topolo.mdc");
7572
+ return join7(projectDir ?? homeDir, ".cursor", "rules", "topolo.mdc");
7084
7573
  case "grok":
7085
- return join6(projectDir ?? homeDir, ".grok", "skills", "topolo", "SKILL.md");
7574
+ return join7(projectDir ?? homeDir, ".grok", "skills", "topolo", "SKILL.md");
7086
7575
  case "gemini":
7087
- return join6(projectDir ?? homeDir, ".gemini", "skills", "topolo", "SKILL.md");
7576
+ return join7(projectDir ?? homeDir, ".gemini", "skills", "topolo", "SKILL.md");
7088
7577
  case "copilot":
7089
- return projectDir ? join6(projectDir, ".github", "skills", "topolo", "SKILL.md") : join6(homeDir, ".copilot", "skills", "topolo", "SKILL.md");
7578
+ return projectDir ? join7(projectDir, ".github", "skills", "topolo", "SKILL.md") : join7(homeDir, ".copilot", "skills", "topolo", "SKILL.md");
7090
7579
  case "windsurf":
7091
- return projectDir ? join6(projectDir, ".windsurf", "rules", "topolo.md") : void 0;
7580
+ return projectDir ? join7(projectDir, ".windsurf", "rules", "topolo.md") : void 0;
7092
7581
  case "cline":
7093
- return join6(projectDir ?? homeDir, ".cline", "skills", "topolo", "SKILL.md");
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 ? join6(projectDir, ".mcp.json") : join6(homeDir, ".claude.json"), format: "json", rootKey: "mcpServers" }];
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: join6(codexHome ?? join6(homeDir, ".codex"), "config.toml"), format: "toml" }];
7599
+ return [{ path: join7(codexHome ?? join7(homeDir, ".codex"), "config.toml"), format: "toml" }];
7111
7600
  case "cursor":
7112
7601
  return [{
7113
- path: projectDir ? join6(projectDir, ".cursor", "mcp.json") : join6(homeDir, ".cursor", "mcp.json"),
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 ? join6(projectDir, ".grok", "config.toml") : join6(homeDir, ".grok", "config.toml"),
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 ? join6(projectDir, ".gemini", "settings.json") : join6(homeDir, ".gemini", "settings.json"),
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: join6(projectDir, ".mcp.json"), format: "json", rootKey: "mcpServers" },
7131
- { path: join6(projectDir, ".vscode", "mcp.json"), format: "json", rootKey: "servers" }
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: join6(homeDir, ".copilot", "mcp-config.json"),
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: join6(homeDir, ".codeium", "windsurf", "mcp_config.json"),
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 ? join6(projectDir, ".cline", "mcp.json") : join6(homeDir, ".cline", "mcp.json"),
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: join6(homeDir, ".config", "goose", "config.yaml"), format: "yaml" }];
7642
+ return [{ path: join7(homeDir, ".config", "goose", "config.yaml"), format: "yaml" }];
7154
7643
  case "amazon-q":
7155
7644
  if (projectDir) {
7156
- return [{ path: join6(projectDir, ".amazonq", "mcp.json"), format: "json", rootKey: "mcpServers" }];
7645
+ return [{ path: join7(projectDir, ".amazonq", "mcp.json"), format: "json", rootKey: "mcpServers" }];
7157
7646
  }
7158
7647
  return [{
7159
- path: existsSync4(join6(homeDir, ".aws", "amazonq", "mcp.json")) ? join6(homeDir, ".aws", "amazonq", "mcp.json") : join6(homeDir, ".aws", "amazonq", "default.json"),
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 = join6(homeDir, ".claude.json");
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 join6(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json");
7224
- if (process.platform === "win32") return join6(process.env.APPDATA ?? join6(homeDir, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
7225
- return join6(process.env.XDG_CONFIG_HOME ?? join6(homeDir, ".config"), "Claude", "claude_desktop_config.json");
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 join6(homeDir, "Library", "Application Support", "Code", "User", "mcp.json");
7229
- if (process.platform === "win32") return join6(process.env.APPDATA ?? join6(homeDir, "AppData", "Roaming"), "Code", "User", "mcp.json");
7230
- return join6(process.env.XDG_CONFIG_HOME ?? join6(homeDir, ".config"), "Code", "User", "mcp.json");
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 && assignment[1] !== "command" && assignment[1] !== "args" && assignment[1] !== "env") continue;
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 = Object.fromEntries(Object.entries(value).filter(
7372
- (entry) => typeof entry[1] === "string"
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) ? Object.fromEntries(Object.entries(candidate.env).filter((entry) => typeof entry[1] === "string")) : {};
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 lstat2, mkdir as mkdir3, open, readFile as readFile5, rename as rename2, unlink, writeFile as writeFile2 } from "fs/promises";
7500
- import { randomUUID as randomUUID3 } from "crypto";
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
- ...registration.context ? { TOPOLO_CONTEXT: registration.context, TOPOLO_ENV: registration.environment } : {},
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
- const entry = {
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(stat2) {
7688
- return [stat2.dev, stat2.ino, stat2.size, stat2.mtimeMs, stat2.ctimeMs, stat2.mode, stat2.nlink].join(":");
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 stat2 = await lstat2(parent);
7695
- if (!stat2.isDirectory() || stat2.isSymbolicLink()) throw new Error("Host destination contains a symbolic link or non-directory at " + parent + ".");
7696
- parents.push({ path: parent, identity: [stat2.dev, stat2.ino].join(":") });
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 stat2 = await lstat2(path);
7704
- if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.nlink !== 1) throw new Error("Host configuration must be a single regular file at " + path + ".");
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 lstat2(path)) !== fileIdentity(stat2)) throw new Error("Host configuration changed while reading at " + path + ".");
7707
- return { contents, identity: fileIdentity(stat2), parents };
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 mkdir3(dirname5(path), { recursive: true });
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-" + randomUUID3() + ".tmp";
7727
- const handle = await open(temporaryPath, "wx", 384);
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 lstat2(temporaryPath);
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 rename2(temporaryPath, path);
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 lstat2(parent.path);
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 lstat2(temporaryPath);
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 environmentSection = "[mcp_servers." + name + ".env]";
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 === environmentSection) {
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 = dirname6(fileURLToPath3(import.meta.url));
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(join7(candidate, "topolo", "SKILL.md"))) return candidate;
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 = spawnSync4(process.platform === "win32" ? "where" : "which", [bin], {
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 && name === "topolo") {
7813
- throw new Error("Pinned contexts require --name topolo-<name> so the active topolo registration remains independent.");
7814
- }
7815
- if (name !== "topolo" && !context) {
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
- if (opts.withMcp && agent === "claude" && projectDir) {
8852
+ const registration = opts.registration;
8853
+ if (registration && agent === "claude" && projectDir) {
7838
8854
  try {
7839
- await assertClaudeProjectScope(homeDir, projectDir, opts.registration.name);
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 readFile6(join7(sourceDir, "topolo", "SKILL.md"), "utf8");
8860
+ const source = await readFile9(join11(sourceDir, "topolo", "SKILL.md"), "utf8");
7845
8861
  const skill = await installAgentSkill(agent, source, homeDir, projectDir, codexHome);
7846
- if (!opts.withMcp) {
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, opts.registration, projectDir, codexHome);
7851
- const installed = (await readAgentRegistrations(agent, homeDir, projectDir, codexHome)).find((registration) => registration.name === opts.registration.name);
7852
- const expectedEnvironment = registrationEnvironment(agent, opts.registration);
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 ${opts.registration.name} MCP registration.`);
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(join7(sourceDir, "topolo", "SKILL.md"))) {
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 resolveTargets({ ...opts, homeDir, project: projectDir, codexHome })) {
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 readFile6(join7(skillsSourceDir(), "topolo", "SKILL.md"), "utf8");
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 readFile6(skillPath, "utf8") : void 0;
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, ...name === "topolo" ? {} : { context: "uninstall-validation" } });
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 rm2(dirname6(skillPath), { recursive: true, force: true });
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) => access(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 Client({ name: "topolo-cli-doctor", version: CLI_VERSION });
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({