@topolo/cli 0.13.15 → 0.13.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,27 +74,50 @@ OAuth errors, and never extends the original device-code expiry.
74
74
  ### Contexts
75
75
 
76
76
  A user can act in their personal space or any organization they belong to. Each
77
- context has its own credential; the CLI (and the MCP) run in the active one.
77
+ context has its own credential. Name the one to act as with `--context <key>` or
78
+ `TOPOLO_CONTEXT`; without a pin the CLI (and the MCP) use the stored context only
79
+ while it is the only one signed in for the environment.
78
80
  These credentials are independent from the shared web preference: switching a
79
81
  CLI/MCP context never moves the browser session, and a web switch never changes
80
82
  an already-issued CLI/MCP token or its refresh chain.
81
83
 
82
84
  ```bash
83
- topolo context ls # personal + your orgs, and which is active
84
- topolo context use <organization|personal> # switch (validated + a fresh token minted server-side)
85
+ topolo context ls # personal + your orgs, and which is the stored default
86
+ topolo context use <organization|personal> # set the stored default (validated + a fresh token minted server-side)
87
+ topolo --context org:org_acme whoami # pin one command to one context
85
88
  ```
86
89
 
90
+ **No implicit default context.** Once several contexts are signed in for an
91
+ environment, every account command without `--context`/`TOPOLO_CONTEXT` refuses
92
+ with "the context is no longer safe to assume", exactly like the
93
+ multi-environment `--env` guard. `topolo context use` stays available for anyone
94
+ who wants a stored default, but nothing relies on it implicitly.
95
+
96
+ `topolo auth login` stores the approved sign-in under its own context key and
97
+ never moves the stored default (`--use` opts in; the environment's first sign-in
98
+ becomes the default). It prints which user and organization were approved. To
99
+ re-authorise one context, name it:
100
+
101
+ ```bash
102
+ topolo --env production --context org:org_acme auth login
103
+ ```
104
+
105
+ If the browser approves a different user or organization, the CLI warns that
106
+ `org:org_acme` was not renewed and exits 1.
107
+
87
108
  ### Wire into agent hosts
88
109
 
89
110
  ```bash
90
- topolo setup # detects installed local hosts and registers the exact bundled MCP
111
+ topolo setup # registers the exact bundled MCP, pinned to the only signed-in context
91
112
  topolo setup --all # configure every supported local host
92
113
  topolo doctor # verifies auth, handshake, tool discovery, and identity
93
114
  ```
94
115
 
95
116
  The MCP reads the same credential `topolo auth login` writes — no API key to paste.
96
- For concurrent agents pinned to different organizations, use an exact context
97
- key returned by `topolo context ls --json`:
117
+ Every registration setup writes pins `TOPOLO_ENV` and `TOPOLO_CONTEXT`. Without
118
+ `--context`, setup pins the only signed-in context, and refuses when there are
119
+ none or several. For each additional organization, use an exact context key
120
+ returned by `topolo context ls --json`:
98
121
 
