@taurgis/bonsai 3.1.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 (164) hide show
  1. package/README.md +21 -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 +37 -10
  6. package/dist/base-command.js +113 -46
  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 +58 -401
  17. package/dist/commands/import.d.ts +4 -9
  18. package/dist/commands/import.js +56 -280
  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 +56 -65
  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 +22 -4
  46. package/dist/lib/config/index.js +59 -4
  47. package/dist/lib/config/resolve.d.ts +21 -0
  48. package/dist/lib/config/resolve.js +55 -6
  49. package/dist/lib/config-persist.d.ts +28 -0
  50. package/dist/lib/config-persist.js +29 -0
  51. package/dist/lib/envelope.d.ts +41 -3
  52. package/dist/lib/envelope.js +147 -4
  53. package/dist/lib/help-preflight.d.ts +5 -8
  54. package/dist/lib/help-preflight.js +13 -32
  55. package/dist/lib/json-meta.d.ts +0 -2
  56. package/dist/lib/json-meta.js +21 -25
  57. package/dist/lib/limit-flag.d.ts +11 -1
  58. package/dist/lib/limit-flag.js +19 -6
  59. package/dist/lib/parse-error-ux.d.ts +5 -0
  60. package/dist/lib/parse-error-ux.js +73 -0
  61. package/dist/lib/prune-flags.d.ts +28 -0
  62. package/dist/lib/prune-flags.js +74 -0
  63. package/dist/lib/research/artifact-index.d.ts +11 -2
  64. package/dist/lib/research/artifact-index.js +7 -4
  65. package/dist/lib/research/browser.d.ts +41 -2
  66. package/dist/lib/research/browser.js +139 -50
  67. package/dist/lib/research/capture.d.ts +24 -2
  68. package/dist/lib/research/capture.js +24 -4
  69. package/dist/lib/research/cli-io.d.ts +33 -0
  70. package/dist/lib/research/cli-io.js +27 -0
  71. package/dist/lib/research/compress.d.ts +7 -0
  72. package/dist/lib/research/compress.js +7 -3
  73. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  74. package/dist/lib/research/docs/capabilities.js +5 -0
  75. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  76. package/dist/lib/research/docs/clean-dom.js +2 -0
  77. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  78. package/dist/lib/research/docs/code-blocks.js +2 -0
  79. package/dist/lib/research/docs/detect.d.ts +4 -0
  80. package/dist/lib/research/docs/detect.js +4 -0
  81. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  82. package/dist/lib/research/docs/machine-readable.js +10 -0
  83. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  84. package/dist/lib/research/docs/markdown-source.js +19 -2
  85. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  86. package/dist/lib/research/docs/quality-gates.js +4 -0
  87. package/dist/lib/research/docs/rsc.d.ts +9 -2
  88. package/dist/lib/research/docs/rsc.js +5 -2
  89. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  90. package/dist/lib/research/docs/section-artifacts.js +12 -0
  91. package/dist/lib/research/docs/sections.d.ts +18 -0
  92. package/dist/lib/research/docs/sections.js +17 -1
  93. package/dist/lib/research/docs/source-map.d.ts +28 -0
  94. package/dist/lib/research/docs/source-map.js +27 -0
  95. package/dist/lib/research/docs/validate.d.ts +35 -2
  96. package/dist/lib/research/docs/validate.js +47 -2
  97. package/dist/lib/research/extract.d.ts +16 -0
  98. package/dist/lib/research/extract.js +12 -1
  99. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  100. package/dist/lib/research/fetch-command-service.js +310 -0
  101. package/dist/lib/research/fetch-result.d.ts +114 -0
  102. package/dist/lib/research/fetch-result.js +212 -0
  103. package/dist/lib/research/fetcher.d.ts +46 -8
  104. package/dist/lib/research/fetcher.js +58 -30
  105. package/dist/lib/research/freshness.d.ts +54 -11
  106. package/dist/lib/research/freshness.js +91 -31
  107. package/dist/lib/research/import-command-service.d.ts +56 -0
  108. package/dist/lib/research/import-command-service.js +381 -0
  109. package/dist/lib/research/keywords.js +5 -1
  110. package/dist/lib/research/markdown.d.ts +12 -1
  111. package/dist/lib/research/markdown.js +13 -5
  112. package/dist/lib/research/persist-artifact.d.ts +23 -0
  113. package/dist/lib/research/persist-artifact.js +27 -0
  114. package/dist/lib/research/prompt-injection.d.ts +8 -0
  115. package/dist/lib/research/prompt-injection.js +42 -13
  116. package/dist/lib/research/proxy.d.ts +14 -8
  117. package/dist/lib/research/proxy.js +14 -10
  118. package/dist/lib/research/revalidate.d.ts +49 -20
  119. package/dist/lib/research/revalidate.js +80 -15
  120. package/dist/lib/research/schema.d.ts +7 -0
  121. package/dist/lib/research/schema.js +2 -0
  122. package/dist/lib/research/secret-scan.d.ts +6 -0
  123. package/dist/lib/research/secret-scan.js +8 -0
  124. package/dist/lib/research/secure-write.d.ts +11 -1
  125. package/dist/lib/research/secure-write.js +38 -7
  126. package/dist/lib/research/storage.d.ts +5 -1
  127. package/dist/lib/research/storage.js +8 -3
  128. package/dist/lib/research/summarize.d.ts +15 -0
  129. package/dist/lib/research/summarize.js +11 -0
  130. package/dist/lib/research/url.d.ts +9 -6
  131. package/dist/lib/research/url.js +33 -21
  132. package/dist/lib/text.d.ts +7 -2
  133. package/dist/lib/text.js +13 -2
  134. package/dist/lib/value-flags.d.ts +14 -0
  135. package/dist/lib/value-flags.js +28 -0
  136. package/dist/sites/coveo.d.ts +10 -0
  137. package/dist/sites/coveo.js +10 -0
  138. package/dist/sites/index.d.ts +16 -0
  139. package/dist/sites/index.js +16 -4
  140. package/dist/sites/markdown-twin.d.ts +31 -0
  141. package/dist/sites/markdown-twin.js +56 -0
  142. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  143. package/dist/sites/salesforce/fetch-page.js +14 -3
  144. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  145. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  146. package/dist/sites/salesforce/index.d.ts +1 -0
  147. package/dist/sites/salesforce/index.js +1 -0
  148. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  149. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  150. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  151. package/dist/sites/salesforce-developer/index.js +1 -0
  152. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  153. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  154. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  155. package/dist/sites/salesforce-doc-fetch.js +52 -7
  156. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  157. package/dist/sites/salesforce-dom-probe.js +20 -2
  158. package/dist/sites/types.d.ts +14 -8
  159. package/dist/sites/types.js +3 -0
  160. package/oclif.manifest.json +192 -87
  161. package/package.json +19 -7
  162. package/agents/agents/official-docs-researcher.agent.md +0 -87
  163. package/agents/instructions/web-research.instructions.md +0 -44
  164. package/agents/skills/web-research/SKILL.md +0 -118
