@procedures/mark 0.0.0-stage → 0.2.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 (91) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -0
  3. package/README.md +799 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/args.d.ts +77 -0
  6. package/dist/args.d.ts.map +1 -0
  7. package/dist/args.js +351 -0
  8. package/dist/args.js.map +1 -0
  9. package/dist/ask.d.ts +48 -0
  10. package/dist/ask.d.ts.map +1 -0
  11. package/dist/ask.js +91 -0
  12. package/dist/ask.js.map +1 -0
  13. package/dist/bin.d.ts +9 -0
  14. package/dist/bin.d.ts.map +1 -0
  15. package/dist/bin.js +10 -0
  16. package/dist/bin.js.map +1 -0
  17. package/dist/cli.d.ts +61 -0
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/cli.js +535 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/client-mode.d.ts +21 -0
  22. package/dist/client-mode.d.ts.map +1 -0
  23. package/dist/client-mode.js +75 -0
  24. package/dist/client-mode.js.map +1 -0
  25. package/dist/ecosystem.d.ts +67 -0
  26. package/dist/ecosystem.d.ts.map +1 -0
  27. package/dist/ecosystem.js +212 -0
  28. package/dist/ecosystem.js.map +1 -0
  29. package/dist/failure.d.ts +16 -0
  30. package/dist/failure.d.ts.map +1 -0
  31. package/dist/failure.js +47 -0
  32. package/dist/failure.js.map +1 -0
  33. package/dist/format.d.ts +30 -0
  34. package/dist/format.d.ts.map +1 -0
  35. package/dist/format.js +125 -0
  36. package/dist/format.js.map +1 -0
  37. package/dist/index.d.ts +13 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +13 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/lockfile.d.ts +26 -0
  42. package/dist/lockfile.d.ts.map +1 -0
  43. package/dist/lockfile.js +68 -0
  44. package/dist/lockfile.js.map +1 -0
  45. package/dist/manifest-build.d.ts +7 -0
  46. package/dist/manifest-build.d.ts.map +1 -0
  47. package/dist/manifest-build.js +11 -0
  48. package/dist/manifest-build.js.map +1 -0
  49. package/dist/manifest.d.ts +57 -0
  50. package/dist/manifest.d.ts.map +1 -0
  51. package/dist/manifest.js +140 -0
  52. package/dist/manifest.js.map +1 -0
  53. package/dist/manifest.json +20960 -0
  54. package/dist/parse.d.ts +143 -0
  55. package/dist/parse.d.ts.map +1 -0
  56. package/dist/parse.js +509 -0
  57. package/dist/parse.js.map +1 -0
  58. package/dist/print.d.ts +42 -0
  59. package/dist/print.d.ts.map +1 -0
  60. package/dist/print.js +80 -0
  61. package/dist/print.js.map +1 -0
  62. package/dist/prompts.d.ts +58 -0
  63. package/dist/prompts.d.ts.map +1 -0
  64. package/dist/prompts.js +124 -0
  65. package/dist/prompts.js.map +1 -0
  66. package/dist/repl.d.ts +34 -0
  67. package/dist/repl.d.ts.map +1 -0
  68. package/dist/repl.js +101 -0
  69. package/dist/repl.js.map +1 -0
  70. package/dist/server-mode.d.ts +37 -0
  71. package/dist/server-mode.d.ts.map +1 -0
  72. package/dist/server-mode.js +167 -0
  73. package/dist/server-mode.js.map +1 -0
  74. package/package.json +97 -3
  75. package/src/args.ts +440 -0
  76. package/src/ask.ts +119 -0
  77. package/src/bin.ts +11 -0
  78. package/src/cli.ts +600 -0
  79. package/src/client-mode.ts +90 -0
  80. package/src/ecosystem.ts +260 -0
  81. package/src/failure.ts +47 -0
  82. package/src/format.ts +177 -0
  83. package/src/index.ts +31 -0
  84. package/src/lockfile.ts +85 -0
  85. package/src/manifest-build.ts +12 -0
  86. package/src/manifest.ts +162 -0
  87. package/src/parse.ts +670 -0
  88. package/src/print.ts +108 -0
  89. package/src/prompts.ts +181 -0
  90. package/src/repl.ts +121 -0
  91. package/src/server-mode.ts +205 -0