99
122
  ```bash
100
123
  topolo setup --name topolo-acme --context org:org_acme --env production
@@ -133,12 +156,14 @@ topolo mcp serve [--port 7337] # what the keep-alive unit runs; foregrou
133
156
  `setup --shared`:
134
157
 
135
158
  1. Adds the identity (`{name, env, context}`, exactly what a stdio entry pins
136
- with `TOPOLO_ENV` + `TOPOLO_CONTEXT`; omit `--context` only for the default
137
- `topolo` identity that follows the active context) to `mcp-hub.json` next to
159
+ with `TOPOLO_ENV` + `TOPOLO_CONTEXT`; both are always pinned, and without
160
+ `--context` the only signed-in context is pinned) to `mcp-hub.json` next to
138
161
  the credential store (`~/.config/topolo/mcp-hub.json`, or the directory of
139
162
  `TOPOLO_CONFIG_PATH`). `--import-stdio` converts existing stdio entries and
140
- skips ones the hub cannot serve (private `TOPOLO_CONFIG_PATH` profiles,
141
- agent installations, invalid or conflicting definitions).
163
+ skips ones the hub cannot serve (entries with no `TOPOLO_CONTEXT` pin,
164
+ private `TOPOLO_CONFIG_PATH` profiles, agent installations, invalid or
165
+ conflicting definitions). Add a skipped unpinned entry back with
166
+ `--name <name> --context <context>`.
142
167
  2. Creates `mcp-hub.token` (256-bit, mode 0600) once and reuses it.
143
168
  3. Registers **every** hub identity with Claude Code and Codex at user scope,
144
169
  replacing same-name stdio entries. `--project` is refused so the token can
@@ -199,8 +224,9 @@ Hub behaviour and security:
199
224
  ## Cross-org isolation
200
225
 
201
226
  The CLI **never accepts an `orgId` argument**. Every command is scoped to the
202
- active context embedded in the credential (personal or a specific org); to act
203
- in a different organization you switch context with `topolo context use`, which
227
+ context embedded in the credential (personal or a specific org); to act
228
+ in a different organization you pin that context's own stored credential
229
+ (`--context`), or create one with `topolo context use`, which
204
230
  mints a new context-scoped credential — you can never target another org by
205
231
  passing an id. The context remains bound through refresh rotation and is not
206
232
  re-hydrated from the user's current web selection. This is enforced in two places:
package/dist/index.js CHANGED
@@ -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
  });
@@ -7129,13 +7254,14 @@ function createOAuthClientPayload(options) {
7129
7254
  }
7130
7255
  async function authFetchJson(path, init = {}) {
7131
7256
  const environment = selectedEnvironment();
7132
- 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);
7133
7260
  if (!credential) {
7134
7261
  const err = new Error("Topolo credentials are required.");
7135
7262
  err.code = "auth_error";
7136
7263
  throw err;
7137
7264
  }
7138
- const config = await readConfig();
7139
7265
  const profile = resolvedProfileConfig(config, environment);
7140
7266
  const authBaseUrl = (profile.serviceUrls?.auth ?? "https://auth.topolo.app").replace(/\/+$/, "");
7141
7267
  const headers = new Headers(init.headers);
@@ -7356,6 +7482,12 @@ import { dirname as dirname7, join as join11, resolve as resolve3 } from "path";
7356
7482
  import { fileURLToPath as fileURLToPath3 } from "url";
7357
7483
  import { createRequire } from "module";
7358
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
+
7359
7491
  // src/commands/agent-host.ts
7360
7492
  import { spawnSync as spawnSync3 } from "child_process";
7361
7493
  import { existsSync as existsSync4 } from "fs";
@@ -7939,7 +8071,10 @@ function registrationEnvironment(agent, registration) {
7939
8071
  TOPOLO_AGENT_NAME: AGENT_NAME_FOR[agent],
7940
8072
  ...registration.configPath ? { TOPOLO_CONFIG_PATH: registration.configPath } : {},
7941
8073
  ...registration.installationId ? { TOPOLO_AGENT_INSTALLATION_ID: registration.installationId } : {},
7942
- ...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 } : {},
7943
8078
  ...privateAliases
7944
8079
  };
7945
8080
  }
@@ -8211,14 +8346,13 @@ function validateMcpHubIdentity(value, where = "identity") {
8211
8346
  throw new Error(`MCP hub identity ${name} env must be one of ${MCP_HUB_ENVIRONMENTS.join(", ")}.`);
8212
8347
  }
8213
8348
  const context = record3.context;
8214
- if (context !== void 0 && (typeof context !== "string" || !context.trim() || /[\r\n]/.test(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)) {
8215
8353
  throw new Error(`MCP hub identity ${name} context must be a non-empty single-line selector.`);
8216
8354
  }
8217
- return {
8218
- name,
8219
- environment,
8220
- ...context !== void 0 ? { context: context.trim() } : {}
8221
- };
8355
+ return { name, environment, context: context.trim() };
8222
8356
  }
8223
8357
  function parseMcpHubConfig(text2, source) {
8224
8358
  let parsed;
@@ -8257,7 +8391,7 @@ function serializeMcpHubConfig(config) {
8257
8391
  identities: config.identities.map((identity) => ({
8258
8392
  name: identity.name,
8259
8393
  env: identity.environment,
8260
- ...identity.context ? { context: identity.context } : {}
8394
+ context: identity.context
8261
8395
  }))
8262
8396
  }, null, 2) + "\n";
8263
8397
  }
@@ -8500,12 +8634,21 @@ async function importStdioIdentities(agents, homeDir, codexHome) {
8500
8634
  skipped.push({ agent, name: registration.name, reason: "uses a private config path or agent installation; keep it on stdio" });
8501
8635
  continue;
8502
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
+ }
8503
8645
  let identity;
8504
8646
  try {
8505
8647
  identity = validateMcpHubIdentity({
8506
8648
  name: registration.name,
8649
+ // A stdio entry without TOPOLO_ENV has always meant production.
8507
8650
  env: registration.env.TOPOLO_ENV || "production",
8508
- ...registration.env.TOPOLO_CONTEXT ? { context: registration.env.TOPOLO_CONTEXT } : {}
8651
+ context: registration.env.TOPOLO_CONTEXT
8509
8652
  });
8510
8653
  } catch (error) {
8511
8654
  skipped.push({ agent, name: registration.name, reason: error.message });
@@ -8547,16 +8690,9 @@ async function installSharedAgentSurfaces(options, dependencies) {
8547
8690
  }
8548
8691
  if (!options.importStdio || options.name) {
8549
8692
  const name = options.name?.trim() || "topolo";
8550
- const context = options.context?.trim();
8551
- if (name !== "topolo" && !context) throw new Error("Named registrations require --context <context>.");
8552
- if (name === "topolo" && context) {
8553
- throw new Error("Pinned contexts require --name topolo-<name> so the active topolo registration remains independent.");
8554
- }
8555
- config = upsertMcpHubIdentity(config, validateMcpHubIdentity({
8556
- name,
8557
- env: options.environment?.trim() || dependencies.environment.TOPOLO_ENV || "production",
8558
- ...context ? { context } : {}
8559
- }));
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 }));
8560
8696
  }
8561
8697
  await writeMcpHubConfig(paths.config, config);
8562
8698
  const { token, created } = await ensureMcpHubToken(paths.token);
@@ -8635,7 +8771,8 @@ function defaultSharedSetupDependencies(skillsDir, realHome) {
8635
8771
  nodeCommand: stableNodeCommand,
8636
8772
  cliEntry: () => resolveCliEntry(),
8637
8773
  environment: process.env,
8638
- run: runCommand
8774
+ run: runCommand,
8775
+ readCredentialStore: readConfig
8639
8776
  };
8640
8777
  }
8641
8778
  function formatSharedSetup(result) {
@@ -8688,11 +8825,10 @@ function resolveMcpRegistration(options) {
8688
8825
  if (name !== "topolo" && !/^topolo-[a-z0-9][a-z0-9_-]*$/.test(name)) {
8689
8826
  throw new Error("Named registrations must use a topolo-<name> identifier.");
8690
8827
  }
8691
- if (context && name === "topolo") {
8692
- throw new Error("Pinned contexts require --name topolo-<name> so the active topolo registration remains independent.");
8693
- }
8694
- if (name !== "topolo" && !context) {
8695
- 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
+ );
8696
8832
  }
8697
8833
  if (context && /[\r\n]/.test(context)) throw new Error("Context must be a single-line identifier.");
8698
8834
  if (!["production", "staging", "development", "local"].includes(environment)) {
@@ -8713,24 +8849,25 @@ function resolveMcpRegistration(options) {
8713
8849
  }
8714
8850
  async function installForAgent(agent, sourceDir, mcp, opts) {
8715
8851
  const { homeDir, projectDir, codexHome } = opts.target;
8716
- if (opts.withMcp && agent === "claude" && projectDir) {
8852
+ const registration = opts.registration;
8853
+ if (registration && agent === "claude" && projectDir) {
8717
8854
  try {
8718
- await assertClaudeProjectScope(homeDir, projectDir, opts.registration.name);
8855
+ await assertClaudeProjectScope(homeDir, projectDir, registration.name);
8719
8856
  } catch (err) {
8720
8857
  return { agent, skillInstalled: false, mcp: "failed", detail: err instanceof Error ? err.message : String(err) };
8721
8858
  }
8722
8859
  }
8723
8860
  const source = await readFile9(join11(sourceDir, "topolo", "SKILL.md"), "utf8");
8724
8861
  const skill = await installAgentSkill(agent, source, homeDir, projectDir, codexHome);
8725
- if (!opts.withMcp) {
8862
+ if (!registration) {
8726
8863
  return { agent, skillPath: skill.path, skillInstalled: skill.installed, mcp: "skipped" };
8727
8864
  }
8728
8865
  try {
8729
- await registerAgentConfig(agent, mcp, homeDir, opts.registration, projectDir, codexHome);
8730
- const installed = (await readAgentRegistrations(agent, homeDir, projectDir, codexHome)).find((registration) => registration.name === opts.registration.name);
8731
- 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);
8732
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)) {
8733
- 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.`);
8734
8871
  }
