@m6d/cortex-cli 0.12.1 → 0.14.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 (76) hide show
  1. package/README.md +193 -128
  2. package/package.json +16 -5
  3. package/src/cli.ts +19 -12
  4. package/src/commands/apply.ts +186 -0
  5. package/src/commands/{cc.ts → management.ts} +173 -112
  6. package/src/commands/new.ts +83 -84
  7. package/src/commands/setup.ts +610 -0
  8. package/src/contracts/charts/chart-spec.ts +1 -1
  9. package/src/contracts/client-tools/index.ts +3 -3
  10. package/src/contracts/{runtime → datasets}/datasets.ts +7 -42
  11. package/src/contracts/datasets/index.ts +3 -0
  12. package/src/contracts/egress-policy/index.ts +16 -0
  13. package/src/contracts/{egress → egress-policy}/scrub.ts +34 -5
  14. package/src/contracts/{egress → egress-policy}/url.ts +22 -9
  15. package/src/contracts/errors/index.ts +38 -0
  16. package/src/contracts/json/index.ts +5 -0
  17. package/src/contracts/manifest/index.ts +973 -0
  18. package/src/contracts/manifest-file/index.ts +16 -0
  19. package/src/contracts/markdown/index.ts +1 -1
  20. package/src/contracts/pattern/index.ts +276 -0
  21. package/src/contracts/server/index.ts +349 -0
  22. package/src/contracts/server/reserved-names.fixtures.json +18 -0
  23. package/src/contracts/triggers/index.ts +112 -0
  24. package/src/contracts/wire/index.ts +67 -33
  25. package/src/{cc → management}/client.ts +33 -7
  26. package/src/{cc → management}/flags.ts +2 -1
  27. package/src/{cc → management}/profiles.ts +10 -8
  28. package/src/scaffold/files.ts +448 -284
  29. package/src/scaffold/ports.ts +12 -24
  30. package/src/scaffold/templates/cspell.json.txt +41 -0
  31. package/src/scaffold/templates/datasets.ts.txt +52 -0
  32. package/src/scaffold/templates/dockerignore.txt +6 -0
  33. package/src/scaffold/templates/fallowrc.jsonc.txt +8 -12
  34. package/src/scaffold/templates/gitignore.txt +1 -1
  35. package/src/scaffold/templates/server.ts.txt +40 -0
  36. package/src/scaffold/templates/setup.ts.txt +44 -0
  37. package/src/ui/prompts.ts +24 -38
  38. package/src/ui/report.ts +5 -15
  39. package/tsconfig.json +2 -3
  40. package/src/commands/graph.ts +0 -228
  41. package/src/commands/swagger.ts +0 -247
  42. package/src/config/load.ts +0 -122
  43. package/src/config/validate.ts +0 -46
  44. package/src/contracts/credentials/index.ts +0 -183
  45. package/src/contracts/egress/index.ts +0 -120
  46. package/src/contracts/graph/clients/embed.ts +0 -62
  47. package/src/contracts/graph/clients/neo4j.ts +0 -85
  48. package/src/contracts/graph/helpers.ts +0 -55
  49. package/src/contracts/graph/index.ts +0 -30
  50. package/src/contracts/graph/schema.ts +0 -65
  51. package/src/contracts/graph/types.ts +0 -125
  52. package/src/contracts/rich-text/index.ts +0 -220
  53. package/src/contracts/runtime/api.ts +0 -345
  54. package/src/contracts/runtime/auth.ts +0 -41
  55. package/src/contracts/runtime/grammar.ts +0 -169
  56. package/src/contracts/runtime/index.ts +0 -15
  57. package/src/contracts/runtime/models.ts +0 -50
  58. package/src/contracts/runtime/signature.ts +0 -255
  59. package/src/contracts/runtime/sync.ts +0 -196
  60. package/src/contracts/runtime/triggers.ts +0 -267
  61. package/src/graph/expand-domains.ts +0 -286
  62. package/src/graph/generate-cypher.ts +0 -201
  63. package/src/graph/seed.ts +0 -199
  64. package/src/graph/validate.ts +0 -78
  65. package/src/scaffold/features.ts +0 -190
  66. package/src/scaffold/templates/domains-README.md.txt +0 -16
  67. package/src/scaffold/templates/index.ts.txt +0 -18
  68. package/src/swagger/extract-endpoints.ts +0 -269
  69. package/src/swagger/generated-block.ts +0 -74
  70. package/src/swagger/openapi-schema.ts +0 -168
  71. package/src/swagger/parse-response.ts +0 -88
  72. package/src/swagger/types.ts +0 -25
  73. /package/src/contracts/{runtime → datasets}/dataset-sql-guide.ts +0 -0
  74. /package/src/contracts/{runtime → datasets}/dataset-sql.ts +0 -0
  75. /package/src/contracts/{runtime → datasets}/datasets.fixtures.json +0 -0
  76. /package/src/{cc → management}/output.ts +0 -0