package/src/parse.ts ADDED
@@ -0,0 +1,670 @@
1
+ /**
2
+ * Schema-based CLI Argument Parser
3
+ *
4
+ * Reads the field types of a procedure's input schema, converts CLI text to those types, and
5
+ * makes the help text. Validation is delegated to the procedure's schema.
6
+ */
7
+
8
+ import { CLI_GLOBAL_FLAGS } from "@procedures/lib/cli-flags";
9
+ import { jsonSchemaOf, type ProcedureSchema } from "@procedures/client";
10
+
11
+ /**
12
+ * CLI metadata that can be attached to procedures via .meta()
13
+ */
14
+ export interface CLIMeta {
15
+ /** Description for help text */
16
+ description?: string;
17
+ /** Field names that are positional args (in order). An array field that is last takes the rest. */
18
+ args?: string[];
19
+ /** Short flag mappings: { fieldName: "f" } */
20
+ shorts?: Record<string, string>;
21
+ /** Output format hint */
22
+ output?: "text" | "json" | "table" | "streaming";
23
+ /** Whether to prompt for missing required fields */
24
+ interactive?: boolean;
25
+ }
26
+
27
+ /**
28
+ * The kinds of field the CLI can convert to. "any" is a field with no usable type: its value is
29
+ * JSON when it parses as JSON, and text otherwise.
30
+ */
31
+ export type FieldKind =
32
+ | "string"
33
+ | "number"
34
+ | "bigint"
35
+ | "boolean"
36
+ | "enum"
37
+ | "literal"
38
+ | "date"
39
+ | "array"
40
+ | "record"
41
+ | "object"
42
+ | "union"
43
+ | "any";
44
+
45
+ /**
46
+ * The type of a schema field, as the CLI sees it
47
+ */
48
+ export interface FieldType {
49
+ kind: FieldKind;
50
+ /** The item type of an array */
51
+ item?: FieldType;
52
+ /** The value type of a record */
53
+ value?: FieldType;
54
+ /** The member types of a union */
55
+ options?: FieldType[];
56
+ /** The values of an enum */
57
+ values?: readonly string[];
58
+ /** The value of a literal */
59
+ literal?: unknown;
60
+ }
61
+
62
+ /**
63
+ * Extracted schema field info for help generation
64
+ */
65
+ export interface SchemaFieldInfo {
66
+ name: string;
67
+ /** The display name of the type, for the help text */
68
+ type: string;
69
+ /** The type the CLI converts values to */
70
+ fieldType: FieldType;
71
+ required: boolean;
72
+ description: string | undefined;
73
+ defaultValue: unknown;
74
+ enumValues: readonly string[] | undefined;
75
+ }
76
+
77
+ /**
78
+ * A JSON Schema node, as `jsonSchemaOf()` of the core gives it (draft-07).
79
+ */
80
+ interface JsonNode {
81
+ type?: string | string[];
82
+ description?: string;
83
+ default?: unknown;
84
+ enum?: unknown[];
85
+ const?: unknown;
86
+ format?: string;
87
+ items?: JsonNode | JsonNode[];
88
+ properties?: Record<string, JsonNode>;
89
+ required?: string[];
90
+ additionalProperties?: boolean | JsonNode;
91
+ anyOf?: JsonNode[];
92
+ oneOf?: JsonNode[];
93
+ allOf?: JsonNode[];
94
+ }
95
+
96
+ /** True for the JSON Schema of `null` (a nullable field is `anyOf: [T, null]`). */
97
+ function isNull(node: JsonNode): boolean {
98
+ return node.type === "null" || (node.const === null && node.type === undefined);
99
+ }
100
+
101
+ /** The members of a union, without `null` (a nullable value is the type itself on the CLI). */
102
+ function unionMembers(node: JsonNode): JsonNode[] | undefined {
103
+ const members = node.anyOf ?? node.oneOf;
104
+ return members?.filter((member) => !isNull(member));
105
+ }
106
+
107
+ /**
108
+ * The CLI type of a JSON Schema node.
109
+ */
110
+ function fieldTypeOfJson(node: JsonNode | undefined): FieldType {
111
+ if (!node || typeof node !== "object") {
112
+ return { kind: "any" };
113
+ }
114
+ const members = unionMembers(node);
115
+ if (members) {
116
+ if (members.length === 1) return fieldTypeOfJson(members[0]);
117
+ // A union of string constants is an enum on the CLI
118
+ if (members.length > 1 && members.every((member) => typeof member.const === "string")) {
119
+ return { kind: "enum", values: members.map((member) => member.const as string) };
120
+ }
121
+ return { kind: "union", options: members.map((member) => fieldTypeOfJson(member)) };
122
+ }
123
+ if (node.allOf?.length === 1) {
124
+ return fieldTypeOfJson(node.allOf[0]);
125
+ }
126
+ if (Array.isArray(node.enum)) {
127
+ return node.enum.every((value) => typeof value === "string")
128
+ ? { kind: "enum", values: node.enum as string[] }
129
+ : { kind: "any" };
130
+ }
131
+ if ("const" in node && node.const !== undefined) {
132
+ return { kind: "literal", literal: node.const };
133
+ }
134
+ const types = Array.isArray(node.type) ? node.type.filter((type) => type !== "null") : node.type ? [node.type] : [];
135
+ if (types.length > 1) {
136
+ return { kind: "union", options: types.map((type) => fieldTypeOfJson({ ...node, type })) };
137
+ }
138
+ switch (types[0]) {
139
+ case "string":
140
+ return node.format === "date-time" || node.format === "date" ? { kind: "date" } : { kind: "string" };
141
+ case "number":
142
+ case "integer":
143
+ return { kind: "number" };
144
+ case "boolean":
145
+ return { kind: "boolean" };
146
+ case "array":
147
+ return { kind: "array", item: fieldTypeOfJson(Array.isArray(node.items) ? undefined : node.items) };
148
+ case "object": {
149
+ const extra = node.additionalProperties;
150
+ const hasFields = node.properties && Object.keys(node.properties).length > 0;
151
+ if (!hasFields && extra && typeof extra === "object") {
152
+ return { kind: "record", value: fieldTypeOfJson(extra) };
153
+ }
154
+ return { kind: "object" };
155
+ }
156
+ default:
157
+ return { kind: "any" };
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The JSON Schema of a procedure schema (or a JSON Schema itself, as the manifest stores it).
163
+ */
164
+ function asJsonSchema(schema: unknown): JsonNode | undefined {
165
+ if (!schema || typeof schema !== "object") {
166
+ return undefined;
167
+ }
168
+ // A JSON Schema already (from the manifest): it has no validator
169
+ const props = schema as Record<string, unknown>;
170
+ if (!("~standard" in props) && typeof props["safeParse"] !== "function" && typeof props["parse"] !== "function") {
171
+ return schema as JsonNode;
172
+ }
173
+ return jsonSchemaOf(schema as ProcedureSchema) as JsonNode | undefined;
174
+ }
175
+
176
+ /**
177
+ * The CLI type of a schema: a procedure schema (a Standard Schema such as zod 4) or its JSON
178
+ * Schema. The type comes from the JSON Schema, so every schema library works. (Before, the
179
+ * parser read the private fields of zod 3.)
180
+ */
181
+ export function fieldTypeOf(schema: unknown): FieldType {
182
+ return fieldTypeOfJson(asJsonSchema(schema));
183
+ }
184
+
185
+ /**
186
+ * The display name of a type, for the help text
187
+ */
188
+ function typeName(type: FieldType): string {
189
+ switch (type.kind) {
190
+ case "array":
191
+ return `${typeName(type.item ?? { kind: "any" })}[]`;
192
+ case "record":
193
+ return "key=value";
194
+ case "object":
195
+ case "any":
196
+ return "json";
197
+ case "union":
198
+ return (type.options ?? []).map(typeName).join("|");
199
+ case "literal":
200
+ return JSON.stringify(type.literal);
201
+ default:
202
+ return type.kind;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Extract field information from a schema: a procedure schema or its JSON Schema. A schema that
208
+ * is not an object (or that has no JSON Schema) gives no fields: the CLI then passes its flags
209
+ * through.
210
+ */
211
+ export function extractSchemaFields(schema: unknown): SchemaFieldInfo[] {
212
+ const json = asJsonSchema(schema);
213
+ const object = json && !json.properties ? unionMembers(json)?.find((member) => member.properties) : json;
214
+ if (!object?.properties) {
215
+ return [];
216
+ }
217
+ const required = new Set(object.required ?? []);
218
+ const fields: SchemaFieldInfo[] = [];
219
+ for (const [name, field] of Object.entries(object.properties)) {
220
+ if (!field || typeof field !== "object") {
221
+ continue;
222
+ }
223
+ const fieldType = fieldTypeOfJson(field);
224
+ const description = field.description ?? unionMembers(field)?.find((member) => member.description)?.description;
225
+ fields.push({
226
+ name,
227
+ type: fieldType.kind === "enum" ? "enum" : typeName(fieldType),
228
+ fieldType,
229
+ required: required.has(name) && field.default === undefined,
230
+ description,
231
+ defaultValue: field.default,
232
+ enumValues: fieldType.kind === "enum" ? fieldType.values : undefined,
233
+ });
234
+ }
235
+ return fields;
236
+ }
237
+
238
+ // =============================================================================
239
+ // Conversion of CLI text to the field types
240
+ // =============================================================================
241
+
242
+ /** The result of a conversion: the value, or a message for the user */
243
+ export type Converted = { ok: true; value: unknown } | { ok: false; error: string };
244
+
245
+ const NUMBER_TEXT = /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$|^0[xX][0-9a-fA-F]+$/;
246
+ const SAFE_INTEGER_LIMIT = BigInt(Number.MAX_SAFE_INTEGER);
247
+
248
+ /**
249
+ * Text as a JSON value, when it is JSON. A whole number that a JavaScript number cannot hold
250
+ * exactly stays text. Anything that is not JSON stays text.
251
+ */
252
+ function jsonOrText(text: string): unknown {
253
+ const trimmed = text.trim();
254
+ if (/^-?\d+$/.test(trimmed)) {
255
+ const big = BigInt(trimmed);
256
+ if (big > SAFE_INTEGER_LIMIT || big < -SAFE_INTEGER_LIMIT) {
257
+ return text;
258
+ }
259
+ }
260
+ try {
261
+ return JSON.parse(trimmed);
262
+ } catch {
263
+ return text;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Convert the CLI text of one value to a field type
269
+ */
270
+ export function convertValue(text: string, type: FieldType, label: string): Converted {
271
+ switch (type.kind) {
272
+ case "string":
273
+ case "date":
274
+ return { ok: true, value: text };
275
+ case "enum":
276
+ return { ok: true, value: text };
277
+ case "number": {
278
+ if (!NUMBER_TEXT.test(text.trim())) {
279
+ return { ok: false, error: `${label} needs a number, not "${text}"` };
280
+ }
281
+ return { ok: true, value: Number(text) };
282
+ }
283
+ case "bigint":
284
+ try {
285
+ return { ok: true, value: BigInt(text) };
286
+ } catch {
287
+ return { ok: false, error: `${label} needs a whole number, not "${text}"` };
288
+ }
289
+ case "boolean":
290
+ if (text === "true") return { ok: true, value: true };
291
+ if (text === "false") return { ok: true, value: false };
292
+ return { ok: false, error: `${label} needs true or false, not "${text}"` };
293
+ case "literal": {
294
+ const literal = type.literal;
295
+ if (typeof literal === "number") return convertValue(text, { kind: "number" }, label);
296
+ if (typeof literal === "boolean") return convertValue(text, { kind: "boolean" }, label);
297
+ return { ok: true, value: text };
298
+ }
299
+ case "array": {
300
+ const item = type.item ?? { kind: "any" };
301
+ if (text.trimStart().startsWith("[")) {
302
+ const parsed = jsonOrText(text);
303
+ if (Array.isArray(parsed)) {
304
+ return { ok: true, value: parsed };
305
+ }
306
+ }
307
+ const converted = convertValue(text, item, label);
308
+ return converted.ok ? { ok: true, value: [converted.value] } : converted;
309
+ }
310
+ case "record": {
311
+ if (text.trimStart().startsWith("{")) {
312
+ const parsed = jsonOrText(text);
313
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
314
+ return { ok: true, value: parsed };
315
+ }
316
+ return { ok: false, error: `${label} needs key=value or a JSON object` };
317
+ }
318
+ const eq = text.indexOf("=");
319
+ if (eq <= 0) {
320
+ return { ok: false, error: `${label} needs key=value, not "${text}"` };
321
+ }
322
+ const converted = convertValue(text.slice(eq + 1), type.value ?? { kind: "any" }, label);
323
+ return converted.ok ? { ok: true, value: { [text.slice(0, eq)]: converted.value } } : converted;
324
+ }
325
+ case "object": {
326
+ const parsed = jsonOrText(text);
327
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
328
+ return { ok: true, value: parsed };
329
+ }
330
+ return { ok: false, error: `${label} needs a JSON object, not "${text}"` };
331
+ }
332
+ case "union": {
333
+ const options = type.options ?? [];
334
+ // Zod tries the members in order: a member that takes text takes the text as it is
335
+ for (const option of options) {
336
+ if (option.kind === "string" || option.kind === "date") return { ok: true, value: text };
337
+ if (option.kind === "enum" && option.values?.includes(text)) return { ok: true, value: text };
338
+ if (option.kind === "literal" && String(option.literal) === text) {
339
+ return { ok: true, value: option.literal };
340
+ }
341
+ }
342
+ for (const option of options) {
343
+ if (option.kind === "enum" || option.kind === "literal") continue;
344
+ const converted = convertValue(text, option, label);
345
+ if (converted.ok) return converted;
346
+ }
347
+ return { ok: true, value: jsonOrText(text) };
348
+ }
349
+ case "any":
350
+ default:
351
+ return { ok: true, value: jsonOrText(text) };
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Add a converted value to a field that already has one. Arrays collect and records merge,
357
+ * so a repeated flag adds to the field. Other fields take the last value.
358
+ */
359
+ export function mergeValue(previous: unknown, next: unknown, type: FieldType): unknown {
360
+ if (previous === undefined) {
361
+ return next;
362
+ }
363
+ if (type.kind === "array" && Array.isArray(previous) && Array.isArray(next)) {
364
+ return [...previous, ...next];
365
+ }
366
+ if (
367
+ type.kind === "record" &&
368
+ previous && typeof previous === "object" && !Array.isArray(previous) &&
369
+ next && typeof next === "object" && !Array.isArray(next)
370
+ ) {
371
+ return { ...previous, ...next };
372
+ }
373
+ return next;
374
+ }
375
+
376
+ // =============================================================================
377
+ // The old parameter interface
378
+ // =============================================================================
379
+
380
+ /**
381
+ * Gluegun parameters shape
382
+ */
383
+ export interface Parameters {
384
+ first?: string;
385
+ second?: string;
386
+ third?: string;
387
+ array?: string[];
388
+ options?: Record<string, unknown>;
389
+ raw?: string[];
390
+ string?: string;
391
+ }
392
+
393
+ /**
394
+ * Convert camelCase to kebab-case
395
+ */
396
+ export function toKebab(str: string): string {
397
+ return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
398
+ }
399
+
400
+ /**
401
+ * Convert kebab-case to camelCase
402
+ */
403
+ export function toCamel(str: string): string {
404
+ return str.replace(/-([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase());
405
+ }
406
+
407
+ /**
408
+ * Convert one parameter by the type of its field. A value that is not text (true for a flag
409
+ * with no value) is not changed.
410
+ */
411
+ function convertParameter(value: unknown, type: FieldType, label: string): unknown {
412
+ if (typeof value !== "string") {
413
+ return value;
414
+ }
415
+ const converted = convertValue(value, type, label);
416
+ if (!converted.ok) {
417
+ throw new Error(converted.error);
418
+ }
419
+ return converted.value;
420
+ }
421
+
422
+ /**
423
+ * Parse CLI parameters into procedure input based on metadata. With the schema, each value is
424
+ * converted by the type of its field: a string field keeps its text. Without the schema, a value
425
+ * is JSON when it parses as JSON, and text otherwise.
426
+ */
427
+ export function parseFromSchema(
428
+ params: Parameters,
429
+ meta: CLIMeta,
430
+ schema?: unknown
431
+ ): Record<string, unknown> {
432
+ const positionalArgs = meta.args ?? [];
433
+ const shorts = meta.shorts ?? {};
434
+ const fields = new Map(extractSchemaFields(schema).map((field) => [field.name, field.fieldType]));
435
+ const typeOfField = (field: string): FieldType => fields.get(field) ?? { kind: "any" };
436
+ const input: Record<string, unknown> = {};
437
+
438
+ // 1. Parse positional args
439
+ const positionalValues = params.array ?? [];
440
+ positionalArgs.forEach((field, i) => {
441
+ if (positionalValues[i] !== undefined) {
442
+ input[field] = convertParameter(positionalValues[i], typeOfField(field), field);
443
+ }
444
+ });
445
+
446
+ // 2. Parse options from CLI flags
447
+ const options = params.options ?? {};
448
+
449
+ // Build reverse lookup for short flags
450
+ const shortToField: Record<string, string> = {};
451
+ for (const [field, short] of Object.entries(shorts)) {
452
+ shortToField[short] = field;
453
+ }
454
+
455
+ for (const [key, value] of Object.entries(options)) {
456
+ // Skip if it's a positional arg already set
457
+ if (positionalArgs.includes(key) && input[key] !== undefined) {
458
+ continue;
459
+ }
460
+ const field = shortToField[key] ?? (key.includes("-") ? toCamel(key) : key);
461
+ input[field] = convertParameter(value, typeOfField(field), `--${toKebab(field)}`);
462
+ }
463
+
464
+ return input;
465
+ }
466
+
467
+ // =============================================================================
468
+ // Help
469
+ // =============================================================================
470
+
471
+ /**
472
+ * How to give a field on the command line, for the help text
473
+ */
474
+ function valueHint(field: SchemaFieldInfo): string {
475
+ const type = field.fieldType;
476
+ switch (type.kind) {
477
+ case "boolean":
478
+ return "";
479
+ case "array":
480
+ return ` <${typeName(type.item ?? { kind: "any" })}>... (repeat the flag, or give a JSON array)`;
481
+ case "record":
482
+ return " <key=value>... (repeat the flag, or give a JSON object)";
483
+ case "object":
484
+ case "any":
485
+ return " <json>";
486
+ case "enum":
487
+ return ` <${(field.enumValues ?? []).join("|")}>`;
488
+ default:
489
+ return ` <${typeName(type)}>`;
490
+ }
491
+ }
492
+
493
+ /**
494
+ * A command as mark sees it: a procedure of the registry (with its input schema), or a command
495
+ * of the manifest (with the fields of its input). See manifest.ts.
496
+ */
497
+ export interface CommandLike {
498
+ path: readonly string[];
499
+ metadata?: unknown;
500
+ /** The input schema of a procedure of the registry */
501
+ input?: unknown;
502
+ /** The output schema of a procedure of the registry */
503
+ output?: unknown;
504
+ /** The fields of the input of a command of the manifest */
505
+ fields?: readonly SchemaFieldInfo[];
506
+ /** The fields of the result of a command of the manifest */
507
+ resultFields?: readonly SchemaFieldInfo[];
508
+ }
509
+
510
+ /** The fields of the input of a command: from the manifest, or read from the schema */
511
+ export function fieldsOf(command: CommandLike): SchemaFieldInfo[] {
512
+ return command.fields ? [...command.fields] : extractSchemaFields(command.input);
513
+ }
514
+
515
+ /**
516
+ * The fields of the result of a procedure, from its output schema: only for a result that is an
517
+ * object (a union, a stream item or a pass-through schema gives none).
518
+ */
519
+ export function resultFieldsOfSchema(output: unknown): SchemaFieldInfo[] {
520
+ const json = output ? (jsonSchemaOf(output as ProcedureSchema, { io: "output" }) as JsonNode | undefined) : undefined;
521
+ if (!json) return [];
522
+ if (json.type === "object") return extractSchemaFields(json);
523
+ // A number, a string, a boolean or an array: the help shows the one value (`mark client add`)
524
+ if (typeof json.type === "string" && ["number", "integer", "string", "boolean", "array"].includes(json.type)) {
525
+ const fieldType = fieldTypeOfJson(json);
526
+ return [
527
+ {
528
+ name: "value",
529
+ type: typeName(fieldType),
530
+ fieldType,
531
+ required: true,
532
+ description: json.description,
533
+ defaultValue: undefined,
534
+ enumValues: undefined,
535
+ },
536
+ ];
537
+ }
538
+ return [];
539
+ }
540
+
541
+ /** The fields of the result of a command: from the manifest, or read from the output schema */
542
+ export function resultFieldsOf(command: CommandLike): SchemaFieldInfo[] {
543
+ return command.resultFields ? [...command.resultFields] : resultFieldsOfSchema(command.output);
544
+ }
545
+
546
+ /**
547
+ * Generate help text for a procedure based on its metadata and schema. `schema` is the input
548
+ * schema, or the fields of the input (a command of the manifest). `result` gives the fields of
549
+ * the result, which the help lists after the options.
550
+ */
551
+ export function generateHelp(
552
+ path: string[],
553
+ meta: CLIMeta,
554
+ schema?: unknown,
555
+ result: readonly SchemaFieldInfo[] = []
556
+ ): string {
557
+ const positionalArgs = meta.args ?? [];
558
+ const shorts = meta.shorts ?? {};
559
+
560
+ const lines: string[] = [];
561
+
562
+ // Extract schema fields for enhanced help
563
+ const schemaFields: SchemaFieldInfo[] = Array.isArray(schema)
564
+ ? (schema as SchemaFieldInfo[])
565
+ : schema
566
+ ? extractSchemaFields(schema)
567
+ : [];
568
+ const schemaFieldMap = new Map(schemaFields.map((f) => [f.name, f]));
569
+
570
+ // Command signature: the last positional array takes the rest of the arguments
571
+ const cmdName = `mark ${path.join(" ")}`;
572
+ const posStr = positionalArgs
573
+ .map((a, i) => {
574
+ const many = i === positionalArgs.length - 1 && schemaFieldMap.get(a)?.fieldType.kind === "array";
575
+ return many ? `<${a}...>` : `<${a}>`;
576
+ })
577
+ .join(" ");
578
+ lines.push(`Usage: ${cmdName}${posStr ? " " + posStr : ""} [options]`);
579
+ lines.push("");
580
+
581
+ if (meta.description) {
582
+ lines.push(meta.description);
583
+ lines.push("");
584
+ }
585
+
586
+ // Positional args with schema info
587
+ if (positionalArgs.length > 0) {
588
+ lines.push("Arguments:");
589
+ for (const arg of positionalArgs) {
590
+ const fieldInfo = schemaFieldMap.get(arg);
591
+ const typeStr = fieldInfo ? ` (${fieldInfo.type})` : "";
592
+ const reqStr = fieldInfo?.required ? " [required]" : "";
593
+ const descStr = fieldInfo?.description ? ` ${fieldInfo.description}` : "";
594
+ lines.push(` ${arg}${typeStr}${reqStr}`);
595
+ if (descStr) {
596
+ lines.push(` ${descStr}`);
597
+ }
598
+ }
599
+ lines.push("");
600
+ }
601
+
602
+ // Options: each schema field that is not positional, with its short flag if it has one
603
+ const optionLines: string[] = [];
604
+ const positionalSet = new Set(positionalArgs);
605
+ const listed = new Set<string>();
606
+ const optionFields = [
607
+ ...Object.keys(shorts).map((name) => schemaFieldMap.get(name) ?? fieldWithoutSchema(name)),
608
+ ...schemaFields.filter((field) => !(field.name in shorts) && !positionalSet.has(field.name)),
609
+ ];
610
+ for (const field of optionFields) {
611
+ if (listed.has(field.name)) {
612
+ continue;
613
+ }
614
+ listed.add(field.name);
615
+ const short = shorts[field.name];
616
+ const kebab = toKebab(field.name);
617
+ const flag = short ? ` -${short}, --${kebab}` : ` --${kebab}`;
618
+ const negation = field.fieldType.kind === "boolean" ? ` (or --no-${kebab})` : "";
619
+ const reqStr = field.required ? " [required]" : "";
620
+ const defaultStr = field.defaultValue !== undefined ? ` (default: ${JSON.stringify(field.defaultValue)})` : "";
621
+ optionLines.push(`${flag}${valueHint(field)}${negation}${reqStr}${defaultStr}`);
622
+ if (field.description) {
623
+ optionLines.push(` ${field.description}`);
624
+ }
625
+ }
626
+
627
+ if (optionLines.length > 0) {
628
+ lines.push("Options:");
629
+ lines.push(...optionLines);
630
+ }
631
+
632
+ // The fields of the result (roadmap 2.1: the output schemas describe them)
633
+ if (result.length > 0) {
634
+ lines.push("");
635
+ lines.push("Result:");
636
+ for (const field of result) {
637
+ const optional = field.required ? "" : ", optional";
638
+ lines.push(` ${field.name} (${field.type}${optional})`);
639
+ if (field.description) {
640
+ lines.push(` ${field.description}`);
641
+ }
642
+ }
643
+ }
644
+
645
+ // Standard options
646
+ lines.push("");
647
+ lines.push("Global Options (before the command, or after it when the command has no flag of that name):");
648
+ for (const flag of CLI_GLOBAL_FLAGS) {
649
+ if (flag.mode || flag.serverOption || flag.long === "version") {
650
+ continue;
651
+ }
652
+ const name = flag.short ? `-${flag.short}, --${flag.long}` : ` --${flag.long}`;
653
+ lines.push(` ${name.padEnd(18)} ${flag.description}`);
654
+ }
655
+
656
+ return lines.join("\n");
657
+ }
658
+
659
+ /** A field that only the metadata names (the schema has no readable shape) */
660
+ function fieldWithoutSchema(name: string): SchemaFieldInfo {
661
+ return {
662
+ name,
663
+ type: "json",
664
+ fieldType: { kind: "any" },
665
+ required: false,
666
+ description: undefined,
667
+ defaultValue: undefined,
668
+ enumValues: undefined,
669
+ };
670
+ }