@taurgis/bonsai 3.2.0 → 3.2.2

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 (162) hide show
  1. package/README.md +7 -4
  2. package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +9 -17
  5. package/dist/base-command.d.ts +22 -12
  6. package/dist/base-command.js +86 -49
  7. package/dist/commands/config/base.js +11 -2
  8. package/dist/commands/config/get.js +8 -12
  9. package/dist/commands/config/index.js +2 -2
  10. package/dist/commands/config/list.js +10 -16
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +34 -27
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +25 -24
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +57 -401
  17. package/dist/commands/import.d.ts +4 -9
  18. package/dist/commands/import.js +56 -284
  19. package/dist/commands/inspect.d.ts +7 -1
  20. package/dist/commands/inspect.js +103 -52
  21. package/dist/commands/list.d.ts +13 -1
  22. package/dist/commands/list.js +79 -32
  23. package/dist/commands/prune.d.ts +4 -1
  24. package/dist/commands/prune.js +55 -75
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +52 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +1 -0
  28. package/dist/hooks/command-not-found/suggest.js +66 -39
  29. package/dist/lib/argv.d.ts +24 -3
  30. package/dist/lib/argv.js +124 -53
  31. package/dist/lib/batch.d.ts +41 -0
  32. package/dist/lib/batch.js +39 -0
  33. package/dist/lib/cache-view.d.ts +21 -0
  34. package/dist/lib/cache-view.js +30 -0
  35. package/dist/lib/cli-emit.d.ts +28 -0
  36. package/dist/lib/cli-emit.js +44 -0
  37. package/dist/lib/cli-error-policy.d.ts +57 -0
  38. package/dist/lib/cli-error-policy.js +74 -0
  39. package/dist/lib/cli-flag-manifest.d.ts +5 -0
  40. package/dist/lib/cli-flag-manifest.js +8 -0
  41. package/dist/lib/cli-presentation.d.ts +39 -0
  42. package/dist/lib/cli-presentation.js +50 -0
  43. package/dist/lib/cli-result-types.d.ts +88 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +21 -3
  46. package/dist/lib/config/index.js +58 -3
  47. package/dist/lib/config-persist.d.ts +28 -0
  48. package/dist/lib/config-persist.js +29 -0
  49. package/dist/lib/envelope.d.ts +41 -3
  50. package/dist/lib/envelope.js +147 -4
  51. package/dist/lib/help-preflight.d.ts +5 -8
  52. package/dist/lib/help-preflight.js +13 -32
  53. package/dist/lib/json-meta.d.ts +0 -2
  54. package/dist/lib/json-meta.js +21 -25
  55. package/dist/lib/limit-flag.d.ts +11 -1
  56. package/dist/lib/limit-flag.js +19 -6
  57. package/dist/lib/parse-error-ux.d.ts +5 -0
  58. package/dist/lib/parse-error-ux.js +73 -0
  59. package/dist/lib/prune-flags.d.ts +28 -0
  60. package/dist/lib/prune-flags.js +74 -0
  61. package/dist/lib/research/artifact-index.d.ts +11 -2
  62. package/dist/lib/research/artifact-index.js +7 -4
  63. package/dist/lib/research/browser.d.ts +41 -2
  64. package/dist/lib/research/browser.js +139 -50
  65. package/dist/lib/research/capture.d.ts +24 -2
  66. package/dist/lib/research/capture.js +24 -4
  67. package/dist/lib/research/cli-io.d.ts +33 -0
  68. package/dist/lib/research/cli-io.js +27 -0
  69. package/dist/lib/research/compress.d.ts +7 -0
  70. package/dist/lib/research/compress.js +7 -3
  71. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  72. package/dist/lib/research/docs/capabilities.js +5 -0
  73. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  74. package/dist/lib/research/docs/clean-dom.js +2 -0
  75. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  76. package/dist/lib/research/docs/code-blocks.js +2 -0
  77. package/dist/lib/research/docs/detect.d.ts +4 -0
  78. package/dist/lib/research/docs/detect.js +4 -0
  79. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  80. package/dist/lib/research/docs/machine-readable.js +10 -0
  81. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  82. package/dist/lib/research/docs/markdown-source.js +19 -2
  83. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  84. package/dist/lib/research/docs/quality-gates.js +4 -0
  85. package/dist/lib/research/docs/rsc.d.ts +9 -2
  86. package/dist/lib/research/docs/rsc.js +5 -2
  87. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  88. package/dist/lib/research/docs/section-artifacts.js +12 -0
  89. package/dist/lib/research/docs/sections.d.ts +18 -0
  90. package/dist/lib/research/docs/sections.js +17 -1
  91. package/dist/lib/research/docs/source-map.d.ts +28 -0
  92. package/dist/lib/research/docs/source-map.js +27 -0
  93. package/dist/lib/research/docs/validate.d.ts +35 -2
  94. package/dist/lib/research/docs/validate.js +47 -2
  95. package/dist/lib/research/extract.d.ts +16 -0
  96. package/dist/lib/research/extract.js +12 -1
  97. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  98. package/dist/lib/research/fetch-command-service.js +310 -0
  99. package/dist/lib/research/fetch-result.d.ts +114 -0
  100. package/dist/lib/research/fetch-result.js +212 -0
  101. package/dist/lib/research/fetcher.d.ts +46 -8
  102. package/dist/lib/research/fetcher.js +58 -30
  103. package/dist/lib/research/freshness.d.ts +54 -11
  104. package/dist/lib/research/freshness.js +91 -31
  105. package/dist/lib/research/import-command-service.d.ts +56 -0
  106. package/dist/lib/research/import-command-service.js +381 -0
  107. package/dist/lib/research/keywords.js +5 -1
  108. package/dist/lib/research/markdown.d.ts +12 -1
  109. package/dist/lib/research/markdown.js +13 -5
  110. package/dist/lib/research/persist-artifact.d.ts +23 -0
  111. package/dist/lib/research/persist-artifact.js +27 -0
  112. package/dist/lib/research/prompt-injection.d.ts +8 -0
  113. package/dist/lib/research/prompt-injection.js +42 -13
  114. package/dist/lib/research/proxy.d.ts +14 -8
  115. package/dist/lib/research/proxy.js +14 -10
  116. package/dist/lib/research/revalidate.d.ts +49 -20
  117. package/dist/lib/research/revalidate.js +80 -15
  118. package/dist/lib/research/schema.d.ts +7 -0
  119. package/dist/lib/research/schema.js +2 -0
  120. package/dist/lib/research/secret-scan.d.ts +6 -0
  121. package/dist/lib/research/secret-scan.js +8 -0
  122. package/dist/lib/research/secure-write.d.ts +4 -0
  123. package/dist/lib/research/secure-write.js +31 -2
  124. package/dist/lib/research/storage.d.ts +5 -1
  125. package/dist/lib/research/storage.js +8 -3
  126. package/dist/lib/research/summarize.d.ts +15 -0
  127. package/dist/lib/research/summarize.js +11 -0
  128. package/dist/lib/research/url.d.ts +9 -6
  129. package/dist/lib/research/url.js +33 -21
  130. package/dist/lib/text.d.ts +7 -2
  131. package/dist/lib/text.js +13 -2
  132. package/dist/lib/value-flags.d.ts +14 -0
  133. package/dist/lib/value-flags.js +28 -0
  134. package/dist/sites/coveo.d.ts +10 -0
  135. package/dist/sites/coveo.js +10 -0
  136. package/dist/sites/index.d.ts +16 -0
  137. package/dist/sites/index.js +16 -4
  138. package/dist/sites/markdown-twin.d.ts +31 -0
  139. package/dist/sites/markdown-twin.js +56 -0
  140. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  141. package/dist/sites/salesforce/fetch-page.js +14 -3
  142. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  143. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  144. package/dist/sites/salesforce/index.d.ts +1 -0
  145. package/dist/sites/salesforce/index.js +1 -0
  146. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  147. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  148. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  149. package/dist/sites/salesforce-developer/index.js +1 -0
  150. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  151. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  152. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  153. package/dist/sites/salesforce-doc-fetch.js +52 -7
  154. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  155. package/dist/sites/salesforce-dom-probe.js +20 -2
  156. package/dist/sites/types.d.ts +14 -8
  157. package/dist/sites/types.js +3 -0
  158. package/oclif.manifest.json +104 -98
  159. package/package.json +19 -7
  160. package/agents/agents/official-docs-researcher.agent.md +0 -87
  161. package/agents/instructions/web-research.instructions.md +0 -44
  162. package/agents/skills/web-research/SKILL.md +0 -118
