@topolo/cli 0.8.2 → 0.8.4

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
@@ -1,16 +1,15 @@
1
1
  # TopoloCli
2
2
 
3
3
  Public customer command-line interface for Topolo organizations, developers,
4
- and agents. First-party engineering, seed, scaffold, audit, provisioning,
5
- deployment, and release commands belong to the restricted `topolo-platform`
6
- toolchain and are deliberately absent.
4
+ and agents. Internal engineering and operations commands are deliberately
5
+ absent.
7
6
 
8
7
  ## Current scope
9
8
 
10
9
  - API-key authentication (long-lived, service-scoped).
11
10
  - OAuth device-flow sign-in with a **built-in first-party client** — `topolo auth login` needs no client id; any Topolo user can sign in.
12
11
  - Stored access-token refresh; the `@topolo/mcp` server reads the same credential, so agents need no API-key plumbing.
13
- - First-class `production`, `staging`, `development`, and `local` environments with separate stored credentials and service URL overrides.
12
+ - Production credentials by default, with isolated profiles for approved self-hosted environments.
14
13
  - **Per-context credentials** — hold and switch between your personal space and multiple organizations with `topolo context`.
15
14
  - **One-command agent setup** — `topolo setup` wires the bundled compatible skill + MCP into Claude Code / Codex.
16
15
  - **End-to-end diagnostics** — `topolo doctor` proves authentication and the MCP protocol path.
@@ -28,19 +27,12 @@ npx @topolo/cli whoami
28
27
 
29
28
  Credentials come from (in priority order):
30
29
 
31
- 1. Environment-specific vars such as `TOPOLO_STAGING_API_KEY` or `TOPOLO_PRODUCTION_ACCESS_TOKEN`
30
+ 1. Environment-specific credential variables
32
31
  2. Global vars `TOPOLO_API_KEY` or `TOPOLO_ACCESS_TOKEN`
33
32
  3. The selected environment profile in the config file (`$XDG_CONFIG_HOME/topolo/config.json`, mode `0600`)
34
33
 
35
- Production is the default. Select staging with `--env staging` anywhere in the command, or set `TOPOLO_ENV=staging`:
36
-
37
- ```bash
38
- topolo auth status --env staging --json
39
- topolo auth login --env staging
40
- topolo whoami --env staging --json
41
- ```
42
-
43
- The staging profile uses `auth.stg.topolo.us`, `developers.stg.topolo.us`, and `seed.stg.topolo.us` by default. Other SDK service calls in staging must have an explicit staging URL override; the CLI refuses to call a production SDK URL while `--env staging` is selected.
34
+ Production is the default customer environment. Use another environment only
35
+ when your Topolo administrator provides its complete service configuration.
44
36
 
45
37
  For interactive use:
46
38
 
@@ -83,12 +75,12 @@ key returned by `topolo context ls --json`:
83
75
 
84
76
  ```bash
85
77
  topolo setup --name topolo-acme --context org:org_acme --env production
86
- topolo doctor --context org:org_acme --env production
78
+ topolo doctor --name topolo-acme
87
79
  ```
88
80
 
89
81
  This creates separately named Claude Code and Codex registrations using the
90
- same exact nested MCP executable. `topolo-platform` is reserved for the
91
- restricted operator toolchain.
82
+ same exact nested MCP executable. Reserved operator registrations cannot be
83
+ created or removed by the public CLI.
92
84
 
93
85
  ## Cross-org isolation
94
86
 
