@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
package/src/cli.ts CHANGED
@@ -22,17 +22,19 @@ import { fileURLToPath } from "node:url";
22
22
  import { TypeshipClient, formatDebugEvent, type ClientOptions, type DebugEvent } from "./index.js";
23
23
  import { asApiResult, mediaTypeForPath, validateAgainstSchema, ValidationError, type Violation } from "./core/http.js";
24
24
  import { SCHEMAS, DEFS } from "./schemas.js";
25
- import { GLOBALS, OMITTED_OPS, OPS, buildArgs, findOp, missingRequired, type OmittedOpSpec, type OpSpec, type ParamSpec } from "./ops.js";
25
+ import { GLOBALS, INPUT_TYPES, OMITTED_OPS, OPS, buildArgs, findOp, missingRequired, type OmittedOpSpec, type OpSpec, type ParamSpec } from "./ops.js";
26
26
  import {
27
27
  MCP_CLIENTS, requiredScopes, agentGuide, agentBlock, agentInstructionsFile, agentMode, bundleProperty, claimProperty, classifyApiError, classifyAuthFailure, collectionProperty, detectHarness, envelope,
28
- exitCodeFor, findMcpClient, installSkills, mcpConfigured, pendingClaims, recordClaim, summarizeDoctor, upsertAgentBlock, writeBundle, writeMcpConfig,
28
+ exitCodeFor, findMcpClient, formatRequestPreview, installSkills, mcpConfigured, pendingClaims, recordClaim, requestPreview, summarizeDoctor, upsertAgentBlock, writeBundle, writeMcpConfig,
29
29
  type AgentContext, type CommandSummary, type DoctorCheck, type EnvelopeInput, type IssueCode, type McpEntry, type McpWriteResult,
30
30
  } from "./cli-agent.js";
31
31
  import { relativeDate } from "./dates.js";
32
32
  import { checkValue, type ArgumentIssue } from "./arguments.js";
