@typeship-ax/cli 0.22.0 → 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 (146) hide show
  1. package/AGENTS.md +13 -9
  2. package/README.md +14 -27
  3. package/api.json +8898 -9026
  4. package/api.md +370 -328
  5. package/dist/arguments.d.ts +54 -0
  6. package/dist/arguments.d.ts.map +1 -0
  7. package/dist/arguments.js +265 -0
  8. package/dist/cli-agent.d.ts +73 -9
  9. package/dist/cli-agent.d.ts.map +1 -1
  10. package/dist/cli-agent.js +331 -44
  11. package/dist/cli.js +802 -291
  12. package/dist/core/http.d.ts +162 -19
  13. package/dist/core/http.d.ts.map +1 -1
  14. package/dist/core/http.js +381 -48
  15. package/dist/core/pagination.d.ts +42 -6
  16. package/dist/core/pagination.d.ts.map +1 -1
  17. package/dist/core/pagination.js +111 -17
  18. package/dist/credential-storage.d.ts +10 -3
  19. package/dist/credential-storage.d.ts.map +1 -1
  20. package/dist/credential-storage.js +15 -6
  21. package/dist/dates.d.ts +1 -1
  22. package/dist/dates.js +1 -1
  23. package/dist/errors.d.ts +20 -84
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +20 -108
  26. package/dist/fields.d.ts +36 -0
  27. package/dist/fields.d.ts.map +1 -0
  28. package/dist/fields.js +187 -0
  29. package/dist/index.d.ts +28 -18
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +35 -25
  32. package/dist/named-credentials.d.ts +19 -0
  33. package/dist/named-credentials.d.ts.map +1 -1
  34. package/dist/named-credentials.js +81 -1
  35. package/dist/oauth-login.d.ts +8 -2
  36. package/dist/oauth-login.d.ts.map +1 -1
  37. package/dist/oauth-login.js +31 -19
  38. package/dist/oauth-request.d.ts +7 -1
  39. package/dist/oauth-request.d.ts.map +1 -1
  40. package/dist/oauth-request.js +26 -4
  41. package/dist/oauth-session.d.ts +13 -1
  42. package/dist/oauth-session.d.ts.map +1 -1
  43. package/dist/oauth-session.js +34 -18
  44. package/dist/ops.d.ts +58 -5
  45. package/dist/ops.d.ts.map +1 -1
  46. package/dist/ops.js +110 -41
  47. package/dist/polling-login.d.ts +8 -2
  48. package/dist/polling-login.d.ts.map +1 -1
  49. package/dist/polling-login.js +25 -11
  50. package/dist/resources/api-keys.d.ts +10 -7
  51. package/dist/resources/api-keys.d.ts.map +1 -1
  52. package/dist/resources/api-keys.js +10 -31
  53. package/dist/resources/deliveries.d.ts +89 -5
  54. package/dist/resources/deliveries.d.ts.map +1 -1
  55. package/dist/resources/deliveries.js +96 -19
  56. package/dist/resources/drafts.d.ts +16 -16
  57. package/dist/resources/drafts.d.ts.map +1 -1
  58. package/dist/resources/drafts.js +12 -65
  59. package/dist/resources/files.d.ts +4 -4
  60. package/dist/resources/files.d.ts.map +1 -1
  61. package/dist/resources/files.js +3 -12
  62. package/dist/resources/generations.d.ts +16 -16
  63. package/dist/resources/generations.d.ts.map +1 -1
  64. package/dist/resources/generations.js +23 -47
  65. package/dist/resources/organization.d.ts +4 -4
  66. package/dist/resources/organization.d.ts.map +1 -1
  67. package/dist/resources/organization.js +3 -10
  68. package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
  69. package/dist/resources/packages.d.ts.map +1 -0
  70. package/dist/resources/{generate.js → packages.js} +13 -29
  71. package/dist/resources/projects.d.ts +50 -50
  72. package/dist/resources/projects.d.ts.map +1 -1
  73. package/dist/resources/projects.js +60 -116
  74. package/dist/resources/releases.d.ts +22 -17
  75. package/dist/resources/releases.d.ts.map +1 -1
  76. package/dist/resources/releases.js +19 -40
  77. package/dist/resources/spec-revisions.d.ts +16 -7
  78. package/dist/resources/spec-revisions.d.ts.map +1 -1
  79. package/dist/resources/spec-revisions.js +7 -29
  80. package/dist/resources/specs.d.ts +7 -7
  81. package/dist/resources/specs.d.ts.map +1 -1
  82. package/dist/resources/specs.js +6 -34
  83. package/dist/resources/targets.d.ts +49 -49
  84. package/dist/resources/targets.d.ts.map +1 -1
  85. package/dist/resources/targets.js +59 -115
  86. package/dist/schemas.d.ts.map +1 -1
  87. package/dist/schemas.js +83 -81
  88. package/dist/search.d.ts +54 -0
  89. package/dist/search.d.ts.map +1 -0
  90. package/dist/search.js +421 -0
  91. package/dist/table.d.ts +28 -0
  92. package/dist/table.d.ts.map +1 -0
  93. package/dist/table.js +167 -0
  94. package/dist/type-docs.d.ts +61 -0
  95. package/dist/type-docs.d.ts.map +1 -0
  96. package/dist/type-docs.js +174 -0
  97. package/dist/types.d.ts +499 -339
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js +18 -18
  100. package/package.json +5 -2
  101. package/src/arguments.ts +254 -0
  102. package/src/cli-agent.ts +351 -46
  103. package/src/cli.ts +753 -266
  104. package/src/core/http.ts +457 -58
  105. package/src/core/pagination.ts +129 -18
  106. package/src/credential-storage.ts +16 -6
  107. package/src/dates.ts +1 -1
  108. package/src/errors.ts +46 -115
  109. package/src/fields.ts +167 -0
  110. package/src/index.ts +45 -28
  111. package/src/named-credentials.ts +66 -1
  112. package/src/oauth-login.ts +36 -21
  113. package/src/oauth-request.ts +32 -6
  114. package/src/oauth-session.ts +37 -19
  115. package/src/ops.ts +146 -45
  116. package/src/polling-login.ts +24 -11
  117. package/src/resources/api-keys.ts +34 -48
  118. package/src/resources/deliveries.ts +213 -32
  119. package/src/resources/drafts.ts +62 -109
  120. package/src/resources/files.ts +19 -20
  121. package/src/resources/generations.ts +61 -79
  122. package/src/resources/organization.ts +11 -16
  123. package/src/resources/{generate.ts → packages.ts} +43 -51
  124. package/src/resources/projects.ts +145 -200
  125. package/src/resources/releases.ts +50 -67
  126. package/src/resources/spec-revisions.ts +40 -49
  127. package/src/resources/specs.ts +39 -59
  128. package/src/resources/targets.ts +144 -194
  129. package/src/schemas.ts +83 -81
  130. package/src/search.ts +434 -0
  131. package/src/table.ts +167 -0
  132. package/src/type-docs.ts +205 -0
  133. package/src/types.ts +538 -357
  134. package/dist/console-login-check.d.ts +0 -21
  135. package/dist/console-login-check.d.ts.map +0 -1
  136. package/dist/console-login-check.js +0 -107
  137. package/dist/console-login-contract.d.ts +0 -45
  138. package/dist/console-login-contract.d.ts.map +0 -1
  139. package/dist/console-login-contract.js +0 -40
  140. package/dist/resources/generate.d.ts.map +0 -1
  141. package/dist/resources/publications.d.ts +0 -47
  142. package/dist/resources/publications.d.ts.map +0 -1
  143. package/dist/resources/publications.js +0 -70
  144. package/src/console-login-check.ts +0 -88
  145. package/src/console-login-contract.ts +0 -65
  146. package/src/resources/publications.ts +0 -140
package/src/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // typeship — command-line client. Generated by typeship — https://typeship.dev
2
+ // Typeship — command-line client. Generated by Typeship — https://typeship.dev
3
3
  // Flags-only for API commands (CI-safe); `login` is the one interactive
4
4
  // exception (hidden prompt on a TTY, --with-token/--token for scripts).
5
5
  // Prints raw JSON to stdout; errors as JSON on stderr.
@@ -9,40 +9,56 @@ import { spawnSync } from "node:child_process";
9
9
  import { oauthBrowserLogin, type OAuthLoginSession } from "./oauth-login.js";
10
10
  import { oauthDeviceLogin, customBrowserApproval, loginEndpoint, type ApprovedCredential } from "./polling-login.js";
11
11
  import { oauthStatusRequest } from "./oauth-request.js";
12
- import { checkConsoleBrowserLogin } from "./console-login-check.js";
13
- import { type FileCredentialStore, assertCredentialDestination, assertStoredIdentity, credentialIdentityBinding, type CredentialDestination, oauthSessionToken, sessionBinding, type SessionConfiguration, type StoredCredentials as StoredCreds } from "./oauth-session.js";
12
+ import { type FileCredentialStore, sessionCredential, assertCredentialDestination, assertStoredIdentity, credentialIdentityBinding, type CredentialDestination, oauthSessionToken, sessionBinding, type SessionConfiguration, type StoredCredentials as StoredCreds } from "./oauth-session.js";
14
13
  import { createCredentialStore } from "./credential-storage.js";
15
14
  import { identityPolicyOf, identityFetch, identityResult, verifyApiIdentity, verifyClientIdentity, readApiIdentity, assertApiIdentity, type ApiIdentity, type IdentityConfiguration, type IdentityPolicy, type VerifiedIdentity } from "./api-identity.js";
16
- import { parseNamedCredentials, readNamedCredentialsFile, resolveNamedCredentials, namedCredentialAvailability, type NamedCredentials, type CredentialSchemes } from "./named-credentials.js";
15
+ import { parseNamedCredentials, readNamedCredentialsFile, resolveNamedCredentials, namedCredentialAvailability, missingCredentials, oauthSessionSchemes, parseExtraHeaders, applyExtraHeaders, type NamedCredentials, type CredentialSchemes } from "./named-credentials.js";
17
16
  import { resolveProfile, listProfiles, selectProfile, removeProfile, readProfileConfig, updateProfileConfig, type ProfileContext } from "./auth-profiles.js";
18
17
  import { randomBytes } from "node:crypto";
19
18
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
20
19
  import { homedir, hostname } from "node:os";
21
- import { basename, dirname, join } from "node:path";
20
+ import { basename, dirname, join, resolve as resolvePath } from "node:path";
22
21
  import { fileURLToPath } from "node:url";
23
22
  import { TypeshipClient, formatDebugEvent, type ClientOptions, type DebugEvent } from "./index.js";
24
- import { asApiResult, validateAgainstSchema, ValidationError, type Violation } from "./core/http.js";
23
+ import { asApiResult, mediaTypeForPath, validateAgainstSchema, ValidationError, type Violation } from "./core/http.js";
25
24
  import { SCHEMAS, DEFS } from "./schemas.js";
26
- 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";
27
26
  import {
28
- MCP_CLIENTS, agentGuide, agentBlock, agentInstructionsFile, agentMode, bundleProperty, claimProperty, classifyApiError, collectionProperty, detectHarness, envelope,
29
- exitCodeFor, findMcpClient, installSkills, mcpConfigured, pendingClaims, recordClaim, summarizeDoctor, upsertAgentBlock, writeBundle, writeMcpConfig,
27
+ MCP_CLIENTS, requiredScopes, agentGuide, agentBlock, agentInstructionsFile, agentMode, bundleProperty, claimProperty, classifyApiError, classifyAuthFailure, collectionProperty, detectHarness, envelope,
28
+ exitCodeFor, findMcpClient, formatRequestPreview, installSkills, mcpConfigured, pendingClaims, recordClaim, requestPreview, summarizeDoctor, upsertAgentBlock, writeBundle, writeMcpConfig,
30
29
  type AgentContext, type CommandSummary, type DoctorCheck, type EnvelopeInput, type IssueCode, type McpEntry, type McpWriteResult,
31
30
  } from "./cli-agent.js";
32
31
  import { relativeDate } from "./dates.js";