@@ -1,74 +1,74 @@
1
1
  import { readFileSync } from "node:fs";
2
2
 
3
3
  import { type Command, Option } from "@commander-js/extra-typings";
4
- import { canCarrySecret } from "@cortex/contracts/egress";
4
+ import { canCarrySecret } from "@cortex/contracts/egress-policy";
5
5
  import { isRecord } from "@cortex/contracts/json";
6
6
 
7
- import { callManagement, inputSchemaOf, loadOpenApi, ManagementError } from "@/cc/client";
8
- import type { OpenApiDocument, OpenApiOperation } from "@/cc/client";
9
- import { flagsFor, parseAnswer, toOption } from "@/cc/flags";
10
- import type { Flag } from "@/cc/flags";
11
- import { print } from "@/cc/output";
7
+ import {
8
+ callManagement,
9
+ inputSchemaOf,
10
+ loadOpenApi,
11
+ ManagementError,
12
+ UnreachableError,
13
+ } from "@/management/client";
14
+ import type { OpenApiDocument, OpenApiOperation } from "@/management/client";
15
+ import { flagsFor, parseAnswer, toOption } from "@/management/flags";
16
+ import type { Flag } from "@/management/flags";
17
+ import { print } from "@/management/output";
12
18
  import {
13
19
  ProfileFileError,
14
20
  profilesPath,
15
21
  removeProfile,
16
22
  resolveProfile,
17
23
  saveProfile,
18
- } from "@/cc/profiles";
24
+ } from "@/management/profiles";
19
25
  import * as prompts from "@/ui/prompts";
20
26
  import * as report from "@/ui/report";
21
27
 
22
- const PROFILE_COMMANDS = new Set(["login", "logout", "status"]);
23
28
  const MAX_PAGES = 1000;
24
29
 
30
+ /** The root command; `--profile` names the saved Cortex every command here talks to. */
31
+ type Program = Command<[], { profile?: string }>;
32
+
25
33
  /**
26
- * `cortex cc <router> <procedure> [id] [flags]`: the Control Center's
27
- * management API as commands. The tree is built from the CC's own OpenAPI
28
- * document on each run, so it always matches the deployment it points at
29
- * and a new procedure needs no CLI work. Profiles hold the URL and key.
34
+ * `cortex <router> <procedure> [id] [flags]`: Cortex's management API as
35
+ * commands, beside `login`, `logout` and `status`. The tree is built from
36
+ * Cortex's own OpenAPI document on each run, so it always matches the
37
+ * deployment it points at and a new procedure needs no CLI work. Profiles
38
+ * hold the URL and key. Registered after every other command, since any
39
+ * name argv gives that none of them claims is a router's.
30
40
  */
