@davidorex/pi-context-cli 0.29.0 → 0.31.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.
package/dist/cli.js CHANGED
@@ -27,13 +27,32 @@ import { execSync } from "node:child_process";
27
27
  import { readFileSync } from "node:fs";
28
28
  import path from "node:path";
29
29
  import { createInterface } from "node:readline";
30
- import { ops } from "@davidorex/pi-context/ops";
30
+ import { fileURLToPath } from "node:url";
31
+ import { renderBlocked, renderConflicts } from "@davidorex/pi-context";
32
+ import { nextId, readBlock, resolveBlockItemSchema } from "@davidorex/pi-context/block-api";
33
+ import { loadConfig } from "@davidorex/pi-context/context";
34
+ import { schemaPath } from "@davidorex/pi-context/context-dir";
35
+ import { boundedJsonOutput, ops, renderOpResultText, } from "@davidorex/pi-context/ops";
36
+ import { validateFromFile } from "@davidorex/pi-context/schema-validator";
37
+ import { readSchema } from "@davidorex/pi-context/schema-write";
38
+ import { runPiBound } from "./pi-bound.js";
39
+ import { formatAjvError, isValidationError, renderTable } from "./render.js";
31
40
  /**
32
41
  * The surfaced command set: every op the CLI exposes. Derived by reflection —
33
42
  * NOT a hardcoded list. A `surface: "process"` op (currently only list-tools)
34
43
  * is excluded here by the partition, never by name.
35
44
  */
36
45
  export const useOps = ops.filter((o) => o.surface === "use");
