@typeship-ax/cli 0.22.0 → 0.23.1

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 (138) hide show
  1. package/AGENTS.md +12 -8
  2. package/README.md +14 -27
  3. package/api.json +8898 -9026
  4. package/api.md +369 -327
  5. package/dist/arguments.d.ts +47 -0
  6. package/dist/arguments.d.ts.map +1 -0
  7. package/dist/arguments.js +254 -0
  8. package/dist/cli-agent.d.ts +31 -8
  9. package/dist/cli-agent.d.ts.map +1 -1
  10. package/dist/cli-agent.js +146 -28
  11. package/dist/cli.js +483 -237
  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 +29 -0
  27. package/dist/fields.d.ts.map +1 -0
  28. package/dist/fields.js +101 -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 +53 -5
  45. package/dist/ops.d.ts.map +1 -1
  46. package/dist/ops.js +49 -40
  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 +88 -4
  54. package/dist/resources/deliveries.d.ts.map +1 -1
  55. package/dist/resources/deliveries.js +95 -18
  56. package/dist/resources/drafts.d.ts +15 -15
  57. package/dist/resources/drafts.d.ts.map +1 -1
  58. package/dist/resources/drafts.js +11 -64
  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 +14 -14
  63. package/dist/resources/generations.d.ts.map +1 -1
  64. package/dist/resources/generations.js +21 -45
  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 +21 -16
  75. package/dist/resources/releases.d.ts.map +1 -1
  76. package/dist/resources/releases.js +18 -39
  77. package/dist/resources/spec-revisions.d.ts +15 -6
  78. package/dist/resources/spec-revisions.d.ts.map +1 -1
  79. package/dist/resources/spec-revisions.js +6 -28
  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 +48 -48
  84. package/dist/resources/targets.d.ts.map +1 -1
  85. package/dist/resources/targets.js +58 -114
  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/types.d.ts +499 -339
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/types.js +18 -18
  94. package/package.json +5 -2
  95. package/src/arguments.ts +242 -0
  96. package/src/cli-agent.ts +156 -30
  97. package/src/cli.ts +444 -211
  98. package/src/core/http.ts +457 -58
  99. package/src/core/pagination.ts +129 -18
  100. package/src/credential-storage.ts +16 -6
  101. package/src/dates.ts +1 -1
  102. package/src/errors.ts +46 -115
  103. package/src/fields.ts +91 -0
  104. package/src/index.ts +45 -28
  105. package/src/named-credentials.ts +66 -1
  106. package/src/oauth-login.ts +36 -21
  107. package/src/oauth-request.ts +32 -6
  108. package/src/oauth-session.ts +37 -19
  109. package/src/ops.ts +82 -44
  110. package/src/polling-login.ts +24 -11
  111. package/src/resources/api-keys.ts +34 -48
  112. package/src/resources/deliveries.ts +211 -30
  113. package/src/resources/drafts.ts +60 -107
  114. package/src/resources/files.ts +19 -20
  115. package/src/resources/generations.ts +57 -75
  116. package/src/resources/organization.ts +11 -16
  117. package/src/resources/{generate.ts → packages.ts} +43 -51
  118. package/src/resources/projects.ts +145 -200
  119. package/src/resources/releases.ts +48 -65
  120. package/src/resources/spec-revisions.ts +38 -47
  121. package/src/resources/specs.ts +39 -59
  122. package/src/resources/targets.ts +143 -193
  123. package/src/schemas.ts +83 -81
  124. package/src/search.ts +434 -0
  125. package/src/types.ts +538 -357
  126. package/dist/console-login-check.d.ts +0 -21
  127. package/dist/console-login-check.d.ts.map +0 -1
  128. package/dist/console-login-check.js +0 -107
  129. package/dist/console-login-contract.d.ts +0 -45
  130. package/dist/console-login-contract.d.ts.map +0 -1
  131. package/dist/console-login-contract.js +0 -40
  132. package/dist/resources/generate.d.ts.map +0 -1
  133. package/dist/resources/publications.d.ts +0 -47
  134. package/dist/resources/publications.d.ts.map +0 -1
  135. package/dist/resources/publications.js +0 -70
  136. package/src/console-login-check.ts +0 -88
  137. package/src/console-login-contract.ts +0 -65
  138. package/src/resources/publications.ts +0 -140
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Argument checks shared by the generated CLI and MCP server: coerce a value
3
+ * toward its schema where the intent is unambiguous, and report every
4
+ * problem, nested ones included, with the dotted path of the argument it
5
+ * belongs to. Generated by Typeship — https://typeship.dev
6
+ * No external dependencies.
7
+ */
8
+
9
+ import { dateKindOf, relativeDate } from "./dates.js";
10
+
11
+ export interface ArgumentIssue {
12
+ code: "UNKNOWN_ARGUMENT" | "INVALID_ARGUMENT" | "MISSING_ARGUMENT";
13
+ argument: string;
14
+ message: string;
15
+ }
16
+
17
+ export const normalizeName = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]/g, "");
18
+
19
+ function editDistance(a: string, b: string): number {
20
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
21
+ for (let i = 1; i <= a.length; i++) {
22
+ let diag = prev[0]!;
23
+ prev[0] = i;
24
+ for (let j = 1; j <= b.length; j++) {
25
+ const tmp = prev[j]!;
26
+ prev[j] = Math.min(prev[j]! + 1, prev[j - 1]! + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
27
+ diag = tmp;
28
+ }
29
+ }
30
+ return prev[b.length]!;
31
+ }
32
+
33
+ /** The closest accepted name: same letters ignoring case/punctuation first,
34
+ * then a small edit distance. Undefined when nothing is close. */
35
+ export function closestName(name: string, known: string[]): string | undefined {
36
+ const exact = known.filter((k) => normalizeName(k) === normalizeName(name));
37
+ if (exact.length === 1) return exact[0];
38
+ if (exact.length > 1) return undefined;
39
+ let best: { name: string; d: number } | undefined;
40
+ for (const k of known) {
41
+ const d = editDistance(name.toLowerCase(), k.toLowerCase());
42
+ if (d <= Math.max(1, Math.floor(k.length / 4)) && (best === undefined || d < best.d)) best = { name: k, d };
43
+ }
44
+ return best?.name;
45
+ }
46
+
47
+ function schemaTypes(schema: Record<string, unknown>): string[] {
48
+ const t = schema.type;
49
+ if (typeof t === "string") return [t];
50
+ if (Array.isArray(t)) return t.filter((x): x is string => typeof x === "string");
51
+ return [];
52
+ }
53
+
54
+ /**
55
+ * Coerce one value toward its schema when the intent is unambiguous: the
56
+ * strings agents produce for booleans and numbers, a JSON string for an
57
+ * object or array, a scalar for a one-element array, an enum member in the
58
+ * wrong case. Returns the value to send, or a message when it can't be made
59
+ * to fit. Untyped schemas (unions, anything) pass through. The first
60
+ * problem only; checkValue reports every nested one.
61
+ */
62
+ export function coerceValue(value: unknown, schema: Record<string, unknown>): { value: unknown } | { error: string } {
63
+ const issues: ArgumentIssue[] = [];
64
+ const out = checkValue(value, schema, "", issues);
65
+ return issues.length > 0 ? { error: issues[0]!.message } : { value: out };
66
+ }
67
+
68
+ /** Coerce one scalar-level value (type and enum) without looking inside it. */
69
+ function coerceShallow(value: unknown, schema: Record<string, unknown>): { value: unknown } | { error: string } {
70
+ if (value === null || value === undefined) return { value };
71
+ // Date-shaped arguments take relative forms (-P7D, 7 days ago, today),
72
+ // resolved here so the API sees an absolute value.
73
+ const dateKind = dateKindOf(schema.format);
74
+ if (dateKind && typeof value === "string") {
75
+ const resolved = relativeDate(value, dateKind);
76
+ if (resolved && "error" in resolved) return { error: resolved.error };
77
+ if (resolved) value = resolved.value;
78
+ }
79
+ const types = schemaTypes(schema);
80
+ const enumValues = Array.isArray(schema.enum) ? schema.enum : undefined;
81
+ const accepts = (t: string) => types.length === 0 || types.includes(t);
82
+ const kind = Array.isArray(value) ? "array" : typeof value;
83
+
84
+ let out: unknown = value;
85
+ if (types.length > 0) {
86
+ if (kind === "boolean" && !accepts("boolean")) {
87
+ if (accepts("string")) out = String(value);
88
+ else return { error: "expected " + types.join(" or ") + ", got boolean" };
89
+ } else if (kind === "number" && !accepts("number") && !accepts("integer")) {
90
+ if (accepts("string")) out = String(value);
91
+ else if (accepts("array")) out = [value];
92
+ else return { error: "expected " + types.join(" or ") + ", got number" };
93
+ } else if (kind === "number" && accepts("integer") && !accepts("number") && !Number.isInteger(value)) {
94
+ return { error: "expected an integer, got " + String(value) };
95
+ } else if (kind === "string" && !accepts("string")) {
96
+ const s = (value as string).trim();
97
+ if (accepts("boolean") && /^(true|false|yes|no|1|0)$/i.test(s)) out = /^(true|yes|1)$/i.test(s);
98
+ else if ((accepts("integer") || accepts("number")) && s !== "" && !Number.isNaN(Number(s))) {
99
+ const n = Number(s);
100
+ if (accepts("integer") && !accepts("number") && !Number.isInteger(n)) return { error: "expected an integer, got \"" + s + "\"" };
101
+ out = n;
102
+ } else if ((accepts("object") || accepts("array")) && /^[[{]/.test(s)) {
103
+ try {
104
+ const parsed: unknown = JSON.parse(s);
105
+ const parsedKind = Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed;
106
+ if (!accepts(parsedKind)) return { error: "expected " + types.join(" or ") + ", got a JSON " + parsedKind + " in a string" };
107
+ out = parsed;
108
+ } catch {
109
+ return { error: "expected " + types.join(" or ") + ", got a string that is not valid JSON" };
110
+ }
111
+ } else if (accepts("array")) {
112
+ out = [value];
113
+ } else {
114
+ return { error: "expected " + types.join(" or ") + ", got string" };
115
+ }
116
+ } else if (kind === "object" && !accepts("object")) {
117
+ if (accepts("array")) out = [value];
118
+ else return { error: "expected " + types.join(" or ") + ", got object" };
119
+ } else if (kind === "array" && !accepts("array")) {
120
+ return { error: "expected " + types.join(" or ") + ", got array" };
121
+ }
122
+ }
123
+
124
+ if (enumValues && typeof out === "string" && !enumValues.includes(out)) {
125
+ const match = enumValues.filter((e) => typeof e === "string" && e.toLowerCase() === (out as string).toLowerCase());
126
+ if (match.length === 1) out = match[0];
127
+ else return { error: "must be one of " + enumValues.map((e) => JSON.stringify(e)).join(", ") + ", got " + JSON.stringify(out) };
128
+ }
129
+ return { value: out };
130
+ }
131
+
132
+ /** A nullable single-variant union (`anyOf: [X, {type: "null"}]`, the shape
133
+ * nullable GraphQL enums and objects take) validates as X; any other union
134
+ * passes through unchecked. */
135
+ function nullableVariant(schema: Record<string, unknown>): Record<string, unknown> | undefined {
136
+ const variants = schema.anyOf ?? schema.oneOf;
137
+ if (!Array.isArray(variants) || variants.length !== 2 || schema.type !== undefined) return undefined;
138
+ const isNull = (v: unknown) => Boolean(v) && typeof v === "object" && (v as Record<string, unknown>).type === "null";
139
+ const others = variants.filter((v) => !isNull(v));
140
+ if (others.length !== 1 || !others[0] || typeof others[0] !== "object") return undefined;
141
+ return others[0] as Record<string, unknown>;
142
+ }
143
+
144
+ const MAX_CHECK_DEPTH = 16;
145
+
146
+ /**
147
+ * Check one argument value against its schema, the way prepareCall checks
148
+ * top-level arguments, and return the value to send. Recurses into array
149
+ * items and object properties: types and enums are coerced where the intent
150
+ * is clear, required properties must be present, strings must match their
151
+ * pattern, and keys an object declares closed (`additionalProperties:
152
+ * false`, as every GraphQL input type is) must be known. Every problem is
153
+ * pushed to issues with its dotted path (input.priority, items[0].name).
154
+ */
155
+ export function checkValue(
156
+ value: unknown,
157
+ schema: Record<string, unknown>,
158
+ path: string,
159
+ issues: ArgumentIssue[],
160
+ options: { skipPattern?: boolean; depth?: number } = {},
161
+ ): unknown {
162
+ const depth = options.depth ?? 0;
163
+ const at = (suffix: string) => path === "" ? suffix : path + ": " + suffix;
164
+ if (value === null || value === undefined) return value;
165
+ const variant = nullableVariant(schema);
166
+ if (variant) return checkValue(value, variant, path, issues, options);
167
+
168
+ const shallow = coerceShallow(value, schema);
169
+ if ("error" in shallow) {
170
+ issues.push({ code: "INVALID_ARGUMENT", argument: path, message: at(shallow.error) });
171
+ return value;
172
+ }
173
+ let out = shallow.value;
174
+ if (depth >= MAX_CHECK_DEPTH) return out;
175
+
176
+ if (typeof out === "string" && typeof schema.pattern === "string" && !options.skipPattern) {
177
+ let pattern: RegExp | undefined;
178
+ try { pattern = new RegExp(schema.pattern, "u"); } catch {
179
+ try { pattern = new RegExp(schema.pattern); } catch { /* not an ECMAScript pattern: never false-alarm */ }
180
+ }
181
+ if (pattern && !pattern.test(out)) {
182
+ issues.push({ code: "INVALID_ARGUMENT", argument: path, message: at("must match the pattern " + schema.pattern + ", got " + JSON.stringify(out)) });
183
+ }
184
+ }
185
+
186
+ // Array items: check each against the items schema when it has one.
187
+ if (Array.isArray(out) && schema.items && typeof schema.items === "object" && !Array.isArray(schema.items)) {
188
+ const itemSchema = schema.items as Record<string, unknown>;
189
+ out = out.map((item, i) => checkValue(item, itemSchema, path + "[" + i + "]", issues, { depth: depth + 1 }));
190
+ }
191
+
192
+ // Object properties: the same checks one level down, keyed by dotted path.
193
+ if (out && typeof out === "object" && !Array.isArray(out) && schema.properties && typeof schema.properties === "object") {
194
+ const properties = schema.properties as Record<string, Record<string, unknown>>;
195
+ const known = Object.keys(properties);
196
+ const closed = schema.additionalProperties === false;
197
+ const input = out as Record<string, unknown>;
198
+ const next: Record<string, unknown> = {};
199
+ const child = (key: string) => path === "" ? key : path + "." + key;
200
+ for (const [key, entry] of Object.entries(input)) {
201
+ if (entry === undefined) continue;
202
+ let name = key;
203
+ if (properties[key] === undefined && closed) {
204
+ const close = closestName(key, known);
205
+ if (close !== undefined && normalizeName(close) === normalizeName(key) && input[close] === undefined) {
206
+ name = close;
207
+ } else {
208
+ const accepted = known.length <= 20 ? " Accepted: " + known.join(", ") + "." : "";
209
+ issues.push({
210
+ code: "UNKNOWN_ARGUMENT",
211
+ argument: child(key),
212
+ message: "Unknown property \"" + child(key) + "\"" + (close !== undefined ? "; did you mean \"" + child(close) + "\"?" : ".") + accepted,
213
+ });
214
+ continue;
215
+ }
216
+ }
217
+ const propSchema = properties[name];
218
+ next[name] = propSchema && typeof propSchema === "object"
219
+ ? checkValue(entry, propSchema, child(name), issues, { depth: depth + 1, skipPattern: typeof entry === "string" && isMeReference(name, entry) })
220
+ : entry;
221
+ }
222
+ for (const name of Array.isArray(schema.required) ? schema.required as string[] : []) {
223
+ if (next[name] === undefined && properties[name] !== undefined) {
224
+ const description = properties[name]!.description;
225
+ issues.push({ code: "MISSING_ARGUMENT", argument: child(name), message: "Missing required property \"" + child(name) + "\"" + (description ? ": " + String(description).split("\n")[0] : ".") });
226
+ }
227
+ }
228
+ out = next;
229
+ }
230
+ return out;
231
+ }
232
+
233
+ export function userShapedReference(name: string): boolean {
234
+ const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/ids?$/, "");
235
+ return ["user", "assignee", "owner", "member", "actor", "creator", "account", "profile", "subscriber"].includes(normalized);
236
+ }
237
+
238
+ /** "me" given for a user-shaped argument or property, resolved to the
239
+ * caller's ID through the identity tool. */
240
+ export function isMeReference(name: string, value: string): boolean {
241
+ return value.trim().toLowerCase() === "me" && userShapedReference(name);
242
+ }
package/src/cli-agent.ts 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
@@ -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
- // (the spec's response description). The error class name rides in
99
- // detail, not in front of the message: "NotFoundError: No such account
100
- // (no such account)" said one thing three times.
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.bin.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_BASE_URL variable, or '" + context.bin + " config base-url') and the network.",
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
  };