31
- export function registerCc(program: Command, argv: string[]) {
32
- const cc = program
33
- .command("cc")
34
- .helpGroup("Control Center:")
35
- .summary("Manage a Control Center without its console")
36
- .description(
37
- "Manage a Control Center without its console.\n\nCommands come from the CC's management API: `cortex cc <router> <procedure>`.\nLog in once with a management key; profiles live in ~/.cortex/cc.json.",
38
- )
39
- .option("-p, --profile <name>", "Which saved Control Center to talk to")
40
- .addOption(
41
- new Option("-o, --output <format>", "Output format")
42
- .choices(["table", "json"])
43
- .default("table"),
44
- )
45
- .helpCommand(false);
46
-
47
- cc.command("login")
48
- .description("Save a Control Center URL and management key as a profile")
41
+ export function registerManagement(program: Program, argv: string[]) {
42
+ program
43
+ .command("login")
44
+ .helpGroup("Cortex:")
45
+ .description("Save a Cortex URL and key as a profile")
49
46
  .option("--url <url>")
50
- .option("--name <name>", "Profile name", "default")
51
47
  .action(async function (options) {
52
- await login(options);
48
+ await login(options.url, program.opts().profile ?? "default");
53
49
  });
54
50
 
55
- cc.command("logout")
51
+ program
52
+ .command("logout")
53
+ .helpGroup("Cortex:")
56
54
  .description("Forget a profile")
57
- .option("--name <name>", "Profile name", "default")
58
- .action(function (options) {
59
- report.header("cortex cc logout");
55
+ .action(function () {
56
+ const name = program.opts().profile ?? "default";
57
+ report.header("cortex logout");
60
58
  report.footer(
61
- withProfileFile(() => removeProfile(options.name))
62
- ? `removed ${options.name}`
63
- : `no profile ${options.name}`,
59
+ withProfileFile(() => removeProfile(name))
60
+ ? `removed ${name}`
61
+ : `no profile ${name}`,
64
62
  );
65
63
  });
66
64
 
67
- cc.command("status")
68
- .description("Which Control Center commands run against, and with which key")
65
+ program
66
+ .command("status")
67
+ .helpGroup("Cortex:")
68
+ .description("Show the Cortex URL and key commands use")
69
69
  .action(async function () {
70
- const profile = requireProfile(cc.opts().profile);
71
- report.header("cortex cc status");
70
+ const profile = requireProfile(program.opts().profile);
71
+ report.header("cortex status");
72
72
  report.input("Profile", profile.name);
73
73
  report.input("URL", profile.url);
74
74
  const me = await whoami(profile);
@@ -76,56 +76,56 @@ export function registerCc(program: Command, argv: string[]) {
76
76
  });
77
77
 
78
78
  // The resource tree needs the document, which needs a profile, so it is
79
- // built only when argv actually names a resource command: the first
80
- // operand after `cc`. Commander parses the group's own options here, so
81
- // every spelling it accepts (`-p x`, `-px`, `--profile=x`) names the same
82
- // profile the commands then run against. Root options may precede `cc`.
83
- const at = argv.findIndex((word) => !word.startsWith("-"));
84
- if (at === -1 || argv[at] !== "cc") return;
85
- const { operands } = cc.parseOptions(argv.slice(at + 1));
86
- const first = operands[0];
87
- if (first === undefined || PROFILE_COMMANDS.has(first)) return;
88
- return buildResourceCommands(cc, requireProfile(cc.opts().profile));
79
+ // built only when argv's first operand names no command registered so
80
+ // far. Commander parses the root's own options here, so every spelling
81
+ // it accepts (`-p x`, `-px`, `--profile=x`) names the same profile the
82
+ // commands then run against.
83
+ const first = program.parseOptions(argv).operands[0];
84
+ if (first === undefined || program.commands.some((command) => command.name() === first)) {
85
+ return;
86
+ }
87
+ return buildResourceCommands(program, requireProfile(program.opts().profile));
89
88
  }
90
89
 
91
90
  /**
92
91
  * The key is never a flag (argv is visible to other processes and lands in
93
- * shell history): it is prompted for masked, or read from `CORTEX_CC_KEY`
92
+ * shell history): it is prompted for masked, or read from `CORTEX_KEY`
94
93
  * when there is no terminal to ask.
95
94
  */
96
- async function login(options: { url?: string; name: string }) {
95
+ async function login(given: string | undefined, name: string) {
97
96
  const url = requireTls(
98
- (
99
- options.url ?? (await prompts.askValue("Control Center URL", "https://cc.example.com"))
100
- ).replace(/\/+$/, ""),
97
+ (given ?? (await prompts.askValue("Cortex URL", "https://cortex.example.com"))).replace(
98
+ /\/+$/,
99
+ "",
100
+ ),
101
101
  );
102
102
  const key = process.stdin.isTTY
103
103
  ? await prompts.askSecret("Management key")
104
- : (process.env["CORTEX_CC_KEY"] ??
104
+ : (process.env["CORTEX_KEY"] ??
105
105
  report.fail({
106
106
  what: "No terminal to ask for the management key",
107
- blocks: ["Set CORTEX_CC_KEY in the environment and run login again."],
107
+ blocks: ["Set CORTEX_KEY in the environment and run login again."],
108
108
  }));
109
109
  const me = await whoami({ url, key });
110
110
  if (!me) {
111
111
  report.fail({
112
- what: "The Control Center rejected that key",
112
+ what: "Cortex rejected that key",
113
113
  blocks: [
114
114
  "Mint one on your account page, or set BOOTSTRAP_MANAGEMENT_KEY on a headless deployment.",
115
115
  ],
116
116
  });
117
117
  }
118
- withProfileFile(() => saveProfile(options.name, { url, key }));
119
- report.header("cortex cc login");
120
- report.input("Profile", options.name);
118
+ withProfileFile(() => saveProfile(name, { url, key }));
119
+ report.header("cortex login");
120
+ report.input("Profile", name);
121
121
  report.input("URL", url);
122
122
  report.input("Key", me);
123
123
  report.footer(`saved to ${profilesPath()}`);
124
124
  }
125
125
 
126
126
  /**
127
- * The key the Control Center recognized the bearer as, or null when it does
128
- * not authenticate. A Control Center that cannot be reached, or answers with
127
+ * The key Cortex recognized the bearer as, or null when it does not
128
+ * authenticate. A Cortex that cannot be reached, or answers with
129
129
  * anything but 200/401, is the run's outcome, not a CLI bug.
130
130
  */
131
131
  async function whoami(profile: { url: string; key: string }) {
@@ -145,7 +145,7 @@ async function whoami(profile: { url: string; key: string }) {
145
145
  report.fail({
146
146
  what: `Could not reach ${profile.url}`,
147
147
  error,
148
- blocks: ["Check the URL and that the Control Center is up."],
148
+ blocks: ["Check the URL and that Cortex is up."],
149
149
  });
150
150
  }
151
151
  }
@@ -158,7 +158,7 @@ function withProfileFile<T>(operation: () => T) {
158
158
  if (error instanceof ProfileFileError) {
159
159
  report.fail({
160
160
  what: error.message,
161
- blocks: ["Fix or delete the file, then log in again:", " cortex cc login"],
161
+ blocks: ["Fix or delete the file, then log in again:", " cortex login"],
162
162
  });
163
163
  }
164
164
  throw error;
@@ -176,15 +176,17 @@ function requireTls(url: string) {
176
176
  });
177
177
  }
178
178
 
179
- function requireProfile(name: string | undefined) {
179
+ /** The named profile, or the current one; a missing or plain-http one ends the run with how to fix it. */
180
+ export function requireProfile(name: string | undefined) {
180
181
  const profile = withProfileFile(() => resolveProfile(name));
181
182
  if (!profile) {
182
183
  report.fail({
183
- what: "No Control Center to talk to",
184
+ what: "No Cortex to talk to",
184
185
  blocks: [
185
186
  "Log in once:",
186
- " cortex cc login",
187
- "or set CORTEX_CC_URL and CORTEX_CC_KEY in the environment.",
187
+ " cortex login",
188
+ "or set CORTEX_URL and CORTEX_KEY in the environment.",
189
+ "`cortex --help` lists the commands that need neither.",
188
190
  ],
189
191
  });
190
192
  }
@@ -194,11 +196,9 @@ function requireProfile(name: string | undefined) {
194
196
 
195
197
  type ResolvedProfile = NonNullable<ReturnType<typeof resolveProfile>>;
196
198
 
197
- type CcGroup = Command<[], { profile?: string; output: "table" | "json" }>;
198
-
199
199
  type Operation = { path: string; method: "get" | "post"; operation: OpenApiOperation };
200
200
 
201
- async function buildResourceCommands(cc: CcGroup, profile: ResolvedProfile) {
201
+ async function buildResourceCommands(program: Program, profile: ResolvedProfile) {
202
202
  const loaded = await loadOpenApi(profile.url).catch((error: unknown) =>
203
203
  report.fail({
204
204
  what: `Could not read the management API at ${profile.url}`,
@@ -220,9 +220,12 @@ async function buildResourceCommands(cc: CcGroup, profile: ResolvedProfile) {
220
220
  [...byRouter.values()].flat().map((o) => [o.operation.operationId, o]),
221
221
  );
222
222
  for (const [router, operations] of byRouter) {
223
- const group = cc.command(router).description(`${router} procedures`).helpCommand(false);
223
+ const group = program
224
+ .command(router)
225
+ .description(`${router} procedures`)
226
+ .helpCommand(false);
224
227
  for (const operation of operations) {
225
- registerOperation({ cc, group, router, operation, byProcedure, document, profile });
228
+ registerOperation({ group, router, operation, byProcedure, document, profile });
226
229
  }
227
230
  }
228
231
  }
@@ -248,7 +251,6 @@ function groupByRouter(document: OpenApiDocument) {
248
251
  * on writes to a publishable router.
249
252
  */
250
253
  function registerOperation(scope: {
251
- cc: CcGroup;
252
254
  group: Command;
253
255
  router: string;
254
256
  operation: Operation;
@@ -256,7 +258,7 @@ function registerOperation(scope: {
256
258
  document: OpenApiDocument;
257
259
  profile: ResolvedProfile;
258
260
  }) {
259
- const { cc, group, router, byProcedure, document, profile } = scope;
261
+ const { group, router, byProcedure, document, profile } = scope;
260
262
  const { path, method, operation } = scope.operation;
261
263
  const name = operation.operationId.split(".")[1] ?? path;
262
264
  const input = inputSchemaOf(operation);
@@ -266,7 +268,7 @@ function registerOperation(scope: {
266
268
  // (`x-publish-procedure`); a create that leaves the publication content
267
269
  // blank carries none, since publishing it would only fail. Fields the
268
270
  // document marks live (`x-live-fields`) are reported as such after the
269
- // write instead of as part of the draft.
271
+ // write, apart from the draft.
270
272
  const publishWith = operation["x-publish-procedure"];
271
273
  const publisher = publishWith === undefined ? undefined : byProcedure.get(publishWith);
272
274
  const canPublish = publisher !== undefined;
@@ -279,6 +281,7 @@ function registerOperation(scope: {
279
281
 
280
282
  command.action(async function (...args) {
281
283
  const options = command.opts() as Record<string, unknown> & {
284
+ output: "table" | "json";
282
285
  json?: string;
283
286
  all?: boolean;
284
287
  publish?: boolean;
@@ -286,14 +289,13 @@ function registerOperation(scope: {
286
289
  // Commander passes the options object first when the command declares
287
290
  // no argument, so only a string is the `[id]` positional.
288
291
  const positional = typeof args[0] === "string" ? args[0] : undefined;
289
- const { json, all, publish, ...given } = options;
292
+ const { output: format, json, all, publish, ...given } = options;
290
293
  const body = await collectInput(flags, given, json, positional, name);
291
- const format = cc.opts().output;
292
294
  // `-o json` promises stdout holds only the result; prose moves to stderr.
293
295
  const note = format === "json" ? report.aside : report.note;
294
296
  if (format === "table") {
295
- report.header(`cortex cc ${router} ${name}`);
296
- report.input("Control Center", profile.url);
297
+ report.header(`cortex ${router} ${name}`);
298
+ report.input("Cortex", profile.url);
297
299
  }
298
300
  const result = await callOrFail(profile, () =>
299
301
  all
@@ -320,9 +322,26 @@ function declareCommand(
320
322
  flags: Flag[],
321
323
  shape: { paginated: boolean; canPublish: boolean },
322
324
  ) {
323
- const command = group.command(name).description(summary);
325
+ const command = group
326
+ .command(name)
327
+ .description(summary)
328
+ .addOption(
329
+ new Option("-o, --output <format>", "Output format")
330
+ .choices(["table", "json"])
331
+ .default("table"),
332
+ );
333
+ // Optional to Commander even when the input requires it: `--json` may hold
334
+ // it, and collecting the input asks for, or names, a required field left
335
+ // out. So the help says which it is.
324
336
  const idFlag = flags.find((flag) => flag.name === "id");
325
- if (idFlag) command.argument(idFlag.required ? "<id>" : "[id]");
337
+ if (idFlag) {
338
+ command.argument(
339
+ "[id]",
340
+ idFlag.required
341
+ ? "Required, here or in --json, or asked for on a terminal"
342
+ : "Optional",
343
+ );
344
+ }
326
345
  // A sensitive field never becomes a flag: argv is visible to every
327
346
  // process on the machine and lands in shell history. It is prompted for
328
347
  // masked on a terminal, or supplied through `--json -` from stdin.
@@ -339,7 +358,7 @@ function declareCommand(
339
358
 
340
359
  /**
341
360
  * A write to a publishable router is a draft: publish it now, or say how to.
342
- * Fields the server reads live are already in effect and are named as such,
361
+ * Fields Cortex reads live are already in effect and are named as such,
343
362
  * since "draft" would understate what just happened.
344
363
  */
345
364
  async function publishAfterWrite(
@@ -351,11 +370,11 @@ async function publishAfterWrite(
351
370
  ) {
352
371
  const id = (result as { id?: string } | null)?.id;
353
372
  if (!id) return;
354
- const command = `cortex cc ${publisher.operation.operationId.replace(".", " ")} ${id}`;
373
+ const command = `cortex ${publisher.operation.operationId.replace(".", " ")} ${id}`;
355
374
  const live = options.liveFields.length > 0;
356
375
  if (live) {
357
376
  options.note(
358
- `${options.liveFields.join(", ")} ${options.liveFields.length === 1 ? "is" : "are"} live: bound on the server's next config revalidation, no publish needed.`,
377
+ `${options.liveFields.join(", ")} ${options.liveFields.length === 1 ? "is" : "are"} live: agents use them from their next turn, no publish needed.`,
359
378
  );
360
379
  }
361
380
  if (publish) {
@@ -370,32 +389,70 @@ async function publishAfterWrite(
370
389
  }
371
390
  }
372
391
 
373
- /**
374
- * A management refusal, or a Control Center that cannot be reached, is the
375
- * run's outcome, in the error grammar; anything else is a bug.
376
- */
377
- async function callOrFail<T>(profile: ResolvedProfile, call: () => Promise<T>) {
392
+ /** `call`'s result, or the run ended by `failCall`. */
393
+ export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promise<T>) {
378
394
  try {
379
395
  return await call();
380
396
  } catch (error) {
381
- if (error instanceof ManagementError) {
382
- report.fail({ what: `${error.status} ${error.code}: ${error.message}` });
383
- }
384
- if (error instanceof TypeError) {
385
- report.fail({
386
- what: `Could not reach ${profile.url}`,
387
- error,
388
- blocks: ["Check the URL and that the Control Center is up."],
389
- });
390
- }
391
- throw error;
397
+ return failCall(profile, error);
392
398
  }
393
399
  }
394
400
 
401
+ /**
402
+ * A management refusal, or a Cortex that cannot be reached, is the
403
+ * run's outcome, in the error grammar, with whatever the refusal names;
404
+ * anything else is a bug, and rethrown.
405
+ */
406
+ export function failCall(profile: { url: string }, error: unknown): never {
407
+ if (error instanceof ManagementError) {
408
+ report.fail({
409
+ what: `${error.status} ${error.code}: ${error.message}`,
410
+ blocks: refusalBlocks(error.detail ?? {}),
411
+ });
412
+ }
413
+ if (error instanceof UnreachableError) {
414
+ report.fail({
415
+ what: `Could not reach ${profile.url}`,
416
+ error,
417
+ blocks: ["Check the URL and that Cortex is up."],
418
+ });
419
+ }
420
+ throw error;
421
+ }
422
+
423
+ /**
424
+ * What a refusal names, a block each: the manifest entry it is about (its
425
+ * kind and name), each issue at its path in that entry, then every other
426
+ * field (who holds a name, what is missing) as `field: value`.
427
+ */
428
+ function refusalBlocks({ error: _literal, kind, name, issues, ...rest }: Record<string, unknown>) {
429
+ const entry = typeof kind === "string" && typeof name === "string";
430
+ const fields = {
431
+ ...(entry ? {} : { kind, name }),
432
+ ...(Array.isArray(issues) ? {} : { issues }),
433
+ ...rest,
434
+ };
435
+ return [
436
+ entry ? `${kind} ${JSON.stringify(name)}` : "",
437
+ Array.isArray(issues) ? issues.map(issueLine).join("\n") : "",
438
+ Object.entries(fields)
439
+ .filter(([, value]) => value !== undefined)
440
+ .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
441
+ .join("\n"),
442
+ ].filter((block) => block !== "");
443
+ }
444
+
445
+ /** One issue at its path in the entry, or at the entry itself when the path is empty. */
446
+ function issueLine(issue: unknown) {
447
+ if (!isRecord(issue)) return JSON.stringify(issue);
448
+ const path = issue["path"];
449
+ return `${typeof path === "string" && path !== "" ? path : "(entry)"}: ${String(issue["message"])}`;
450
+ }
451
+
395
452
  /**
396
453
  * The body: `--json` first, flags layered over it, the positional as `id`.
397
- * A required field still missing is asked for on a TTY and named as the
398
- * flag to add otherwise.
454
+ * A required field left missing is asked for on a TTY and named as the
455
+ * flag, or the positional, to add otherwise.
399
456
  */
400
457
  async function collectInput(
401
458
  flags: Flag[],
@@ -413,7 +470,11 @@ async function collectInput(
413
470
  const missing = flags.filter((flag) => flag.required && body.get(flag.name) === undefined);
414
471
  if (missing.length > 0 && !process.stdin.isTTY) {
415
472
  const named = (flag: Flag) =>
416
- flag.sensitive ? `${flag.name} (via --json -)` : `--${flag.name}`;
473
+ flag.name === "id"
474
+ ? "<id>"
475
+ : flag.sensitive
476
+ ? `${flag.name} (via --json -)`
477
+ : `--${flag.name}`;
417
478
  report.fail({
418
479
  what: `${verb} needs ${named(missing[0]!)}`,
419
480
  blocks: [
@@ -455,7 +516,7 @@ function readJsonBody(source: string) {
455
516
  if (source === "-" && process.stdin.isTTY) {
456
517
  report.fail({
457
518
  what: "--json - reads the body from stdin, but nothing is piped in",
458
- blocks: ["Pipe a JSON document:", " cat body.json | cortex cc ... --json -"],
519
+ blocks: ["Pipe a JSON document:", " cat body.json | cortex ... --json -"],
459
520
  });
460
521
  }
461
522
  const label = source === "-" ? "stdin" : source;
@@ -477,7 +538,7 @@ function readJsonBody(source: string) {
477
538
  return parsed;
478
539
  }
479
540
 
480
- async function walkPages(
541
+ export async function walkPages(
481
542
  profile: ResolvedProfile,
482
543
  method: "get" | "post",
483
544
  path: string,
@@ -488,8 +549,8 @@ async function walkPages(
488
549
  const items = new Array<unknown>();
489
550
  let count = 0;
490
551
  let filteredCount = 0;
491
- // Bounded: a server that reports more rows than it serves must not spin,
492
- // and a listing the bound cannot cover is an error, never a short result.
552
+ // Bounded: if Cortex reports more rows than it serves, the walk must not
553
+ // spin, and a listing the bound cannot cover is an error, never a short result.
493
554
  for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
494
555
  const page = (await callManagement(profile, method, path, {
495
556
  ...given,