46
+ /**
47
+ * The pi-context-cli package version, read ONCE at module load from the shipped
48
+ * package.json. Resolved RELATIVE to this module's URL (mirrors pi-bound.ts's
49
+ * createRequire(import.meta.url) pattern) so it works from the BUILT bin: from
50
+ * `dist/cli.js`, `../package.json` is the package root (npm always ships
51
+ * package.json in a published tarball). A plain `import pkg from "../package.json"`
52
+ * is deliberately avoided — tsconfig.build's rootDir:"./src" / include:["src/**"]
53
+ * would make that compile path a hazard. `--version`/`-v` prints this value.
54
+ */
55
+ export const PKG_VERSION = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
37
56
  /**
38
57
  * Extract the literal string values of a string-enum field, or null for any
39
58
  * non-enum shape. A typebox `Type.Union([Type.Literal("eq"), …])` serializes at
@@ -157,6 +176,29 @@ export function parseOpArgs(op, argv, cwdBase = process.cwd()) {
157
176
  out.help = true;
158
177
  continue;
159
178
  }
179
+ if (tok === "--show-schema") {
180
+ // FGAP-022 — global flag (not an op param): print the block contract and exit.
181
+ out.showSchema = true;
182
+ continue;
183
+ }
184
+ if (op.name === "append-block-item" && (tok === "--dryRun" || tok === "--dry-run") && props.dryRun === undefined) {
185
+ // FGAP-024 — `--dry-run` is a GLOBAL flag scoped to append-block-item, the
186
+ // sole op the main() honor branch handles: it declares no `dryRun` param yet
187
+ // supports a client-side prospective-whole-file dry run. The token is captured
188
+ // here and NEVER routed into params, because the frozen op would reject
189
+ // `dryRun` as an unknown flag. Both the camel and kebab tokens are matched
190
+ // explicitly since, not being a schema key on this op, neither would resolve
191
+ // through the kebab→camel normalization below.
192
+ //
193
+ // For ops that DO declare a `dryRun` param (relation ops, upsert, update, …)
194
+ // `props.dryRun === undefined` is false, so this branch was already skipped and
195
+ // the token flows to the boolean-param handling, preserving the op's own dryRun
196
+ // semantics. For every OTHER no-`dryRun` op (the non-append block-mutation ops)
197
+ // the token is NOT swallowed here; it falls through to the unknown-flag throw
198
+ // below (UsageError → exit 2), a clean rejection rather than a silent write.
199
+ out.dryRun = true;
200
+ continue;
201
+ }
160
202
  if (tok === "--json") {
161
203
  out.json = true;
162
204
  continue;
@@ -172,10 +214,36 @@ export function parseOpArgs(op, argv, cwdBase = process.cwd()) {
172
214
  out.cwd = path.isAbsolute(v) ? v : path.resolve(cwdBase, v);
173
215
  continue;
174
216
  }
217
+ if (tok === "--format") {
218
+ // FGAP-021 — explicit render selector. `text` reproduces each op's prior
219
+ // run() text; `json` is the `--json` envelope; `table` projects a renderable
220
+ // array (read-page / data array) as markdown. An unknown value is an operator
221
+ // error, not a silent fallback.
222
+ const v = argv[++i];
223
+ if (v === undefined)
224
+ throw new UsageError("--format requires one of: text, json, table");
225
+ if (v !== "text" && v !== "json" && v !== "table") {
226
+ throw new UsageError(`--format expects one of: text, json, table; got '${v}'`);
227
+ }
228
+ out.format = v;
229
+ continue;
230
+ }
175
231
  if (tok === "--writer") {
176
232
  const v = argv[++i];
177
233
  if (v === undefined)
178
234
  throw new UsageError("--writer requires a JSON argument");
235
+ // Shorthand `kind:id` (FGAP-025): a value that is neither `{`-prefixed JSON
236
+ // nor an `@file` reference and matches `<kind>:<identifier>` expands to the
237
+ // canonical {kind, <id-field>:<rest>} WriterIdentity (the id-field per
238
+ // WRITER_KIND_IDENTIFIER_FIELD). The rest may itself contain colons (an
239
+ // email or step-id) — only the FIRST colon delimits kind from identifier.
240
+ // A JSON / @file value (the canonical form) is parsed unchanged.
241
+ const shorthand = /^(human|agent|monitor|workflow):(.+)$/.exec(v);
242
+ if (shorthand && !v.startsWith("@")) {
243
+ const kind = shorthand[1];
244
+ out.explicitWriter = { kind, [WRITER_KIND_IDENTIFIER_FIELD[kind]]: shorthand[2] };
245
+ continue;
246
+ }
179
247
  try {
180
248
  out.explicitWriter = v.startsWith("@") ? JSON.parse(readFileSync(v.slice(1), "utf8")) : JSON.parse(v);
181
249
  }
@@ -184,10 +252,57 @@ export function parseOpArgs(op, argv, cwdBase = process.cwd()) {
184
252
  }
185
253
  continue;
186
254
  }
255
+ // `--where field:op:value` shorthand (FGAP-025): a single token expanding to
256
+ // the op's declared field/op/value params. Split on the FIRST TWO colons only —
257
+ // the value segment may itself contain colons. The canonical explicit
258
+ // `--field/--op/--value` flags remain available and pass through unchanged.
259
+ if (tok === "--where") {
260
+ const v = argv[++i];
261
+ if (v === undefined)
262
+ throw new UsageError("--where requires a field:op:value argument");
263
+ const c1 = v.indexOf(":");
264
+ const c2 = c1 >= 0 ? v.indexOf(":", c1 + 1) : -1;
265
+ if (c1 < 0 || c2 < 0) {
266
+ throw new UsageError(`--where expects field:op:value; got '${v}'`);
267
+ }
268
+ out.params.field = v.slice(0, c1);
269
+ out.params.op = v.slice(c1 + 1, c2);
270
+ out.params.value = v.slice(c2 + 1);
271
+ continue;
272
+ }
187
273
  if (!tok.startsWith("--")) {
188
274
  throw new UsageError(`unexpected argument: ${tok}`);
189
275
  }
190
- const field = tok.slice(2);
276
+ // Raw token kept verbatim for error messages; `field` resolves to the op's
277
+ // actual property key via the normalization layer below.
278
+ let field = tok.slice(2);
279
+ // FGAP-032 — `--id` aliases the op's single declared id-param. An op may key
280
+ // its id param `id`, or `<x>Id` (itemId/parentId/taskId/unitId/…). When the op
281
+ // has no literal `id` property and exactly one such param, `--id` resolves to
282
+ // it; zero leaves `field` as-is (the unknown-flag error fires); two or more is
283
+ // ambiguous (the caller must name the explicit flag). An id-param is always
284
+ // string-typed (every identity selector is Type.String / Type.Optional(String));
285
+ // the string-type guard excludes boolean flags whose name happens to end in `Id`
286
+ // — e.g. append-block-item's `autoId` (a Type.Boolean allocation flag, not an
287
+ // identity selector) — so `--id` never silently binds to them.
288
+ if (field === "id" && props.id === undefined) {
289
+ const idParams = Object.keys(props).filter((k) => (k === "id" || /Id$/.test(k)) && fieldType(props[k]) === "string");
290
+ if (idParams.length === 1) {
291
+ field = idParams[0];
292
+ }
293
+ else if (idParams.length >= 2) {
294
+ throw new UsageError(`ambiguous --id (op declares ${idParams.length} id-params: ${idParams.join(", ")}); use the explicit flag`);
295
+ }
296
+ }
297
+ else if (props[field] === undefined && field.includes("-")) {
298
+ // FGAP-064 — kebab→camel normalization: a conventional `--dry-run` / `--id`
299
+ // kebab form resolves to the camelCase op-schema key (`dryRun`) when that
300
+ // camel key exists. An unresolved kebab token stays raw → unknown-flag error.
301
+ const camel = field.replace(/-([a-z0-9])/g, (_m, c) => c.toUpperCase());
302
+ if (props[camel] !== undefined) {
303
+ field = camel;
304
+ }
305
+ }
191
306
  const fschema = props[field];
192
307
  if (fschema === undefined) {
193
308
  throw new UsageError(`unknown flag: ${tok}`);
@@ -229,17 +344,50 @@ export function parseOpArgs(op, argv, cwdBase = process.cwd()) {
229
344
  : JSON.parse(value);
230
345
  }
231
346
  catch (err) {
232
- throw new UsageError(`--${field}: ${err instanceof Error ? err.message : String(err)}`);
347
+ // FGAP-025: the `value` field (the comparison operand of
348
+ // filter-block-items, Type.Unknown) is the sole CSV-shorthand target. A
349
+ // bare unquoted operand that is not valid JSON is retained as the raw
350
+ // string so the post-loop `--op in` CSV pass can split it (and an
351
+ // unquoted scalar operand still reaches the op as a string). Every other
352
+ // json field keeps the strict parse-or-error contract (e.g. a malformed
353
+ // `--item` is an operator error, never a silent string).
354
+ if (field === "value" && !value.startsWith("@")) {
355
+ out.params[field] = value;
356
+ }
357
+ else {
358
+ throw new UsageError(`--${field}: ${err instanceof Error ? err.message : String(err)}`);
359
+ }
233
360
  }
234
361
  }
235
362
  }
236
363
  if (out.explicitWriter !== undefined) {
237
364
  out.params.writer = out.explicitWriter;
238
365
  }
239
- // Required-field check `writer` is exempt (schema-driven auto-injected
240
- // after parse when the op declares it and none was passed).
241
- if (!out.help) {
242
- const required = (schema.required ?? []).filter((r) => r !== "writer");
366
+ // CSV `--op in` normalization (FGAP-025): the `in` membership operator takes a
367
+ // list value. When `op === "in"` and the value arrived as a single string,
368
+ // split it on commas into the string array the op's declared shape expects.
369
+ // Argv-order-independent runs after the whole loop, so `--value a,b,c --op in`
370
+ // and `--op in --value a,b,c` both normalize. A value already an array passes
371
+ // through unchanged (the typeof guard).
372
+ if (out.params.op === "in" && typeof out.params.value === "string") {
373
+ out.params.value = out.params.value.split(",");
374
+ }
375
+ // Required-field check — the exemption set derives from AUTO_SUPPLIED, the single
376
+ // source for the CLI auto-supplied param contract: a key there is auto-supplied, so
377
+ // it is exempt from this missing-required check, rendered bracketed-optional in the
378
+ // per-op synopsis (isSynopsisRequired), and `autoSupplied`-annotated in the Flags
379
+ // block. Concretely today: `writer` (injectWriter fills it from the resolved operator
380
+ // identity after parse) and `arrayKey` (injectArrayKey derives it from
381
+ // config.block_kinds[].array_key for the block-mutation ops after parse, FGAP-019, so
382
+ // a `--block` without an explicit `--arrayKey` must not be flagged missing here).
383
+ // Adding a new AUTO_SUPPLIED key also requires adding its injector (injectWriter /
384
+ // injectArrayKey-style): the map single-sources the exemption/help CONTRACT, not the
385
+ // value-supply wiring.
386
+ // `--help` and `--show-schema` exit before any op invocation and need no item, so
387
+ // the required-field check is skipped for them (FGAP-022). `--dryRun` still requires
388
+ // the op's declared inputs (it validates a prospective item) and so is NOT exempt.
389
+ if (!out.help && !out.showSchema) {
390
+ const required = (schema.required ?? []).filter((r) => !(r in AUTO_SUPPLIED));
243
391
  const missing = required.filter((r) => !(r in out.params));
244
392
  if (missing.length > 0) {
245
393
  throw new UsageError(`missing required: ${missing.map((m) => `--${m}`).join(", ")}`);
@@ -259,6 +407,84 @@ export function injectWriter(op, params, identity) {
259
407
  }
260
408
  return params;
261
409
  }
410
+ /**
411
+ * Schema- + config-driven arrayKey injection (FGAP-019), mirroring injectWriter.
412
+ * The 7 block-mutation ops still DECLARE `arrayKey` required (their in-pi schema +
413
+ * handler are byte-unchanged and still receive + require it) — the CLI supplies it
414
+ * pre-call so a caller passes only `--block`. When the op declares `arrayKey`, none
415
+ * was passed, and a string `block` is present, derive the array_key from the
416
+ * config block_kinds entry whose canonical_id matches `block` (canonical_id ≠
417
+ * array_key in real data, e.g. framework-gaps → gaps, so the derivation is genuinely
418
+ * needed). Best-effort: no substrate, no config, or an unknown block leaves arrayKey
419
+ * unset — the op then throws its own missing-param error. Mutates and returns params.
420
+ */
421
+ export function injectArrayKey(op, params, cwd) {
422
+ const props = objectSchema(op).properties ?? {};
423
+ if (props.arrayKey !== undefined && params.arrayKey === undefined && typeof params.block === "string") {
424
+ let cfg = null;
425
+ try {
426
+ cfg = loadConfig(cwd);
427
+ }
428
+ catch {
429
+ cfg = null;
430
+ }
431
+ const bk = cfg?.block_kinds.find((b) => b.canonical_id === params.block);
432
+ if (bk)
433
+ params.arrayKey = bk.array_key;
434
+ }
435
+ return params;
436
+ }
437
+ /**
438
+ * Build the DispatchContext threaded into `op.run` as its 3rd arg so every CLI
439
+ * write op stamps attestation (created_by / created_at) on schemas that declare
440
+ * author fields. Identity precedence:
441
+ * - an explicit `--writer` (parsed JSON) is used verbatim as the writer — it
442
+ * may already be a full WriterIdentity ({kind, ...}) or the {kind, user}
443
+ * shape the smuggle-ops declare; either way it is the operator's stated
444
+ * identity, so it is passed through unchanged.
445
+ * - otherwise a human writer from the resolved operator identity, falling
446
+ * back to "operator" (mirrors injectWriter's fallback).
447
+ *
448
+ * This is independent of injectWriter, which fills the schema `writer` PARAM
449
+ * the smuggle-ops (promote-item / write-schema-migration) still DECLARE so the
450
+ * in-pi auth-gate has a field to stamp. The op bodies now read the contract ctx
451
+ * built here, not params.writer; the param + ctx coexist harmlessly.
452
+ */
453
+ export function buildCliDispatchContext(explicitWriter, identity) {
454
+ if (explicitWriter !== undefined) {
455
+ if (explicitWriter === null || typeof explicitWriter !== "object") {
456
+ throw new UsageError('--writer must be a valid WriterIdentity, e.g. {"kind":"human","user":"me@example.com"}');
457
+ }
458
+ assertWriterIdentity(explicitWriter);
459
+ return { writer: explicitWriter };
460
+ }
461
+ return { writer: { kind: "human", user: identity ?? "operator" } };
462
+ }
463
+ /** The identifier field each WriterIdentity kind requires as a non-empty string. */
464
+ const WRITER_KIND_IDENTIFIER_FIELD = {
465
+ human: "user",
466
+ agent: "agent_id",
467
+ monitor: "monitor_name",
468
+ workflow: "workflow_step_id",
469
+ };
470
+ /**
471
+ * Narrow an arbitrary object to a well-formed WriterIdentity, or throw
472
+ * UsageError. A valid writer has a `kind` of human/agent/monitor/workflow AND
473
+ * the kind's required identifier field present as a non-empty string. A
474
+ * malformed explicit writer is an operator error — never a silent fallback.
475
+ */
476
+ function assertWriterIdentity(value) {
477
+ const rec = value;
478
+ const kind = rec.kind;
479
+ if (kind !== "human" && kind !== "agent" && kind !== "monitor" && kind !== "workflow") {
480
+ throw new UsageError('--writer must be a valid WriterIdentity, e.g. {"kind":"human","user":"me@example.com"}');
481
+ }
482
+ const idField = WRITER_KIND_IDENTIFIER_FIELD[kind];
483
+ const idValue = rec[idField];
484
+ if (typeof idValue !== "string" || idValue.length === 0) {
485
+ throw new UsageError(`--writer kind '${kind}' requires a non-empty '${idField}'; e.g. {"kind":"${kind}","${idField}":"..."}`);
486
+ }
487
+ }
262
488
  /**
263
489
  * Pure auth-gate decision (no I/O). Mirrors the pi-agent-dispatch gate:
264
490
  * - not gated → allow
@@ -279,37 +505,200 @@ export function authDecision(op, opts) {
279
505
  reason: `${op.name} requires authorization; re-run with --yes in a non-interactive context`,
280
506
  };
281
507
  }
282
- /** Per-op help text: description + one line per declared field. */
283
- export function deriveHelp(op) {
508
+ /**
509
+ * Single source for the CLI's auto-supplied params: a field declared `required`
510
+ * by the op schema that the CLI fills after parse, so the caller never passes it.
511
+ * Maps the param name to its provenance phrase. This map is the ONE source for
512
+ * both the synopsis exemption (bracketed-optional, `isSynopsisRequired`) and the
513
+ * per-flag `autoSupplied` annotation surfaced in the Flags block + json help —
514
+ * reconciling the Flags `(required)` schema-truth with the optional synopsis so
515
+ * neither surface contradicts the other (TASK-042 iterate-to-zero finding):
516
+ * - writer: injectWriter fills it from the resolved operator identity
517
+ * - arrayKey: injectArrayKey derives it from config.block_kinds[].array_key
518
+ */
519
+ export const AUTO_SUPPLIED = {
520
+ writer: "auto-injected",
521
+ arrayKey: "auto-derived from --block",
522
+ };
523
+ /**
524
+ * A field is synopsis-required iff the schema lists it required AND the CLI does
525
+ * NOT auto-supply it. Auto-supplied params (AUTO_SUPPLIED) are post-parse fills,
526
+ * so they render bracketed-optional in the synopsis even when schema-required —
527
+ * derived from AUTO_SUPPLIED so the exemption and the `autoSupplied` annotation
528
+ * share one source (no hardcoded writer/arrayKey literal here).
529
+ */
530
+ function isSynopsisRequired(field, schemaRequired) {
531
+ return schemaRequired.has(field) && !(field in AUTO_SUPPLIED);
532
+ }
533
+ /**
534
+ * Build the structured per-op help model from the op's typebox parameters +
535
+ * the registry `examples` + the help-group siblings. Pure — no I/O.
536
+ */
537
+ export function buildHelpModel(op) {
284
538
  const schema = objectSchema(op);
285
539
  const props = schema.properties ?? {};
286
540
  const required = new Set(schema.required ?? []);
287
- const lines = [`${op.name} ${op.description}`, ""];
288
- const entries = Object.entries(props);
289
- if (entries.length === 0) {
541
+ const flags = Object.entries(props).map(([name, fschema]) => {
542
+ const vals = stringEnumValues(fschema);
543
+ return {
544
+ name,
545
+ type: vals ? vals.join("|") : fieldType(fschema),
546
+ required: required.has(name),
547
+ ...(fschema.description ? { description: fschema.description } : {}),
548
+ ...(name in AUTO_SUPPLIED ? { autoSupplied: AUTO_SUPPLIED[name] } : {}),
549
+ };
550
+ });
551
+ // Synopsis: required (sans writer/arrayKey) then optional, each `--<f> <type>`,
552
+ // optionals bracketed. writer/arrayKey fall into the optional/bracketed set.
553
+ const synReq = [];
554
+ const synOpt = [];
555
+ for (const f of flags) {
556
+ const token = `--${f.name} <${f.type}>`;
557
+ if (isSynopsisRequired(f.name, required))
558
+ synReq.push(token);
559
+ else
560
+ synOpt.push(`[${token}]`);
561
+ }
562
+ const synopsis = [`pi-context ${op.name}`, ...synReq, ...synOpt].join(" ");
563
+ const group = groupForOp(op.name);
564
+ const related = useOps
565
+ .filter((o) => o.name !== op.name && groupForOp(o.name) === group)
566
+ .map((o) => o.name)
567
+ .sort();
568
+ return {
569
+ name: op.name,
570
+ synopsis,
571
+ summary: op.promptSnippet ?? op.description,
572
+ flags,
573
+ examples: op.examples ?? [],
574
+ related,
575
+ };
576
+ }
577
+ /**
578
+ * Best-of-breed per-op help text (TASK-042): `<name> — <description>` → SYNOPSIS →
579
+ * Flags (per-field, enum joins + required/optional + desc; json fields show
580
+ * `<json | @file>`) → EXAMPLES → RELATED (omitted when empty) → footer →
581
+ * the Global flags trailer. Plain text.
582
+ */
583
+ export function deriveHelp(op) {
584
+ const model = buildHelpModel(op);
585
+ const lines = [`${op.name} — ${op.description}`, "", "SYNOPSIS", ` ${model.synopsis}`, ""];
586
+ if (model.flags.length === 0) {
290
587
  lines.push(" (no parameters)");
291
588
  }
292
589
  else {
293
590
  lines.push("Flags:");
294
- for (const [field, fschema] of entries) {
295
- const vals = stringEnumValues(fschema);
296
- const t = vals ? vals.join("|") : fieldType(fschema);
297
- const req = required.has(field) ? "required" : "optional";
298
- const desc = fschema.description ? ` ${fschema.description}` : "";
299
- lines.push(` --${field} <${t}> (${req})${desc}`);
591
+ for (const f of model.flags) {
592
+ const typeShown = f.type === "json" ? "json | @file" : f.type;
593
+ // required/optional is schema-truth; an auto-supplied param appends its
594
+ // provenance (`required; auto-derived from --block`) so the (required) tag
595
+ // here does not contradict the bracketed-optional synopsis.
596
+ const baseTag = f.required ? "required" : "optional";
597
+ const tag = f.autoSupplied ? `${baseTag}; ${f.autoSupplied}` : baseTag;
598
+ const desc = f.description ? ` — ${f.description}` : "";
599
+ lines.push(` --${f.name} <${typeShown}> (${tag})${desc}`);
300
600
  }
301
601
  }
302
- lines.push("", "Global flags: --cwd <dir> --json --yes --writer <json> --help");
602
+ if (model.examples.length > 0) {
603
+ lines.push("", "EXAMPLES");
604
+ for (const ex of model.examples)
605
+ lines.push(` ${ex}`);
606
+ }
607
+ if (model.related.length > 0) {
608
+ lines.push("", "RELATED", ` ${model.related.join(" ")}`);
609
+ }
610
+ lines.push("", `Run 'pi-context --help' for all commands; '${op.name} --help --format json' for machine-readable help.`, "Global flags: --cwd <dir> --json --format text|json|table --yes --writer <json> --show-schema --dry-run --help");
303
611
  return lines.join("\n");
304
612
  }
305
- /** Top-level help: one line per surfaced op + global flag notes. */
613
+ /** Membership test against an explicit name-set. */
614
+ const oneOf = (...names) => (name) => names.includes(name);
615
+ const HELP_GROUPS = [
616
+ {
617
+ label: "Read & query",
618
+ match: (n) => n.startsWith("read-") ||
619
+ oneOf("filter-block-items", "join-blocks", "resolve-item-by-id", "resolve-items-by-id", "find-references", "walk-ancestors", "context-walk-descendants", "context-edges-for-lens", "context-lens-view", "context-status", "context-check-status", "context-current-state", "context-bootstrap-state", "context-validate", "context-validate-relations", "gather-execution-context", "validate-block-items")(n),
620
+ },
621
+ {
622
+ // Relations BEFORE Block writes: `*-relation` / `*-relations` must win over
623
+ // the `append-`/`remove-` block-write prefixes below.
624
+ label: "Relations",
625
+ match: (n) => n.endsWith("-relation") || n.endsWith("-relations"),
626
+ },
627
+ {
628
+ label: "Block writes",
629
+ match: (n) => oneOf("append-block-item", "update-block-item", "upsert-block-item", "remove-block-item", "append-block-nested-item", "update-block-nested-item", "remove-block-nested-item", "write-block")(n),
630
+ },
631
+ {
632
+ label: "Schema & config",
633
+ match: oneOf("write-schema", "write-schema-migration", "amend-config", "update", "resolve-conflict", "resolve-blocked", "rename-canonical-id"),
634
+ },
635
+ {
636
+ label: "Substrate lifecycle",
637
+ match: oneOf("context-init", "context-accept-all", "context-install", "context-switch", "context-list", "context-archive"),
638
+ },
639
+ {
640
+ label: "Workflow",
641
+ match: (n) => n.startsWith("context-roadmap-") || oneOf("complete-task", "promote-item")(n),
642
+ },
643
+ ];
644
+ /**
645
+ * Classify a use-op into its top-level help group LABEL (first-match-wins over the
646
+ * ordered HELP_GROUPS). Throws when a use-op matches no group — a new op that slips
647
+ * past every rule fails loudly here rather than silently vanishing from the help.
648
+ * The drift-guard test asserts every `useOps` name maps to exactly one group.
649
+ */
650
+ export function groupForOp(name) {
651
+ const group = HELP_GROUPS.find((g) => g.match(name));
652
+ if (group === undefined) {
653
+ throw new Error(`groupForOp: op '${name}' matches no help group — add it to HELP_GROUPS`);
654
+ }
655
+ return group.label;
656
+ }
657
+ /** Max one-liner width before truncation in the grouped help. */
658
+ const HELP_ONELINER_WIDTH = 72;
659
+ /**
660
+ * One-liner for an op row: prefer `promptSnippet` (the terse reflection summary),
661
+ * fall back to `description`. Collapse to a single line, then truncate uniformly to
662
+ * ~HELP_ONELINER_WIDTH at the last word boundary, appending an ellipsis when cut.
663
+ * Applied to EVERY row identically — no per-op special-casing, and the ops-registry
664
+ * text itself is never edited.
665
+ */
666
+ export function helpOneLiner(op) {
667
+ const raw = (op.promptSnippet ?? op.description).split("\n")[0].trim();
668
+ if (raw.length <= HELP_ONELINER_WIDTH)
669
+ return raw;
670
+ const head = raw.slice(0, HELP_ONELINER_WIDTH);
671
+ const lastSpace = head.lastIndexOf(" ");
672
+ const cut = lastSpace > 0 ? head.slice(0, lastSpace) : head;
673
+ return `${cut.trimEnd()}…`;
674
+ }
675
+ /**
676
+ * Grouped, scannable top-level help. Sections appear in HELP_GROUPS order (only
677
+ * groups with ≥1 op), ops sorted alphabetically within a group, each row a name
678
+ * padded to the group's own max-name width followed by the truncated one-liner.
679
+ * A static Process modes section surfaces `pi-bound` (a process mode, not a
680
+ * substrate op). The Global flags block is retained verbatim plus `--version`.
681
+ * Plain text only.
682
+ */
306
683
  export function deriveTopHelp() {
307
- const lines = ["pi-context <op> [flags]", "", "Commands:"];
308
- const width = Math.max(...useOps.map((o) => o.name.length));
309
- for (const op of useOps) {
310
- lines.push(` ${op.name.padEnd(width)} — ${op.description.split("\n")[0]}`);
684
+ const lines = ["pi-context <op> [flags]", ""];
685
+ for (const group of HELP_GROUPS) {
686
+ const groupOps = useOps
687
+ .filter((o) => groupForOp(o.name) === group.label)
688
+ .sort((a, b) => a.name.localeCompare(b.name));
689
+ if (groupOps.length === 0)
690
+ continue;
691
+ const width = Math.max(...groupOps.map((o) => o.name.length));
692
+ lines.push(group.label);
693
+ for (const op of groupOps) {
694
+ lines.push(` ${op.name.padEnd(width)} — ${helpOneLiner(op)}`);
695
+ }
696
+ lines.push("");
311
697
  }
312
- lines.push("", "Global flags:", " --cwd <dir> substrate root (default: cwd)", " --json emit { ok, op, output } envelope", " --yes, --force pre-authorize gated ops in non-interactive contexts", " --writer <json> override the auto-resolved writer identity", " --help, -h this help, or per-op help after an op name");
698
+ lines.push("Process modes");
699
+ lines.push(" pi-bound — Launch an embedded pi agent in-process on a bounded tool surface");
700
+ lines.push("");
701
+ lines.push("Global flags:", " --cwd <dir> substrate root (default: cwd)", " --json emit { ok, op, output } envelope (≡ --format json)", " --format <fmt> render as text | json | table (default: text, or json with --json)", " --yes, --force pre-authorize gated ops in non-interactive contexts", " --writer <json> override the auto-resolved writer identity", " --show-schema preview a block op's contract (array_key/required/types/id) and exit", " --dry-run append-block-item: validate the prospective file, write nothing", " --version, -v print the pi-context version and exit", " --help, -h this help, or per-op help after an op name");
313
702
  return lines.join("\n");
314
703
  }
315
704
  /** Prompt the operator on an interactive TTY. Resolves true on y/yes. */
@@ -323,12 +712,53 @@ function promptConfirm(opName) {
323
712
  });
324
713
  });
325
714
  }