@@ -129,26 +135,77 @@ export function classifyApiError(
129
135
  const same = apiMessage !== undefined && (apiMessage.toLowerCase() === base.toLowerCase() || base.toLowerCase().includes(apiMessage.toLowerCase()) || apiMessage.toLowerCase().includes(base.toLowerCase()));
130
136
  const message = apiMessage === undefined ? base : same ? apiMessage : apiMessage + " (" + base + ")";
131
137
  const upgradeUrl = extractUrl(e.body, ["upgrade_url", "upgradeUrl", "signup_url", "claim_url"]);
138
+ // A rate limit can arrive as a 403 (GitHub); the SDK marks it either way.
139
+ if (status === 429 || e.rateLimit !== undefined) {
140
+ 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")] };
141
+ }
142
+ // A failure the API reported inside a 2xx body: classify by its code.
143
+ const scopes = context.requiredScopes ?? [];
144
+ const scopeFailure = (): EnvelopeInput => ({ code: "INSUFFICIENT_SCOPE", message: message + " This operation requires the OAuth scopes: " + scopes.join(", ") + ".", detail: { ...detail, required_scopes: scopes }, nextSteps: [
145
+ context.canLogin ? "Sign in again with those scopes: '" + context.bin + " login --scopes " + scopes.join(",") + "'." : "Use a token granted those scopes.",
146
+ "If the token already has them, the account may lack access to this resource.",
147
+ ] });
148
+ if (e.name === "PayloadError") {
149
+ if (scopes.length && /^missing_scope$/i.test(String(e.code))) return scopeFailure();
150
+ const reported = payloadFailureCode(e.code);
151
+ 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'."] };
152
+ if (reported === "RATE_LIMITED") return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: ["Wait, then run the same command again."] };
153
+ return { code: "CALL_FAILED", message, detail, nextSteps: ["The API reported a failure in a successful response; detail.body says why."] };
154
+ }
132
155
  if (status === 401) {
133
156
  return context.hadCredential
134
157
  ? { 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."] }
135
158
  : { 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."] };
136
159
  }
160
+ if (status === 403 && scopes.length) return scopeFailure();
137
161
  if (status === 403) return { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential lacks access to this operation."] };
138
162
  if (status === 402) {
139
163
  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
164
  }
141
165
  if (status === 404) return { code: "NOT_FOUND", message, detail, nextSteps: notFoundNextSteps(message) };
142
- if (status === 429) {
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."] };
166
+ 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
167
  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
168
  if (status >= 500) return { code: "SERVER_ERROR", message, detail, nextSteps: ["Retry once with backoff. If it persists, report detail.request_id."] };
149
169
  return { code: "CALL_FAILED", message, detail };
150
170
  }
151
171
 
172
+ /** Login, saved-session and credential-store failures, recognised by error
173
+ * name (so this module needs no login runtime) on the error or its cause.
174
+ * Undefined when the error is none of them. */
175
+ export function classifyAuthFailure(
176
+ error: unknown,
177
+ context: { bin: string; envVars: string[]; storeVariable: string },
178
+ ): EnvelopeInput | undefined {
179
+ const login = "'" + context.bin + " login'";
180
+ const environment = context.envVars.length ? ["Or supply credentials through the environment: " + context.envVars.join(", ") + "."] : [];
181
+ 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++) {
182
+ const message = typeof node.message === "string" && node.message ? node.message : "Login failed.";
183
+ switch (node.name) {
184
+ case "DeviceFlowUnavailableError":
185
+ return { status: "action_required", code: "LOGIN_FAILED", message, nextSteps: ["Run " + login + " without --device to sign in through the browser.", ...environment] };
186
+ case "LoginPollingError":
187
+ 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] };
188
+ if (node.code === "request_failed") return { code: "LOGIN_FAILED", message, nextSteps: ["Check the network and the login service, then run " + login + " again.", ...environment] };
189
+ 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] };
190
+ return { code: "LOGIN_FAILED", message, nextSteps: ["Run " + login + " again and approve the new request.", ...environment] };
191
+ case "OAuthResponseError":
192
+ 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."] };
193
+ 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] };
194
+ case "OAuthLoginError":
195
+ 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] };
196
+ case "OAuthSessionError":
197
+ return { status: "action_required", code: "LOGIN_REQUIRED", message, nextSteps: ["Run " + login + " to sign in again.", ...environment] };
198
+ case "CredentialStorageError":
199
+ return { status: "action_required", code: "CREDENTIAL_STORE_UNAVAILABLE", message, nextSteps: [
200
+ "Unlock or enable the OS credential store (macOS Keychain, Linux Secret Service or Windows DPAPI) and retry.",
201
+ ...environment,
202
+ "To use a plaintext file store instead, set " + context.storeVariable + "=file and run " + login + ".",
203
+ ] };
204
+ }
205
+ }
206
+ return undefined;
207
+ }
208
+
152
209
  /** A file lookup needs path guidance, not the generic advice for a missing
153
210
  * resource id. Prefer the API's own index when its message names one. */
