@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.
Files changed (146) hide show
  1. package/AGENTS.md +13 -9
  2. package/README.md +14 -27
  3. package/api.json +8898 -9026
  4. package/api.md +370 -328
  5. package/dist/arguments.d.ts +54 -0
  6. package/dist/arguments.d.ts.map +1 -0
  7. package/dist/arguments.js +265 -0
  8. package/dist/cli-agent.d.ts +73 -9
  9. package/dist/cli-agent.d.ts.map +1 -1
  10. package/dist/cli-agent.js +331 -44
  11. package/dist/cli.js +802 -291
  12. package/dist/core/http.d.ts +162 -19
  13. package/dist/core/http.d.ts.map +1 -1
  14. package/dist/core/http.js +381 -48
  15. package/dist/core/pagination.d.ts +42 -6
  16. package/dist/core/pagination.d.ts.map +1 -1
  17. package/dist/core/pagination.js +111 -17
  18. package/dist/credential-storage.d.ts +10 -3
  19. package/dist/credential-storage.d.ts.map +1 -1
  20. package/dist/credential-storage.js +15 -6
  21. package/dist/dates.d.ts +1 -1
  22. package/dist/dates.js +1 -1
  23. package/dist/errors.d.ts +20 -84
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +20 -108
  26. package/dist/fields.d.ts +36 -0
  27. package/dist/fields.d.ts.map +1 -0
  28. package/dist/fields.js +187 -0
  29. package/dist/index.d.ts +28 -18
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +35 -25
  32. package/dist/named-credentials.d.ts +19 -0
  33. package/dist/named-credentials.d.ts.map +1 -1
  34. package/dist/named-credentials.js +81 -1
  35. package/dist/oauth-login.d.ts +8 -2
  36. package/dist/oauth-login.d.ts.map +1 -1
  37. package/dist/oauth-login.js +31 -19
  38. package/dist/oauth-request.d.ts +7 -1
  39. package/dist/oauth-request.d.ts.map +1 -1
  40. package/dist/oauth-request.js +26 -4
  41. package/dist/oauth-session.d.ts +13 -1
  42. package/dist/oauth-session.d.ts.map +1 -1
  43. package/dist/oauth-session.js +34 -18
  44. package/dist/ops.d.ts +58 -5
  45. package/dist/ops.d.ts.map +1 -1
  46. package/dist/ops.js +110 -41
  47. package/dist/polling-login.d.ts +8 -2
  48. package/dist/polling-login.d.ts.map +1 -1
  49. package/dist/polling-login.js +25 -11
  50. package/dist/resources/api-keys.d.ts +10 -7
  51. package/dist/resources/api-keys.d.ts.map +1 -1
  52. package/dist/resources/api-keys.js +10 -31
  53. package/dist/resources/deliveries.d.ts +89 -5
  54. package/dist/resources/deliveries.d.ts.map +1 -1
  55. package/dist/resources/deliveries.js +96 -19
  56. package/dist/resources/drafts.d.ts +16 -16
  57. package/dist/resources/drafts.d.ts.map +1 -1
  58. package/dist/resources/drafts.js +12 -65
  59. package/dist/resources/files.d.ts +4 -4
  60. package/dist/resources/files.d.ts.map +1 -1
  61. package/dist/resources/files.js +3 -12
  62. package/dist/resources/generations.d.ts +16 -16
  63. package/dist/resources/generations.d.ts.map +1 -1
  64. package/dist/resources/generations.js +23 -47
  65. package/dist/resources/organization.d.ts +4 -4
  66. package/dist/resources/organization.d.ts.map +1 -1
  67. package/dist/resources/organization.js +3 -10
  68. package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
  69. package/dist/resources/packages.d.ts.map +1 -0
  70. package/dist/resources/{generate.js → packages.js} +13 -29
  71. package/dist/resources/projects.d.ts +50 -50
  72. package/dist/resources/projects.d.ts.map +1 -1
  73. package/dist/resources/projects.js +60 -116
  74. package/dist/resources/releases.d.ts +22 -17
  75. package/dist/resources/releases.d.ts.map +1 -1
  76. package/dist/resources/releases.js +19 -40
  77. package/dist/resources/spec-revisions.d.ts +16 -7
  78. package/dist/resources/spec-revisions.d.ts.map +1 -1
  79. package/dist/resources/spec-revisions.js +7 -29
  80. package/dist/resources/specs.d.ts +7 -7
  81. package/dist/resources/specs.d.ts.map +1 -1
  82. package/dist/resources/specs.js +6 -34
  83. package/dist/resources/targets.d.ts +49 -49
  84. package/dist/resources/targets.d.ts.map +1 -1
  85. package/dist/resources/targets.js +59 -115
  86. package/dist/schemas.d.ts.map +1 -1
  87. package/dist/schemas.js +83 -81
  88. package/dist/search.d.ts +54 -0
  89. package/dist/search.d.ts.map +1 -0
  90. package/dist/search.js +421 -0
  91. package/dist/table.d.ts +28 -0
  92. package/dist/table.d.ts.map +1 -0
  93. package/dist/table.js +167 -0
  94. package/dist/type-docs.d.ts +61 -0
  95. package/dist/type-docs.d.ts.map +1 -0
  96. package/dist/type-docs.js +174 -0
  97. package/dist/types.d.ts +499 -339
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js +18 -18
  100. package/package.json +5 -2
  101. package/src/arguments.ts +254 -0
  102. package/src/cli-agent.ts +351 -46
  103. package/src/cli.ts +753 -266
  104. package/src/core/http.ts +457 -58
  105. package/src/core/pagination.ts +129 -18
  106. package/src/credential-storage.ts +16 -6
  107. package/src/dates.ts +1 -1
  108. package/src/errors.ts +46 -115
  109. package/src/fields.ts +167 -0
  110. package/src/index.ts +45 -28
  111. package/src/named-credentials.ts +66 -1
  112. package/src/oauth-login.ts +36 -21
  113. package/src/oauth-request.ts +32 -6
  114. package/src/oauth-session.ts +37 -19
  115. package/src/ops.ts +146 -45
  116. package/src/polling-login.ts +24 -11
  117. package/src/resources/api-keys.ts +34 -48
  118. package/src/resources/deliveries.ts +213 -32
  119. package/src/resources/drafts.ts +62 -109
  120. package/src/resources/files.ts +19 -20
  121. package/src/resources/generations.ts +61 -79
  122. package/src/resources/organization.ts +11 -16
  123. package/src/resources/{generate.ts → packages.ts} +43 -51
  124. package/src/resources/projects.ts +145 -200
  125. package/src/resources/releases.ts +50 -67
  126. package/src/resources/spec-revisions.ts +40 -49
  127. package/src/resources/specs.ts +39 -59
  128. package/src/resources/targets.ts +144 -194
  129. package/src/schemas.ts +83 -81
  130. package/src/search.ts +434 -0
  131. package/src/table.ts +167 -0
  132. package/src/type-docs.ts +205 -0
  133. package/src/types.ts +538 -357
  134. package/dist/console-login-check.d.ts +0 -21
  135. package/dist/console-login-check.d.ts.map +0 -1
  136. package/dist/console-login-check.js +0 -107
  137. package/dist/console-login-contract.d.ts +0 -45
  138. package/dist/console-login-contract.d.ts.map +0 -1
  139. package/dist/console-login-contract.js +0 -40
  140. package/dist/resources/generate.d.ts.map +0 -1
  141. package/dist/resources/publications.d.ts +0 -47
  142. package/dist/resources/publications.d.ts.map +0 -1
  143. package/dist/resources/publications.js +0 -70
  144. package/src/console-login-check.ts +0 -88
  145. package/src/console-login-contract.ts +0 -65
  146. package/src/resources/publications.ts +0 -140
