@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.
Files changed (57) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +2 -2
  3. package/api.md +2 -2
  4. package/dist/arguments.d.ts +9 -2
  5. package/dist/arguments.d.ts.map +1 -1
  6. package/dist/arguments.js +17 -6
  7. package/dist/cli-agent.d.ts +42 -1
  8. package/dist/cli-agent.d.ts.map +1 -1
  9. package/dist/cli-agent.js +187 -18
  10. package/dist/cli.js +341 -76
  11. package/dist/fields.d.ts +8 -1
  12. package/dist/fields.d.ts.map +1 -1
  13. package/dist/fields.js +91 -5
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.js +3 -3
  16. package/dist/ops.d.ts +5 -0
  17. package/dist/ops.d.ts.map +1 -1
  18. package/dist/ops.js +66 -6
  19. package/dist/resources/deliveries.d.ts +1 -1
  20. package/dist/resources/deliveries.d.ts.map +1 -1
  21. package/dist/resources/deliveries.js +1 -1
  22. package/dist/resources/drafts.d.ts +1 -1
  23. package/dist/resources/drafts.d.ts.map +1 -1
  24. package/dist/resources/drafts.js +1 -1
  25. package/dist/resources/generations.d.ts +2 -2
  26. package/dist/resources/generations.d.ts.map +1 -1
  27. package/dist/resources/generations.js +2 -2
  28. package/dist/resources/releases.d.ts +1 -1
  29. package/dist/resources/releases.d.ts.map +1 -1
  30. package/dist/resources/releases.js +1 -1
  31. package/dist/resources/spec-revisions.d.ts +1 -1
  32. package/dist/resources/spec-revisions.d.ts.map +1 -1
  33. package/dist/resources/spec-revisions.js +1 -1
  34. package/dist/resources/targets.d.ts +1 -1
  35. package/dist/resources/targets.d.ts.map +1 -1
  36. package/dist/resources/targets.js +1 -1
  37. package/dist/table.d.ts +28 -0
  38. package/dist/table.d.ts.map +1 -0
  39. package/dist/table.js +167 -0
  40. package/dist/type-docs.d.ts +61 -0
  41. package/dist/type-docs.d.ts.map +1 -0
  42. package/dist/type-docs.js +174 -0
  43. package/package.json +1 -1
  44. package/src/arguments.ts +19 -7
  45. package/src/cli-agent.ts +196 -17
  46. package/src/cli.ts +330 -76
  47. package/src/fields.ts +81 -5
  48. package/src/index.ts +3 -3
  49. package/src/ops.ts +69 -6
  50. package/src/resources/deliveries.ts +2 -2
  51. package/src/resources/drafts.ts +2 -2
  52. package/src/resources/generations.ts +4 -4
  53. package/src/resources/releases.ts +2 -2
  54. package/src/resources/spec-revisions.ts +2 -2
  55. package/src/resources/targets.ts +2 -2
  56. package/src/table.ts +167 -0
  57. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeship-ax/cli",
3
- "version": "0.23.1",
3
+ "version": "0.24.0",
4
4
  "description": "CLI for Typeship.",
5
5
  "keywords": [
6
6
  "typeship",
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
- if (typeof out === "string" && typeof schema.pattern === "string" && !options.skipPattern) {
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, skipPattern: typeof entry === "string" && isMeReference(name, entry) })
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 to the
239
- * caller's ID through the identity tool. */
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
- const detail = { status, ...(e.name ? { error: e.name } : {}), ...(requestId ? { request_id: requestId } : {}), ...(e.body !== undefined ? { body: e.body } : {}) };
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
- if (status === 401) {
156
- return context.hadCredential
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."] }
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."] };
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 === 403 && scopes.length) return scopeFailure();
161
- if (status === 403) return { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential lacks access to this operation."] };
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 index = ctx.docsIndexUrl ?? (ctx.docsUrl ? ctx.docsUrl.replace(/\/+$/, "") + "/llms.txt" : null);
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`, `" + ctx.bin + " <resource> <command> --help`, `" + ctx.bin + " help --json` (machine-readable), `" + ctx.bin + " agent-guide --format json`.",
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)") + ".",
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
- "Commands (resource command | METHOD path | required flags | summary):",
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, or read the AGENTS.md block '" + ctx.bin + " init' writes.",
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
+ }