8735
8872
  return { agent, skillPath: skill.path, skillInstalled: skill.installed, mcp: "config-file" };
8736
8873
  } catch (err) {
@@ -8767,7 +8904,8 @@ function resolveTargets(opts) {
8767
8904
  function registerSkillsCommand(program2) {
8768
8905
  const skills = program2.command("skills").description("Install the Topolo agent skill + MCP server into supported agent hosts");
8769
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(
8770
- async (opts) => {
8907
+ async (opts, command) => {
8908
+ opts = withProgramContext(opts, command);
8771
8909
  const mode = resolveOutputMode(opts.json);
8772
8910
  try {
8773
8911
  const { results, mcp } = await installAgentSurfaces({
@@ -8821,13 +8959,13 @@ async function installAgentSurfaces(opts) {
8821
8959
  throw new Error("Bundled skill file not found. Reinstall the CLI: npm install -g @topolo/cli");
8822
8960
  }
8823
8961
  const mcp = resolveMcpCommand();
8824
- const registration = resolveMcpRegistration(opts);
8825
8962
  const target = resolveAgentHostTarget({ homeDir: opts.homeDir, projectDir: opts.project, codexHome: opts.codexHome });
8826
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));
8827
8966
  const results = [];
8828
- for (const agent of resolveTargets({ ...opts, homeDir, project: projectDir, codexHome })) {
8967
+ for (const agent of agents) {
8829
8968
  results.push(await installForAgent(agent, sourceDir, mcp, {
8830
- withMcp: opts.mcp !== false,
8831
8969
  target,
8832
8970
  registration
8833
8971
  }));
@@ -8835,7 +8973,8 @@ async function installAgentSurfaces(opts) {
8835
8973
  return { results, mcp };
8836
8974
  }
8837
8975
  function registerSetupCommand(program2) {
8838
- program2.command("setup").description("Install the Topolo skill and deterministic local MCP server for supported agent hosts").option("--claude", "Target Claude Code only").option("--codex", "Target Codex only").option("--agent <host...>", "Target one or more hosts by id").option("--all", "Configure every supported local host").option("--project <dir>", "Write project-scoped host files under <dir> where supported").option("--codex-home <dir>", "Use an explicit native Codex home instead of <agent-home>/.codex").option("--agent-home <dir>", "Target an alternate agent home (managed installs and clean-room verification)").option("--name <name>", "Register a named pinned MCP, such as topolo-acme").option("--context <context>", "Pin the named MCP to an exact context returned by topolo context ls").option("--installation <id>", "Bind the MCP runtime to a registered external-agent installation").option("--shared", "Opt in: register Claude Code and Codex against the shared local MCP hub (one process) instead of stdio").option("--port <port>", "With --shared: hub port on 127.0.0.1 (default 7337)").option("--import-stdio", "With --shared: also move every existing stdio topolo/topolo-* registration onto the hub").option("--no-service", "With --shared: do not install the launchd/systemd keep-alive unit").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
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);
8839
8978
  const mode = resolveOutputMode(opts.json);
8840
8979
  try {
8841
8980
  if (opts.shared) {
@@ -8905,7 +9044,7 @@ async function uninstallAgentSurfaces(options) {
8905
9044
  const target = resolveAgentHostTarget({ homeDir: options.homeDir, projectDir: options.project, codexHome: options.codexHome });
8906
9045
  const { homeDir, projectDir, codexHome } = target;
8907
9046
  const name = options.name?.trim() || "topolo";
8908
- resolveMcpRegistration({ name, ...name === "topolo" ? {} : { context: "uninstall-validation" } });
9047
+ resolveMcpRegistration({ name, context: "uninstall-validation" });
8909
9048
  const removed = [];
8910
9049
  for (const agent of resolveTargets({ ...options, homeDir, project: projectDir, codexHome })) {
8911
9050
  const before = await readAgentRegistrations(agent, homeDir, projectDir, codexHome);
@@ -9162,7 +9301,8 @@ function definedEnvironment(environment) {
9162
9301
  return Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== void 0));
9163
9302
  }
9164
9303
  function registerDoctorCommand(program2) {
9165
- program2.command("doctor").description("Verify installed agent surfaces, exact credential, and MCP protocol end to end").option("--name <name>", "Verify one installed MCP registration (defaults to topolo)").option("--context <context>", "Verify one exact personal or organization context").option("--environment <env>", "Override the context environment").option("--claude", "Verify Claude Code only").option("--codex", "Verify Codex only").option("--agent <host...>", "Verify one or more hosts by id").option("--all", "Verify every supported local host").option("--project <dir>", "Inspect project-scoped host files under <dir>").option("--agent-home <dir>", "Inspect an alternate agent home").option("--shared", "Verify the shared local MCP hub end to end for every hub identity (or --name)").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
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);
9166
9306
  const mode = resolveOutputMode(opts.json);
9167
9307
  try {
9168
9308
  if (opts.shared) {
@@ -9810,7 +9950,7 @@ function createMcpHubHandler(options) {
9810
9950
  const log = options.log ?? (() => void 0);
9811
9951
  const handlers = /* @__PURE__ */ new Map();
9812
9952
  function handlerFor(identity, agentName) {
9813
- const key = JSON.stringify([identity.name, identity.environment, identity.context ?? null, agentName]);
9953
+ const key = JSON.stringify([identity.name, identity.environment, identity.context, agentName]);
9814
9954
  let handler = handlers.get(key);
9815
9955
  if (!handler) {
9816
9956
  handler = options.createIdentityHandler(identity, agentName);
@@ -9961,7 +10101,7 @@ async function startMcpHub(options) {
9961
10101
 
9962
10102
  // src/commands/mcp.ts
9963
10103
  var createLocalIdentityHandler = (identity, agentName) => createTopoloMcpLocalHandler({
9964
- identity: { environment: identity.environment, context: identity.context ?? null },
10104
+ identity: { environment: identity.environment, context: identity.context },
9965
10105
  agentName
9966
10106
  });
9967
10107
  var defaultServeDependencies = () => ({
@@ -10027,10 +10167,18 @@ try {
10027
10167
  var environmentWasStated = Boolean(inheritedEnvironment) || environmentArgSeen;
10028
10168
  program.name("topolo").description(
10029
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"
10030
- ).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.");
10031
10171
  program.hook("preAction", async (thisCommand, actionCommand) => {
10032
10172
  const opts = thisCommand.opts();
10033
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
+ }
10034
10182
  if (opts.env || environmentWasStated) return;
10035
10183
  for (let node = actionCommand; node; node = node.parent) {
10036
10184
  if (node.name() === "agent-packs") return;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@topolo/cli",
3
3
  "packageManager": "pnpm@10.17.1",
4
- "version": "0.13.15",
5
- "topoloPublishedVersion": "0.13.15",
4
+ "version": "0.13.16",
5
+ "topoloPublishedVersion": "0.13.16",
6
6
  "description": "Public command-line interface for Topolo customer organizations, developers, and agents.",
7
7
  "license": "UNLICENSED",
8
8
  "author": "Topolo",
@@ -67,8 +67,16 @@ TOPOLO_CONTEXT=org:org_acme topolo --env production whoami --json
67
67
  TOPOLO_CONTEXT=org:org_acme topolo --env production actions --service brand --json
68
68
  ```
69
69
 
70
- The CLI keeps this pin through credential refresh and never changes the shared
71
- active context. A missing pinned credential does not select another context.
70
+ `topolo --context <key> <command>` pins one command the same way. The CLI keeps
71
+ this pin through credential refresh and never changes the stored default
72
+ context. A missing pinned credential does not select another context.
73
+
74
+ There is no implicit default: when several contexts are signed in for an
75
+ environment, any command or MCP without `--context`/`TOPOLO_CONTEXT` refuses
76
+ ("the context is no longer safe to assume"). Pin the context; do not run
77
+ `topolo context use` to work around the refusal. `topolo auth login` never moves
78
+ the stored default and prints which user and organization were approved; check
79
+ that line, because the browser may approve a different account than intended.
72
80
 
73
81
  For concurrent agents or multiple MCP connections, never coordinate by changing
74
82
  one shared active context. Register each pinned context through the CLI so it
@@ -79,6 +87,8 @@ topolo setup --name topolo-acme --context org:org_acme --env production
79
87
  topolo doctor --name topolo-acme
80
88
  ```
81
89
 
90
+ Every registration setup writes pins `TOPOLO_ENV` and `TOPOLO_CONTEXT`; without
91
+ `--context` it pins the only signed-in context and refuses when there are several.
82
92
  The context selector accepts an
83
93
  exact key returned by `topolo context ls --json` (such as `personal` or
84
94
  `org:<orgId>`) or a unique stored organization id, slug, or name. Each process