@taurgis/bonsai 3.2.0 → 4.0.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 (173) hide show
  1. package/README.md +9 -6
  2. package/agents/agents/salesforce-docs-researcher.agent.md +17 -4
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +13 -18
  5. package/dist/base-command.d.ts +59 -12
  6. package/dist/base-command.js +179 -51
  7. package/dist/commands/config/base.js +12 -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 +11 -17
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +35 -28
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +26 -25
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +61 -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 +112 -52
  21. package/dist/commands/list.d.ts +18 -3
  22. package/dist/commands/list.js +121 -46
  23. package/dist/commands/prune.d.ts +6 -1
  24. package/dist/commands/prune.js +89 -78
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +62 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +2 -1
  28. package/dist/hooks/command-not-found/suggest.js +78 -41
  29. package/dist/lib/argv.d.ts +29 -3
  30. package/dist/lib/argv.js +147 -54
  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 +12 -0
  40. package/dist/lib/cli-flag-manifest.js +15 -0
  41. package/dist/lib/cli-presentation.d.ts +41 -0
  42. package/dist/lib/cli-presentation.js +53 -0
  43. package/dist/lib/cli-result-types.d.ts +112 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +23 -5
  46. package/dist/lib/config/index.js +60 -5
  47. package/dist/lib/config/io.d.ts +9 -0
  48. package/dist/lib/config/io.js +71 -21
  49. package/dist/lib/config/schema.d.ts +2 -0
  50. package/dist/lib/config/schema.js +7 -0
  51. package/dist/lib/config-persist.d.ts +28 -0
  52. package/dist/lib/config-persist.js +29 -0
  53. package/dist/lib/envelope.d.ts +41 -3
  54. package/dist/lib/envelope.js +154 -4
  55. package/dist/lib/help-preflight.d.ts +5 -8
  56. package/dist/lib/help-preflight.js +13 -32
  57. package/dist/lib/json-meta.d.ts +0 -2
  58. package/dist/lib/json-meta.js +21 -25
  59. package/dist/lib/limit-flag.d.ts +11 -1
  60. package/dist/lib/limit-flag.js +19 -6
  61. package/dist/lib/parse-error-ux.d.ts +5 -0
  62. package/dist/lib/parse-error-ux.js +73 -0
  63. package/dist/lib/prune-flags.d.ts +30 -0
  64. package/dist/lib/prune-flags.js +86 -0
  65. package/dist/lib/research/artifact-index.d.ts +11 -2
  66. package/dist/lib/research/artifact-index.js +14 -5
  67. package/dist/lib/research/artifact.js +20 -2
  68. package/dist/lib/research/browser.d.ts +53 -2
  69. package/dist/lib/research/browser.js +202 -58
  70. package/dist/lib/research/capture.d.ts +24 -2
  71. package/dist/lib/research/capture.js +24 -4
  72. package/dist/lib/research/cli-io.d.ts +40 -0
  73. package/dist/lib/research/cli-io.js +27 -0
  74. package/dist/lib/research/compress.d.ts +7 -0
  75. package/dist/lib/research/compress.js +7 -3
  76. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  77. package/dist/lib/research/docs/capabilities.js +5 -0
  78. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  79. package/dist/lib/research/docs/clean-dom.js +2 -0
  80. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  81. package/dist/lib/research/docs/code-blocks.js +2 -0
  82. package/dist/lib/research/docs/detect.d.ts +4 -0
  83. package/dist/lib/research/docs/detect.js +4 -0
  84. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  85. package/dist/lib/research/docs/machine-readable.js +10 -0
  86. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  87. package/dist/lib/research/docs/markdown-source.js +19 -2
  88. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  89. package/dist/lib/research/docs/quality-gates.js +4 -0
  90. package/dist/lib/research/docs/rsc.d.ts +9 -2
  91. package/dist/lib/research/docs/rsc.js +5 -2
  92. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  93. package/dist/lib/research/docs/section-artifacts.js +12 -0
  94. package/dist/lib/research/docs/sections.d.ts +18 -0
  95. package/dist/lib/research/docs/sections.js +17 -1
  96. package/dist/lib/research/docs/source-map.d.ts +28 -0
  97. package/dist/lib/research/docs/source-map.js +27 -0
  98. package/dist/lib/research/docs/validate.d.ts +35 -2
  99. package/dist/lib/research/docs/validate.js +47 -2
  100. package/dist/lib/research/extract.d.ts +16 -0
  101. package/dist/lib/research/extract.js +12 -1
  102. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  103. package/dist/lib/research/fetch-command-service.js +367 -0
  104. package/dist/lib/research/fetch-result.d.ts +114 -0
  105. package/dist/lib/research/fetch-result.js +212 -0
  106. package/dist/lib/research/fetcher.d.ts +46 -8
  107. package/dist/lib/research/fetcher.js +61 -31
  108. package/dist/lib/research/freshness.d.ts +54 -11
  109. package/dist/lib/research/freshness.js +91 -31
  110. package/dist/lib/research/import-command-service.d.ts +56 -0
  111. package/dist/lib/research/import-command-service.js +402 -0
  112. package/dist/lib/research/keywords.js +5 -1
  113. package/dist/lib/research/markdown.d.ts +12 -1
  114. package/dist/lib/research/markdown.js +13 -5
  115. package/dist/lib/research/metadata-filters.d.ts +18 -0
  116. package/dist/lib/research/metadata-filters.js +37 -0
  117. package/dist/lib/research/metadata-flags.d.ts +34 -0
  118. package/dist/lib/research/metadata-flags.js +45 -0
  119. package/dist/lib/research/persist-artifact.d.ts +23 -0
  120. package/dist/lib/research/persist-artifact.js +27 -0
  121. package/dist/lib/research/prompt-injection.d.ts +9 -0
  122. package/dist/lib/research/prompt-injection.js +59 -14
  123. package/dist/lib/research/proxy.d.ts +14 -8
  124. package/dist/lib/research/proxy.js +14 -10
  125. package/dist/lib/research/resolve-target.d.ts +2 -0
  126. package/dist/lib/research/resolve-target.js +1 -1
  127. package/dist/lib/research/revalidate.d.ts +49 -20
  128. package/dist/lib/research/revalidate.js +80 -15
  129. package/dist/lib/research/schema.d.ts +7 -0
  130. package/dist/lib/research/schema.js +2 -0
  131. package/dist/lib/research/secret-scan.d.ts +6 -0
  132. package/dist/lib/research/secret-scan.js +8 -0
  133. package/dist/lib/research/secure-write.d.ts +4 -0
  134. package/dist/lib/research/secure-write.js +31 -2
  135. package/dist/lib/research/storage.d.ts +9 -8
  136. package/dist/lib/research/storage.js +34 -28
  137. package/dist/lib/research/summarize.d.ts +15 -0
  138. package/dist/lib/research/summarize.js +11 -0
  139. package/dist/lib/research/url.d.ts +9 -6
  140. package/dist/lib/research/url.js +33 -21
  141. package/dist/lib/text.d.ts +19 -8
  142. package/dist/lib/text.js +32 -9
  143. package/dist/lib/value-flags.d.ts +14 -0
  144. package/dist/lib/value-flags.js +28 -0
  145. package/dist/sites/coveo.d.ts +10 -0
  146. package/dist/sites/coveo.js +10 -0
  147. package/dist/sites/index.d.ts +16 -0
  148. package/dist/sites/index.js +16 -4
  149. package/dist/sites/markdown-twin.d.ts +31 -0
  150. package/dist/sites/markdown-twin.js +56 -0
  151. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  152. package/dist/sites/salesforce/fetch-page.js +14 -3
  153. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  154. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  155. package/dist/sites/salesforce/index.d.ts +1 -0
  156. package/dist/sites/salesforce/index.js +1 -0
  157. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  158. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  159. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  160. package/dist/sites/salesforce-developer/index.js +1 -0
  161. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  162. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  163. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  164. package/dist/sites/salesforce-doc-fetch.js +52 -7
  165. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  166. package/dist/sites/salesforce-dom-probe.js +20 -2
  167. package/dist/sites/types.d.ts +14 -8
  168. package/dist/sites/types.js +3 -0
  169. package/oclif.manifest.json +216 -102
  170. package/package.json +20 -7
  171. package/agents/agents/official-docs-researcher.agent.md +0 -87
  172. package/agents/instructions/web-research.instructions.md +0 -44
  173. 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.
