@topolo/cli 0.8.2 → 0.8.3
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 +11 -19
- package/dist/index.js +338 -142
- package/package.json +7 -4
- package/skills/topolo/SKILL.md +7 -3
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.
|
|
5
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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 --
|
|
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.
|
|
91
|
-
|
|
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
|
|
229
|
-
|
|
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
|
-
//
|
|
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,
|
|
34
|
-
super(
|
|
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(
|
|
132
|
-
super(canonicalTopoloErrorCode(code) ?? "auth_error",
|
|
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(
|
|
154
|
+
constructor(message2, required, options = {}) {
|
|
155
155
|
super(
|
|
156
156
|
canonicalTopoloErrorCode(options.code) ?? serviceErrorCode(options.body, "permission_denied"),
|
|
157
|
-
|
|
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(
|
|
290
|
-
super("confirmation_required",
|
|
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,
|
|
303
|
-
super(serviceErrorCode(body, "http_error"),
|
|
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;
|
|
@@ -999,7 +999,7 @@ var TopoloClient = class _TopoloClient {
|
|
|
999
999
|
const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
|
|
1000
1000
|
if (!res.ok) {
|
|
1001
1001
|
const durationMs = Date.now() - startedAt;
|
|
1002
|
-
const
|
|
1002
|
+
const message2 = describeError(parsed, `HTTP ${res.status}`);
|
|
1003
1003
|
const publicCode = extractTopoloServiceErrorCode(parsed) ?? "http_error";
|
|
1004
1004
|
this.emit({
|
|
1005
1005
|
phase: "error",
|
|
@@ -1039,7 +1039,7 @@ var TopoloClient = class _TopoloClient {
|
|
|
1039
1039
|
opts.path,
|
|
1040
1040
|
res.status,
|
|
1041
1041
|
parsed,
|
|
1042
|
-
|
|
1042
|
+
message2,
|
|
1043
1043
|
errorContext
|
|
1044
1044
|
);
|
|
1045
1045
|
}
|
|
@@ -2253,15 +2253,15 @@ function createTopolo(options) {
|
|
|
2253
2253
|
};
|
|
2254
2254
|
}
|
|
2255
2255
|
|
|
2256
|
-
//
|
|
2257
|
-
import { mkdir, readFile, writeFile, chmod } from "fs/promises";
|
|
2256
|
+
// node_modules/@topolo/sdk/dist/node/credential-store.js
|
|
2257
|
+
import { mkdir, readFile, writeFile, chmod, rm, stat } from "fs/promises";
|
|
2258
2258
|
import { existsSync } from "fs";
|
|
2259
2259
|
import { homedir, platform } from "os";
|
|
2260
2260
|
import { dirname, join } from "path";
|
|
2261
2261
|
var TopoloSdkError2 = class extends Error {
|
|
2262
2262
|
code;
|
|
2263
|
-
constructor(code,
|
|
2264
|
-
super(
|
|
2263
|
+
constructor(code, message2) {
|
|
2264
|
+
super(message2);
|
|
2265
2265
|
this.name = "TopoloSdkError";
|
|
2266
2266
|
this.code = code;
|
|
2267
2267
|
}
|
|
@@ -2358,8 +2358,8 @@ var TopoloAuthError2 = class extends TopoloSdkError2 {
|
|
|
2358
2358
|
requestId;
|
|
2359
2359
|
retryAfter;
|
|
2360
2360
|
details;
|
|
2361
|
-
constructor(
|
|
2362
|
-
super(canonicalTopoloErrorCode2(code) ?? "auth_error",
|
|
2361
|
+
constructor(message2, code = "auth_error", options = {}) {
|
|
2362
|
+
super(canonicalTopoloErrorCode2(code) ?? "auth_error", message2);
|
|
2363
2363
|
this.name = "TopoloAuthError";
|
|
2364
2364
|
this.status = options.status ?? 401;
|
|
2365
2365
|
this.body = options.body;
|
|
@@ -2451,8 +2451,8 @@ var TopoloHttpError2 = class extends TopoloSdkError2 {
|
|
|
2451
2451
|
requestId;
|
|
2452
2452
|
retryAfter;
|
|
2453
2453
|
details;
|
|
2454
|
-
constructor(service, path, status, body,
|
|
2455
|
-
super(serviceErrorCode2(body, "http_error"),
|
|
2454
|
+
constructor(service, path, status, body, message2, options = {}) {
|
|
2455
|
+
super(serviceErrorCode2(body, "http_error"), message2 ?? `HTTP ${status} from ${service}${path}`);
|
|
2456
2456
|
this.name = "TopoloHttpError";
|
|
2457
2457
|
this.status = status;
|
|
2458
2458
|
this.body = body;
|
|
@@ -2755,6 +2755,41 @@ async function resolveCredential(environment = selectedEnvironment()) {
|
|
|
2755
2755
|
return profileForEnvironment(config, environment).credential ?? null;
|
|
2756
2756
|
}
|
|
2757
2757
|
var REFRESH_SKEW_SECONDS = 60;
|
|
2758
|
+
var CONFIG_LOCK_RETRY_MS = 25;
|
|
2759
|
+
var CONFIG_LOCK_TIMEOUT_MS = 5e3;
|
|
2760
|
+
var CONFIG_LOCK_STALE_MS = 12e4;
|
|
2761
|
+
async function withConfigWriteLock(operation) {
|
|
2762
|
+
const lockPath = `${configPath()}.lock`;
|
|
2763
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
2764
|
+
const startedAt = Date.now();
|
|
2765
|
+
while (true) {
|
|
2766
|
+
try {
|
|
2767
|
+
await mkdir(lockPath);
|
|
2768
|
+
break;
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
if (error.code !== "EEXIST") throw error;
|
|
2771
|
+
try {
|
|
2772
|
+
const lock = await stat(lockPath);
|
|
2773
|
+
if (Date.now() - lock.mtimeMs > CONFIG_LOCK_STALE_MS) {
|
|
2774
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
2775
|
+
continue;
|
|
2776
|
+
}
|
|
2777
|
+
} catch (inspectionError) {
|
|
2778
|
+
if (inspectionError.code !== "ENOENT") throw inspectionError;
|
|
2779
|
+
continue;
|
|
2780
|
+
}
|
|
2781
|
+
if (Date.now() - startedAt >= CONFIG_LOCK_TIMEOUT_MS) {
|
|
2782
|
+
throw new Error(`Timed out waiting for credential configuration lock at ${lockPath}.`);
|
|
2783
|
+
}
|
|
2784
|
+
await new Promise((resolve3) => setTimeout(resolve3, CONFIG_LOCK_RETRY_MS));
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
try {
|
|
2788
|
+
return await operation();
|
|
2789
|
+
} finally {
|
|
2790
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2758
2793
|
async function refreshCredentialIfNeeded(environment = selectedEnvironment(), options = {}) {
|
|
2759
2794
|
const prefix = envVarPrefix(environment);
|
|
2760
2795
|
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 +2843,47 @@ async function refreshCredentialIfNeeded(environment = selectedEnvironment(), op
|
|
|
2808
2843
|
}
|
|
2809
2844
|
}
|
|
2810
2845
|
async function refreshContextCredentialIfNeeded(environment, key, options = {}) {
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
serviceUrls
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
[key]: {
|
|
2846
|
-
...context,
|
|
2847
|
-
credential: refreshed,
|
|
2848
|
-
oauthClientId
|
|
2846
|
+
return withConfigWriteLock(async () => {
|
|
2847
|
+
const config = await readConfig();
|
|
2848
|
+
const profile = profileForEnvironment(config, environment);
|
|
2849
|
+
const context = profile.contexts?.[key];
|
|
2850
|
+
const credential = context?.credential;
|
|
2851
|
+
if (!credential || credential.kind !== "access_token") return credential ?? null;
|
|
2852
|
+
const oauthClientId = context.oauthClientId ?? profile.oauthClientId;
|
|
2853
|
+
if (!credential.refreshToken || !oauthClientId) return credential;
|
|
2854
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2855
|
+
if (!options.force && credential.expiresAt && credential.expiresAt - REFRESH_SKEW_SECONDS > now) return credential;
|
|
2856
|
+
const serviceUrls = resolveServiceUrls(profile.serviceUrls, environment);
|
|
2857
|
+
if (environment === "local" && !serviceUrls?.auth) {
|
|
2858
|
+
throw new TopoloSdkError2(
|
|
2859
|
+
"environment_service_url_required",
|
|
2860
|
+
"Local Topolo context refresh requires an explicit local Auth service origin."
|
|
2861
|
+
);
|
|
2862
|
+
}
|
|
2863
|
+
try {
|
|
2864
|
+
const oauth = new TopoloOAuth2({ serviceUrls });
|
|
2865
|
+
const tokens = await oauth.refreshToken({
|
|
2866
|
+
clientId: oauthClientId,
|
|
2867
|
+
refreshToken: credential.refreshToken
|
|
2868
|
+
});
|
|
2869
|
+
const refreshed = {
|
|
2870
|
+
kind: "access_token",
|
|
2871
|
+
accessToken: tokens.access_token,
|
|
2872
|
+
...tokens.refresh_token ? { refreshToken: tokens.refresh_token } : { refreshToken: credential.refreshToken },
|
|
2873
|
+
expiresAt: Math.floor(Date.now() / 1e3) + tokens.expires_in
|
|
2874
|
+
};
|
|
2875
|
+
await writeConfig(writeProfileConfig(config, environment, {
|
|
2876
|
+
...profile.activeContext === key ? { credential: refreshed } : {},
|
|
2877
|
+
contexts: {
|
|
2878
|
+
...profile.contexts ?? {},
|
|
2879
|
+
[key]: { ...context, credential: refreshed, oauthClientId }
|
|
2849
2880
|
}
|
|
2850
|
-
}
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
}
|
|
2881
|
+
}));
|
|
2882
|
+
return refreshed;
|
|
2883
|
+
} catch {
|
|
2884
|
+
return credential;
|
|
2885
|
+
}
|
|
2886
|
+
});
|
|
2856
2887
|
}
|
|
2857
2888
|
function resolveAgentName(configAgentName) {
|
|
2858
2889
|
return process.env.TOPOLO_AGENT_NAME ?? configAgentName;
|
|
@@ -2906,9 +2937,6 @@ function apiKeyIssuanceUrl(environment = selectedEnvironment()) {
|
|
|
2906
2937
|
return `${developerConsoleUrl(environment)}/credentials`;
|
|
2907
2938
|
}
|
|
2908
2939
|
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
2940
|
production: "topolo-cli",
|
|
2913
2941
|
staging: "topolo-cli",
|
|
2914
2942
|
development: "topolo-cli"
|
|
@@ -3270,13 +3298,13 @@ async function pollUntilApproved(params) {
|
|
|
3270
3298
|
throw new Error("Device code expired. Run `topolo auth login` again.");
|
|
3271
3299
|
}
|
|
3272
3300
|
function sleep(ms) {
|
|
3273
|
-
return new Promise((
|
|
3301
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
3274
3302
|
}
|
|
3275
3303
|
|
|
3276
3304
|
// src/client.ts
|
|
3277
3305
|
async function buildClient(options) {
|
|
3278
|
-
const environment = selectedEnvironment();
|
|
3279
|
-
const credential = await refreshCredentialIfNeeded(environment);
|
|
3306
|
+
const environment = options?.environment ?? selectedEnvironment();
|
|
3307
|
+
const credential = options?.context ? await refreshContextCredentialIfNeeded(environment, options.context) : await refreshCredentialIfNeeded(environment);
|
|
3280
3308
|
if (!credential) return null;
|
|
3281
3309
|
const config = await readConfig();
|
|
3282
3310
|
const profile = resolvedProfileConfig(config, environment);
|
|
@@ -3290,7 +3318,9 @@ async function buildClient(options) {
|
|
|
3290
3318
|
},
|
|
3291
3319
|
serviceUrls: profile.serviceUrls,
|
|
3292
3320
|
requireConfirmForWrites: options?.requireConfirmForWrites,
|
|
3293
|
-
...credential.kind === "access_token" && credential.refreshToken ? {
|
|
3321
|
+
...credential.kind === "access_token" && credential.refreshToken ? {
|
|
3322
|
+
refreshCredential: () => options?.context ? refreshContextCredentialIfNeeded(environment, options.context, { force: true }) : refreshCredentialIfNeeded(environment, { force: true })
|
|
3323
|
+
} : {},
|
|
3294
3324
|
...isDebugEnabled() ? { debug: stderrDebugLogger } : {}
|
|
3295
3325
|
});
|
|
3296
3326
|
}
|
|
@@ -4081,7 +4111,7 @@ function normalizeList(values, fallback = []) {
|
|
|
4081
4111
|
}
|
|
4082
4112
|
|
|
4083
4113
|
// src/commands/integrations.ts
|
|
4084
|
-
import { readdir, readFile as readFile2, stat } from "fs/promises";
|
|
4114
|
+
import { readdir, readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
4085
4115
|
import { join as join3, relative } from "path";
|
|
4086
4116
|
function registerIntegrationsCommand(program2) {
|
|
4087
4117
|
const integrations = program2.command("integrations").description("Generate and validate Topolo integration handoffs.");
|
|
@@ -4244,7 +4274,7 @@ async function collectFiles(repoRoot) {
|
|
|
4244
4274
|
continue;
|
|
4245
4275
|
}
|
|
4246
4276
|
if (!entry.isFile() || !isInspectable(entry.name)) continue;
|
|
4247
|
-
const info = await
|
|
4277
|
+
const info = await stat2(fullPath);
|
|
4248
4278
|
if (info.size > 25e4) continue;
|
|
4249
4279
|
result.push({
|
|
4250
4280
|
path: relative(root, fullPath),
|
|
@@ -4264,46 +4294,135 @@ function formatCheckResult(result) {
|
|
|
4264
4294
|
}
|
|
4265
4295
|
|
|
4266
4296
|
// src/commands/skills.ts
|
|
4297
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
4298
|
+
import { existsSync as existsSync3 } from "fs";
|
|
4299
|
+
import { mkdir as mkdir2, copyFile, readFile as readFile4, writeFile as writeFile2, rm as rm2, rename } from "fs/promises";
|
|
4300
|
+
import { homedir as homedir3 } from "os";
|
|
4301
|
+
import { dirname as dirname3, join as join5, resolve } from "path";
|
|
4302
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4303
|
+
import { createRequire } from "module";
|
|
4304
|
+
|
|
4305
|
+
// src/commands/agent-host.ts
|
|
4267
4306
|
import { spawnSync } from "child_process";
|
|
4268
4307
|
import { existsSync as existsSync2 } from "fs";
|
|
4269
|
-
import {
|
|
4308
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
4270
4309
|
import { homedir as homedir2 } from "os";
|
|
4271
|
-
import {
|
|
4272
|
-
|
|
4273
|
-
|
|
4310
|
+
import { isAbsolute, join as join4 } from "path";
|
|
4311
|
+
var RESERVED_OPERATOR_REGISTRATION = ["topolo", "platform"].join("-");
|
|
4312
|
+
function isPublicRegistrationName(name) {
|
|
4313
|
+
return name === "topolo" || name.startsWith("topolo-") && name !== RESERVED_OPERATOR_REGISTRATION;
|
|
4314
|
+
}
|
|
4315
|
+
function selectStableNodeCommand(whichOutput, fallback = process.execPath) {
|
|
4316
|
+
const candidate = whichOutput?.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
4317
|
+
return candidate && isAbsolute(candidate) ? candidate : fallback;
|
|
4318
|
+
}
|
|
4319
|
+
function stableNodeCommand() {
|
|
4320
|
+
const probe = spawnSync(process.platform === "win32" ? "where" : "which", ["node"], {
|
|
4321
|
+
encoding: "utf8",
|
|
4322
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
4323
|
+
});
|
|
4324
|
+
return selectStableNodeCommand(probe.status === 0 ? probe.stdout : void 0);
|
|
4325
|
+
}
|
|
4326
|
+
function agentSkillPath(agent, homeDir = homedir2()) {
|
|
4327
|
+
return agent === "claude" ? join4(homeDir, ".claude", "skills", "topolo", "SKILL.md") : join4(homeDir, ".codex", "skills", "topolo", "SKILL.md");
|
|
4328
|
+
}
|
|
4329
|
+
async function readAgentRegistrations(agent, homeDir = homedir2()) {
|
|
4330
|
+
return agent === "claude" ? readClaudeRegistrations(homeDir) : readCodexRegistrations(homeDir);
|
|
4331
|
+
}
|
|
4332
|
+
async function readClaudeRegistrations(homeDir) {
|
|
4333
|
+
const path = join4(homeDir, ".claude.json");
|
|
4334
|
+
if (!existsSync2(path)) return [];
|
|
4335
|
+
let parsed;
|
|
4336
|
+
try {
|
|
4337
|
+
parsed = JSON.parse(await readFile3(path, "utf8"));
|
|
4338
|
+
} catch (error) {
|
|
4339
|
+
throw new Error(`Cannot read malformed Claude configuration at ${path}: ${message(error)}`);
|
|
4340
|
+
}
|
|
4341
|
+
const servers = parsed.mcpServers;
|
|
4342
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) return [];
|
|
4343
|
+
return Object.entries(servers).filter(([name]) => isPublicRegistrationName(name)).flatMap(([name, value]) => normalizeRegistration(name, value));
|
|
4344
|
+
}
|
|
4345
|
+
async function readCodexRegistrations(homeDir) {
|
|
4346
|
+
const path = join4(homeDir, ".codex", "config.toml");
|
|
4347
|
+
if (!existsSync2(path)) return [];
|
|
4348
|
+
const registrations = /* @__PURE__ */ new Map();
|
|
4349
|
+
let section;
|
|
4350
|
+
for (const rawLine of (await readFile3(path, "utf8")).split(/\r?\n/)) {
|
|
4351
|
+
const line = rawLine.trim();
|
|
4352
|
+
const heading = line.match(/^\[mcp_servers\.([A-Za-z0-9_-]+)(\.env)?\]$/);
|
|
4353
|
+
if (heading) {
|
|
4354
|
+
section = { name: heading[1], env: Boolean(heading[2]) };
|
|
4355
|
+
if (isPublicRegistrationName(section.name) && !registrations.has(section.name)) {
|
|
4356
|
+
registrations.set(section.name, { name: section.name, command: "", args: [], env: {} });
|
|
4357
|
+
}
|
|
4358
|
+
continue;
|
|
4359
|
+
}
|
|
4360
|
+
if (line.startsWith("[")) {
|
|
4361
|
+
section = void 0;
|
|
4362
|
+
continue;
|
|
4363
|
+
}
|
|
4364
|
+
if (!section || !isPublicRegistrationName(section.name) || !line || line.startsWith("#")) continue;
|
|
4365
|
+
const assignment = line.match(/^([A-Za-z0-9_]+)\s*=\s*(.+)$/);
|
|
4366
|
+
if (!assignment) continue;
|
|
4367
|
+
const registration = registrations.get(section.name);
|
|
4368
|
+
try {
|
|
4369
|
+
const value = JSON.parse(assignment[2]);
|
|
4370
|
+
if (section.env && typeof value === "string") registration.env[assignment[1]] = value;
|
|
4371
|
+
if (!section.env && assignment[1] === "command" && typeof value === "string") registration.command = value;
|
|
4372
|
+
if (!section.env && assignment[1] === "args" && Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
|
4373
|
+
registration.args = value;
|
|
4374
|
+
}
|
|
4375
|
+
} catch {
|
|
4376
|
+
throw new Error(`Cannot read unsupported MCP value in Codex configuration at ${path}.`);
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
4379
|
+
return [...registrations.values()].filter((registration) => registration.command);
|
|
4380
|
+
}
|
|
4381
|
+
function normalizeRegistration(name, value) {
|
|
4382
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
4383
|
+
const candidate = value;
|
|
4384
|
+
if (typeof candidate.command !== "string") return [];
|
|
4385
|
+
const args = Array.isArray(candidate.args) && candidate.args.every((arg) => typeof arg === "string") ? candidate.args : [];
|
|
4386
|
+
const env = candidate.env && typeof candidate.env === "object" && !Array.isArray(candidate.env) ? Object.fromEntries(Object.entries(candidate.env).filter((entry) => typeof entry[1] === "string")) : {};
|
|
4387
|
+
return [{ name, command: candidate.command, args, env }];
|
|
4388
|
+
}
|
|
4389
|
+
function message(error) {
|
|
4390
|
+
return error instanceof Error ? error.message : String(error);
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
// src/commands/skills.ts
|
|
4274
4394
|
var AGENT_LABELS = { claude: "Claude Code", codex: "Codex" };
|
|
4275
4395
|
var AGENT_NAME_FOR = { claude: "claude-code", codex: "codex" };
|
|
4276
|
-
var RESERVED_OPERATOR_REGISTRATION = ["topolo", "platform"].join("-");
|
|
4277
4396
|
function skillsSourceDir() {
|
|
4278
4397
|
const here = dirname3(fileURLToPath2(import.meta.url));
|
|
4279
4398
|
const distCandidate = resolve(here, "../skills");
|
|
4280
4399
|
const srcCandidate = resolve(here, "../../skills");
|
|
4281
4400
|
for (const candidate of [distCandidate, srcCandidate]) {
|
|
4282
|
-
if (
|
|
4401
|
+
if (existsSync3(join5(candidate, "topolo", "SKILL.md"))) return candidate;
|
|
4283
4402
|
}
|
|
4284
4403
|
return distCandidate;
|
|
4285
4404
|
}
|
|
4286
4405
|
function resolveMcpCommand() {
|
|
4287
4406
|
const require2 = createRequire(import.meta.url);
|
|
4288
|
-
return { command:
|
|
4407
|
+
return { command: stableNodeCommand(), args: [require2.resolve("@topolo/mcp/stdio")] };
|
|
4289
4408
|
}
|
|
4290
4409
|
function agentBinaryExists(bin) {
|
|
4291
|
-
const probe =
|
|
4410
|
+
const probe = spawnSync2(process.platform === "win32" ? "where" : "which", [bin], {
|
|
4292
4411
|
stdio: "ignore"
|
|
4293
4412
|
});
|
|
4294
4413
|
return probe.status === 0;
|
|
4295
4414
|
}
|
|
4296
|
-
function claudeSkillDir(projectDir, homeDir =
|
|
4297
|
-
const base = projectDir ? resolve(projectDir, ".claude") :
|
|
4298
|
-
return
|
|
4415
|
+
function claudeSkillDir(projectDir, homeDir = homedir3()) {
|
|
4416
|
+
const base = projectDir ? resolve(projectDir, ".claude") : join5(homeDir, ".claude");
|
|
4417
|
+
return join5(base, "skills", "topolo");
|
|
4299
4418
|
}
|
|
4300
|
-
function codexSkillDir(homeDir =
|
|
4301
|
-
return
|
|
4419
|
+
function codexSkillDir(homeDir = homedir3()) {
|
|
4420
|
+
return join5(homeDir, ".codex", "skills", "topolo");
|
|
4302
4421
|
}
|
|
4303
4422
|
async function installSkillFile(sourceDir, targetDir) {
|
|
4304
4423
|
await mkdir2(targetDir, { recursive: true });
|
|
4305
|
-
const target =
|
|
4306
|
-
await copyFile(
|
|
4424
|
+
const target = join5(targetDir, "SKILL.md");
|
|
4425
|
+
await copyFile(join5(sourceDir, "topolo", "SKILL.md"), target);
|
|
4307
4426
|
return target;
|
|
4308
4427
|
}
|
|
4309
4428
|
async function atomicWrite(path, contents) {
|
|
@@ -4327,11 +4446,11 @@ function withoutMcpServerSections(text, name) {
|
|
|
4327
4446
|
}).join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
4328
4447
|
}
|
|
4329
4448
|
async function registerClaudeViaConfig(mcp, homeDir, registration) {
|
|
4330
|
-
const path =
|
|
4449
|
+
const path = join5(homeDir, ".claude.json");
|
|
4331
4450
|
let config = {};
|
|
4332
|
-
if (
|
|
4451
|
+
if (existsSync3(path)) {
|
|
4333
4452
|
try {
|
|
4334
|
-
config = JSON.parse(await
|
|
4453
|
+
config = JSON.parse(await readFile4(path, "utf8"));
|
|
4335
4454
|
} catch (error) {
|
|
4336
4455
|
throw new Error(`Cannot update malformed Claude configuration at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4337
4456
|
}
|
|
@@ -4348,9 +4467,9 @@ async function registerClaudeViaConfig(mcp, homeDir, registration) {
|
|
|
4348
4467
|
`);
|
|
4349
4468
|
}
|
|
4350
4469
|
async function registerCodexViaConfig(mcp, homeDir, registration) {
|
|
4351
|
-
const path =
|
|
4470
|
+
const path = join5(homeDir, ".codex", "config.toml");
|
|
4352
4471
|
let text = "";
|
|
4353
|
-
if (
|
|
4472
|
+
if (existsSync3(path)) text = await readFile4(path, "utf8");
|
|
4354
4473
|
text = withoutMcpServerSections(text, registration.name);
|
|
4355
4474
|
const argsToml = mcp.args.length ? `
|
|
4356
4475
|
args = [${mcp.args.map((a) => `"${a}"`).join(", ")}]` : "";
|
|
@@ -4391,21 +4510,21 @@ function resolveMcpRegistration(options) {
|
|
|
4391
4510
|
}
|
|
4392
4511
|
return { name, ...context ? { context } : {}, environment };
|
|
4393
4512
|
}
|
|
4394
|
-
async function unregisterFromConfig(agent, homeDir) {
|
|
4513
|
+
async function unregisterFromConfig(agent, homeDir, name) {
|
|
4395
4514
|
if (agent === "claude") {
|
|
4396
|
-
const path2 =
|
|
4397
|
-
if (!
|
|
4398
|
-
const config = JSON.parse(await
|
|
4515
|
+
const path2 = join5(homeDir, ".claude.json");
|
|
4516
|
+
if (!existsSync3(path2)) return;
|
|
4517
|
+
const config = JSON.parse(await readFile4(path2, "utf8"));
|
|
4399
4518
|
const servers = config.mcpServers ?? {};
|
|
4400
|
-
delete servers
|
|
4519
|
+
delete servers[name];
|
|
4401
4520
|
config.mcpServers = servers;
|
|
4402
4521
|
await atomicWrite(path2, `${JSON.stringify(config, null, 2)}
|
|
4403
4522
|
`);
|
|
4404
4523
|
return;
|
|
4405
4524
|
}
|
|
4406
|
-
const path =
|
|
4407
|
-
if (!
|
|
4408
|
-
const text = withoutMcpServerSections(await
|
|
4525
|
+
const path = join5(homeDir, ".codex", "config.toml");
|
|
4526
|
+
if (!existsSync3(path)) return;
|
|
4527
|
+
const text = withoutMcpServerSections(await readFile4(path, "utf8"), name);
|
|
4409
4528
|
await atomicWrite(path, text ? `${text}
|
|
4410
4529
|
` : "");
|
|
4411
4530
|
}
|
|
@@ -4418,6 +4537,11 @@ async function installForAgent(agent, sourceDir, mcp, opts) {
|
|
|
4418
4537
|
try {
|
|
4419
4538
|
if (agent === "claude") await registerClaudeViaConfig(mcp, opts.homeDir, opts.registration);
|
|
4420
4539
|
else await registerCodexViaConfig(mcp, opts.homeDir, opts.registration);
|
|
4540
|
+
const installed = (await readAgentRegistrations(agent, opts.homeDir)).find((registration) => registration.name === opts.registration.name);
|
|
4541
|
+
const expectedEnvironment = registrationEnvironment(AGENT_NAME_FOR[agent], opts.registration);
|
|
4542
|
+
if (!installed || installed.command !== mcp.command || JSON.stringify(installed.args) !== JSON.stringify(mcp.args) || Object.entries(expectedEnvironment).some(([key, value]) => installed.env[key] !== value)) {
|
|
4543
|
+
throw new Error(`Host configuration did not retain the ${opts.registration.name} MCP registration.`);
|
|
4544
|
+
}
|
|
4421
4545
|
return { agent, skillPath, skillInstalled: true, mcp: "config-file" };
|
|
4422
4546
|
} catch (err) {
|
|
4423
4547
|
return {
|
|
@@ -4441,46 +4565,36 @@ function registerSkillsCommand(program2) {
|
|
|
4441
4565
|
const mode = resolveOutputMode(opts.json);
|
|
4442
4566
|
try {
|
|
4443
4567
|
const { results, mcp } = await installAgentSurfaces({ ...opts, homeDir: opts.agentHome });
|
|
4568
|
+
assertAgentSetupSucceeded(results);
|
|
4444
4569
|
emit({ installed: results, mcpCommand: mcp }, mode, () => formatInstall(results, mcp));
|
|
4445
4570
|
} catch (err) {
|
|
4446
4571
|
fail(err, mode);
|
|
4447
4572
|
}
|
|
4448
4573
|
}
|
|
4449
4574
|
);
|
|
4450
|
-
skills.command("status").description("
|
|
4575
|
+
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
4576
|
const mode = resolveOutputMode(opts.json);
|
|
4452
4577
|
try {
|
|
4453
|
-
const rows =
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
agent,
|
|
4457
|
-
skillInstalled: existsSync2(join4(skillDir, "SKILL.md")),
|
|
4458
|
-
skillPath: join4(skillDir, "SKILL.md"),
|
|
4459
|
-
cliAvailable: agentBinaryExists(agent)
|
|
4460
|
-
};
|
|
4578
|
+
const rows = await agentSurfaceStatus({
|
|
4579
|
+
homeDir: opts.agentHome ? resolve(opts.agentHome) : void 0,
|
|
4580
|
+
name: opts.name
|
|
4461
4581
|
});
|
|
4462
4582
|
emit({ agents: rows }, mode, () => formatStatus(rows));
|
|
4463
4583
|
} catch (err) {
|
|
4464
4584
|
fail(err, mode);
|
|
4465
4585
|
}
|
|
4466
4586
|
});
|
|
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) => {
|
|
4587
|
+
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
4588
|
const mode = resolveOutputMode(opts.json);
|
|
4469
4589
|
try {
|
|
4470
|
-
const
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
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
|
-
}
|
|
4590
|
+
const removed = await uninstallAgentSurfaces({
|
|
4591
|
+
...opts,
|
|
4592
|
+
homeDir: opts.agentHome ? resolve(opts.agentHome) : void 0
|
|
4593
|
+
});
|
|
4480
4594
|
emit(
|
|
4481
4595
|
{ removed },
|
|
4482
4596
|
mode,
|
|
4483
|
-
() => removed.map((r) => `${AGENT_LABELS[r.agent]}:
|
|
4597
|
+
() => removed.map((r) => `${AGENT_LABELS[r.agent]}: ${r.name} removed=${r.registrationRemoved}, skill removed=${r.skillRemoved}`).join("\n")
|
|
4484
4598
|
);
|
|
4485
4599
|
} catch (err) {
|
|
4486
4600
|
fail(err, mode);
|
|
@@ -4489,12 +4603,12 @@ function registerSkillsCommand(program2) {
|
|
|
4489
4603
|
}
|
|
4490
4604
|
async function installAgentSurfaces(opts) {
|
|
4491
4605
|
const sourceDir = skillsSourceDir();
|
|
4492
|
-
if (!
|
|
4606
|
+
if (!existsSync3(join5(sourceDir, "topolo", "SKILL.md"))) {
|
|
4493
4607
|
throw new Error("Bundled skill file not found. Reinstall the CLI: npm install -g @topolo/cli");
|
|
4494
4608
|
}
|
|
4495
4609
|
const mcp = resolveMcpCommand();
|
|
4496
4610
|
const registration = resolveMcpRegistration(opts);
|
|
4497
|
-
const homeDir = opts.homeDir ??
|
|
4611
|
+
const homeDir = opts.homeDir ?? homedir3();
|
|
4498
4612
|
const results = [];
|
|
4499
4613
|
for (const agent of resolveTargets(opts)) {
|
|
4500
4614
|
results.push(await installForAgent(agent, sourceDir, mcp, {
|
|
@@ -4511,12 +4625,53 @@ function registerSetupCommand(program2) {
|
|
|
4511
4625
|
const mode = resolveOutputMode(opts.json);
|
|
4512
4626
|
try {
|
|
4513
4627
|
const { results, mcp } = await installAgentSurfaces({ ...opts, homeDir: opts.agentHome });
|
|
4628
|
+
assertAgentSetupSucceeded(results);
|
|
4514
4629
|
emit({ installed: results, mcpCommand: mcp }, mode, () => formatInstall(results, mcp));
|
|
4515
4630
|
} catch (error) {
|
|
4516
4631
|
fail(error, mode);
|
|
4517
4632
|
}
|
|
4518
4633
|
});
|
|
4519
4634
|
}
|
|
4635
|
+
function assertAgentSetupSucceeded(results) {
|
|
4636
|
+
const failed = results.filter((result) => result.mcp === "failed");
|
|
4637
|
+
if (failed.length) {
|
|
4638
|
+
throw new Error(failed.map((result) => `${AGENT_LABELS[result.agent]}: ${result.detail ?? "MCP registration failed"}`).join("; "));
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
async function agentSurfaceStatus(options = {}) {
|
|
4642
|
+
const homeDir = options.homeDir ?? homedir3();
|
|
4643
|
+
const sourceSkill = await readFile4(join5(skillsSourceDir(), "topolo", "SKILL.md"), "utf8");
|
|
4644
|
+
return Promise.all(["claude", "codex"].map(async (agent) => {
|
|
4645
|
+
const skillPath = agentSkillPath(agent, homeDir);
|
|
4646
|
+
const installedSkill = existsSync3(skillPath) ? await readFile4(skillPath, "utf8") : void 0;
|
|
4647
|
+
const registrations = (await readAgentRegistrations(agent, homeDir)).filter((registration) => !options.name || registration.name === options.name);
|
|
4648
|
+
return {
|
|
4649
|
+
agent,
|
|
4650
|
+
skillInstalled: installedSkill !== void 0,
|
|
4651
|
+
skillCurrent: installedSkill === sourceSkill,
|
|
4652
|
+
skillPath,
|
|
4653
|
+
cliAvailable: agentBinaryExists(agent),
|
|
4654
|
+
registrations
|
|
4655
|
+
};
|
|
4656
|
+
}));
|
|
4657
|
+
}
|
|
4658
|
+
async function uninstallAgentSurfaces(options) {
|
|
4659
|
+
const homeDir = options.homeDir ?? homedir3();
|
|
4660
|
+
const name = options.name?.trim() || "topolo";
|
|
4661
|
+
resolveMcpRegistration({ name, ...name === "topolo" ? {} : { context: "uninstall-validation" } });
|
|
4662
|
+
const removed = [];
|
|
4663
|
+
for (const agent of resolveTargets(options)) {
|
|
4664
|
+
const before = await readAgentRegistrations(agent, homeDir);
|
|
4665
|
+
const registrationRemoved = before.some((entry) => entry.name === name);
|
|
4666
|
+
await unregisterFromConfig(agent, homeDir, name);
|
|
4667
|
+
const remaining = await readAgentRegistrations(agent, homeDir);
|
|
4668
|
+
const skillPath = agentSkillPath(agent, homeDir);
|
|
4669
|
+
const skillRemoved = remaining.length === 0 && existsSync3(skillPath);
|
|
4670
|
+
if (skillRemoved) await rm2(dirname3(skillPath), { recursive: true, force: true });
|
|
4671
|
+
removed.push({ agent, name, registrationRemoved, skillRemoved });
|
|
4672
|
+
}
|
|
4673
|
+
return removed;
|
|
4674
|
+
}
|
|
4520
4675
|
function formatInstall(results, mcp) {
|
|
4521
4676
|
const lines = [];
|
|
4522
4677
|
const mcpStr = [mcp.command, ...mcp.args].join(" ");
|
|
@@ -4536,43 +4691,49 @@ function formatInstall(results, mcp) {
|
|
|
4536
4691
|
}
|
|
4537
4692
|
function formatStatus(rows) {
|
|
4538
4693
|
return rows.map(
|
|
4539
|
-
(r) => `${AGENT_LABELS[r.agent]}: skill ${r.skillInstalled ? "
|
|
4694
|
+
(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
4695
|
).join("\n");
|
|
4541
4696
|
}
|
|
4542
4697
|
|
|
4543
4698
|
// src/commands/doctor.ts
|
|
4544
4699
|
import { access } from "fs/promises";
|
|
4700
|
+
import { homedir as homedir4 } from "os";
|
|
4701
|
+
import { resolve as resolve2 } from "path";
|
|
4545
4702
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4546
4703
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4547
4704
|
var defaultDependencies = {
|
|
4548
4705
|
resolveMcp: resolveMcpCommand,
|
|
4549
4706
|
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`.");
|
|
4707
|
+
verifyAuthentication: async (context, environment) => {
|
|
4708
|
+
const topolo = await buildClient({ context, environment });
|
|
4709
|
+
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
4710
|
const identity = await topolo.identity.whoami();
|
|
4554
4711
|
return identity.organization?.slug ?? identity.user.email;
|
|
4555
4712
|
},
|
|
4556
4713
|
verifyMcp: verifyMcpProtocol
|
|
4557
4714
|
};
|
|
4558
|
-
async function runDoctor(dependencies = defaultDependencies) {
|
|
4559
|
-
const checks = [];
|
|
4715
|
+
async function runDoctor(dependencies = defaultDependencies, target) {
|
|
4716
|
+
const checks = [...target?.preflightChecks ?? []];
|
|
4560
4717
|
let mcp;
|
|
4561
4718
|
try {
|
|
4562
|
-
mcp = dependencies.resolveMcp();
|
|
4719
|
+
mcp = target?.mcp ?? dependencies.resolveMcp();
|
|
4563
4720
|
await dependencies.verifyFile(mcp.args[0]);
|
|
4564
4721
|
checks.push({ name: "launcher", ok: true, detail: `${mcp.command} ${mcp.args.join(" ")}` });
|
|
4565
4722
|
} catch (error) {
|
|
4566
4723
|
checks.push({ name: "launcher", ok: false, detail: errorMessage(error) });
|
|
4567
4724
|
}
|
|
4568
4725
|
try {
|
|
4569
|
-
checks.push({
|
|
4726
|
+
checks.push({
|
|
4727
|
+
name: "authentication",
|
|
4728
|
+
ok: true,
|
|
4729
|
+
detail: await dependencies.verifyAuthentication(target?.context, target?.environment)
|
|
4730
|
+
});
|
|
4570
4731
|
} catch (error) {
|
|
4571
4732
|
checks.push({ name: "authentication", ok: false, detail: errorMessage(error) });
|
|
4572
4733
|
}
|
|
4573
4734
|
if (mcp) {
|
|
4574
4735
|
try {
|
|
4575
|
-
checks.push({ name: "mcp", ok: true, detail: await dependencies.verifyMcp(mcp.command, mcp.args) });
|
|
4736
|
+
checks.push({ name: "mcp", ok: true, detail: await dependencies.verifyMcp(mcp.command, mcp.args, target?.env) });
|
|
4576
4737
|
} catch (error) {
|
|
4577
4738
|
checks.push({ name: "mcp", ok: false, detail: errorMessage(error) });
|
|
4578
4739
|
}
|
|
@@ -4581,9 +4742,9 @@ async function runDoctor(dependencies = defaultDependencies) {
|
|
|
4581
4742
|
}
|
|
4582
4743
|
return { ok: checks.every((check) => check.ok), cliVersion: CLI_VERSION, checks };
|
|
4583
4744
|
}
|
|
4584
|
-
async function verifyMcpProtocol(command, args) {
|
|
4745
|
+
async function verifyMcpProtocol(command, args, env = definedEnvironment(process.env)) {
|
|
4585
4746
|
const client = new Client({ name: "topolo-cli-doctor", version: CLI_VERSION });
|
|
4586
|
-
const transport = new StdioClientTransport({ command, args, stderr: "pipe" });
|
|
4747
|
+
const transport = new StdioClientTransport({ command, args, stderr: "pipe", env });
|
|
4587
4748
|
try {
|
|
4588
4749
|
await client.connect(transport);
|
|
4589
4750
|
const tools = await client.listTools();
|
|
@@ -4597,12 +4758,50 @@ async function verifyMcpProtocol(command, args) {
|
|
|
4597
4758
|
await client.close();
|
|
4598
4759
|
}
|
|
4599
4760
|
}
|
|
4761
|
+
async function resolveInstalledDoctorTarget(options) {
|
|
4762
|
+
const name = options.name?.trim() || "topolo";
|
|
4763
|
+
const homeDir = options.agentHome ? resolve2(options.agentHome) : homedir4();
|
|
4764
|
+
const expected = resolveMcpCommand();
|
|
4765
|
+
const requestedAgents = options.claude && !options.codex ? ["claude"] : options.codex && !options.claude ? ["codex"] : ["claude", "codex"];
|
|
4766
|
+
const rows = (await agentSurfaceStatus({ homeDir, name })).filter((row) => requestedAgents.includes(row.agent));
|
|
4767
|
+
const preflightChecks = [];
|
|
4768
|
+
let selected = rows.flatMap((row) => row.registrations)[0];
|
|
4769
|
+
for (const row of rows) {
|
|
4770
|
+
preflightChecks.push({
|
|
4771
|
+
name: "skill",
|
|
4772
|
+
ok: row.skillCurrent,
|
|
4773
|
+
detail: `${row.agent}: ${row.skillCurrent ? "bundled skill is current" : row.skillInstalled ? "installed skill is stale" : "skill is missing"}`
|
|
4774
|
+
});
|
|
4775
|
+
const registration = row.registrations.find((entry) => entry.name === name);
|
|
4776
|
+
const exact = Boolean(
|
|
4777
|
+
registration && registration.command === expected.command && JSON.stringify(registration.args) === JSON.stringify(expected.args)
|
|
4778
|
+
);
|
|
4779
|
+
preflightChecks.push({
|
|
4780
|
+
name: "registration",
|
|
4781
|
+
ok: exact,
|
|
4782
|
+
detail: `${row.agent}: ${exact ? `${name} uses the bundled launcher` : `${name} is missing or does not use the bundled launcher`}`
|
|
4783
|
+
});
|
|
4784
|
+
}
|
|
4785
|
+
const context = options.context !== void 0 ? normalizeDoctorContext(options.context) : selected?.env.TOPOLO_CONTEXT;
|
|
4786
|
+
const environment = normalizeEnvironment(options.environment ?? selected?.env.TOPOLO_ENV);
|
|
4787
|
+
if (name !== "topolo") resolveMcpRegistration({ name, context, environment });
|
|
4788
|
+
if (!selected) selected = { name, ...expected, env: {} };
|
|
4789
|
+
const env = {
|
|
4790
|
+
...definedEnvironment(process.env),
|
|
4791
|
+
...selected.env,
|
|
4792
|
+
...context ? { TOPOLO_CONTEXT: context, TOPOLO_ENV: environment } : {}
|
|
4793
|
+
};
|
|
4794
|
+
return { mcp: selected, env, context, environment, preflightChecks };
|
|
4795
|
+
}
|
|
4796
|
+
function definedEnvironment(environment) {
|
|
4797
|
+
return Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== void 0));
|
|
4798
|
+
}
|
|
4600
4799
|
function registerDoctorCommand(program2) {
|
|
4601
|
-
program2.command("doctor").description("Verify
|
|
4800
|
+
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
4801
|
const mode = resolveOutputMode(opts.json);
|
|
4603
4802
|
try {
|
|
4604
|
-
|
|
4605
|
-
const result = await runDoctor();
|
|
4803
|
+
const target = await resolveInstalledDoctorTarget(opts);
|
|
4804
|
+
const result = await runDoctor(defaultDependencies, target);
|
|
4606
4805
|
if (!result.ok) throw new Error(formatDoctor(result));
|
|
4607
4806
|
emit(result, mode, () => formatDoctor(result));
|
|
4608
4807
|
} catch (error) {
|
|
@@ -4617,9 +4816,6 @@ function normalizeDoctorContext(value) {
|
|
|
4617
4816
|
}
|
|
4618
4817
|
return context;
|
|
4619
4818
|
}
|
|
4620
|
-
function applyDoctorContext(value, environment = process.env) {
|
|
4621
|
-
environment.TOPOLO_CONTEXT = normalizeDoctorContext(value);
|
|
4622
|
-
}
|
|
4623
4819
|
function formatDoctor(result) {
|
|
4624
4820
|
return result.checks.map((check) => `${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}`).join("\n");
|
|
4625
4821
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@topolo/cli",
|
|
3
|
-
"version": "0.8.
|
|
4
|
-
"topoloPublishedVersion": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
|
+
"topoloPublishedVersion": "0.8.3",
|
|
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"
|
|
@@ -22,8 +25,8 @@
|
|
|
22
25
|
},
|
|
23
26
|
"dependencies": {
|
|
24
27
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
25
|
-
"@topolo/mcp": "0.11.
|
|
26
|
-
"@topolo/sdk": "0.11.
|
|
28
|
+
"@topolo/mcp": "0.11.1",
|
|
29
|
+
"@topolo/sdk": "0.11.1",
|
|
27
30
|
"commander": "^12.1.0"
|
|
28
31
|
},
|
|
29
32
|
"devDependencies": {
|
package/skills/topolo/SKILL.md
CHANGED
|
@@ -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.
|
|
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 --
|
|
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
|
|