package/dist/cli-agent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The agent contract of a generated CLI. Generated by typeship — https://typeship.dev
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
@@ -27,6 +27,7 @@ export function exitCodeFor(code) {
27
27
  case "UNKNOWN_COMMAND":
28
28
  case "UNKNOWN_FLAG":
29
29
  case "MISSING_ARGUMENT":
30
+ case "FIELDS_UNMATCHED":
30
31
  case "TTY_REQUIRED":
31
32
  case "CONFIRMATION_REQUIRED":
32
33
  return 2;
@@ -38,9 +39,9 @@ export function exitCodeFor(code) {
38
39
  export function classifyApiError(error, context) {
39
40
  const e = (error ?? {});
40
41
  // The message is the API's own words when it sent any, else the SDK's
41
- // (the spec's response description). The error class name rides in
42
- // detail, not in front of the message: "NotFoundError: No such account
43
- // (no such account)" said one thing three times.
42
+ // ("HTTP 404"). The error class name rides in detail, not in front of the
43
+ // message: "NotFoundError: No such account (no such account)" said one
44
+ // thing three times.
44
45
  const base = e.message ? e.message : String(error);
45
46
  if (e.violations !== undefined) {
46
47
  const nextStep = e.direction === "response"
@@ -50,12 +51,12 @@ export function classifyApiError(error, context) {
50
51
  : "Correct the body fields named in detail.violations to match their constraints, then run the command again.";
51
52
  return { code: "VALIDATION_FAILED", message: base, detail: { ...(e.name ? { error: e.name } : {}), violations: e.violations }, nextSteps: [nextStep] };
52
53
  }
53
- if (e.name === "TransportError" || (typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(base))) {
54
+ if (e.name === "TransportError" || (e.name !== "PaginationError" && typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(base))) {
54
55
  return {
55
56
  code: "NETWORK_ERROR",
56
57
  message: base,
57
58
  nextSteps: [
58
- "Check the base URL (--base-url, the " + context.bin.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_BASE_URL variable, or '" + context.bin + " config base-url') and the network.",
59
+ "Check the base URL (--base-url, the " + context.envPrefix + "_BASE_URL variable, or '" + context.bin + " config base-url') and the network.",
59
60
  "Retry once with backoff; do not loop.",
60
61
  ],
61
62
  };
@@ -68,27 +69,57 @@ export function classifyApiError(error, context) {
68
69
  ? e.body.request_id ?? e.body.requestId
69
70
  : undefined;
70
71
  const requestId = e.response?.requestId ?? (typeof bodyRequestId === "string" ? bodyRequestId : undefined);
71
- const detail = { status, ...(e.name ? { error: e.name } : {}), ...(requestId ? { request_id: requestId } : {}), ...(e.body !== undefined ? { body: e.body } : {}) };
72
+ // An in-band GraphQL error keeps the vendor's code next to the normalized one.
73
+ const vendorCode = e.name === "GraphQLRequestError" ? graphqlVendorCode(error) : undefined;
74
+ const detail = { status, ...(e.name ? { error: e.name } : {}), ...(vendorCode ? { vendor_code: vendorCode } : {}), ...(requestId ? { request_id: requestId } : {}), ...(e.body !== undefined ? { body: e.body } : {}) };
72
75
  const same = apiMessage !== undefined && (apiMessage.toLowerCase() === base.toLowerCase() || base.toLowerCase().includes(apiMessage.toLowerCase()) || apiMessage.toLowerCase().includes(base.toLowerCase()));
73
76
  const message = apiMessage === undefined ? base : same ? apiMessage : apiMessage + " (" + base + ")";
74
77
  const upgradeUrl = extractUrl(e.body, ["upgrade_url", "upgradeUrl", "signup_url", "claim_url"]);
75
- if (status === 401) {
76
- return context.hadCredential
77
- ? { 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."] }
78
- : { 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."] };
78
+ // A rate limit can arrive as a 403 (GitHub); the SDK marks it either way.
79
+ if (status === 429 || e.rateLimit !== undefined) {
80
+ 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")] };
79
81
  }
82
+ // A failure the API reported inside a 2xx body: classify by its code.
83
+ const scopes = context.requiredScopes ?? [];
84
+ const scopeFailure = () => ({ code: "INSUFFICIENT_SCOPE", message: message + " This operation requires the OAuth scopes: " + scopes.join(", ") + ".", detail: { ...detail, required_scopes: scopes }, nextSteps: [
85
+ context.canLogin ? "Sign in again with those scopes: '" + context.bin + " login --scopes " + scopes.join(",") + "'." : "Use a token granted those scopes.",
86
+ "If the token already has them, the account may lack access to this resource.",
87
+ ] });
88
+ if (e.name === "PayloadError") {
89
+ if (scopes.length && /^missing_scope$/i.test(String(e.code)))
90
+ return scopeFailure();
91
+ const reported = payloadFailureCode(e.code);
92
+ if (reported === "AUTH_INVALID")
93
+ return { code: "AUTH_INVALID", message, detail, nextSteps: ["The API rejected the credential (" + String(e.code) + "). Check it is current: '" + context.bin + " auth check'."] };
94
+ if (reported === "RATE_LIMITED")
95
+ return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: ["Wait, then run the same command again."] };
96
+ return { code: "CALL_FAILED", message, detail, nextSteps: ["The API reported a failure in a successful response; detail.body says why."] };
97
+ }
98
+ const unauthenticated = () => context.hadCredential
99
+ ? { 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."] }
100
+ : { 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."] };
101
+ const forbidden = () => scopes.length ? scopeFailure() : { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential lacks access to this operation."] };
102
+ // An in-band GraphQL error (HTTP 200): classified by the vendor's code.
103
+ if (e.name === "GraphQLRequestError") {
104
+ switch (vendorCode === undefined ? undefined : graphqlErrorClass(vendorCode)) {
105
+ 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."] };
106
+ case "unauthenticated": return unauthenticated();
107
+ case "forbidden": return forbidden();
108
+ 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."] };
109
+ case "rate_limited": return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: [rateLimitNextStep(undefined, "run the same command again")] };
110
+ default: return { code: "CALL_FAILED", message, detail };
111
+ }
112
+ }
113
+ if (status === 401)
114
+ return unauthenticated();
80
115
  if (status === 403)
81
- return { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential lacks access to this operation."] };
116
+ return forbidden();
82
117
  if (status === 402) {
83
118
  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."] };
84
119
  }
85
120
  if (status === 404)
86
121
  return { code: "NOT_FOUND", message, detail, nextSteps: notFoundNextSteps(message) };
87
- if (status === 429) {
88
- const retryAfter = extractRetryAfter(e);
89
- 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."] };
90
- }
91
- if (status === 422 && apiCode === "spec_error")
122
+ if (status === 422 && (apiCode === "spec_invalid" || apiCode === "spec_error"))
92
123
  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."] };
93
124
  if (status === 400 || status === 422 || status === 409 || status === 413)
94
125
  return { code: "INVALID_REQUEST", message, detail, nextSteps: ["Read detail.body for the field the API named; run the command with --help for its flags."] };
@@ -96,6 +127,43 @@ export function classifyApiError(error, context) {
96
127
  return { code: "SERVER_ERROR", message, detail, nextSteps: ["Retry once with backoff. If it persists, report detail.request_id."] };
97
128
  return { code: "CALL_FAILED", message, detail };
98
129
  }
130
+ /** Login, saved-session and credential-store failures, recognised by error
131
+ * name (so this module needs no login runtime) on the error or its cause.
132
+ * Undefined when the error is none of them. */
133
+ export function classifyAuthFailure(error, context) {
134
+ const login = "'" + context.bin + " login'";
135
+ const environment = context.envVars.length ? ["Or supply credentials through the environment: " + context.envVars.join(", ") + "."] : [];
136
+ for (let node = error, depth = 0; node && typeof node === "object" && depth < 4; node = node.cause, depth++) {
137
+ const message = typeof node.message === "string" && node.message ? node.message : "Login failed.";
138
+ switch (node.name) {
139
+ case "DeviceFlowUnavailableError":
140
+ return { status: "action_required", code: "LOGIN_FAILED", message, nextSteps: ["Run " + login + " without --device to sign in through the browser.", ...environment] };
141
+ case "LoginPollingError":
142
+ if (node.code === "request_failed" && typeof node.providerError === "string")
143
+ 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] };
144
+ if (node.code === "request_failed")
145
+ return { code: "LOGIN_FAILED", message, nextSteps: ["Check the network and the login service, then run " + login + " again.", ...environment] };
146
+ if (node.code === "invalid_response")
147
+ 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] };
148
+ return { code: "LOGIN_FAILED", message, nextSteps: ["Run " + login + " again and approve the new request.", ...environment] };
149
+ case "OAuthResponseError":
150
+ if (node.code === "request_failed" || node.code === "timed_out")
151
+ 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."] };
152
+ 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] };
153
+ case "OAuthLoginError":
154
+ 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] };
155
+ case "OAuthSessionError":
156
+ return { status: "action_required", code: "LOGIN_REQUIRED", message, nextSteps: ["Run " + login + " to sign in again.", ...environment] };
157
+ case "CredentialStorageError":
158
+ return { status: "action_required", code: "CREDENTIAL_STORE_UNAVAILABLE", message, nextSteps: [
159
+ "Unlock or enable the OS credential store (macOS Keychain, Linux Secret Service or Windows DPAPI) and retry.",
160
+ ...environment,
161
+ "To use a plaintext file store instead, set " + context.storeVariable + "=file and run " + login + ".",
162
+ ] };
163
+ }
164
+ }
165
+ return undefined;
166
+ }
99
167
  /** A file lookup needs path guidance, not the generic advice for a missing
100
168
  * resource id. Prefer the API's own index when its message names one. */
101
169
  function notFoundNextSteps(message) {
@@ -122,13 +190,50 @@ function extractUrl(body, keys) {
122
190
  }
123
191
  return undefined;
124
192
  }
125
- function extractRetryAfter(e) {
126
- const headers = e.headers;
127
- const fromHeader = headers?.get?.("retry-after");
128
- if (fromHeader)
129
- return fromHeader;
130
- const match = /retry after (\d+)s/i.exec(JSON.stringify(e.body ?? ""));
131
- return match?.[1];
193
+ function isoSeconds(at) {
194
+ return new Date(Math.ceil(at.getTime() / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
195
+ }
196
+ /** "Wait until <time>" when the API said when the limit resets. */
197
+ export function rateLimitNextStep(retryAt, then) {
198
+ return retryAt instanceof Date && !Number.isNaN(retryAt.getTime())
199
+ ? "Rate limited: wait until " + isoSeconds(retryAt) + ", then " + then + "."
200
+ : "Rate limited: back off, then " + then + "; the SDK already honored any Retry-After within its ceiling.";
201
+ }
202
+ /** The vendor's own code on an in-band GraphQL error: the first error's
203
+ * extensions.code, or its type (GitHub). */
204
+ function graphqlVendorCode(error) {
205
+ const first = error?.errors?.[0];
206
+ const code = first?.extensions?.code ?? first?.type;
207
+ return typeof code === "string" && code !== "" ? code : undefined;
208
+ }
209
+ /** GraphQL error codes whose meaning is settled (Apollo's standard codes,
210
+ * GitHub's types, Linear's codes), by recovery class. Any other code is
211
+ * passed through as is, with no guessed next step. The MCP server's
212
+ * classification uses the same table. */
213
+ function graphqlErrorClass(code) {
214
+ const c = code.toUpperCase();
215
+ if (c === "NOT_FOUND")
216
+ return "not_found";
217
+ if (c === "UNAUTHENTICATED" || c === "AUTHENTICATION_ERROR")
218
+ return "unauthenticated";
219
+ if (c === "FORBIDDEN")
220
+ return "forbidden";
221
+ if (c === "BAD_USER_INPUT" || c === "GRAPHQL_VALIDATION_FAILED" || c === "GRAPHQL_PARSE_FAILED" || c === "INPUT_ERROR")
222
+ return "bad_input";
223
+ if (c === "RATE_LIMITED" || c === "RATELIMITED")
224
+ return "rate_limited";
225
+ return undefined;
226
+ }
227
+ /** Error codes APIs report inside a 2xx body (Slack's `error`), mapped to
228
+ * the stable codes when their meaning is unambiguous. */
229
+ export function payloadFailureCode(code) {
230
+ if (typeof code !== "string")
231
+ return undefined;
232
+ if (/^(not_authed|invalid_auth|token_revoked|token_expired|account_inactive|unauthorized|unauthenticated|forbidden|access_denied|missing_scope)$/i.test(code))
233
+ return "AUTH_INVALID";
234
+ if (/^(ratelimited|rate_limited|rate_limit_exceeded|too_many_requests)$/i.test(code))
235
+ return "RATE_LIMITED";
236
+ return undefined;
132
237
  }
133
238
  /**
134
239
  * --mode agent beats <PREFIX>_MODE=agent beats "no terminal on either end".
@@ -166,6 +271,28 @@ export function detectHarness(env = process.env) {
166
271
  return "devin";
167
272
  return null;
168
273
  }
274
+ const ENV_REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
275
+ /** Rewrite `${VAR}` header references into the client's syntax, or drop the headers that hold one. */
276
+ function entryForClient(client, entry) {
277
+ const style = client.headerEnv ?? "dollar";
278
+ if (!entry.headers || style === "dollar")
279
+ return { entry };
280
+ const headers = {};
281
+ const dropped = [];
282
+ for (const [name, value] of Object.entries(entry.headers)) {
283
+ if (style === "none" && value.search(ENV_REF) !== -1)
284
+ dropped.push(name);
285
+ else
286
+ headers[name] = style === "env-colon" ? value.replace(ENV_REF, "${env:$1}") : style === "brace-env" ? value.replace(ENV_REF, "{env:$1}") : value;
287
+ }
288
+ const next = { ...entry };
289
+ delete next.headers;
290
+ if (Object.keys(headers).length)
291
+ next.headers = headers;
292
+ return dropped.length
293
+ ? { 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." }
294
+ : { entry: next };
295
+ }
169
296
  function readOr(file, fallback) {
170
297
  try {
171
298
  return readFileSync(file, "utf8");
@@ -201,10 +328,18 @@ function tomlMerge(existing, name, entry) {
201
328
  const lines = [header];
202
329
  if (entry.url) {
203
330
  lines.push("url = " + JSON.stringify(entry.url));
204
- const auth = entry.headers?.Authorization ?? entry.headers?.authorization;
205
- const envRef = auth ? /\$\{([A-Z0-9_]+)\}/.exec(auth)?.[1] : undefined;
206
- if (envRef)
207
- lines.push("bearer_token_env_var = " + JSON.stringify(envRef));
331
+ const envHeaders = [];
332
+ for (const [header, value] of Object.entries(entry.headers ?? {})) {
333
+ const envRef = /\$\{([A-Z0-9_]+)\}/.exec(value)?.[1];
334
+ if (!envRef)
335
+ continue;
336
+ if (header.toLowerCase() === "authorization" && /^Bearer /.test(value))
337
+ lines.push("bearer_token_env_var = " + JSON.stringify(envRef));
338
+ else
339
+ envHeaders.push(JSON.stringify(header) + " = " + JSON.stringify(envRef));
340
+ }
341
+ if (envHeaders.length)
342
+ lines.push("env_http_headers = { " + envHeaders.join(", ") + " }");
208
343
  }
209
344
  else {
210
345
  lines.push("command = " + JSON.stringify(entry.command ?? "node"));
@@ -238,6 +373,7 @@ export const MCP_CLIENTS = [
238
373
  label: "VS Code",
239
374
  file: (cwd) => join(cwd, ".vscode", "mcp.json"),
240
375
  detect: (cwd) => existsSync(join(cwd, ".vscode")) || existsSync(join(home(), ".vscode")),
376
+ headerEnv: "env-colon",
241
377
  write: (existing, name, entry) => jsonMerge(existing, ["servers"], name, standardEntry(entry)),
242
378
  },
243
379
  {
@@ -245,6 +381,7 @@ export const MCP_CLIENTS = [
245
381
  label: "Windsurf",
246
382
  file: () => join(home(), ".codeium", "windsurf", "mcp_config.json"),
247
383
  detect: () => existsSync(join(home(), ".codeium", "windsurf")),
384
+ headerEnv: "env-colon",
248
385
  write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, entry.url ? { serverUrl: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : stdioEntry(entry)),
249
386
  },
250
387
  {
@@ -252,6 +389,7 @@ export const MCP_CLIENTS = [
252
389
  label: "Gemini CLI",
253
390
  file: () => join(home(), ".gemini", "settings.json"),
254
391
  detect: () => existsSync(join(home(), ".gemini")),
392
+ headerEnv: "none",
255
393
  write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, entry.url ? { httpUrl: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : stdioEntry(entry)),
256
394
  },
257
395
  {
@@ -259,6 +397,7 @@ export const MCP_CLIENTS = [
259
397
  label: "OpenCode",
260
398
  file: () => join(xdg(), "opencode", "opencode.json"),
261
399
  detect: () => existsSync(join(xdg(), "opencode")),
400
+ headerEnv: "brace-env",
262
401
  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 ?? [])] }),
263
402
  },
264
403
  {
@@ -266,6 +405,7 @@ export const MCP_CLIENTS = [
266
405
  label: "Zed",
267
406
  file: () => join(xdg(), "zed", "settings.json"),
268
407
  detect: () => existsSync(join(xdg(), "zed")),
408
+ headerEnv: "none",
269
409
  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 ?? [] }),
270
410
  },
271
411
  {
@@ -284,23 +424,32 @@ export const MCP_CLIENTS = [
284
424
  label: "Cursor",
285
425
  file: (cwd) => join(cwd, ".cursor", "mcp.json"),
286
426
  detect: () => existsSync(join(home(), ".cursor")),
427
+ headerEnv: "env-colon",
287
428
  write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, standardEntry(entry)),
288
429
  },
289
430
  ];
431
+ // Every client's write receives entries in its own env-reference syntax,
432
+ // whether it is called through writeMcpConfig or directly.
433
+ for (const client of MCP_CLIENTS) {
434
+ const write = client.write;
435
+ client.write = (existing, name, entry) => write(existing, name, entryForClient(client, entry).entry);
436
+ }
290
437
  export function findMcpClient(id) {
291
438
  return MCP_CLIENTS.find((c) => c.id === id);
292
439
  }
293
- /** Merge the entry into one client's config file. Never writes a literal secret: callers pass env references. */
440
+ /** 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. */
294
441
  export function writeMcpConfig(client, cwd, name, entry) {
295
442
  const file = client.file(cwd);
296
443
  if (!entry.url && client.id === "claude-desktop" && !entry.command) {
297
444
  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." };
298
445
  }
299
446
  const existing = readOr(file, "");
447
+ const adapted = entryForClient(client, entry);
300
448
  const next = client.write(existing, name, entry);
301
449
  mkdirSync(dirname(file), { recursive: true });
302
450
  writeFileSync(file, next);
303
- return { client: client.id, file, written: true, ...(client.incompatible ? { note: client.incompatible } : {}) };
451
+ const note = [client.incompatible, adapted.note].filter(Boolean).join(" ");
452
+ return { client: client.id, file, written: true, ...(note ? { note } : {}) };
304
453
  }
305
454
  /** Does a client's config already mention this server? For doctor. */
306
455
  export function mcpConfigured(client, cwd, name) {
@@ -346,7 +495,6 @@ export function agentInstructionsFile(cwd) {
346
495
  return claude;
347
496
  return agents;
348
497
  }
349
- /** One line per command, pipe-delimited: the compact index that goes into AGENTS.md. */
350
498
  /** string, number, usd|eur, string[], usd|eur[], object, json — the type as a reader expects it. */
351
499
  export function flagTypeLabel(f) {
352
500
  const inline = (values) => values && values.join("|").length <= 24 ? values.join("|") : values ? "enum" : undefined;
@@ -354,6 +502,7 @@ export function flagTypeLabel(f) {
354
502
  return (inline(f.items?.enum) ?? f.items?.type ?? "json") + "[]";
355
503
  return inline(f.enum) ?? f.type;
356
504
  }
505
+ /** One line per command, pipe-delimited: the compact index that goes into AGENTS.md. */
357
506
  export function compactIndex(commands) {
358
507
  return commands
359
508
  .map((c) => {
@@ -362,27 +511,56 @@ export function compactIndex(commands) {
362
511
  })
363
512
  .join("\n");
364
513
  }
514
+ /** Bytes of command index the guide and AGENTS.md carry before they switch to one line per resource. */
515
+ export const COMMAND_INDEX_BUDGET = 8_000;
516
+ const RESOURCE_INDEX_NAMES = 12;
517
+ /**
518
+ * One line per resource with its first command names, for an API whose
519
+ * per-command index would not fit COMMAND_INDEX_BUDGET. Resources past the
520
+ * budget are counted, not listed; help --json has them all.
521
+ */
522
+ export function resourceIndex(bin, commands) {
523
+ const byResource = new Map();
524
+ for (const c of commands)
525
+ byResource.set(c.resource, [...(byResource.get(c.resource) ?? []), c.command]);
526
+ const lines = [];
527
+ let bytes = 0;
528
+ let listed = 0;
529
+ for (const [resource, names] of byResource) {
530
+ const more = names.length - RESOURCE_INDEX_NAMES;
531
+ const line = resource + ": " + names.slice(0, RESOURCE_INDEX_NAMES).join(", ") + (more > 0 ? ", … +" + more + " more" : "");
532
+ if (bytes + line.length + 1 > COMMAND_INDEX_BUDGET)
533
+ break;
534
+ lines.push(line);
535
+ bytes += line.length + 1;
536
+ listed++;
537
+ }
538
+ if (listed < byResource.size)
539
+ lines.push("… " + (byResource.size - listed) + " more resources: " + bin + " help --json");
540
+ return lines.join("\n");
541
+ }
365
542
  /** The AGENTS.md block body. */
366
543
  export function agentBlock(ctx, commands) {
367
544
  const auth = ctx.authEnvVars.length ? ctx.authEnvVars.join(", ") : "(none)";
368
- const index = ctx.docsIndexUrl ?? (ctx.docsUrl ? ctx.docsUrl.replace(/\/+$/, "") + "/llms.txt" : null);
545
+ const docsIndex = ctx.docsIndexUrl ?? (ctx.docsUrl ? ctx.docsUrl.replace(/\/+$/, "") + "/llms.txt" : null);
546
+ const index = compactIndex(commands);
369
547
  return [
370
548
  "## " + ctx.bin + " CLI (" + ctx.apiTitle + ")",
371
549
  "",
372
550
  "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.",
373
551
  "",
374
- ...((ctx.omittedOperations?.length ?? 0) > 0 ? ["- Plan limit: generated " + (ctx.generatedOperationCount ?? commands.length) + " of " + ((ctx.generatedOperationCount ?? commands.length) + ctx.omittedOperations.length) + " operations. Omitted: " + ctx.omittedOperations.map((op) => "`" + op.tool + "` (" + op.method + " " + op.path + ")").join(", ") + ". These return `PLAN_LIMIT`; upgrade and regenerate before use."] : []),
375
- "- Auth: " + auth + " in the environment, or `" + ctx.bin + " login`. Never write a key into a file in this repo.",
376
- "- Discover: `" + ctx.bin + " --help`, `" + ctx.bin + " <resource> <command> --help`, `" + ctx.bin + " help --json` (machine-readable), `" + ctx.bin + " agent-guide --format json`.",
377
- "- Docs: " + (index ? "`" + ctx.bin + " docs search <term> --json`; " + index : "`" + ctx.bin + " docs <resource> <command> --json` (a docs URL was not provided at generate time)") + ".",
552
+ ...((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`."] : []),
553
+ ctx.authNotDeclared
554
+ ? "- 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."
555
+ : "- Auth: " + auth + " in the environment, or `" + ctx.bin + " login`. Never write a key into a file in this repo.",
556
+ "- 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`.",
557
+ "- Docs: " + (docsIndex ? "`" + ctx.bin + " docs search <term> --json`; " + docsIndex : "`" + ctx.bin + " docs <resource> <command> --json` (a docs URL was not provided at generate time)") + ".",
378
558
  "- Lists: `--all` streams every page as NDJSON. Destructive commands need `--force`.",
379
559
  ...(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 + ")" : "") + "."] : []),
380
560
  "",
381
- "Commands (resource command | METHOD path | required flags | summary):",
382
- "",
383
- "```",
384
- compactIndex(commands),
385
- "```",
561
+ ...(Buffer.byteLength(index) <= COMMAND_INDEX_BUDGET
562
+ ? ["Commands (resource command | METHOD path | required flags | summary):", "", "```", index, "```"]
563
+ : ["Commands by resource (" + commands.length + " commands; `" + ctx.bin + " help <resource> --json` lists a resource's commands with summaries):", "", "```", resourceIndex(ctx.bin, commands), "```"]),
386
564
  ].join("\n");
387
565
  }
388
566
  /** What `agent-guide --format json` returns. */
@@ -401,12 +579,12 @@ export function agentGuide(ctx, commands) {
401
579
  package: ctx.pkg,
402
580
  api: ctx.apiTitle,
403
581
  version: ctx.version,
404
- generated_by: "typeship",
582
+ generated_by: "Typeship",
405
583
  guide: agentBlock(ctx, commands),
406
584
  first_command: first ? ctx.bin + " " + first.resource + " " + first.command : ctx.bin + " --help",
407
585
  docs_index_url: docsIndexUrl,
408
586
  docs_full_url: docsFullUrl,
409
- ...((ctx.omittedOperations?.length ?? 0) > 0 ? { coverage: { generated_operations: ctx.generatedOperationCount ?? commands.length, total_operations: (ctx.generatedOperationCount ?? commands.length) + ctx.omittedOperations.length, omitted_operations: ctx.omittedOperations } } : {}),
587
+ ...((ctx.omittedOperationCount ?? 0) > 0 ? { coverage: { generated_operations: ctx.generatedOperationCount ?? commands.length, total_operations: (ctx.generatedOperationCount ?? commands.length) + ctx.omittedOperationCount } } : {}),
410
588
  hosted_mcp_url: ctx.mcpUrl,
411
589
  local_mcp: ctx.hasMcp ? ctx.bin + "-mcp (stdio) or '" + ctx.bin + " mcp install --all'" : null,
412
590
  skills_install: ctx.skillsRepo ? "npx skills add " + ctx.skillsRepo : null,
@@ -419,12 +597,13 @@ export function agentGuide(ctx, commands) {
419
597
  destructive: "Commands classified as destructive need --force (or --yes); without it they return CONFIRMATION_REQUIRED with the exact command to run.",
420
598
  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).",
421
599
  pagination: "Paginated commands return {items, hasMore, nextPage, nextCommand}: run nextCommand for the next page, or --all walks every page.",
600
+ 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.",
422
601
  },
423
602
  builtins: ctx.builtins,
424
603
  next_steps: [
425
604
  ...(ctx.authEnvVars.length ? ["Set " + ctx.authEnvVars[0] + " in the environment or run '" + ctx.bin + " login'."] : []),
426
605
  "Run '" + ctx.bin + " auth check'.",
427
- "Run '" + ctx.bin + " help --json' for the command index, or read the AGENTS.md block '" + ctx.bin + " init' writes.",
606
+ "Run '" + ctx.bin + " help --json' for the command index, then '" + ctx.bin + " help <resource> <command> --json' for the command you need.",
428
607
  ...(ctx.hasMcp ? ["Run '" + ctx.bin + " mcp install --all' if this session has an MCP-capable client."] : []),
429
608
  ],
430
609
  };
@@ -544,3 +723,111 @@ export function summarizeDoctor(checks) {
544
723
  next_steps: failing.map((c) => c.fix).filter((f) => Boolean(f)),
545
724
  };
546
725
  }
726
+ /** Every OAuth scope an operation's security requirements name, in order. */
727
+ export function requiredScopes(security) {
728
+ return [...new Set((security ?? []).flatMap((requirement) => Object.values(requirement).flat()))];
729
+ }
730
+ export const REDACTED = "<redacted>";
731
+ /** Header and query names that carry credentials, whatever the API calls
732
+ * them: Authorization, Cookie, X-Api-Key, api_key, access_token, a session
733
+ * or signature. */
734
+ const SENSITIVE_NAME = /^(authorization|proxy-authorization|cookie|cookie2|set-cookie)$|api[-_]?key|apikey|token|secret|password|passwd|session|signature|credential|^key$|^auth$/i;
735
+ function redactText(text, secrets) {
736
+ let out = text;
737
+ for (const secret of secrets)
738
+ if (secret.length >= 4)
739
+ out = out.split(secret).join(REDACTED);
740
+ return out;
741
+ }
742
+ /**
743
+ * The request a dry run captured, as --dry-run shows it. `sensitiveNames`
744
+ * are the header and query names the API's security schemes bind; `secrets`
745
+ * are the resolved credential values, redacted wherever they appear (a
746
+ * token pasted into a body field, a key in a URL).
747
+ */
748
+ export async function requestPreview(input, options = {}) {
749
+ const secrets = [...new Set((options.secrets ?? []).filter((s) => typeof s === "string" && s.length >= 4))].sort((a, b) => b.length - a.length);
750
+ const named = new Set((options.sensitiveNames ?? []).map((n) => n.toLowerCase()));
751
+ const sensitive = (name) => named.has(name.toLowerCase()) || SENSITIVE_NAME.test(name);
752
+ const url = new URL(input.url);
753
+ const query = {};
754
+ for (const [name, raw] of url.searchParams) {
755
+ const value = sensitive(name) ? REDACTED : redactText(raw, secrets);
756
+ const previous = query[name];
757
+ query[name] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value];
758
+ }
759
+ const encodedSecrets = [...secrets, ...secrets.map(encodeURIComponent)];
760
+ const search = [...url.searchParams].map(([name, raw]) => encodeURIComponent(name) + "=" + (sensitive(name) ? REDACTED : redactText(encodeURIComponent(raw), encodedSecrets))).join("&");
761
+ const shownUrl = url.origin + redactText(url.pathname, encodedSecrets) + (search ? "?" + search : "");
762
+ const headers = {};
763
+ for (const [name, value] of Object.entries(input.headers)) {
764
+ headers[name] = sensitive(name) ? REDACTED : redactText(value, secrets);
765
+ }
766
+ const contentType = Object.entries(input.headers).find(([name]) => name.toLowerCase() === "content-type")?.[1] ?? "";
767
+ const body = await previewBody(input.body, contentType, secrets);
768
+ return {
769
+ dry_run: true,
770
+ sent: false,
771
+ message: "Dry run: nothing was sent to the API. This is the request the command would send, with credentials redacted.",
772
+ request: {
773
+ method: input.method,
774
+ url: shownUrl,
775
+ path: redactText(url.pathname, encodedSecrets),
776
+ query,
777
+ headers,
778
+ ...(body !== undefined ? { body } : {}),
779
+ },
780
+ };
781
+ }
782
+ async function previewBody(body, contentType, secrets) {
783
+ if (body === undefined || body === null)
784
+ return undefined;
785
+ const redactValue = (value) => {
786
+ if (typeof value === "string")
787
+ return redactText(value, secrets);
788
+ if (Array.isArray(value))
789
+ return value.map(redactValue);
790
+ if (value && typeof value === "object")
791
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, SENSITIVE_NAME.test(k) && typeof v === "string" && secrets.some((s) => v.includes(s)) ? REDACTED : redactValue(v)]));
792
+ return value;
793
+ };
794
+ if (typeof body === "string") {
795
+ if (/json/i.test(contentType)) {
796
+ try {
797
+ return redactValue(JSON.parse(body));
798
+ }
799
+ catch { /* shown as text */ }
800
+ }
801
+ return redactText(body, secrets);
802
+ }
803
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
804
+ const parts = [];
805
+ for (const [name, value] of body.entries()) {
806
+ parts.push(typeof value === "string"
807
+ ? { name, value: redactText(value, secrets) }
808
+ : { name, filename: value.name, ...(value.type ? { type: value.type } : {}), bytes: value.size });
809
+ }
810
+ return { multipart: parts };
811
+ }
812
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams)
813
+ return redactText(body.toString(), secrets);
814
+ if (typeof Blob !== "undefined" && body instanceof Blob)
815
+ return { binary: true, bytes: body.size, ...(body.type ? { type: body.type } : {}) };
816
+ if (body instanceof Uint8Array || body instanceof ArrayBuffer)
817
+ return { binary: true, bytes: body.byteLength };
818
+ return { stream: true };
819
+ }
820
+ /** --dry-run for a person: the request line, then query, headers and body. */
821
+ export function formatRequestPreview(preview) {
822
+ const { request } = preview;
823
+ const lines = ["Dry run: nothing was sent. Credentials are redacted.", "", request.method + " " + request.url];
824
+ const headers = Object.entries(request.headers);
825
+ if (headers.length)
826
+ lines.push("", "Headers:", ...headers.map(([name, value]) => " " + name + ": " + value));
827
+ if (request.body !== undefined) {
828
+ lines.push("", "Body:");
829
+ const text = typeof request.body === "string" ? request.body : JSON.stringify(request.body, null, 2);
830
+ lines.push(...text.split("\n").map((line) => " " + line));
831
+ }
832
+ return lines.join("\n") + "\n";
833
+ }