@@ -141,7 +141,7 @@ npx @taurgis/bonsai inspect <url>
141
141
 
142
142
  ### `list`
143
143
 
144
- List cached artifacts by metadata filters (`--topic`, `--tags`, `--freshness`, `--artifact-type`, `--capture-method`, `--limit`), newest first, without printing content.
144
+ List cached artifacts by metadata filters (`--topic`, `--tags`, `--url`, `--freshness`, `--artifact-type`, `--capture-method`, `--limit`), newest first, without printing content. The default `--json`/`--toon` row is a minimal 4-field shape (`sourceUrls`, `topic`, `freshness`, `tokenEstimate`); pass `--full` for every metadata field. Running `bonsai` with no arguments at all is shorthand for `bonsai list`.
145
145
 
146
146
  ```bash
147
147
  npx @taurgis/bonsai list [flags]
@@ -149,7 +149,7 @@ npx @taurgis/bonsai list [flags]
149
149
 
150
150
  ### `prune`
151
151
 
152
- Delete old or inactive cache entries. Requires a filter (`--older-than`, `--inactive`, `--artifact-type`, or `--url`) and exactly one of `--dry-run` or `--yes` (passing both is a usage error). In [read-only mode](#read-only--plan-mode), `--yes` is rejected and the safety check is implicitly satisfied by a preview.
152
+ Delete old or inactive cache entries. Requires a filter (`--older-than`, `--inactive`, `--artifact-type`, `--url`, `--topic`, or `--tags`) and exactly one of `--dry-run` or `--yes` (passing both is a usage error). In [read-only mode](#read-only--plan-mode), `--yes` is rejected and the safety check is implicitly satisfied by a preview.
153
153
 
154
154
  ```bash
