@typeship-ax/cli 0.23.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/README.md +2 -2
- package/api.md +2 -2
- package/dist/arguments.d.ts +9 -2
- package/dist/arguments.d.ts.map +1 -1
- package/dist/arguments.js +17 -6
- package/dist/cli-agent.d.ts +42 -1
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +187 -18
- package/dist/cli.js +341 -76
- package/dist/fields.d.ts +8 -1
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +91 -5
- package/dist/index.d.ts +2 -2
- package/dist/index.js +3 -3
- package/dist/ops.d.ts +5 -0
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +66 -6
- package/dist/resources/deliveries.d.ts +1 -1
- package/dist/resources/deliveries.d.ts.map +1 -1
- package/dist/resources/deliveries.js +1 -1
- package/dist/resources/drafts.d.ts +1 -1
- package/dist/resources/drafts.d.ts.map +1 -1
- package/dist/resources/drafts.js +1 -1
- package/dist/resources/generations.d.ts +2 -2
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +2 -2
- package/dist/resources/releases.d.ts +1 -1
- package/dist/resources/releases.d.ts.map +1 -1
- package/dist/resources/releases.js +1 -1
- package/dist/resources/spec-revisions.d.ts +1 -1
- package/dist/resources/spec-revisions.d.ts.map +1 -1
- package/dist/resources/spec-revisions.js +1 -1
- package/dist/resources/targets.d.ts +1 -1
- package/dist/resources/targets.d.ts.map +1 -1
- package/dist/resources/targets.js +1 -1
- package/dist/table.d.ts +28 -0
- package/dist/table.d.ts.map +1 -0
- package/dist/table.js +167 -0
- package/dist/type-docs.d.ts +61 -0
- package/dist/type-docs.d.ts.map +1 -0
- package/dist/type-docs.js +174 -0
- package/package.json +1 -1
- package/src/arguments.ts +19 -7
- package/src/cli-agent.ts +196 -17
- package/src/cli.ts +330 -76
- package/src/fields.ts +81 -5
- package/src/index.ts +3 -3
- package/src/ops.ts +69 -6
- package/src/resources/deliveries.ts +2 -2
- package/src/resources/drafts.ts +2 -2
- package/src/resources/generations.ts +4 -4
- package/src/resources/releases.ts +2 -2
- package/src/resources/spec-revisions.ts +2 -2
- package/src/resources/targets.ts +2 -2
- package/src/table.ts +167 -0
- package/src/type-docs.ts +205 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named input types for the docs surfaces (MCP read_docs, the CLI's docs
|
|
3
|
+
* command): a type's reference by name, and an argument path such as
|
|
4
|
+
* "filter.team.key" resolved to its type. Each type is described once with
|
|
5
|
+
* its fields typed by name, so a lookup is bounded and every type it
|
|
6
|
+
* mentions can be looked up the same way.
|
|
7
|
+
*/
|
|
8
|
+
/** Enum values a type page lists before a count. */
|
|
9
|
+
const TYPE_ENUM_VALUES = 200;
|
|
10
|
+
/** One line of prose: links reduced to their text. */
|
|
11
|
+
function prose(text) {
|
|
12
|
+
return (text ?? "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\s+/g, " ").trim();
|
|
13
|
+
}
|
|
14
|
+
/** The table's type names an expression mentions, in order. */
|
|
15
|
+
export function namedTypesIn(table, expression) {
|
|
16
|
+
if (!table || !expression)
|
|
17
|
+
return [];
|
|
18
|
+
return [...new Set(expression.split(/[|()[\]\s]+/).filter((part) => Object.hasOwn(table.types, part)))];
|
|
19
|
+
}
|
|
20
|
+
/** The one input object an expression names (IssueFilter, IssueFilter[]),
|
|
21
|
+
* whose fields a path continues into. */
|
|
22
|
+
function objectTypeOf(table, expression) {
|
|
23
|
+
const objects = namedTypesIn(table, expression).filter((name) => table.types[name].fields);
|
|
24
|
+
return objects.length === 1 ? objects[0] : undefined;
|
|
25
|
+
}
|
|
26
|
+
/** A type by exact name, else by a case-insensitive match. */
|
|
27
|
+
export function findInputType(table, name) {
|
|
28
|
+
if (!table)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (Object.hasOwn(table.types, name))
|
|
31
|
+
return name;
|
|
32
|
+
const lower = name.toLowerCase();
|
|
33
|
+
return Object.keys(table.types).find((candidate) => candidate.toLowerCase() === lower);
|
|
34
|
+
}
|
|
35
|
+
function fieldLine(name, field) {
|
|
36
|
+
const extras = [
|
|
37
|
+
...(field.required ? ["required"] : []),
|
|
38
|
+
...(field.default !== undefined ? ["default " + JSON.stringify(field.default)] : []),
|
|
39
|
+
...(field.deprecated ? ["deprecated"] : []),
|
|
40
|
+
];
|
|
41
|
+
const description = prose(field.description);
|
|
42
|
+
return " " + name + " (" + field.type + (extras.length ? ", " + extras.join(", ") : "") + ")" + (description ? ": " + description : "");
|
|
43
|
+
}
|
|
44
|
+
/** A named type's reference: description, then its fields, values or
|
|
45
|
+
* members, then how to read the named types it mentions. */
|
|
46
|
+
export function inputTypeText(table, name, lookup) {
|
|
47
|
+
const doc = table.types[name];
|
|
48
|
+
const kind = doc.fields ? "input object" : doc.values ? "enum" : "union";
|
|
49
|
+
const lines = [name + " (" + kind + ")"];
|
|
50
|
+
if (doc.description)
|
|
51
|
+
lines.push(prose(doc.description));
|
|
52
|
+
const mentioned = [];
|
|
53
|
+
if (doc.fields) {
|
|
54
|
+
const entries = Object.entries(doc.fields);
|
|
55
|
+
lines.push("", entries.length === 0 ? "No fields." : "Fields (" + entries.length + "):");
|
|
56
|
+
for (const [field, spec] of entries) {
|
|
57
|
+
lines.push(fieldLine(field, spec));
|
|
58
|
+
mentioned.push(...namedTypesIn(table, spec.type));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (doc.values) {
|
|
62
|
+
const values = doc.values.slice(0, TYPE_ENUM_VALUES).map((value) => JSON.stringify(value));
|
|
63
|
+
lines.push("", "Values: " + values.join(", ") + (doc.values.length > TYPE_ENUM_VALUES ? ", … " + (doc.values.length - TYPE_ENUM_VALUES) + " more" : ""));
|
|
64
|
+
}
|
|
65
|
+
else if (doc.variants) {
|
|
66
|
+
lines.push("", "One of: " + doc.variants.join(" | "));
|
|
67
|
+
for (const variant of doc.variants)
|
|
68
|
+
mentioned.push(...namedTypesIn(table, variant));
|
|
69
|
+
}
|
|
70
|
+
const others = [...new Set(mentioned)].filter((other) => other !== name);
|
|
71
|
+
if (others.length > 0) {
|
|
72
|
+
lines.push("", "Types used above (" + others.length + "): " + others.join(", ") + ". Read one with " + lookup("<type>") + ".");
|
|
73
|
+
}
|
|
74
|
+
return lines.join("\n");
|
|
75
|
+
}
|
|
76
|
+
function inlineObject(schema) {
|
|
77
|
+
if (!schema || typeof schema !== "object")
|
|
78
|
+
return undefined;
|
|
79
|
+
if (schema.properties && typeof schema.properties === "object")
|
|
80
|
+
return schema;
|
|
81
|
+
if (schema.items)
|
|
82
|
+
return inlineObject(schema.items);
|
|
83
|
+
const variants = schema.anyOf ?? schema.oneOf;
|
|
84
|
+
if (Array.isArray(variants)) {
|
|
85
|
+
const objects = variants.map(inlineObject).filter((variant) => variant !== undefined);
|
|
86
|
+
if (objects.length === 1)
|
|
87
|
+
return objects[0];
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
function inlineType(schema) {
|
|
92
|
+
if (Array.isArray(schema.enum))
|
|
93
|
+
return schema.enum.filter((value) => value !== null).map((value) => JSON.stringify(value)).join("|");
|
|
94
|
+
const variants = schema.anyOf ?? schema.oneOf;
|
|
95
|
+
if (Array.isArray(variants))
|
|
96
|
+
return [...new Set(variants.map(inlineType))].filter((t) => t !== "null").join("|") || "null";
|
|
97
|
+
const types = (Array.isArray(schema.type) ? schema.type : typeof schema.type === "string" ? [schema.type] : []).filter((t) => t !== "null");
|
|
98
|
+
if (types.includes("array")) {
|
|
99
|
+
const inner = schema.items ? inlineType(schema.items) : "any";
|
|
100
|
+
return (/[| ]/.test(inner) ? "(" + inner + ")" : inner) + "[]";
|
|
101
|
+
}
|
|
102
|
+
return types.join("|") || (schema.properties ? "object" : "any");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* What one argument path means: the field's type, whether it is required,
|
|
106
|
+
* its description, and, when its type is named, that type's reference in
|
|
107
|
+
* full (a comparator's operators, an enum's values). `root` is an
|
|
108
|
+
* operation (its tool name, inline input schema, and how the caller names
|
|
109
|
+
* it) or a named type.
|
|
110
|
+
*/
|
|
111
|
+
export function argumentPathText(table, root, path, lookup) {
|
|
112
|
+
const segments = path.split(".").map((segment) => segment.trim()).filter((segment) => segment.length > 0);
|
|
113
|
+
if (segments.length === 0)
|
|
114
|
+
return { ok: false, message: "The path is empty.", available: [] };
|
|
115
|
+
// Walk the named-type table where the path has a type name, else the
|
|
116
|
+
// operation's inline schema.
|
|
117
|
+
let typeName = "type" in root ? root.type : undefined;
|
|
118
|
+
let inline = "type" in root ? undefined : root.inputSchema;
|
|
119
|
+
let expression;
|
|
120
|
+
let field;
|
|
121
|
+
const label = "type" in root ? root.type : root.label ?? root.tool;
|
|
122
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
123
|
+
const segment = segments[index];
|
|
124
|
+
const at = index === 0 ? label : label + " " + segments.slice(0, index).join(".");
|
|
125
|
+
if (typeName && table) {
|
|
126
|
+
const fields = table.types[typeName].fields ?? {};
|
|
127
|
+
if (!Object.hasOwn(fields, segment))
|
|
128
|
+
return { ok: false, message: at + " (" + typeName + ") has no field \"" + segment + "\".", available: Object.keys(fields) };
|
|
129
|
+
field = fields[segment];
|
|
130
|
+
expression = field.type;
|
|
131
|
+
inline = undefined;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const object = inlineObject(inline);
|
|
135
|
+
const properties = object?.properties ?? {};
|
|
136
|
+
if (!object || !Object.hasOwn(properties, segment)) {
|
|
137
|
+
return { ok: false, message: object ? at + " has no field \"" + segment + "\"." : at + " is not an object; the path cannot continue past it.", available: Object.keys(properties) };
|
|
138
|
+
}
|
|
139
|
+
const child = properties[segment];
|
|
140
|
+
const topLevel = index === 0 && !("type" in root) ? table?.args[root.tool]?.[segment] : undefined;
|
|
141
|
+
expression = topLevel ?? inlineType(child);
|
|
142
|
+
field = {
|
|
143
|
+
type: expression,
|
|
144
|
+
...(Array.isArray(object.required) && object.required.includes(segment) ? { required: true } : {}),
|
|
145
|
+
...(typeof child.description === "string" ? { description: child.description } : {}),
|
|
146
|
+
};
|
|
147
|
+
inline = child;
|
|
148
|
+
}
|
|
149
|
+
typeName = table ? objectTypeOf(table, expression) : undefined;
|
|
150
|
+
if (!typeName && index < segments.length - 1 && !inlineObject(inline)) {
|
|
151
|
+
return { ok: false, message: label + " " + segments.slice(0, index + 1).join(".") + " is " + expression + ", not an object; the path cannot continue past it.", available: [] };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const lines = [label + " " + segments.join(".") + ": " + field.type + (field.required ? " (required)" : "")];
|
|
155
|
+
const description = prose(field.description);
|
|
156
|
+
if (description)
|
|
157
|
+
lines.push(description);
|
|
158
|
+
const named = namedTypesIn(table, expression);
|
|
159
|
+
if (named.length > 0 && table) {
|
|
160
|
+
for (const name of named)
|
|
161
|
+
lines.push("", inputTypeText(table, name, lookup));
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const object = inlineObject(inline);
|
|
165
|
+
if (object) {
|
|
166
|
+
const required = new Set(object.required ?? []);
|
|
167
|
+
lines.push("", "Fields:");
|
|
168
|
+
for (const [name, child] of Object.entries(object.properties ?? {})) {
|
|
169
|
+
lines.push(fieldLine(name, { type: inlineType(child), ...(required.has(name) ? { required: true } : {}), ...(child.description ? { description: child.description } : {}) }));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, text: lines.join("\n") };
|
|
174
|
+
}
|
package/package.json
CHANGED
package/src/arguments.ts
CHANGED
|
@@ -157,7 +157,7 @@ export function checkValue(
|
|
|
157
157
|
schema: Record<string, unknown>,
|
|
158
158
|
path: string,
|
|
159
159
|
issues: ArgumentIssue[],
|
|
160
|
-
options: { skipPattern?: boolean; depth?: number } = {},
|
|
160
|
+
options: { skipPattern?: boolean; depth?: number; meName?: string } = {},
|
|
161
161
|
): unknown {
|
|
162
162
|
const depth = options.depth ?? 0;
|
|
163
163
|
const at = (suffix: string) => path === "" ? suffix : path + ": " + suffix;
|
|
@@ -173,7 +173,10 @@ export function checkValue(
|
|
|
173
173
|
let out = shallow.value;
|
|
174
174
|
if (depth >= MAX_CHECK_DEPTH) return out;
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
// "me" for a user-shaped property (or an item of one) is resolved to an
|
|
177
|
+
// ID or login later, so it skips the pattern the resolved value must meet.
|
|
178
|
+
const skipPattern = options.skipPattern || (typeof out === "string" && options.meName !== undefined && isMeReference(options.meName, out));
|
|
179
|
+
if (typeof out === "string" && typeof schema.pattern === "string" && !skipPattern) {
|
|
177
180
|
let pattern: RegExp | undefined;
|
|
178
181
|
try { pattern = new RegExp(schema.pattern, "u"); } catch {
|
|
179
182
|
try { pattern = new RegExp(schema.pattern); } catch { /* not an ECMAScript pattern: never false-alarm */ }
|
|
@@ -186,7 +189,7 @@ export function checkValue(
|
|
|
186
189
|
// Array items: check each against the items schema when it has one.
|
|
187
190
|
if (Array.isArray(out) && schema.items && typeof schema.items === "object" && !Array.isArray(schema.items)) {
|
|
188
191
|
const itemSchema = schema.items as Record<string, unknown>;
|
|
189
|
-
out = out.map((item, i) => checkValue(item, itemSchema, path + "[" + i + "]", issues, { depth: depth + 1 }));
|
|
192
|
+
out = out.map((item, i) => checkValue(item, itemSchema, path + "[" + i + "]", issues, { depth: depth + 1, meName: options.meName }));
|
|
190
193
|
}
|
|
191
194
|
|
|
192
195
|
// Object properties: the same checks one level down, keyed by dotted path.
|
|
@@ -216,7 +219,7 @@ export function checkValue(
|
|
|
216
219
|
}
|
|
217
220
|
const propSchema = properties[name];
|
|
218
221
|
next[name] = propSchema && typeof propSchema === "object"
|
|
219
|
-
? checkValue(entry, propSchema, child(name), issues, { depth: depth + 1,
|
|
222
|
+
? checkValue(entry, propSchema, child(name), issues, { depth: depth + 1, meName: name })
|
|
220
223
|
: entry;
|
|
221
224
|
}
|
|
222
225
|
for (const name of Array.isArray(schema.required) ? schema.required as string[] : []) {
|
|
@@ -230,13 +233,22 @@ export function checkValue(
|
|
|
230
233
|
return out;
|
|
231
234
|
}
|
|
232
235
|
|
|
236
|
+
/** A name that holds one user or a list of them: assignee, assigneeId,
|
|
237
|
+
* assignees, subscriberIds, owner, user_id. */
|
|
233
238
|
export function userShapedReference(name: string): boolean {
|
|
234
|
-
const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/ids?$/, "");
|
|
239
|
+
const normalized = name.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/ids?$/, "").replace(/s$/, "");
|
|
235
240
|
return ["user", "assignee", "owner", "member", "actor", "creator", "account", "profile", "subscriber"].includes(normalized);
|
|
236
241
|
}
|
|
237
242
|
|
|
238
|
-
/** "me" given for a user-shaped argument or property, resolved
|
|
239
|
-
*
|
|
243
|
+
/** "me" given for a user-shaped argument or property, resolved through the
|
|
244
|
+
* identity tool. */
|
|
240
245
|
export function isMeReference(name: string, value: string): boolean {
|
|
241
246
|
return value.trim().toLowerCase() === "me" && userShapedReference(name);
|
|
242
247
|
}
|
|
248
|
+
|
|
249
|
+
/** What "me" becomes for a user-shaped name: the caller's id for an ID
|
|
250
|
+
* name (assigneeId, subscriberIds, user_id), otherwise their login
|
|
251
|
+
* (GitHub's owner and assignees take logins). */
|
|
252
|
+
export function meIdentityField(name: string): "id" | "login" {
|
|
253
|
+
return /ids?$/i.test(name.replace(/[^A-Za-z0-9]/g, "")) ? "id" : "login";
|
|
254
|
+
}
|
package/src/cli-agent.ts
CHANGED
|
@@ -131,7 +131,9 @@ export function classifyApiError(
|
|
|
131
131
|
? (e.body as Record<string, unknown>).request_id ?? (e.body as Record<string, unknown>).requestId
|
|
132
132
|
: undefined;
|
|
133
133
|
const requestId = e.response?.requestId ?? (typeof bodyRequestId === "string" ? bodyRequestId : undefined);
|
|
134
|
-
|
|
134
|
+
// An in-band GraphQL error keeps the vendor's code next to the normalized one.
|
|
135
|
+
const vendorCode = e.name === "GraphQLRequestError" ? graphqlVendorCode(error) : undefined;
|
|
136
|
+
const detail = { status, ...(e.name ? { error: e.name } : {}), ...(vendorCode ? { vendor_code: vendorCode } : {}), ...(requestId ? { request_id: requestId } : {}), ...(e.body !== undefined ? { body: e.body } : {}) };
|
|
135
137
|
const same = apiMessage !== undefined && (apiMessage.toLowerCase() === base.toLowerCase() || base.toLowerCase().includes(apiMessage.toLowerCase()) || apiMessage.toLowerCase().includes(base.toLowerCase()));
|
|
136
138
|
const message = apiMessage === undefined ? base : same ? apiMessage : apiMessage + " (" + base + ")";
|
|
137
139
|
const upgradeUrl = extractUrl(e.body, ["upgrade_url", "upgradeUrl", "signup_url", "claim_url"]);
|
|
@@ -152,13 +154,23 @@ export function classifyApiError(
|
|
|
152
154
|
if (reported === "RATE_LIMITED") return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: ["Wait, then run the same command again."] };
|
|
153
155
|
return { code: "CALL_FAILED", message, detail, nextSteps: ["The API reported a failure in a successful response; detail.body says why."] };
|
|
154
156
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
157
|
+
const unauthenticated = (): EnvelopeInput => context.hadCredential
|
|
158
|
+
? { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential was rejected. Check it is current: '" + context.bin + " auth check', then '" + context.bin + " login --help' to store a new one."] }
|
|
159
|
+
: { status: "action_required", code: "NO_AUTH", message, detail, nextSteps: ["No credential was sent. Set the auth env var, pass --token, or run '" + context.bin + " login'.", "'" + context.bin + " auth check' shows what the CLI would send."] };
|
|
160
|
+
const forbidden = (): EnvelopeInput => scopes.length ? scopeFailure() : { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential lacks access to this operation."] };
|
|
161
|
+
// An in-band GraphQL error (HTTP 200): classified by the vendor's code.
|
|
162
|
+
if (e.name === "GraphQLRequestError") {
|
|
163
|
+
switch (vendorCode === undefined ? undefined : graphqlErrorClass(vendorCode)) {
|
|
164
|
+
case "not_found": return { code: "NOT_FOUND", message, detail, nextSteps: ["Check the id in the arguments; list the resource first to find the right one."] };
|
|
165
|
+
case "unauthenticated": return unauthenticated();
|
|
166
|
+
case "forbidden": return forbidden();
|
|
167
|
+
case "bad_input": return { code: "INVALID_REQUEST", message, detail, nextSteps: ["The API rejected an argument or the selection; the errors in detail.body name it (message, path). Fix that and run the command again."] };
|
|
168
|
+
case "rate_limited": return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: [rateLimitNextStep(undefined, "run the same command again")] };
|
|
169
|
+
default: return { code: "CALL_FAILED", message, detail };
|
|
170
|
+
}
|
|
159
171
|
}
|
|
160
|
-
if (status ===
|
|
161
|
-
if (status === 403) return
|
|
172
|
+
if (status === 401) return unauthenticated();
|
|
173
|
+
if (status === 403) return forbidden();
|
|
162
174
|
if (status === 402) {
|
|
163
175
|
return { status: "action_required", code: "PLAN_LIMIT", message, detail, nextSteps: [upgradeUrl ? "Lift the limit at " + upgradeUrl + ", then run the same command again." : "The account's plan stops here; upgrade it, then run the same command again.", "Do not retry the same call as is."] };
|
|
164
176
|
}
|
|
@@ -242,6 +254,28 @@ export function rateLimitNextStep(retryAt: Date | undefined, then: string): stri
|
|
|
242
254
|
: "Rate limited: back off, then " + then + "; the SDK already honored any Retry-After within its ceiling.";
|
|
243
255
|
}
|
|
244
256
|
|
|
257
|
+
/** The vendor's own code on an in-band GraphQL error: the first error's
|
|
258
|
+
* extensions.code, or its type (GitHub). */
|
|
259
|
+
function graphqlVendorCode(error: unknown): string | undefined {
|
|
260
|
+
const first = (error as { errors?: { extensions?: { code?: unknown }; type?: unknown }[] } | null)?.errors?.[0];
|
|
261
|
+
const code = first?.extensions?.code ?? first?.type;
|
|
262
|
+
return typeof code === "string" && code !== "" ? code : undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** GraphQL error codes whose meaning is settled (Apollo's standard codes,
|
|
266
|
+
* GitHub's types, Linear's codes), by recovery class. Any other code is
|
|
267
|
+
* passed through as is, with no guessed next step. The MCP server's
|
|
268
|
+
* classification uses the same table. */
|
|
269
|
+
function graphqlErrorClass(code: string): "not_found" | "unauthenticated" | "forbidden" | "bad_input" | "rate_limited" | undefined {
|
|
270
|
+
const c = code.toUpperCase();
|
|
271
|
+
if (c === "NOT_FOUND") return "not_found";
|
|
272
|
+
if (c === "UNAUTHENTICATED" || c === "AUTHENTICATION_ERROR") return "unauthenticated";
|
|
273
|
+
if (c === "FORBIDDEN") return "forbidden";
|
|
274
|
+
if (c === "BAD_USER_INPUT" || c === "GRAPHQL_VALIDATION_FAILED" || c === "GRAPHQL_PARSE_FAILED" || c === "INPUT_ERROR") return "bad_input";
|
|
275
|
+
if (c === "RATE_LIMITED" || c === "RATELIMITED") return "rate_limited";
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
245
279
|
/** Error codes APIs report inside a 2xx body (Slack's `error`), mapped to
|
|
246
280
|
* the stable codes when their meaning is unambiguous. */
|
|
247
281
|
export function payloadFailureCode(code: unknown): "AUTH_INVALID" | "RATE_LIMITED" | undefined {
|
|
@@ -600,7 +634,6 @@ export interface AgentContext {
|
|
|
600
634
|
builtins: string[];
|
|
601
635
|
}
|
|
602
636
|
|
|
603
|
-
/** One line per command, pipe-delimited: the compact index that goes into AGENTS.md. */
|
|
604
637
|
/** string, number, usd|eur, string[], usd|eur[], object, json — the type as a reader expects it. */
|
|
605
638
|
export function flagTypeLabel(f: CommandFlagSummary): string {
|
|
606
639
|
const inline = (values: string[] | undefined) => values && values.join("|").length <= 24 ? values.join("|") : values ? "enum" : undefined;
|
|
@@ -608,6 +641,7 @@ export function flagTypeLabel(f: CommandFlagSummary): string {
|
|
|
608
641
|
return inline(f.enum) ?? f.type;
|
|
609
642
|
}
|
|
610
643
|
|
|
644
|
+
/** One line per command, pipe-delimited: the compact index that goes into AGENTS.md. */
|
|
611
645
|
export function compactIndex(commands: CommandSummary[]): string {
|
|
612
646
|
return commands
|
|
613
647
|
.map((c) => {
|
|
@@ -617,10 +651,38 @@ export function compactIndex(commands: CommandSummary[]): string {
|
|
|
617
651
|
.join("\n");
|
|
618
652
|
}
|
|
619
653
|
|
|
654
|
+
/** Bytes of command index the guide and AGENTS.md carry before they switch to one line per resource. */
|
|
655
|
+
export const COMMAND_INDEX_BUDGET = 8_000;
|
|
656
|
+
const RESOURCE_INDEX_NAMES = 12;
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* One line per resource with its first command names, for an API whose
|
|
660
|
+
* per-command index would not fit COMMAND_INDEX_BUDGET. Resources past the
|
|
661
|
+
* budget are counted, not listed; help --json has them all.
|
|
662
|
+
*/
|
|
663
|
+
export function resourceIndex(bin: string, commands: CommandSummary[]): string {
|
|
664
|
+
const byResource = new Map<string, string[]>();
|
|
665
|
+
for (const c of commands) byResource.set(c.resource, [...(byResource.get(c.resource) ?? []), c.command]);
|
|
666
|
+
const lines: string[] = [];
|
|
667
|
+
let bytes = 0;
|
|
668
|
+
let listed = 0;
|
|
669
|
+
for (const [resource, names] of byResource) {
|
|
670
|
+
const more = names.length - RESOURCE_INDEX_NAMES;
|
|
671
|
+
const line = resource + ": " + names.slice(0, RESOURCE_INDEX_NAMES).join(", ") + (more > 0 ? ", … +" + more + " more" : "");
|
|
672
|
+
if (bytes + line.length + 1 > COMMAND_INDEX_BUDGET) break;
|
|
673
|
+
lines.push(line);
|
|
674
|
+
bytes += line.length + 1;
|
|
675
|
+
listed++;
|
|
676
|
+
}
|
|
677
|
+
if (listed < byResource.size) lines.push("… " + (byResource.size - listed) + " more resources: " + bin + " help --json");
|
|
678
|
+
return lines.join("\n");
|
|
679
|
+
}
|
|
680
|
+
|
|
620
681
|
/** The AGENTS.md block body. */
|
|
621
682
|
export function agentBlock(ctx: AgentContext, commands: CommandSummary[]): string {
|
|
622
683
|
const auth = ctx.authEnvVars.length ? ctx.authEnvVars.join(", ") : "(none)";
|
|
623
|
-
const
|
|
684
|
+
const docsIndex = ctx.docsIndexUrl ?? (ctx.docsUrl ? ctx.docsUrl.replace(/\/+$/, "") + "/llms.txt" : null);
|
|
685
|
+
const index = compactIndex(commands);
|
|
624
686
|
return [
|
|
625
687
|
"## " + ctx.bin + " CLI (" + ctx.apiTitle + ")",
|
|
626
688
|
"",
|
|
@@ -630,16 +692,14 @@ export function agentBlock(ctx: AgentContext, commands: CommandSummary[]): strin
|
|
|
630
692
|
ctx.authNotDeclared
|
|
631
693
|
? "- Auth: not declared by the API Spec. If the API needs a token, set " + auth + " or run `" + ctx.bin + " login`; other headers go in `--header \"Name: value\"` or " + ctx.envPrefix + "_HEADERS. Never write a key into a file in this repo."
|
|
632
694
|
: "- Auth: " + auth + " in the environment, or `" + ctx.bin + " login`. Never write a key into a file in this repo.",
|
|
633
|
-
"- Discover: `" + ctx.bin + " --help
|
|
634
|
-
"- Docs: " + (
|
|
695
|
+
"- Discover: `" + ctx.bin + " help --json` indexes resources and command names; `" + ctx.bin + " help <resource> --json` lists a resource's commands; `" + ctx.bin + " help <resource> <command> --json` gives one command's flags. `" + ctx.bin + " help --json --all` prints every command with every flag at once. Humans: `" + ctx.bin + " --help`, `" + ctx.bin + " <resource> <command> --help`.",
|
|
696
|
+
"- Docs: " + (docsIndex ? "`" + ctx.bin + " docs search <term> --json`; " + docsIndex : "`" + ctx.bin + " docs <resource> <command> --json` (a docs URL was not provided at generate time)") + ".",
|
|
635
697
|
"- Lists: `--all` streams every page as NDJSON. Destructive commands need `--force`.",
|
|
636
698
|
...(ctx.hasMcp ? ["- MCP: `" + ctx.bin + " mcp install --all` registers this API's MCP server with the agent clients on this machine" + (ctx.mcpUrl ? " (hosted: " + ctx.mcpUrl + ")" : "") + "."] : []),
|
|
637
699
|
"",
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
compactIndex(commands),
|
|
642
|
-
"```",
|
|
700
|
+
...(Buffer.byteLength(index) <= COMMAND_INDEX_BUDGET
|
|
701
|
+
? ["Commands (resource command | METHOD path | required flags | summary):", "", "```", index, "```"]
|
|
702
|
+
: ["Commands by resource (" + commands.length + " commands; `" + ctx.bin + " help <resource> --json` lists a resource's commands with summaries):", "", "```", resourceIndex(ctx.bin, commands), "```"]),
|
|
643
703
|
].join("\n");
|
|
644
704
|
}
|
|
645
705
|
|
|
@@ -674,12 +734,13 @@ export function agentGuide(ctx: AgentContext, commands: CommandSummary[]): Recor
|
|
|
674
734
|
destructive: "Commands classified as destructive need --force (or --yes); without it they return CONFIRMATION_REQUIRED with the exact command to run.",
|
|
675
735
|
flags: "Positional path arguments first, then --flags. Array flags take a comma list, the flag repeated, or a JSON array; object flags take JSON. --data '<json>' merges under field flags (@<file> reads a file, - reads stdin).",
|
|
676
736
|
pagination: "Paginated commands return {items, hasMore, nextPage, nextCommand}: run nextCommand for the next page, or --all walks every page.",
|
|
737
|
+
discovery: "help --json is a bounded index of resources and command names. help <resource> --json pages through one resource's commands (next_command), help <resource> <command> --json is one command's flags, docs <resource> <command> --json its complete schemas, and docs search <term> --json finds commands by topic. help --json --all prints every command with every flag; on a large API it is large.",
|
|
677
738
|
},
|
|
678
739
|
builtins: ctx.builtins,
|
|
679
740
|
next_steps: [
|
|
680
741
|
...(ctx.authEnvVars.length ? ["Set " + ctx.authEnvVars[0] + " in the environment or run '" + ctx.bin + " login'."] : []),
|
|
681
742
|
"Run '" + ctx.bin + " auth check'.",
|
|
682
|
-
"Run '" + ctx.bin + " help --json' for the command index,
|
|
743
|
+
"Run '" + ctx.bin + " help --json' for the command index, then '" + ctx.bin + " help <resource> <command> --json' for the command you need.",
|
|
683
744
|
...(ctx.hasMcp ? ["Run '" + ctx.bin + " mcp install --all' if this session has an MCP-capable client."] : []),
|
|
684
745
|
],
|
|
685
746
|
};
|
|
@@ -830,3 +891,121 @@ export function summarizeDoctor(checks: DoctorCheck[]): { status: "ok" | "action
|
|
|
830
891
|
export function requiredScopes(security: Record<string, string[]>[] | undefined): string[] {
|
|
831
892
|
return [...new Set((security ?? []).flatMap((requirement) => Object.values(requirement).flat()))];
|
|
832
893
|
}
|
|
894
|
+
|
|
895
|
+
// ---- request preview (--dry-run) ----------------------------------------------
|
|
896
|
+
|
|
897
|
+
/** What --dry-run prints: the request an API command would send, with
|
|
898
|
+
* every credential replaced by "<redacted>". Nothing in it was sent. */
|
|
899
|
+
export interface RequestPreview {
|
|
900
|
+
dry_run: true;
|
|
901
|
+
sent: false;
|
|
902
|
+
message: string;
|
|
903
|
+
request: {
|
|
904
|
+
method: string;
|
|
905
|
+
url: string;
|
|
906
|
+
path: string;
|
|
907
|
+
query: Record<string, string | string[]>;
|
|
908
|
+
headers: Record<string, string>;
|
|
909
|
+
body?: unknown;
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export const REDACTED = "<redacted>";
|
|
914
|
+
|
|
915
|
+
/** Header and query names that carry credentials, whatever the API calls
|
|
916
|
+
* them: Authorization, Cookie, X-Api-Key, api_key, access_token, a session
|
|
917
|
+
* or signature. */
|
|
918
|
+
const SENSITIVE_NAME = /^(authorization|proxy-authorization|cookie|cookie2|set-cookie)$|api[-_]?key|apikey|token|secret|password|passwd|session|signature|credential|^key$|^auth$/i;
|
|
919
|
+
|
|
920
|
+
function redactText(text: string, secrets: string[]): string {
|
|
921
|
+
let out = text;
|
|
922
|
+
for (const secret of secrets) if (secret.length >= 4) out = out.split(secret).join(REDACTED);
|
|
923
|
+
return out;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* The request a dry run captured, as --dry-run shows it. `sensitiveNames`
|
|
928
|
+
* are the header and query names the API's security schemes bind; `secrets`
|
|
929
|
+
* are the resolved credential values, redacted wherever they appear (a
|
|
930
|
+
* token pasted into a body field, a key in a URL).
|
|
931
|
+
*/
|
|
932
|
+
export async function requestPreview(
|
|
933
|
+
input: { method: string; url: string; headers: Record<string, string>; body?: unknown },
|
|
934
|
+
options: { sensitiveNames?: string[]; secrets?: string[] } = {},
|
|
935
|
+
): Promise<RequestPreview> {
|
|
936
|
+
const secrets = [...new Set((options.secrets ?? []).filter((s) => typeof s === "string" && s.length >= 4))].sort((a, b) => b.length - a.length);
|
|
937
|
+
const named = new Set((options.sensitiveNames ?? []).map((n) => n.toLowerCase()));
|
|
938
|
+
const sensitive = (name: string) => named.has(name.toLowerCase()) || SENSITIVE_NAME.test(name);
|
|
939
|
+
const url = new URL(input.url);
|
|
940
|
+
const query: Record<string, string | string[]> = {};
|
|
941
|
+
for (const [name, raw] of url.searchParams) {
|
|
942
|
+
const value = sensitive(name) ? REDACTED : redactText(raw, secrets);
|
|
943
|
+
const previous = query[name];
|
|
944
|
+
query[name] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value];
|
|
945
|
+
}
|
|
946
|
+
const encodedSecrets = [...secrets, ...secrets.map(encodeURIComponent)];
|
|
947
|
+
const search = [...url.searchParams].map(([name, raw]) => encodeURIComponent(name) + "=" + (sensitive(name) ? REDACTED : redactText(encodeURIComponent(raw), encodedSecrets))).join("&");
|
|
948
|
+
const shownUrl = url.origin + redactText(url.pathname, encodedSecrets) + (search ? "?" + search : "");
|
|
949
|
+
const headers: Record<string, string> = {};
|
|
950
|
+
for (const [name, value] of Object.entries(input.headers)) {
|
|
951
|
+
headers[name] = sensitive(name) ? REDACTED : redactText(value, secrets);
|
|
952
|
+
}
|
|
953
|
+
const contentType = Object.entries(input.headers).find(([name]) => name.toLowerCase() === "content-type")?.[1] ?? "";
|
|
954
|
+
const body = await previewBody(input.body, contentType, secrets);
|
|
955
|
+
return {
|
|
956
|
+
dry_run: true,
|
|
957
|
+
sent: false,
|
|
958
|
+
message: "Dry run: nothing was sent to the API. This is the request the command would send, with credentials redacted.",
|
|
959
|
+
request: {
|
|
960
|
+
method: input.method,
|
|
961
|
+
url: shownUrl,
|
|
962
|
+
path: redactText(url.pathname, encodedSecrets),
|
|
963
|
+
query,
|
|
964
|
+
headers,
|
|
965
|
+
...(body !== undefined ? { body } : {}),
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function previewBody(body: unknown, contentType: string, secrets: string[]): Promise<unknown> {
|
|
971
|
+
if (body === undefined || body === null) return undefined;
|
|
972
|
+
const redactValue = (value: unknown): unknown => {
|
|
973
|
+
if (typeof value === "string") return redactText(value, secrets);
|
|
974
|
+
if (Array.isArray(value)) return value.map(redactValue);
|
|
975
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, SENSITIVE_NAME.test(k) && typeof v === "string" && secrets.some((s) => v.includes(s)) ? REDACTED : redactValue(v)]));
|
|
976
|
+
return value;
|
|
977
|
+
};
|
|
978
|
+
if (typeof body === "string") {
|
|
979
|
+
if (/json/i.test(contentType)) {
|
|
980
|
+
try { return redactValue(JSON.parse(body)); } catch { /* shown as text */ }
|
|
981
|
+
}
|
|
982
|
+
return redactText(body, secrets);
|
|
983
|
+
}
|
|
984
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
985
|
+
const parts: Record<string, unknown>[] = [];
|
|
986
|
+
for (const [name, value] of body.entries()) {
|
|
987
|
+
parts.push(typeof value === "string"
|
|
988
|
+
? { name, value: redactText(value, secrets) }
|
|
989
|
+
: { name, filename: (value as File).name, ...((value as Blob).type ? { type: (value as Blob).type } : {}), bytes: (value as Blob).size });
|
|
990
|
+
}
|
|
991
|
+
return { multipart: parts };
|
|
992
|
+
}
|
|
993
|
+
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return redactText(body.toString(), secrets);
|
|
994
|
+
if (typeof Blob !== "undefined" && body instanceof Blob) return { binary: true, bytes: body.size, ...(body.type ? { type: body.type } : {}) };
|
|
995
|
+
if (body instanceof Uint8Array || body instanceof ArrayBuffer) return { binary: true, bytes: body.byteLength };
|
|
996
|
+
return { stream: true };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** --dry-run for a person: the request line, then query, headers and body. */
|
|
1000
|
+
export function formatRequestPreview(preview: RequestPreview): string {
|
|
1001
|
+
const { request } = preview;
|
|
1002
|
+
const lines = ["Dry run: nothing was sent. Credentials are redacted.", "", request.method + " " + request.url];
|
|
1003
|
+
const headers = Object.entries(request.headers);
|
|
1004
|
+
if (headers.length) lines.push("", "Headers:", ...headers.map(([name, value]) => " " + name + ": " + value));
|
|
1005
|
+
if (request.body !== undefined) {
|
|
1006
|
+
lines.push("", "Body:");
|
|
1007
|
+
const text = typeof request.body === "string" ? request.body : JSON.stringify(request.body, null, 2);
|
|
1008
|
+
lines.push(...text.split("\n").map((line) => " " + line));
|
|
1009
|
+
}
|
|
1010
|
+
return lines.join("\n") + "\n";
|
|
1011
|
+
}
|