@typeship-ax/cli 0.22.0 → 0.24.0
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/AGENTS.md +13 -9
- package/README.md +14 -27
- package/api.json +8898 -9026
- package/api.md +370 -328
- package/dist/arguments.d.ts +54 -0
- package/dist/arguments.d.ts.map +1 -0
- package/dist/arguments.js +265 -0
- package/dist/cli-agent.d.ts +73 -9
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +331 -44
- package/dist/cli.js +802 -291
- package/dist/core/http.d.ts +162 -19
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +381 -48
- package/dist/core/pagination.d.ts +42 -6
- package/dist/core/pagination.d.ts.map +1 -1
- package/dist/core/pagination.js +111 -17
- package/dist/credential-storage.d.ts +10 -3
- package/dist/credential-storage.d.ts.map +1 -1
- package/dist/credential-storage.js +15 -6
- package/dist/dates.d.ts +1 -1
- package/dist/dates.js +1 -1
- package/dist/errors.d.ts +20 -84
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -108
- package/dist/fields.d.ts +36 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +187 -0
- package/dist/index.d.ts +28 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -25
- package/dist/named-credentials.d.ts +19 -0
- package/dist/named-credentials.d.ts.map +1 -1
- package/dist/named-credentials.js +81 -1
- package/dist/oauth-login.d.ts +8 -2
- package/dist/oauth-login.d.ts.map +1 -1
- package/dist/oauth-login.js +31 -19
- package/dist/oauth-request.d.ts +7 -1
- package/dist/oauth-request.d.ts.map +1 -1
- package/dist/oauth-request.js +26 -4
- package/dist/oauth-session.d.ts +13 -1
- package/dist/oauth-session.d.ts.map +1 -1
- package/dist/oauth-session.js +34 -18
- package/dist/ops.d.ts +58 -5
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +110 -41
- package/dist/polling-login.d.ts +8 -2
- package/dist/polling-login.d.ts.map +1 -1
- package/dist/polling-login.js +25 -11
- package/dist/resources/api-keys.d.ts +10 -7
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +10 -31
- package/dist/resources/deliveries.d.ts +89 -5
- package/dist/resources/deliveries.d.ts.map +1 -1
- package/dist/resources/deliveries.js +96 -19
- package/dist/resources/drafts.d.ts +16 -16
- package/dist/resources/drafts.d.ts.map +1 -1
- package/dist/resources/drafts.js +12 -65
- package/dist/resources/files.d.ts +4 -4
- package/dist/resources/files.d.ts.map +1 -1
- package/dist/resources/files.js +3 -12
- package/dist/resources/generations.d.ts +16 -16
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +23 -47
- package/dist/resources/organization.d.ts +4 -4
- package/dist/resources/organization.d.ts.map +1 -1
- package/dist/resources/organization.js +3 -10
- package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
- package/dist/resources/packages.d.ts.map +1 -0
- package/dist/resources/{generate.js → packages.js} +13 -29
- package/dist/resources/projects.d.ts +50 -50
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +60 -116
- package/dist/resources/releases.d.ts +22 -17
- package/dist/resources/releases.d.ts.map +1 -1
- package/dist/resources/releases.js +19 -40
- package/dist/resources/spec-revisions.d.ts +16 -7
- package/dist/resources/spec-revisions.d.ts.map +1 -1
- package/dist/resources/spec-revisions.js +7 -29
- package/dist/resources/specs.d.ts +7 -7
- package/dist/resources/specs.d.ts.map +1 -1
- package/dist/resources/specs.js +6 -34
- package/dist/resources/targets.d.ts +49 -49
- package/dist/resources/targets.d.ts.map +1 -1
- package/dist/resources/targets.js +59 -115
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +83 -81
- package/dist/search.d.ts +54 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +421 -0
- package/dist/table.d.ts +28 -0
- package/dist/table.d.ts.map +1 -0
- package/dist/table.js +167 -0
- package/dist/type-docs.d.ts +61 -0
- package/dist/type-docs.d.ts.map +1 -0
- package/dist/type-docs.js +174 -0
- package/dist/types.d.ts +499 -339
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +18 -18
- package/package.json +5 -2
- package/src/arguments.ts +254 -0
- package/src/cli-agent.ts +351 -46
- package/src/cli.ts +753 -266
- package/src/core/http.ts +457 -58
- package/src/core/pagination.ts +129 -18
- package/src/credential-storage.ts +16 -6
- package/src/dates.ts +1 -1
- package/src/errors.ts +46 -115
- package/src/fields.ts +167 -0
- package/src/index.ts +45 -28
- package/src/named-credentials.ts +66 -1
- package/src/oauth-login.ts +36 -21
- package/src/oauth-request.ts +32 -6
- package/src/oauth-session.ts +37 -19
- package/src/ops.ts +146 -45
- package/src/polling-login.ts +24 -11
- package/src/resources/api-keys.ts +34 -48
- package/src/resources/deliveries.ts +213 -32
- package/src/resources/drafts.ts +62 -109
- package/src/resources/files.ts +19 -20
- package/src/resources/generations.ts +61 -79
- package/src/resources/organization.ts +11 -16
- package/src/resources/{generate.ts → packages.ts} +43 -51
- package/src/resources/projects.ts +145 -200
- package/src/resources/releases.ts +50 -67
- package/src/resources/spec-revisions.ts +40 -49
- package/src/resources/specs.ts +39 -59
- package/src/resources/targets.ts +144 -194
- package/src/schemas.ts +83 -81
- package/src/search.ts +434 -0
- package/src/table.ts +167 -0
- package/src/type-docs.ts +205 -0
- package/src/types.ts +538 -357
- package/dist/console-login-check.d.ts +0 -21
- package/dist/console-login-check.d.ts.map +0 -1
- package/dist/console-login-check.js +0 -107
- package/dist/console-login-contract.d.ts +0 -45
- package/dist/console-login-contract.d.ts.map +0 -1
- package/dist/console-login-contract.js +0 -40
- package/dist/resources/generate.d.ts.map +0 -1
- package/dist/resources/publications.d.ts +0 -47
- package/dist/resources/publications.d.ts.map +0 -1
- package/dist/resources/publications.js +0 -70
- package/src/console-login-check.ts +0 -88
- package/src/console-login-contract.ts +0 -65
- package/src/resources/publications.ts +0 -140
package/src/cli-agent.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The agent contract of a generated CLI. Generated by
|
|
2
|
+
* The agent contract of a generated CLI. Generated by Typeship — https://typeship.dev
|
|
3
3
|
*
|
|
4
4
|
* Everything a coding agent needs from a command-line tool and a human does
|
|
5
5
|
* not: a JSON error envelope with stable codes and next steps, agent-mode
|
|
@@ -21,20 +21,25 @@ import { dirname, join, resolve } from "node:path";
|
|
|
21
21
|
export type IssueCode =
|
|
22
22
|
| "NO_AUTH" // no credential resolved and the API said 401
|
|
23
23
|
| "AUTH_INVALID" // a credential was sent and the API said 401/403
|
|
24
|
+
| "INSUFFICIENT_SCOPE" // 403 (or missing_scope) on an operation that requires OAuth scopes
|
|
24
25
|
| "PLAN_LIMIT" // 402: the account's plan stops here
|
|
25
26
|
| "NOT_FOUND" // 404
|
|
26
27
|
| "INVALID_REQUEST" // 400/422 the API rejected the input
|
|
27
|
-
| "SPEC_INVALID" // 422 whose error code is spec_error
|
|
28
|
+
| "SPEC_INVALID" // 422 whose error code is spec_invalid (spec_error before the rename)
|
|
28
29
|
| "RATE_LIMITED" // 429
|
|
29
30
|
| "SERVER_ERROR" // 5xx
|
|
30
31
|
| "NETWORK_ERROR" // no response: DNS, TLS, timeout, refused
|
|
31
32
|
| "VALIDATION_FAILED" // --validate found parameters or a body that do not match the schema
|
|
32
33
|
| "TTY_REQUIRED" // a prompt was needed and there is no terminal
|
|
34
|
+
| "LOGIN_FAILED" // browser, device or approval login did not complete
|
|
35
|
+
| "LOGIN_REQUIRED" // a saved login cannot be used as is; sign in again
|
|
36
|
+
| "CREDENTIAL_STORE_UNAVAILABLE" // the OS credential store or saved credential file cannot be used
|
|
33
37
|
| "CONFIRMATION_REQUIRED" // a destructive command needs --force
|
|
34
38
|
| "INVALID_USAGE" // wrong flags or arguments
|
|
35
39
|
| "UNKNOWN_COMMAND"
|
|
36
40
|
| "UNKNOWN_FLAG"
|
|
37
41
|
| "MISSING_ARGUMENT"
|
|
42
|
+
| "FIELDS_UNMATCHED" // the call ran, but a --fields path matched nothing in the result
|
|
38
43
|
| "CALL_FAILED"; // anything else
|
|
39
44
|
|
|
40
45
|
export interface Issue {
|
|
@@ -80,6 +85,7 @@ export function exitCodeFor(code: IssueCode): 1 | 2 {
|
|
|
80
85
|
case "UNKNOWN_COMMAND":
|
|
81
86
|
case "UNKNOWN_FLAG":
|
|
82
87
|
case "MISSING_ARGUMENT":
|
|
88
|
+
case "FIELDS_UNMATCHED":
|
|
83
89
|
case "TTY_REQUIRED":
|
|
84
90
|
case "CONFIRMATION_REQUIRED":
|
|
85
91
|
return 2;
|
|
@@ -91,13 +97,13 @@ export function exitCodeFor(code: IssueCode): 1 | 2 {
|
|
|
91
97
|
/** Interpret an SDK error result: HTTP status, body, transport, validation. */
|
|
92
98
|
export function classifyApiError(
|
|
93
99
|
error: unknown,
|
|
94
|
-
context: { bin: string; hadCredential: boolean; docsUrl: string | null },
|
|
100
|
+
context: { bin: string; envPrefix: string; hadCredential: boolean; docsUrl: string | null; requiredScopes?: string[]; canLogin?: boolean },
|
|
95
101
|
): EnvelopeInput {
|
|
96
|
-
const e = (error ?? {}) as { name?: string; message?: string; status?: number; body?: unknown; violations?: unknown; direction?: string; target?: string; response?: { requestId?: string } };
|
|
102
|
+
const e = (error ?? {}) as { name?: string; message?: string; status?: number; code?: unknown; body?: unknown; violations?: unknown; direction?: string; target?: string; rateLimit?: { retryAt?: Date }; response?: { requestId?: string } };
|
|
97
103
|
// The message is the API's own words when it sent any, else the SDK's
|
|
98
|
-
// (
|
|
99
|
-
//
|
|
100
|
-
//
|
|
104
|
+
// ("HTTP 404"). The error class name rides in detail, not in front of the
|
|
105
|
+
// message: "NotFoundError: No such account (no such account)" said one
|
|
106
|
+
// thing three times.
|
|
101
107
|
const base = e.message ? e.message : String(error);
|
|
102
108
|
if (e.violations !== undefined) {
|
|
103
109
|
const nextStep = e.direction === "response"
|
|
@@ -107,12 +113,12 @@ export function classifyApiError(
|
|
|
107
113
|
: "Correct the body fields named in detail.violations to match their constraints, then run the command again.";
|
|
108
114
|
return { code: "VALIDATION_FAILED", message: base, detail: { ...(e.name ? { error: e.name } : {}), violations: e.violations }, nextSteps: [nextStep] };
|
|
109
115
|
}
|
|
110
|
-
if (e.name === "TransportError" || (typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(base))) {
|
|
116
|
+
if (e.name === "TransportError" || (e.name !== "PaginationError" && typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(base))) {
|
|
111
117
|
return {
|
|
112
118
|
code: "NETWORK_ERROR",
|
|
113
119
|
message: base,
|
|
114
120
|
nextSteps: [
|
|
115
|
-
"Check the base URL (--base-url, the " + context.
|
|
121
|
+
"Check the base URL (--base-url, the " + context.envPrefix + "_BASE_URL variable, or '" + context.bin + " config base-url') and the network.",
|
|
116
122
|
"Retry once with backoff; do not loop.",
|
|
117
123
|
],
|
|
118
124
|
};
|
|
@@ -125,30 +131,93 @@ export function classifyApiError(
|
|
|
125
131
|
? (e.body as Record<string, unknown>).request_id ?? (e.body as Record<string, unknown>).requestId
|
|
126
132
|
: undefined;
|
|
127
133
|
const requestId = e.response?.requestId ?? (typeof bodyRequestId === "string" ? bodyRequestId : undefined);
|
|
128
|
-
|
|
134
|
+
// An in-band GraphQL error keeps the vendor's code next to the normalized one.
|
|
135
|
+
const vendorCode = e.name === "GraphQLRequestError" ? graphqlVendorCode(error) : undefined;
|
|
136
|
+
const detail = { status, ...(e.name ? { error: e.name } : {}), ...(vendorCode ? { vendor_code: vendorCode } : {}), ...(requestId ? { request_id: requestId } : {}), ...(e.body !== undefined ? { body: e.body } : {}) };
|
|
129
137
|
const same = apiMessage !== undefined && (apiMessage.toLowerCase() === base.toLowerCase() || base.toLowerCase().includes(apiMessage.toLowerCase()) || apiMessage.toLowerCase().includes(base.toLowerCase()));
|
|
130
138
|
const message = apiMessage === undefined ? base : same ? apiMessage : apiMessage + " (" + base + ")";
|
|
131
139
|
const upgradeUrl = extractUrl(e.body, ["upgrade_url", "upgradeUrl", "signup_url", "claim_url"]);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
: { status: "action_required", code: "NO_AUTH", message, detail, nextSteps: ["No credential was sent. Set the auth env var, pass --token, or run '" + context.bin + " login'.", "'" + context.bin + " auth check' shows what the CLI would send."] };
|
|
140
|
+
// A rate limit can arrive as a 403 (GitHub); the SDK marks it either way.
|
|
141
|
+
if (status === 429 || e.rateLimit !== undefined) {
|
|
142
|
+
return { status: "action_required", code: "RATE_LIMITED", message, detail: { ...detail, ...(e.rateLimit?.retryAt ? { retry_at: isoSeconds(e.rateLimit.retryAt) } : {}) }, nextSteps: [rateLimitNextStep(e.rateLimit?.retryAt, "run the same command again")] };
|
|
136
143
|
}
|
|
137
|
-
|
|
144
|
+
// A failure the API reported inside a 2xx body: classify by its code.
|
|
145
|
+
const scopes = context.requiredScopes ?? [];
|
|
146
|
+
const scopeFailure = (): EnvelopeInput => ({ code: "INSUFFICIENT_SCOPE", message: message + " This operation requires the OAuth scopes: " + scopes.join(", ") + ".", detail: { ...detail, required_scopes: scopes }, nextSteps: [
|
|
147
|
+
context.canLogin ? "Sign in again with those scopes: '" + context.bin + " login --scopes " + scopes.join(",") + "'." : "Use a token granted those scopes.",
|
|
148
|
+
"If the token already has them, the account may lack access to this resource.",
|
|
149
|
+
] });
|
|
150
|
+
if (e.name === "PayloadError") {
|
|
151
|
+
if (scopes.length && /^missing_scope$/i.test(String(e.code))) return scopeFailure();
|
|
152
|
+
const reported = payloadFailureCode(e.code);
|
|
153
|
+
if (reported === "AUTH_INVALID") return { code: "AUTH_INVALID", message, detail, nextSteps: ["The API rejected the credential (" + String(e.code) + "). Check it is current: '" + context.bin + " auth check'."] };
|
|
154
|
+
if (reported === "RATE_LIMITED") return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: ["Wait, then run the same command again."] };
|
|
155
|
+
return { code: "CALL_FAILED", message, detail, nextSteps: ["The API reported a failure in a successful response; detail.body says why."] };
|
|
156
|
+
}
|
|
157
|
+
const unauthenticated = (): EnvelopeInput => context.hadCredential
|
|
158
|
+
? { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential was rejected. Check it is current: '" + context.bin + " auth check', then '" + context.bin + " login --help' to store a new one."] }
|
|
159
|
+
: { status: "action_required", code: "NO_AUTH", message, detail, nextSteps: ["No credential was sent. Set the auth env var, pass --token, or run '" + context.bin + " login'.", "'" + context.bin + " auth check' shows what the CLI would send."] };
|
|
160
|
+
const forbidden = (): EnvelopeInput => scopes.length ? scopeFailure() : { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential lacks access to this operation."] };
|
|
161
|
+
// An in-band GraphQL error (HTTP 200): classified by the vendor's code.
|
|
162
|
+
if (e.name === "GraphQLRequestError") {
|
|
163
|
+
switch (vendorCode === undefined ? undefined : graphqlErrorClass(vendorCode)) {
|
|
164
|
+
case "not_found": return { code: "NOT_FOUND", message, detail, nextSteps: ["Check the id in the arguments; list the resource first to find the right one."] };
|
|
165
|
+
case "unauthenticated": return unauthenticated();
|
|
166
|
+
case "forbidden": return forbidden();
|
|
167
|
+
case "bad_input": return { code: "INVALID_REQUEST", message, detail, nextSteps: ["The API rejected an argument or the selection; the errors in detail.body name it (message, path). Fix that and run the command again."] };
|
|
168
|
+
case "rate_limited": return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: [rateLimitNextStep(undefined, "run the same command again")] };
|
|
169
|
+
default: return { code: "CALL_FAILED", message, detail };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (status === 401) return unauthenticated();
|
|
173
|
+
if (status === 403) return forbidden();
|
|
138
174
|
if (status === 402) {
|
|
139
175
|
return { status: "action_required", code: "PLAN_LIMIT", message, detail, nextSteps: [upgradeUrl ? "Lift the limit at " + upgradeUrl + ", then run the same command again." : "The account's plan stops here; upgrade it, then run the same command again.", "Do not retry the same call as is."] };
|
|
140
176
|
}
|
|
141
177
|
if (status === 404) return { code: "NOT_FOUND", message, detail, nextSteps: notFoundNextSteps(message) };
|
|
142
|
-
if (status ===
|
|
143
|
-
const retryAfter = extractRetryAfter(e);
|
|
144
|
-
return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: [retryAfter ? "Wait " + retryAfter + " seconds, then run the same command again." : "Back off and retry once; the SDK already retried with the server's Retry-After."] };
|
|
145
|
-
}
|
|
146
|
-
if (status === 422 && apiCode === "spec_error") return { code: "SPEC_INVALID", message, detail, nextSteps: ["The API rejected the spec it was given; the message says why.", context.docsUrl ? "Look the message up: '" + context.bin + " docs search \"" + (apiMessage ?? "").slice(0, 60).replace(/"/g, "'") + "\"'." : "Fix the spec and run again."] };
|
|
178
|
+
if (status === 422 && (apiCode === "spec_invalid" || apiCode === "spec_error")) return { code: "SPEC_INVALID", message, detail, nextSteps: ["The API rejected the spec it was given; the message says why.", context.docsUrl ? "Look the message up: '" + context.bin + " docs search \"" + (apiMessage ?? "").slice(0, 60).replace(/"/g, "'") + "\"'." : "Fix the spec and run again."] };
|
|
147
179
|
if (status === 400 || status === 422 || status === 409 || status === 413) return { code: "INVALID_REQUEST", message, detail, nextSteps: ["Read detail.body for the field the API named; run the command with --help for its flags."] };
|
|
148
180
|
if (status >= 500) return { code: "SERVER_ERROR", message, detail, nextSteps: ["Retry once with backoff. If it persists, report detail.request_id."] };
|
|
149
181
|
return { code: "CALL_FAILED", message, detail };
|
|
150
182
|
}
|
|
151
183
|
|
|
184
|
+
/** Login, saved-session and credential-store failures, recognised by error
|
|
185
|
+
* name (so this module needs no login runtime) on the error or its cause.
|
|
186
|
+
* Undefined when the error is none of them. */
|
|
187
|
+
export function classifyAuthFailure(
|
|
188
|
+
error: unknown,
|
|
189
|
+
context: { bin: string; envVars: string[]; storeVariable: string },
|
|
190
|
+
): EnvelopeInput | undefined {
|
|
191
|
+
const login = "'" + context.bin + " login'";
|
|
192
|
+
const environment = context.envVars.length ? ["Or supply credentials through the environment: " + context.envVars.join(", ") + "."] : [];
|
|
193
|
+
for (let node = error as { name?: unknown; message?: unknown; code?: unknown; cause?: unknown } | undefined, depth = 0; node && typeof node === "object" && depth < 4; node = node.cause as typeof node, depth++) {
|
|
194
|
+
const message = typeof node.message === "string" && node.message ? node.message : "Login failed.";
|
|
195
|
+
switch (node.name) {
|
|
196
|
+
case "DeviceFlowUnavailableError":
|
|
197
|
+
return { status: "action_required", code: "LOGIN_FAILED", message, nextSteps: ["Run " + login + " without --device to sign in through the browser.", ...environment] };
|
|
198
|
+
case "LoginPollingError":
|
|
199
|
+
if (node.code === "request_failed" && typeof (node as { providerError?: unknown }).providerError === "string") return { code: "LOGIN_FAILED", message, nextSteps: ["The provider rejected the login request. Check the client ID and that the application is registered for this sign-in flow, then run " + login + " again.", ...environment] };
|
|
200
|
+
if (node.code === "request_failed") return { code: "LOGIN_FAILED", message, nextSteps: ["Check the network and the login service, then run " + login + " again.", ...environment] };
|
|
201
|
+
if (node.code === "invalid_response") return { code: "LOGIN_FAILED", message, nextSteps: ["The login service's response did not match its contract. Retry " + login + " once; if it persists, report it to the API owner.", ...environment] };
|
|
202
|
+
return { code: "LOGIN_FAILED", message, nextSteps: ["Run " + login + " again and approve the new request.", ...environment] };
|
|
203
|
+
case "OAuthResponseError":
|
|
204
|
+
if (node.code === "request_failed" || node.code === "timed_out") return { code: "NETWORK_ERROR", message: "The OAuth provider could not be reached (" + String(node.code).replace("_", " ") + ").", nextSteps: ["Check the network and the provider, then run " + login + " again."] };
|
|
205
|
+
return { code: "LOGIN_FAILED", message: "The OAuth provider returned an unusable response (" + String(node.code).replace(/_/g, " ") + ").", nextSteps: ["Run " + login + " again. If it keeps failing, report it to the API owner.", ...environment] };
|
|
206
|
+
case "OAuthLoginError":
|
|
207
|
+
return { code: "LOGIN_FAILED", message, nextSteps: ["Run " + login + " again. If it keeps failing, the OAuth application settings may need attention from the API owner.", ...environment] };
|
|
208
|
+
case "OAuthSessionError":
|
|
209
|
+
return { status: "action_required", code: "LOGIN_REQUIRED", message, nextSteps: ["Run " + login + " to sign in again.", ...environment] };
|
|
210
|
+
case "CredentialStorageError":
|
|
211
|
+
return { status: "action_required", code: "CREDENTIAL_STORE_UNAVAILABLE", message, nextSteps: [
|
|
212
|
+
"Unlock or enable the OS credential store (macOS Keychain, Linux Secret Service or Windows DPAPI) and retry.",
|
|
213
|
+
...environment,
|
|
214
|
+
"To use a plaintext file store instead, set " + context.storeVariable + "=file and run " + login + ".",
|
|
215
|
+
] };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
152
221
|
/** A file lookup needs path guidance, not the generic advice for a missing
|
|
153
222
|
* resource id. Prefer the API's own index when its message names one. */
|
|
154
223
|
function notFoundNextSteps(message: string): string[] {
|
|
@@ -174,12 +243,46 @@ function extractUrl(body: unknown, keys: string[]): string | undefined {
|
|
|
174
243
|
return undefined;
|
|
175
244
|
}
|
|
176
245
|
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
246
|
+
function isoSeconds(at: Date): string {
|
|
247
|
+
return new Date(Math.ceil(at.getTime() / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** "Wait until <time>" when the API said when the limit resets. */
|
|
251
|
+
export function rateLimitNextStep(retryAt: Date | undefined, then: string): string {
|
|
252
|
+
return retryAt instanceof Date && !Number.isNaN(retryAt.getTime())
|
|
253
|
+
? "Rate limited: wait until " + isoSeconds(retryAt) + ", then " + then + "."
|
|
254
|
+
: "Rate limited: back off, then " + then + "; the SDK already honored any Retry-After within its ceiling.";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The vendor's own code on an in-band GraphQL error: the first error's
|
|
258
|
+
* extensions.code, or its type (GitHub). */
|
|
259
|
+
function graphqlVendorCode(error: unknown): string | undefined {
|
|
260
|
+
const first = (error as { errors?: { extensions?: { code?: unknown }; type?: unknown }[] } | null)?.errors?.[0];
|
|
261
|
+
const code = first?.extensions?.code ?? first?.type;
|
|
262
|
+
return typeof code === "string" && code !== "" ? code : undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** GraphQL error codes whose meaning is settled (Apollo's standard codes,
|
|
266
|
+
* GitHub's types, Linear's codes), by recovery class. Any other code is
|
|
267
|
+
* passed through as is, with no guessed next step. The MCP server's
|
|
268
|
+
* classification uses the same table. */
|
|
269
|
+
function graphqlErrorClass(code: string): "not_found" | "unauthenticated" | "forbidden" | "bad_input" | "rate_limited" | undefined {
|
|
270
|
+
const c = code.toUpperCase();
|
|
271
|
+
if (c === "NOT_FOUND") return "not_found";
|
|
272
|
+
if (c === "UNAUTHENTICATED" || c === "AUTHENTICATION_ERROR") return "unauthenticated";
|
|
273
|
+
if (c === "FORBIDDEN") return "forbidden";
|
|
274
|
+
if (c === "BAD_USER_INPUT" || c === "GRAPHQL_VALIDATION_FAILED" || c === "GRAPHQL_PARSE_FAILED" || c === "INPUT_ERROR") return "bad_input";
|
|
275
|
+
if (c === "RATE_LIMITED" || c === "RATELIMITED") return "rate_limited";
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Error codes APIs report inside a 2xx body (Slack's `error`), mapped to
|
|
280
|
+
* the stable codes when their meaning is unambiguous. */
|
|
281
|
+
export function payloadFailureCode(code: unknown): "AUTH_INVALID" | "RATE_LIMITED" | undefined {
|
|
282
|
+
if (typeof code !== "string") return undefined;
|
|
283
|
+
if (/^(not_authed|invalid_auth|token_revoked|token_expired|account_inactive|unauthorized|unauthenticated|forbidden|access_denied|missing_scope)$/i.test(code)) return "AUTH_INVALID";
|
|
284
|
+
if (/^(ratelimited|rate_limited|rate_limit_exceeded|too_many_requests)$/i.test(code)) return "RATE_LIMITED";
|
|
285
|
+
return undefined;
|
|
183
286
|
}
|
|
184
287
|
|
|
185
288
|
// ---- agent mode -------------------------------------------------------------
|
|
@@ -243,6 +346,33 @@ export interface McpClient {
|
|
|
243
346
|
write: (existing: string, name: string, entry: McpEntry) => string;
|
|
244
347
|
/** True when the client cannot speak the current MCP protocol; skipped by --all. */
|
|
245
348
|
incompatible?: string;
|
|
349
|
+
/**
|
|
350
|
+
* How the client expands an environment variable inside a header value.
|
|
351
|
+
* Entries carry `${VAR}`; each client gets its own spelling, and a client
|
|
352
|
+
* that expands nothing gets no Authorization header rather than a literal
|
|
353
|
+
* one it would send as-is. Defaults to "dollar".
|
|
354
|
+
*/
|
|
355
|
+
headerEnv?: "dollar" | "env-colon" | "brace-env" | "none";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const ENV_REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
359
|
+
|
|
360
|
+
/** Rewrite `${VAR}` header references into the client's syntax, or drop the headers that hold one. */
|
|
361
|
+
function entryForClient(client: McpClient, entry: McpEntry): { entry: McpEntry; note?: string } {
|
|
362
|
+
const style = client.headerEnv ?? "dollar";
|
|
363
|
+
if (!entry.headers || style === "dollar") return { entry };
|
|
364
|
+
const headers: Record<string, string> = {};
|
|
365
|
+
const dropped: string[] = [];
|
|
366
|
+
for (const [name, value] of Object.entries(entry.headers)) {
|
|
367
|
+
if (style === "none" && value.search(ENV_REF) !== -1) dropped.push(name);
|
|
368
|
+
else headers[name] = style === "env-colon" ? value.replace(ENV_REF, "${env:$1}") : style === "brace-env" ? value.replace(ENV_REF, "{env:$1}") : value;
|
|
369
|
+
}
|
|
370
|
+
const next: McpEntry = { ...entry };
|
|
371
|
+
delete next.headers;
|
|
372
|
+
if (Object.keys(headers).length) next.headers = headers;
|
|
373
|
+
return dropped.length
|
|
374
|
+
? { entry: next, note: client.label + " does not expand environment variables in headers, so the entry omits " + dropped.join(", ") + "; the client signs in with MCP authorization when the server supports it." }
|
|
375
|
+
: { entry: next };
|
|
246
376
|
}
|
|
247
377
|
|
|
248
378
|
function readOr(file: string, fallback: string): string {
|
|
@@ -280,9 +410,14 @@ function tomlMerge(existing: string, name: string, entry: McpEntry): string {
|
|
|
280
410
|
const lines: string[] = [header];
|
|
281
411
|
if (entry.url) {
|
|
282
412
|
lines.push("url = " + JSON.stringify(entry.url));
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
|
|
413
|
+
const envHeaders: string[] = [];
|
|
414
|
+
for (const [header, value] of Object.entries(entry.headers ?? {})) {
|
|
415
|
+
const envRef = /\$\{([A-Z0-9_]+)\}/.exec(value)?.[1];
|
|
416
|
+
if (!envRef) continue;
|
|
417
|
+
if (header.toLowerCase() === "authorization" && /^Bearer /.test(value)) lines.push("bearer_token_env_var = " + JSON.stringify(envRef));
|
|
418
|
+
else envHeaders.push(JSON.stringify(header) + " = " + JSON.stringify(envRef));
|
|
419
|
+
}
|
|
420
|
+
if (envHeaders.length) lines.push("env_http_headers = { " + envHeaders.join(", ") + " }");
|
|
286
421
|
} else {
|
|
287
422
|
lines.push("command = " + JSON.stringify(entry.command ?? "node"));
|
|
288
423
|
lines.push("args = " + JSON.stringify(entry.args ?? []));
|
|
@@ -316,6 +451,7 @@ export const MCP_CLIENTS: McpClient[] = [
|
|
|
316
451
|
label: "VS Code",
|
|
317
452
|
file: (cwd) => join(cwd, ".vscode", "mcp.json"),
|
|
318
453
|
detect: (cwd) => existsSync(join(cwd, ".vscode")) || existsSync(join(home(), ".vscode")),
|
|
454
|
+
headerEnv: "env-colon",
|
|
319
455
|
write: (existing, name, entry) => jsonMerge(existing, ["servers"], name, standardEntry(entry)),
|
|
320
456
|
},
|
|
321
457
|
{
|
|
@@ -323,6 +459,7 @@ export const MCP_CLIENTS: McpClient[] = [
|
|
|
323
459
|
label: "Windsurf",
|
|
324
460
|
file: () => join(home(), ".codeium", "windsurf", "mcp_config.json"),
|
|
325
461
|
detect: () => existsSync(join(home(), ".codeium", "windsurf")),
|
|
462
|
+
headerEnv: "env-colon",
|
|
326
463
|
write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, entry.url ? { serverUrl: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : stdioEntry(entry)),
|
|
327
464
|
},
|
|
328
465
|
{
|
|
@@ -330,6 +467,7 @@ export const MCP_CLIENTS: McpClient[] = [
|
|
|
330
467
|
label: "Gemini CLI",
|
|
331
468
|
file: () => join(home(), ".gemini", "settings.json"),
|
|
332
469
|
detect: () => existsSync(join(home(), ".gemini")),
|
|
470
|
+
headerEnv: "none",
|
|
333
471
|
write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, entry.url ? { httpUrl: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : stdioEntry(entry)),
|
|
334
472
|
},
|
|
335
473
|
{
|
|
@@ -337,6 +475,7 @@ export const MCP_CLIENTS: McpClient[] = [
|
|
|
337
475
|
label: "OpenCode",
|
|
338
476
|
file: () => join(xdg(), "opencode", "opencode.json"),
|
|
339
477
|
detect: () => existsSync(join(xdg(), "opencode")),
|
|
478
|
+
headerEnv: "brace-env",
|
|
340
479
|
write: (existing, name, entry) => jsonMerge(existing, ["mcp"], name, entry.url ? { type: "remote", url: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : { type: "local", command: [entry.command ?? "node", ...(entry.args ?? [])] }),
|
|
341
480
|
},
|
|
342
481
|
{
|
|
@@ -344,6 +483,7 @@ export const MCP_CLIENTS: McpClient[] = [
|
|
|
344
483
|
label: "Zed",
|
|
345
484
|
file: () => join(xdg(), "zed", "settings.json"),
|
|
346
485
|
detect: () => existsSync(join(xdg(), "zed")),
|
|
486
|
+
headerEnv: "none",
|
|
347
487
|
write: (existing, name, entry) => jsonMerge(existing, ["context_servers"], name, entry.url ? { source: "custom", url: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : { source: "custom", command: entry.command ?? "node", args: entry.args ?? [] }),
|
|
348
488
|
},
|
|
349
489
|
{
|
|
@@ -362,10 +502,18 @@ export const MCP_CLIENTS: McpClient[] = [
|
|
|
362
502
|
label: "Cursor",
|
|
363
503
|
file: (cwd) => join(cwd, ".cursor", "mcp.json"),
|
|
364
504
|
detect: () => existsSync(join(home(), ".cursor")),
|
|
505
|
+
headerEnv: "env-colon",
|
|
365
506
|
write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, standardEntry(entry)),
|
|
366
507
|
},
|
|
367
508
|
];
|
|
368
509
|
|
|
510
|
+
// Every client's write receives entries in its own env-reference syntax,
|
|
511
|
+
// whether it is called through writeMcpConfig or directly.
|
|
512
|
+
for (const client of MCP_CLIENTS) {
|
|
513
|
+
const write = client.write;
|
|
514
|
+
client.write = (existing, name, entry) => write(existing, name, entryForClient(client, entry).entry);
|
|
515
|
+
}
|
|
516
|
+
|
|
369
517
|
export function findMcpClient(id: string): McpClient | undefined {
|
|
370
518
|
return MCP_CLIENTS.find((c) => c.id === id);
|
|
371
519
|
}
|
|
@@ -377,17 +525,19 @@ export interface McpWriteResult {
|
|
|
377
525
|
note?: string;
|
|
378
526
|
}
|
|
379
527
|
|
|
380
|
-
/** Merge the entry into one client's config file. Never writes a literal secret: callers pass
|
|
528
|
+
/** Merge the entry into one client's config file. Never writes a literal secret: callers pass `${VAR}` references, which each client receives in its own syntax. */
|
|
381
529
|
export function writeMcpConfig(client: McpClient, cwd: string, name: string, entry: McpEntry): McpWriteResult {
|
|
382
530
|
const file = client.file(cwd);
|
|
383
531
|
if (!entry.url && client.id === "claude-desktop" && !entry.command) {
|
|
384
532
|
return { client: client.id, file, written: false, note: "Claude Desktop reads only stdio servers from its config file; add a remote server as a connector in the app." };
|
|
385
533
|
}
|
|
386
534
|
const existing = readOr(file, "");
|
|
535
|
+
const adapted = entryForClient(client, entry);
|
|
387
536
|
const next = client.write(existing, name, entry);
|
|
388
537
|
mkdirSync(dirname(file), { recursive: true });
|
|
389
538
|
writeFileSync(file, next);
|
|
390
|
-
|
|
539
|
+
const note = [client.incompatible, adapted.note].filter(Boolean).join(" ");
|
|
540
|
+
return { client: client.id, file, written: true, ...(note ? { note } : {}) };
|
|
391
541
|
}
|
|
392
542
|
|
|
393
543
|
/** Does a client's config already mention this server? For doctor. */
|
|
@@ -469,10 +619,13 @@ export interface AgentContext {
|
|
|
469
619
|
version: string;
|
|
470
620
|
envPrefix: string;
|
|
471
621
|
authEnvVars: string[];
|
|
622
|
+
/** The API Spec is silent on authentication: say so instead of "no auth". */
|
|
623
|
+
authNotDeclared?: boolean;
|
|
472
624
|
docsUrl: string | null;
|
|
473
625
|
docsIndexUrl?: string | null;
|
|
474
626
|
generatedOperationCount?: number;
|
|
475
|
-
|
|
627
|
+
/** Operations in the API left out of a capped build; api.json lists them. */
|
|
628
|
+
omittedOperationCount?: number;
|
|
476
629
|
/** The API's hosted MCP endpoint, when it has one. */
|
|
477
630
|
mcpUrl: string | null;
|
|
478
631
|
/** Skills repository (owner/name) an agent can install with npx skills add. */
|
|
@@ -481,7 +634,6 @@ export interface AgentContext {
|
|
|
481
634
|
builtins: string[];
|
|
482
635
|
}
|
|
483
636
|
|
|
484
|
-
/** One line per command, pipe-delimited: the compact index that goes into AGENTS.md. */
|
|
485
637
|
/** string, number, usd|eur, string[], usd|eur[], object, json — the type as a reader expects it. */
|
|
486
638
|
export function flagTypeLabel(f: CommandFlagSummary): string {
|
|
487
639
|
const inline = (values: string[] | undefined) => values && values.join("|").length <= 24 ? values.join("|") : values ? "enum" : undefined;
|
|
@@ -489,6 +641,7 @@ export function flagTypeLabel(f: CommandFlagSummary): string {
|
|
|
489
641
|
return inline(f.enum) ?? f.type;
|
|
490
642
|
}
|
|
491
643
|
|
|
644
|
+
/** One line per command, pipe-delimited: the compact index that goes into AGENTS.md. */
|
|
492
645
|
export function compactIndex(commands: CommandSummary[]): string {
|
|
493
646
|
return commands
|
|
494
647
|
.map((c) => {
|
|
@@ -498,27 +651,55 @@ export function compactIndex(commands: CommandSummary[]): string {
|
|
|
498
651
|
.join("\n");
|
|
499
652
|
}
|
|
500
653
|
|
|
654
|
+
/** Bytes of command index the guide and AGENTS.md carry before they switch to one line per resource. */
|
|
655
|
+
export const COMMAND_INDEX_BUDGET = 8_000;
|
|
656
|
+
const RESOURCE_INDEX_NAMES = 12;
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* One line per resource with its first command names, for an API whose
|
|
660
|
+
* per-command index would not fit COMMAND_INDEX_BUDGET. Resources past the
|
|
661
|
+
* budget are counted, not listed; help --json has them all.
|
|
662
|
+
*/
|
|
663
|
+
export function resourceIndex(bin: string, commands: CommandSummary[]): string {
|
|
664
|
+
const byResource = new Map<string, string[]>();
|
|
665
|
+
for (const c of commands) byResource.set(c.resource, [...(byResource.get(c.resource) ?? []), c.command]);
|
|
666
|
+
const lines: string[] = [];
|
|
667
|
+
let bytes = 0;
|
|
668
|
+
let listed = 0;
|
|
669
|
+
for (const [resource, names] of byResource) {
|
|
670
|
+
const more = names.length - RESOURCE_INDEX_NAMES;
|
|
671
|
+
const line = resource + ": " + names.slice(0, RESOURCE_INDEX_NAMES).join(", ") + (more > 0 ? ", … +" + more + " more" : "");
|
|
672
|
+
if (bytes + line.length + 1 > COMMAND_INDEX_BUDGET) break;
|
|
673
|
+
lines.push(line);
|
|
674
|
+
bytes += line.length + 1;
|
|
675
|
+
listed++;
|
|
676
|
+
}
|
|
677
|
+
if (listed < byResource.size) lines.push("… " + (byResource.size - listed) + " more resources: " + bin + " help --json");
|
|
678
|
+
return lines.join("\n");
|
|
679
|
+
}
|
|
680
|
+
|
|
501
681
|
/** The AGENTS.md block body. */
|
|
502
682
|
export function agentBlock(ctx: AgentContext, commands: CommandSummary[]): string {
|
|
503
683
|
const auth = ctx.authEnvVars.length ? ctx.authEnvVars.join(", ") : "(none)";
|
|
504
|
-
const
|
|
684
|
+
const docsIndex = ctx.docsIndexUrl ?? (ctx.docsUrl ? ctx.docsUrl.replace(/\/+$/, "") + "/llms.txt" : null);
|
|
685
|
+
const index = compactIndex(commands);
|
|
505
686
|
return [
|
|
506
687
|
"## " + ctx.bin + " CLI (" + ctx.apiTitle + ")",
|
|
507
688
|
"",
|
|
508
689
|
"API commands write JSON on stdout; discovery commands take --json. Errors are JSON on stderr ({status, issues[{code,message}], next_steps}), exit 0/1/2. Non-interactive under an agent: no prompts, no browsers.",
|
|
509
690
|
"",
|
|
510
|
-
...((ctx.
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
691
|
+
...((ctx.omittedOperationCount ?? 0) > 0 ? ["- Coverage: this build includes " + (ctx.generatedOperationCount ?? commands.length) + " of " + ((ctx.generatedOperationCount ?? commands.length) + ctx.omittedOperationCount!) + " operations; api.json lists the rest, and calling one returns `PLAN_LIMIT`."] : []),
|
|
692
|
+
ctx.authNotDeclared
|
|
693
|
+
? "- Auth: not declared by the API Spec. If the API needs a token, set " + auth + " or run `" + ctx.bin + " login`; other headers go in `--header \"Name: value\"` or " + ctx.envPrefix + "_HEADERS. Never write a key into a file in this repo."
|
|
694
|
+
: "- Auth: " + auth + " in the environment, or `" + ctx.bin + " login`. Never write a key into a file in this repo.",
|
|
695
|
+
"- Discover: `" + ctx.bin + " help --json` indexes resources and command names; `" + ctx.bin + " help <resource> --json` lists a resource's commands; `" + ctx.bin + " help <resource> <command> --json` gives one command's flags. `" + ctx.bin + " help --json --all` prints every command with every flag at once. Humans: `" + ctx.bin + " --help`, `" + ctx.bin + " <resource> <command> --help`.",
|
|
696
|
+
"- Docs: " + (docsIndex ? "`" + ctx.bin + " docs search <term> --json`; " + docsIndex : "`" + ctx.bin + " docs <resource> <command> --json` (a docs URL was not provided at generate time)") + ".",
|
|
514
697
|
"- Lists: `--all` streams every page as NDJSON. Destructive commands need `--force`.",
|
|
515
698
|
...(ctx.hasMcp ? ["- MCP: `" + ctx.bin + " mcp install --all` registers this API's MCP server with the agent clients on this machine" + (ctx.mcpUrl ? " (hosted: " + ctx.mcpUrl + ")" : "") + "."] : []),
|
|
516
699
|
"",
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
compactIndex(commands),
|
|
521
|
-
"```",
|
|
700
|
+
...(Buffer.byteLength(index) <= COMMAND_INDEX_BUDGET
|
|
701
|
+
? ["Commands (resource command | METHOD path | required flags | summary):", "", "```", index, "```"]
|
|
702
|
+
: ["Commands by resource (" + commands.length + " commands; `" + ctx.bin + " help <resource> --json` lists a resource's commands with summaries):", "", "```", resourceIndex(ctx.bin, commands), "```"]),
|
|
522
703
|
].join("\n");
|
|
523
704
|
}
|
|
524
705
|
|
|
@@ -535,12 +716,12 @@ export function agentGuide(ctx: AgentContext, commands: CommandSummary[]): Recor
|
|
|
535
716
|
package: ctx.pkg,
|
|
536
717
|
api: ctx.apiTitle,
|
|
537
718
|
version: ctx.version,
|
|
538
|
-
generated_by: "
|
|
719
|
+
generated_by: "Typeship",
|
|
539
720
|
guide: agentBlock(ctx, commands),
|
|
540
721
|
first_command: first ? ctx.bin + " " + first.resource + " " + first.command : ctx.bin + " --help",
|
|
541
722
|
docs_index_url: docsIndexUrl,
|
|
542
723
|
docs_full_url: docsFullUrl,
|
|
543
|
-
...((ctx.
|
|
724
|
+
...((ctx.omittedOperationCount ?? 0) > 0 ? { coverage: { generated_operations: ctx.generatedOperationCount ?? commands.length, total_operations: (ctx.generatedOperationCount ?? commands.length) + ctx.omittedOperationCount! } } : {}),
|
|
544
725
|
hosted_mcp_url: ctx.mcpUrl,
|
|
545
726
|
local_mcp: ctx.hasMcp ? ctx.bin + "-mcp (stdio) or '" + ctx.bin + " mcp install --all'" : null,
|
|
546
727
|
skills_install: ctx.skillsRepo ? "npx skills add " + ctx.skillsRepo : null,
|
|
@@ -553,12 +734,13 @@ export function agentGuide(ctx: AgentContext, commands: CommandSummary[]): Recor
|
|
|
553
734
|
destructive: "Commands classified as destructive need --force (or --yes); without it they return CONFIRMATION_REQUIRED with the exact command to run.",
|
|
554
735
|
flags: "Positional path arguments first, then --flags. Array flags take a comma list, the flag repeated, or a JSON array; object flags take JSON. --data '<json>' merges under field flags (@<file> reads a file, - reads stdin).",
|
|
555
736
|
pagination: "Paginated commands return {items, hasMore, nextPage, nextCommand}: run nextCommand for the next page, or --all walks every page.",
|
|
737
|
+
discovery: "help --json is a bounded index of resources and command names. help <resource> --json pages through one resource's commands (next_command), help <resource> <command> --json is one command's flags, docs <resource> <command> --json its complete schemas, and docs search <term> --json finds commands by topic. help --json --all prints every command with every flag; on a large API it is large.",
|
|
556
738
|
},
|
|
557
739
|
builtins: ctx.builtins,
|
|
558
740
|
next_steps: [
|
|
559
741
|
...(ctx.authEnvVars.length ? ["Set " + ctx.authEnvVars[0] + " in the environment or run '" + ctx.bin + " login'."] : []),
|
|
560
742
|
"Run '" + ctx.bin + " auth check'.",
|
|
561
|
-
"Run '" + ctx.bin + " help --json' for the command index,
|
|
743
|
+
"Run '" + ctx.bin + " help --json' for the command index, then '" + ctx.bin + " help <resource> <command> --json' for the command you need.",
|
|
562
744
|
...(ctx.hasMcp ? ["Run '" + ctx.bin + " mcp install --all' if this session has an MCP-capable client."] : []),
|
|
563
745
|
],
|
|
564
746
|
};
|
|
@@ -704,3 +886,126 @@ export function summarizeDoctor(checks: DoctorCheck[]): { status: "ok" | "action
|
|
|
704
886
|
next_steps: failing.map((c) => c.fix).filter((f): f is string => Boolean(f)),
|
|
705
887
|
};
|
|
706
888
|
}
|
|
889
|
+
|
|
890
|
+
/** Every OAuth scope an operation's security requirements name, in order. */
|
|
891
|
+
export function requiredScopes(security: Record<string, string[]>[] | undefined): string[] {
|
|
892
|
+
return [...new Set((security ?? []).flatMap((requirement) => Object.values(requirement).flat()))];
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ---- request preview (--dry-run) ----------------------------------------------
|
|
896
|
+
|
|
897
|
+
/** What --dry-run prints: the request an API command would send, with
|
|
898
|
+
* every credential replaced by "<redacted>". Nothing in it was sent. */
|
|
899
|
+
export interface RequestPreview {
|
|
900
|
+
dry_run: true;
|
|
901
|
+
sent: false;
|
|
902
|
+
message: string;
|
|
903
|
+
request: {
|
|
904
|
+
method: string;
|
|
905
|
+
url: string;
|
|
906
|
+
path: string;
|
|
907
|
+
query: Record<string, string | string[]>;
|
|
908
|
+
headers: Record<string, string>;
|
|
909
|
+
body?: unknown;
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export const REDACTED = "<redacted>";
|
|
914
|
+
|
|
915
|
+
/** Header and query names that carry credentials, whatever the API calls
|
|
916
|
+
* them: Authorization, Cookie, X-Api-Key, api_key, access_token, a session
|
|
917
|
+
* or signature. */
|
|
918
|
+
const SENSITIVE_NAME = /^(authorization|proxy-authorization|cookie|cookie2|set-cookie)$|api[-_]?key|apikey|token|secret|password|passwd|session|signature|credential|^key$|^auth$/i;
|
|
919
|
+
|
|
920
|
+
function redactText(text: string, secrets: string[]): string {
|
|
921
|
+
let out = text;
|
|
922
|
+
for (const secret of secrets) if (secret.length >= 4) out = out.split(secret).join(REDACTED);
|
|
923
|
+
return out;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The request a dry run captured, as --dry-run shows it. `sensitiveNames`
|
|
928
|
+
* are the header and query names the API's security schemes bind; `secrets`
|
|
929
|
+
* are the resolved credential values, redacted wherever they appear (a
|
|
930
|
+
* token pasted into a body field, a key in a URL).
|
|
931
|
+
*/
|
|
932
|
+
export async function requestPreview(
|
|
933
|
+
input: { method: string; url: string; headers: Record<string, string>; body?: unknown },
|
|
934
|
+
options: { sensitiveNames?: string[]; secrets?: string[] } = {},
|
|
935
|
+
): Promise<RequestPreview> {
|
|
936
|
+
const secrets = [...new Set((options.secrets ?? []).filter((s) => typeof s === "string" && s.length >= 4))].sort((a, b) => b.length - a.length);
|
|
937
|
+
const named = new Set((options.sensitiveNames ?? []).map((n) => n.toLowerCase()));
|
|
938
|
+
const sensitive = (name: string) => named.has(name.toLowerCase()) || SENSITIVE_NAME.test(name);
|
|
939
|
+
const url = new URL(input.url);
|
|
940
|
+
const query: Record<string, string | string[]> = {};
|
|
941
|
+
for (const [name, raw] of url.searchParams) {
|
|
942
|
+
const value = sensitive(name) ? REDACTED : redactText(raw, secrets);
|
|
943
|
+
const previous = query[name];
|
|
944
|
+
query[name] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value];
|
|
945
|
+
}
|
|
946
|
+
const encodedSecrets = [...secrets, ...secrets.map(encodeURIComponent)];
|
|
947
|
+
const search = [...url.searchParams].map(([name, raw]) => encodeURIComponent(name) + "=" + (sensitive(name) ? REDACTED : redactText(encodeURIComponent(raw), encodedSecrets))).join("&");
|
|
948
|
+
const shownUrl = url.origin + redactText(url.pathname, encodedSecrets) + (search ? "?" + search : "");
|
|
949
|
+
const headers: Record<string, string> = {};
|
|
950
|
+
for (const [name, value] of Object.entries(input.headers)) {
|
|
951
|
+
headers[name] = sensitive(name) ? REDACTED : redactText(value, secrets);
|
|
952
|
+
}
|
|
953
|
+
const contentType = Object.entries(input.headers).find(([name]) => name.toLowerCase() === "content-type")?.[1] ?? "";
|
|
954
|
+
const body = await previewBody(input.body, contentType, secrets);
|
|
955
|
+
return {
|
|
956
|
+
dry_run: true,
|
|
957
|
+
sent: false,
|
|
958
|
+
message: "Dry run: nothing was sent to the API. This is the request the command would send, with credentials redacted.",
|
|
959
|
+
request: {
|
|
960
|
+
method: input.method,
|
|
961
|
+
url: shownUrl,
|
|
962
|
+
path: redactText(url.pathname, encodedSecrets),
|
|
963
|
+
query,
|
|
964
|
+
headers,
|
|
965
|
+
...(body !== undefined ? { body } : {}),
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function previewBody(body: unknown, contentType: string, secrets: string[]): Promise<unknown> {
|
|
971
|
+
if (body === undefined || body === null) return undefined;
|
|
972
|
+
const redactValue = (value: unknown): unknown => {
|
|
973
|
+
if (typeof value === "string") return redactText(value, secrets);
|
|
974
|
+
if (Array.isArray(value)) return value.map(redactValue);
|
|
975
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, SENSITIVE_NAME.test(k) && typeof v === "string" && secrets.some((s) => v.includes(s)) ? REDACTED : redactValue(v)]));
|
|
976
|
+
return value;
|
|
977
|
+
};
|
|
978
|
+
if (typeof body === "string") {
|
|
979
|
+
if (/json/i.test(contentType)) {
|
|
980
|
+
try { return redactValue(JSON.parse(body)); } catch { /* shown as text */ }
|
|
981
|
+
}
|
|
982
|
+
return redactText(body, secrets);
|
|
983
|
+
}
|
|
984
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
985
|
+
const parts: Record<string, unknown>[] = [];
|
|
986
|
+
for (const [name, value] of body.entries()) {
|
|
987
|
+
parts.push(typeof value === "string"
|
|
988
|
+
? { name, value: redactText(value, secrets) }
|
|
989
|
+
: { name, filename: (value as File).name, ...((value as Blob).type ? { type: (value as Blob).type } : {}), bytes: (value as Blob).size });
|
|
990
|
+
}
|
|
991
|
+
return { multipart: parts };
|
|
992
|
+
}
|
|
993
|
+
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return redactText(body.toString(), secrets);
|
|
994
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) return { binary: true, bytes: body.size, ...(body.type ? { type: body.type } : {}) };
|
|
995
|
+
if (body instanceof Uint8Array || body instanceof ArrayBuffer) return { binary: true, bytes: body.byteLength };
|
|
996
|
+
return { stream: true };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** --dry-run for a person: the request line, then query, headers and body. */
|
|
1000
|
+
export function formatRequestPreview(preview: RequestPreview): string {
|
|
1001
|
+
const { request } = preview;
|
|
1002
|
+
const lines = ["Dry run: nothing was sent. Credentials are redacted.", "", request.method + " " + request.url];
|
|
1003
|
+
const headers = Object.entries(request.headers);
|
|
1004
|
+
if (headers.length) lines.push("", "Headers:", ...headers.map(([name, value]) => " " + name + ": " + value));
|
|
1005
|
+
if (request.body !== undefined) {
|
|
1006
|
+
lines.push("", "Body:");
|
|
1007
|
+
const text = typeof request.body === "string" ? request.body : JSON.stringify(request.body, null, 2);
|
|
1008
|
+
lines.push(...text.split("\n").map((line) => " " + line));
|
|
1009
|
+
}
|
|
1010
|
+
return lines.join("\n") + "\n";
|
|
1011
|
+
}
|