155
155
  npx @taurgis/bonsai prune --older-than 90d --dry-run
@@ -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.2.0'
9
9
  ---
10
10
 
11
11
  # Salesforce Docs Researcher Agent
@@ -20,7 +20,9 @@ Do not research from memory alone. Training-data knowledge does not satisfy this
20
20
 
21
21
  ## Invocation
22
22
 
23
- Run Bonsai as `npx @taurgis/bonsai ...`. Add `--json` when you need structured output.
23
+ Run Bonsai as `npx @taurgis/bonsai ...`. Add `--toon` when you need structured output at a lower token cost; `--json` is also available if you specifically need JSON (mutually exclusive with `--toon`).
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.
24
26
 
25
27
  ## Default Workflow
26
28
 
@@ -43,7 +45,7 @@ Run Bonsai as `npx @taurgis/bonsai ...`. Add `--json` when you need structured o
43
45
  For structured output:
44
46
 
45
47
  ```bash
46
- npx @taurgis/bonsai <official-url> --format detailed --json
48
+ npx @taurgis/bonsai <official-url> --format detailed --toon
47
49
  ```
48
50
 
49
51
  ## Working with Salesforce Sites
@@ -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:
@@ -96,4 +109,4 @@ Return:
96
109
  - Cache status or validation timestamp when Bonsai output provides it.
97
110
  - Gaps or uncertainty where official docs do not answer the question.
98
111
 
99
- Keep stdout clean when using `--json`; warnings and diagnostic notes belong in stderr or in the final prose summary.
112
+ Keep stdout clean when using `--toon`/`--json`; warnings and diagnostic notes belong in stderr or in the final prose summary.
@@ -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, KNOWN_COMMAND_ROOT_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,36 +23,29 @@ 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, {
27
+ valueTakingFlags: VALUE_TAKING_FLAG_TOKENS,
28
+ knownCommandRoots: KNOWN_COMMAND_ROOT_TOKENS,
29
+ });
25
30
  const root = __dirname + '/../';
26
31
 
27
- if (result.exitWithJson) {
28
- process.exitCode = result.exitWithJson.exitCode;
29
- console.log(JSON.stringify(result.exitWithJson.envelope, null, 2));
30
- process.exit();
32
+ if (result.earlyExit) {
33
+ exitWithPreflight(result.earlyExit);
31
34
  }
32
35
 
33
36
  const unknownHelp = await tryUnknownHelpOutput(result.argv, root);
34
37
  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();
38
+ exitWithPreflight(unknownHelp);
42
39
  }
43
40
 
44
41
  const jsonMeta = await tryJsonMetaOutput(result.argv, root);
45
42
  if (jsonMeta) {
46
- process.exitCode = jsonMeta.exitCode;
47
- console.log(JSON.stringify(jsonMeta.envelope, null, 2));
48
- process.exit();
43
+ exitWithPreflight({ ...jsonMeta, json: true });
49
44
  }
