@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 +11 -19
- package/dist/index.js +374 -143
- package/package.json +9 -6
- 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;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
2264
|
-
super(
|
|
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(
|
|
2362
|
-
super(canonicalTopoloErrorCode2(code) ?? "auth_error",
|
|
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,
|
|
2455
|
-
super(serviceErrorCode2(body, "http_error"),
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
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((
|
|
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 ? {
|
|
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
|
|
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 {
|
|
4343
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
4270
4344
|
import { homedir as homedir2 } from "os";
|
|
4271
|
-
import {
|
|
4272
|
-
|
|
4273
|
-
|
|
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 (
|
|
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:
|
|
4442
|
+
return { command: stableNodeCommand(), args: [require2.resolve("@topolo/mcp/stdio")] };
|
|
4289
4443
|
}
|
|
4290
4444
|
function agentBinaryExists(bin) {
|
|
4291
|
-
const probe =
|
|
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 =
|
|
4297
|
-
const base = projectDir ? resolve(projectDir, ".claude") :
|
|
4298
|
-
return
|
|
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 =
|
|
4301
|
-
return
|
|
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 =
|
|
4306
|
-
await copyFile(
|
|
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 =
|
|
4484
|
+
const path = join5(homeDir, ".claude.json");
|
|
4331
4485
|
let config = {};
|
|
4332
|
-
if (
|
|
4486
|
+
if (existsSync3(path)) {
|
|
4333
4487
|
try {
|
|
4334
|
-
config = JSON.parse(await
|
|
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 =
|
|
4505
|
+
const path = join5(homeDir, ".codex", "config.toml");
|
|
4352
4506
|
let text = "";
|
|
4353
|
-
if (
|
|
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 =
|
|
4397
|
-
if (!
|
|
4398
|
-
const config = JSON.parse(await
|
|
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
|
|
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 =
|
|
4407
|
-
if (!
|
|
4408
|
-
const text = withoutMcpServerSections(await
|
|
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("
|
|
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 =
|
|
4454
|
-
|
|
4455
|
-
|
|
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
|
|
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
|
-
}
|
|
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]}:
|
|
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 (!
|
|
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 ??
|
|
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 ? "
|
|
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({
|
|
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
|
|
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
|
-
|
|
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.
|
|
4
|
-
"topoloPublishedVersion": "0.8.
|
|
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.
|
|
26
|
-
"@topolo/sdk": "0.11.
|
|
28
|
+
"@topolo/mcp": "0.11.2",
|
|
29
|
+
"@topolo/sdk": "0.11.2",
|
|
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
|
|