715
+ /**
716
+ * FGAP-021 — extract the renderable row array from an OpResult for `--format table`,
717
+ * or null when the result is not a complete tabular collection. Precedence:
718
+ * - `{read}` whose `data` is an array AND `complete !== false` (an over-cap read,
719
+ * complete:false with data:null, is NOT tabular) → that array;
720
+ * - `{read}` whose `data` is an object exposing an `items` array → `.items`
721
+ * (the paged-collection shape: {items,total,hasMore});
722
+ * - `{json}` whose value is an array → that array;
723
+ * - anything else (prose, scalar/object data, over-cap, non-array) → null,
724
+ * so the dispatch falls back to text rather than emit a degenerate table.
725
+ */
726
+ function tabularRows(r) {
727
+ if (typeof r === "string")
728
+ return null;
729
+ if ("read" in r) {
730
+ const read = r.read;
731
+ if (read.complete === false)
732
+ return null;
733
+ if (Array.isArray(read.data))
734
+ return read.data;
735
+ if (read.data !== null && typeof read.data === "object") {
736
+ const items = read.data.items;
737
+ if (Array.isArray(items))
738
+ return items;
739
+ }
740
+ return null;
741
+ }
742
+ if ("json" in r && Array.isArray(r.json))
743
+ return r.json;
744
+ return null;
745
+ }
326
746
  export async function main(argv) {
327
747
  const first = argv[0];
328
748
  if (first === undefined || first === "--help" || first === "-h") {
329
749
  process.stdout.write(`${deriveTopHelp()}\n`);
330
750
  return 0;
331
751
  }
752
+ if (first === "--version" || first === "-v") {
753
+ // CHANGE B — print the package version (read once at module load, build-safe
754
+ // relative to dist/cli.js) and exit. STDOUT, exit 0. Placed before the
755
+ // pi-bound branch and resolveOp so `--version`/`-v` are never mistaken for ops.
756
+ process.stdout.write(`pi-context ${PKG_VERSION}\n`);
757
+ return 0;
758
+ }
759
+ if (first === "pi-bound") {
760
+ return runPiBound(argv.slice(1));
761
+ }
332
762
  const op = resolveOp(first);
333
763
  if (op === undefined) {
334
764
  if (isProcessOnlyOp(first)) {
@@ -351,10 +781,115 @@ export async function main(argv) {
351
781
  throw err;
352
782
  }
353
783
  if (parsed.help) {
354
- process.stdout.write(`${deriveHelp(op)}\n`);
784
+ // `--help --format json` (or `--help --json`) emits the machine-readable
785
+ // HelpModel; otherwise the text template. table → text fallback (only json
786
+ // diverges). parsed.format/parsed.json are already populated by parseOpArgs.
787
+ const helpFormat = parsed.format ?? (parsed.json ? "json" : "text");
788
+ if (helpFormat === "json") {
789
+ process.stdout.write(`${JSON.stringify(buildHelpModel(op))}\n`);
790
+ }
791
+ else {
792
+ process.stdout.write(`${deriveHelp(op)}\n`);
793
+ }
794
+ return 0;
795
+ }
796
+ // FGAP-022 — `--show-schema`: preview the block contract (array_key / required /
797
+ // field types / id pattern) and exit 0 BEFORE any write. Only meaningful for the
798
+ // block-mutation ops (the ops that declare `arrayKey` and take a `--block`); on any
799
+ // other op it is a misuse (exit 2). Reads the installed schema through the lifted
800
+ // readSchema → resolveBlockItemSchema path (no op change).
801
+ if (parsed.showSchema) {
802
+ const opProps = objectSchema(op).properties ?? {};
803
+ if (opProps.arrayKey === undefined || typeof parsed.params.block !== "string") {
804
+ process.stderr.write(`error: --show-schema applies only to a block op with --block <name>\n`);
805
+ return 2;
806
+ }
807
+ const block = parsed.params.block;
808
+ const schema = readSchema(parsed.cwd, block);
809
+ if (schema === null) {
810
+ process.stderr.write(`error: schema not found for block ${block}\n`);
811
+ return 3;
812
+ }
813
+ const { arrayKey, itemSchema } = resolveBlockItemSchema(schema);
814
+ const props = (itemSchema.properties ?? {});
815
+ const required = (itemSchema.required ?? []);
816
+ const lines = [
817
+ `Block: ${block}`,
818
+ `Array key: ${arrayKey}`,
819
+ `Required fields: ${required.join(", ")}`,
820
+ "All fields:",
821
+ ];
822
+ for (const [name, fschema] of Object.entries(props)) {
823
+ const enumVals = Array.isArray(fschema.enum) ? fschema.enum : null;
824
+ const type = typeof fschema.type === "string"
825
+ ? fschema.type
826
+ : typeof fschema.$ref === "string"
827
+ ? fschema.$ref
828
+ : enumVals
829
+ ? "enum"
830
+ : "object";
831
+ const enumSuffix = enumVals ? ` [enum: ${enumVals.join(", ")}]` : "";
832
+ lines.push(` - ${name}: ${type}${enumSuffix}`);
833
+ }
834
+ const idPattern = props.id?.pattern ?? "(none)";
835
+ lines.push(`ID pattern: ${idPattern}`);
836
+ process.stdout.write(`${lines.join("\n")}\n`);
355
837
  return 0;
356
838
  }
357
- injectWriter(op, parsed.params, resolveIdentity());
839
+ const identity = resolveIdentity();
840
+ injectWriter(op, parsed.params, identity);
841
+ injectArrayKey(op, parsed.params, parsed.cwd);
842
+ // FGAP-024 — append-block-item `--dry-run`: client-side prospective-whole-file
843
+ // validation. Replicates the op's autoId allocation, builds the prospective file
844
+ // {...existing, [arrayKey]: [...items, item]}, and validates it against the WHOLE
845
+ // block schema (matching exactly what appendToBlock validates on write) — then
846
+ // RETURNS before the auth/op-run block, so the frozen op is never invoked and
847
+ // nothing is written. The `--dryRun` flag itself never enters parsed.params, so
848
+ // the op would never see it even if reached.
849
+ if (op.name === "append-block-item" && parsed.dryRun) {
850
+ const block = parsed.params.block;
851
+ const arrayKey = parsed.params.arrayKey;
852
+ if (typeof arrayKey !== "string") {
853
+ process.stderr.write(`error: cannot resolve array key for block ${block}\n`);
854
+ return 2;
855
+ }
856
+ let item = (parsed.params.item ?? {});
857
+ if (parsed.params.autoId && (item == null || item.id === undefined)) {
858
+ try {
859
+ item = { ...item, id: nextId(parsed.cwd, block) };
860
+ }
861
+ catch (e) {
862
+ process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`);
863
+ return 4;
864
+ }
865
+ }
866
+ let existing = {};
867
+ try {
868
+ existing = readBlock(parsed.cwd, block);
869
+ }
870
+ catch {
871
+ existing = {};
872
+ }
873
+ const items = Array.isArray(existing[arrayKey]) ? existing[arrayKey] : [];
874
+ const prospective = { ...existing, [arrayKey]: [...items, item] };
875
+ try {
876
+ validateFromFile(schemaPath(parsed.cwd, block), prospective, `${block}.${arrayKey}[item]`);
877
+ }
878
+ catch (e) {
879
+ if (isValidationError(e)) {
880
+ process.stderr.write(`error: ${formatAjvError(e)}\n`);
881
+ return 5;
882
+ }
883
+ if (/schema (file )?not found/i.test(String(e.message))) {
884
+ process.stderr.write(`error: ${e.message}\n`);
885
+ return 3;
886
+ }
887
+ throw e;
888
+ }
889
+ process.stdout.write(`[dry-run] PASS${item.id ? ` — would append ${item.id}` : ""}\n`);
890
+ return 0;
891
+ }
892
+ const dctx = buildCliDispatchContext(parsed.explicitWriter, identity);
358
893
  const decision = authDecision(op, {
359
894
  yes: parsed.yes,
360
895
  interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
@@ -372,25 +907,110 @@ export async function main(argv) {
372
907
  return 1;
373
908
  }
374
909
  }
910
+ // Resolve the effective render. `--format` wins; absent it, `--json` → json,
911
+ // else text — so `--json` and `--format json` are exact aliases.
912
+ const format = parsed.format ?? (parsed.json ? "json" : "text");
375
913
  try {
376
- const text = await op.run(parsed.cwd, parsed.params);
377
- if (parsed.json) {
378
- process.stdout.write(`${JSON.stringify({ ok: true, op: op.name, output: text })}\n`);
914
+ const r = await op.run(parsed.cwd, parsed.params, dctx);
915
+ if (format === "json") {
916
+ // FGAP-013: emit `output` as a JSON VALUE, not a stringified JSON string.
917
+ // Prose → the string itself; a read op → its structured ReadStructured
918
+ // (data + paging/cap metadata); a data op → its raw JSON value. No
919
+ // double-encode: the value is placed directly into the envelope and
920
+ // JSON.stringify'd ONCE here.
921
+ // TASK-013 / FGAP-015: boundedJsonOutput enforces the 50KB read cap at this
922
+ // boundary — under-cap values pass through unchanged; an over-cap `{json}`
923
+ // (or prose) result fails closed (`{ data: null, truncated: true, … }` /
924
+ // REFUSAL string) so a `{json}` op can no longer leak substrate content
925
+ // past the cap on the `--json` surface.
926
+ const output = boundedJsonOutput(r);
927
+ process.stdout.write(`${JSON.stringify({ ok: true, op: op.name, output })}\n`);
928
+ }
929
+ else if (format === "table") {
930
+ // FGAP-021 — extract the renderable array, falling back to text whenever the
931
+ // result is not a complete tabular collection (over-cap, prose, or a non-array
932
+ // data op) so a degenerate one-row table never substitutes for the real output.
933
+ const arr = tabularRows(r);
934
+ if (arr !== null) {
935
+ process.stdout.write(`${renderTable(arr)}\n`);
936
+ }
937
+ else {
938
+ process.stdout.write(`${renderOpResultText(r)}\n`);
939
+ }
379
940
  }
380
941
  else {
381
- process.stdout.write(`${text}\n`);
942
+ // Default text surface stays byte-identical to before: the shared renderer
943
+ // reproduces each op's prior `run()` text (prose / JSON.stringify / read footer).
944
+ // Ops declaring `verbatimText` (e.g. read-catalog-schema) emit their string
945
+ // OpResult byte-exact — reproducing the file's own bytes (its single trailing
946
+ // `\n` included) WITHOUT appending a second one — so the output round-trips to
947
+ // a file / diffs cleanly against an on-disk source whose bytes it reproduces
948
+ // verbatim (the catalog schema file carries its own trailing `\n`; appending
949
+ // another would double it to `}\n\n` and show a phantom trailing-empty-line).
950
+ if (op.verbatimText) {
951
+ process.stdout.write(renderOpResultText(r));
952
+ }
953
+ else {
954
+ process.stdout.write(`${renderOpResultText(r)}\n`);
955
+ }
956
+ }
957
+ // TASK-037 — FEAT-006 T4: the `update` op returns the whole UpdateResult under
958
+ // `{ json }`; if it recorded any irreconcilable 3-way-merge conflicts, the CLI
959
+ // SURFACES them — it does NOT spawn a subordinate resolver. The CALLING agent
960
+ // reconciles via the existing `read-schema` / `write-schema` ops. On a NON-json
961
+ // surface (text or table), render the conflict report (renderConflicts carries the
962
+ // reconcile-via-write-schema guidance line) below the op's own output. Under
963
+ // `--format json` the structured `conflicts` array already prints in the op-result
964
+ // envelope above — do NOT double-emit. A non-`update` op, or an `update` with no
965
+ // conflicts, is a no-op here.
966
+ if (format !== "json" && op.name === "update" && r && typeof r === "object" && "json" in r) {
967
+ const update = r.json;
968
+ const conflicts = update?.conflicts;
969
+ if (Array.isArray(conflicts) && conflicts.length > 0) {
970
+ process.stdout.write(`${renderConflicts(conflicts)}\n`);
971
+ }
972
+ // TASK-048 — FGAP-077: surface the per-schema blocked-resync diagnostic
973
+ // (reason, version pair, per-item failures) below the op's own output on the
974
+ // non-json surface. Under --format json the structured blockedDetail array
975
+ // already prints in the op-result envelope above — do NOT double-emit.
976
+ const blockedDetail = update?.blockedDetail;
977
+ if (Array.isArray(blockedDetail) && blockedDetail.length > 0) {
978
+ process.stdout.write(`${renderBlocked(blockedDetail)}\n`);
979
+ }
382
980
  }
383
981
  return 0;
384
982
  }
385
983
  catch (err) {
386
- const message = err instanceof Error ? err.message : String(err);
387
- if (parsed.json) {
984
+ // FGAP-023 an AJV ValidationError surfaces field-named guidance (which field,
985
+ // what constraint) rather than the raw concatenated `.message`. The shaped message
986
+ // flows through both the `--json` envelope and the stderr line below.
987
+ const message = isValidationError(err) ? formatAjvError(err) : err instanceof Error ? err.message : String(err);
988
+ if (format === "json") {
388
989
  process.stdout.write(`${JSON.stringify({ ok: false, op: op.name, error: message })}\n`);
389
990
  }
390
991
  else {
391
992
  process.stderr.write(`error: ${message}\n`);
392
993
  }
393
- return 1;
994
+ // FGAP-026 — granular exit codes distinguishing error classes. Name/message-based
995
+ // (instanceof is unreliable across the package boundary): validation → 5;
996
+ // not-initialized / BootstrapNotFoundError → 1 (generic runtime); schema-absent →
997
+ // 3; id-allocation failure → 4; everything else → 1. Usage/arg errors are 2,
998
+ // classified earlier at the UsageError catch. The message emit above is unchanged.
999
+ // Ordering matters: schema-absent is tested BEFORE id-allocation because the
1000
+ // schema-missing throw from nextId ("nextId: schema not found for block …") also
1001
+ // matches the id-allocation pattern — its true cause is the absent schema (→ 3),
1002
+ // not an allocation failure (→ 4). The genuine allocation throws (no id.pattern;
1003
+ // not prefix+width parseable) do not contain "schema not found" and stay 4.
1004
+ let code = 1;
1005
+ if (isValidationError(err))
1006
+ code = 5;
1007
+ else if (err instanceof Error && err.name === "BootstrapNotFoundError")
1008
+ code = 1;
1009
+ else if (err instanceof Error && /schema (file )?not found/i.test(err.message))
1010
+ code = 3;
1011
+ else if (err instanceof Error && /nextId|id pattern|allocate/i.test(err.message))
1012
+ code = 4;
1013
+ return code;
394
1014
  }
395
1015
  }
396
1016
  //# sourceMappingURL=cli.js.map