@@ -2,6 +2,26 @@ import { BUILT_IN_DEFAULTS, STORAGE_MODES, SUMMARY_LEVELS } from './schema.js';
2
2
  /** Scoped env override, following oclif's `<BIN>_*` env-var convention for this CLI. */
3
3
  export const STORAGE_ENV_VAR = 'BONSAI_STORAGE';
4
4
  export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
5
+ /** Two names for the same safety switch: `PLAN_MODE` mirrors agent-harness "plan mode" terminology. */
6
+ export const READ_ONLY_ENV_VAR = 'BONSAI_READ_ONLY';
7
+ export const PLAN_MODE_ENV_VAR = 'BONSAI_PLAN_MODE';
8
+ const BOOLEAN_TRUE_VALUES = ['1', 'true', 'yes'];
9
+ const BOOLEAN_FALSE_VALUES = ['0', 'false', 'no'];
10
+ /**
11
+ * Parses a boolean-ish env value. Empty/unset is `undefined` (not present); an unrecognized
12
+ * non-empty value is also `undefined` (invalid — see {@link invalidEnvOverrideWarnings}), so a
13
+ * typo never silently reads as `true`.
14
+ */
15
+ export function parseEnvBoolean(raw) {
16
+ const v = raw?.trim().toLowerCase();
17
+ if (!v)
18
+ return undefined;
19
+ if (BOOLEAN_TRUE_VALUES.includes(v))
20
+ return true;
21
+ if (BOOLEAN_FALSE_VALUES.includes(v))
22
+ return false;
23
+ return undefined;
24
+ }
5
25
  /**
6
26
  * Warnings for Bonsai env overrides that are set but hold an unrecognized value. Such a value is
7
27
  * dropped during resolution (falling back to config/default), which would otherwise silently mask
@@ -9,18 +29,36 @@ export const SUMMARY_ENV_VAR = 'BONSAI_SUMMARY';
9
29
  * is visible without breaking the run. Returns one message per offending variable, or empty.
10
30
  */