32
+ import { checkValue, type ArgumentIssue } from "./arguments.js";
33
33
  import { docsReadCommand, docsReadTarget, fetchDocsText, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
34
+ import { projectFields, unmatchedFields, unmatchedFieldsMessage } from "./fields.js";
35
+ import { renderTable } from "./table.js";
36
+ import { SEARCH_PAGE_SIZE, rankOperations } from "./search.js";
37
+ import { argumentPathText, findInputType, inputTypeText, namedTypesIn } from "./type-docs.js";
34
38
 
35
39
 
36
40
  const BIN = "typeship";
41
+ /** The MCP server's key in client configs: the API's name, even when this
42
+ * command was renamed away from a vendor's. It reads this command's login. */
43
+ const MCP_SERVER_KEY = "typeship";
37
44
  const DEFAULT_BASE_URL = "https://typeship.dev/api/v1";
38
45
  const NAMED_SCHEMES: CredentialSchemes = {"apiKey":{"kind":"bearer","options":["bearerToken"]}};
39
46
  const AUTH_SCALARS: { option: string; flag: string; env: string }[] = [{"option":"bearerToken","flag":"token","env":"TYPESHIP_TOKEN"}];
47
+ /** Hosted MCP request headers as name → env reference, never a literal. */
48
+ const HOSTED_MCP_HEADERS: Record<string, string> = {"Authorization":"Bearer ${TYPESHIP_TOKEN}"};
49
+ const HOSTED_MCP_NOTE: string | null = null;
40
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[] = [];
53
+ /** The spec declares no security, so the token is offered, never required. */
54
+ const AUTH_UNDECLARED = false;
55
+ /** Repeated --header "Name: value" flags for this invocation. */
56
+ let HEADER_FLAGS: string[] = [];
41
57
  /** Operations omitted from the generated package by its plan cap. */
42
58
  const EXCLUDED_OPS = 0;
43
59
  /** Generated CLI operations that are intentionally unavailable to MCP. */
44
60
  const MCP_EXCLUDED_OPS = 0;
45
- const VERSION = "0.22.0";
61
+ const VERSION = "0.24.0";
46
62
  const API_VERSION = "1.0.0";
47
63
  const SPEC_FORMAT = "openapi";
48
64
  const IDENTITY_POLICY: IdentityPolicy = {};
@@ -51,10 +67,13 @@ const WHOAMI: { resource: string; method: string } | null = null;
51
67
  const ENVIRONMENTS: Record<string, string> = {};
52
68
  const HAS_MCP = false;
53
69
  const PKG_NAME = "@typeship-ax/cli";
70
+ /** False when the package name was derived rather than chosen: the npm
71
+ * package of that name may be someone else's, so upgrade never installs it. */
72
+ const PKG_CONFIRMED = true;
54
73
  const UPDATE_NOTICE = false;
55
74
  const API_DESCRIPTION: string | null = "Resolve an OpenAPI or GraphQL Spec, diagnose it, and keep every\nselected CLI, MCP, and SDK Target current.\n\nEvery operation but one requires a bearer credential: an organization\nAPI key from the console, or an OAuth access token carrying the operation's\nread, generate, or write capability and the organization selected during\nconsent. OAuth grants cannot switch organizations after consent. A browser\nsession is not a credential for this API. The exception is POST /generate,\nwhich works anonymously with the free plan's limits.\n\nExamples use Parcel, a fictional delivery service. Replace its domains,\nrepository names, and resource identifiers with your own. The hosted\npetstore Spec is a runnable sample.\n";
56
- const DOCS_URL_DEFAULT: string | null = "https://typeship.dev";
57
- const DOCS_INDEX_URL_DEFAULT: string | null = null;
75
+ const DOCS_URL_DEFAULT: string | null = "https://typeship.dev/docs";
76
+ const DOCS_INDEX_URL_DEFAULT: string | null = "https://typeship.dev/llms.txt";
58
77
  const RELAY: { mintUrl: string; project: string } | null = null;
59
78
  const SUPPORT_URL: string | null = null;
60
79
  const OAUTH_TOKEN_URL: string | null = null;
@@ -64,6 +83,9 @@ const OAUTH_DISCOVERY_URLS: string[] = [];
64
83
  const OAUTH_DEVICE_URL: string | null = null;
65
84
  const HAS_OAUTH_LOGIN = OAUTH_TOKEN_URL !== null || OAUTH_DISCOVERY_URLS.length > 0;
66
85
  const OAUTH_LOGIN_METHOD: string = "device";
86
+ /** The loopback port browser login listens on unless the redirect URI names
87
+ * one: fixed per CLI, so it can be registered with the provider. */
88
+ const OAUTH_DEFAULT_REDIRECT_PORT = 49910;
67
89
  const OAUTH_REDIRECT_URI: string | undefined = undefined;
68
90
  const OAUTH_ORGANIZATION_PARAMETER: "organization" | "organization_id" | undefined = undefined;
69
91
  const OAUTH_AUTHORIZATION_URL: string | undefined = undefined;
@@ -74,7 +96,7 @@ const MCP_URL: string | null = "https://typeship.dev/mcp";
74
96
  const SKILLS_REPO: string | null = "typeship-ax/skills";
75
97
  const CLI_AUTH_URL: string | null = "https://typeship.dev/api/auth/cli";
76
98
  const ENV_PREFIX = "TYPESHIP";
77
- const API_TITLE = "typeship";
99
+ const API_TITLE = "Typeship";
78
100
 
79
101
  interface Parsed {
80
102
  positionals: string[];
@@ -91,7 +113,7 @@ interface Parsed {
91
113
  * API parameter with the same name (`accounts list --cursor <c>`) still
92
114
  * takes its value. Boolean API parameters are recognized once the command
93
115
  * is known. */
94
- 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"]);
95
117
  const BUILTIN_BOOLEAN_FLAGS: Record<string, string[]> = {
96
118
  login: ["with-token", "no-browser", "device"],
97
119
  logout: ["local"],
@@ -99,7 +121,7 @@ const BUILTIN_BOOLEAN_FLAGS: Record<string, string[]> = {
99
121
  mcp: ["claude", "cursor", "claude-desktop", "codex", "vscode", "windsurf", "gemini", "opencode", "zed", "all", "read-only"],
100
122
  docs: ["web", "schema"],
101
123
  init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md", "no-browser"],
102
- auth: ["live"],
124
+ auth: ["live", "offline"],
103
125
  doctor: [],
104
126
  };
105
127
 
@@ -222,25 +244,64 @@ function out(value: unknown): void {
222
244
  /** --fields a,b.c: the dotted paths to keep in API results (null = everything). Set in main(). */
223
245
  let FIELDS: string[][] | null = null;
224
246
 
225
- /** Keep only FIELDS of a result: arrays item by item, objects by dotted path; scalars untouched. */
226
- function project(value: unknown, paths: string[][] | null = FIELDS): unknown {
227
- if (paths === null) return value;
228
- if (Array.isArray(value)) return value.map((item) => project(item, paths));
229
- if (value === null || typeof value !== "object") return value;
230
- const groups = new Map<string, string[][]>();
231
- for (const [key, ...rest] of paths) {
232
- if (key === undefined) continue;
233
- const group = groups.get(key);
234
- if (group) group.push(rest); else groups.set(key, [rest]);
235
- }
236
- const out: Record<string, unknown> = {};
237
- for (const [key, rests] of groups) {
238
- const child = (value as Record<string, unknown>)[key];
239
- if (child === undefined) continue;
240
- if (rests.some((rest) => rest.length === 0)) out[key] = child;
241
- else if (child !== null && typeof child === "object") out[key] = project(child, rests);
242
- }
243
- return out;
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
+
256
+ /** Whether the command being run writes: an --fields mistake on a write
257
+ * must not tempt anyone into running it again. Set in main(). */
258
+ let FIELDS_AFTER_WRITE = false;
259
+
260
+ /** Keep only FIELDS of a result: arrays item by item, objects by dotted path;
261
+ * scalars untouched. A path that matches nothing is an error naming the keys
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 {
265
+ if (FIELDS === null) return value;
266
+ const unmatched = unmatchedFields(value, FIELDS, schema);
267
+ if (unmatched.length > 0) failUnmatchedFields(unmatched, perItem, value);
268
+ return projectFields(value, FIELDS);
269
+ }
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
+
276
+ /** --fields over a stream (--all, events): paths no item has matched yet.
277
+ * The stream is printed as it arrives, so the check fails at its end. */
278
+ function streamFieldsCheck(schema: unknown): { item(value: unknown): unknown; finish(): void } {
279
+ let pending: ReturnType<typeof unmatchedFields> | null = null;
280
+ return {
281
+ item(value: unknown): unknown {
282
+ if (FIELDS === null) return value;
283
+ const unmatched = unmatchedFields(value, FIELDS, schema);
284
+ pending = pending === null ? unmatched : pending.filter((p) => unmatched.some((u) => u.path === p.path));
285
+ return projectFields(value, FIELDS);
286
+ },
287
+ finish(): void {
288
+ if (pending !== null && pending.length > 0) failUnmatchedFields(pending, true, undefined);
289
+ },
290
+ };
291
+ }
292
+
293
+ function failUnmatchedFields(unmatched: ReturnType<typeof unmatchedFields>, perItem: boolean, result: unknown): never {
294
+ return failWith({
295
+ code: "FIELDS_UNMATCHED",
296
+ message: unmatchedFieldsMessage(unmatched, perItem),
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 } : {}) },
304
+ });
244
305
  }
245
306
 
246
307
  /** Thrown after scheduling exit so sync callers stop; main() swallows it. */
@@ -323,7 +384,7 @@ let USAGE_HINT = BIN + " --help";
323
384
  function fail(code: number, message: string, extra?: unknown, nextSteps?: string[]): never {
324
385
  const usageCode: IssueCode = /^Unknown command/.test(message) ? "UNKNOWN_COMMAND"
325
386
  : /^Unknown flag/.test(message) ? "UNKNOWN_FLAG"
326
- : /^(Missing required|Expected \d+ argument)/.test(message) ? "MISSING_ARGUMENT"
387
+ : /^(Missing required|Expected \d+ (or \d+ )?argument)/.test(message) ? "MISSING_ARGUMENT"
327
388
  : "INVALID_USAGE";
328
389
  return failWith({
329
390
  code: code === 2 ? usageCode : "CALL_FAILED",
@@ -334,8 +395,26 @@ function fail(code: number, message: string, extra?: unknown, nextSteps?: string
334
395
  }
335
396
 
336
397
  /** An SDK error result as an envelope: status-derived code, the API's body as detail, concrete next steps. */
398
+ /** OAuth scopes of the operation being run, so a 403 can name them. */
399
+ let CURRENT_SCOPES: string[] = [];
337
400
  function failApi(error: unknown, hadCredential: boolean): never {
338
- return failWith(classifyApiError(error, { bin: BIN, hadCredential, docsUrl: DOCS_URL_DEFAULT }));
401
+ return failWith(classifyAuthFailure(error, authFailureContext()) ?? classifyApiError(error, { bin: BIN, envPrefix: ENV_PREFIX, hadCredential, docsUrl: DOCS_URL_DEFAULT, requiredScopes: CURRENT_SCOPES, canLogin: HAS_OAUTH_LOGIN }));
402
+ }
403
+
404
+ /** The SDK raises NotModifiedError for a 304: a conditional request
405
+ * matched. For the CLI that is a result, not a failure. */
406
+ function isNotModified(error: unknown): error is { etag?: string } {
407
+ return (error as { name?: string } | null)?.name === "NotModifiedError";
408
+ }
409
+
410
+ function printNotModified(error: { etag?: string }): Promise<never> {
411
+ out({ ok: true, not_modified: true, ...(error.etag ? { etag: error.etag } : {}) });
412
+ return flushExit(0);
413
+ }
414
+
415
+ /** What a login, saved-session or credential-store failure names as alternatives. */
416
+ function authFailureContext(): { bin: string; envVars: string[]; storeVariable: string } {
417
+ return { bin: BIN, storeVariable: ENV_PREFIX + "_CREDENTIAL_STORE", envVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []), ...(Object.keys(NAMED_SCHEMES).length ? [ENV_PREFIX + "_CREDENTIALS"] : [])] };
339
418
  }
340
419
 
341
420
  // ---------------------------------------------------------------------------
@@ -353,7 +432,7 @@ function credsPath(): string {
353
432
  return credentialStore().path;
354
433
  }
355
434
 
356
- function credentialStore(): FileCredentialStore { return createCredentialStore(configDir(), process.env["TYPESHIP_CREDENTIAL_STORE"], "TYPESHIP_CREDENTIAL_STORE"); }
435
+ function credentialStore(): FileCredentialStore { return createCredentialStore(configDir(), BIN, process.env["TYPESHIP_CREDENTIAL_STORE"], "TYPESHIP_CREDENTIAL_STORE"); }
357
436
  function readCreds(): StoredCreds | null { return credentialStore().read(); }
358
437
  function sessionConfiguration(baseUrl: string, config = readConfig()): SessionConfiguration {
359
438
  return {
@@ -492,9 +571,10 @@ async function deviceLogin(clientId: string, parsed: Parsed): Promise<void> {
492
571
  if (!apiBaseUrl) fail(2, "Set the API base URL before logging in.");
493
572
  const loginConfiguration = sessionConfiguration(apiBaseUrl!);
494
573
  await credentialStore().prepare();
574
+ if (!OAUTH_DEVICE_URL && !OAUTH_ISSUER && !OAUTH_DISCOVERY_URL) fail(2, "This API does not declare device authorization. Run '" + BIN + " login' without --device to sign in through the browser.");
495
575
  const session = await withLoginCancellation((signal) => oauthDeviceLogin({
496
576
  clientId, issuer: OAUTH_ISSUER, discoveryUrls: OAUTH_DISCOVERY_URLS,
497
- deviceUrl: OAUTH_DEVICE_URL, tokenUrl: OAUTH_TOKEN_URL, scopes: OAUTH_SCOPES,
577
+ deviceUrl: OAUTH_DEVICE_URL, tokenUrl: OAUTH_TOKEN_URL, scopes: loginScopes(parsed.flags),
498
578
  audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
499
579
  }, { signal, authorize({ verificationUri, userCode, expiresIn }) {
500
580
  process.stderr.write("Open " + paintErr("cyan", verificationUri) + " and enter code: " + paintErr("bold", userCode) + "\n");
@@ -509,11 +589,40 @@ async function deviceLogin(clientId: string, parsed: Parsed): Promise<void> {
509
589
  await flushExit(0);
510
590
  }
511
591
 
512
- async function storePastedToken(token: string, flags: Map<string, string | boolean>): Promise<void> {
513
- const first = AUTH_SCALARS[0];
514
- if (!first) fail(2, "This API declares no credential the CLI can store. Use --username/--password if it uses basic auth.");
515
- await saveLoginCredentials({ scalars: { [first!.option]: token } }, flags);
516
- out({ ok: true, method: "paste", stored_as: first!.flag, credentials: credsPath(), ...loginIdentityReport() });
592
+ type LoginValue = string | { username: string; password: string };
593
+ /** What --with-token, the prompt and browser approval store. */
594
+ interface LoginTarget { label: string; basic: boolean; storedAs: string; save(value: LoginValue): StoredCreds }
595
+
596
+ /** The scheme named by --scheme, else the convenience credential that the
597
+ * most operations accept on its own (declared order breaks ties), else null. */
598
+ function loginTarget(flags: Map<string, string | boolean>): LoginTarget | null {
599
+ const requested = flags.get("scheme");
600
+ if (requested !== undefined) {
601
+ if (typeof requested !== "string" || !Object.hasOwn(NAMED_SCHEMES, requested)) fail(2, "--scheme expects one of this API's security schemes: " + (Object.keys(NAMED_SCHEMES).join(", ") || "none") + ".");
602
+ const name = requested as string;
603
+ return { label: name, basic: NAMED_SCHEMES[name]!.kind === "basic", storedAs: name, save(value) {
604
+ try { return { named: parseNamedCredentials({ [name]: value }, NAMED_SCHEMES) }; } catch (error) { fail(2, (error as Error).message); }
605
+ } };
606
+ }
607
+ const candidates = [...AUTH_SCALARS.map((a) => a.option), ...(BASIC ? ["basicAuth"] : [])];
608
+ if (!candidates.length) return null;
609
+ const uses = (option: string) => OPS.filter((op) => op.credentialOptions?.some((alternative) => alternative.length === 1 && alternative[0] === option)).length;
610
+ const option = candidates.reduce((best, candidate) => uses(candidate) > uses(best) ? candidate : best);
611
+ if (option === "basicAuth") return { label: "username and password", basic: true, storedAs: "basic", save: (value) => ({ basic: value as { username: string; password: string } }) };
612
+ const scalar = AUTH_SCALARS.find((a) => a.option === option)!;
613
+ return { label: scalar.flag.replace(/-/g, " "), basic: false, storedAs: scalar.flag, save: (value) => ({ scalars: { [scalar.option]: value as string } }) };
614
+ }
615
+
616
+ /** Basic credentials on stdin are one line: username:password. */
617
+ function basicFromText(text: string): { username: string; password: string } {
618
+ const separator = text.indexOf(":");
619
+ if (separator <= 0 || separator === text.length - 1) fail(2, "--with-token expects username:password on stdin for Basic auth.");
620
+ return { username: text.slice(0, separator), password: text.slice(separator + 1) };
621
+ }
622
+
623
+ async function storePastedToken(value: LoginValue, target: LoginTarget, flags: Map<string, string | boolean>): Promise<void> {
624
+ await saveLoginCredentials(target.save(value), flags);
625
+ out({ ok: true, method: "paste", stored_as: target.storedAs, credentials: credsPath(), ...loginIdentityReport() });
517
626
  await flushExit(0);
518
627
  }
519
628
 
@@ -547,10 +656,9 @@ async function browserApprove(headless: boolean, flags: Map<string, string | boo
547
656
  }
548
657
 
549
658
  /** Store what the browser approval minted, marked as this CLI's own. */
550
- async function storeMinted(minted: ApprovedCredential, flags: Map<string, string | boolean>): Promise<void> {
551
- const first = AUTH_SCALARS[0]!;
659
+ async function storeMinted(minted: ApprovedCredential, target: LoginTarget, flags: Map<string, string | boolean>): Promise<void> {
552
660
  await saveLoginCredentials({
553
- scalars: { [first.option]: minted.api_key },
661
+ ...target.save(minted.api_key),
554
662
  minted: { via: "browser", key_name: minted.key_name, revocationUrl: minted.revocationUrl, ...(minted.org_id ? { org_id: minted.org_id } : {}) },
555
663
  }, flags, undefined, async () => {
556
664
  try {
@@ -567,16 +675,37 @@ async function storeMinted(minted: ApprovedCredential, flags: Map<string, string
567
675
  });
568
676
  }
569
677
 
570
- async function browserLogin(headless: boolean, flags: Map<string, string | boolean>): Promise<void> {
678
+ async function browserLogin(headless: boolean, target: LoginTarget, flags: Map<string, string | boolean>): Promise<void> {
571
679
  await credentialStore().prepare();
572
680
  const minted = await browserApprove(headless, flags);
573
- await storeMinted(minted, flags);
574
- out({ ok: true, method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), credentials: credsPath(), ...loginIdentityReport() });
681
+ await storeMinted(minted, target, flags);
682
+ out({ ok: true, method: "browser", stored_as: target.storedAs, key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), credentials: credsPath(), ...loginIdentityReport() });
575
683
  await flushExit(0);
576
684
  }
577
685
 
686
+ /** The callback URL browser login uses: the configured redirect URI, else
687
+ * http://127.0.0.1:<port>/callback. --redirect-port or
688
+ * TYPESHIP_OAUTH_REDIRECT_PORT picks another port. */
689
+ function oauthRedirectUri(flags: Map<string, string | boolean>): string {
690
+ const requested = typeof flags.get("redirect-port") === "string" ? flags.get("redirect-port") as string : process.env["TYPESHIP_OAUTH_REDIRECT_PORT"];
691
+ const redirect = new URL(OAUTH_REDIRECT_URI ?? "http://127.0.0.1:" + OAUTH_DEFAULT_REDIRECT_PORT + "/callback");
692
+ if (requested !== undefined) {
693
+ if (!/^[1-9][0-9]{0,4}$/.test(requested) || Number(requested) > 65535) fail(2, "--redirect-port expects a port number from 1 to 65535.");
694
+ redirect.port = requested;
695
+ }
696
+ return redirect.href;
697
+ }
698
+
699
+ /** --scopes a,b narrows (or widens) what this login asks for. */
700
+ function loginScopes(flags: Map<string, string | boolean>): string[] {
701
+ const requested = flags.get("scopes");
702
+ if (requested === undefined) return OAUTH_SCOPES;
703
+ if (typeof requested !== "string" || !requested.trim()) fail(2, "--scopes expects a comma- or space-separated list of scopes.");
704
+ return [...new Set((requested as string).split(/[\s,]+/).filter(Boolean))];
705
+ }
706
+
578
707
  async function acquireOAuthBrowserSession(parsed: Parsed, clientId: string): Promise<void> {
579
- if (!OAUTH_ISSUER) fail(2, "Browser OAuth requires the exact auth.oauth_issuer configured by the API owner.");
708
+ if (!OAUTH_ISSUER && !OAUTH_AUTHORIZATION_URL) fail(2, "Browser OAuth needs an authorization URL: the API Spec's authorizationCode flow, or auth.oauth_server.issuer configured by the API owner.");
580
709
  const apiBaseUrl = resolveBaseUrl(parsed.flags);
581
710
  if (!apiBaseUrl) fail(2, "Set the API base URL before logging in.");
582
711
  const loginConfiguration = sessionConfiguration(apiBaseUrl!);
@@ -588,7 +717,7 @@ async function acquireOAuthBrowserSession(parsed: Parsed, clientId: string): Pro
588
717
  } }, parsed.flags, loginConfiguration);
589
718
  }
590
719
 
591
- /** Normal login and Console verification use the same native exchange. */
720
+ /** The native browser exchange. */
592
721
  async function startOAuthBrowserSession(parsed: Parsed, clientId: string, timeoutMs?: number): Promise<OAuthLoginSession> {
593
722
  const controller = new AbortController();
594
723
  const cancel = () => controller.abort();
@@ -596,9 +725,9 @@ async function startOAuthBrowserSession(parsed: Parsed, clientId: string, timeou
596
725
  process.once("SIGTERM", cancel);
597
726
  try {
598
727
  return await oauthBrowserLogin({
599
- issuer: OAUTH_ISSUER!, clientId, discoveryUrl: OAUTH_DISCOVERY_URL,
728
+ issuer: OAUTH_ISSUER, clientId, discoveryUrl: OAUTH_DISCOVERY_URL,
600
729
  authorizationUrl: OAUTH_AUTHORIZATION_URL, tokenUrl: OAUTH_TOKEN_URL ?? undefined,
601
- redirectUri: OAUTH_REDIRECT_URI, scopes: OAUTH_SCOPES,
730
+ redirectUri: oauthRedirectUri(parsed.flags), scopes: loginScopes(parsed.flags),
602
731
  audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
603
732
  organization: requestedLoginOrganization(parsed.flags),
604
733
  }, { signal: controller.signal, timeoutMs, authorize(url) {
@@ -609,56 +738,23 @@ async function startOAuthBrowserSession(parsed: Parsed, clientId: string, timeou
609
738
  } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
610
739
  }
611
740
 
612
- async function cmdConsoleLoginCheck(parsed: Parsed): Promise<void> {
613
- const file = parsed.flags.get("console-check");
614
- if (typeof file !== "string" || !file || parsed.flags.get("device") === true || parsed.flags.get("with-token") === true || explicitNonInteractive(parsed)) fail(2, "--console-check requires a downloaded JSON file and browser login. Use --no-browser to print the sign-in link.");
615
- const baseUrl = resolveBaseUrl(parsed.flags);
616
- const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") as string : undefined) ?? process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID;
617
- const op = WHOAMI && OPS.find((value) => value.resource === WHOAMI.resource && value.method === WHOAMI.method);
618
- if (!baseUrl || !OAUTH_ISSUER || !clientId || OAUTH_LOGIN_METHOD !== "browser" || !op || op.auth !== "required" || !op.security?.length) fail(2, "Configure browser OAuth and a required-authentication identity read, then regenerate this CLI.");
619
- const envOptions: Record<string, unknown> = {}, flagOptions: Record<string, unknown> = {};
620
- for (const scalar of AUTH_SCALARS) {
621
- if (process.env[scalar.env] !== undefined) envOptions[scalar.option] = process.env[scalar.env];
622
- if (typeof parsed.flags.get(scalar.flag) === "string") flagOptions[scalar.option] = parsed.flags.get(scalar.flag);
623
- }
624
- if (BASIC && process.env[BASIC.envUser] && process.env[BASIC.envPass]) envOptions.basicAuth = { username: process.env[BASIC.envUser], password: process.env[BASIC.envPass] };
625
- if (BASIC && typeof parsed.flags.get("username") === "string" && typeof parsed.flags.get("password") === "string") flagOptions.basicAuth = { username: parsed.flags.get("username"), password: parsed.flags.get("password") };
626
- const environment = (typeof parsed.flags.get("environment") === "string" ? parsed.flags.get("environment") as string : Object.entries(ENVIRONMENTS).find(([, url]) => new URL(url).href === new URL(baseUrl!).href)?.[0]) ?? null;
627
- const result = await checkConsoleBrowserLogin({
628
- file: file as string, configuration: {
629
- baseUrl: baseUrl!, environment, operation: op!.resource + "." + op!.method, requirements: op!.security!,
630
- request: { method: op!.graphql ? "POST" : op!.httpMethod, path: op!.graphql ? "" : op!.path, graphqlField: op!.graphql?.field ?? null, graphqlQuery: op!.graphql ? op!.graphql.docPrefix + op!.graphql.defaultSelection + " }" : null },
631
- issuer: OAUTH_ISSUER!, clientId: clientId!, discoveryUrl: OAUTH_DISCOVERY_URL ?? null,
632
- authorizationUrl: OAUTH_AUTHORIZATION_URL ?? null, tokenUrl: OAUTH_TOKEN_URL,
633
- redirectUri: OAUTH_REDIRECT_URI ?? "http://127.0.0.1/callback", scopes: OAUTH_SCOPES,
634
- audience: OAUTH_TOKEN_PARAMS.audience ?? null, resource: OAUTH_TOKEN_PARAMS.resource ?? null,
635
- }, schemes: NAMED_SCHEMES,
636
- credentials: resolveNamedCredentials(NAMED_SCHEMES, [{ options: envOptions, named: environmentCredentials() }, { options: flagOptions, named: flagCredentials(parsed.flags) }]),
637
- login: (timeoutMs) => startOAuthBrowserSession(parsed, clientId!, timeoutMs),
638
- async verify(credentials, expectations) {
639
- const kind = (value: "user" | "account" | "organization") => value === "user" ? "subject" : value;
640
- const policy = Object.fromEntries(expectations.map((entry) => [kind(entry.kind), entry.pointer])) as IdentityPolicy;
641
- const expected = Object.fromEntries(expectations.map((entry) => [kind(entry.kind), String(entry.expected)])) as ApiIdentity;
642
- await verifyClientIdentity((options) => new TypeshipClient(options as unknown as ClientOptions), { baseUrl: baseUrl!, credentials }, op!, policy, expected);
643
- },
644
- progress: (message) => process.stderr.write(message + "\n"),
645
- });
646
- out(result);
647
- await flushExit(0);
648
- }
649
741
 
650
742
  async function cmdLogin(parsed: Parsed): Promise<void> {
651
743
  if (parsed.help) {
744
+ const target = loginTarget(new Map());
652
745
  const lines = [
653
746
  BIN + " login — store credentials at " + credsPath(),
654
747
  "",
655
748
  ...AUTH_SCALARS.map((a) => " " + BIN + " login --" + a.flag + " <value>"),
656
- ...(CLI_AUTH_URL ? [" " + BIN + " login approve in the browser: a key is minted for you (add --no-browser to print the link instead of opening it)"] : []),
657
- " " + BIN + " login --with-token read the credential from stdin (CI)",
658
- ...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --client-id <id> OAuth " + OAUTH_LOGIN_METHOD + " login" + (OAUTH_CLIENT_ID ? " (a default id is built in)" : "")] : []),
659
- ...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --no-browser print the approval URL instead of opening a browser", " " + BIN + " login --device use device authorization when your provider supports it"] : []),
749
+ ...(CLI_AUTH_URL && target && !target.basic ? [" " + BIN + " login approve in the browser: a key is minted for you (add --no-browser to print the link instead of opening it)"] : []),
750
+ ...(target ? [" " + BIN + " login --with-token read the " + target.label + " from stdin" + (target.basic ? " as one username:password line" : "") + " (CI)"] : []),
751
+ ...(Object.keys(NAMED_SCHEMES).length > 1 || (!target && Object.keys(NAMED_SCHEMES).length) ? [" " + BIN + " login --scheme <name> choose the scheme --with-token, the prompt" + (CLI_AUTH_URL ? " and browser approval" : "") + " store" + (target ? " (default: " + target.storedAs + ")" : "") + "; a Basic scheme reads username:password"] : []),
752
+ ...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --client-id <id> OAuth " + (OAUTH_LOGIN_METHOD === "browser" ? "browser (authorization code + PKCE)" : "device") + " login" + (OAUTH_CLIENT_ID ? " (a default id is built in)" : "; register an application with the provider for its client ID, or set " + ENV_PREFIX + "_CLIENT_ID")] : []),
753
+ ...(HAS_OAUTH_LOGIN && OAUTH_LOGIN_METHOD === "browser" ? [" " + BIN + " login --no-browser print the sign-in URL instead of opening a browser", " " + BIN + " login --redirect-port <n> listen on another loopback port (default callback " + oauthRedirectUri(new Map()) + "; register it with the provider)"] : []),
754
+ ...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --scopes <a,b> request these scopes instead of " + (OAUTH_SCOPES.length ? OAUTH_SCOPES.join(" ") : "the provider's defaults")] : []),
755
+ ...(HAS_OAUTH_LOGIN && (OAUTH_DEVICE_URL || OAUTH_ISSUER || OAUTH_DISCOVERY_URL) ? [" " + BIN + " login --device use device authorization when your provider supports it"] : []),
660
756
  ...(BASIC ? [" " + BIN + " login --username <u> --password <p>"] : []),
661
- ...(CLI_AUTH_URL || HAS_OAUTH_LOGIN ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
757
+ ...(CLI_AUTH_URL || HAS_OAUTH_LOGIN || !target ? [] : [" " + BIN + " login interactive prompt" + (target.basic ? " for username and hidden password" : "") + " (TTY only)"]),
662
758
  "",
663
759
  "Precedence per scheme: flags > env vars > stored credentials. Named inputs beat convenience flags within the same source.",
664
760
  " " + BIN + " login --credentials @<JSON-file> store named credentials (use - for stdin)",
@@ -674,9 +770,8 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
674
770
  }
675
771
  if (parsed.flags.has("login-organization")) {
676
772
  expectedLoginIdentity(parsed.flags);
677
- if (!HAS_OAUTH_LOGIN || OAUTH_LOGIN_METHOD !== "browser" || parsed.flags.has("device") || parsed.flags.has("console-check") || parsed.flags.has("with-token") || explicitNonInteractive(parsed)) fail(2, "--login-organization is available only for interactive OAuth browser login, including --no-browser.");
773
+ if (!HAS_OAUTH_LOGIN || OAUTH_LOGIN_METHOD !== "browser" || parsed.flags.has("device") || parsed.flags.has("with-token") || explicitNonInteractive(parsed)) fail(2, "--login-organization is available only for interactive OAuth browser login, including --no-browser.");
678
774
  }
679
- if (parsed.flags.has("console-check")) { await cmdConsoleLoginCheck(parsed); return; }
680
775
  const named = flagCredentials(parsed.flags);
681
776
  const scalarValues: Record<string, string> = {};
682
777
  for (const a of AUTH_SCALARS) {
@@ -698,9 +793,11 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
698
793
  }
699
794
 
700
795
  if (parsed.flags.get("with-token") === true) {
796
+ const target = loginTarget(parsed.flags);
797
+ if (!target) fail(2, "This API declares no credential the CLI can store. See '" + BIN + " login --help'.");
701
798
  const token = (await readStdin()).trim();
702
799
  if (!token) fail(2, "--with-token expects the credential on stdin.");
703
- await storePastedToken(token, parsed.flags);
800
+ await storePastedToken(target!.basic ? basicFromText(token) : token, target!, parsed.flags);
704
801
  }
705
802
 
706
803
  const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") as string : undefined)
@@ -717,8 +814,9 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
717
814
  // Browser approval: the API mints a key for this CLI once a person
718
815
  // approves in the browser. Works under an agent too (it prints the URL and
719
816
  // polls); only the explicit non-interactive switch turns it off.
720
- if (CLI_AUTH_URL && AUTH_SCALARS[0] && !explicitNonInteractive(parsed)) {
721
- await browserLogin(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
817
+ const target = loginTarget(parsed.flags);
818
+ if (CLI_AUTH_URL && target && !target.basic && !explicitNonInteractive(parsed)) {
819
+ await browserLogin(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, target, parsed.flags);
722
820
  }
723
821
 
724
822
  if (nonInteractive(parsed) || !process.stdin.isTTY) {
@@ -728,17 +826,24 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
728
826
  message: "login needs a terminal to prompt, and there is none.",
729
827
  nextSteps: [
730
828
  ...AUTH_SCALARS.map((a) => "Pass the credential: '" + BIN + " login --" + a.flag + " <value>', or set " + a.env + " in the environment."),
731
- "Pipe it: echo \"$TOKEN\" | " + BIN + " login --with-token",
829
+ ...(BASIC ? ["Pass Basic credentials: '" + BIN + " login --username <u> --password <p>', or set " + BASIC.envUser + " and " + BASIC.envPass + " in the environment."] : []),
830
+ ...(target ? ["Pipe it: echo \"" + (target.basic ? "$USERNAME:$PASSWORD" : "$TOKEN") + "\" | " + BIN + " login --with-token" + (parsed.flags.has("scheme") ? " --scheme " + target.storedAs : "")] : []),
732
831
  ...(HAS_OAUTH_LOGIN ? ["OAuth login (or --device when supported by your provider): '" + BIN + " login --client-id <id>' (opens or prints the provider sign-in URL)."] : []),
733
832
  ...(CLI_AUTH_URL ? ["Browser approval: '" + BIN + " login --no-browser' prints a link for the user to approve and waits."] : []),
734
833
  ],
735
834
  });
736
835
  }
737
- const first = AUTH_SCALARS[0];
738
- if (!first) fail(2, "This API declares no credential the CLI can prompt for. See '" + BIN + " login --help'.");
739
- const token = (await promptHidden("Paste " + first.flag.replace(/-/g, " ") + " (input hidden): ")).trim();
836
+ if (!target) fail(2, "This API declares no credential the CLI can prompt for. See '" + BIN + " login --help'.");
837
+ if (target!.basic) {
838
+ process.stderr.write("Username: ");
839
+ const username = (await readLine()).trim();
840
+ const password = await promptHidden("Password (input hidden): ");
841
+ if (!username || !password) fail(2, "Enter both a username and a password.");
842
+ await storePastedToken({ username, password }, target!, parsed.flags);
843
+ }
844
+ const token = (await promptHidden("Paste " + target!.label + " (input hidden): ")).trim();
740
845
  if (!token) fail(2, "Nothing entered.");
741
- await storePastedToken(token, parsed.flags);
846
+ await storePastedToken(token, target!, parsed.flags);
742
847
  }
743
848
 
744
849
  async function cmdLogout(parsed: Parsed): Promise<void> {
@@ -753,8 +858,8 @@ async function cmdLogout(parsed: Parsed): Promise<void> {
753
858
  // way out, so logging out ends the credential and not just the file. A
754
859
  // pasted or CI key is someone else's to revoke, and is left alone.
755
860
  let revoked: boolean | null = null;
756
- const first = AUTH_SCALARS[0];
757
- const ownKey = first && stored?.minted?.via === "browser" ? stored.scalars?.[first.option] : undefined;
861
+ // The approval stored exactly one credential, as a scalar or a named scheme.
862
+ const ownKey = stored?.minted?.via === "browser" ? [...Object.values(stored.scalars ?? {}), ...Object.values(stored.named ?? {})].find((value): value is string => typeof value === "string") : undefined;
758
863
  if (ownKey) {
759
864
  try {
760
865
  const response = await oauthStatusRequest(loginEndpoint(stored!.minted!.revocationUrl!), { method: "POST", headers: { Authorization: "Bearer " + ownKey } }, 15_000);
@@ -915,12 +1020,12 @@ function mcpEntryFor(url: string | undefined, readOnly = false): { entry: McpEnt
915
1020
  const warnings: string[] = [];
916
1021
  const hosted = url ?? MCP_URL ?? undefined;
917
1022
  if (hosted) {
918
- const envVar = AUTH_SCALARS[0]?.env;
919
1023
  // The hosted endpoint serves its read-only twin at <url>/readonly; a
920
1024
  // remote server of someone else's may not, so say so.
921
1025
  const target = readOnly ? hosted.replace(/\/+$/, "") + "/readonly" : hosted;
922
- if (readOnly && url !== undefined && !MCP_URL) warnings.push("--read-only appended /readonly to the URL, which typeship-hosted endpoints serve; check that this server does too.");
923
- return { entry: { url: target, ...(envVar ? { headers: { Authorization: "Bearer ${" + envVar + "}" } } : {}) }, warnings };
1026
+ if (readOnly && url !== undefined && !MCP_URL) warnings.push("--read-only appended /readonly to the URL; check that this server serves a read-only endpoint there.");
1027
+ if (HOSTED_MCP_NOTE) warnings.push(HOSTED_MCP_NOTE);
1028
+ return { entry: { url: target, ...(Object.keys(HOSTED_MCP_HEADERS).length ? { headers: { ...HOSTED_MCP_HEADERS } } : {}) }, warnings };
924
1029
  }
925
1030
  if (!HAS_MCP) {
926
1031
  fail(2, "This package was generated without the MCP server target. Regenerate with it, or pass --url for a remote endpoint.");
@@ -992,7 +1097,7 @@ async function cmdMcp(parsed: Parsed): Promise<void> {
992
1097
 
993
1098
  if (wanted.size === 0) {
994
1099
  out({
995
- server: BIN,
1100
+ server: MCP_SERVER_KEY,
996
1101
  entry,
997
1102
  ...(warnings.length > 0 ? { warnings } : {}),
998
1103
  detected: MCP_CLIENTS.filter((c) => c.detect(cwd)).map((c) => c.id),
@@ -1003,11 +1108,11 @@ async function cmdMcp(parsed: Parsed): Promise<void> {
1003
1108
  const results: McpWriteResult[] = [];
1004
1109
  for (const id of wanted) {
1005
1110
  const client = findMcpClient(id)!;
1006
- results.push(writeMcpConfig(client, cwd, BIN, entry));
1111
+ results.push(writeMcpConfig(client, cwd, MCP_SERVER_KEY, entry));
1007
1112
  }
1008
1113
  out({
1009
1114
  ok: true,
1010
- server: BIN,
1115
+ server: MCP_SERVER_KEY,
1011
1116
  entry,
1012
1117
  written: results.filter((r) => r.written).map((r) => r.file),
1013
1118
  clients: results,
@@ -1029,10 +1134,11 @@ function agentContext(): AgentContext {
1029
1134
  version: VERSION,
1030
1135
  envPrefix: ENV_PREFIX,
1031
1136
  authEnvVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []), ...(Object.keys(NAMED_SCHEMES).length ? ["TYPESHIP_CREDENTIALS"] : [])],
1137
+ ...(AUTH_UNDECLARED ? { authNotDeclared: true } : {}),
1032
1138
  docsUrl: docsSiteUrl(),
1033
1139
  docsIndexUrl: docsIndexUrl(),
1034
1140
  generatedOperationCount: OPS.length,
1035
- omittedOperations: OMITTED_OPS.map((op) => ({ command: op.command.join(" "), tool: op.tool, method: op.httpMethod, path: op.path })),
1141
+ omittedOperationCount: OMITTED_OPS.length,
1036
1142
  mcpUrl: MCP_URL,
1037
1143
  skillsRepo: SKILLS_REPO,
1038
1144
  hasMcp: HAS_MCP,
@@ -1055,63 +1161,173 @@ function commandSummaries(): CommandSummary[] {
1055
1161
  }
1056
1162
 
1057
1163
  /**
1058
- * help --json: a compact command index. Full schemas live behind the
1059
- * per-operation docs command so discovery does not spend an agent's context
1060
- * 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.
1061
1170
  */
1062
- function helpJson(): Record<string, unknown> {
1063
- const byResource = new Map<string, CommandSummary[]>();
1064
- for (const c of commandSummaries()) {
1065
- const list = byResource.get(c.resource) ?? [];
1066
- list.push(c);
1067
- 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);
1068
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> {
1069
1206
  return {
1070
- schema_version: "2",
1071
- name: BIN,
1072
- version: VERSION,
1073
- api: API_TITLE,
1074
- api_version: API_VERSION,
1075
- 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"),
1076
1226
  usage: BIN + " <resource> <command> [args] [--flags]",
1077
- resources: [...byResource.entries()].map(([resource, commands]) => ({
1227
+ resources: [...byResource.entries()].map(([resource, ops]) => ({
1078
1228
  resource,
1079
- commands: commands.map((c) => {
1080
- const op = OPS.find((o) => o.command[0] === resource && o.command[1] === c.command)!;
1081
- return {
1082
- command: c.command,
1083
- method: c.method,
1084
- path: c.path,
1085
- ...(c.summary ? { summary: c.summary } : {}),
1086
- paginated: c.paginated,
1087
- safety: op.safety,
1088
- destructive: c.destructive,
1089
- auth: c.auth,
1090
- positional: op.params.filter((p) => p.kind === "path").map((p) => p.name),
1091
- flags: c.flags,
1092
- details_command: BIN + " docs " + resource + " " + c.command + " --json",
1093
- };
1094
- }),
1229
+ commands: ops.map((op) => helpEntry(op, summaries[OPS.indexOf(op)]!)),
1095
1230
  })),
1096
- ...(EXCLUDED_OPS > 0 ? {
1097
- coverage: {
1098
- generated_operations: OPS.length,
1099
- total_operations: OPS.length + EXCLUDED_OPS,
1100
- omitted_operations: OMITTED_OPS.map((op) => ({ command: op.command.join(" "), tool: op.tool, method: op.httpMethod, path: op.path })),
1101
- reason: "plan_limit",
1102
- },
1103
- } : {}),
1104
- discovery: {
1105
- search: BIN + " docs search <term> --json",
1106
- operation: BIN + " docs <resource> <command> --json",
1107
- note: "Choose an operation from this index, then read only that operation's complete schemas and example arguments.",
1108
- },
1231
+ ...helpCoverage(),
1232
+ discovery: helpDiscovery(),
1109
1233
  builtins: BUILTIN_COMMANDS,
1110
- global_flags: ["--help", "--version", "--debug", "--non-interactive", "--mode agent|human", "--yes", "--force", "--color on|off|auto", "--credentials @<JSON-file>|-", "--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>")],
1111
1235
  auth_env_vars: agentContext().authEnvVars,
1112
1236
  };
1113
1237
  }
1114
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
+
1115
1331
  async function cmdAgentGuide(parsed: Parsed): Promise<void> {
1116
1332
  if (parsed.help) {
1117
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");
@@ -1139,24 +1355,26 @@ async function cmdAuth(parsed: Parsed): Promise<void> {
1139
1355
  }
1140
1356
  if (parsed.help || sub !== "check") {
1141
1357
  process.stdout.write([
1142
- BIN + " auth check [--live] — report the credential the CLI would use, as JSON: {status: ok|action_required, authenticated, source, ...}",
1358
+ BIN + " auth check [--offline] — report the credential the CLI would use and verify it with the API's identity read, as JSON: {status: ok|action_required, authenticated, verification: verified|unverified|rejected, source, ...}",
1143
1359
  " " + BIN + " auth profiles list profiles without unlocking credentials",
1144
1360
  " " + BIN + " auth use <name> select the default profile",
1145
1361
  " " + BIN + " auth remove <name> remove a profile after logout",
1146
1362
  " --profile <name> overrides TYPESHIP_PROFILE, then the saved selection, then default.",
1147
- " --live also call the API's identity endpoint" + (WHOAMI ? "" : " (none in this API; --live is a no-op)"),
1363
+ " --offline skip the identity read; the credential is then reported as unverified" + (WHOAMI ? "" : " (this API has no identity read, so credentials are always unverified)"),
1148
1364
  "",
1149
1365
  "Precedence: flags > env vars > stored credentials (" + credsPath() + ").",
1150
1366
  ].join("\n") + "\n");
1151
1367
  await flushExit(parsed.help ? 0 : 2);
1152
1368
  }
1153
1369
  const source = credentialSource(parsed.flags) ?? "none";
1154
- const authenticated = source !== "none";
1370
+ const present = source !== "none";
1155
1371
  const savedIdentity = source === "login" ? readCreds() : null;
1156
1372
  if (savedIdentity) assertStoredIdentity(savedIdentity, identityConfiguration());
1373
+ // A credential counts as authenticated only once the API accepted it.
1157
1374
  const report: Record<string, unknown> = {
1158
- status: authenticated ? "ok" : "action_required",
1159
- authenticated,
1375
+ status: present ? "ok" : "action_required",
1376
+ authenticated: false,
1377
+ verification: present ? "unverified" : "none",
1160
1378
  source,
1161
1379
  credentials_path: existsSync(credsPath()) ? credsPath() : null,
1162
1380
  credential_storage: credentialStore().backend,
@@ -1164,12 +1382,13 @@ async function cmdAuth(parsed: Parsed): Promise<void> {
1164
1382
  profile: PROFILE.name, profile_source: PROFILE.source,
1165
1383
  auth_env_vars: agentContext().authEnvVars,
1166
1384
  base_url: resolveBaseUrl(parsed.flags) ?? null,
1167
- next_steps: authenticated ? [] : [
1385
+ next_steps: present ? [WHOAMI ? "Run '" + BIN + " auth check' without --offline to verify the credential." : "This API has no identity read, so the credential was not checked. Run a read command to confirm the API accepts it."] : [
1168
1386
  ...AUTH_SCALARS.map((a) => "Set " + a.env + " in the environment, or run '" + BIN + " login --" + a.flag + " <value>'."),
1169
- "Then run '" + BIN + " auth check --live'.",
1387
+ ...(AUTH_UNDECLARED ? ["The API Spec does not declare authentication. Add another header with --header \"Name: value\" or TYPESHIP_HEADERS."] : []),
1388
+ ...(WHOAMI ? ["Then run '" + BIN + " auth check'."] : []),
1170
1389
  ],
1171
1390
  };
1172
- if (authenticated && parsed.flags.get("live") === true && WHOAMI) {
1391
+ if (present && parsed.flags.get("offline") !== true && WHOAMI) {
1173
1392
  const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
1174
1393
  if (op) {
1175
1394
  const client = await makeClient(parsed.flags, op);
@@ -1178,9 +1397,12 @@ async function cmdAuth(parsed: Parsed): Promise<void> {
1178
1397
  if (result.ok) {
1179
1398
  if (identityConfiguration() && savedIdentity?.identity) assertApiIdentity(savedIdentity.identity.values, readApiIdentity(result.data, IDENTITY_POLICY));
1180
1399
  report.identity = result.data;
1400
+ // An identity read that also answers anonymous callers proves nothing.
1401
+ if (op.auth !== "none") { report.authenticated = true; report.verification = "verified"; report.next_steps = []; }
1181
1402
  }
1182
1403
  else {
1183
- const why = classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT });
1404
+ const why = classifyApiError(result.error, { bin: BIN, envPrefix: ENV_PREFIX, hadCredential: true, docsUrl: DOCS_URL_DEFAULT });
1405
+ report.verification = "rejected";
1184
1406
  report.status = "action_required";
1185
1407
  report.live = { ok: false, code: why.code, message: why.message };
1186
1408
  report.next_steps = why.nextSteps ?? [];
@@ -1209,7 +1431,13 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
1209
1431
  if (baseUrl) {
1210
1432
  try {
1211
1433
  const response = await fetch(baseUrl, { method: "GET", signal: AbortSignal.timeout(8_000) });
1212
- checks.push({ name: "base_url", ok: true, detail: baseUrl + " → HTTP " + response.status });
1434
+ void response.body?.cancel().catch(() => {});
1435
+ const reachable = response.status >= 200 && response.status < 300;
1436
+ checks.push({ name: "base_url", ok: reachable, detail: baseUrl + " → HTTP " + response.status, ...(reachable ? {} : { fix: response.status === 404 || response.status === 405
1437
+ ? "The API does not answer at this base URL. Check --base-url / " + ENV_PREFIX + "_BASE_URL, or run '" + BIN + " upgrade' if this CLI is older than the API."
1438
+ : response.status === 401 || response.status === 403
1439
+ ? "The API answered without a credential with HTTP " + response.status + "; the identity check below tests the credential."
1440
+ : "The API answered HTTP " + response.status + ". Check the base URL and the API's status." }) });
1213
1441
  } catch (e) {
1214
1442
  checks.push({ name: "base_url", ok: false, detail: baseUrl + ": " + (e as Error).message, fix: "Check the network, or set --base-url / " + ENV_PREFIX + "_BASE_URL." });
1215
1443
  }
@@ -1223,7 +1451,13 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
1223
1451
  const client = await makeClient(parsed.flags, op);
1224
1452
  const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; error?: unknown }>>>)[op.resource]!;
1225
1453
  const result = await asApiResult(target[op.method]!());
1226
- checks.push(result.ok ? { name: "identity", ok: true, detail: op.command.join(" ") + " ok" } : { name: "identity", ok: false, detail: classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT }).message, fix: "The credential was rejected; run '" + BIN + " login' with a current one." });
1454
+ const status = (result.error as { status?: unknown } | undefined)?.status;
1455
+ checks.push(result.ok ? { name: "identity", ok: true, detail: op.command.join(" ") + " ok" } : { name: "identity", ok: false, detail: classifyApiError(result.error, { bin: BIN, envPrefix: ENV_PREFIX, hadCredential: true, docsUrl: DOCS_URL_DEFAULT }).message,
1456
+ // Only 401 and 403 are about the credential. A 404 or 405 means the
1457
+ // API no longer has this endpoint where this CLI version expects it.
1458
+ fix: status === 401 || status === 403 ? "The credential was rejected; run '" + BIN + " login' with a current one."
1459
+ : status === 404 || status === 405 ? "The API does not know " + wireOf(op) + ", which this CLI version calls. Run '" + BIN + " upgrade', and check the base URL."
1460
+ : "The identity read failed; the detail says why." });
1227
1461
  } catch (e) {
1228
1462
  checks.push({ name: "identity", ok: false, detail: (e as Error).message });
1229
1463
  }
@@ -1236,7 +1470,7 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
1236
1470
  }
1237
1471
  if (HAS_MCP || MCP_URL) {
1238
1472
  const cwd = process.cwd();
1239
- const configured = MCP_CLIENTS.filter((c) => c.detect(cwd) && mcpConfigured(c, cwd, BIN)).map((c) => c.id);
1473
+ const configured = MCP_CLIENTS.filter((c) => c.detect(cwd) && mcpConfigured(c, cwd, MCP_SERVER_KEY)).map((c) => c.id);
1240
1474
  const detected = MCP_CLIENTS.filter((c) => c.detect(cwd) && !c.incompatible).map((c) => c.id);
1241
1475
  checks.push({ name: "mcp_clients", ok: detected.length === 0 || configured.length > 0, detail: "detected: " + (detected.join(", ") || "none") + "; configured: " + (configured.join(", ") || "none"), ...(detected.length > 0 && configured.length === 0 ? { fix: "Run '" + BIN + " mcp install --all'." } : {}) });
1242
1476
  }
@@ -1276,13 +1510,18 @@ async function cmdInit(parsed: Parsed): Promise<void> {
1276
1510
  const first = AUTH_SCALARS[0];
1277
1511
  const named = flagCredentials(parsed.flags), envNamed = environmentCredentials();
1278
1512
  const stored: StoredCreds = Object.keys(named).length || Object.keys(envNamed).length || first && process.env[first.env] ? {} : readCreds() ?? {};
1279
- const given = (typeof parsed.flags.get("k") === "string" ? parsed.flags.get("k") as string : undefined)
1280
- ?? (first && typeof parsed.flags.get(first.flag) === "string" ? parsed.flags.get(first.flag) as string : undefined);
1513
+ // -k stores the credential login would store; --<flag> stores that scalar.
1514
+ const target = loginTarget(parsed.flags);
1515
+ const keyValue = typeof parsed.flags.get("k") === "string" ? parsed.flags.get("k") as string : undefined;
1516
+ const flagValue = first && typeof parsed.flags.get(first.flag) === "string" ? parsed.flags.get(first.flag) as string : undefined;
1281
1517
  if (Object.keys(named).length) {
1282
1518
  await saveLoginCredentials({ named }, parsed.flags);
1283
1519
  report.credential = { status: "stored", path: credsPath() };
1284
- } else if (given && first) {
1285
- await saveLoginCredentials({ scalars: { [first.option]: given } }, parsed.flags);
1520
+ } else if (keyValue && target && !target.basic) {
1521
+ await saveLoginCredentials(target.save(keyValue), parsed.flags);
1522
+ report.credential = { status: "stored", path: credsPath() };
1523
+ } else if ((keyValue ?? flagValue) && first) {
1524
+ await saveLoginCredentials({ scalars: { [first.option]: (keyValue ?? flagValue)! } }, parsed.flags);
1286
1525
  report.credential = { status: "stored", path: credsPath() };
1287
1526
  } else if (first && process.env[first.env]) {
1288
1527
  report.credential = { status: "env", variable: first.env };
@@ -1293,13 +1532,13 @@ async function cmdInit(parsed: Parsed): Promise<void> {
1293
1532
  } else if (first && HAS_OAUTH_LOGIN && OAUTH_LOGIN_METHOD === "browser" && (process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID) && !explicitNonInteractive(parsed)) {
1294
1533
  await acquireOAuthBrowserSession(parsed, (process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID)!);
1295
1534
  report.credential = { status: "stored", method: "oauth_browser", path: credsPath() };
1296
- } else if (first && CLI_AUTH_URL && !explicitNonInteractive(parsed)) {
1535
+ } else if (target && !target.basic && CLI_AUTH_URL && !explicitNonInteractive(parsed)) {
1297
1536
  // Nothing anywhere: approve a credential in the browser, as `login`
1298
1537
  // would, then carry on. Under an agent the URL is printed for the person
1299
1538
  // and polled; only the explicit non-interactive switch skips this.
1300
1539
  await credentialStore().prepare();
1301
1540
  const minted = await browserApprove(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
1302
- await storeMinted(minted, parsed.flags);
1541
+ await storeMinted(minted, target, parsed.flags);
1303
1542
  report.credential = { status: "minted", method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), path: credsPath() };
1304
1543
  } else {
1305
1544
  report.credential = { status: "none" };
@@ -1317,7 +1556,7 @@ async function cmdInit(parsed: Parsed): Promise<void> {
1317
1556
  } else {
1318
1557
  const { entry, warnings } = mcpEntryFor(undefined);
1319
1558
  const clients = MCP_CLIENTS.filter((c) => !c.incompatible && c.detect(cwd) && !(c.id === "claude-desktop" && entry.url));
1320
- const results = clients.map((c) => writeMcpConfig(c, cwd, BIN, entry));
1559
+ const results = clients.map((c) => writeMcpConfig(c, cwd, MCP_SERVER_KEY, entry));
1321
1560
  report.mcp = { status: results.length > 0 ? "written" : "no-clients", entry, clients: results, ...(warnings.length > 0 ? { warnings } : {}) };
1322
1561
  if (results.length === 0) nextSteps.push("No MCP client was found on this machine; run '" + BIN + " mcp' to print the entry.");
1323
1562
  }
@@ -1331,7 +1570,7 @@ async function cmdInit(parsed: Parsed): Promise<void> {
1331
1570
  report.agents_md = { status: result.updated ? "updated" : "written", file: result.file };
1332
1571
  }
1333
1572
 
1334
- nextSteps.push("Run '" + BIN + " auth check --live'" + (WHOAMI ? "" : " (or any read command)") + " to confirm the connection.");
1573
+ nextSteps.push("Run '" + BIN + " auth check'" + (WHOAMI ? "" : " (or any read command)") + " to confirm the connection.");
1335
1574
  nextSteps.push("Run '" + BIN + " agent-guide' for the conventions, or '" + BIN + " --help' for commands.");
1336
1575
  out({ ...report, next_steps: nextSteps });
1337
1576
  await flushExit(0);
@@ -1357,6 +1596,7 @@ function registryBase(): string {
1357
1596
  }
1358
1597
 
1359
1598
  async function latestVersion(timeoutMs: number): Promise<string | null> {
1599
+ if (!PKG_CONFIRMED) return null;
1360
1600
  try {
1361
1601
  const response = await fetch(registryBase() + "/" + PKG_NAME, {
1362
1602
  headers: { Accept: "application/vnd.npm.install-v1+json" },
@@ -1383,6 +1623,9 @@ async function cmdUpgrade(parsed: Parsed): Promise<void> {
1383
1623
  process.stdout.write(lines.join("\n") + "\n");
1384
1624
  await flushExit(0);
1385
1625
  }
1626
+ if (!PKG_CONFIRMED) {
1627
+ fail(1, "This build's package name (" + PKG_NAME + ") was not confirmed when it was generated, so upgrade does not look it up on npm, where that name may belong to another package. This package updates by regeneration from its API spec; get the latest from the API provider.");
1628
+ }
1386
1629
  const latest = await latestVersion(5000);
1387
1630
  if (latest === null) {
1388
1631
  fail(1, PKG_NAME + " is not on the registry (" + registryBase() + "). This package updates by regeneration from its API spec; get the latest from the API provider.");
@@ -1434,7 +1677,7 @@ function completionFlagsFor(op: OpSpec): { flags: string[]; values: Record<strin
1434
1677
  return { flags, values };
1435
1678
  }
1436
1679
 
1437
- const COMPLETION_GLOBAL_FLAGS = ["--help", "--version", "--non-interactive", "--color", "--credentials", "--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)];
1438
1681
  const BUILTIN_WORDS: Record<string, string[]> = {
1439
1682
  config: ["list", "get", "set", "unset", "path"],
1440
1683
  completion: ["bash", "zsh", "fish"],
@@ -1574,34 +1817,6 @@ async function fetchDocs(pathOrFile: string): Promise<string | null> {
1574
1817
  }
1575
1818
  }
1576
1819
 
1577
- function searchTerms(query: string): string[] {
1578
- return [...new Set(query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length >= 2))];
1579
- }
1580
-
1581
- /** Token search across names, prose, paths, and arguments. A phrase such as
1582
- * "create project" should find the projects create command, even though that exact
1583
- * substring never occurs in the generated command. */
1584
- function referenceSearchScore(op: OpSpec, query: string): number {
1585
- const terms = searchTerms(query);
1586
- if (terms.length === 0) return 0;
1587
- const names = [...op.command, op.tool].join("_").toLowerCase().split(/[^a-z0-9]+/);
1588
- const summary = (op.summary ?? "").toLowerCase();
1589
- const description = (op.description ?? "").toLowerCase();
1590
- const path = op.path.toLowerCase();
1591
- const params = op.params.flatMap((p) => [p.name.toLowerCase(), p.flag.toLowerCase()]);
1592
- let score = 0;
1593
- for (const term of terms) {
1594
- if (names.includes(term)) score += 10;
1595
- if (summary.split(/[^a-z0-9]+/).includes(term)) score += 5;
1596
- else if (summary.includes(term)) score += 3;
1597
- if (path.includes(term)) score += 3;
1598
- if (params.includes(term)) score += 3;
1599
- else if (params.some((param) => param.includes(term))) score += 1;
1600
- if (description.includes(term)) score += 1;
1601
- }
1602
- return score;
1603
- }
1604
-
1605
1820
  function referenceFor(op: OpSpec, includeSchemas = false): string {
1606
1821
  const lines: string[] = [];
1607
1822
  lines.push(paintOut("bold", usageLine(op)));
@@ -1620,7 +1835,9 @@ function referenceFor(op: OpSpec, includeSchemas = false): string {
1620
1835
  lines.push("", paintOut("bold", label + ":"));
1621
1836
  for (const p of params) {
1622
1837
  const name = p.kind === "path" ? "<" + p.name + ">" : "--" + p.flag;
1623
- 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)") : ""));
1624
1841
  const values = p.type === "array" ? p.items?.enum : p.enum;
1625
1842
  if (values && values.join("|").length > 24) lines.push(" one of: " + values.join(", "));
1626
1843
  if (p.description) {
@@ -1639,11 +1856,34 @@ function referenceFor(op: OpSpec, includeSchemas = false): string {
1639
1856
  lines.push("", paintOut("bold", "Wire arguments:"), JSON.stringify(op.exampleArguments, null, 2));
1640
1857
  if (op.outputSchema) lines.push("", paintOut("bold", "Output schema:"), JSON.stringify(op.outputSchema, null, 2));
1641
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
+ }
1642
1865
  lines.push("", "Add --schema for the complete input/output schemas, or --json for the machine contract.");
1643
1866
  }
1644
1867
  return lines.join("\n");
1645
1868
  }
1646
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
+
1647
1887
  /** Opens a browser for a person; under an agent it prints the URL instead of opening anything. */
1648
1888
  function openInBrowser(url: string, parsed?: Parsed): { opened: boolean; url: string } {
1649
1889
  if (parsed && nonInteractive(parsed)) {
@@ -1663,11 +1903,12 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1663
1903
  "",
1664
1904
  " " + BIN + " docs overview",
1665
1905
  " " + BIN + " docs <resource> <command> operation contract and example",
1906
+ " --path <a.b> one nested argument's type and fields",
1666
1907
  " --schema include input/output JSON Schema",
1667
1908
  " --json print the machine contract as JSON",
1668
1909
  " " + BIN + " docs search <term> search reference and guides",
1669
1910
  " --json / --format json print structured matches and availability",
1670
- " " + 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",
1671
1912
  " " + BIN + " docs --web open the docs site in a browser",
1672
1913
  "",
1673
1914
  "Guides come from the docs site's llms.txt (set with '" + BIN + " config set docs-url <url>').",
@@ -1690,23 +1931,22 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1690
1931
  const term = parsed.positionals.slice(2).join(" ");
1691
1932
  if (!term) fail(2, "docs search expects a term");
1692
1933
  const jsonOutput = parsed.flags.get("json") === true || parsed.flags.get("format") === "json";
1693
- const refMatches = OPS.map((op) => ({ op, score: referenceSearchScore(op, term) }))
1694
- .filter((match) => match.score > 0)
1695
- .sort((a, b) => b.score - a.score || a.op.command.join(" ").localeCompare(b.op.command.join(" ")))
1696
- .map((match) => match.op);
1934
+ // The MCP server's search_docs ranking, so both surfaces agree.
1935
+ const refMatches = rankOperations(OPS, term).map((match) => match.op);
1697
1936
  const { guides: proseMatches, status: docsStatus } = await searchConnectedGuides(docsSiteUrl(), docsIndexUrl(), fetchDocs, term);
1698
1937
  if (jsonOutput) {
1699
1938
  out({
1700
1939
  schema_version: "1",
1701
1940
  query: term,
1702
- reference: refMatches.slice(0, 15).map((op) => ({
1941
+ reference: refMatches.slice(0, SEARCH_PAGE_SIZE).map((op) => ({
1703
1942
  command: op.command.join(" "),
1704
1943
  method: op.httpMethod,
1705
1944
  path: op.path,
1706
1945
  ...(op.summary ? { summary: op.summary } : {}),
1946
+ ...(op.deprecated ? { deprecated: true } : {}),
1707
1947
  details_command: BIN + " docs " + op.command.join(" ") + " --json",
1708
1948
  })),
1709
- guides: proseMatches.slice(0, 15).map((match) => ({ ...match, read_command: docsReadCommand(BIN, match.url) })),
1949
+ guides: proseMatches.slice(0, SEARCH_PAGE_SIZE).map((match) => ({ ...match, read_command: docsReadCommand(BIN, match.url) })),
1710
1950
  totals: { reference: refMatches.length, guides: proseMatches.length },
1711
1951
  guides_status: docsStatus,
1712
1952
  ...(docsStatus === "not_configured" ? { next_steps: ["Run '" + BIN + " config set docs-url <url>' to add guide search; the API reference was still searched."] } : {}),
@@ -1717,11 +1957,11 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1717
1957
  const lines: string[] = [];
1718
1958
  if (refMatches.length > 0) {
1719
1959
  lines.push(paintOut("bold", "Reference:"));
1720
- for (const op of refMatches.slice(0, 15)) lines.push(" " + padPaint("cyan", op.command.join(" "), 34) + (op.summary ?? wireOf(op)));
1960
+ for (const op of refMatches.slice(0, SEARCH_PAGE_SIZE)) lines.push(" " + padPaint("cyan", op.command.join(" "), 34) + (op.deprecated ? "(deprecated) " : "") + (op.summary ?? wireOf(op)));
1721
1961
  }
1722
1962
  if (proseMatches.length > 0) {
1723
1963
  lines.push(...(lines.length > 0 ? [""] : []), paintOut("bold", "Guides:"));
1724
- for (const match of proseMatches.slice(0, 15)) lines.push(" " + paintOut("cyan", match.title + (match.section ? " / " + match.section : "")), " " + match.excerpt, " " + docsReadCommand(BIN, match.url));
1964
+ for (const match of proseMatches.slice(0, SEARCH_PAGE_SIZE)) lines.push(" " + paintOut("cyan", match.title + (match.section ? " / " + match.section : "")), " " + match.excerpt, " " + docsReadCommand(BIN, match.url));
1725
1965
  } else if (docsStatus === "not_configured") {
1726
1966
  lines.push(...(lines.length > 0 ? [""] : []), "(no docs site configured for guide search: '" + BIN + " config set docs-url <url>')");
1727
1967
  } else if (docsStatus === "unavailable") {
@@ -1735,6 +1975,13 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1735
1975
  if (sub === "read") {
1736
1976
  const page = parsed.positionals[2];
1737
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
+ }
1738
1985
  let target = page!;
1739
1986
  if (!/^https?:\/\//.test(target)) {
1740
1987
  const index = await fetchDocs("llms.txt");
@@ -1773,12 +2020,14 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1773
2020
  }, null, 2) + "\n");
1774
2021
  await flushExit(0);
1775
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);
1776
2025
  process.stdout.write(referenceFor(op!, parsed.flags.get("schema") === true) + "\n");
1777
2026
  await flushExit(0);
1778
2027
  }
1779
2028
 
1780
2029
  const lines: string[] = [];
1781
- lines.push(paintOut("bold", "typeship") + " (v" + API_VERSION + ")");
2030
+ lines.push(paintOut("bold", "Typeship") + " (v" + API_VERSION + ")");
1782
2031
  if (API_DESCRIPTION) lines.push("", API_DESCRIPTION.trim());
1783
2032
  lines.push("", paintOut("bold", "Reference:") + " " + BIN + " docs <resource> <command>");
1784
2033
  const byResource = new Map<string, number>();
@@ -1834,7 +2083,7 @@ function shellQuote(value: string): string {
1834
2083
  }
1835
2084
 
1836
2085
  function usageLine(op: OpSpec): string {
1837
- const paths = op.params.filter((p) => p.kind === "path").map((p) => "<" + p.name + ">").join(" ");
2086
+ const paths = op.params.filter((p) => p.kind === "path").map((p) => p.credential ? "[<" + p.name + ">]" : "<" + p.name + ">").join(" ");
1838
2087
  return BIN + " " + op.command[0] + " " + op.command[1] + (paths ? " " + paths : "");
1839
2088
  }
1840
2089
 
@@ -1882,7 +2131,7 @@ function helpSentence(description: string | undefined): string {
1882
2131
  /** Type column text for a param: string, number, string[], a|b|c, enum, object, json, path. */
1883
2132
  function typeLabel(p: ParamSpec): string {
1884
2133
  if (p.nullable) return typeLabel({ ...p, nullable: false }) + "|null";
1885
- if (p.type === "file") return "path (uploaded)";
2134
+ if (p.type === "file") return p.multiple ? "paths (uploaded, repeatable)" : "path (uploaded)";
1886
2135
  if (p.format && p.type === "string") return p.format;
1887
2136
  const inlineEnum = (values: string[] | undefined) => values && values.join("|").length <= 24 ? values.join("|") : undefined;
1888
2137
  if (p.type === "array") {
@@ -1957,7 +2206,7 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
1957
2206
  }
1958
2207
  const width = termWidth();
1959
2208
  const lines: string[] = [];
1960
- lines.push(paintOut("bold", BIN) + ": " + "typeship API" + " (v" + "1.0.0" + "), package " + "0.22.0");
2209
+ lines.push(paintOut("bold", BIN) + ": " + "Typeship API" + " (v" + "1.0.0" + "), package " + "0.24.0");
1961
2210
  lines.push("");
1962
2211
  lines.push(paintOut("bold", "Usage:") + " " + BIN + " <resource> <command> [args] [--flags]");
1963
2212
  lines.push("");
@@ -1976,18 +2225,18 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
1976
2225
  }
1977
2226
  if (EXCLUDED_OPS > 0) {
1978
2227
  lines.push("");
1979
- lines.push(...labeled(paintOut("yellow", "Plan limit:") + " ", "generated " + OPS.length + " of " + (OPS.length + EXCLUDED_OPS) + " operations", width, 14));
1980
- lines.push(...labeled("Omitted: ", OMITTED_OPS.map((op) => op.command.join(" ") + " (" + op.httpMethod + " " + op.path + ")").join(", "), width, 14));
1981
- lines.push(...labeled("Upgrade: ", "https://typeship.dev/pricing, then regenerate without the operation cap", width, 14));
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));
1982
2229
  }
1983
2230
  lines.push("");
1984
- const flagsText = "-v/--version, -h/--help, --debug, --non-interactive, --color on|off|auto, --base-url <url>, --profile <name>, --credentials @<file>|-, --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)" +
1985
2232
  (AUTH_SCALARS.length > 0 ? ", " + AUTH_SCALARS.map((a) => "--" + a.flag + " <value>").join(", ") : "");
1986
2233
  lines.push(...labeled(paintOut("bold", "Global flags:") + " ", flagsText, width, 14).map((l, i) => (i === 0 ? l : l)));
1987
2234
  lines.push(...labeled("Credential env vars: ", [
1988
2235
  "TYPESHIP_CREDENTIALS", ...AUTH_SCALARS.map((a) => a.env),
1989
2236
  ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []),
1990
2237
  ].join(", ") || "none", width, 21));
2238
+ if (AUTH_UNDECLARED) lines.push(...labeled("Auth: ", "not declared by the API Spec; " + AUTH_SCALARS[0]!.env + " is sent as Authorization: Bearer when set", width, 6));
2239
+ lines.push(...labeled("Extra headers: ", "--header \"Name: value\" (repeatable) or TYPESHIP_HEADERS", width, 15));
1991
2240
  lines.push(...labeled("Endpoint env var: ", "TYPESHIP_BASE_URL", width, 18));
1992
2241
  lines.push(...labeled("Sign-in: ", BIN + " login | logout | whoami | auth check (stored at " + credsPath() + ")", width, 9));
1993
2242
  lines.push(...labeled("Setup: ", BIN + " init (connect this machine)" + " | " + BIN + " config (defaults)" + (HAS_MCP || MCP_URL ? " | " + BIN + " mcp install --all (agent clients)" : "") + " | " + BIN + " doctor | " + BIN + " upgrade | " + BIN + " completion <shell>", width, 7));
@@ -2030,12 +2279,15 @@ function commandExtras(op: OpSpec): [string, string][] {
2030
2279
  const extras: [string, string][] = [];
2031
2280
  if (op.auth !== "none" && Object.keys(NAMED_SCHEMES).length) extras.push(["--credentials @<file>|-", "named credentials as JSON; use - for stdin"]);
2032
2281
  if (op.hasBody && op.bodyKind === "binary") extras.push(["--file <path>", "raw request body, uploaded as-is (- reads stdin)"]);
2282
+ if (op.rawResponse) extras.push(["--output <file>", "write the " + (op.rawResponse === "binary" ? "binary " : "") + "response body to a file (- for stdout) and print what was written"]);
2033
2283
  else if (op.hasBody) extras.push(["--data '<json>'", "raw JSON body" + (op.bodyStyle === "fields" ? " (merged under field flags)" : "") + "; @<file> reads a file, - reads stdin"]);
2034
2284
  if (op.select) extras.push(["--select '<selection>'", "GraphQL selection set replacing the default, e.g. '{ id name }'"]);
2035
2285
  if (op.paginated) extras.push(["--all", "stream every item from every page (NDJSON)"]);
2036
2286
  const collectionField = collectionProperty(op.outputSchema);
2037
2287
  extras.push(["--fields <a,b.c>", "keep only these fields of the result" + (op.paginated ? " (per item)" : collectionField ? " (per item in " + collectionField + ")" : "")]);
2038
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"]);
2039
2291
  if (op.safety === "destructive") extras.push(["--force, -y", "destructive: required without a terminal, skips the prompt with one"]);
2040
2292
  return extras;
2041
2293
  }
@@ -2044,6 +2296,7 @@ function commandExtras(op: OpSpec): [string, string][] {
2044
2296
  function exampleLine(op: OpSpec): string {
2045
2297
  const parts = [BIN, op.command[0], op.command[1]];
2046
2298
  for (const p of op.params) {
2299
+ if (p.credential) continue; // defaulted from the configured credential
2047
2300
  const hasExample = p.type !== "file" && Object.hasOwn(op.exampleArguments, p.name);
2048
2301
  if (!p.required && !hasExample) continue;
2049
2302
  const generated = p.type === "file" ? undefined : op.exampleArguments[p.name];
@@ -2074,6 +2327,7 @@ function exampleLine(op: OpSpec): string {
2074
2327
 
2075
2328
  /** " (no auth needed)" for an anonymous operation in an API that otherwise authenticates. */
2076
2329
  function authNote(op: OpSpec): string {
2330
+ if (AUTH_UNDECLARED) return op.auth === "none" ? " (no auth needed)" : " (auth not declared)";
2077
2331
  const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null || HAS_OAUTH_LOGIN;
2078
2332
  return apiHasAuth && op.auth === "none" ? " (no auth needed)" : apiHasAuth && op.auth === "optional" ? " (auth optional)" : "";
2079
2333
  }
@@ -2083,6 +2337,8 @@ function printOp(op: OpSpec): void {
2083
2337
  lines.push(paintOut("bold", usageLine(op)));
2084
2338
  if (op.summary) lines.push(helpSentence(op.summary));
2085
2339
  lines.push(wireOf(op) + authNote(op));
2340
+ const scopes = requiredScopes(op.security);
2341
+ if (scopes.length) lines.push("Requires OAuth scopes: " + scopes.join(", ") + (HAS_OAUTH_LOGIN ? " (login --scopes " + scopes.join(",") + ")" : ""));
2086
2342
  lines.push("");
2087
2343
  const rows = op.params.filter((p) => p.kind !== "path");
2088
2344
  const extras = commandExtras(op);
@@ -2135,7 +2391,7 @@ function readStdinBytes(): Promise<Uint8Array<ArrayBuffer>> {
2135
2391
  /** A local file as an upload part; the SDK's multipart encoder takes Blobs. */
2136
2392
  function fileFromPath(flag: string, path: string): File {
2137
2393
  try {
2138
- return new File([readFileSync(path)], basename(path));
2394
+ return new File([readFileSync(path)], basename(path), { type: mediaTypeForPath(path) });
2139
2395
  } catch (e) {
2140
2396
  return fail(2, "--" + flag + ": cannot read " + path + " (" + (e as Error).message + ")");
2141
2397
  }
@@ -2182,6 +2438,7 @@ function coerce(spec: ParamSpec, raw: string | boolean, repeated?: string[]): un
2182
2438
  if (spec.nullable && raw === "null" && repeated === undefined) return null;
2183
2439
  if (spec.type === "file") {
2184
2440
  if (raw === true) fail(2, "--" + spec.flag + " expects a file path");
2441
+ if (spec.multiple) return (repeated ?? [String(raw)]).map((path) => fileFromPath(spec.flag, path));
2185
2442
  return fileFromPath(spec.flag, String(raw));
2186
2443
  }
2187
2444
  if (spec.type === "boolean") {
@@ -2252,18 +2509,83 @@ function validateParameters(op: OpSpec, values: Record<string, unknown>, flags:
2252
2509
 
2253
2510
  /** Whether the last client built carried any credential; failApi tells NO_AUTH from AUTH_INVALID with it. */
2254
2511
  let LAST_CLIENT_HAD_CREDENTIAL = false;
2512
+ /** The last client's Basic credentials, for path arguments that default to the username. */
2513
+ let LAST_CLIENT_BASIC: { basicAuth?: unknown; credentials?: unknown } = {};
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
+
2574
+ /** The configured Basic-auth username: the named scheme's, else basicAuth's. */
2575
+ function credentialUsername(scheme: string): string | undefined {
2576
+ const named = (LAST_CLIENT_BASIC.credentials as Record<string, unknown> | undefined)?.[scheme];
2577
+ const username = named && typeof named === "object" ? (named as { username?: unknown }).username
2578
+ : (LAST_CLIENT_BASIC.basicAuth as { username?: unknown } | undefined)?.username;
2579
+ return typeof username === "string" && username !== "" ? username : undefined;
2580
+ }
2255
2581
 
2256
2582
  /** Check the same complete alternatives the request runtime can select, after
2257
2583
  * per-scheme flag, environment, profile, and OAuth resolution. */
2258
2584
  function requireOperationCredentials(op: OpSpec, options: ClientOptions & Record<string, unknown>): void {
2259
2585
  if (op.auth !== "required") return;
2260
- const supplied = (value: unknown): boolean => typeof value === "function" || (typeof value === "string" && value.length > 0)
2261
- || (value !== null && typeof value === "object" && "username" in value && "password" in value && typeof value.username === "string" && value.username.length > 0 && typeof value.password === "string" && value.password.length > 0);
2262
- const named = Object.fromEntries(Object.entries(options.credentials ?? {}).filter(([, value]) => supplied(value))) as NamedCredentials;
2263
- const available = namedCredentialAvailability(NAMED_SCHEMES, new Set(Object.keys(options).filter((key) => supplied(options[key]))), named);
2264
- if (op.credentialOptions?.some((alternative) => alternative.length > 0 && alternative.every((option) => available.has(option)))) return;
2265
- const alternatives = (op.credentialOptions ?? []).filter((alternative) => alternative.length > 0 && alternative.every((option) => option.startsWith("credentials.")));
2266
- const missing = alternatives.map((alternative) => alternative.filter((option) => !available.has(option)).map((option) => option.slice("credentials.".length)));
2586
+ const gap = missingCredentials(NAMED_SCHEMES, op.credentialOptions, options);
2587
+ if (!gap) return;
2588
+ const { alternatives, missing } = gap;
2267
2589
  const names = new Set(missing.flat());
2268
2590
  const relevant = AUTH_SCALARS.filter((scalar) => [...names].some((name) => NAMED_SCHEMES[name]?.options.includes(scalar.option)));
2269
2591
  const needsBasic = [...names].some((name) => NAMED_SCHEMES[name]?.options.includes("basicAuth"));
@@ -2276,7 +2598,8 @@ function requireOperationCredentials(op: OpSpec, options: ClientOptions & Record
2276
2598
  nextSteps: alternatives.length ? [
2277
2599
  ...relevant.map((a) => "Set " + a.env + " in the environment, pass --" + a.flag + " <value>, or run '" + BIN + " login'."),
2278
2600
  ...(needsBasic && BASIC ? ["Set " + BASIC.envUser + " and " + BASIC.envPass + ", or pass --username and --password."] : []),
2279
- "Supply all schemes in one alternative through " + "TYPESHIP_CREDENTIALS" + " or --credentials @<JSON-file>: " + alternatives.map((alternative) => alternative.map((option) => option.slice("credentials.".length)).join(" + ")).join(" OR ") + ".",
2601
+ ...(HAS_OAUTH_LOGIN && [...names].some((name) => OAUTH_SESSION_SCHEMES.includes(name)) ? ["Sign in with OAuth: '" + BIN + " login'."] : []),
2602
+ "Supply all schemes in one alternative through " + "TYPESHIP_CREDENTIALS" + " or --credentials @<JSON-file>: " + alternatives.map((alternative) => alternative.join(" + ")).join(" OR ") + ".",
2280
2603
  ] : ["Check the operation's security schemes in the API Spec and regenerate with a supported, compatible alternative."],
2281
2604
  });
2282
2605
  }
@@ -2307,6 +2630,23 @@ function environmentCredentials(): NamedCredentials {
2307
2630
  return value === undefined ? {} : parseNamedCredentials(value, NAMED_SCHEMES);
2308
2631
  }
2309
2632
 
2633
+ /** --timeout <seconds> or TYPESHIP_TIMEOUT: the per-attempt deadline
2634
+ * (default 60 seconds) for slow operations. */
2635
+ function requestTimeoutMs(flags: Map<string, string | boolean>): number | undefined {
2636
+ const raw = typeof flags.get("timeout") === "string" ? flags.get("timeout") as string : process.env["TYPESHIP_TIMEOUT"];
2637
+ if (raw === undefined) return undefined;
2638
+ const seconds = Number(raw);
2639
+ if (!/^\d+(\.\d+)?$/.test(raw.trim()) || !Number.isFinite(seconds) || seconds <= 0 || seconds > 3600) fail(2, "--timeout expects seconds between 0 and 3600, such as --timeout 120.");
2640
+ return Math.round(seconds * 1000);
2641
+ }
2642
+
2643
+ /** --header flags and TYPESHIP_HEADERS: sent on every API request, after
2644
+ * (and in place of) any generated header of the same name. */
2645
+ function extraRequestHeaders(): Record<string, string> {
2646
+ try { return parseExtraHeaders(process.env["TYPESHIP_HEADERS"], HEADER_FLAGS, "TYPESHIP_HEADERS"); }
2647
+ catch (error) { fail(2, (error as Error).message); }
2648
+ }
2649
+
2310
2650
  /** Where a credential would come from, without sending it: "flags", "env:<VAR>", "login", or null. */
2311
2651
  function credentialSource(flags: Map<string, string | boolean>): string | null {
2312
2652
  if (Object.keys(flagCredentials(flags)).length) return "flags";
@@ -2330,7 +2670,29 @@ function resolveBaseUrl(flags: Map<string, string | boolean>, config = readConfi
2330
2670
  ?? DEFAULT_BASE_URL ?? undefined;
2331
2671
  }
2332
2672
 
2673
+ /** OAuth schemes the login session authenticates by name; empty when the
2674
+ * session is the convenience bearer token (see oauthSessionSchemes). */
2675
+ const OAUTH_SESSION_SCHEMES = oauthSessionSchemes(NAMED_SCHEMES);
2676
+
2677
+ /** Send a login session to the OAuth scheme, never to a separate http bearer
2678
+ * scheme. Explicit credentials for the same scheme keep precedence. */
2679
+ function applyOAuthSession(options: ClientOptions & Record<string, unknown>, token: string | (() => Promise<string>), replace = false): void {
2680
+ if (!OAUTH_SESSION_SCHEMES.length) {
2681
+ if (replace || options.bearerToken === undefined) options.bearerToken = token;
2682
+ return;
2683
+ }
2684
+ const credentials = (options.credentials ??= {}) as Record<string, unknown>;
2685
+ for (const name of OAUTH_SESSION_SCHEMES) if (replace || !Object.hasOwn(credentials, name)) credentials[name] = token;
2686
+ }
2687
+ function withOAuthSession(options: ClientOptions & Record<string, unknown>, accessToken: string): ClientOptions & Record<string, unknown> {
2688
+ const next: ClientOptions & Record<string, unknown> = { ...options, credentials: { ...(options.credentials as Record<string, unknown>) } as ClientOptions["credentials"] };
2689
+ applyOAuthSession(next, accessToken, true);
2690
+ if (!OAUTH_SESSION_SCHEMES.length) next.credentials = { ...(next.credentials as Record<string, unknown>), ...resolveNamedCredentials(NAMED_SCHEMES, [{ options: { bearerToken: accessToken } }]) } as ClientOptions["credentials"];
2691
+ return next;
2692
+ }
2693
+
2333
2694
  async function makeClient(flags: Map<string, string | boolean>, op: OpSpec, candidate?: StoredCreds, forIdentity = false): Promise<TypeshipClient> {
2695
+ CURRENT_SCOPES = requiredScopes(op.security);
2334
2696
  const flagNamed = flagCredentials(flags), envNamed = environmentCredentials();
2335
2697
  const explicitOptions = new Set(AUTH_SCALARS.filter((a) => typeof flags.get(a.flag) === "string" || process.env[a.env] !== undefined).map((a) => a.option));
2336
2698
  if (BASIC && (typeof flags.get("username") === "string" || process.env[BASIC.envUser] !== undefined) && (typeof flags.get("password") === "string" || process.env[BASIC.envPass] !== undefined)) explicitOptions.add("basicAuth");
@@ -2369,25 +2731,29 @@ async function makeClient(flags: Map<string, string | boolean>, op: OpSpec, cand
2369
2731
  options.credentials = resolveNamedCredentials(NAMED_SCHEMES, [
2370
2732
  { named: stored?.named, options: { ...stored?.scalars, ...(stored?.basic ? { basicAuth: stored.basic } : {}) } },
2371
2733
  { named: envNamed, options: envOptions }, { named: flagNamed, options: flagOptions },
2372
- ...(forIdentity && candidate ? [{ named: candidate.named, options: { ...candidate.scalars, ...(candidate.basic ? { basicAuth: candidate.basic } : {}), ...(candidate.oauth ? { bearerToken: candidate.oauth.accessToken } : {}) } }] : []),
2734
+ ...(forIdentity && candidate ? [{ named: candidate.named, options: { ...candidate.scalars, ...(candidate.basic ? { basicAuth: candidate.basic } : {}), ...(candidate.oauth && !OAUTH_SESSION_SCHEMES.length ? { bearerToken: candidate.oauth.accessToken } : {}) } }] : []),
2373
2735
  ]);
2374
- if (stored?.oauth && (forIdentity || options.bearerToken === undefined)) {
2736
+ if (stored?.oauth) {
2375
2737
  const sessionId = stored.oauth.sessionId;
2376
- options.bearerToken = forIdentity ? stored.oauth.accessToken : () => oauthSessionToken(credentialStore(), {
2738
+ const token = forIdentity ? stored.oauth.accessToken : sessionCredential((rejected) => oauthSessionToken(credentialStore(), {
2377
2739
  ...sessionConfiguration(baseUrl, config),
2378
- ...(identityConfiguration() && WHOAMI ? { verifyIdentity: (accessToken: string) => verifyClientIdentity((values) => new TypeshipClient(values as unknown as ClientOptions), { ...options, bearerToken: accessToken, credentials: resolveNamedCredentials(NAMED_SCHEMES, [{ named: options.credentials as NamedCredentials }, { options: { bearerToken: accessToken } }]) }, WHOAMI!, IDENTITY_POLICY) } : {}),
2379
- }, sessionId, OAUTH_TOKEN_PARAMS);
2740
+ ...(identityConfiguration() && WHOAMI ? { verifyIdentity: (accessToken: string) => verifyClientIdentity((values) => new TypeshipClient(values as unknown as ClientOptions), withOAuthSession(options, accessToken), WHOAMI!, IDENTITY_POLICY) } : {}),
2741
+ }, sessionId, OAUTH_TOKEN_PARAMS, rejected));
2742
+ applyOAuthSession(options, token, forIdentity);
2380
2743
  }
2381
2744
  if (flags.get("debug") === true || process.env["TYPESHIP_DEBUG"] === "1") {
2382
2745
  options.debug = (event: DebugEvent) => process.stderr.write(paintErr("dim", formatDebugEvent(BIN, event)) + "\n");
2383
2746
  }
2384
2747
  if (flags.get("validate") === true) options.validate = true;
2748
+ const timeout = requestTimeoutMs(flags);
2749
+ if (timeout !== undefined) options.timeoutMs = timeout;
2385
2750
  for (const g of GLOBALS) {
2386
2751
  const flagValue = flags.get(g.flag);
2387
2752
  const value = typeof flagValue === "string" ? flagValue : process.env["TYPESHIP_" + g.envSuffix];
2388
2753
  if (value !== undefined) options[g.option] = value;
2389
2754
  }
2390
2755
  LAST_CLIENT_HAD_CREDENTIAL = Object.keys(options.credentials ?? {}).length > 0 || AUTH_SCALARS.some((a) => options[a.option] !== undefined) || options.basicAuth !== undefined || options.bearerToken !== undefined;
2756
+ LAST_CLIENT_BASIC = { basicAuth: options.basicAuth, credentials: options.credentials };
2391
2757
  requireOperationCredentials(op, options);
2392
2758
  // Identify the package and version. Optional harness and caller details
2393
2759
  // let the API distinguish agent traffic from other non-interactive use.
@@ -2401,6 +2767,14 @@ async function makeClient(flags: Map<string, string | boolean>, op: OpSpec, cand
2401
2767
  "User-Agent": PKG_NAME + "-cli/" + VERSION + (details ? " (" + details + ")" : ""),
2402
2768
  };
2403
2769
  if (forIdentity) { options.fetch = identityFetch(baseUrl); options.maxRetries = 0; options.timeoutMs = 10_000; }
2770
+ const extraHeaders = extraRequestHeaders();
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
+ }
2404
2778
  return new TypeshipClient(options);
2405
2779
  }
2406
2780
 
@@ -2438,9 +2812,9 @@ function failOmitted(op: OmittedOpSpec): never {
2438
2812
  return failWith({
2439
2813
  status: "action_required",
2440
2814
  code: "PLAN_LIMIT",
2441
- message: "The command '" + BIN + " " + op.command.join(" ") + "' exists in the API Spec but was omitted from this generated package by its plan limit.",
2442
- detail: { operation: op.tool, method: op.httpMethod, path: op.path, generated_operations: OPS.length, total_operations: OPS.length + EXCLUDED_OPS },
2443
- nextSteps: ["Upgrade at https://typeship.dev/pricing and regenerate the package without the operation cap.", "Do not invent or retry an omitted command against this generated package."],
2815
+ message: "The command '" + BIN + " " + op.command.join(" ") + "' is in the API but not in this package, which was generated with " + OPS.length + " of its " + (OPS.length + EXCLUDED_OPS) + " operations.",
2816
+ detail: { operation: op.tool, ...(op.graphql ? { graphql: op.graphql.kind + " " + op.graphql.field } : { method: op.httpMethod, path: op.path }), generated_operations: OPS.length, total_operations: OPS.length + EXCLUDED_OPS },
2817
+ nextSteps: ["The package's publisher can regenerate it with every operation.", "Do not invent or retry an omitted command against this package."],
2444
2818
  });
2445
2819
  }
2446
2820
 
@@ -2449,24 +2823,32 @@ const BUILTIN_COMMANDS = ["login","logout","whoami","config","mcp","docs","upgra
2449
2823
  async function main(): Promise<void> {
2450
2824
  const argv = process.argv.slice(2);
2451
2825
  const parsed = parseArgv(argv);
2826
+ const headerFlag = parsed.flags.get("header");
2827
+ HEADER_FLAGS = parsed.repeated.get("header") ?? (typeof headerFlag === "string" ? [headerFlag] : []);
2828
+ if (headerFlag === true) fail(2, "--header expects \"Name: value\".");
2452
2829
  if (!parsed.help) validateCredentialsInput(parsed.flags);
2453
2830
  const profileFlag = parsed.flags.get("profile");
2454
2831
  if (profileFlag !== undefined && typeof profileFlag !== "string") fail(2, "--profile requires a profile name.");
2455
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 });
2456
2833
  if (!parsed.help && ["login", "init"].includes(parsed.positionals[0] ?? "")) expectedLoginIdentity(parsed.flags);
2457
2834
  if (parsed.positionals[0] === "help") {
2458
- // help --json: the command surface as data (agents read this once).
2459
- 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); }
2460
2837
  parsed.positionals.shift(); parsed.help = true;
2461
2838
  }
2462
- if (parsed.flags.get("format") !== undefined && parsed.flags.get("format") !== "json") {
2463
- 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.");
2464
2846
  }
2465
2847
  if (parsed.flags.has("version") || parsed.positionals[0] === "version") {
2466
2848
  // "acme 1.0.0 (acme 1.0.0)" would say the name twice; when the API's
2467
2849
  // title is the bin, name the API version as such.
2468
2850
  const apiLabel = API_TITLE.toLowerCase().replace(/[^a-z0-9]/g, "") === BIN.toLowerCase().replace(/[^a-z0-9]/g, "") ? "API " + API_VERSION : API_TITLE + " " + API_VERSION;
2469
- process.stdout.write(BIN + " " + VERSION + " (" + apiLabel + ", generated by typeship)\n");
2851
+ process.stdout.write(BIN + " " + VERSION + " (" + apiLabel + ", generated by Typeship)\n");
2470
2852
  await flushExit(0);
2471
2853
  }
2472
2854
  const [resourceCmd, methodCmd] = parsed.positionals;
@@ -2531,12 +2913,18 @@ async function main(): Promise<void> {
2531
2913
 
2532
2914
  const pathSpecs = op.params.filter((p) => p.kind === "path");
2533
2915
  const pathValues = parsed.positionals.slice(2);
2534
- if (pathValues.length !== pathSpecs.length) {
2535
- fail(2, "Expected " + pathSpecs.length + " argument(s): " + usageLine(op));
2916
+ // A path argument that is the Basic-auth username (Twilio's AccountSid)
2917
+ // may be left out; it defaults to the configured credential below.
2918
+ const pathGiven = pathValues.length === pathSpecs.length ? pathSpecs
2919
+ : pathValues.length === pathSpecs.filter((p) => !p.credential).length ? pathSpecs.filter((p) => !p.credential)
2920
+ : null;
2921
+ if (!pathGiven) {
2922
+ const optional = pathSpecs.filter((p) => p.credential).length;
2923
+ fail(2, "Expected " + (optional ? (pathSpecs.length - optional) + " or " : "") + pathSpecs.length + " argument(s): " + usageLine(op));
2536
2924
  }
2537
2925
 
2538
2926
  const values: Record<string, unknown> = {};
2539
- pathSpecs.forEach((spec, i) => { values[spec.name] = pathValues[i]; });
2927
+ pathGiven!.forEach((spec, i) => { values[spec.name] = pathValues[i]; });
2540
2928
 
2541
2929
  let dataBody: unknown;
2542
2930
  const dataRaw = parsed.flags.get("data");
@@ -2557,7 +2945,7 @@ async function main(): Promise<void> {
2557
2945
  // Mirrors opReservedFlags() in the generator: API parameters never use these
2558
2946
  // names (colliding ones are emitted as --<kind>-<name>), so an unknown flag
2559
2947
  // check can be exact.
2560
- const RESERVED_FLAGS = new Set(["data", "credentials", "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)]);
2561
2949
  for (const spec of op.params) {
2562
2950
  if (spec.kind === "path") continue;
2563
2951
  const raw = parsed.flags.get(spec.flag);
@@ -2566,7 +2954,7 @@ async function main(): Promise<void> {
2566
2954
  // every value (each coerced to the element type); loosely typed (json)
2567
2955
  // params become an array of the parsed values.
2568
2956
  const all = parsed.repeated.get(spec.flag);
2569
- values[spec.name] = spec.type === "array"
2957
+ values[spec.name] = spec.type === "array" || (spec.type === "file" && spec.multiple)
2570
2958
  ? coerce(spec, raw, all)
2571
2959
  : all !== undefined && spec.type === "json"
2572
2960
  ? all.map((v) => coerce(spec, v))
@@ -2575,12 +2963,39 @@ async function main(): Promise<void> {
2575
2963
  for (const key of parsed.flags.keys()) {
2576
2964
  if (RESERVED_FLAGS.has(key)) continue;
2577
2965
  if (key === "file" && op.bodyKind === "binary") continue;
2966
+ if (key === "output" && op.rawResponse) continue;
2578
2967
  if (!op.params.some((p) => p.flag === key)) {
2579
2968
  const suggestion = didYouMean(key, [...op.params.filter((p) => p.kind !== "path").map((p) => p.flag), ...RESERVED_FLAGS]);
2580
2969
  fail(2, "Unknown flag --" + key + "." + (suggestion ? " Did you mean --" + suggestion + "?" : ""));
2581
2970
  }
2582
2971
  }
2583
2972
 
2973
+ // Object and array values (a GraphQL input, a JSON flag, --data fields)
2974
+ // get the checks the MCP server applies: nested types, enums, required
2975
+ // properties, patterns and a closed object's unknown keys, all at once.
2976
+ const argumentSchemas = (op.inputSchema.properties ?? {}) as Record<string, Record<string, unknown>>;
2977
+ const nestedIssues: ArgumentIssue[] = [];
2978
+ const checkNested = (name: string, value: unknown): unknown =>
2979
+ value !== null && typeof value === "object" && !(value instanceof Blob) && argumentSchemas[name]
2980
+ ? checkValue(value, argumentSchemas[name]!, name, nestedIssues) : value;
2981
+ for (const spec of op.params) {
2982
+ if (spec.kind !== "path" && spec.type !== "file" && values[spec.name] !== undefined) values[spec.name] = checkNested(spec.name, values[spec.name]);
2983
+ }
2984
+ if (op.bodyStyle === "fields" && dataBody !== null && typeof dataBody === "object" && !Array.isArray(dataBody) && !(dataBody instanceof Blob)) {
2985
+ const body = dataBody as Record<string, unknown>;
2986
+ for (const name of Object.keys(body)) {
2987
+ if (op.params.some((p) => p.kind === "body" && p.name === name && p.type !== "file")) body[name] = checkNested(name, body[name]);
2988
+ }
2989
+ }
2990
+ if (nestedIssues.length > 0) {
2991
+ failWith({
2992
+ code: nestedIssues.every((issue) => issue.code === "MISSING_ARGUMENT") ? "MISSING_ARGUMENT" : "INVALID_USAGE",
2993
+ message: nestedIssues.length + (nestedIssues.length === 1 ? " problem" : " problems") + " in the arguments; nothing was sent: " + nestedIssues.map((issue) => issue.message).join("; "),
2994
+ detail: { issues: nestedIssues },
2995
+ nextSteps: ["Fix the values listed in detail.issues and run again.", "Run '" + USAGE_HINT + "' for each argument's type."],
2996
+ });
2997
+ }
2998
+
2584
2999
  const missing = missingRequired(op, values).filter((name) =>
2585
3000
  !(op.bodyStyle === "fields" && dataBody !== undefined && typeof dataBody === "object" && dataBody !== null && name in (dataBody as object)),
2586
3001
  );
@@ -2600,6 +3015,7 @@ async function main(): Promise<void> {
2600
3015
  if (typeof fieldsRaw === "string") {
2601
3016
  FIELDS = fieldsRaw.split(",").map((f) => f.trim()).filter((f) => f !== "").map((f) => f.split("."));
2602
3017
  if (FIELDS.length === 0) fail(2, "--fields expects at least one field path");
3018
+ FIELDS_AFTER_WRITE = op.safety !== "read";
2603
3019
  }
2604
3020
 
2605
3021
  // --out <dir> materializes a file-shaped response (see cli-agent.ts bundleProperty).
@@ -2611,13 +3027,54 @@ async function main(): Promise<void> {
2611
3027
  }
2612
3028
 
2613
3029
  validateParameters(op, values, parsed.flags);
3030
+ // Raw bytes on a terminal are unreadable and can garble it: ask for a
3031
+ // destination before the request runs (it may be billed, like speech).
3032
+ const outputFlag = op.rawResponse ? parsed.flags.get("output") : undefined;
3033
+ if (outputFlag === true) fail(2, "--output expects a file path, or - for stdout.");
3034
+ if (op.rawResponse === "binary" && outputFlag === undefined && process.stdout.isTTY) {
3035
+ fail(2, op.command.join(" ") + " returns binary data. Pass --output <file>, or redirect stdout to a file.");
3036
+ }
3037
+ // --all on a list the generator could not page would print one page and
3038
+ // exit 0, which reads as "that is everything".
3039
+ if (!op.paginated && parsed.flags.get("all") === true) {
3040
+ fail(2, op.command.join(" ") + " does not paginate, so --all has nothing to walk. Run it without --all; it returns the whole response.");
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
+ }
2614
3054
  const client = await makeClient(parsed.flags, op);
3055
+ for (const spec of pathSpecs) {
3056
+ if (!spec.credential || values[spec.name] !== undefined) continue;
3057
+ const username = credentialUsername(spec.credential.scheme);
3058
+ if (username === undefined) {
3059
+ fail(2, "Missing required: <" + spec.name + ">. It defaults to the Basic-auth username (" + spec.credential.env + "), and none is configured.", undefined,
3060
+ ["Pass " + spec.name + " as an argument: " + usageLine(op) + ".", "Or set " + spec.credential.env + (BASIC ? " and " + BASIC.envPass : "") + ", or run '" + BIN + " login'."]);
3061
+ }
3062
+ values[spec.name] = username;
3063
+ }
2615
3064
 
2616
3065
  // Destructive commands need --force. A person gets asked; an agent gets
2617
3066
  // an action_required envelope with the exact command to run, so nothing
2618
3067
  // is deleted on a guess.
2619
- if (op.safety === "destructive" && !assumeYes(parsed)) {
2620
- const rerun = BIN + " " + process.argv.slice(2).map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ") + " --force";
3068
+ if (op.safety === "destructive" && !assumeYes(parsed) && !DRY_RUN) {
3069
+ // Credential flags are replaced by placeholders: the rerun is shown to
3070
+ // agents and logged, so it must never repeat a key.
3071
+ const secretFlags = new Set([...AUTH_SCALARS.map((a) => "--" + a.flag), "--header"]);
3072
+ const rerun = BIN + " " + process.argv.slice(2).map((a, i, all) => {
3073
+ const eq = a.indexOf("=");
3074
+ if (a.startsWith("--") && eq > 0 && secretFlags.has(a.slice(0, eq))) return a.slice(0, eq) + "=<" + a.slice(2, eq) + ">";
3075
+ if (i > 0 && secretFlags.has(all[i - 1]!)) return "<" + all[i - 1]!.slice(2) + ">";
3076
+ return /\s/.test(a) ? JSON.stringify(a) : a;
3077
+ }).join(" ") + " --force";
2621
3078
  if (nonInteractive(parsed) || !process.stdin.isTTY) {
2622
3079
  failWith({
2623
3080
  status: "action_required",
@@ -2634,20 +3091,30 @@ async function main(): Promise<void> {
2634
3091
  const selectValue = typeof parsed.flags.get("select") === "string" ? (parsed.flags.get("select") as string) : undefined;
2635
3092
  const args = buildArgs(op, values, dataBody, selectValue);
2636
3093
  const target = (client as unknown as Record<string, Record<string, (...a: unknown[]) => unknown>>)[op.resource]!;
2637
- const callResult = target[op.method]!(...args);
3094
+ // --stream true on an operation with a streaming twin prints its events
3095
+ // as NDJSON as they arrive, instead of failing on the event stream.
3096
+ const streaming = op.streamMethod !== undefined && values[op.streamMethod.flag] === op.streamMethod.value;
3097
+ const callResult = target[streaming ? op.streamMethod!.method : op.method]!(...args);
3098
+ if (DRY_RUN) await printDryRun(parsed, callResult);
2638
3099
 
2639
3100
  if (op.paginated && parsed.flags.get("all") === true) {
3101
+ const fieldsCheck = streamFieldsCheck((listSchemaOf(op.outputSchema, "items") as { items?: unknown } | undefined)?.items);
2640
3102
  try {
2641
3103
  for await (const item of callResult as AsyncIterable<unknown>) {
2642
- process.stdout.write(JSON.stringify(project(item)) + "\n");
3104
+ process.stdout.write(JSON.stringify(fieldsCheck.item(item)) + "\n");
2643
3105
  }
3106
+ fieldsCheck.finish();
2644
3107
  await flushExit(0);
2645
3108
  } catch (e) {
3109
+ if (isNotModified(e)) await printNotModified(e);
2646
3110
  failApi(e, LAST_CLIENT_HAD_CREDENTIAL);
2647
3111
  }
2648
3112
  }
2649
3113
 
2650
3114
  let result = await asApiResult(callResult as Promise<unknown>);
3115
+ // 304 Not Modified: the conditional request matched, so there is no
3116
+ // body. Say so, with the ETag to send next time.
3117
+ if (!result.ok && isNotModified(result.error)) await printNotModified(result.error);
2651
3118
  if (result.ok && op.httpMethod === "POST" && op.path === "/projects/{project_id}/generate") {
2652
3119
  const batch = result.data as { data: Array<{ id: string }> };
2653
3120
  const generations = (client as unknown as { generations: { wait(id: string): Promise<unknown> } }).generations;
@@ -2660,15 +3127,33 @@ async function main(): Promise<void> {
2660
3127
  result = { ...result, data: { ...batch, data: completed } };
2661
3128
  }
2662
3129
  if (result.ok) {
2663
- if (op.sse) {
3130
+ if (op.sse || streaming) {
2664
3131
  // Server-sent events as NDJSON, one line per event, until the stream ends.
3132
+ const fieldsCheck = streamFieldsCheck(undefined);
2665
3133
  try {
2666
3134
  for await (const event of result.data as AsyncIterable<{ event?: string; id?: string; data: string }>) {
2667
- process.stdout.write(JSON.stringify(project(event)) + "\n");
3135
+ process.stdout.write(JSON.stringify(fieldsCheck.item(event)) + "\n");
2668
3136
  }
2669
3137
  } catch (e) {
2670
3138
  failApi(e, LAST_CLIENT_HAD_CREDENTIAL);
2671
3139
  }
3140
+ fieldsCheck.finish();
3141
+ await flushExit(0);
3142
+ }
3143
+ // A non-JSON body (audio, a file, CSV): raw bytes to stdout, or to the
3144
+ // --output file with a JSON note of what was written. Never "{}".
3145
+ if (op.rawResponse || result.data instanceof Blob) {
3146
+ const data = result.data;
3147
+ const bytes = data instanceof Blob
3148
+ ? new Uint8Array(await data.arrayBuffer())
3149
+ : new TextEncoder().encode(typeof data === "string" ? data : JSON.stringify(data ?? ""));
3150
+ if (typeof outputFlag === "string" && outputFlag !== "-") {
3151
+ try { writeFileSync(outputFlag, bytes); } catch (e) { fail(1, "--output: cannot write " + outputFlag + " (" + (e as Error).message + ")"); }
3152
+ const mediaType = data instanceof Blob && data.type ? data.type : op.rawResponse === "text" ? "text/plain" : "application/octet-stream";
3153
+ out({ saved_to: resolvePath(outputFlag), bytes: bytes.length, media_type: mediaType });
3154
+ } else {
3155
+ process.stdout.write(bytes);
3156
+ }
2672
3157
  await flushExit(0);
2673
3158
  }
2674
3159
  // A claim-shaped response (claim.url): say where to claim it, and leave
@@ -2686,17 +3171,17 @@ async function main(): Promise<void> {
2686
3171
  // agent never has to reconstruct the cursor flag.
2687
3172
  const page = result.data as { items: unknown[]; hasNextPage(): boolean; nextPageParams(): Record<string, unknown> | null; response: { requestId?: string } };
2688
3173
  const next = page.nextPageParams();
2689
- out({
2690
- items: project(page.items),
3174
+ printResult({
3175
+ items: project(page.items, true, listSchemaOf(op.outputSchema, "items")),
2691
3176
  hasMore: next !== null,
2692
3177
  ...(next !== null ? { nextPage: next, nextCommand: nextCommandFor(op, pathValues, next) } : {}),
2693
3178
  ...(page.response.requestId ? { request_id: page.response.requestId } : {}),
2694
- });
3179
+ }, op.command[0]);
2695
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])) {
2696
3181
  // Batch-style collection envelopes ({data: [...]}) use item-relative
2697
3182
  // fields, matching paginated results, while retaining envelope metadata.
2698
3183
  const data = result.data as Record<string, unknown>;
2699
- out({ ...data, [collectionField]: project(data[collectionField]) });
3184
+ printResult({ ...data, [collectionField]: project(data[collectionField], true, listSchemaOf(op.outputSchema, collectionField)) }, op.command[0], collectionField);
2700
3185
  } else if (outDir !== undefined && bundleField !== null) {
2701
3186
  // --out: a file-shaped response (an array of {path, content}) lands
2702
3187
  // on disk; stdout gets the rest of the response plus a summary.
@@ -2704,9 +3189,9 @@ async function main(): Promise<void> {
2704
3189
  const files = (data[bundleField] as { path: string; content: string }[] | undefined) ?? [];
2705
3190
  const written = writeBundle(outDir, files);
2706
3191
  const { [bundleField]: _omitted, ...rest } = data;
2707
- out({ ...(project(rest) as Record<string, unknown>), out: written });
3192
+ printResult({ ...(project(rest, false, op.outputSchema) as Record<string, unknown>), out: written }, op.command[0]);
2708
3193
  } else {
2709
- out(project(result.data ?? { ok: true }));
3194
+ printResult(result.data === undefined || result.data === null ? { ok: true } : project(result.data, Array.isArray(result.data), op.outputSchema), op.command[0], collectionField);
2710
3195
  }
2711
3196
  await flushExit(0);
2712
3197
  }
@@ -2724,6 +3209,8 @@ function errorMessage(error: unknown): string {
2724
3209
  main().catch((e) => {
2725
3210
  if (e instanceof ExitPending) return;
2726
3211
  try {
3212
+ const auth = classifyAuthFailure(e, authFailureContext());
3213
+ if (auth) failWith(auth);
2727
3214
  fail(1, errorMessage(e));
2728
3215
  } catch {
2729
3216
  // exit already scheduled