33
33
  import { docsReadCommand, docsReadTarget, fetchDocsText, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
34
34
  import { projectFields, unmatchedFields, unmatchedFieldsMessage } from "./fields.js";
35
+ import { renderTable } from "./table.js";
35
36
  import { SEARCH_PAGE_SIZE, rankOperations } from "./search.js";
37
+ import { argumentPathText, findInputType, inputTypeText, namedTypesIn } from "./type-docs.js";
36
38
 
37
39
 
38
40
  const BIN = "typeship";
@@ -46,6 +48,8 @@ const AUTH_SCALARS: { option: string; flag: string; env: string }[] = [{"option"
46
48
  const HOSTED_MCP_HEADERS: Record<string, string> = {"Authorization":"Bearer ${TYPESHIP_TOKEN}"};
47
49
  const HOSTED_MCP_NOTE: string | null = null;
48
50
  const BASIC: { envUser: string; envPass: string } | null = null;
51
+ /** Header and query names the API's key schemes use: --dry-run redacts them. */
52
+ const CREDENTIAL_NAMES: string[] = [];
49
53
  /** The spec declares no security, so the token is offered, never required. */
50
54
  const AUTH_UNDECLARED = false;
51
55
  /** Repeated --header "Name: value" flags for this invocation. */
@@ -54,7 +58,7 @@ let HEADER_FLAGS: string[] = [];
54
58
  const EXCLUDED_OPS = 0;
55
59
  /** Generated CLI operations that are intentionally unavailable to MCP. */
56
60
  const MCP_EXCLUDED_OPS = 0;
57
- const VERSION = "0.23.1";
61
+ const VERSION = "0.24.0";
58
62
  const API_VERSION = "1.0.0";
59
63
  const SPEC_FORMAT = "openapi";
60
64
  const IDENTITY_POLICY: IdentityPolicy = {};
@@ -109,7 +113,7 @@ interface Parsed {
109
113
  * API parameter with the same name (`accounts list --cursor <c>`) still
110
114
  * takes its value. Boolean API parameters are recognized once the command
111
115
  * is known. */
112
- const CORE_BOOLEAN_FLAGS = new Set(["all", "version", "non-interactive", "debug", "validate", "yes", "force", "json"]);
116
+ const CORE_BOOLEAN_FLAGS = new Set(["all", "version", "non-interactive", "debug", "validate", "yes", "force", "json", "dry-run"]);
113
117
  const BUILTIN_BOOLEAN_FLAGS: Record<string, string[]> = {
114
118
  login: ["with-token", "no-browser", "device"],
115
119
  logout: ["local"],
@@ -240,28 +244,43 @@ function out(value: unknown): void {
240
244
  /** --fields a,b.c: the dotted paths to keep in API results (null = everything). Set in main(). */
241
245
  let FIELDS: string[][] | null = null;
242
246
 
247
+ /** --format table: print API results as text for a person instead of JSON. Opt-in only; set in main(). */
248
+ let TABLE = false;
249
+
250
+ /** An API result on stdout: JSON, or the --format table view of the same value. */
251
+ function printResult(value: unknown, resource: string, collectionField: string | null = null): void {
252
+ if (!TABLE) { out(value); return; }
253
+ process.stdout.write(renderTable(value, { width: process.stdout.columns || 120, heading: (text) => paintOut("bold", text), collectionField, resource }));
254
+ }
255
+
243
256
  /** Whether the command being run writes: an --fields mistake on a write
244
257
  * must not tempt anyone into running it again. Set in main(). */
245
258
  let FIELDS_AFTER_WRITE = false;
246
259
 
247
260
  /** Keep only FIELDS of a result: arrays item by item, objects by dotted path;
248
261
  * scalars untouched. A path that matches nothing is an error naming the keys
249
- * that exist, never a silent {}. */
250
- function project(value: unknown, perItem: boolean): unknown {
262
+ * that exist, never a silent {}; one the response schema declares (an
263
+ * optional key no item has) is simply absent. */
264
+ function project(value: unknown, perItem: boolean, schema: unknown): unknown {
251
265
  if (FIELDS === null) return value;
252
- const unmatched = unmatchedFields(value, FIELDS);
266
+ const unmatched = unmatchedFields(value, FIELDS, schema);
253
267
  if (unmatched.length > 0) failUnmatchedFields(unmatched, perItem, value);
254
268
  return projectFields(value, FIELDS);
255
269
  }
256
270
 
271
+ /** The declared schema of the list a result holds under `key`. */
272
+ function listSchemaOf(schema: Record<string, unknown> | undefined, key: string): unknown {
273
+ return (schema?.properties as Record<string, unknown> | undefined)?.[key];
274
+ }
275
+
257
276
  /** --fields over a stream (--all, events): paths no item has matched yet.
258
277
  * The stream is printed as it arrives, so the check fails at its end. */
259
- function streamFieldsCheck(): { item(value: unknown): unknown; finish(): void } {
278
+ function streamFieldsCheck(schema: unknown): { item(value: unknown): unknown; finish(): void } {
260
279
  let pending: ReturnType<typeof unmatchedFields> | null = null;
261
280
  return {
262
281
  item(value: unknown): unknown {
263
282
  if (FIELDS === null) return value;
264
- const unmatched = unmatchedFields(value, FIELDS);
283
+ const unmatched = unmatchedFields(value, FIELDS, schema);
265
284
  pending = pending === null ? unmatched : pending.filter((p) => unmatched.some((u) => u.path === p.path));
266
285
  return projectFields(value, FIELDS);
267
286
  },
@@ -275,10 +294,13 @@ function failUnmatchedFields(unmatched: ReturnType<typeof unmatchedFields>, perI
275
294
  return failWith({
276
295
  code: "FIELDS_UNMATCHED",
277
296
  message: unmatchedFieldsMessage(unmatched, perItem),
278
- nextSteps: FIELDS_AFTER_WRITE
279
- ? ["This command has already run; do not run it again to change --fields." + (result !== undefined ? " Its full result is in detail.result." : "")]
280
- : ["Run the command again with --fields from the available keys" + (perItem ? " (fields apply to each item)" : "") + ", or without --fields for the whole result."],
281
- detail: { unmatched, ...(FIELDS_AFTER_WRITE && result !== undefined ? { result } : {}) },
297
+ nextSteps: [
298
+ FIELDS_AFTER_WRITE
299
+ ? "This command has already run; do not run it again to change --fields." + (result !== undefined ? " Its full result is in detail.result." : "")
300
+ : result !== undefined ? "The full result is in detail.result; use it rather than running the command again." : "",
301
+ (FIELDS_AFTER_WRITE ? "Next time, use" : "To project a later run, use") + " --fields from the available keys" + (perItem ? " (fields apply to each item)" : "") + ", or omit --fields for the whole result.",
302
+ ].filter((step) => step !== ""),
303
+ detail: { unmatched, ...(result !== undefined ? { result } : {}) },
282
304
  });
283
305
  }
284
306
 
@@ -1139,61 +1161,173 @@ function commandSummaries(): CommandSummary[] {
1139
1161
  }
1140
1162
 
1141
1163
  /**
1142
- * help --json: a compact command index. Full schemas live behind the
1143
- * per-operation docs command so discovery does not spend an agent's context
1144
- * window on every response shape before it has chosen an operation.
1164
+ * help --json: discovery as data, bounded so one call cannot fill an agent's
1165
+ * context window on a large API. The default is an index of resources and
1166
+ * command names; "help <resource> --json" pages through one resource's
1167
+ * commands with their methods, paths and summaries; "help <resource>
1168
+ * <command> --json" is one command with its flags; "help --json --all" is
1169
+ * every command with every flag. Full schemas stay behind the docs command.
1145
1170
  */
1146
- function helpJson(): Record<string, unknown> {
1147
- const byResource = new Map<string, CommandSummary[]>();
1148
- for (const c of commandSummaries()) {
1149
- const list = byResource.get(c.resource) ?? [];
1150
- list.push(c);
1151
- byResource.set(c.resource, list);
1171
+ const HELP_INDEX_NAMES = 40;
1172
+ const HELP_INDEX_BYTES = 16_000;
1173
+ const HELP_PAGE_SIZE = 50;
1174
+
1175
+ function helpHeader(detail: "index" | "resource" | "command" | "all"): Record<string, unknown> {
1176
+ return { schema_version: "3", detail, name: BIN, version: VERSION, api: API_TITLE, api_version: API_VERSION, spec_format: SPEC_FORMAT };
1177
+ }
1178
+
1179
+ function helpDiscovery(): Record<string, string> {
1180
+ return {
1181
+ resource: BIN + " help <resource> --json",
1182
+ command: BIN + " help <resource> <command> --json",
1183
+ search: BIN + " docs search <term> --json",
1184
+ operation: BIN + " docs <resource> <command> --json",
1185
+ all: BIN + " help --json --all",
1186
+ note: "Find a command by resource or search, read its flags with help <resource> <command> --json, and its complete schemas with docs. --all prints every command with every flag at once.",
1187
+ };
1188
+ }
1189
+
1190
+ function helpCoverage(): Record<string, unknown> {
1191
+ return EXCLUDED_OPS > 0 ? { coverage: { generated_operations: OPS.length, total_operations: OPS.length + EXCLUDED_OPS } } : {};
1192
+ }
1193
+
1194
+ function opsByResource(): Map<string, OpSpec[]> {
1195
+ const byResource = new Map<string, OpSpec[]>();
1196
+ for (const op of OPS) {
1197
+ const list = byResource.get(op.command[0]) ?? [];
1198
+ list.push(op);
1199
+ byResource.set(op.command[0], list);
1152
1200
  }
1201
+ return byResource;
1202
+ }
1203
+
1204
+ /** One command with its flags: an entry of help --json --all. */
1205
+ function helpEntry(op: OpSpec, summary: CommandSummary): Record<string, unknown> {
1153
1206
  return {
1154
- schema_version: "2",
1155
- name: BIN,
1156
- version: VERSION,
1157
- api: API_TITLE,
1158
- api_version: API_VERSION,
1159
- spec_format: SPEC_FORMAT,
1207
+ command: op.command[1],
1208
+ method: summary.method,
1209
+ path: summary.path,
1210
+ ...(summary.summary ? { summary: summary.summary } : {}),
1211
+ paginated: summary.paginated,
1212
+ safety: op.safety,
1213
+ destructive: summary.destructive,
1214
+ auth: summary.auth,
1215
+ positional: op.params.filter((p) => p.kind === "path").map((p) => p.name),
1216
+ flags: summary.flags,
1217
+ details_command: BIN + " docs " + op.command[0] + " " + op.command[1] + " --json",
1218
+ };
1219
+ }
1220
+
1221
+ function helpAll(): Record<string, unknown> {
1222
+ const summaries = commandSummaries();
1223
+ const byResource = opsByResource();
1224
+ return {
1225
+ ...helpHeader("all"),
1160
1226
  usage: BIN + " <resource> <command> [args] [--flags]",
1161
- resources: [...byResource.entries()].map(([resource, commands]) => ({
1227
+ resources: [...byResource.entries()].map(([resource, ops]) => ({
1162
1228
  resource,
1163
- commands: commands.map((c) => {
1164
- const op = OPS.find((o) => o.command[0] === resource && o.command[1] === c.command)!;
1165
- return {
1166
- command: c.command,
1167
- method: c.method,
1168
- path: c.path,
1169
- ...(c.summary ? { summary: c.summary } : {}),
1170
- paginated: c.paginated,
1171
- safety: op.safety,
1172
- destructive: c.destructive,
1173
- auth: c.auth,
1174
- positional: op.params.filter((p) => p.kind === "path").map((p) => p.name),
1175
- flags: c.flags,
1176
- details_command: BIN + " docs " + resource + " " + c.command + " --json",
1177
- };
1178
- }),
1229
+ commands: ops.map((op) => helpEntry(op, summaries[OPS.indexOf(op)]!)),
1179
1230
  })),
1180
- ...(EXCLUDED_OPS > 0 ? {
1181
- coverage: {
1182
- generated_operations: OPS.length,
1183
- total_operations: OPS.length + EXCLUDED_OPS,
1184
- },
1185
- } : {}),
1186
- discovery: {
1187
- search: BIN + " docs search <term> --json",
1188
- operation: BIN + " docs <resource> <command> --json",
1189
- note: "Choose an operation from this index, then read only that operation's complete schemas and example arguments.",
1190
- },
1231
+ ...helpCoverage(),
1232
+ discovery: helpDiscovery(),
1191
1233
  builtins: BUILTIN_COMMANDS,
1192
- global_flags: ["--help", "--version", "--debug", "--non-interactive", "--mode agent|human", "--yes", "--force", "--color on|off|auto", "--credentials @<JSON-file>|-", "--header 'Name: value'", "--timeout <seconds>", "--base-url <url>", "--profile <name>", "--data '<json>' | @<file> | -", "--fields <a,b.c>", "--all", "--validate", "--out <dir>", ...AUTH_SCALARS.map((a) => "--" + a.flag + " <value>")],
1234
+ global_flags: ["--help", "--version", "--debug", "--non-interactive", "--mode agent|human", "--yes", "--force", "--color on|off|auto", "--credentials @<JSON-file>|-", "--header 'Name: value'", "--timeout <seconds>", "--base-url <url>", "--profile <name>", "--data '<json>' | @<file> | -", "--fields <a,b.c>", "--all", "--validate", "--dry-run", "--out <dir>", ...AUTH_SCALARS.map((a) => "--" + a.flag + " <value>")],
1193
1235
  auth_env_vars: agentContext().authEnvVars,
1194
1236
  };
1195
1237
  }
1196
1238
 
1239
+ /** Resources and command names. Past HELP_INDEX_BYTES a resource keeps only its count. */
1240
+ function helpIndex(): Record<string, unknown> {
1241
+ const all = helpAll();
1242
+ let bytes = 0;
1243
+ let overBudget = false;
1244
+ let truncated = false;
1245
+ const resources = [...opsByResource().entries()].map(([resource, ops]) => {
1246
+ const names = ops.map((op) => op.command[1]);
1247
+ const entry = { resource, command_count: names.length, commands: names.slice(0, HELP_INDEX_NAMES) };
1248
+ const size = JSON.stringify(entry).length;
1249
+ if (overBudget || bytes + size > HELP_INDEX_BYTES) { overBudget = truncated = true; return { resource, command_count: names.length }; }
1250
+ bytes += size;
1251
+ if (names.length > HELP_INDEX_NAMES) truncated = true;
1252
+ return entry;
1253
+ });
1254
+ return {
1255
+ ...helpHeader("index"),
1256
+ usage: all.usage,
1257
+ command_count: OPS.length,
1258
+ resource_count: resources.length,
1259
+ resources,
1260
+ ...(truncated ? { truncated: "Some resources list only their first " + HELP_INDEX_NAMES + " command names or only a count. Run " + BIN + " help <resource> --json for a resource's commands." } : {}),
1261
+ ...helpCoverage(),
1262
+ discovery: all.discovery,
1263
+ builtins: all.builtins,
1264
+ global_flags: all.global_flags,
1265
+ auth_env_vars: all.auth_env_vars,
1266
+ };
1267
+ }
1268
+
1269
+ function helpTarget(resource: string, method: string | undefined): OpSpec[] {
1270
+ const ops = opsByResource().get(resource);
1271
+ if (!ops) {
1272
+ const omitted = omittedCommand(resource, method);
1273
+ if (omitted) failOmitted(omitted);
1274
+ const suggestion = didYouMean(resource, opsByResource().keys());
1275
+ fail(2, "Unknown command: " + resource + "." + (suggestion ? " Did you mean '" + BIN + " help " + suggestion + " --json'?" : ""),
1276
+ undefined, ["Run '" + BIN + " help --json' for the resources and their commands."]);
1277
+ }
1278
+ if (method === undefined) return ops!;
1279
+ const op = findOp(resource, method);
1280
+ if (!op) {
1281
+ const omitted = omittedCommand(resource, method);
1282
+ if (omitted) failOmitted(omitted);
1283
+ const suggestion = didYouMean(method, ops!.map((o) => o.command[1]));
1284
+ fail(2, "Unknown command: " + resource + " " + method + "." + (suggestion ? " Did you mean '" + BIN + " help " + resource + " " + suggestion + " --json'?" : ""),
1285
+ undefined, ["Run '" + BIN + " help " + resource + " --json' for its commands."]);
1286
+ }
1287
+ return [op!];
1288
+ }
1289
+
1290
+ async function cmdHelpJson(parsed: Parsed): Promise<void> {
1291
+ const [, resource, method, extra] = parsed.positionals;
1292
+ if (extra !== undefined) fail(2, "help --json takes at most a resource and a command.", undefined, ["Run '" + BIN + " help --json' for the index."]);
1293
+ const all = parsed.flags.get("all") === true;
1294
+ const pageFlag = parsed.flags.get("page");
1295
+ const page = pageFlag === undefined ? 1 : Number(pageFlag);
1296
+ if (!Number.isInteger(page) || page < 1) fail(2, "--page expects a whole number from 1.");
1297
+ if (resource === undefined) {
1298
+ if (pageFlag !== undefined) fail(2, "--page applies to help <resource> --json.");
1299
+ out(all ? helpAll() : helpIndex());
1300
+ await flushExit(0);
1301
+ }
1302
+ const ops = helpTarget(resource!, method);
1303
+ const summaries = commandSummaries();
1304
+ if (method !== undefined) {
1305
+ out({ ...helpHeader("command"), resource, ...helpEntry(ops[0]!, summaries[OPS.indexOf(ops[0]!)]!) });
1306
+ await flushExit(0);
1307
+ }
1308
+ if (all) {
1309
+ out({ ...helpHeader("resource"), resource, command_count: ops.length, commands: ops.map((op) => helpEntry(op, summaries[OPS.indexOf(op)]!)) });
1310
+ await flushExit(0);
1311
+ }
1312
+ const pages = Math.max(1, Math.ceil(ops.length / HELP_PAGE_SIZE));
1313
+ if (page > pages) fail(2, "--page " + page + " is past the last page (" + pages + ") of " + resource + ".", undefined, ["Run '" + BIN + " help " + resource + " --json' for the first page."]);
1314
+ const commands = ops.slice((page - 1) * HELP_PAGE_SIZE, page * HELP_PAGE_SIZE).map((op) => {
1315
+ const s = summaries[OPS.indexOf(op)]!;
1316
+ return { command: op.command[1], method: s.method, path: s.path, ...(s.summary ? { summary: s.summary } : {}), paginated: s.paginated, safety: op.safety, positional: op.params.filter((p) => p.kind === "path").map((p) => p.name) };
1317
+ });
1318
+ out({
1319
+ ...helpHeader("resource"),
1320
+ resource,
1321
+ command_count: ops.length,
1322
+ page,
1323
+ pages,
1324
+ commands,
1325
+ ...(page < pages ? { next_command: BIN + " help " + resource + " --json --page " + (page + 1) } : {}),
1326
+ discovery: { command: BIN + " help " + resource + " <command> --json", operation: BIN + " docs " + resource + " <command> --json", all: BIN + " help " + resource + " --json --all" },
1327
+ });
1328
+ await flushExit(0);
1329
+ }
1330
+
1197
1331
  async function cmdAgentGuide(parsed: Parsed): Promise<void> {
1198
1332
  if (parsed.help) {
1199
1333
  process.stdout.write(BIN + " agent-guide [--format json] — how an agent should drive this CLI: conventions, first command, docs, MCP, skills, next steps. JSON.\n");
@@ -1543,7 +1677,7 @@ function completionFlagsFor(op: OpSpec): { flags: string[]; values: Record<strin
1543
1677
  return { flags, values };
1544
1678
  }
1545
1679
 
1546
- const COMPLETION_GLOBAL_FLAGS = ["--help", "--version", "--non-interactive", "--color", "--credentials", "--header", "--timeout", "--base-url", "--profile", "--data", "--fields", "--all", "--validate", "--debug", "--mode", "--yes", "--force", "--out", ...AUTH_SCALARS.map((a) => "--" + a.flag)];
1680
+ const COMPLETION_GLOBAL_FLAGS = ["--help", "--version", "--non-interactive", "--color", "--credentials", "--header", "--timeout", "--base-url", "--profile", "--data", "--fields", "--all", "--validate", "--dry-run", "--debug", "--mode", "--yes", "--force", "--out", ...AUTH_SCALARS.map((a) => "--" + a.flag)];
1547
1681
  const BUILTIN_WORDS: Record<string, string[]> = {
1548
1682
  config: ["list", "get", "set", "unset", "path"],
1549
1683
  completion: ["bash", "zsh", "fish"],
@@ -1701,7 +1835,9 @@ function referenceFor(op: OpSpec, includeSchemas = false): string {
1701
1835
  lines.push("", paintOut("bold", label + ":"));
1702
1836
  for (const p of params) {
1703
1837
  const name = p.kind === "path" ? "<" + p.name + ">" : "--" + p.flag;
1704
- lines.push(" " + padPaint("cyan", name, 30) + typeLabel(p) + (p.required ? " " + paintOut("yellow", "(required)") : ""));
1838
+ // An object flag reads as its named type, which docs read explains.
1839
+ const named = objectTypeName(op, p);
1840
+ lines.push(" " + padPaint("cyan", name, 30) + (named ? INPUT_TYPES.args[op.tool]![p.name] : typeLabel(p)) + (p.required ? " " + paintOut("yellow", "(required)") : ""));
1705
1841
  const values = p.type === "array" ? p.items?.enum : p.enum;
1706
1842
  if (values && values.join("|").length > 24) lines.push(" one of: " + values.join(", "));
1707
1843
  if (p.description) {
@@ -1720,11 +1856,34 @@ function referenceFor(op: OpSpec, includeSchemas = false): string {
1720
1856
  lines.push("", paintOut("bold", "Wire arguments:"), JSON.stringify(op.exampleArguments, null, 2));
1721
1857
  if (op.outputSchema) lines.push("", paintOut("bold", "Output schema:"), JSON.stringify(op.outputSchema, null, 2));
1722
1858
  } else {
1859
+ const nested = op.params.find((p) => objectTypeName(op, p) || p.type === "object" || (p.type === "array" && p.items?.type === "object"));
1860
+ if (nested) {
1861
+ const named = objectTypeName(op, nested);
1862
+ lines.push("", "Nested fields: " + BIN + " docs " + op.command.join(" ") + " --path " + nested.name + " gives a flag's type and all its fields; extend the path (" + nested.name + ".<field>) to go deeper."
1863
+ + (named ? " Named types: " + BIN + " docs read " + named + "." : ""));
1864
+ }
1723
1865
  lines.push("", "Add --schema for the complete input/output schemas, or --json for the machine contract.");
1724
1866
  }
1725
1867
  return lines.join("\n");
1726
1868
  }
1727
1869
 
1870
+ /** The named input object a flag takes (IssueFilter), when it has one. */
1871
+ function objectTypeName(op: OpSpec, p: ParamSpec): string | undefined {
1872
+ return namedTypesIn(INPUT_TYPES, INPUT_TYPES.args[op.tool]?.[p.name]).find((name) => INPUT_TYPES.types[name]!.fields);
1873
+ }
1874
+
1875
+ function docsTypeHint(name: string): string {
1876
+ return BIN + " docs read " + name;
1877
+ }
1878
+
1879
+ /** docs --path: one nested argument, or a usage error naming the fields. */
1880
+ async function docsPathExit(root: { tool: string; inputSchema: Record<string, unknown>; label: string } | { type: string }, path: string): Promise<never> {
1881
+ const found = argumentPathText(INPUT_TYPES, root, path, docsTypeHint);
1882
+ if (!found.ok) fail(2, found.message + (found.available.length > 0 ? " Fields: " + found.available.join(", ") + "." : ""));
1883
+ process.stdout.write((found as { text: string }).text + "\n");
1884
+ return flushExit(0);
1885
+ }
1886
+
1728
1887
  /** Opens a browser for a person; under an agent it prints the URL instead of opening anything. */
1729
1888
  function openInBrowser(url: string, parsed?: Parsed): { opened: boolean; url: string } {
1730
1889
  if (parsed && nonInteractive(parsed)) {
@@ -1744,11 +1903,12 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1744
1903
  "",
1745
1904
  " " + BIN + " docs overview",
1746
1905
  " " + BIN + " docs <resource> <command> operation contract and example",
1906
+ " --path <a.b> one nested argument's type and fields",
1747
1907
  " --schema include input/output JSON Schema",
1748
1908
  " --json print the machine contract as JSON",
1749
1909
  " " + BIN + " docs search <term> search reference and guides",
1750
1910
  " --json / --format json print structured matches and availability",
1751
- " " + BIN + " docs read <page> print a docs-site page in the terminal",
1911
+ " " + BIN + " docs read <page> print a named input type or a docs-site page",
1752
1912
  " " + BIN + " docs --web open the docs site in a browser",
1753
1913
  "",
1754
1914
  "Guides come from the docs site's llms.txt (set with '" + BIN + " config set docs-url <url>').",
@@ -1815,6 +1975,13 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1815
1975
  if (sub === "read") {
1816
1976
  const page = parsed.positionals[2];
1817
1977
  if (page === undefined) fail(2, "docs read expects a page path or URL");
1978
+ const typeName = findInputType(INPUT_TYPES, page!);
1979
+ if (typeName) {
1980
+ const path = parsed.flags.get("path");
1981
+ if (typeof path === "string" && path.trim()) await docsPathExit({ type: typeName }, path);
1982
+ process.stdout.write(inputTypeText(INPUT_TYPES, typeName, docsTypeHint) + "\n");
1983
+ await flushExit(0);
1984
+ }
1818
1985
  let target = page!;
1819
1986
  if (!/^https?:\/\//.test(target)) {
1820
1987
  const index = await fetchDocs("llms.txt");
@@ -1853,6 +2020,8 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1853
2020
  }, null, 2) + "\n");
1854
2021
  await flushExit(0);
1855
2022
  }
2023
+ const path = parsed.flags.get("path");
2024
+ if (typeof path === "string" && path.trim()) await docsPathExit({ tool: op!.tool, inputSchema: op!.inputSchema, label: op!.command.join(" ") }, path);
1856
2025
  process.stdout.write(referenceFor(op!, parsed.flags.get("schema") === true) + "\n");
1857
2026
  await flushExit(0);
1858
2027
  }
@@ -2037,7 +2206,7 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
2037
2206
  }
2038
2207
  const width = termWidth();
2039
2208
  const lines: string[] = [];
2040
- lines.push(paintOut("bold", BIN) + ": " + "Typeship API" + " (v" + "1.0.0" + "), package " + "0.23.1");
2209
+ lines.push(paintOut("bold", BIN) + ": " + "Typeship API" + " (v" + "1.0.0" + "), package " + "0.24.0");
2041
2210
  lines.push("");
2042
2211
  lines.push(paintOut("bold", "Usage:") + " " + BIN + " <resource> <command> [args] [--flags]");
2043
2212
  lines.push("");
@@ -2059,7 +2228,7 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
2059
2228
  lines.push(...labeled(paintOut("yellow", "Coverage:") + " ", "this build includes " + OPS.length + " of " + (OPS.length + EXCLUDED_OPS) + " operations; api.json lists the rest", width, 14));
2060
2229
  }
2061
2230
  lines.push("");
2062
- const flagsText = "-v/--version, -h/--help, --debug, --non-interactive, --color on|off|auto, --base-url <url>, --profile <name>, --credentials @<file>|-, --header \"Name: value\", --timeout <seconds>, --data '<json>', --fields <a,b.c>, --all (paginated lists), --validate (schema-check parameters and JSON bodies)" +
2231
+ const flagsText = "-v/--version, -h/--help, --debug, --non-interactive, --color on|off|auto, --base-url <url>, --profile <name>, --credentials @<file>|-, --header \"Name: value\", --timeout <seconds>, --data '<json>', --fields <a,b.c>, --all (paginated lists), --format table (results as text), --validate (schema-check parameters and JSON bodies), --dry-run (print the request, send nothing)" +
2063
2232
  (AUTH_SCALARS.length > 0 ? ", " + AUTH_SCALARS.map((a) => "--" + a.flag + " <value>").join(", ") : "");
2064
2233
  lines.push(...labeled(paintOut("bold", "Global flags:") + " ", flagsText, width, 14).map((l, i) => (i === 0 ? l : l)));
2065
2234
  lines.push(...labeled("Credential env vars: ", [
@@ -2117,6 +2286,8 @@ function commandExtras(op: OpSpec): [string, string][] {
2117
2286
  const collectionField = collectionProperty(op.outputSchema);
2118
2287
  extras.push(["--fields <a,b.c>", "keep only these fields of the result" + (op.paginated ? " (per item)" : collectionField ? " (per item in " + collectionField + ")" : "")]);
2119
2288
  if ((op.fileBundleProperty ?? bundleProperty(op.outputSchema)) !== null) extras.push(["--out <dir>", "write the response's files ({path, content}) into a directory"]);
2289
+ extras.push(["--dry-run", "print the resolved request (method, URL, headers, body) with credentials redacted; nothing is sent" + (op.safety === "destructive" ? ", so no --force is needed" : "")]);
2290
+ if (!op.rawResponse && !op.sse) extras.push(["--format table", "print the result as a table for reading instead of JSON"]);
2120
2291
  if (op.safety === "destructive") extras.push(["--force, -y", "destructive: required without a terminal, skips the prompt with one"]);
2121
2292
  return extras;
2122
2293
  }
@@ -2341,6 +2512,65 @@ let LAST_CLIENT_HAD_CREDENTIAL = false;
2341
2512
  /** The last client's Basic credentials, for path arguments that default to the username. */
2342
2513
  let LAST_CLIENT_BASIC: { basicAuth?: unknown; credentials?: unknown } = {};
2343
2514
 
2515
+ /** --dry-run: the client's fetch records the first request and sends
2516
+ * nothing. Set before makeClient; secrets are the credential values it
2517
+ * resolved, redacted wherever they appear in the preview. */
2518
+ let DRY_RUN: { fetch: typeof fetch; request?: { method: string; url: string; headers: Record<string, string>; body?: unknown }; secrets: string[] } | null = null;
2519
+
2520
+ class DryRunStop extends Error {}
2521
+
2522
+ function dryRunCapture(): NonNullable<typeof DRY_RUN> {
2523
+ const capture: NonNullable<typeof DRY_RUN> = {
2524
+ secrets: [],
2525
+ fetch: async (input, init) => {
2526
+ if (!capture.request) {
2527
+ const headers: Record<string, string> = {};
2528
+ new Headers(init?.headers).forEach((value, name) => { headers[name] = value; });
2529
+ capture.request = { method: init?.method ?? "GET", url: typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url, headers, body: init?.body ?? undefined };
2530
+ }
2531
+ throw new DryRunStop("dry run: not sent");
2532
+ },
2533
+ };
2534
+ return capture;
2535
+ }
2536
+
2537
+ /** Every credential value a client was built with (not a Basic username,
2538
+ * which is an account identifier such as Twilio's AccountSid). */
2539
+ function credentialSecrets(options: Record<string, unknown>): string[] {
2540
+ const found: string[] = [];
2541
+ const walk = (value: unknown, key: string) => {
2542
+ if (typeof value === "string") { if (key !== "username") found.push(value); return; }
2543
+ if (value && typeof value === "object") for (const [k, v] of Object.entries(value as Record<string, unknown>)) walk(v, k);
2544
+ };
2545
+ for (const a of AUTH_SCALARS) walk(options[a.option], a.option);
2546
+ walk(options.basicAuth, "basicAuth");
2547
+ walk(options.credentials, "credentials");
2548
+ walk(options.bearerToken, "bearerToken");
2549
+ return found;
2550
+ }
2551
+
2552
+ /** Drive the command's call until its request reaches the dry-run fetch,
2553
+ * then print that request (JSON in agent mode, text for a person) and exit.
2554
+ * A failure before any request (a --validate violation) is reported as usual. */
2555
+ async function printDryRun(parsed: Parsed, callResult: unknown): Promise<never> {
2556
+ let failure: unknown;
2557
+ try {
2558
+ const pending = callResult as { then?: unknown; [Symbol.asyncIterator]?: () => AsyncIterator<unknown> };
2559
+ if (typeof pending.then === "function") await (callResult as Promise<unknown>);
2560
+ else if (typeof pending[Symbol.asyncIterator] === "function") await pending[Symbol.asyncIterator]!().next();
2561
+ } catch (e) {
2562
+ failure = e;
2563
+ }
2564
+ const request = DRY_RUN?.request;
2565
+ if (!request) {
2566
+ failApi(failure ?? new Error("--dry-run: the command made no request"), LAST_CLIENT_HAD_CREDENTIAL);
2567
+ }
2568
+ const preview = await requestPreview(request!, { sensitiveNames: CREDENTIAL_NAMES, secrets: DRY_RUN!.secrets });
2569
+ if (isAgentMode(parsed)) out(preview);
2570
+ else process.stdout.write(formatRequestPreview(preview));
2571
+ return await flushExit(0);
2572
+ }
2573
+
2344
2574
  /** The configured Basic-auth username: the named scheme's, else basicAuth's. */
2345
2575
  function credentialUsername(scheme: string): string | undefined {
2346
2576
  const named = (LAST_CLIENT_BASIC.credentials as Record<string, unknown> | undefined)?.[scheme];
@@ -2539,6 +2769,12 @@ async function makeClient(flags: Map<string, string | boolean>, op: OpSpec, cand
2539
2769
  if (forIdentity) { options.fetch = identityFetch(baseUrl); options.maxRetries = 0; options.timeoutMs = 10_000; }
2540
2770
  const extraHeaders = extraRequestHeaders();
2541
2771
  if (Object.keys(extraHeaders).length) options.onRequest = (context) => { applyExtraHeaders(context.headers, extraHeaders); };
2772
+ // --dry-run swaps fetch on this client only: an OAuth refresh (and its
2773
+ // identity check) still uses the real transport, the command does not.
2774
+ if (DRY_RUN && !forIdentity) {
2775
+ DRY_RUN.secrets = credentialSecrets(options);
2776
+ return new TypeshipClient({ ...options, fetch: DRY_RUN.fetch, maxRetries: 0 });
2777
+ }
2542
2778
  return new TypeshipClient(options);
2543
2779
  }
2544
2780
 
@@ -2596,12 +2832,17 @@ async function main(): Promise<void> {
2596
2832
  PROFILE = resolveProfile(configRoot(), { flag: profileFlag as string | undefined, environment: process.env["TYPESHIP_PROFILE"], allowMissing: ["login", "config", "auth", "init", "help"].includes(parsed.positionals[0] ?? "") || parsed.help });
2597
2833
  if (!parsed.help && ["login", "init"].includes(parsed.positionals[0] ?? "")) expectedLoginIdentity(parsed.flags);
2598
2834
  if (parsed.positionals[0] === "help") {
2599
- // help --json: the command surface as data (agents read this once).
2600
- if (parsed.flags.get("json") === true || parsed.flags.get("format") === "json") { out(helpJson()); await flushExit(0); }
2835
+ // help [<resource> [<command>]] --json: bounded discovery as data; --all is exhaustive.
2836
+ if (parsed.flags.get("json") === true || parsed.flags.get("format") === "json") { await cmdHelpJson(parsed); }
2601
2837
  parsed.positionals.shift(); parsed.help = true;
2602
2838
  }
2603
- if (parsed.flags.get("format") !== undefined && parsed.flags.get("format") !== "json") {
2604
- fail(2, "--format json is the only format; output is always JSON.");
2839
+ const formatFlag = parsed.flags.get("format");
2840
+ if (formatFlag !== undefined && formatFlag !== "json" && formatFlag !== "table") {
2841
+ fail(2, "--format takes json (the default) or table.");
2842
+ }
2843
+ TABLE = formatFlag === "table";
2844
+ if (TABLE && !parsed.help && (BUILTIN_COMMANDS.includes(parsed.positionals[0] ?? "") || parsed.positionals.length === 0)) {
2845
+ fail(2, "--format table applies to API commands; " + (parsed.positionals[0] ? BIN + " " + parsed.positionals[0] : BIN) + " prints JSON.");
2605
2846
  }
2606
2847
  if (parsed.flags.has("version") || parsed.positionals[0] === "version") {
2607
2848
  // "acme 1.0.0 (acme 1.0.0)" would say the name twice; when the API's
@@ -2704,7 +2945,7 @@ async function main(): Promise<void> {
2704
2945
  // Mirrors opReservedFlags() in the generator: API parameters never use these
2705
2946
  // names (colliding ones are emitted as --<kind>-<name>), so an unknown flag
2706
2947
  // check can be exact.
2707
- const RESERVED_FLAGS = new Set(["data", "credentials", "header", "timeout", "all", "select", "base-url", "profile", "debug", "validate", "non-interactive", "color", "version", "help", "yes", "force", "mode", "format", "json", "out", "fields", ...AUTH_SCALARS.map((a) => a.flag), ...(BASIC ? ["username", "password"] : []), ...GLOBALS.map((g) => g.flag)]);
2948
+ const RESERVED_FLAGS = new Set(["data", "credentials", "header", "timeout", "all", "select", "base-url", "profile", "debug", "validate", "dry-run", "non-interactive", "color", "version", "help", "yes", "force", "mode", "format", "json", "out", "fields", ...AUTH_SCALARS.map((a) => a.flag), ...(BASIC ? ["username", "password"] : []), ...GLOBALS.map((g) => g.flag)]);
2708
2949
  for (const spec of op.params) {
2709
2950
  if (spec.kind === "path") continue;
2710
2951
  const raw = parsed.flags.get(spec.flag);
@@ -2798,6 +3039,18 @@ async function main(): Promise<void> {
2798
3039
  if (!op.paginated && parsed.flags.get("all") === true) {
2799
3040
  fail(2, op.command.join(" ") + " does not paginate, so --all has nothing to walk. Run it without --all; it returns the whole response.");
2800
3041
  }
3042
+ // --dry-run resolves everything a real call would (arguments, defaults,
3043
+ // credentials, the body encoding) and prints the request instead of
3044
+ // sending it. A destructive command needs no --force: nothing runs.
3045
+ if (parsed.flags.get("dry-run") === true) {
3046
+ if (op.paginated && parsed.flags.get("all") === true) fail(2, "--dry-run previews one request; run it without --all to see the first page's.");
3047
+ DRY_RUN = dryRunCapture();
3048
+ }
3049
+ // A table needs the whole result: streams (--all, events) and raw bodies stay as they are.
3050
+ if (TABLE) {
3051
+ const streams = parsed.flags.get("all") === true || op.sse || (op.streamMethod !== undefined && values[op.streamMethod.flag] === op.streamMethod.value);
3052
+ if (streams || op.rawResponse) fail(2, "--format table needs one complete JSON result; " + op.command.join(" ") + (op.rawResponse ? " returns a raw body." : " streams NDJSON here.") + " Drop --format table" + (parsed.flags.get("all") === true ? " or --all." : "."));
3053
+ }
2801
3054
  const client = await makeClient(parsed.flags, op);
2802
3055
  for (const spec of pathSpecs) {
2803
3056
  if (!spec.credential || values[spec.name] !== undefined) continue;
@@ -2812,7 +3065,7 @@ async function main(): Promise<void> {
2812
3065
  // Destructive commands need --force. A person gets asked; an agent gets
2813
3066
  // an action_required envelope with the exact command to run, so nothing
2814
3067
  // is deleted on a guess.
2815
- if (op.safety === "destructive" && !assumeYes(parsed)) {
3068
+ if (op.safety === "destructive" && !assumeYes(parsed) && !DRY_RUN) {
2816
3069
  // Credential flags are replaced by placeholders: the rerun is shown to
2817
3070
  // agents and logged, so it must never repeat a key.
2818
3071
  const secretFlags = new Set([...AUTH_SCALARS.map((a) => "--" + a.flag), "--header"]);
@@ -2842,9 +3095,10 @@ async function main(): Promise<void> {
2842
3095
  // as NDJSON as they arrive, instead of failing on the event stream.
2843
3096
  const streaming = op.streamMethod !== undefined && values[op.streamMethod.flag] === op.streamMethod.value;
2844
3097
  const callResult = target[streaming ? op.streamMethod!.method : op.method]!(...args);
3098
+ if (DRY_RUN) await printDryRun(parsed, callResult);
2845
3099
 
2846
3100
  if (op.paginated && parsed.flags.get("all") === true) {
2847
- const fieldsCheck = streamFieldsCheck();
3101
+ const fieldsCheck = streamFieldsCheck((listSchemaOf(op.outputSchema, "items") as { items?: unknown } | undefined)?.items);
2848
3102
  try {
2849
3103
  for await (const item of callResult as AsyncIterable<unknown>) {
2850
3104
  process.stdout.write(JSON.stringify(fieldsCheck.item(item)) + "\n");
@@ -2875,7 +3129,7 @@ async function main(): Promise<void> {
2875
3129
  if (result.ok) {
2876
3130
  if (op.sse || streaming) {
2877
3131
  // Server-sent events as NDJSON, one line per event, until the stream ends.
2878
- const fieldsCheck = streamFieldsCheck();
3132
+ const fieldsCheck = streamFieldsCheck(undefined);
2879
3133
  try {
2880
3134
  for await (const event of result.data as AsyncIterable<{ event?: string; id?: string; data: string }>) {
2881
3135
  process.stdout.write(JSON.stringify(fieldsCheck.item(event)) + "\n");
@@ -2917,17 +3171,17 @@ async function main(): Promise<void> {
2917
3171
  // agent never has to reconstruct the cursor flag.
2918
3172
  const page = result.data as { items: unknown[]; hasNextPage(): boolean; nextPageParams(): Record<string, unknown> | null; response: { requestId?: string } };
2919
3173
  const next = page.nextPageParams();
2920
- out({
2921
- items: project(page.items, true),
3174
+ printResult({
3175
+ items: project(page.items, true, listSchemaOf(op.outputSchema, "items")),
2922
3176
  hasMore: next !== null,
2923
3177
  ...(next !== null ? { nextPage: next, nextCommand: nextCommandFor(op, pathValues, next) } : {}),
2924
3178
  ...(page.response.requestId ? { request_id: page.response.requestId } : {}),
2925
- });
3179
+ }, op.command[0]);
2926
3180
  } else if (FIELDS !== null && collectionField !== null && result.data !== null && typeof result.data === "object" && !Array.isArray(result.data) && Array.isArray((result.data as Record<string, unknown>)[collectionField])) {
2927
3181
  // Batch-style collection envelopes ({data: [...]}) use item-relative
2928
3182
  // fields, matching paginated results, while retaining envelope metadata.
2929
3183
  const data = result.data as Record<string, unknown>;
2930
- out({ ...data, [collectionField]: project(data[collectionField], true) });
3184
+ printResult({ ...data, [collectionField]: project(data[collectionField], true, listSchemaOf(op.outputSchema, collectionField)) }, op.command[0], collectionField);
2931
3185
  } else if (outDir !== undefined && bundleField !== null) {
2932
3186
  // --out: a file-shaped response (an array of {path, content}) lands
2933
3187
  // on disk; stdout gets the rest of the response plus a summary.
@@ -2935,9 +3189,9 @@ async function main(): Promise<void> {
2935
3189
  const files = (data[bundleField] as { path: string; content: string }[] | undefined) ?? [];
2936
3190
  const written = writeBundle(outDir, files);
2937
3191
  const { [bundleField]: _omitted, ...rest } = data;
2938
- out({ ...(project(rest, false) as Record<string, unknown>), out: written });
3192
+ printResult({ ...(project(rest, false, op.outputSchema) as Record<string, unknown>), out: written }, op.command[0]);
2939
3193
  } else {
2940
- out(result.data === undefined || result.data === null ? { ok: true } : project(result.data, Array.isArray(result.data)));
3194
+ printResult(result.data === undefined || result.data === null ? { ok: true } : project(result.data, Array.isArray(result.data), op.outputSchema), op.command[0], collectionField);
2941
3195
  }
2942
3196
  await flushExit(0);
2943
3197
  }