@@ -225,8 +217,8 @@ topolo apps get mail --json
225
217
  ```
226
218
 
227
219
  Only live, credential-scoped applications are returned. The public CLI contains
228
- no first-party repository metadata or compiled fallback catalog. Internal app
229
- creation and conformance workflows use `topolo-platform apps`.
220
+ no first-party repository metadata or compiled fallback catalog. Internal
221
+ application lifecycle workflows are outside this customer CLI.
230
222
 
231
223
  ### `topolo oauth clients`
232
224
 
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { platform as platform2 } from "os";
9
9
  import { createInterface } from "readline/promises";
10
10
  import { stdin as input } from "process";
11
11
 
12
- // ../../../public-platform-tooling-boundary-20260813/packages/topolo-sdk/dist/index.js
12
+ // node_modules/@topolo/sdk/dist/index.js
13
13
  function applyAuthHeaders(headers, credential) {
14
14
  if (credential.kind === "api_key") {
15
15
  headers.set("X-Api-Key", credential.apiKey);
@@ -30,8 +30,8 @@ function generateRequestId() {
30
30
  }
31
31
  var TopoloSdkError = class extends Error {
32
32
  code;
33
- constructor(code, message) {
34
- super(message);
33
+ constructor(code, message2) {
34
+ super(message2);
35
35
  this.name = "TopoloSdkError";
36
36
  this.code = code;
37
37
  }
@@ -128,8 +128,8 @@ var TopoloAuthError = class extends TopoloSdkError {
128
128
  requestId;
129
129
  retryAfter;
130
130
  details;
131
- constructor(message, code = "auth_error", options = {}) {
132
- super(canonicalTopoloErrorCode(code) ?? "auth_error", message);
131
+ constructor(message2, code = "auth_error", options = {}) {
132
+ super(canonicalTopoloErrorCode(code) ?? "auth_error", message2);
133
133
  this.name = "TopoloAuthError";
134
134
  this.status = options.status ?? 401;
135
135
  this.body = options.body;
@@ -151,10 +151,10 @@ var TopoloPermissionError = class extends TopoloSdkError {
151
151
  requestId;
152
152
  retryAfter;
153
153
  details;
154
- constructor(message, required, options = {}) {
154
+ constructor(message2, required, options = {}) {
155
155
  super(
156
156
  canonicalTopoloErrorCode(options.code) ?? serviceErrorCode(options.body, "permission_denied"),
157
- message
157
+ message2
158
158
  );
159
159
  this.name = "TopoloPermissionError";
160
160
  this.required = canonicalTopoloRequired(required);
@@ -286,8 +286,8 @@ function topoloErrorHint(error) {
286
286
  return void 0;
287
287
  }
288
288
  var TopoloConfirmationRequiredError = class extends TopoloSdkError {
289
- constructor(message) {
290
- super("confirmation_required", message);
289
+ constructor(message2) {
290
+ super("confirmation_required", message2);
291
291
  this.name = "TopoloConfirmationRequiredError";
292
292
  }
293
293
  };
@@ -299,8 +299,8 @@ var TopoloHttpError = class extends TopoloSdkError {
299
299
  requestId;
300
300
  retryAfter;
301
301
  details;
302
- constructor(service, path, status, body, message, options = {}) {
303
- super(serviceErrorCode(body, "http_error"), message ?? `HTTP ${status} from ${service}${path}`);
302
+ constructor(service, path, status, body, message2, options = {}) {
303
+ super(serviceErrorCode(body, "http_error"), message2 ?? `HTTP ${status} from ${service}${path}`);
304
304
  this.name = "TopoloHttpError";
305
305
  this.status = status;
306
306
  this.body = body;
@@ -764,6 +764,18 @@ function prepareTopoloUrlSessionUploadInput(contractValue, byteLength, input2, o
764
764
  function topoloUploadResponseValue(response, path) {
765
765
  return path.split(".").reduce((value, segment) => isRecord2(value) ? value[segment] : void 0, response);
766
766
  }
767
+ function redactTopoloUrlSessionResult(response, uploadUrlPath) {
768
+ const redacted = cloneUploadValue(response);
769
+ let target = redacted;
770
+ const segments = uploadUrlPath.split(".");
771
+ for (const segment of segments.slice(0, -1)) {
772
+ target = isRecord2(target) ? target[segment] : void 0;
773
+ }
774
+ const finalSegment = segments.at(-1);
775
+ if (finalSegment && isRecord2(target) && finalSegment in target) target[finalSegment] = "[REDACTED]";
776
+ redactUploadHeaders(redacted);
777
+ return redacted;
778
+ }
767
779
  function decodeTopoloActionBase64(contentBase64) {
768
780
  if (!isCanonicalBase64(contentBase64)) {
769
781
  throw new TopoloSdkError("invalid_upload_content", "Upload content must be canonical base64.");
@@ -782,6 +794,28 @@ function mapUploadInput(contract, contentBase64, input2, options) {
782
794
  [contract.fileNameField]: fileName
783
795
  };
784
796
  }
797
+ function cloneUploadValue(value) {
798
+ if (Array.isArray(value)) return value.map(cloneUploadValue);
799
+ if (!isRecord2(value)) return value;
800
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneUploadValue(item)]));
801
+ }
802
+ function redactUploadHeaders(value) {
803
+ if (Array.isArray(value)) {
804
+ value.forEach(redactUploadHeaders);
805
+ return;
806
+ }
807
+ if (!isRecord2(value)) return;
808
+ for (const [key, item] of Object.entries(value)) {
809
+ if (key.toLowerCase() === "headers" && isRecord2(item)) {
810
+ for (const header of Object.keys(item)) {
811
+ if (["authorization", "cookie", "set-cookie", "proxy-authorization", "x-api-key"].includes(header.toLowerCase())) {
812
+ item[header] = "[REDACTED]";
813
+ }
814
+ }
815
+ }
816
+ redactUploadHeaders(item);
817
+ }
818
+ }
785
819
  function validateUploadMetadata(contract, byteLength, options) {
786
820
  const fileName = options.fileName.trim();
787
821
  const contentType = options.contentType.trim().toLowerCase();
@@ -999,7 +1033,7 @@ var TopoloClient = class _TopoloClient {
999
1033
  const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
1000
1034
  if (!res.ok) {
1001
1035
  const durationMs = Date.now() - startedAt;
1002
- const message = describeError(parsed, `HTTP ${res.status}`);
1036
+ const message2 = describeError(parsed, `HTTP ${res.status}`);
1003
1037
  const publicCode = extractTopoloServiceErrorCode(parsed) ?? "http_error";
1004
1038
  this.emit({
1005
1039
  phase: "error",
@@ -1039,7 +1073,7 @@ var TopoloClient = class _TopoloClient {
1039
1073
  opts.path,
1040
1074
  res.status,
1041
1075
  parsed,
1042
- message,
1076
+ message2,
1043
1077
  errorContext
1044
1078
  );
1045
1079
  }
@@ -1423,7 +1457,7 @@ var TopoloClient = class _TopoloClient {
1423
1457
  throw new TopoloSdkError("invalid_upload_response", `Upload action response is missing ${contract.uploadUrlPath}.`);
1424
1458
  }
1425
1459
  await this.putUrlSessionContent(entry, uploadUrl, contract.uploadMethod, bytes, options);
1426
- if (!contract.complete) return initiation;
1460
+ if (!contract.complete) return redactTopoloUrlSessionResult(initiation, contract.uploadUrlPath);
1427
1461
  const completionValue = topoloUploadResponseValue(initiation, contract.complete.responsePath);
1428
1462
  if (typeof completionValue !== "string" || !completionValue.trim()) {
1429
1463
  throw new TopoloSdkError("invalid_upload_response", `Upload action response is missing ${contract.complete.responsePath}.`);
@@ -2253,15 +2287,15 @@ function createTopolo(options) {
2253
2287
  };
2254
2288
  }
2255
2289
 
2256
- // ../../../public-platform-tooling-boundary-20260813/packages/topolo-sdk/dist/node/credential-store.js
2257
- import { mkdir, readFile, writeFile, chmod } from "fs/promises";
2290
+ // node_modules/@topolo/sdk/dist/node/credential-store.js
2291
+ import { mkdir, readFile, writeFile, chmod, rm, stat } from "fs/promises";
2258
2292
  import { existsSync } from "fs";
2259
2293
  import { homedir, platform } from "os";
2260
2294
  import { dirname, join } from "path";
2261
2295
  var TopoloSdkError2 = class extends Error {
2262
2296
  code;
2263
- constructor(code, message) {
2264
- super(message);
2297
+ constructor(code, message2) {
2298
+ super(message2);
2265
2299
  this.name = "TopoloSdkError";
2266
2300
  this.code = code;
2267
2301
  }
@@ -2358,8 +2392,8 @@ var TopoloAuthError2 = class extends TopoloSdkError2 {
2358
2392
  requestId;
2359
2393
  retryAfter;
2360
2394
  details;
2361
- constructor(message, code = "auth_error", options = {}) {
2362
- super(canonicalTopoloErrorCode2(code) ?? "auth_error", message);
2395
+ constructor(message2, code = "auth_error", options = {}) {
2396
+ super(canonicalTopoloErrorCode2(code) ?? "auth_error", message2);
2363
2397
  this.name = "TopoloAuthError";
2364
2398
  this.status = options.status ?? 401;
2365
2399
  this.body = options.body;
@@ -2451,8 +2485,8 @@ var TopoloHttpError2 = class extends TopoloSdkError2 {
2451
2485
  requestId;
2452
2486
  retryAfter;
2453
2487
  details;
2454
- constructor(service, path, status, body, message, options = {}) {
2455
- super(serviceErrorCode2(body, "http_error"), message ?? `HTTP ${status} from ${service}${path}`);
2488
+ constructor(service, path, status, body, message2, options = {}) {
2489
+ super(serviceErrorCode2(body, "http_error"), message2 ?? `HTTP ${status} from ${service}${path}`);
2456
2490
  this.name = "TopoloHttpError";
2457
2491
  this.status = status;
2458
2492
  this.body = body;
@@ -2755,6 +2789,41 @@ async function resolveCredential(environment = selectedEnvironment()) {
2755
2789
  return profileForEnvironment(config, environment).credential ?? null;
2756
2790
  }
2757
2791
  var REFRESH_SKEW_SECONDS = 60;
2792
+ var CONFIG_LOCK_RETRY_MS = 25;
2793
+ var CONFIG_LOCK_TIMEOUT_MS = 5e3;
2794
+ var CONFIG_LOCK_STALE_MS = 12e4;
2795
+ async function withConfigWriteLock(operation) {
2796
+ const lockPath = `${configPath()}.lock`;
2797
+ await mkdir(dirname(lockPath), { recursive: true });
2798
+ const startedAt = Date.now();
2799
+ while (true) {
2800
+ try {
2801
+ await mkdir(lockPath);
2802
+ break;
2803
+ } catch (error) {
2804
+ if (error.code !== "EEXIST") throw error;
2805
+ try {
2806
+ const lock = await stat(lockPath);
2807
+ if (Date.now() - lock.mtimeMs > CONFIG_LOCK_STALE_MS) {
2808
+ await rm(lockPath, { recursive: true, force: true });
2809
+ continue;
2810
+ }
2811
+ } catch (inspectionError) {
2812
+ if (inspectionError.code !== "ENOENT") throw inspectionError;
2813
+ continue;
2814
+ }
2815
+ if (Date.now() - startedAt >= CONFIG_LOCK_TIMEOUT_MS) {
2816
+ throw new Error(`Timed out waiting for credential configuration lock at ${lockPath}.`);
2817
+ }
2818
+ await new Promise((resolve3) => setTimeout(resolve3, CONFIG_LOCK_RETRY_MS));
2819
+ }
2820
+ }
2821
+ try {
2822
+ return await operation();
2823
+ } finally {
2824
+ await rm(lockPath, { recursive: true, force: true });
2825
+ }
2826
+ }
2758
2827
  async function refreshCredentialIfNeeded(environment = selectedEnvironment(), options = {}) {
2759
2828
  const prefix = envVarPrefix(environment);
2760
2829
  if (process.env[`${prefix}_API_KEY`] || process.env[`${prefix}_ACCESS_TOKEN`] || environment === "production" && process.env.TOPOLO_API_KEY || environment === "production" && process.env.TOPOLO_ACCESS_TOKEN) {
@@ -2808,51 +2877,47 @@ async function refreshCredentialIfNeeded(environment = selectedEnvironment(), op
2808
2877
  }
2809
2878
  }
2810
2879
  async function refreshContextCredentialIfNeeded(environment, key, options = {}) {
2811
- const config = await readConfig();
2812
- const profile = profileForEnvironment(config, environment);
2813
- const context = profile.contexts?.[key];
2814
- const credential = context?.credential;
2815
- if (!credential || credential.kind !== "access_token") return credential ?? null;
2816
- const oauthClientId = context.oauthClientId ?? profile.oauthClientId;
2817
- if (!credential.refreshToken || !oauthClientId) return credential;
2818
- const now = Math.floor(Date.now() / 1e3);
2819
- if (!options.force && credential.expiresAt && credential.expiresAt - REFRESH_SKEW_SECONDS > now) return credential;
2820
- const serviceUrls = resolveServiceUrls(profile.serviceUrls, environment);
2821
- if (environment === "local" && !serviceUrls?.auth) {
2822
- throw new TopoloSdkError2(
2823
- "environment_service_url_required",
2824
- "Local Topolo context refresh requires an explicit local Auth service origin."
2825
- );
2826
- }
2827
- try {
2828
- const oauth = new TopoloOAuth2({
2829
- serviceUrls
2830
- });
2831
- const tokens = await oauth.refreshToken({
2832
- clientId: oauthClientId,
2833
- refreshToken: credential.refreshToken
2834
- });
2835
- const refreshed = {
2836
- kind: "access_token",
2837
- accessToken: tokens.access_token,
2838
- ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : { refreshToken: credential.refreshToken },
2839
- expiresAt: Math.floor(Date.now() / 1e3) + tokens.expires_in
2840
- };
2841
- await writeConfig(writeProfileConfig(config, environment, {
2842
- ...profile.activeContext === key ? { credential: refreshed } : {},
2843
- contexts: {
2844
- ...profile.contexts ?? {},
2845
- [key]: {
2846
- ...context,
2847
- credential: refreshed,
2848
- oauthClientId
2880
+ return withConfigWriteLock(async () => {
2881
+ const config = await readConfig();
2882
+ const profile = profileForEnvironment(config, environment);
2883
+ const context = profile.contexts?.[key];
2884
+ const credential = context?.credential;
2885
+ if (!credential || credential.kind !== "access_token") return credential ?? null;
2886
+ const oauthClientId = context.oauthClientId ?? profile.oauthClientId;
2887
+ if (!credential.refreshToken || !oauthClientId) return credential;
2888
+ const now = Math.floor(Date.now() / 1e3);
2889
+ if (!options.force && credential.expiresAt && credential.expiresAt - REFRESH_SKEW_SECONDS > now) return credential;
2890
+ const serviceUrls = resolveServiceUrls(profile.serviceUrls, environment);
2891
+ if (environment === "local" && !serviceUrls?.auth) {
2892
+ throw new TopoloSdkError2(
2893
+ "environment_service_url_required",
2894
+ "Local Topolo context refresh requires an explicit local Auth service origin."
2895
+ );
2896
+ }
2897
+ try {
2898
+ const oauth = new TopoloOAuth2({ serviceUrls });
2899
+ const tokens = await oauth.refreshToken({
2900
+ clientId: oauthClientId,
2901
+ refreshToken: credential.refreshToken
2902
+ });
2903
+ const refreshed = {
2904
+ kind: "access_token",
2905
+ accessToken: tokens.access_token,
2906
+ ...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : { refreshToken: credential.refreshToken },
2907
+ expiresAt: Math.floor(Date.now() / 1e3) + tokens.expires_in
2908
+ };
2909
+ await writeConfig(writeProfileConfig(config, environment, {
2910
+ ...profile.activeContext === key ? { credential: refreshed } : {},
2911
+ contexts: {
2912
+ ...profile.contexts ?? {},
2913
+ [key]: { ...context, credential: refreshed, oauthClientId }
2849
2914
  }
2850
- }
2851
- }));
2852
- return refreshed;
2853
- } catch {
2854
- return credential;
2855
- }
2915
+ }));
2916
+ return refreshed;
2917
+ } catch {
2918
+ return credential;
2919
+ }
2920
+ });
2856
2921
  }
2857
2922
  function resolveAgentName(configAgentName) {
2858
2923
  return process.env.TOPOLO_AGENT_NAME ?? configAgentName;
@@ -2906,9 +2971,6 @@ function apiKeyIssuanceUrl(environment = selectedEnvironment()) {
2906
2971
  return `${developerConsoleUrl(environment)}/credentials`;
2907
2972
  }
2908
2973
  var DEFAULT_CLI_CLIENT_IDS = {
2909
- // First-party `topolo-cli` device-flow client, seeded per environment by Auth
2910
- // (core/TopoloAuth/scripts/seed-first-party-oauth-clients.sql). Each env's
2911
- // Auth must have this client before the CLI ships pointing at it.
2912
2974
  production: "topolo-cli",
2913
2975
  staging: "topolo-cli",
2914
2976
  development: "topolo-cli"
@@ -3270,13 +3332,13 @@ async function pollUntilApproved(params) {
3270
3332
  throw new Error("Device code expired. Run `topolo auth login` again.");
3271
3333
  }
3272
3334
  function sleep(ms) {
3273
- return new Promise((resolve2) => setTimeout(resolve2, ms));
3335
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
3274
3336
  }
3275
3337
 
3276
3338
  // src/client.ts
3277
3339
  async function buildClient(options) {
3278
- const environment = selectedEnvironment();
3279
- const credential = await refreshCredentialIfNeeded(environment);
3340
+ const environment = options?.environment ?? selectedEnvironment();
3341
+ const credential = options?.context ? await refreshContextCredentialIfNeeded(environment, options.context) : await refreshCredentialIfNeeded(environment);
3280
3342
  if (!credential) return null;
3281
3343
  const config = await readConfig();
3282
3344
  const profile = resolvedProfileConfig(config, environment);
@@ -3290,7 +3352,9 @@ async function buildClient(options) {
3290
3352
  },
3291
3353
  serviceUrls: profile.serviceUrls,
3292
3354
  requireConfirmForWrites: options?.requireConfirmForWrites,
3293
- ...credential.kind === "access_token" && credential.refreshToken ? { refreshCredential: () => refreshCredentialIfNeeded(environment, { force: true }) } : {},
3355
+ ...credential.kind === "access_token" && credential.refreshToken ? {
3356
+ refreshCredential: () => options?.context ? refreshContextCredentialIfNeeded(environment, options.context, { force: true }) : refreshCredentialIfNeeded(environment, { force: true })
3357
+ } : {},
3294
3358
  ...isDebugEnabled() ? { debug: stderrDebugLogger } : {}
3295
3359
  });
3296
3360
  }
@@ -3732,6 +3796,7 @@ function inferContentType(path) {
3732
3796
  ".pdf": "application/pdf",
3733
3797
  ".png": "image/png",
3734
3798
  ".svg": "image/svg+xml",
3799
+ ".txt": "text/plain",
3735
3800
  ".webp": "image/webp"
3736
3801
  }[extname(path).toLowerCase()] ?? null;
3737
3802
  }
@@ -4081,7 +4146,7 @@ function normalizeList(values, fallback = []) {
4081
4146
  }
4082
4147
 
4083
4148
  // src/commands/integrations.ts
4084
- import { readdir, readFile as readFile2, stat } from "fs/promises";
4149
+ import { readdir, readFile as readFile2, stat as stat2 } from "fs/promises";
4085
4150
  import { join as join3, relative } from "path";
4086
4151
  function registerIntegrationsCommand(program2) {
4087
4152
  const integrations = program2.command("integrations").description("Generate and validate Topolo integration handoffs.");
@@ -4244,7 +4309,7 @@ async function collectFiles(repoRoot) {
4244
4309
  continue;
4245
4310
  }
4246
4311
  if (!entry.isFile() || !isInspectable(entry.name)) continue;
4247
- const info = await stat(fullPath);
4312
+ const info = await stat2(fullPath);
4248
4313
  if (info.size > 25e4) continue;
4249
4314
  result.push({
4250
4315
  path: relative(root, fullPath),
@@ -4264,46 +4329,135 @@ function formatCheckResult(result) {
4264
4329
  }
4265
4330
 
4266
4331
  // src/commands/skills.ts
4332
+ import { spawnSync as spawnSync2 } from "child_process";
4333
+ import { existsSync as existsSync3 } from "fs";
4334
+ import { mkdir as mkdir2, copyFile, readFile as readFile4, writeFile as writeFile2, rm as rm2, rename } from "fs/promises";
4335
+ import { homedir as homedir3 } from "os";
4336
+ import { dirname as dirname3, join as join5, resolve } from "path";
4337
+ import { fileURLToPath as fileURLToPath2 } from "url";
4338
+ import { createRequire } from "module";
4339
+
4340
+ // src/commands/agent-host.ts
4267
4341
  import { spawnSync } from "child_process";
4268
4342
  import { existsSync as existsSync2 } from "fs";
4269
- import { mkdir as mkdir2, copyFile, readFile as readFile3, writeFile as writeFile2, rm, rename } from "fs/promises";
4343
+ import { readFile as readFile3 } from "fs/promises";
4270
4344
  import { homedir as homedir2 } from "os";
4271
- import { dirname as dirname3, join as join4, resolve } from "path";
4272
- import { fileURLToPath as fileURLToPath2 } from "url";
4273
- import { createRequire } from "module";
4345
+ import { isAbsolute, join as join4 } from "path";
4346
+ var RESERVED_OPERATOR_REGISTRATION = ["topolo", "platform"].join("-");
4347
+ function isPublicRegistrationName(name) {
4348
+ return name === "topolo" || name.startsWith("topolo-") && name !== RESERVED_OPERATOR_REGISTRATION;
4349
+ }
4350
+ function selectStableNodeCommand(whichOutput, fallback = process.execPath) {
4351
+ const candidate = whichOutput?.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
4352
+ return candidate && isAbsolute(candidate) ? candidate : fallback;
4353
+ }
4354
+ function stableNodeCommand() {
4355
+ const probe = spawnSync(process.platform === "win32" ? "where" : "which", ["node"], {
4356
+ encoding: "utf8",
4357
+ stdio: ["ignore", "pipe", "ignore"]
4358
+ });
4359
+ return selectStableNodeCommand(probe.status === 0 ? probe.stdout : void 0);
4360
+ }
4361
+ function agentSkillPath(agent, homeDir = homedir2()) {
4362
+ return agent === "claude" ? join4(homeDir, ".claude", "skills", "topolo", "SKILL.md") : join4(homeDir, ".codex", "skills", "topolo", "SKILL.md");
4363
+ }
4364
+ async function readAgentRegistrations(agent, homeDir = homedir2()) {
4365
+ return agent === "claude" ? readClaudeRegistrations(homeDir) : readCodexRegistrations(homeDir);
4366
+ }
4367
+ async function readClaudeRegistrations(homeDir) {
4368
+ const path = join4(homeDir, ".claude.json");
4369
+ if (!existsSync2(path)) return [];
4370
+ let parsed;
4371
+ try {
4372
+ parsed = JSON.parse(await readFile3(path, "utf8"));
4373
+ } catch (error) {
4374
+ throw new Error(`Cannot read malformed Claude configuration at ${path}: ${message(error)}`);
4375
+ }
4376
+ const servers = parsed.mcpServers;
4377
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) return [];
4378
+ return Object.entries(servers).filter(([name]) => isPublicRegistrationName(name)).flatMap(([name, value]) => normalizeRegistration(name, value));
4379
+ }
4380
+ async function readCodexRegistrations(homeDir) {
4381
+ const path = join4(homeDir, ".codex", "config.toml");
4382
+ if (!existsSync2(path)) return [];
4383
+ const registrations = /* @__PURE__ */ new Map();
4384
+ let section;
4385
+ for (const rawLine of (await readFile3(path, "utf8")).split(/\r?\n/)) {
4386
+ const line = rawLine.trim();
4387
+ const heading = line.match(/^\[mcp_servers\.([A-Za-z0-9_-]+)(\.env)?\]$/);
4388
+ if (heading) {
4389
+ section = { name: heading[1], env: Boolean(heading[2]) };
4390
+ if (isPublicRegistrationName(section.name) && !registrations.has(section.name)) {
4391
+ registrations.set(section.name, { name: section.name, command: "", args: [], env: {} });
4392
+ }
4393
+ continue;
4394
+ }
4395
+ if (line.startsWith("[")) {
4396
+ section = void 0;
4397
+ continue;
4398
+ }
4399
+ if (!section || !isPublicRegistrationName(section.name) || !line || line.startsWith("#")) continue;
4400
+ const assignment = line.match(/^([A-Za-z0-9_]+)\s*=\s*(.+)$/);
4401
+ if (!assignment) continue;
4402
+ const registration = registrations.get(section.name);
4403
+ try {
4404
+ const value = JSON.parse(assignment[2]);
4405
+ if (section.env && typeof value === "string") registration.env[assignment[1]] = value;
4406
+ if (!section.env && assignment[1] === "command" && typeof value === "string") registration.command = value;
4407
+ if (!section.env && assignment[1] === "args" && Array.isArray(value) && value.every((item) => typeof item === "string")) {
4408
+ registration.args = value;
4409
+ }
4410
+ } catch {
4411
+ throw new Error(`Cannot read unsupported MCP value in Codex configuration at ${path}.`);
4412
+ }
4413
+ }
4414
+ return [...registrations.values()].filter((registration) => registration.command);
4415
+ }
4416
+ function normalizeRegistration(name, value) {
4417
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [];
4418
+ const candidate = value;
4419
+ if (typeof candidate.command !== "string") return [];
4420
+ const args = Array.isArray(candidate.args) && candidate.args.every((arg) => typeof arg === "string") ? candidate.args : [];
4421
+ const env = candidate.env && typeof candidate.env === "object" && !Array.isArray(candidate.env) ? Object.fromEntries(Object.entries(candidate.env).filter((entry) => typeof entry[1] === "string")) : {};
4422
+ return [{ name, command: candidate.command, args, env }];
4423
+ }
4424
+ function message(error) {
4425
+ return error instanceof Error ? error.message : String(error);
4426
+ }
4427
+
4428
+ // src/commands/skills.ts
4274
4429
  var AGENT_LABELS = { claude: "Claude Code", codex: "Codex" };
4275
4430
  var AGENT_NAME_FOR = { claude: "claude-code", codex: "codex" };
4276
- var RESERVED_OPERATOR_REGISTRATION = ["topolo", "platform"].join("-");
4277
4431
  function skillsSourceDir() {
4278
4432
  const here = dirname3(fileURLToPath2(import.meta.url));
4279
4433
  const distCandidate = resolve(here, "../skills");
4280
4434
  const srcCandidate = resolve(here, "../../skills");
4281
4435
  for (const candidate of [distCandidate, srcCandidate]) {
4282
- if (existsSync2(join4(candidate, "topolo", "SKILL.md"))) return candidate;
4436
+ if (existsSync3(join5(candidate, "topolo", "SKILL.md"))) return candidate;
4283
4437
  }
4284
4438
  return distCandidate;
4285
4439
  }
4286
4440
  function resolveMcpCommand() {
4287
4441
  const require2 = createRequire(import.meta.url);
4288
- return { command: process.execPath, args: [require2.resolve("@topolo/mcp/stdio")] };
4442
+ return { command: stableNodeCommand(), args: [require2.resolve("@topolo/mcp/stdio")] };
4289
4443
  }
4290
4444
  function agentBinaryExists(bin) {
4291
- const probe = spawnSync(process.platform === "win32" ? "where" : "which", [bin], {
4445
+ const probe = spawnSync2(process.platform === "win32" ? "where" : "which", [bin], {
4292
4446
  stdio: "ignore"
4293
4447
  });
4294
4448
  return probe.status === 0;
4295
4449
  }
4296
- function claudeSkillDir(projectDir, homeDir = homedir2()) {
4297
- const base = projectDir ? resolve(projectDir, ".claude") : join4(homeDir, ".claude");
4298
- return join4(base, "skills", "topolo");
4450
+ function claudeSkillDir(projectDir, homeDir = homedir3()) {
4451
+ const base = projectDir ? resolve(projectDir, ".claude") : join5(homeDir, ".claude");
4452
+ return join5(base, "skills", "topolo");
4299
4453
  }
4300
- function codexSkillDir(homeDir = homedir2()) {
4301
- return join4(homeDir, ".codex", "skills", "topolo");
4454
+ function codexSkillDir(homeDir = homedir3()) {
4455
+ return join5(homeDir, ".codex", "skills", "topolo");
4302
4456
  }
4303
4457
  async function installSkillFile(sourceDir, targetDir) {
4304
4458
  await mkdir2(targetDir, { recursive: true });
4305
- const target = join4(targetDir, "SKILL.md");
4306
- await copyFile(join4(sourceDir, "topolo", "SKILL.md"), target);
4459
+ const target = join5(targetDir, "SKILL.md");
4460
+ await copyFile(join5(sourceDir, "topolo", "SKILL.md"), target);
4307
4461
  return target;
4308
4462
  }
4309
4463
  async function atomicWrite(path, contents) {
@@ -4327,11 +4481,11 @@ function withoutMcpServerSections(text, name) {
4327
4481
  }).join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
4328
4482
  }
4329
4483
  async function registerClaudeViaConfig(mcp, homeDir, registration) {
4330
- const path = join4(homeDir, ".claude.json");
4484
+ const path = join5(homeDir, ".claude.json");
4331
4485
  let config = {};
4332
- if (existsSync2(path)) {
4486
+ if (existsSync3(path)) {
4333
4487
  try {
4334
- config = JSON.parse(await readFile3(path, "utf8"));
4488
+ config = JSON.parse(await readFile4(path, "utf8"));
4335
4489
  } catch (error) {
4336
4490
  throw new Error(`Cannot update malformed Claude configuration at ${path}: ${error instanceof Error ? error.message : String(error)}`);
4337
4491
  }
@@ -4348,9 +4502,9 @@ async function registerClaudeViaConfig(mcp, homeDir, registration) {
4348
4502
  `);