50
45
 
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.
53
- if (!process.argv.includes('--json')) {
46
+ // Only notify after the earlyExit fast-path so the notifier never fires before
47
+ // a usage-error envelope exits. Also skip under --json/--toon so agent callers never see stderr noise.
48
+ if (!process.argv.includes('--json') && !process.argv.includes('--toon')) {
54
49
  updateNotifier({ pkg }).notify();
55
50
  }
56
51
 
@@ -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`
@@ -16,11 +17,24 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
16
17
  */
17
18
  static baseFlags: {
18
19
  'read-only': Interfaces.BooleanFlag<boolean>;
20
+ toon: Interfaces.BooleanFlag<boolean>;
19
21
  };
20
22
  flags: Interfaces.InferredFlags<T['flags']>;
21
23
  args: Interfaces.InferredArgs<T['args']>;
22
24
  parsedArgv: string[];
23
25
  init(): Promise<void>;
26
+ /**
27
+ * Treat `--toon` as machine mode too, so every existing `!this.jsonEnabled()` human-output branch
28
+ * across every command (table rendering, tips, empty-state guidance) is suppressed under `--toon`
29
+ * for free, exactly as it already is under `--json`.
30
+ */
31
+ jsonEnabled(): boolean;
32
+ /**
33
+ * Real `--json` keeps oclif's own colorized-JSON rendering untouched (zero regression risk).
34
+ * `--toon` (and no real `--json`) re-encodes the identical envelope as TOON instead — same data,
35
+ * ~40% fewer tokens for callers who opted in.
36
+ */
37
+ protected logJson(json: unknown): void;
24
38
  /**
25
39
  * Always surface warnings on stderr, even under `--json`. oclif silences `warn()` in JSON mode,
26
40
  * but our warnings (secret-redirect, stale-serve, prune failures) are
@@ -28,6 +42,30 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
28
42
  * JSON envelope, so machine output stays clean.
29
43
  */
30
44
  warn(input: string | Error): string | Error;
45
+ /**
46
+ * Sanitize the human-readable error surface before handing off to oclif. Error text throughout
47
+ * this CLI routinely echoes raw user input back (a rejected URL, `--ttl`, an unknown config key)
48
+ * so the message stays actionable, but that value is untrusted per the repo's trust-boundary
49
+ * rules — the same reasoning that already strips ANSI/control bytes from cached content before
50
+ * terminal render (see `sanitizeForTerminal`). Left unsanitized, a value containing raw escape
51
+ * bytes would replay as a terminal-injection attack the moment the rejected command's own error
52
+ * is printed. `--json` is untouched: `JSON.stringify` already escapes control characters, and the
53
+ * raw value there preserves exact input for programmatic callers.
54
+ */
55
+ error(input: string | Error, options: {
56
+ code?: string;
57
+ exit: false;
58
+ } & Errors.PrettyPrintableError): void;
59
+ error(input: string | Error, options?: {
60
+ code?: string;
61
+ exit?: number;
62
+ } & Errors.PrettyPrintableError): never;
63
+ /**
64
+ * Human-mode-only contextual next-step suggestion after a successful command, mirroring the
65
+ * "Try this:" pattern already used on errors. A no-op under `--json`/`--toon`: the envelope's
66
+ * `data` is self-describing, so machine callers get no prose.
67
+ */
68
+ protected tip(message: string): void;
31
69
  /** Whether read-only/plan mode is active for this invocation (flag OR either env var). */
32
70
  protected get readOnly(): boolean;
33
71
  /**
@@ -36,12 +74,6 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
36
74
  * write, so a skipped write is never a silent surprise to the caller.
37
75
  */
38
76
  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
77
  /**
46
78
  * Align the process exit code with the code reported in the JSON envelope. oclif's default
47
79
  * `catch` sets `process.exitCode = err.exitCode ?? 1`, but oclif `CLIError`s carry their code in
@@ -56,6 +88,7 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
56
88
  };
57
89
  exitCode?: number;
58
90
  code?: string;
91
+ suggestions?: string[];
59
92
  }): Promise<any>;
60
93
  /** Command id for the JSON envelope; falls back to the binary name when a command has no id. */
61
94
  protected envelopeCommandId(): string;
@@ -66,16 +99,30 @@ export declare abstract class BaseCommand<T extends typeof Command> extends Comm
66
99
  * stays INVALID_URL.
67
100
  */
68
101
  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
- */
102
+ /** Exit for a URL that failed normalization; see {@link failInvalidUrl} for the code contract. */
74
103
  protected failInvalidUrl(url: string, message: string): never;
104
+ /** Command context handed to command services (fetch/import) so they stay oclif-free. */
105
+ protected cliIo(): CliIo;
75
106
  /** Single source of truth for the `--json` envelope shape, shared by success and error output. */
76
107
  private envelope;
108
+ /**
109
+ * Base success envelope from `process.exitCode` (including exit 5 = served stale / ok).
110
+ * Subclasses that overlay batch failures must call this — not `toSuccessJson` — to avoid
111
+ * recursing through their own override.
112
+ */
113
+ protected baseSuccessJson(data: unknown): Record<string, unknown>;
77
114
  /** Wrap a command's return value in the machine-readable envelope emitted under `--json`. */
78
115
  protected toSuccessJson(data: unknown): Record<string, unknown>;
116
+ /**
117
+ * Success overlay for multi-URL read commands (`status`, `inspect`). Apply CACHE_MISS first,
118
+ * then row `.error` so validation failures win when both appear in one batch.
119
+ */
120
+ protected batchReadSuccessJson(data: unknown): Record<string, unknown>;
121
+ /**
122
+ * Map each URL through `fn`. In a multi-URL batch, per-URL CLIErrors become failure rows so
123
+ * prior hits are kept — matching fetch's INVALID_URL / MISSING_URL_SCHEME batch contract.
124
+ */
125
+ protected mapUrlsAllowingBatchErrors<T, F>(urls: string[], fn: (url: string) => T, failureRow: (url: string, err: InstanceType<typeof Errors.CLIError>) => F): Array<T | F>;
79
126
  /** Mirror the success envelope for failures so JSON consumers get one consistent shape. */
80
127
  protected toErrorJson(err: unknown): Record<string, unknown>;
81
128
  }
@@ -1,8 +1,24 @@
1
- import { Command, Errors, Flags, toConfiguredId } from '@oclif/core';
2
- import { invalidEnvOverrideWarnings, resolveReadOnly } from './lib/config/index.js';
3
- import { buildEnvelope, formatErrorForJson, normalizeCliErrorMessage, stableErrorCodeFrom, } from './lib/envelope.js';
1
+ import { Command, Errors, Flags, toConfiguredId, ux } from '@oclif/core';
2
+ import { encode } from '@toon-format/toon';
3
+ import { invalidConfigFileWarnings, invalidEnvOverrideWarnings, resolveReadOnly, } from './lib/config/index.js';
4
+ import { CLI_FLAG_DESCRIPTIONS } from './lib/cli-presentation.js';
5
+ import { formatTip, sanitizeForTerminal } from './lib/text.js';
6
+ import { enrichErrorForDisplay, resolveExitCode, prepareCliError, EXIT_OK, EXIT_STALE_SERVED, } from './lib/cli-error-policy.js';
7
+ import { buildEnvelope, enrichCacheMissEnvelope, enrichRowErrorEnvelope } from './lib/envelope.js';
8
+ import { enrichParseError } from './lib/parse-error-ux.js';
4
9
  import { resolveResearchTarget, } from './lib/research/resolve-target.js';
5
- import { looksLikeSchemelessUrl } from './lib/research/url.js';
10
+ import { failInvalidUrl } from './lib/research/cli-io.js';
11
+ /**
12
+ * Mirrors oclif's own `--json` argv-scan (respecting a `--` pass-through boundary) so `--toon`
13
+ * gets identical treatment: present in argv, regardless of parse state.
14
+ */
15
+ function argvHasFlag(argv, flag) {
16
+ const passThroughIndex = argv.indexOf('--');
17
+ const flagIndex = argv.indexOf(flag);
18
+ return passThroughIndex === -1
19
+ ? flagIndex !== -1
20
+ : flagIndex !== -1 && flagIndex < passThroughIndex;
21
+ }
6
22
  /**
7
23
  * Shared base for every Bonsai command. Enables oclif's native `--json` flag,
8
24
  * parses args/flags once in `init()` so commands read `this.args`/`this.flags`
@@ -21,8 +37,11 @@ export class BaseCommand extends Command {
21
37
  'read-only': Flags.boolean({
22
38
  aliases: ['plan'],
23
39
  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.',
40
+ description: CLI_FLAG_DESCRIPTIONS.readOnly,
41
+ }),
42
+ toon: Flags.boolean({
43
+ default: false,
44
+ description: CLI_FLAG_DESCRIPTIONS.toon,
26
45
  }),
27
46
  };
28
47
  flags;
@@ -40,11 +59,52 @@ export class BaseCommand extends Command {
40
59
  this.flags = flags;
41
60
  this.args = args;
42
61
  this.parsedArgv = argv;
62
+ // --json and --toon both claim the machine-output seat; picking one silently over the other
63
+ // would surprise a caller who set both by mistake, so reject the combination outright. Reuses
64
+ // the existing CONFLICTING_FLAGS code (not a new one) — the catalog already documents it as the
65
+ // one code for "choose one of the mutually exclusive options", regardless of which flags.
66
+ if (this.flags?.['json'] && this.flags?.['toon']) {
67
+ this.error('Cannot combine --json and --toon: pick one output format.', {
68
+ exit: 2,
69
+ code: 'CONFLICTING_FLAGS',
70
+ suggestions: ['Pass only one of --json or --toon'],
71
+ });
72
+ }
43
73
  // Surface a set-but-invalid BONSAI_* override once per run. Resolution silently drops such a
44
74
  // value, so without this a typo'd env var would take no effect with no signal. Warnings go to
45
75
  // stderr (even under --json), so machine output stays clean.
46
76
  for (const warning of invalidEnvOverrideWarnings(process.env))
47
77
  this.warn(warning);
78
+ // Same signal for a corrupted or hand-edited config file: parsing silently degrades to `{}`
79
+ // (or drops just the offending key), which would otherwise mask the problem entirely.
80
+ for (const warning of invalidConfigFileWarnings(this.config.configDir, process.cwd(), this.config.bin)) {
81
+ this.warn(warning);
82
+ }
83
+ }
84
+ /**
85
+ * Treat `--toon` as machine mode too, so every existing `!this.jsonEnabled()` human-output branch
86
+ * across every command (table rendering, tips, empty-state guidance) is suppressed under `--toon`
87
+ * for free, exactly as it already is under `--json`.
88
+ */
89
+ jsonEnabled() {
90
+ // Mirrors oclif's own enableJsonFlag guard (every command in this CLI sets it, but the base
91
+ // class's contract shouldn't silently break if a future command ever opts out).
92
+ return (super.jsonEnabled() ||
93
+ (Boolean(this.ctor?.enableJsonFlag) && argvHasFlag(this.argv, '--toon')));
94
+ }
95
+ /**
96
+ * Real `--json` keeps oclif's own colorized-JSON rendering untouched (zero regression risk).
97
+ * `--toon` (and no real `--json`) re-encodes the identical envelope as TOON instead — same data,
98
+ * ~40% fewer tokens for callers who opted in.
99
+ */
100
+ logJson(json) {
101
+ if (super.jsonEnabled()) {
102
+ super.logJson(json);
103
+ return;
104
+ }
105
+ // this.log() no-ops whenever jsonEnabled() is true (by design, everywhere else in this CLI) —
106
+ // ux.stdout writes directly, matching how oclif's own default logJson bypasses that guard too.
107
+ ux.stdout(encode(json));
48
108
  }
49
109
  /**
50
110
  * Always surface warnings on stderr, even under `--json`. oclif silences `warn()` in JSON mode,
@@ -56,6 +116,35 @@ export class BaseCommand extends Command {
56
116
  Errors.warn(input);
57
117
  return input;
58
118
  }
119
+ error(input, options = {}) {
120
+ if (!this.jsonEnabled()) {
121
+ if (typeof input === 'string')
122
+ input = sanitizeForTerminal(input);
123
+ if (options.suggestions) {
124
+ options = { ...options, suggestions: options.suggestions.map(sanitizeForTerminal) };
125
+ }
126
+ }
127
+ // `exit: false` is oclif's non-throwing error render (see `errorRow` on the CliIo port and
128
+ // `mapUrlsAllowingBatchErrors`/prune's per-row failures, all of which route through here for the
129
+ // same terminal-injection sanitization above). Destructure `exit` into its own binding so
130
+ // narrowing it to the `false` literal actually narrows the object passed to `super.error()` into
131
+ // each of oclif's two overloads — narrowing a property read alone (`options.exit === false`)
132
+ // doesn't narrow the enclosing object's type, only a local variable's.
133
+ const { exit, ...rest } = options;
134
+ if (exit === false)
135
+ return super.error(input, { ...rest, exit });
136
+ return super.error(input, { ...rest, exit });
137
+ }
138
+ /**
139
+ * Human-mode-only contextual next-step suggestion after a successful command, mirroring the
140
+ * "Try this:" pattern already used on errors. A no-op under `--json`/`--toon`: the envelope's
141
+ * `data` is self-describing, so machine callers get no prose.
142
+ */
143
+ tip(message) {
144
+ if (this.jsonEnabled())
145
+ return;
146
+ this.warn(formatTip(message));
147
+ }
59
148
  /** Whether read-only/plan mode is active for this invocation (flag OR either env var). */
60
149
  get readOnly() {
61
150
  return resolveReadOnly({ flag: Boolean(this.flags?.['read-only']), env: process.env });
@@ -71,14 +160,6 @@ export class BaseCommand extends Command {
71
160
  }
72
161
  return explicitDryRun || this.readOnly;
73
162
  }
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
163
  /**
83
164
  * Align the process exit code with the code reported in the JSON envelope. oclif's default
84
165
  * `catch` sets `process.exitCode = err.exitCode ?? 1`, but oclif `CLIError`s carry their code in
@@ -92,16 +173,10 @@ export class BaseCommand extends Command {
92
173
  // [UnparsedCommand] warning to stderr even under `--json`. CLIParseError subclasses carry `parse`.
93
174
  if (err && typeof err === 'object' && 'parse' in err)
94
175
  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);
176
+ // Unwrap / fuzzy tips are no-ops when not applicable (including non-Error throws with no message).
177
+ enrichParseError(err);
178
+ enrichErrorForDisplay(err, { bin: this.config.bin, command: this.envelopeCommandId() });
179
+ process.exitCode = process.exitCode ?? resolveExitCode(err);
105
180
  return super.catch(err);
106
181
  }
107
182
  /** Command id for the JSON envelope; falls back to the binary name when a command has no id. */
@@ -120,6 +195,7 @@ export class BaseCommand extends Command {
120
195
  configDir: this.config.configDir,
121
196
  cwd: process.cwd(),
122
197
  dataDir: this.config.dataDir,
198
+ readOnly: this.readOnly,
123
199
  url,
124
200
  ...extra,
125
201
  });
@@ -128,16 +204,23 @@ export class BaseCommand extends Command {
128
204
  this.failInvalidUrl(url, err.message);
129
205
  }
130
206
  }
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
- */
207
+ /** Exit for a URL that failed normalization; see {@link failInvalidUrl} for the code contract. */
136
208
  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' });
209
+ failInvalidUrl((msg, opts) => this.error(msg, opts), url, message);
210
+ }
211
+ /** Command context handed to command services (fetch/import) so they stay oclif-free. */
212
+ cliIo() {
213
+ return {
214
+ bin: this.config.bin,
215
+ configDir: this.config.configDir,
216
+ dataDir: this.config.dataDir,
217
+ cwd: process.cwd(),
218
+ json: this.jsonEnabled(),
219
+ warn: (msg) => void this.warn(msg),
220
+ log: (msg) => this.log(msg),
221
+ error: (msg, opts) => this.error(msg, opts),
222
+ errorRow: (msg, opts) => this.error(msg, { ...opts, exit: false }),
223
+ };
141
224
  }
142
225
  /** Single source of truth for the `--json` envelope shape, shared by success and error output. */
143
226
  envelope(parts) {
@@ -149,33 +232,78 @@ export class BaseCommand extends Command {
149
232
  data: parts.data,
150
233
  });
151
234
  }
235
+ /**
236
+ * Base success envelope from `process.exitCode` (including exit 5 = served stale / ok).
237
+ * Subclasses that overlay batch failures must call this — not `toSuccessJson` — to avoid
238
+ * recursing through their own override.
239
+ */
240
+ baseSuccessJson(data) {
241
+ // ponytail: the stale-serve path signals EXIT_STALE_SERVED by setting process.exitCode inside
242
+ // run() (e.g. fetch's handleStaleRevalidationResult); this reads it back to mark the envelope.
243
+ // If that signalling moves off process.exitCode, update here too. Number() normalizes Node's
244
+ // string codes.
245
+ const exitCode = Number(process.exitCode ?? EXIT_OK);
246
+ // EXIT_STALE_SERVED means "served stale" — a successful, usable result, so it reports ok.
247
+ return this.envelope({
248
+ ok: exitCode === EXIT_OK || exitCode === EXIT_STALE_SERVED,
249
+ exitCode,
250
+ stderr: '',
251
+ data,
252
+ });
253
+ }
152
254
  /** Wrap a command's return value in the machine-readable envelope emitted under `--json`. */
153
255
  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 });
256
+ return this.baseSuccessJson(data);
257
+ }
258
+ /**
259
+ * Success overlay for multi-URL read commands (`status`, `inspect`). Apply CACHE_MISS first,
260
+ * then row `.error` so validation failures win when both appear in one batch.
261
+ */
262
+ batchReadSuccessJson(data) {
263
+ return enrichRowErrorEnvelope(enrichCacheMissEnvelope(this.baseSuccessJson(data), data, this.config.bin), data);
264
+ }
265
+ /**
266
+ * Map each URL through `fn`. In a multi-URL batch, per-URL CLIErrors become failure rows so
267
+ * prior hits are kept — matching fetch's INVALID_URL / MISSING_URL_SCHEME batch contract.
268
+ */
269
+ mapUrlsAllowingBatchErrors(urls, fn, failureRow) {
270
+ const batch = urls.length > 1;
271
+ const results = [];
272
+ for (const url of urls) {
273
+ try {
274
+ results.push(fn(url));
275
+ }
276
+ catch (err) {
277
+ if (!batch || !(err instanceof Errors.CLIError))
278
+ throw err;
279
+ // Render with the same "Error:" formatting as a single-URL failure (this.error's exit:false
280
+ // form prints the CLIError's own message/Code/Try this and returns instead of throwing) rather
281
+ // than this.warn, since a row failure still fails the command overall (status/inspect batch
282
+ // exit code, JSON ok:false) and should never read as merely a warning.
283
+ if (!this.jsonEnabled())
284
+ this.error(err, { exit: false });
285
+ results.push(failureRow(url, err));
286
+ }
287
+ }
288
+ return results;
160
289
  }
161
290
  /** Mirror the success envelope for failures so JSON consumers get one consistent shape. */
162
291
  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);
292
+ const prepared = prepareCliError(err, {
293
+ bin: this.config.bin,
294
+ command: this.envelopeCommandId(),
295
+ });
296
+ // oclif prints the returned object to stdout; error text lives in envelope.stderr only
297
+ // so process stderr stays clean under `--json` (CACHE_MISS / batch overlays + oclif JSON log suppression).
170
298
  return buildEnvelope({
171
299
  command: this.envelopeCommandId(),
172
300
  ok: false,
173
- exitCode,
174
- stderr,
301
+ exitCode: prepared.exitCode,
302
+ stderr: prepared.stderr,
175
303
  data: null,
176
- code,
177
- suggestions: e.suggestions?.length ? e.suggestions : undefined,
178
- ref: e.ref,
304
+ code: prepared.code,
305
+ suggestions: prepared.suggestions,
306
+ ref: prepared.ref,
179
307
  });
180
308
  }
181
309
  }
@@ -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
  /**
@@ -11,6 +11,7 @@ export class ConfigCommand extends BaseCommand {
11
11
  this.error('--global and --local are mutually exclusive.', {
12
12
  exit: 2,
13
13
  code: 'CONFLICTING_FLAGS',
14
+ suggestions: ['Pass --global or --local, not both.'],
14
15
  });
15
16
  }
16
17
  }
@@ -30,15 +31,24 @@ export class ConfigCommand extends BaseCommand {
30
31
  if (!isKnownKey(keyArg)) {
31
32
  const suggestion = suggestKey(keyArg);
32
33
  const hint = suggestion ? ` Did you mean: ${suggestion}?` : '';
34
+ const command = this.ctor.id ? toConfiguredId(this.ctor.id, this.config) : 'config';
33
35
  this.error(`Unknown config key: "${keyArg}".${hint} Valid keys: ${validKeysHint()}.`, {
34
36
  exit: 2,
35
37
  code: 'UNKNOWN_KEY',
38
+ suggestions: suggestion
39
+ ? [`Use "${suggestion}": ${this.config.bin} ${command} ${suggestion}`]
40
+ : [`Use one of: ${validKeysHint()}`],
36
41
  });
37
42
  }
38
43
  }
39
44
  requireConfigKey(key) {
40
45
  if (!key) {
41
- this.error('Missing required argument: key', { exit: 2, code: 'MISSING_ARGUMENT' });
46
+ const command = this.ctor.id ? toConfiguredId(this.ctor.id, this.config) : 'config';
47
+ this.error('Missing required argument: key', {
48
+ exit: 2,
49
+ code: 'MISSING_ARGUMENT',
50
+ suggestions: [`Choose a key: ${this.config.bin} ${command} storage`],
51
+ });
42
52
  }
43
53
  this.assertKnownKey(key);
44
54
  }