11
31
  export function invalidEnvOverrideWarnings(env) {
32
+ // Each check names the var, how to tell a value is invalid, and the hint to show when it is.
12
33
  const checks = [
13
- [STORAGE_ENV_VAR, STORAGE_MODES],
14
- [SUMMARY_ENV_VAR, SUMMARY_LEVELS],
34
+ [
35
+ STORAGE_ENV_VAR,
36
+ (raw) => STORAGE_MODES.includes(raw),
37
+ `not one of ${STORAGE_MODES.join(', ')}. Using the configured value or default instead.`,
38
+ ],
39
+ [
40
+ SUMMARY_ENV_VAR,
41
+ (raw) => SUMMARY_LEVELS.includes(raw),
42
+ `not one of ${SUMMARY_LEVELS.join(', ')}. Using the configured value or default instead.`,
43
+ ],
44
+ [
45
+ READ_ONLY_ENV_VAR,
46
+ (raw) => parseEnvBoolean(raw) !== undefined,
47
+ 'expected 1/true/yes or 0/false/no. Read-only mode was not toggled by this value.',
48
+ ],
49
+ [
50
+ PLAN_MODE_ENV_VAR,
51
+ (raw) => parseEnvBoolean(raw) !== undefined,
52
+ 'expected 1/true/yes or 0/false/no. Read-only mode was not toggled by this value.',
53
+ ],
15
54
  ];
16
55
  const warnings = [];
17
- for (const [name, valid] of checks) {
56
+ for (const [name, isValid, hint] of checks) {
18
57
  // After trim, an empty/whitespace-only value is falsy and treated as "unset" (no warning),
19
58
  // matching parseEnv*; matching is case-sensitive, so a wrong-case value is invalid and warns.
20
59
  const raw = env[name]?.trim();
21
- if (raw && !valid.includes(raw)) {
22
- warnings.push(`Ignoring ${name}="${raw}": not one of ${valid.join(', ')}. ` +
23
- `Using the configured value or default instead.`);
60
+ if (raw && !isValid(raw)) {
61
+ warnings.push(`Ignoring ${name}="${raw}": ${hint}`);
24
62
  }
25
63
  }
26
64
  return warnings;
@@ -59,3 +97,14 @@ export function resolveSummaryLevel(input) {
59
97
  input.userConfig.summary ??
60
98
  BUILT_IN_DEFAULTS.summary);
61
99
  }
100
+ /**
101
+ * Resolve whether read-only (plan) mode is active. Unlike storage/summary, this is a safety gate
102
+ * rather than a preference: composition is OR, not override-by-precedence — if the flag or either
103
+ * env var says read-only, the run is read-only. There is no way to force writes back on when an
104
+ * ambient `BONSAI_READ_ONLY`/`BONSAI_PLAN_MODE` is set, by design.
105
+ */
106
+ export function resolveReadOnly(input) {
107
+ return Boolean(input.flag ||
108
+ parseEnvBoolean(input.env[READ_ONLY_ENV_VAR]) ||
109
+ parseEnvBoolean(input.env[PLAN_MODE_ENV_VAR]));
110
+ }
@@ -0,0 +1,28 @@
1
+ import { type ConfigKey, type ConfigValues } from './config/index.js';
2
+ /** Scope for config writes (`user` = XDG config dir; `project` = cwd `.bonsai`). */
3
+ export type ConfigWriteScope = 'user' | 'project';
4
+ /** Successful config persist outcome. */
5
+ export interface ConfigPersistResult {
6
+ ok: true;
7
+ }
8
+ /** Failed config persist outcome with stable code and recovery tips. */
9
+ export interface ConfigPersistFailure {
10
+ ok: false;
11
+ code: 'CONFIG_DIR_UNAVAILABLE';
12
+ message: string;
13
+ suggestions: string[];
14
+ }
15
+ /**
16
+ * Persist a config patch to user or project scope. Pure of oclif — callers map failures to
17
+ * this.error(...). Shared by config set and unset so the write branch cannot drift.
18
+ */
19
+ export declare function persistConfigPatch(opts: {
20
+ scope: ConfigWriteScope;
21
+ cwd: string;
22
+ configDir: string | undefined;
23
+ patch: Partial<ConfigValues>;
24
+ /** Used in failure suggestions (set vs unset). */
25
+ action: 'set' | 'unset';
26
+ bin: string;
27
+ key: ConfigKey;
28
+ }): ConfigPersistResult | ConfigPersistFailure;
@@ -0,0 +1,29 @@
1
+ import { writeUserConfig, writeProjectConfig, } from './config/index.js';
2
+ /**
3
+ * Persist a config patch to user or project scope. Pure of oclif — callers map failures to
4
+ * this.error(...). Shared by config set and unset so the write branch cannot drift.
5
+ */
6
+ export function persistConfigPatch(opts) {
7
+ if (opts.scope === 'project') {
8
+ writeProjectConfig(opts.cwd, opts.patch);
9
+ return { ok: true };
10
+ }
11
+ if (!opts.configDir) {
12
+ const verb = opts.action === 'set' ? 'write' : 'remove';
13
+ const example = opts.action === 'set'
14
+ ? `${opts.bin} config set ${opts.key} <value> --local`
15
+ : `${opts.bin} config unset ${opts.key} --local`;
16
+ return {
17
+ ok: false,
18
+ code: 'CONFIG_DIR_UNAVAILABLE',
19
+ message: `Could not determine user config directory. Use --local to ${verb} project config.`,
20
+ suggestions: [
21
+ opts.action === 'set'
22
+ ? `Write project config instead: ${example}`
23
+ : `Remove project config instead: ${example}`,
24
+ ],
25
+ };
26
+ }
27
+ writeUserConfig(opts.configDir, opts.patch);
28
+ return { ok: true };
29
+ }
@@ -7,8 +7,12 @@ export interface CliErrorShape {
7
7
  }
8
8
  /** Map oclif parse-time errors to stable Bonsai codes when the throw site did not set one. */
9
9
  export declare function stableErrorCodeFrom(err: unknown): string | undefined;
10
- /** Strip oclif's generic help suffix from JSON stderr — agents already have structured codes. */
10
+ /**
11
+ * Strip oclif's generic help suffix and the `Parsing --flag` wrapper so agents and humans see the
12
+ * actionable message we threw (e.g. `Limit must be between 1 and 100.`) instead of the wrapper.
13
+ */
11
14
  export declare function normalizeCliErrorMessage(message: string): string;
15
+ /** Machine-readable envelope fields for `--json` success and error paths. */
12
16
  export interface EnvelopeParts {
13
17
  command: string;
14
18
  ok: boolean;
@@ -25,12 +29,46 @@ export interface EnvelopeParts {
25
29
  * without ANSI or the leading "Error:" prefix (the message is already the primary line).
26
30
  */
27
31
  export declare function formatErrorForJson(err: CliErrorShape): string;
32
+ /** Stable CLIError fields for batch failure rows (status/inspect/fetch). */
33
+ export declare function cliErrorFields(err: {
34
+ code?: string;
35
+ message?: string;
36
+ suggestions?: string[];
37
+ ref?: string;
38
+ }, fallbackCode?: string): {
39
+ code: string;
40
+ message: string;
41
+ suggestions?: string[];
42
+ ref?: string;
43
+ };
44
+ /**
45
+ * CACHE_MISS overlay. Message wording is fixed so status/inspect cannot drift.
46
+ */
47
+ export declare function enrichCacheMissEnvelope(envelope: Record<string, unknown>, data: unknown, bin: string): Record<string, unknown>;
48
+ /**
49
+ * Per-row `.error` overlay for multi-URL batches (fetch, status, inspect).
50
+ * Keeps prior successes in `data` and surfaces the first row's code/message.
51
+ */
52
+ export declare function enrichRowErrorEnvelope(envelope: Record<string, unknown>, data: unknown): Record<string, unknown>;
53
+ /**
54
+ * Partial prune unlink failure: candidates remain in `data`, envelope reports the stable code.
55
+ */
56
+ export declare function enrichPrunePartialEnvelope(envelope: Record<string, unknown>, data: unknown): Record<string, unknown>;
28
57
  /**
29
58
  * Builds the standard Bonsai CLI JSON envelope structure.
30
59
  * This is the single source of truth for the output envelope schema.
31
60
  */
32
61
  export declare function buildEnvelope(parts: EnvelopeParts): Record<string, unknown>;
33
62
  /**
34
- * Formats a JSON envelope to a string with indentation.
63
+ * Usage/not-found style error envelope: message + Code (+ Try this) in `stderr`, stable `code`,
64
+ * and top-level `suggestions` when present. Shared by argv preflight, help-preflight, and the
65
+ * command_not_found hook so those paths cannot drift.
35
66
  */
36
- export declare function formatEnvelope(parts: EnvelopeParts): string;
67
+ export declare function buildCliErrorEnvelope(opts: {
68
+ command: string;
69
+ message: string;
70
+ code: string;
71
+ suggestions?: string[];
72
+ exitCode?: number;
73
+ ref?: string;
74
+ }): Record<string, unknown>;
@@ -1,3 +1,4 @@
1
+ import { EXIT_RUNTIME_FAILURE } from './cli-error-policy.js';
1
2
  /** Map oclif parse-time errors to stable Bonsai codes when the throw site did not set one. */
2
3
  export function stableErrorCodeFrom(err) {
3
4
  const e = err;
@@ -21,10 +22,23 @@ export function stableErrorCodeFrom(err) {
21
22
  /^Flag --\S+ expects (a value$|one of these values:)/.test(e.message)) {
22
23
  return 'MISSING_FLAG_VALUE';
23
24
  }
25
+ // Repeating a single-value flag (e.g. `--topic a --topic b`) is also a plain CLIError with no
26
+ // dedicated class — oclif's parser rejects it before any flag-specific validation runs.
27
+ if (typeof e?.message === 'string' && /^Flag --\S+ can only be specified once$/.test(e.message)) {
28
+ return 'DUPLICATE_FLAG';
29
+ }
24
30
  return undefined;
25
31
  }
26
- /** Strip oclif's generic help suffix from JSON stderr — agents already have structured codes. */
32
+ /** oclif wraps custom flag parse failures as `Parsing --name \n\t…\nSee more help with --help`. */
33
+ const PARSING_WRAPPER = /^Parsing --\S+ \n\t([\s\S]*?)(?:\nSee more help with --help)?$/;
34
+ /**
35
+ * Strip oclif's generic help suffix and the `Parsing --flag` wrapper so agents and humans see the
36
+ * actionable message we threw (e.g. `Limit must be between 1 and 100.`) instead of the wrapper.
37
+ */
27
38
  export function normalizeCliErrorMessage(message) {
39
+ const wrapped = message.match(PARSING_WRAPPER);
40
+ if (wrapped?.[1])
41
+ return wrapped[1];
28
42
  const suffix = '\nSee more help with --help';
29
43
  return message.endsWith(suffix) ? message.slice(0, -suffix.length) : message;
30
44
  }
@@ -52,6 +66,119 @@ export function formatErrorForJson(err) {
52
66
  lines.push(`Reference: ${err.ref}`);
53
67
  return lines.join('\n');
54
68
  }
69
+ /** Stable CLIError fields for batch failure rows (status/inspect/fetch). */
70
+ export function cliErrorFields(err, fallbackCode = 'INVALID_URL') {
71
+ return {
72
+ code: typeof err.code === 'string' && err.code ? err.code : fallbackCode,
73
+ message: typeof err.message === 'string' ? err.message : String(err),
74
+ suggestions: err.suggestions,
75
+ ref: err.ref,
76
+ };
77
+ }
78
+ /**
79
+ * Overlay a batch failure onto a success envelope while keeping per-row `data`.
80
+ * Shared by multi-URL status/inspect (CACHE_MISS) and fetch/row errors.
81
+ */
82
+ function enrichBatchFailureEnvelope(envelope, data, opts) {
83
+ const list = Array.isArray(data) ? data : [data];
84
+ const failures = list.map(opts.pick).filter(Boolean);
85
+ if (failures.length === 0)
86
+ return envelope;
87
+ const first = failures[0];
88
+ const code = typeof opts.code === 'function' ? opts.code(first, failures) : opts.code;
89
+ const suggestions = opts.suggestions?.(failures);
90
+ const ref = opts.ref?.(first);
91
+ const stderr = formatErrorForJson({
92
+ message: opts.message(first, failures),
93
+ code,
94
+ suggestions,
95
+ ref,
96
+ });
97
+ return {
98
+ ...envelope,
99
+ ok: false,
100
+ exitCode: opts.exitCode ?? 1,
101
+ stderr,
102
+ code,
103
+ suggestions,
104
+ ref,
105
+ };
106
+ }
107
+ /**
108
+ * CACHE_MISS overlay. Message wording is fixed so status/inspect cannot drift.
109
+ */
110
+ export function enrichCacheMissEnvelope(envelope, data, bin) {
111
+ return enrichBatchFailureEnvelope(envelope, data, {
112
+ pick: (row) => {
113
+ const r = row;
114
+ return r?.status === 'miss' ? r : null;
115
+ },
116
+ code: 'CACHE_MISS',
117
+ exitCode: EXIT_RUNTIME_FAILURE,
118
+ message: (first, failures) => {
119
+ const url = first.normalizedUrl ?? '';
120
+ return failures.length > 1
121
+ ? `Cache miss for ${url} and ${failures.length - 1} other URLs`
122
+ : `Cache miss for ${url}`;
123
+ },
124
+ // A URL that is already a secondary source of a different cached artifact must not be pointed
125
+ // at a plain fetch — that would create an unrelated duplicate entry instead of finding the
126
+ // note that already covers it.
127
+ suggestions: (failures) => failures.map((m) => m.partOfExistingNote
128
+ ? `Find it with: ${bin} list --url "${m.normalizedUrl}"`
129
+ : `Fetch and cache it first: ${bin} ${m.normalizedUrl}`),
130
+ });
131
+ }
132
+ /**
133
+ * Per-row `.error` overlay for multi-URL batches (fetch, status, inspect).
134
+ * Keeps prior successes in `data` and surfaces the first row's code/message.
135
+ */
136
+ export function enrichRowErrorEnvelope(envelope, data) {
137
+ return enrichBatchFailureEnvelope(envelope, data, {
138
+ pick: (row) => {
139
+ if (!row || typeof row !== 'object')
140
+ return null;
141
+ return row.error ?? null;
142
+ },
143
+ code: (first) => first.code ?? 'FETCH_FAILED',
144
+ message: (first, failures) => (first.message ?? '') +
145
+ (failures.length > 1 ? `\n…and ${failures.length - 1} other URL failure(s)` : ''),
146
+ suggestions: (failures) => failures[0]?.suggestions,
147
+ ref: (first) => first.ref,
148
+ exitCode: EXIT_RUNTIME_FAILURE,
149
+ });
150
+ }
151
+ /**
152
+ * Partial prune unlink failure: candidates remain in `data`, envelope reports the stable code.
153
+ */
154
+ export function enrichPrunePartialEnvelope(envelope, data) {
155
+ if (!isPruneCounts(data))
156
+ return envelope;
157
+ // A dry run never deletes anything, so prunedCount is always 0 by design — that is a preview,
158
+ // not a partial failure, and must never be reported as one.
159
+ if (data.dryRun)
160
+ return envelope;
161
+ if (data.candidateCount <= 0 || data.prunedCount >= data.candidateCount)
162
+ return envelope;
163
+ const failed = data.candidateCount - data.prunedCount;
164
+ const code = 'PRUNE_PARTIAL_FAILURE';
165
+ const message = `Failed to delete ${failed} of ${data.candidateCount} cache ${failed === 1 ? 'entry' : 'entries'}.`;
166
+ return {
167
+ ...envelope,
168
+ ok: false,
169
+ exitCode: EXIT_RUNTIME_FAILURE,
170
+ code,
171
+ stderr: formatErrorForJson({ message, code }),
172
+ };
173
+ }
174
+ function isPruneCounts(data) {
175
+ if (!data || typeof data !== 'object')
176
+ return false;
177
+ const d = data;
178
+ return (typeof d.prunedCount === 'number' &&
179
+ typeof d.candidateCount === 'number' &&
180
+ typeof d.dryRun === 'boolean');
181
+ }
55
182
  /**
56
183
  * Builds the standard Bonsai CLI JSON envelope structure.
57
184
  * This is the single source of truth for the output envelope schema.
@@ -75,8 +202,24 @@ export function buildEnvelope(parts) {
75
202
  return envelope;
76
203
  }
77
204
  /**
78
- * Formats a JSON envelope to a string with indentation.
205
+ * Usage/not-found style error envelope: message + Code (+ Try this) in `stderr`, stable `code`,
206
+ * and top-level `suggestions` when present. Shared by argv preflight, help-preflight, and the
207
+ * command_not_found hook so those paths cannot drift.
79
208
  */
80
- export function formatEnvelope(parts) {
81
- return JSON.stringify(buildEnvelope(parts), null, 2);
209
+ export function buildCliErrorEnvelope(opts) {
210
+ return buildEnvelope({
211
+ command: opts.command,
212
+ ok: false,
213
+ exitCode: opts.exitCode ?? 2,
214
+ stderr: formatErrorForJson({
215
+ message: opts.message,
216
+ code: opts.code,
217
+ suggestions: opts.suggestions,
218
+ ref: opts.ref,
219
+ }),
220
+ data: null,
221
+ code: opts.code,
222
+ suggestions: opts.suggestions?.length ? opts.suggestions : undefined,
223
+ ref: opts.ref,
224
+ });
82
225
  }
@@ -1,15 +1,12 @@
1
- type UnknownHelpResult = {
2
- exitCode: 2;
3
- kind: 'human';
4
- message: string;
5
- } | {
1
+ import { EXIT_USAGE } from './cli-error-policy.js';
2
+ /** Always an envelope — human and JSON share one formatted stderr (Code + Try this). */
3
+ export type UnknownHelpResult = {
4
+ exitCode: typeof EXIT_USAGE;
6
5
  envelope: Record<string, unknown>;
7
- exitCode: 2;
8
- kind: 'json';
6
+ json: boolean;
9
7
  };
10
8
  /**
11
9
  * oclif resolves help before its command_not_found hook, so `bonsai config gett --help` otherwise
12
10
  * falls back to a terse framework error. Catch that narrow case and reuse the normal typo guidance.
13
11
  */
14
12
  export declare function tryUnknownHelpOutput(argv: readonly string[], root: string): Promise<UnknownHelpResult | null>;
15
- export {};
@@ -1,22 +1,12 @@
1
1
  import { Config } from '@oclif/core';
2
- import { buildEnvelope } from './envelope.js';
2
+ import { positionalArgvTokens } from './argv.js';
3
+ import { EXIT_USAGE } from './cli-error-policy.js';
4
+ import { VALUE_TAKING_FLAG_TOKENS } from './cli-flag-manifest.js';
5
+ import { buildCliErrorEnvelope } from './envelope.js';
3
6
  import { buildCommandNotFoundDetails } from '../hooks/command-not-found/suggest.js';
4
7
  function commandId(parts) {
5
8
  return parts.join(':');
6
9
  }
7
- function positionalTokens(argv) {
8
- const tokens = [];
9
- for (const arg of argv) {
10
- if (arg === '--')
11
- break;
12
- if (arg === '--help' || arg === '--json')
13
- continue;
14
- if (arg.startsWith('-'))
15
- continue;
16
- tokens.push(arg);
17
- }
18
- return tokens;
19
- }
20
10
  function commandPrefixLength(tokens, config) {
21
11
  let longest = 0;
22
12
  for (let n = 1; n <= tokens.length; n++) {
@@ -51,7 +41,7 @@ function hasValidHelpTarget(tokens, config) {
51
41
  export async function tryUnknownHelpOutput(argv, root) {
52
42
  if (!argv.includes('--help'))
53
43
  return null;
54
- const tokens = positionalTokens(argv);
44
+ const tokens = positionalArgvTokens(argv, VALUE_TAKING_FLAG_TOKENS);
55
45
  if (tokens.length === 0)
56
46
  return null;
57
47
  const config = await Config.load({ root });
@@ -59,23 +49,14 @@ export async function tryUnknownHelpOutput(argv, root) {
59
49
  return null;
60
50
  const attemptedId = commandId(tokens);
61
51
  const details = buildCommandNotFoundDetails(attemptedId, [...argv], config);
62
- if (argv.includes('--json')) {
63
- return {
64
- kind: 'json',
65
- exitCode: 2,
66
- envelope: buildEnvelope({
67
- command: details.command,
68
- ok: false,
69
- exitCode: 2,
70
- stderr: `${details.message}\nCode: ${details.code}`,
71
- data: null,
72
- code: details.code,
73
- }),
74
- };
75
- }
76
52
  return {
77
- kind: 'human',
78
- exitCode: 2,
79
- message: `${details.message}\nCode: ${details.code}`,
53
+ exitCode: EXIT_USAGE,
54
+ json: argv.includes('--json'),
55
+ envelope: buildCliErrorEnvelope({
56
+ command: details.command,
57
+ message: details.message,
58
+ code: details.code,
59
+ suggestions: details.suggestions,
60
+ }),
80
61
  };
81
62
  }
@@ -1,5 +1,3 @@
1
- /** True when argv asks for help or version together with --json. */
2
- export declare function isJsonMetaRequest(argv: readonly string[]): boolean;
3
1
  /**
4
2
  * When `--json` is combined with `--help` or `--version`, oclif would print human text to stdout.
5
3
  * Render the same content inside the standard Bonsai JSON envelope instead.
@@ -1,12 +1,14 @@
1
1
  import { Config, Help, toConfiguredId } from '@oclif/core';
2
+ import { positionalArgvTokens } from './argv.js';
3
+ import { EXIT_OK, EXIT_RUNTIME_FAILURE } from './cli-error-policy.js';
4
+ import { VALUE_TAKING_FLAG_TOKENS } from './cli-flag-manifest.js';
2
5
  import { buildEnvelope } from './envelope.js';
3
6
  /** Strip ANSI color codes so JSON help text stays machine-stable. */
4
7
  function stripAnsi(text) {
5
- // eslint-disable-next-line no-control-regex
6
8
  return text.replace(/\x1b\[[0-9;]*m/g, '');
7
9
  }
8
10
  /** True when argv asks for help or version together with --json. */
9
- export function isJsonMetaRequest(argv) {
11
+ function isJsonMetaRequest(argv) {
10
12
  if (!argv.includes('--json'))
11
13
  return false;
12
14
  return argv.includes('--help') || argv.includes('--version');
@@ -21,27 +23,21 @@ class CaptureHelp extends Help {
21
23
  }
22
24
  }
23
25
  }
24
- function helpSubject(argv) {
25
- for (const arg of argv) {
26
- if (arg === '--' || arg === '--help' || arg === 'help')
27
- continue;
28
- if (arg.startsWith('-'))
29
- return;
30
- return arg;
31
- }
32
- return undefined;
33
- }
34
26
  function envelopeCommandId(config, argv) {
35
- const subject = helpSubject(argv);
36
- if (!subject)
27
+ const tokens = positionalArgvTokens(argv, VALUE_TAKING_FLAG_TOKENS);
28
+ if (tokens.length === 0)
37
29
  return config.bin;
38
- const command = config.findCommand(subject);
39
- if (command?.id)
40
- return toConfiguredId(command.id, config);
41
- const topic = config.findTopic(subject);
30
+ // Longest matching command id wins (`config:get` over topic `config`).
31
+ for (let n = tokens.length; n >= 1; n--) {
32
+ const id = tokens.slice(0, n).join(':');
33
+ const command = config.findCommand(id);
34
+ if (command?.id)
35
+ return toConfiguredId(command.id, config);
36
+ }
37
+ const topic = config.findTopic(tokens[0]);
42
38
  if (topic)
43
39
  return topic.name;
44
- return subject;
40
+ return tokens[0];
45
41
  }
46
42
  /**
47
43
  * When `--json` is combined with `--help` or `--version`, oclif would print human text to stdout.
@@ -54,11 +50,11 @@ export async function tryJsonMetaOutput(argv, root) {
54
50
  const config = await Config.load({ root });
55
51
  if (argv.includes('--version')) {
56
52
  return {
57
- exitCode: 0,
53
+ exitCode: EXIT_OK,
58
54
  envelope: buildEnvelope({
59
55
  command: config.bin,
60
56
  ok: true,
61
- exitCode: 0,
57
+ exitCode: EXIT_OK,
62
58
  stderr: '',
63
59
  data: {
64
60
  version: config.version,
@@ -71,11 +67,11 @@ export async function tryJsonMetaOutput(argv, root) {
71
67
  await help.showHelp([...argv]);
72
68
  const helpText = stripAnsi(help.lines.join('\n').trimEnd());
73
69
  return {
74
- exitCode: 0,
70
+ exitCode: EXIT_OK,
75
71
  envelope: buildEnvelope({
76
72
  command: envelopeCommandId(config, argv),
77
73
  ok: true,
78
- exitCode: 0,
74
+ exitCode: EXIT_OK,
79
75
  stderr: '',
80
76
  data: { help: helpText },
81
77
  }),
@@ -84,11 +80,11 @@ export async function tryJsonMetaOutput(argv, root) {
84
80
  catch (err) {
85
81
  const message = err instanceof Error ? err.message : String(err);
86
82
  return {
87
- exitCode: 1,
83
+ exitCode: EXIT_RUNTIME_FAILURE,
88
84
  envelope: buildEnvelope({
89
85
  command: 'bonsai',
90
86
  ok: false,
91
- exitCode: 1,
87
+ exitCode: EXIT_RUNTIME_FAILURE,
92
88
  stderr: message,
93
89
  data: null,
94
90
  code: 'META_RENDER_FAILED',
@@ -1 +1,11 @@
1
- export declare function limitFlag(max: number, defaultValue: number, description: string): import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
1
+ /**
2
+ * Build an oclif integer `--limit` flag capped at `maxLimit`.
3
+ *
4
+ * @param maxLimit - Inclusive upper bound for accepted values.
5
+ * @param defaultValue - Default when the flag is omitted.
6
+ * @param description - Help text for the flag.
7
+ * @returns An oclif custom flag definition.
8
+ * @throws {Errors.CLIError} With code `INVALID_LIMIT` and exit {@link EXIT_USAGE} on bad input
9
+ * (thrown by oclif during parse, not by this factory itself).
10
+ */
11
+ export declare function limitFlag(maxLimit: number, defaultValue: number, description: string): import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,18 +1,31 @@
1
1
  import { Errors, Flags } from '@oclif/core';
2
- export function limitFlag(max, defaultValue, description) {
2
+ import { EXIT_USAGE } from './cli-error-policy.js';
3
+ /**
4
+ * Build an oclif integer `--limit` flag capped at `maxLimit`.
5
+ *
6
+ * @param maxLimit - Inclusive upper bound for accepted values.
7
+ * @param defaultValue - Default when the flag is omitted.
8
+ * @param description - Help text for the flag.
9
+ * @returns An oclif custom flag definition.
10
+ * @throws {Errors.CLIError} With code `INVALID_LIMIT` and exit {@link EXIT_USAGE} on bad input
11
+ * (thrown by oclif during parse, not by this factory itself).
12
+ */
13
+ export function limitFlag(maxLimit, defaultValue, description) {
3
14
  return Flags.custom({
4
15
  parse: async (input) => {
5
16
  if (!/^\d+$/.test(input)) {
6
- throw new Errors.CLIError(`Limit must be an integer between 1 and ${max}.`, {
7
- exit: 2,
17
+ throw new Errors.CLIError(`Limit must be an integer between 1 and ${maxLimit}.`, {
18
+ exit: EXIT_USAGE,
8
19
  code: 'INVALID_LIMIT',
20
+ suggestions: [`Use an integer from 1 to ${maxLimit}.`],
9
21
  });
10
22
  }
11
23
  const value = Number(input);
12
- if (value < 1 || value > max) {
13
- throw new Errors.CLIError(`Limit must be between 1 and ${max}.`, {
14
- exit: 2,
24
+ if (value < 1 || value > maxLimit) {
25
+ throw new Errors.CLIError(`Limit must be between 1 and ${maxLimit}.`, {
26
+ exit: EXIT_USAGE,
15
27
  code: 'INVALID_LIMIT',
28
+ suggestions: [`Use a value from 1 to ${maxLimit}.`],
16
29
  });
17
30
  }
18
31
  return value;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Improve oclif parse-time errors in place: unwrap wrapper text, then suggest flag/option typos.
3
+ * Accepts `unknown` because `catch` can receive non-Error throws (bare strings, etc.).
4
+ */
5
+ export declare function enrichParseError(err: unknown): void;