@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.
- package/AGENTS.md +12 -8
- package/README.md +14 -27
- package/api.json +8898 -9026
- package/api.md +369 -327
- package/dist/arguments.d.ts +47 -0
- package/dist/arguments.d.ts.map +1 -0
- package/dist/arguments.js +254 -0
- package/dist/cli-agent.d.ts +31 -8
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +146 -28
- package/dist/cli.js +483 -237
- package/dist/core/http.d.ts +162 -19
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +381 -48
- package/dist/core/pagination.d.ts +42 -6
- package/dist/core/pagination.d.ts.map +1 -1
- package/dist/core/pagination.js +111 -17
- package/dist/credential-storage.d.ts +10 -3
- package/dist/credential-storage.d.ts.map +1 -1
- package/dist/credential-storage.js +15 -6
- package/dist/dates.d.ts +1 -1
- package/dist/dates.js +1 -1
- package/dist/errors.d.ts +20 -84
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -108
- package/dist/fields.d.ts +29 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +101 -0
- package/dist/index.d.ts +28 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -25
- package/dist/named-credentials.d.ts +19 -0
- package/dist/named-credentials.d.ts.map +1 -1
- package/dist/named-credentials.js +81 -1
- package/dist/oauth-login.d.ts +8 -2
- package/dist/oauth-login.d.ts.map +1 -1
- package/dist/oauth-login.js +31 -19
- package/dist/oauth-request.d.ts +7 -1
- package/dist/oauth-request.d.ts.map +1 -1
- package/dist/oauth-request.js +26 -4
- package/dist/oauth-session.d.ts +13 -1
- package/dist/oauth-session.d.ts.map +1 -1
- package/dist/oauth-session.js +34 -18
- package/dist/ops.d.ts +53 -5
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +49 -40
- package/dist/polling-login.d.ts +8 -2
- package/dist/polling-login.d.ts.map +1 -1
- package/dist/polling-login.js +25 -11
- package/dist/resources/api-keys.d.ts +10 -7
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +10 -31
- package/dist/resources/deliveries.d.ts +88 -4
- package/dist/resources/deliveries.d.ts.map +1 -1
- package/dist/resources/deliveries.js +95 -18
- package/dist/resources/drafts.d.ts +15 -15
- package/dist/resources/drafts.d.ts.map +1 -1
- package/dist/resources/drafts.js +11 -64
- package/dist/resources/files.d.ts +4 -4
- package/dist/resources/files.d.ts.map +1 -1
- package/dist/resources/files.js +3 -12
- package/dist/resources/generations.d.ts +14 -14
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +21 -45
- package/dist/resources/organization.d.ts +4 -4
- package/dist/resources/organization.d.ts.map +1 -1
- package/dist/resources/organization.js +3 -10
- package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
- package/dist/resources/packages.d.ts.map +1 -0
- package/dist/resources/{generate.js → packages.js} +13 -29
- package/dist/resources/projects.d.ts +50 -50
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +60 -116
- package/dist/resources/releases.d.ts +21 -16
- package/dist/resources/releases.d.ts.map +1 -1
- package/dist/resources/releases.js +18 -39
- package/dist/resources/spec-revisions.d.ts +15 -6
- package/dist/resources/spec-revisions.d.ts.map +1 -1
- package/dist/resources/spec-revisions.js +6 -28
- package/dist/resources/specs.d.ts +7 -7
- package/dist/resources/specs.d.ts.map +1 -1
- package/dist/resources/specs.js +6 -34
- package/dist/resources/targets.d.ts +48 -48
- package/dist/resources/targets.d.ts.map +1 -1
- package/dist/resources/targets.js +58 -114
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +83 -81
- package/dist/search.d.ts +54 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +421 -0
- package/dist/types.d.ts +499 -339
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +18 -18
- package/package.json +5 -2
- package/src/arguments.ts +242 -0
- package/src/cli-agent.ts +156 -30
- package/src/cli.ts +444 -211
- package/src/core/http.ts +457 -58
- package/src/core/pagination.ts +129 -18
- package/src/credential-storage.ts +16 -6
- package/src/dates.ts +1 -1
- package/src/errors.ts +46 -115
- package/src/fields.ts +91 -0
- package/src/index.ts +45 -28
- package/src/named-credentials.ts +66 -1
- package/src/oauth-login.ts +36 -21
- package/src/oauth-request.ts +32 -6
- package/src/oauth-session.ts +37 -19
- package/src/ops.ts +82 -44
- package/src/polling-login.ts +24 -11
- package/src/resources/api-keys.ts +34 -48
- package/src/resources/deliveries.ts +211 -30
- package/src/resources/drafts.ts +60 -107
- package/src/resources/files.ts +19 -20
- package/src/resources/generations.ts +57 -75
- package/src/resources/organization.ts +11 -16
- package/src/resources/{generate.ts → packages.ts} +43 -51
- package/src/resources/projects.ts +145 -200
- package/src/resources/releases.ts +48 -65
- package/src/resources/spec-revisions.ts +38 -47
- package/src/resources/specs.ts +39 -59
- package/src/resources/targets.ts +143 -193
- package/src/schemas.ts +83 -81
- package/src/search.ts +434 -0
- package/src/types.ts +538 -357
- package/dist/console-login-check.d.ts +0 -21
- package/dist/console-login-check.d.ts.map +0 -1
- package/dist/console-login-check.js +0 -107
- package/dist/console-login-contract.d.ts +0 -45
- package/dist/console-login-contract.d.ts.map +0 -1
- package/dist/console-login-contract.js +0 -40
- package/dist/resources/generate.d.ts.map +0 -1
- package/dist/resources/publications.d.ts +0 -47
- package/dist/resources/publications.d.ts.map +0 -1
- package/dist/resources/publications.js +0 -70
- package/src/console-login-check.ts +0 -88
- package/src/console-login-contract.ts +0 -65
- package/src/resources/publications.ts +0 -140
package/src/arguments.ts
ADDED
|
@@ -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
|
|
2
|
+
* The agent contract of a generated CLI. Generated by Typeship — https://typeship.dev
|
|
3
3
|
*
|
|
4
4
|
* Everything a coding agent needs from a command-line tool and a human does
|
|
5
5
|
* not: a JSON error envelope with stable codes and next steps, agent-mode
|
|
@@ -21,20 +21,25 @@ import { dirname, join, resolve } from "node:path";
|
|
|
21
21
|
export type IssueCode =
|
|
22
22
|
| "NO_AUTH" // no credential resolved and the API said 401
|
|
23
23
|
| "AUTH_INVALID" // a credential was sent and the API said 401/403
|
|
24
|
+
| "INSUFFICIENT_SCOPE" // 403 (or missing_scope) on an operation that requires OAuth scopes
|
|
24
25
|
| "PLAN_LIMIT" // 402: the account's plan stops here
|
|
25
26
|
| "NOT_FOUND" // 404
|
|
26
27
|
| "INVALID_REQUEST" // 400/422 the API rejected the input
|
|
27
|
-
| "SPEC_INVALID" // 422 whose error code is spec_error
|
|
28
|
+
| "SPEC_INVALID" // 422 whose error code is spec_invalid (spec_error before the rename)
|
|
28
29
|
| "RATE_LIMITED" // 429
|
|
29
30
|
| "SERVER_ERROR" // 5xx
|
|
30
31
|
| "NETWORK_ERROR" // no response: DNS, TLS, timeout, refused
|
|
31
32
|
| "VALIDATION_FAILED" // --validate found parameters or a body that do not match the schema
|
|
32
33
|
| "TTY_REQUIRED" // a prompt was needed and there is no terminal
|
|
34
|
+
| "LOGIN_FAILED" // browser, device or approval login did not complete
|
|
35
|
+
| "LOGIN_REQUIRED" // a saved login cannot be used as is; sign in again
|
|
36
|
+
| "CREDENTIAL_STORE_UNAVAILABLE" // the OS credential store or saved credential file cannot be used
|
|
33
37
|
| "CONFIRMATION_REQUIRED" // a destructive command needs --force
|
|
34
38
|
| "INVALID_USAGE" // wrong flags or arguments
|
|
35
39
|
| "UNKNOWN_COMMAND"
|
|
36
40
|
| "UNKNOWN_FLAG"
|
|
37
41
|
| "MISSING_ARGUMENT"
|
|
42
|
+
| "FIELDS_UNMATCHED" // the call ran, but a --fields path matched nothing in the result
|
|
38
43
|
| "CALL_FAILED"; // anything else
|
|
39
44
|
|
|
40
45
|
export interface Issue {
|
|
@@ -80,6 +85,7 @@ export function exitCodeFor(code: IssueCode): 1 | 2 {
|
|
|
80
85
|
case "UNKNOWN_COMMAND":
|
|
81
86
|
case "UNKNOWN_FLAG":
|
|
82
87
|
case "MISSING_ARGUMENT":
|
|
88
|
+
case "FIELDS_UNMATCHED":
|
|
83
89
|
case "TTY_REQUIRED":
|
|
84
90
|
case "CONFIRMATION_REQUIRED":
|
|
85
91
|
return 2;
|
|
@@ -91,13 +97,13 @@ export function exitCodeFor(code: IssueCode): 1 | 2 {
|
|
|
91
97
|
/** Interpret an SDK error result: HTTP status, body, transport, validation. */
|
|
92
98
|
export function classifyApiError(
|
|
93
99
|
error: unknown,
|
|
94
|
-
context: { bin: string; hadCredential: boolean; docsUrl: string | null },
|
|
100
|
+
context: { bin: string; envPrefix: string; hadCredential: boolean; docsUrl: string | null; requiredScopes?: string[]; canLogin?: boolean },
|
|
95
101
|
): EnvelopeInput {
|
|
96
|
-
const e = (error ?? {}) as { name?: string; message?: string; status?: number; body?: unknown; violations?: unknown; direction?: string; target?: string; response?: { requestId?: string } };
|
|
102
|
+
const e = (error ?? {}) as { name?: string; message?: string; status?: number; code?: unknown; body?: unknown; violations?: unknown; direction?: string; target?: string; rateLimit?: { retryAt?: Date }; response?: { requestId?: string } };
|
|
97
103
|
// The message is the API's own words when it sent any, else the SDK's
|
|
98
|
-
// (
|
|
99
|
-
//
|
|
100
|
-
//
|
|
104
|
+
// ("HTTP 404"). The error class name rides in detail, not in front of the
|
|
105
|
+
// message: "NotFoundError: No such account (no such account)" said one
|
|
106
|
+
// thing three times.
|
|
101
107
|
const base = e.message ? e.message : String(error);
|
|
102
108
|
if (e.violations !== undefined) {
|
|
103
109
|
const nextStep = e.direction === "response"
|
|
@@ -107,12 +113,12 @@ export function classifyApiError(
|
|
|
107
113
|
: "Correct the body fields named in detail.violations to match their constraints, then run the command again.";
|
|
108
114
|
return { code: "VALIDATION_FAILED", message: base, detail: { ...(e.name ? { error: e.name } : {}), violations: e.violations }, nextSteps: [nextStep] };
|
|
109
115
|
}
|
|
110
|
-
if (e.name === "TransportError" || (typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(base))) {
|
|
116
|
+
if (e.name === "TransportError" || (e.name !== "PaginationError" && typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(base))) {
|
|
111
117
|
return {
|
|
112
118
|
code: "NETWORK_ERROR",
|
|
113
119
|
message: base,
|
|
114
120
|
nextSteps: [
|
|
115
|
-
"Check the base URL (--base-url, the " + context.
|
|
121
|
+
"Check the base URL (--base-url, the " + context.envPrefix + "_BASE_URL variable, or '" + context.bin + " config base-url') and the network.",
|
|
116
122
|
"Retry once with backoff; do not loop.",
|
|
117
123
|
],
|
|
118
124
|
};
|
|
@@ -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 ===
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
284
|
-
const
|
|
285
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
511
|
-
|
|
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: "
|
|
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.
|
|
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
|
+
}
|