4349
4503
  }
4350
4504
  async function registerCodexViaConfig(mcp, homeDir, registration) {
4351
- const path = join4(homeDir, ".codex", "config.toml");
4505
+ const path = join5(homeDir, ".codex", "config.toml");
4352
4506
  let text = "";
4353
- if (existsSync2(path)) text = await readFile3(path, "utf8");
4507
+ if (existsSync3(path)) text = await readFile4(path, "utf8");
4354
4508
  text = withoutMcpServerSections(text, registration.name);
4355
4509
  const argsToml = mcp.args.length ? `
4356
4510
  args = [${mcp.args.map((a) => `"${a}"`).join(", ")}]` : "";
@@ -4391,21 +4545,21 @@ function resolveMcpRegistration(options) {
4391
4545
  }
4392
4546
  return { name, ...context ? { context } : {}, environment };
4393
4547
  }
4394
- async function unregisterFromConfig(agent, homeDir) {
4548
+ async function unregisterFromConfig(agent, homeDir, name) {
4395
4549
  if (agent === "claude") {
4396
- const path2 = join4(homeDir, ".claude.json");
4397
- if (!existsSync2(path2)) return;
4398
- const config = JSON.parse(await readFile3(path2, "utf8"));
4550
+ const path2 = join5(homeDir, ".claude.json");
4551
+ if (!existsSync3(path2)) return;
4552
+ const config = JSON.parse(await readFile4(path2, "utf8"));
4399
4553
  const servers = config.mcpServers ?? {};
4400
- delete servers.topolo;
4554
+ delete servers[name];
4401
4555
  config.mcpServers = servers;
4402
4556
  await atomicWrite(path2, `${JSON.stringify(config, null, 2)}
4403
4557
  `);
4404
4558
  return;
4405
4559
  }
4406
- const path = join4(homeDir, ".codex", "config.toml");
4407
- if (!existsSync2(path)) return;
4408
- const text = withoutMcpServerSections(await readFile3(path, "utf8"), "topolo");
4560
+ const path = join5(homeDir, ".codex", "config.toml");
4561
+ if (!existsSync3(path)) return;
4562
+ const text = withoutMcpServerSections(await readFile4(path, "utf8"), name);
4409
4563
  await atomicWrite(path, text ? `${text}
4410
4564
  ` : "");
4411
4565
  }
@@ -4418,6 +4572,11 @@ async function installForAgent(agent, sourceDir, mcp, opts) {
4418
4572
  try {
4419
4573
  if (agent === "claude") await registerClaudeViaConfig(mcp, opts.homeDir, opts.registration);
4420
4574
  else await registerCodexViaConfig(mcp, opts.homeDir, opts.registration);
4575
+ const installed = (await readAgentRegistrations(agent, opts.homeDir)).find((registration) => registration.name === opts.registration.name);
4576
+ const expectedEnvironment = registrationEnvironment(AGENT_NAME_FOR[agent], opts.registration);
4577
+ if (!installed || installed.command !== mcp.command || JSON.stringify(installed.args) !== JSON.stringify(mcp.args) || Object.entries(expectedEnvironment).some(([key, value]) => installed.env[key] !== value)) {
4578
+ throw new Error(`Host configuration did not retain the ${opts.registration.name} MCP registration.`);
4579
+ }
4421
4580
  return { agent, skillPath, skillInstalled: true, mcp: "config-file" };
4422
4581
  } catch (err) {
4423
4582
  return {
@@ -4441,46 +4600,36 @@ function registerSkillsCommand(program2) {
4441
4600
  const mode = resolveOutputMode(opts.json);
4442
4601
  try {
4443
4602
  const { results, mcp } = await installAgentSurfaces({ ...opts, homeDir: opts.agentHome });
4603
+ assertAgentSetupSucceeded(results);
4444
4604
  emit({ installed: results, mcpCommand: mcp }, mode, () => formatInstall(results, mcp));
4445
4605
  } catch (err) {
4446
4606
  fail(err, mode);
4447
4607
  }
4448
4608
  }
4449
4609
  );
4450
- skills.command("status").description("Show whether the skill file + MCP server are installed for each agent").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
4610
+ skills.command("status").description("List public Topolo skill and MCP registrations for each agent").option("--agent-home <dir>", "Inspect an alternate agent home").option("--name <name>", "Inspect one registration name").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
4451
4611
  const mode = resolveOutputMode(opts.json);
4452
4612
  try {
4453
- const rows = ["claude", "codex"].map((agent) => {
4454
- const skillDir = agent === "claude" ? claudeSkillDir() : codexSkillDir();
4455
- return {
4456
- agent,
4457
- skillInstalled: existsSync2(join4(skillDir, "SKILL.md")),
4458
- skillPath: join4(skillDir, "SKILL.md"),
4459
- cliAvailable: agentBinaryExists(agent)
4460
- };
4613
+ const rows = await agentSurfaceStatus({
4614
+ homeDir: opts.agentHome ? resolve(opts.agentHome) : void 0,
4615
+ name: opts.name
4461
4616
  });
4462
4617
  emit({ agents: rows }, mode, () => formatStatus(rows));
4463
4618
  } catch (err) {
4464
4619
  fail(err, mode);
4465
4620
  }
4466
4621
  });
4467
- skills.command("uninstall").description("Remove the skill file and MCP registration for each agent").option("--claude", "Target Claude Code only").option("--codex", "Target Codex only").option("--agent-home <dir>", "Target an alternate agent home (managed installs and clean-room verification)").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
4622
+ skills.command("uninstall").description("Remove the skill file and MCP registration for each agent").option("--claude", "Target Claude Code only").option("--codex", "Target Codex only").option("--agent-home <dir>", "Target an alternate agent home (managed installs and clean-room verification)").option("--name <name>", "Remove one registration (defaults to topolo)").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
4468
4623
  const mode = resolveOutputMode(opts.json);
4469
4624
  try {
4470
- const homeDir = opts.agentHome ? resolve(opts.agentHome) : homedir2();
4471
- const removed = [];
4472
- for (const agent of resolveTargets(opts)) {
4473
- const skillDir = agent === "claude" ? claudeSkillDir(void 0, homeDir) : codexSkillDir(homeDir);
4474
- const skillFile = join4(skillDir, "SKILL.md");
4475
- const skillRemoved = existsSync2(skillFile);
4476
- if (skillRemoved) await rm(skillFile, { force: true });
4477
- await unregisterFromConfig(agent, homeDir);
4478
- removed.push({ agent, skillRemoved });
4479
- }
4625
+ const removed = await uninstallAgentSurfaces({
4626
+ ...opts,
4627
+ homeDir: opts.agentHome ? resolve(opts.agentHome) : void 0
4628
+ });
4480
4629
  emit(
4481
4630
  { removed },
4482
4631
  mode,
4483
- () => removed.map((r) => `${AGENT_LABELS[r.agent]}: skill removed=${r.skillRemoved}, MCP unregistered`).join("\n")
4632
+ () => removed.map((r) => `${AGENT_LABELS[r.agent]}: ${r.name} removed=${r.registrationRemoved}, skill removed=${r.skillRemoved}`).join("\n")
4484
4633
  );
4485
4634
  } catch (err) {
4486
4635
  fail(err, mode);
@@ -4489,12 +4638,12 @@ function registerSkillsCommand(program2) {
4489
4638
  }
4490
4639
  async function installAgentSurfaces(opts) {
4491
4640
  const sourceDir = skillsSourceDir();
4492
- if (!existsSync2(join4(sourceDir, "topolo", "SKILL.md"))) {
4641
+ if (!existsSync3(join5(sourceDir, "topolo", "SKILL.md"))) {
4493
4642
  throw new Error("Bundled skill file not found. Reinstall the CLI: npm install -g @topolo/cli");
4494
4643
  }
4495
4644
  const mcp = resolveMcpCommand();
4496
4645
  const registration = resolveMcpRegistration(opts);
4497
- const homeDir = opts.homeDir ?? homedir2();
4646
+ const homeDir = opts.homeDir ?? homedir3();
4498
4647
  const results = [];
4499
4648
  for (const agent of resolveTargets(opts)) {
4500
4649
  results.push(await installForAgent(agent, sourceDir, mcp, {
@@ -4511,12 +4660,53 @@ function registerSetupCommand(program2) {
4511
4660
  const mode = resolveOutputMode(opts.json);
4512
4661
  try {
4513
4662
  const { results, mcp } = await installAgentSurfaces({ ...opts, homeDir: opts.agentHome });
4663
+ assertAgentSetupSucceeded(results);
4514
4664
  emit({ installed: results, mcpCommand: mcp }, mode, () => formatInstall(results, mcp));
4515
4665
  } catch (error) {
4516
4666
  fail(error, mode);
4517
4667
  }
4518
4668
  });
4519
4669
  }
4670
+ function assertAgentSetupSucceeded(results) {
4671
+ const failed = results.filter((result) => result.mcp === "failed");
4672
+ if (failed.length) {
4673
+ throw new Error(failed.map((result) => `${AGENT_LABELS[result.agent]}: ${result.detail ?? "MCP registration failed"}`).join("; "));
4674
+ }
4675
+ }
4676
+ async function agentSurfaceStatus(options = {}) {
4677
+ const homeDir = options.homeDir ?? homedir3();
4678
+ const sourceSkill = await readFile4(join5(skillsSourceDir(), "topolo", "SKILL.md"), "utf8");
4679
+ return Promise.all(["claude", "codex"].map(async (agent) => {
4680
+ const skillPath = agentSkillPath(agent, homeDir);
4681
+ const installedSkill = existsSync3(skillPath) ? await readFile4(skillPath, "utf8") : void 0;
4682
+ const registrations = (await readAgentRegistrations(agent, homeDir)).filter((registration) => !options.name || registration.name === options.name);
4683
+ return {
4684
+ agent,
4685
+ skillInstalled: installedSkill !== void 0,
4686
+ skillCurrent: installedSkill === sourceSkill,
4687
+ skillPath,
4688
+ cliAvailable: agentBinaryExists(agent),
4689
+ registrations
4690
+ };
4691
+ }));
4692
+ }
4693
+ async function uninstallAgentSurfaces(options) {
4694
+ const homeDir = options.homeDir ?? homedir3();
4695
+ const name = options.name?.trim() || "topolo";
4696
+ resolveMcpRegistration({ name, ...name === "topolo" ? {} : { context: "uninstall-validation" } });
4697
+ const removed = [];
4698
+ for (const agent of resolveTargets(options)) {
4699
+ const before = await readAgentRegistrations(agent, homeDir);
4700
+ const registrationRemoved = before.some((entry) => entry.name === name);
4701
+ await unregisterFromConfig(agent, homeDir, name);
4702
+ const remaining = await readAgentRegistrations(agent, homeDir);
4703
+ const skillPath = agentSkillPath(agent, homeDir);
4704
+ const skillRemoved = remaining.length === 0 && existsSync3(skillPath);
4705
+ if (skillRemoved) await rm2(dirname3(skillPath), { recursive: true, force: true });
4706
+ removed.push({ agent, name, registrationRemoved, skillRemoved });
4707
+ }
4708
+ return removed;
4709
+ }
4520
4710
  function formatInstall(results, mcp) {
4521
4711
  const lines = [];
4522
4712
  const mcpStr = [mcp.command, ...mcp.args].join(" ");
@@ -4536,43 +4726,49 @@ function formatInstall(results, mcp) {
4536
4726
  }
4537
4727
  function formatStatus(rows) {
4538
4728
  return rows.map(
4539
- (r) => `${AGENT_LABELS[r.agent]}: skill ${r.skillInstalled ? "installed" : "not installed"} (${r.skillPath}), ${r.agent} CLI ${r.cliAvailable ? "available" : "not found"}`
4729
+ (r) => `${AGENT_LABELS[r.agent]}: skill ${r.skillInstalled ? r.skillCurrent ? "current" : "stale" : "not installed"} (${r.skillPath}), ${r.agent} CLI ${r.cliAvailable ? "available" : "not found"}, MCP registrations: ${r.registrations.map((registration) => registration.name).join(", ") || "none"}`
4540
4730
  ).join("\n");
4541
4731
  }
4542
4732
 
4543
4733
  // src/commands/doctor.ts
4544
4734
  import { access } from "fs/promises";
4735
+ import { homedir as homedir4 } from "os";
4736
+ import { resolve as resolve2 } from "path";
4545
4737
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4546
4738
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4547
4739
  var defaultDependencies = {
4548
4740
  resolveMcp: resolveMcpCommand,
4549
4741
  verifyFile: (path) => access(path),
4550
- verifyAuthentication: async () => {
4551
- const topolo = await buildClient();
4552
- if (!topolo) throw new Error("No credential configured. Run `topolo auth login`.");
4742
+ verifyAuthentication: async (context, environment) => {
4743
+ const topolo = await buildClient({ context, environment });
4744
+ 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`.");
4553
4745
  const identity = await topolo.identity.whoami();
4554
4746
  return identity.organization?.slug ?? identity.user.email;
4555
4747
  },
4556
4748
  verifyMcp: verifyMcpProtocol
4557
4749
  };
4558
- async function runDoctor(dependencies = defaultDependencies) {
4559
- const checks = [];
4750
+ async function runDoctor(dependencies = defaultDependencies, target) {
4751
+ const checks = [...target?.preflightChecks ?? []];
4560
4752
  let mcp;
4561
4753
  try {
4562
- mcp = dependencies.resolveMcp();
4754
+ mcp = target?.mcp ?? dependencies.resolveMcp();
4563
4755
  await dependencies.verifyFile(mcp.args[0]);
4564
4756
  checks.push({ name: "launcher", ok: true, detail: `${mcp.command} ${mcp.args.join(" ")}` });
4565
4757
  } catch (error) {
4566
4758
  checks.push({ name: "launcher", ok: false, detail: errorMessage(error) });
4567
4759
  }
4568
4760
  try {
4569
- checks.push({ name: "authentication", ok: true, detail: await dependencies.verifyAuthentication() });
4761
+ checks.push({
4762
+ name: "authentication",
4763
+ ok: true,
4764
+ detail: await dependencies.verifyAuthentication(target?.context, target?.environment)
4765
+ });
4570
4766
  } catch (error) {
4571
4767
  checks.push({ name: "authentication", ok: false, detail: errorMessage(error) });
4572
4768
  }
4573
4769
  if (mcp) {
4574
4770
  try {
4575
- checks.push({ name: "mcp", ok: true, detail: await dependencies.verifyMcp(mcp.command, mcp.args) });
4771
+ checks.push({ name: "mcp", ok: true, detail: await dependencies.verifyMcp(mcp.command, mcp.args, target?.env) });
4576
4772
  } catch (error) {
4577
4773
  checks.push({ name: "mcp", ok: false, detail: errorMessage(error) });
4578
4774
  }
@@ -4581,9 +4777,9 @@ async function runDoctor(dependencies = defaultDependencies) {
4581
4777
  }
4582
4778
  return { ok: checks.every((check) => check.ok), cliVersion: CLI_VERSION, checks };
4583
4779
  }
4584
- async function verifyMcpProtocol(command, args) {
4780
+ async function verifyMcpProtocol(command, args, env = definedEnvironment(process.env)) {
4585
4781
  const client = new Client({ name: "topolo-cli-doctor", version: CLI_VERSION });
4586
- const transport = new StdioClientTransport({ command, args, stderr: "pipe" });
4782
+ const transport = new StdioClientTransport({ command, args, stderr: "pipe", env });
4587
4783
  try {
4588
4784
  await client.connect(transport);
4589
4785
  const tools = await client.listTools();
@@ -4597,12 +4793,50 @@ async function verifyMcpProtocol(command, args) {
4597
4793
  await client.close();
4598
4794
  }
4599
4795
  }
4796
+ async function resolveInstalledDoctorTarget(options) {
4797
+ const name = options.name?.trim() || "topolo";
4798
+ const homeDir = options.agentHome ? resolve2(options.agentHome) : homedir4();
4799
+ const expected = resolveMcpCommand();
4800
+ const requestedAgents = options.claude && !options.codex ? ["claude"] : options.codex && !options.claude ? ["codex"] : ["claude", "codex"];
4801
+ const rows = (await agentSurfaceStatus({ homeDir, name })).filter((row) => requestedAgents.includes(row.agent));
4802
+ const preflightChecks = [];
4803
+ let selected = rows.flatMap((row) => row.registrations)[0];
4804
+ for (const row of rows) {
4805
+ preflightChecks.push({
4806
+ name: "skill",
4807
+ ok: row.skillCurrent,
4808
+ detail: `${row.agent}: ${row.skillCurrent ? "bundled skill is current" : row.skillInstalled ? "installed skill is stale" : "skill is missing"}`
4809
+ });
4810
+ const registration = row.registrations.find((entry) => entry.name === name);
4811
+ const exact = Boolean(
4812
+ registration && registration.command === expected.command && JSON.stringify(registration.args) === JSON.stringify(expected.args)
4813
+ );
4814
+ preflightChecks.push({
4815
+ name: "registration",
4816
+ ok: exact,
4817
+ detail: `${row.agent}: ${exact ? `${name} uses the bundled launcher` : `${name} is missing or does not use the bundled launcher`}`
4818
+ });
4819
+ }
4820
+ const context = options.context !== void 0 ? normalizeDoctorContext(options.context) : selected?.env.TOPOLO_CONTEXT;
4821
+ const environment = normalizeEnvironment(options.environment ?? selected?.env.TOPOLO_ENV);
4822
+ if (name !== "topolo") resolveMcpRegistration({ name, context, environment });
4823
+ if (!selected) selected = { name, ...expected, env: {} };
4824
+ const env = {
4825
+ ...definedEnvironment(process.env),
4826
+ ...selected.env,
4827
+ ...context ? { TOPOLO_CONTEXT: context, TOPOLO_ENV: environment } : {}
4828
+ };
4829
+ return { mcp: selected, env, context, environment, preflightChecks };
4830
+ }
4831
+ function definedEnvironment(environment) {
4832
+ return Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== void 0));
4833
+ }
4600
4834
  function registerDoctorCommand(program2) {
4601
- program2.command("doctor").description("Verify the CLI credential and bundled MCP server end to end").option("--context <context>", "Verify one exact personal or organization context").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
4835
+ 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-home <dir>", "Inspect an alternate agent home").option("--json", "Output JSON", true).option("--no-json", "Output human-readable text").action(async (opts) => {
4602
4836
  const mode = resolveOutputMode(opts.json);
4603
4837
  try {
4604
- if (opts.context !== void 0) applyDoctorContext(opts.context);
4605
- const result = await runDoctor();
4838
+ const target = await resolveInstalledDoctorTarget(opts);
4839
+ const result = await runDoctor(defaultDependencies, target);
4606
4840
  if (!result.ok) throw new Error(formatDoctor(result));
4607
4841
  emit(result, mode, () => formatDoctor(result));
4608
4842
  } catch (error) {
@@ -4617,9 +4851,6 @@ function normalizeDoctorContext(value) {
4617
4851
  }
4618
4852
  return context;
4619
4853
  }
4620
- function applyDoctorContext(value, environment = process.env) {
4621
- environment.TOPOLO_CONTEXT = normalizeDoctorContext(value);
4622
- }
4623
4854
  function formatDoctor(result) {
4624
4855
  return result.checks.map((check) => `${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}`).join("\n");
4625
4856
  }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@topolo/cli",
3
- "version": "0.8.2",
4
- "topoloPublishedVersion": "0.8.2",
3
+ "version": "0.8.4",
4
+ "topoloPublishedVersion": "0.8.4",
5
5
  "description": "Public command-line interface for Topolo customer organizations, developers, and agents.",
6
+ "license": "UNLICENSED",
7
+ "author": "Topolo",
8
+ "homepage": "https://docs.topolo.app/guides/agents-and-automation",
6
9
  "type": "module",
7
10
  "bin": {
8
11
  "topolo": "dist/index.js"
@@ -16,14 +19,14 @@
16
19
  "dev": "tsup --watch",
17
20
  "typecheck": "tsc --noEmit",
18
21
  "start": "node dist/index.js",
19
- "test": "vitest run",
22
+ "test": "if test \"$TOPOLO_BUILD_READY\" = 1; then vitest run; else python3 ../../tooling/with-resource-lock.py ../topolo-sdk -- sh -c 'pnpm --dir ../topolo-sdk build && exec vitest run'; fi",
20
23
  "test:watch": "vitest",
21
- "coverage": "vitest run --coverage"
24
+ "coverage": "if test \"$TOPOLO_BUILD_READY\" = 1; then vitest run --coverage; else python3 ../../tooling/with-resource-lock.py ../topolo-sdk -- sh -c 'pnpm --dir ../topolo-sdk build && exec vitest run --coverage'; fi"
22
25
  },
23
26
  "dependencies": {
24
27
  "@modelcontextprotocol/sdk": "^1.0.0",
25
- "@topolo/mcp": "0.11.0",
26
- "@topolo/sdk": "0.11.0",
28
+ "@topolo/mcp": "0.11.2",
29
+ "@topolo/sdk": "0.11.2",
27
30
  "commander": "^12.1.0"
28
31
  },
29
32
  "devDependencies": {
@@ -31,7 +31,8 @@ npm view @topolo/cli version
31
31
 
32
32
  If the CLI version differs, update `@topolo/cli`, run `topolo setup`, run `topolo doctor`, and restart the agent. The CLI owns compatible SDK and MCP versions; customers must not assemble that dependency set manually. Record the CLI version used with results.
33
33
 
34
- Production is the customer environment. Do not use internal development, staging, seed, deployment, audit, scaffold, or release workflows from this skill; those belong to the restricted `topolo-platform` toolchain.
34
+ Production is the customer environment. Internal engineering, deployment,
35
+ audit, provisioning, and release workflows are outside this public skill.
35
36
 
36
37
  ## Authentication gate
37
38
 
@@ -65,7 +66,7 @@ uses the exact bundled MCP instead of a global binary:
65
66
 
66
67
  ```bash
67
68
  topolo setup --name topolo-acme --context org:org_acme --env production
68
- topolo doctor --context org:org_acme --env production
69
+ topolo doctor --name topolo-acme
69
70
  ```
70
71
 
71
72
  The context selector accepts an
@@ -100,6 +101,7 @@ Its `data.policy.canCreate` and `data.policy.reason` are the caller-specific cre
100
101
 
101
102
  MCP equivalents:
102
103
 
104
+ - `topolo_whoami` for the current credential identity
103
105
  - `topolo_search_applications`
104
106
  - `topolo_get_application`
105
107
  - `topolo_get_resource_types`
@@ -165,7 +167,9 @@ Before every call, validate exact input against the published JSON Schema:
165
167
  topolo actions validate <action-id> --data '<json>' --json
166
168
  ```
167
169
 
168
- MCP: `topolo_validate_action`.
170
+ MCP: `topolo_validate_action`. Use `topolo_prepare_action` for one compact
171
+ validate-and-plan response, and `topolo_upload_action` only when the selected
172
+ action publishes an upload contract.
169
173
 
170
174
  For large inputs, write one schema-valid JSON object to a file and pass it without shell escaping:
171
175