154
211
  function notFoundNextSteps(message: string): string[] {
@@ -174,12 +231,24 @@ function extractUrl(body: unknown, keys: string[]): string | undefined {
174
231
  return undefined;
175
232
  }
176
233
 
177
- function extractRetryAfter(e: { headers?: unknown; body?: unknown }): string | undefined {
178
- const headers = e.headers as { get?: (name: string) => string | null } | undefined;
179
- const fromHeader = headers?.get?.("retry-after");
180
- if (fromHeader) return fromHeader;
181
- const match = /retry after (\d+)s/i.exec(JSON.stringify(e.body ?? ""));
182
- return match?.[1];
234
+ function isoSeconds(at: Date): string {
235
+ return new Date(Math.ceil(at.getTime() / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
236
+ }
237
+
238
+ /** "Wait until <time>" when the API said when the limit resets. */
239
+ export function rateLimitNextStep(retryAt: Date | undefined, then: string): string {
240
+ return retryAt instanceof Date && !Number.isNaN(retryAt.getTime())
241
+ ? "Rate limited: wait until " + isoSeconds(retryAt) + ", then " + then + "."
242
+ : "Rate limited: back off, then " + then + "; the SDK already honored any Retry-After within its ceiling.";
243
+ }
244
+
245
+ /** Error codes APIs report inside a 2xx body (Slack's `error`), mapped to
246
+ * the stable codes when their meaning is unambiguous. */
247
+ export function payloadFailureCode(code: unknown): "AUTH_INVALID" | "RATE_LIMITED" | undefined {
248
+ if (typeof code !== "string") return undefined;
249
+ if (/^(not_authed|invalid_auth|token_revoked|token_expired|account_inactive|unauthorized|unauthenticated|forbidden|access_denied|missing_scope)$/i.test(code)) return "AUTH_INVALID";
250
+ if (/^(ratelimited|rate_limited|rate_limit_exceeded|too_many_requests)$/i.test(code)) return "RATE_LIMITED";
251
+ return undefined;
183
252
  }
184
253
 
185
254
  // ---- agent mode -------------------------------------------------------------
@@ -243,6 +312,33 @@ export interface McpClient {
243
312
  write: (existing: string, name: string, entry: McpEntry) => string;
244
313
  /** True when the client cannot speak the current MCP protocol; skipped by --all. */
245
314
  incompatible?: string;
315
+ /**
316
+ * How the client expands an environment variable inside a header value.
317
+ * Entries carry `${VAR}`; each client gets its own spelling, and a client
318
+ * that expands nothing gets no Authorization header rather than a literal
319
+ * one it would send as-is. Defaults to "dollar".
320
+ */
321
+ headerEnv?: "dollar" | "env-colon" | "brace-env" | "none";
322
+ }
323
+
324
+ const ENV_REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
325
+
326
+ /** Rewrite `${VAR}` header references into the client's syntax, or drop the headers that hold one. */
327
+ function entryForClient(client: McpClient, entry: McpEntry): { entry: McpEntry; note?: string } {
328
+ const style = client.headerEnv ?? "dollar";
329
+ if (!entry.headers || style === "dollar") return { entry };
330
+ const headers: Record<string, string> = {};
331
+ const dropped: string[] = [];
332
+ for (const [name, value] of Object.entries(entry.headers)) {
333
+ if (style === "none" && value.search(ENV_REF) !== -1) dropped.push(name);
334
+ else headers[name] = style === "env-colon" ? value.replace(ENV_REF, "${env:$1}") : style === "brace-env" ? value.replace(ENV_REF, "{env:$1}") : value;
335
+ }
336
+ const next: McpEntry = { ...entry };
337
+ delete next.headers;
338
+ if (Object.keys(headers).length) next.headers = headers;
339
+ return dropped.length
340
+ ? { 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." }
341
+ : { entry: next };
246
342
  }
247
343
 
248
344
  function readOr(file: string, fallback: string): string {
@@ -280,9 +376,14 @@ function tomlMerge(existing: string, name: string, entry: McpEntry): string {
280
376
  const lines: string[] = [header];
281
377
  if (entry.url) {
282
378
  lines.push("url = " + JSON.stringify(entry.url));
283
- const auth = entry.headers?.Authorization ?? entry.headers?.authorization;
284
- const envRef = auth ? /\$\{([A-Z0-9_]+)\}/.exec(auth)?.[1] : undefined;
285
- if (envRef) lines.push("bearer_token_env_var = " + JSON.stringify(envRef));
379
+ const envHeaders: string[] = [];
380
+ for (const [header, value] of Object.entries(entry.headers ?? {})) {
381
+ const envRef = /\$\{([A-Z0-9_]+)\}/.exec(value)?.[1];
382
+ if (!envRef) continue;
383
+ if (header.toLowerCase() === "authorization" && /^Bearer /.test(value)) lines.push("bearer_token_env_var = " + JSON.stringify(envRef));
384
+ else envHeaders.push(JSON.stringify(header) + " = " + JSON.stringify(envRef));
385
+ }
386
+ if (envHeaders.length) lines.push("env_http_headers = { " + envHeaders.join(", ") + " }");
286
387
  } else {
287
388
  lines.push("command = " + JSON.stringify(entry.command ?? "node"));
288
389
  lines.push("args = " + JSON.stringify(entry.args ?? []));
@@ -316,6 +417,7 @@ export const MCP_CLIENTS: McpClient[] = [
316
417
  label: "VS Code",
317
418
  file: (cwd) => join(cwd, ".vscode", "mcp.json"),
318
419
  detect: (cwd) => existsSync(join(cwd, ".vscode")) || existsSync(join(home(), ".vscode")),
420
+ headerEnv: "env-colon",
319
421
  write: (existing, name, entry) => jsonMerge(existing, ["servers"], name, standardEntry(entry)),
320
422
  },
321
423
  {
@@ -323,6 +425,7 @@ export const MCP_CLIENTS: McpClient[] = [
323
425
  label: "Windsurf",
324
426
  file: () => join(home(), ".codeium", "windsurf", "mcp_config.json"),
325
427
  detect: () => existsSync(join(home(), ".codeium", "windsurf")),
428
+ headerEnv: "env-colon",
326
429
  write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, entry.url ? { serverUrl: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : stdioEntry(entry)),
327
430
  },
328
431
  {
@@ -330,6 +433,7 @@ export const MCP_CLIENTS: McpClient[] = [
330
433
  label: "Gemini CLI",
331
434
  file: () => join(home(), ".gemini", "settings.json"),
332
435
  detect: () => existsSync(join(home(), ".gemini")),
436
+ headerEnv: "none",
333
437
  write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, entry.url ? { httpUrl: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : stdioEntry(entry)),
334
438
  },
335
439
  {
@@ -337,6 +441,7 @@ export const MCP_CLIENTS: McpClient[] = [
337
441
  label: "OpenCode",
338
442
  file: () => join(xdg(), "opencode", "opencode.json"),
339
443
  detect: () => existsSync(join(xdg(), "opencode")),
444
+ headerEnv: "brace-env",
340
445
  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
446
  },
342
447
  {
@@ -344,6 +449,7 @@ export const MCP_CLIENTS: McpClient[] = [
344
449
  label: "Zed",
345
450
  file: () => join(xdg(), "zed", "settings.json"),
346
451
  detect: () => existsSync(join(xdg(), "zed")),
452
+ headerEnv: "none",
347
453
  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
454
  },
349
455
  {
@@ -362,10 +468,18 @@ export const MCP_CLIENTS: McpClient[] = [
362
468
  label: "Cursor",
363
469
  file: (cwd) => join(cwd, ".cursor", "mcp.json"),
364
470
  detect: () => existsSync(join(home(), ".cursor")),
471
+ headerEnv: "env-colon",
365
472
  write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, standardEntry(entry)),
366
473
  },
367
474
  ];
368
475
 
476
+ // Every client's write receives entries in its own env-reference syntax,
477
+ // whether it is called through writeMcpConfig or directly.
478
+ for (const client of MCP_CLIENTS) {
479
+ const write = client.write;
480
+ client.write = (existing, name, entry) => write(existing, name, entryForClient(client, entry).entry);
481
+ }
482
+
369
483
  export function findMcpClient(id: string): McpClient | undefined {
370
484
  return MCP_CLIENTS.find((c) => c.id === id);
371
485
  }
@@ -377,17 +491,19 @@ export interface McpWriteResult {
377
491
  note?: string;
378
492
  }
379
493
 
380
- /** Merge the entry into one client's config file. Never writes a literal secret: callers pass env references. */
494
+ /** 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
495
  export function writeMcpConfig(client: McpClient, cwd: string, name: string, entry: McpEntry): McpWriteResult {
382
496
  const file = client.file(cwd);
383
497
  if (!entry.url && client.id === "claude-desktop" && !entry.command) {
384
498
  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
499
  }
386
500
  const existing = readOr(file, "");
501
+ const adapted = entryForClient(client, entry);
387
502
  const next = client.write(existing, name, entry);
388
503
  mkdirSync(dirname(file), { recursive: true });
389
504
  writeFileSync(file, next);
390
- return { client: client.id, file, written: true, ...(client.incompatible ? { note: client.incompatible } : {}) };
505
+ const note = [client.incompatible, adapted.note].filter(Boolean).join(" ");
506
+ return { client: client.id, file, written: true, ...(note ? { note } : {}) };
391
507
  }
392
508
 
393
509
  /** Does a client's config already mention this server? For doctor. */
@@ -469,10 +585,13 @@ export interface AgentContext {
469
585
  version: string;
470
586
  envPrefix: string;
471
587
  authEnvVars: string[];
588
+ /** The API Spec is silent on authentication: say so instead of "no auth". */
589
+ authNotDeclared?: boolean;
472
590
  docsUrl: string | null;
473
591
  docsIndexUrl?: string | null;
474
592
  generatedOperationCount?: number;
475
- omittedOperations?: { command: string; tool: string; method: string; path: string }[];
593
+ /** Operations in the API left out of a capped build; api.json lists them. */
594
+ omittedOperationCount?: number;
476
595
  /** The API's hosted MCP endpoint, when it has one. */
477
596
  mcpUrl: string | null;
478
597
  /** Skills repository (owner/name) an agent can install with npx skills add. */
@@ -507,8 +626,10 @@ export function agentBlock(ctx: AgentContext, commands: CommandSummary[]): strin
507
626
  "",
508
627
  "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
628
  "",
510
- ...((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."] : []),
511
- "- Auth: " + auth + " in the environment, or `" + ctx.bin + " login`. Never write a key into a file in this repo.",
629
+ ...((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`."] : []),
630
+ ctx.authNotDeclared
631
+ ? "- 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."
632
+ : "- Auth: " + auth + " in the environment, or `" + ctx.bin + " login`. Never write a key into a file in this repo.",
512
633
  "- Discover: `" + ctx.bin + " --help`, `" + ctx.bin + " <resource> <command> --help`, `" + ctx.bin + " help --json` (machine-readable), `" + ctx.bin + " agent-guide --format json`.",
513
634
  "- Docs: " + (index ? "`" + ctx.bin + " docs search <term> --json`; " + index : "`" + ctx.bin + " docs <resource> <command> --json` (a docs URL was not provided at generate time)") + ".",
514
635
  "- Lists: `--all` streams every page as NDJSON. Destructive commands need `--force`.",
@@ -535,12 +656,12 @@ export function agentGuide(ctx: AgentContext, commands: CommandSummary[]): Recor
535
656
  package: ctx.pkg,
536
657
  api: ctx.apiTitle,
537
658
  version: ctx.version,
538
- generated_by: "typeship",
659
+ generated_by: "Typeship",
539
660
  guide: agentBlock(ctx, commands),
540
661
  first_command: first ? ctx.bin + " " + first.resource + " " + first.command : ctx.bin + " --help",
541
662
  docs_index_url: docsIndexUrl,
542
663
  docs_full_url: docsFullUrl,
543
- ...((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 } } : {}),
664
+ ...((ctx.omittedOperationCount ?? 0) > 0 ? { coverage: { generated_operations: ctx.generatedOperationCount ?? commands.length, total_operations: (ctx.generatedOperationCount ?? commands.length) + ctx.omittedOperationCount! } } : {}),
544
665
  hosted_mcp_url: ctx.mcpUrl,
545
666
  local_mcp: ctx.hasMcp ? ctx.bin + "-mcp (stdio) or '" + ctx.bin + " mcp install --all'" : null,
546
667
  skills_install: ctx.skillsRepo ? "npx skills add " + ctx.skillsRepo : null,
@@ -704,3 +825,8 @@ export function summarizeDoctor(checks: DoctorCheck[]): { status: "ok" | "action
704
825
  next_steps: failing.map((c) => c.fix).filter((f): f is string => Boolean(f)),
705
826
  };
706
827
  }
828
+
829
+ /** Every OAuth scope an operation's security requirements name, in order. */
830
+ export function requiredScopes(security: Record<string, string[]>[] | undefined): string[] {
831
+ return [...new Set((security ?? []).flatMap((requirement) => Object.values(requirement).flat()))];
832
+ }