package/README.md CHANGED
@@ -110,7 +110,7 @@ Common flags:
110
110
  * `--max-age`: Read-time freshness threshold.
111
111
  * `--force`: Force a fresh fetch.
112
112
  * `--dry-run`: Scrape and validate without writing to cache.
113
- * `--allow-stale`: Serve stale cache if the remote site is offline.
113
+ * `--allow-stale`: Suppress the exit `5` signal when a within-grace stale entry is served after failed revalidation (see [Freshness and Cache Rules](#freshness-and-cache-rules) below). Stale content is served within the grace window regardless of this flag; it does not extend serving beyond the grace window.
114
114
  * `--rendered`: Use browser-rendered extraction for SPA pages.
115
115
  * `--read-only` (alias `--plan`): Block all filesystem writes/deletes across every command; network fetches still run. See [Read-only / plan mode](#read-only--plan-mode) below.
116
116
  * `--json`: Return structured machine-readable output.
@@ -181,7 +181,7 @@ npx @taurgis/bonsai import https://example.com/docs --file notes.md --read-only
181
181
  npx @taurgis/bonsai prune --older-than 90d --plan
182
182
  ```
183
183
 
184
- It can also be set via the `BONSAI_READ_ONLY` or `BONSAI_PLAN_MODE` environment variables (both are equivalent). Composition is OR, not override: read-only mode is active if *either* env var or the flag is set, and there is no way to force writes back on once one of them is — this is a safety gate, not a preference. Commands that already expose `--dry-run` (`fetch`, `prune`, `config set`/`unset`) treat it the same as an explicit `--dry-run`; `import` (which has no `--dry-run` of its own) previews the write and reports `dryRun: true` instead of persisting.
184
+ It can also be set via the `BONSAI_READ_ONLY` or `BONSAI_PLAN_MODE` environment variables (both are equivalent). Composition is OR, not override: read-only mode is active if *either* env var or the flag is set, and there is no way to force writes back on once one of them is — this is a safety gate, not a preference. Commands that expose `--dry-run` (`fetch`, `import`, `prune`, `config set`/`unset`) treat global read-only the same as an explicit `--dry-run` and report `dryRun: true` (with `would_*` cache statuses where applicable) instead of persisting.
185
185
 
186
186
  ---
187
187
 
@@ -235,7 +235,10 @@ If no `--ttl` is specified, freshness is computed from the tier:
235
235
  | `standard` | 30 days | 14 days | General API docs and developer guides |
236
236
  | `stable` | 180 days | 60 days | RFCs, standards, long-lived references |
237
237
 
238
- When stale entries still have `ETag` or `Last-Modified` metadata, Bonsai attempts cheap revalidation before a full refetch. If revalidation fails and `--allow-stale` is omitted, the CLI can serve the stale content while exiting with code `5`.
238
+ When stale entries still have `ETag` or `Last-Modified` metadata, Bonsai attempts cheap revalidation before a full refetch. If revalidation fails:
239
+
240
+ * **Within the grace window**, the stale content is served either way. By default this exits with code `5` so a caller can detect "this result may be outdated"; `--allow-stale` suppresses that signal and exits `0` instead, since the caller has already said outdated content is acceptable.
241
+ * **Past the grace window**, the entry is expired and revalidation failure is always a hard error (exit `1`) — `--allow-stale` has no effect here, since there is no fresher fallback to serve.
239
242
 
240
243
  ---
241
244
 
@@ -303,7 +306,7 @@ When run with `--json`, commands return a stable envelope:
303
306
  | `0` | Success. |
304
307
  | `1` | Runtime failure (network error, fetch failed, file read error). |
305
308
  | `2` | Usage error (invalid flag, bad URL, missing required argument). |
306
- | `5` | Stale content served — the cache entry is past its grace window but `--allow-stale` is set. The returned content is still usable. |
309
+ | `5` | Stale content served — revalidation failed within the grace window and `--allow-stale` was omitted. The returned content is still usable. |
307
310
 
308
311
  ---
309
312
 
@@ -5,7 +5,7 @@ model: 'Auto'
5
5
  tools: ['vscode/askQuestions', 'execute', 'read', 'search', 'web', 'vscode/memory']
6
6
  argument-hint: 'What Salesforce topic should I research in official docs?'
7
7
  metadata:
8
- version: '2.0.0'
8
+ version: '2.1.0'
9
9
  ---
10
10
 
11
11
  # Salesforce Docs Researcher Agent
@@ -22,6 +22,8 @@ Do not research from memory alone. Training-data knowledge does not satisfy this
22
22
 
23
23
  Run Bonsai as `npx @taurgis/bonsai ...`. Add `--json` when you need structured output.
24
24
 
25
+ If you are operating in a read-only/plan mode (no filesystem writes allowed), add `--read-only` (alias `--plan`) to every Bonsai call, or export `BONSAI_READ_ONLY=1` (or `BONSAI_PLAN_MODE=1`) once for the session — fetches still run, but nothing is persisted to the local cache or config. See "Read-only / Plan Mode" below.
26
+
25
27
  ## Default Workflow
26
28
 
27
29
  1. Identify the product, version, edition, and source authority needed for the request.
@@ -68,6 +70,17 @@ Choose the freshness tier based on the source:
68
70
 
69
71
  Stale volatile sources must be revalidated before they are trusted. Never overwrite a good cache entry with an error response.
70
72
 
73
+ ## Read-only / Plan Mode
74
+
75
+ Bonsai supports a global `--read-only` flag (alias `--plan`), also honored via the `BONSAI_READ_ONLY`/`BONSAI_PLAN_MODE` environment variables, that blocks all filesystem writes and deletes (cache persistence, config writes, `prune` deletions) while still allowing network fetches to run. Use it whenever you are invoked under a read-only/plan-mode harness so this research workflow stays non-destructive:
76
+
77
+ - Research fetches (`npx @taurgis/bonsai <url>`), including the rendered Salesforce site-module fetches above, still hit the network and return content normally; they just skip writing the result to the local cache.
78
+ - `import` previews the write and reports `dryRun: true` instead of persisting.
79
+ - `config set` / `config unset` preview the write and report `would_set` / `would_unset`.
80
+ - `prune` treats `--read-only` like an implicit dry run; combining it with `--yes` is rejected.
81
+
82
+ Composition is OR: once read-only mode is active (flag or either env var), there is no per-call way to force writes back on.
83
+
71
84
  ## Fallbacks
72
85
 
73
86
  If direct web access was unavoidable because Bonsai could not fetch the content, import the synthesized notes back into Bonsai before returning:
@@ -4,7 +4,7 @@ applyTo: '**'
4
4
  skills:
5
5
  - web-research
6
6
  metadata:
7
- version: '2.0.0'
7
+ version: '2.1.0'
8
8
  ---
9
9
 
10
10
  # Web Research Requirement (Salesforce)
@@ -29,6 +29,7 @@ metadata:
29
29
  - **Browse cached entries**: Use `npx @taurgis/bonsai list` when you need to see what is already cached by topic, tags, or freshness.
30
30
  - If Bonsai is configured for project storage and `.bonsai/research/` is not ignored by git, treat those cache artifacts as intentional shared project files. It is OK to check them in, and agents must not delete them as incidental generated output without an explicit request.
31
31
  - Re-running on a recent topic is cheap — research the topic rather than skipping it to "save" a fetch.
32
+ - If you are operating under a read-only/plan-mode harness, add `--read-only` (alias `--plan`) to Bonsai calls, or rely on `BONSAI_READ_ONLY`/`BONSAI_PLAN_MODE` if the harness sets it — fetches still run, but nothing is persisted to the local cache or config; `config set` / `config unset` preview with `would_set` / `would_unset`.
32
33
 
33
34
  ## Working with Salesforce Sites
34
35
 
package/bin/cli.mjs CHANGED
@@ -7,6 +7,8 @@ import updateNotifier from 'update-notifier';
7
7
  import { createRequire } from 'node:module';
8
8
 
9
9
  import { normalizeArgv } from '../dist/lib/argv.js';
10
+ import { VALUE_TAKING_FLAG_TOKENS } from '../dist/lib/cli-flag-manifest.js';
11
+ import { exitWithPreflight } from '../dist/lib/cli-emit.js';
10
12
  import { tryUnknownHelpOutput } from '../dist/lib/help-preflight.js';
11
13
  import { tryJsonMetaOutput } from '../dist/lib/json-meta.js';
12
14
 
@@ -21,35 +23,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
21
23
  // trying to resolve the bare URL as a command — which crashes with a stack trace. Rewrite
22
24
  // process.argv itself so both the run path and the help/error path agree.
23
25
  const rawArgv = process.argv.slice(2);
24
- const result = normalizeArgv(rawArgv);
26
+ const result = normalizeArgv(rawArgv, { valueTakingFlags: VALUE_TAKING_FLAG_TOKENS });
25
27
  const root = __dirname + '/../';
26
28
 
27
- if (result.exitWithJson) {
28
- process.exitCode = result.exitWithJson.exitCode;
29
- console.log(JSON.stringify(result.exitWithJson.envelope, null, 2));
30
- process.exit();
29
+ if (result.earlyExit) {
30
+ exitWithPreflight(result.earlyExit);
31
31
  }
32
32
 
33
33
  const unknownHelp = await tryUnknownHelpOutput(result.argv, root);
34
34
  if (unknownHelp) {
35
- process.exitCode = unknownHelp.exitCode;
36
- if (unknownHelp.kind === 'json') {
37
- console.log(JSON.stringify(unknownHelp.envelope, null, 2));
38
- } else {
39
- console.error(` › Error: ${unknownHelp.message.replaceAll('\n', '\n › ')}`);
40
- }
41
- process.exit();
35
+ exitWithPreflight(unknownHelp);
42
36
  }
43
37
 
44
38
  const jsonMeta = await tryJsonMetaOutput(result.argv, root);
45
39
  if (jsonMeta) {
46
- process.exitCode = jsonMeta.exitCode;
47
- console.log(JSON.stringify(jsonMeta.envelope, null, 2));
48
- process.exit();
40
+ exitWithPreflight({ ...jsonMeta, json: true });
49
41
  }
50
42
 
51
- // Only notify after the exitWithJson fast-path so the notifier never fires before
52
- // a JSON envelope exits. Also skip under --json so agent callers never see stderr noise.
43
+ // Only notify after the earlyExit fast-path so the notifier never fires before
44
+ // a usage-error envelope exits. Also skip under --json so agent callers never see stderr noise.
53
45
  if (!process.argv.includes('--json')) {
54
46
  updateNotifier({ pkg }).notify();
55
47
  }
@@ -1,5 +1,6 @@
1
- import { Command, Interfaces } from '@oclif/core';
1
+ import { Command, Errors, Interfaces } from '@oclif/core';
2
2
  import { type ResolveResearchTargetOptions, type ResolvedResearchTarget } from './lib/research/resolve-target.js';
3
+ import { type CliIo } from './lib/research/cli-io.js';
3
4
  /**
4
5
  * Shared base for every Bonsai command. Enables oclif's native `--json` flag,
5
6
  * parses args/flags once in `init()` so commands read `this.args`/`this.flags`
@@ -36,12 +37,6 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
36
37
  * write, so a skipped write is never a silent surprise to the caller.
37
38
  */
38
39
  protected effectiveDryRun(explicitDryRun: boolean): boolean;
39
- /**
40
- * The exit code an error maps to. oclif `CLIError`s carry it in `err.oclif.exit`; other errors may
41
- * expose `err.exitCode`. Defined once so the process exit code (`catch`) and the JSON envelope
42
- * (`toErrorJson`) can never disagree — a mismatch between these two was itself the bug this fixes.
43
- */
44
- private static exitCodeOf;
45
40
  /**
46
41
  * Align the process exit code with the code reported in the JSON envelope. oclif's default
47
42
  * `catch` sets `process.exitCode = err.exitCode ?? 1`, but oclif `CLIError`s carry their code in
@@ -56,6 +51,7 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
56
51
  };
57
52
  exitCode?: number;
58
53
  code?: string;
54
+ suggestions?: string[];
59
55
  }): Promise<any>;
60
56
  /** Command id for the JSON envelope; falls back to the binary name when a command has no id. */
61
57
  protected envelopeCommandId(): string;
@@ -66,16 +62,30 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
66
62
  * stays INVALID_URL.
67
63
  */
68
64
  protected resolveResearchTargetOrFail(url: string, extra?: Pick<ResolveResearchTargetOptions, 'flagOverride' | 'lookup'>): ResolvedResearchTarget;
69
- /**
70
- * Single exit point for a URL that failed normalization, shared by every URL-accepting command.
71
- * The scheme-less message is already self-contained and actionable, so it is surfaced as-is;
72
- * other parse failures keep the "Invalid URL:" prefix for context.
73
- */
65
+ /** Exit for a URL that failed normalization; see {@link failInvalidUrl} for the code contract. */
74
66
  protected failInvalidUrl(url: string, message: string): never;
67
+ /** Command context handed to command services (fetch/import) so they stay oclif-free. */
68
+ protected cliIo(): CliIo;
75
69
  /** Single source of truth for the `--json` envelope shape, shared by success and error output. */
76
70
  private envelope;
71
+ /**
72
+ * Base success envelope from `process.exitCode` (including exit 5 = served stale / ok).
73
+ * Subclasses that overlay batch failures must call this — not `toSuccessJson` — to avoid
74
+ * recursing through their own override.
75
+ */
76
+ protected baseSuccessJson(data: unknown): Record<string, unknown>;
77
77
  /** Wrap a command's return value in the machine-readable envelope emitted under `--json`. */
78
78
  protected toSuccessJson(data: unknown): Record<string, unknown>;
79
+ /**
80
+ * Success overlay for multi-URL read commands (`status`, `inspect`). Apply CACHE_MISS first,
81
+ * then row `.error` so validation failures win when both appear in one batch.
82
+ */
83
+ protected batchReadSuccessJson(data: unknown): Record<string, unknown>;
84
+ /**
85
+ * Map each URL through `fn`. In a multi-URL batch, per-URL CLIErrors become failure rows so
86
+ * prior hits are kept — matching fetch's INVALID_URL / MISSING_URL_SCHEME batch contract.
87
+ */
88
+ protected mapUrlsAllowingBatchErrors<T, F>(urls: string[], fn: (url: string) => T, failureRow: (url: string, err: InstanceType<typeof Errors.CLIError>) => F): Array<T | F>;
79
89
  /** Mirror the success envelope for failures so JSON consumers get one consistent shape. */
80
90
  protected toErrorJson(err: unknown): Record<string, unknown>;
81
91
  }
@@ -1,8 +1,11 @@
1
1
  import { Command, Errors, Flags, toConfiguredId } from '@oclif/core';
2
2
  import { invalidEnvOverrideWarnings, resolveReadOnly } from './lib/config/index.js';
3
- import { buildEnvelope, formatErrorForJson, normalizeCliErrorMessage, stableErrorCodeFrom, } from './lib/envelope.js';
3
+ import { CLI_FLAG_DESCRIPTIONS } from './lib/cli-presentation.js';
4
+ import { enrichErrorForDisplay, resolveExitCode, prepareCliError, EXIT_OK, EXIT_STALE_SERVED, } from './lib/cli-error-policy.js';
5
+ import { buildEnvelope, enrichCacheMissEnvelope, enrichRowErrorEnvelope, formatErrorForJson, } from './lib/envelope.js';
6
+ import { enrichParseError } from './lib/parse-error-ux.js';
4
7
  import { resolveResearchTarget, } from './lib/research/resolve-target.js';
5
- import { looksLikeSchemelessUrl } from './lib/research/url.js';
8
+ import { failInvalidUrl } from './lib/research/cli-io.js';
6
9
  /**
7
10
  * Shared base for every Bonsai command. Enables oclif's native `--json` flag,
8
11
  * parses args/flags once in `init()` so commands read `this.args`/`this.flags`
@@ -21,8 +24,7 @@ export class BaseCommand extends Command {
21
24
  'read-only': Flags.boolean({
22
25
  aliases: ['plan'],
23
26
  default: false,
24
- description: 'Block all filesystem writes/deletes (cache and config); network fetches still run. ' +
25
- 'Also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE — enable for agent read-only/plan modes.',
27
+ description: CLI_FLAG_DESCRIPTIONS.readOnly,
26
28
  }),
27
29
  };
28
30
  flags;
@@ -71,14 +73,6 @@ export class BaseCommand extends Command {
71
73
  }
72
74
  return explicitDryRun || this.readOnly;
73
75
  }
74
- /**
75
- * The exit code an error maps to. oclif `CLIError`s carry it in `err.oclif.exit`; other errors may
76
- * expose `err.exitCode`. Defined once so the process exit code (`catch`) and the JSON envelope
77
- * (`toErrorJson`) can never disagree — a mismatch between these two was itself the bug this fixes.
78
- */
79
- static exitCodeOf(err) {
80
- return err?.oclif?.exit ?? err?.exitCode ?? 1;
81
- }
82
76
  /**
83
77
  * Align the process exit code with the code reported in the JSON envelope. oclif's default
84
78
  * `catch` sets `process.exitCode = err.exitCode ?? 1`, but oclif `CLIError`s carry their code in
@@ -92,16 +86,10 @@ export class BaseCommand extends Command {
92
86
  // [UnparsedCommand] warning to stderr even under `--json`. CLIParseError subclasses carry `parse`.
93
87
  if (err && typeof err === 'object' && 'parse' in err)
94
88
  this.parsed = true;
95
- // Attach the stable code to the error itself so oclif's human pretty-print renders the same
96
- // `Code:` line that `--json` already reports via stableErrorCodeFrom. Without this, built-in
97
- // oclif parse errors (invalid enum value, missing arg, unknown flag) printed a code under
98
- // --json but not for humans — the two output modes disagreed on whether an error had a code.
99
- if (err && typeof err === 'object' && !err.code) {
100
- const code = stableErrorCodeFrom(err);
101
- if (code)
102
- err.code = code;
103
- }
104
- process.exitCode = process.exitCode ?? BaseCommand.exitCodeOf(err);
89
+ // Unwrap / fuzzy tips are no-ops when not applicable (including non-Error throws with no message).
90
+ enrichParseError(err);
91
+ enrichErrorForDisplay(err, { bin: this.config.bin, command: this.envelopeCommandId() });
92
+ process.exitCode = process.exitCode ?? resolveExitCode(err);
105
93
  return super.catch(err);
106
94
  }
107
95
  /** Command id for the JSON envelope; falls back to the binary name when a command has no id. */
@@ -128,16 +116,22 @@ export class BaseCommand extends Command {
128
116
  this.failInvalidUrl(url, err.message);
129
117
  }
130
118
  }
131
- /**
132
- * Single exit point for a URL that failed normalization, shared by every URL-accepting command.
133
- * The scheme-less message is already self-contained and actionable, so it is surfaced as-is;
134
- * other parse failures keep the "Invalid URL:" prefix for context.
135
- */
119
+ /** Exit for a URL that failed normalization; see {@link failInvalidUrl} for the code contract. */
136
120
  failInvalidUrl(url, message) {
137
- if (looksLikeSchemelessUrl(url)) {
138
- this.error(message, { exit: 2, code: 'MISSING_URL_SCHEME' });
139
- }
140
- this.error(`Invalid URL: ${message}`, { exit: 2, code: 'INVALID_URL' });
121
+ failInvalidUrl((msg, opts) => this.error(msg, opts), url, message);
122
+ }
123
+ /** Command context handed to command services (fetch/import) so they stay oclif-free. */
124
+ cliIo() {
125
+ return {
126
+ bin: this.config.bin,
127
+ configDir: this.config.configDir,
128
+ dataDir: this.config.dataDir,
129
+ cwd: process.cwd(),
130
+ json: this.jsonEnabled(),
131
+ warn: (msg) => void this.warn(msg),
132
+ log: (msg) => this.log(msg),
133
+ error: (msg, opts) => this.error(msg, opts),
134
+ };
141
135
  }
142
136
  /** Single source of truth for the `--json` envelope shape, shared by success and error output. */
143
137
  envelope(parts) {
@@ -149,33 +143,76 @@ export class BaseCommand extends Command {
149
143
  data: parts.data,
150
144
  });
151
145
  }
146
+ /**
147
+ * Base success envelope from `process.exitCode` (including exit 5 = served stale / ok).
148
+ * Subclasses that overlay batch failures must call this — not `toSuccessJson` — to avoid
149
+ * recursing through their own override.
150
+ */
151
+ baseSuccessJson(data) {
152
+ // ponytail: the stale-serve path signals EXIT_STALE_SERVED by setting process.exitCode inside
153
+ // run() (e.g. fetch's handleStaleRevalidationResult); this reads it back to mark the envelope.
154
+ // If that signalling moves off process.exitCode, update here too. Number() normalizes Node's
155
+ // string codes.
156
+ const exitCode = Number(process.exitCode ?? EXIT_OK);
157
+ // EXIT_STALE_SERVED means "served stale" — a successful, usable result, so it reports ok.
158
+ return this.envelope({
159
+ ok: exitCode === EXIT_OK || exitCode === EXIT_STALE_SERVED,
160
+ exitCode,
161
+ stderr: '',
162
+ data,
163
+ });
164
+ }
152
165
  /** Wrap a command's return value in the machine-readable envelope emitted under `--json`. */
153
166
  toSuccessJson(data) {
154
- // ponytail: the stale-serve path signals "exit 5" by setting process.exitCode inside run()
155
- // (e.g. fetch's handleStaleRevalidationResult); this reads it back to mark the envelope. If that
156
- // signalling moves off process.exitCode, update here too. Number() normalizes Node's string codes.
157
- const exitCode = Number(process.exitCode ?? 0);
158
- // Exit 5 means "served stale" — a successful, usable result, so it reports ok.
159
- return this.envelope({ ok: exitCode === 0 || exitCode === 5, exitCode, stderr: '', data });
167
+ return this.baseSuccessJson(data);
168
+ }
169
+ /**
170
+ * Success overlay for multi-URL read commands (`status`, `inspect`). Apply CACHE_MISS first,
171
+ * then row `.error` so validation failures win when both appear in one batch.
172
+ */
173
+ batchReadSuccessJson(data) {
174
+ return enrichRowErrorEnvelope(enrichCacheMissEnvelope(this.baseSuccessJson(data), data, this.config.bin), data);
175
+ }
176
+ /**
177
+ * Map each URL through `fn`. In a multi-URL batch, per-URL CLIErrors become failure rows so
178
+ * prior hits are kept — matching fetch's INVALID_URL / MISSING_URL_SCHEME batch contract.
179
+ */
180
+ mapUrlsAllowingBatchErrors(urls, fn, failureRow) {
181
+ const batch = urls.length > 1;
182
+ const results = [];
183
+ for (const url of urls) {
184
+ try {
185
+ results.push(fn(url));
186
+ }
187
+ catch (err) {
188
+ if (!batch || !(err instanceof Errors.CLIError))
189
+ throw err;
190
+ // Mirror the single-URL error format (message + Code: + Try this:) rather than a bare
191
+ // message, so a row failure in a batch is exactly as actionable as it is standalone.
192
+ if (!this.jsonEnabled())
193
+ this.warn(formatErrorForJson(err));
194
+ results.push(failureRow(url, err));
195
+ }
196
+ }
197
+ return results;
160
198
  }
161
199
  /** Mirror the success envelope for failures so JSON consumers get one consistent shape. */
162
200
  toErrorJson(err) {
163
- const e = err;
164
- const exitCode = BaseCommand.exitCodeOf(e);
165
- const code = stableErrorCodeFrom(e);
166
- const message = typeof e?.message === 'string' ? normalizeCliErrorMessage(e.message) : undefined;
167
- const stderr = message || code || e?.suggestions?.length || e?.ref
168
- ? formatErrorForJson({ ...e, message, code })
169
- : String(err);
201
+ const prepared = prepareCliError(err, {
202
+ bin: this.config.bin,
203
+ command: this.envelopeCommandId(),
204
+ });
205
+ // oclif prints the returned object to stdout; error text lives in envelope.stderr only
206
+ // so process stderr stays clean under `--json` (CACHE_MISS / batch overlays + oclif JSON log suppression).
170
207
  return buildEnvelope({
171
208
  command: this.envelopeCommandId(),
172
209
  ok: false,
173
- exitCode,
174
- stderr,
210
+ exitCode: prepared.exitCode,
211
+ stderr: prepared.stderr,
175
212
  data: null,
176
- code,
177
- suggestions: e.suggestions?.length ? e.suggestions : undefined,
178
- ref: e.ref,
213
+ code: prepared.code,
214
+ suggestions: prepared.suggestions,
215
+ ref: prepared.ref,
179
216
  });
180
217
  }
181
218
  }
@@ -1,4 +1,4 @@
1
- import { Flags } from '@oclif/core';
1
+ import { Flags, toConfiguredId } from '@oclif/core';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  import { isKnownKey, suggestKey, validKeysHint } from '../../lib/config/index.js';
4
4
  /**
@@ -30,15 +30,24 @@ export class ConfigCommand extends BaseCommand {
30
30
  if (!isKnownKey(keyArg)) {
31
31
  const suggestion = suggestKey(keyArg);
32
32
  const hint = suggestion ? ` Did you mean: ${suggestion}?` : '';
33
+ const command = this.ctor.id ? toConfiguredId(this.ctor.id, this.config) : 'config';
33
34
  this.error(`Unknown config key: "${keyArg}".${hint} Valid keys: ${validKeysHint()}.`, {
34
35
  exit: 2,
35
36
  code: 'UNKNOWN_KEY',
37
+ suggestions: suggestion
38
+ ? [`Use "${suggestion}": ${this.config.bin} ${command} ${suggestion}`]
39
+ : [`Use one of: ${validKeysHint()}`],
36
40
  });
37
41
  }
38
42
  }
39
43
  requireConfigKey(key) {
40
44
  if (!key) {
41
- this.error('Missing required argument: key', { exit: 2, code: 'MISSING_ARGUMENT' });
45
+ const command = this.ctor.id ? toConfiguredId(this.ctor.id, this.config) : 'config';
46
+ this.error('Missing required argument: key', {
47
+ exit: 2,
48
+ code: 'MISSING_ARGUMENT',
49
+ suggestions: [`Choose a key: ${this.config.bin} ${command} storage`],
50
+ });
42
51
  }
43
52
  this.assertKnownKey(key);
44
53
  }
@@ -1,10 +1,10 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { ConfigCommand, configScopeFlags } from './base.js';
3
- import { KEY_META, BUILT_IN_DEFAULTS, readScopedConfig, validKeysHint, } from '../../lib/config/index.js';
3
+ import { formatConfigEntry, resolveConfigEntry, validKeysHint } from '../../lib/config/index.js';
4
4
  export default class ConfigGet extends ConfigCommand {
5
5
  static id = 'config get';
6
- static summary = 'Get the effective value of a research configuration key.';
7
- static description = `Print a config value. Without --global/--local, shows the merged effective value (flag > env > project > user > default).\n\nValid keys: ${validKeysHint()}.`;
6
+ static summary = 'Get a research configuration value';
7
+ static description = `Print a config value. Without --global/--local, shows the merged effective value, in precedence order: a per-command override flag (e.g. \`fetch --storage\`) > env var > project file > user file > built-in default.\n\nValid keys: ${validKeysHint()}.`;
8
8
  static examples = [
9
9
  {
10
10
  description: 'get the effective storage mode',
@@ -20,8 +20,8 @@ export default class ConfigGet extends ConfigCommand {
20
20
  };
21
21
  static flags = {
22
22
  ...configScopeFlags({
23
- global: 'Read from user-level config only.',
24
- local: 'Read from project-level config only.',
23
+ global: 'read user-level config only',
24
+ local: 'read project-level config only',
25
25
  }),
26
26
  };
27
27
  static stdoutIsPrimaryData = true;
@@ -29,13 +29,9 @@ export default class ConfigGet extends ConfigCommand {
29
29
  const key = this.args.key;
30
30
  this.validateConfigKeyAndScope(key, this.flags.global, this.flags.local);
31
31
  const scope = this.readScope(this.flags.global, this.flags.local);
32
- const value = readScopedConfig(scope, this.config.configDir, process.cwd())[key];
33
- const meta = KEY_META[key];
34
- const displayValue = value !== undefined ? value : BUILT_IN_DEFAULTS[key];
35
- const formatted = meta.format(displayValue);
36
- const suffix = value === undefined ? ' (not configured)' : '';
32
+ const entry = resolveConfigEntry(key, scope, this.config.configDir, process.cwd());
37
33
  if (!this.jsonEnabled())
38
- this.log(formatted + suffix);
39
- return { key, value: displayValue };
34
+ this.log(formatConfigEntry(entry, scope));
35
+ return entry;
40
36
  }
41
37
  }
@@ -2,7 +2,7 @@ import { Help, toConfiguredId } from '@oclif/core';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  export default class ConfigIndex extends BaseCommand {
4
4
  static id = 'config';
5
- static summary = 'Manage research cache storage configuration (global vs project).';
5
+ static summary = 'Manage research cache configuration';
6
6
  static description = 'Show config subcommands for reading and writing Bonsai research cache configuration.';
7
7
  static examples = [
8
8
  {
@@ -10,7 +10,7 @@ export default class ConfigIndex extends BaseCommand {
10
10
  command: '<%= config.bin %> <%= command.id %> get storage',
11
11
  },
12
12
  {
13
- description: 'set a configuration value for the current project',
13
+ description: 'set project storage mode',
14
14
  command: '<%= config.bin %> <%= command.id %> set storage project --local',
15
15
  },
16
16
  ];
@@ -1,8 +1,8 @@
1
1
  import { ConfigCommand, configScopeFlags } from './base.js';
2
- import { ALL_KEYS, KEY_META, BUILT_IN_DEFAULTS, readScopedConfig, validKeysHint, } from '../../lib/config/index.js';
2
+ import { formatConfigEntry, resolveConfigEntries, validKeysHint } from '../../lib/config/index.js';
3
3
  export default class ConfigList extends ConfigCommand {
4
4
  static id = 'config list';
5
- static summary = 'List all research configuration keys and their effective values.';
5
+ static summary = 'List research configuration values';
6
6
  static description = `Show every configuration key with its current value. Use --global/--local to show a single scope.\n\nValid keys: ${validKeysHint()}.`;
7
7
  static examples = [
8
8
  {
@@ -16,28 +16,22 @@ export default class ConfigList extends ConfigCommand {
16
16
  ];
17
17
  static flags = {
18
18
  ...configScopeFlags({
19
- global: 'Show user-level config only.',
20
- local: 'Show project-level config only.',
19
+ global: 'show user-level config only',
20
+ local: 'show project-level config only',
21
21
  }),
22
22
  };
23
23
  static stdoutIsPrimaryData = true;
24
24
  async run() {
25
25
  this.assertScopeFlagsExclusive(this.flags.global, this.flags.local);
26
26
  const scope = this.readScope(this.flags.global, this.flags.local);
27
- const scoped = readScopedConfig(scope, this.config.configDir, process.cwd());
27
+ const entries = resolveConfigEntries(scope, this.config.configDir, process.cwd());
28
28
  if (!this.jsonEnabled()) {
29
- const width = Math.max(...ALL_KEYS.map((k) => k.length)) + 2;
30
- for (const key of ALL_KEYS) {
31
- const raw = scoped[key];
32
- const formatted = raw !== undefined ? KEY_META[key].format(raw) : '(not configured)';
33
- this.log(`${key.padEnd(width)}${formatted}`);
29
+ const width = Math.max(...entries.map((e) => e.key.length)) + 2;
30
+ for (const entry of entries) {
31
+ this.log(`${entry.key.padEnd(width)}${formatConfigEntry(entry, scope)}`);
34
32
  }
35
33
  }
36
- const values = {};
37
- for (const key of ALL_KEYS) {
38
- const raw = scoped[key];
39
- values[key] = raw !== undefined ? raw : BUILT_IN_DEFAULTS[key];
40
- }
41
- return values;
34
+ // Bare array — same shape as `list`'s data payload; no `{ entries }` wrapper.
35
+ return entries;
42
36
  }
43
37
  }
@@ -1,4 +1,5 @@
1
1
  import { ConfigCommand } from './base.js';
2
+ import type { ConfigWriteResult } from '../../lib/cli-result-types.js';
2
3
  export default class ConfigSet extends ConfigCommand<typeof ConfigSet> {
3
4
  static id: string;
4
5
  static summary: string;
@@ -17,5 +18,5 @@ export default class ConfigSet extends ConfigCommand<typeof ConfigSet> {
17
18
  local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
19
  };
19
20
  static stdoutIsPrimaryData: boolean;
20
- run(): Promise<unknown>;
21
+ run(): Promise<ConfigWriteResult>;
21
22
  }