@taurgis/bonsai 1.0.5 → 3.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 (126) hide show
  1. package/README.md +59 -12
  2. package/agents/agents/official-docs-researcher.agent.md +15 -26
  3. package/agents/agents/salesforce-docs-researcher.agent.md +17 -35
  4. package/agents/hooks/README.md +2 -9
  5. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +1 -1
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +1 -1
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +1 -1
  8. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +1 -1
  9. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +1 -1
  10. package/agents/instructions/salesforce-research.instructions.md +7 -6
  11. package/agents/instructions/web-research.instructions.md +3 -2
  12. package/agents/skills/web-research/SKILL.md +24 -32
  13. package/bin/cli.mjs +44 -14
  14. package/dist/base-command.d.ts +17 -1
  15. package/dist/base-command.js +70 -9
  16. package/dist/commands/config/base.d.ts +2 -0
  17. package/dist/commands/config/base.js +15 -1
  18. package/dist/commands/config/get.d.ts +1 -1
  19. package/dist/commands/config/get.js +4 -5
  20. package/dist/commands/config/index.d.ts +12 -0
  21. package/dist/commands/config/index.js +34 -0
  22. package/dist/commands/config/list.js +2 -2
  23. package/dist/commands/config/set.js +18 -12
  24. package/dist/commands/config/unset.d.ts +1 -1
  25. package/dist/commands/config/unset.js +9 -6
  26. package/dist/commands/fetch.d.ts +2 -0
  27. package/dist/commands/fetch.js +69 -45
  28. package/dist/commands/import.d.ts +3 -0
  29. package/dist/commands/import.js +91 -53
  30. package/dist/commands/inspect.d.ts +8 -0
  31. package/dist/commands/inspect.js +59 -30
  32. package/dist/commands/list.d.ts +5 -3
  33. package/dist/commands/list.js +60 -35
  34. package/dist/commands/prune.d.ts +2 -1
  35. package/dist/commands/prune.js +62 -19
  36. package/dist/commands/status.d.ts +8 -0
  37. package/dist/commands/status.js +84 -26
  38. package/dist/commands.d.ts +2 -2
  39. package/dist/commands.js +2 -2
  40. package/dist/hooks/command-not-found/suggest.d.ts +7 -1
  41. package/dist/hooks/command-not-found/suggest.js +100 -46
  42. package/dist/lib/argv.d.ts +10 -0
  43. package/dist/lib/argv.js +97 -0
  44. package/dist/lib/color.d.ts +20 -0
  45. package/dist/lib/color.js +36 -0
  46. package/dist/lib/envelope.d.ts +36 -0
  47. package/dist/lib/envelope.js +82 -0
  48. package/dist/lib/help-preflight.d.ts +15 -0
  49. package/dist/lib/help-preflight.js +81 -0
  50. package/dist/lib/json-meta.d.ts +10 -0
  51. package/dist/lib/json-meta.js +98 -0
  52. package/dist/lib/limit-flag.d.ts +1 -0
  53. package/dist/lib/limit-flag.js +24 -0
  54. package/dist/lib/research/{search-index.d.ts → artifact-index.d.ts} +5 -5
  55. package/dist/lib/research/{search-index.js → artifact-index.js} +10 -10
  56. package/dist/lib/research/browser.d.ts +0 -8
  57. package/dist/lib/research/browser.js +20 -43
  58. package/dist/lib/research/regression.d.ts +18 -0
  59. package/dist/lib/research/regression.js +47 -0
  60. package/dist/lib/research/revalidate.js +3 -1
  61. package/dist/lib/research/schema.d.ts +5 -2
  62. package/dist/lib/research/schema.js +12 -1
  63. package/dist/lib/research/storage.d.ts +0 -1
  64. package/dist/lib/research/storage.js +15 -13
  65. package/dist/lib/research/url.d.ts +22 -0
  66. package/dist/lib/research/url.js +63 -1
  67. package/dist/lib/text.d.ts +5 -1
  68. package/dist/lib/text.js +11 -1
  69. package/dist/sites/coveo.d.ts +7 -0
  70. package/dist/sites/coveo.js +33 -0
  71. package/dist/sites/index.js +1 -2
  72. package/dist/sites/salesforce/fetch-page.js +1 -1
  73. package/dist/sites/salesforce/index.js +3 -8
  74. package/dist/sites/salesforce-developer/fetch-page.js +3 -1
  75. package/dist/sites/salesforce-developer/index.js +1 -2
  76. package/dist/sites/salesforce-doc-fetch.d.ts +11 -0
  77. package/dist/sites/salesforce-doc-fetch.js +117 -76
  78. package/dist/sites/salesforce-dom-probe.d.ts +16 -0
  79. package/dist/sites/salesforce-dom-probe.js +114 -0
  80. package/dist/sites/types.d.ts +0 -6
  81. package/oclif.manifest.json +153 -155
  82. package/package.json +38 -25
  83. package/dist/commands/index.d.ts +0 -35
  84. package/dist/commands/index.js +0 -306
  85. package/dist/commands/research/config/base.d.ts +0 -23
  86. package/dist/commands/research/config/base.js +0 -46
  87. package/dist/commands/research/config/get.d.ts +0 -20
  88. package/dist/commands/research/config/get.js +0 -45
  89. package/dist/commands/research/config/list.d.ts +0 -17
  90. package/dist/commands/research/config/list.js +0 -42
  91. package/dist/commands/research/config/set.d.ts +0 -31
  92. package/dist/commands/research/config/set.js +0 -94
  93. package/dist/commands/research/config/unset.d.ts +0 -21
  94. package/dist/commands/research/config/unset.js +0 -59
  95. package/dist/commands/research/import.d.ts +0 -38
  96. package/dist/commands/research/import.js +0 -313
  97. package/dist/commands/research/inspect.d.ts +0 -13
  98. package/dist/commands/research/inspect.js +0 -86
  99. package/dist/commands/research/list.d.ts +0 -25
  100. package/dist/commands/research/list.js +0 -143
  101. package/dist/commands/research/prune.d.ts +0 -24
  102. package/dist/commands/research/prune.js +0 -141
  103. package/dist/commands/research/search.d.ts +0 -32
  104. package/dist/commands/research/search.js +0 -334
  105. package/dist/commands/research/status.d.ts +0 -17
  106. package/dist/commands/research/status.js +0 -108
  107. package/dist/commands/research.d.ts +0 -36
  108. package/dist/commands/research.js +0 -304
  109. package/dist/commands/search.d.ts +0 -31
  110. package/dist/commands/search.js +0 -329
  111. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +0 -1
  112. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +0 -2
  113. package/dist/lib/research/docs/discovery-index.d.ts +0 -14
  114. package/dist/lib/research/docs/discovery-index.js +0 -112
  115. package/dist/lib/research/docs/remote-search-runner.d.ts +0 -21
  116. package/dist/lib/research/docs/remote-search-runner.js +0 -114
  117. package/dist/lib/research/docs/remote-search.d.ts +0 -14
  118. package/dist/lib/research/docs/remote-search.js +0 -101
  119. package/dist/lib/research/docs/search-index.d.ts +0 -13
  120. package/dist/lib/research/docs/search-index.js +0 -176
  121. package/dist/sites/salesforce/browser-search.d.ts +0 -7
  122. package/dist/sites/salesforce/browser-search.js +0 -82
  123. package/dist/sites/salesforce/coveo.d.ts +0 -23
  124. package/dist/sites/salesforce/coveo.js +0 -161
  125. package/dist/sites/salesforce/token.d.ts +0 -26
  126. package/dist/sites/salesforce/token.js +0 -154
@@ -1,20 +1,24 @@
1
1
  import { toConfiguredId } from '@oclif/core';
2
- import { closestMatch } from '../../lib/text.js';
3
- // Nearest command must be within this edit distance to be offered as a correction. Bonsai's command
4
- // ids are short, so 3 catches realistic typos ("statuss", "improt") without suggesting an unrelated
5
- // command for input that resembles nothing.
6
- const MAX_SUGGESTION_DISTANCE = 3;
2
+ import { closestMatch, maxFuzzyDistance } from '../../lib/text.js';
3
+ import { buildEnvelope, formatErrorForJson } from '../../lib/envelope.js';
4
+ import { looksLikeSchemelessUrl } from '../../lib/research/url.js';
7
5
  /**
8
6
  * The nearest visible command to a typo, plus how many leading segments of the attempted id name
9
7
  * the command. With `topicSeparator: ' '`, oclif folds every leading token of an unknown command
10
- * into one colon-delimited id, so `bonsai serch hello` arrives as `serch:hello`. Matching that whole
8
+ * into one colon-delimited id, so `bonsai lisst extra` arrives as `lisst:extra`. Matching that whole
11
9
  * string never finds a short command, so walk prefixes longest→shortest and suggest off the closest:
12
- * `serch:hello` resolves to nothing, but its prefix `serch` resolves to `search`. The matched prefix
10
+ * `lisst:extra` resolves to nothing, but its prefix `lisst` resolves to `list`. The matched prefix
13
11
  * length doubles as the command-segment count, so a folded-in positional arg is dropped from display.
14
12
  */
15
13
  function findSuggestion(segments, visibleIds) {
16
14
  for (let n = segments.length; n >= 1; n--) {
17
- const suggestion = closestMatch(segments.slice(0, n).join(':'), visibleIds, MAX_SUGGESTION_DISTANCE);
15
+ const attempted = segments.slice(0, n).join(':');
16
+ const suggestion = closestMatch(attempted, visibleIds, maxFuzzyDistance(attempted));
17
+ if (suggestion === attempted &&
18
+ n < segments.length &&
19
+ visibleIds.some((id) => id.startsWith(`${attempted}:`))) {
20
+ continue;
21
+ }
18
22
  if (suggestion)
19
23
  return { suggestion, commandSegments: n };
20
24
  }
@@ -31,63 +35,113 @@ function topicChainLength(segments, config) {
31
35
  i++;
32
36
  return i + 1;
33
37
  }
34
- // Dot-separated, non-empty labels: a real domain (`docs.nestjs.com`) or IPv4 (`192.168.1.1`). The
35
- // URL parser also accepts a leading-dot filename (`.env`) or a relative path (`./x`) as a "host",
36
- // whose suggestion would be a nonsense `https://.env`; requiring well-formed labels rejects those.
37
- // Anchored with no overlapping quantifiers, so it is ReDoS-safe on the (already length-bounded) host.
38
- const DOMAIN_OR_IPV4 = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
38
+ function exactZeroArgCommandPrefix(segments, visibleIds, config) {
39
+ for (let n = segments.length - 1; n >= 1; n--) {
40
+ const candidate = segments.slice(0, n).join(':');
41
+ if (!visibleIds.includes(candidate))
42
+ continue;
43
+ if (visibleIds.some((id) => id.startsWith(`${candidate}:`)))
44
+ continue;
45
+ const command = config.commands.find((entry) => entry.id === candidate);
46
+ const args = Object.keys(command?.args ?? {});
47
+ if (args.length > 0)
48
+ continue;
49
+ const extra = segments[n];
50
+ if (extra === undefined)
51
+ continue;
52
+ return { commandId: candidate, extra };
53
+ }
54
+ return null;
55
+ }
39
56
  /**
40
57
  * The input as-typed when it looks like a URL missing its scheme (`example.com`,
41
58
  * `docs.nestjs.com/guide`), else null. `bonsai <url>` is the headline command, but bin/cli.mjs only
42
59
  * routes args carrying a `://` scheme to the fetch shorthand — so a scheme-less URL falls through to
43
60
  * `command_not_found`. A domain-shaped host separates a forgotten-scheme URL from an ordinary command
44
61
  * typo: `statuss` has no dot and never matches, while `config:frobnicate` fails to parse (the second
45
- * segment becomes a non-numeric port), so neither is misread as a URL.
62
+ * segment becomes a non-numeric port), so neither is misread as a URL. The same domain-shape test
63
+ * powers normalizeUrl's "missing scheme" hint, so the two entry paths stay in lockstep.
46
64
  */
47
65
  function bareUrlInput(id) {
48
- if (id.includes('://'))
49
- return null;
50
- let hostname;
51
- try {
52
- hostname = new URL(`https://${id}`).hostname;
53
- }
54
- catch {
55
- return null;
56
- }
57
- return DOMAIN_OR_IPV4.test(hostname) ? id : null;
66
+ return looksLikeSchemelessUrl(id) ? id : null;
58
67
  }
59
- /**
60
- * Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
61
- * throwing here (via `this.error`) replaces that default with a more helpful message. A correction
62
- * is offered only when the nearest command is a close match, and it is never auto-run — the user
63
- * re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
64
- */
65
- const hook = async function (opts) {
66
- const hiddenIds = new Set(opts.config.commands.filter((c) => c.hidden).map((c) => c.id));
67
- const visibleIds = opts.config.commandIDs.filter((id) => !hiddenIds.has(id));
68
+ function isJsonMode(argv) {
69
+ return argv?.includes('--json') ?? false;
70
+ }
71
+ function emitJsonError(command, message, code) {
72
+ const envelope = buildEnvelope({
73
+ command,
74
+ ok: false,
75
+ exitCode: 2,
76
+ stderr: formatErrorForJson({ message, code }),
77
+ data: null,
78
+ code,
79
+ });
80
+ process.exitCode = 2;
81
+ console.log(JSON.stringify(envelope, null, 2));
82
+ return envelope;
83
+ }
84
+ export function buildCommandNotFoundDetails(id, argv, config) {
85
+ const hiddenIds = new Set(config.commands.filter((c) => c.hidden).map((c) => c.id));
86
+ const visibleIds = config.commandIDs.filter((commandId) => !hiddenIds.has(commandId));
68
87
  // A scheme-less URL is the most common "not a command" mistake for this CLI, so steer the user to
69
88
  // the `bonsai <url>` shorthand with a scheme before falling back to nearest-command matching (which
70
89
  // never finds a command for a hostname). The correction is shown, never auto-run (clig.dev).
71
- const bareUrl = bareUrlInput(opts.id);
90
+ const bareUrl = bareUrlInput(id);
72
91
  if (bareUrl) {
73
- this.error([
74
- `${bareUrl} is not a ${opts.config.bin} command.`,
75
- `Run ${opts.config.bin} help for a list of available commands.`,
76
- `Did you mean ${opts.config.bin} https://${bareUrl}? URLs need an http:// or https:// scheme.`,
77
- ].join('\n'), { exit: 2 });
92
+ return {
93
+ code: 'MISSING_URL_SCHEME',
94
+ command: bareUrl,
95
+ jsonMode: isJsonMode(argv),
96
+ message: [
97
+ `${bareUrl} is not a ${config.bin} command.`,
98
+ `Run ${config.bin} help for a list of available commands.`,
99
+ `Did you mean ${config.bin} https://${bareUrl}? URLs need an http:// or https:// scheme.`,
100
+ ].join('\n'),
101
+ };
102
+ }
103
+ const segments = id.split(':');
104
+ const unexpected = exactZeroArgCommandPrefix(segments, visibleIds, config);
105
+ if (unexpected) {
106
+ const command = toConfiguredId(unexpected.commandId, config);
107
+ return {
108
+ code: 'UNEXPECTED_ARGUMENT',
109
+ command,
110
+ jsonMode: isJsonMode(argv),
111
+ message: [
112
+ `Unexpected argument: ${unexpected.extra}`,
113
+ `Run ${config.bin} ${command} --help for usage.`,
114
+ ].join('\n'),
115
+ };
78
116
  }
79
- const segments = opts.id.split(':');
80
117
  const { suggestion, commandSegments } = findSuggestion(segments, visibleIds);
81
118
  // Show only the segments that name the command, never the positional args oclif folded into the id
82
119
  // (which would otherwise glue the arg on and turn `://` into ` //`).
83
- const displaySegments = commandSegments || topicChainLength(segments, opts.config);
84
- const attempted = toConfiguredId(segments.slice(0, displaySegments).join(':'), opts.config);
120
+ const displaySegments = commandSegments || topicChainLength(segments, config);
121
+ const attempted = toConfiguredId(segments.slice(0, displaySegments).join(':'), config);
85
122
  // Order so the most actionable line lands last, where the eye rests (clig.dev): the help pointer
86
123
  // is the floor, and a concrete "Did you mean …?" correction — when we have one — goes below it.
87
- const lines = [`${attempted} is not a ${opts.config.bin} command.`];
88
- lines.push(`Run ${opts.config.bin} help for a list of available commands.`);
124
+ const lines = [`${attempted} is not a ${config.bin} command.`];
125
+ lines.push(`Run ${config.bin} help for a list of available commands.`);
89
126
  if (suggestion)
90
- lines.push(`Did you mean ${toConfiguredId(suggestion, opts.config)}?`);
91
- this.error(lines.join('\n'), { exit: 2 });
127
+ lines.push(`Did you mean ${toConfiguredId(suggestion, config)}?`);
128
+ return {
129
+ code: 'COMMAND_NOT_FOUND',
130
+ command: attempted,
131
+ jsonMode: isJsonMode(argv),
132
+ message: lines.join('\n'),
133
+ };
134
+ }
135
+ /**
136
+ * Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
137
+ * throwing here (via `this.error`) replaces that default with a more helpful message. A correction
138
+ * is offered only when the nearest command is a close match, and it is never auto-run — the user
139
+ * re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
140
+ */
141
+ const hook = async function (opts) {
142
+ const details = buildCommandNotFoundDetails(opts.id, opts.argv, opts.config);
143
+ if (details.jsonMode)
144
+ return emitJsonError(details.command, details.message, details.code);
145
+ this.error(details.message, { exit: 2, code: details.code });
92
146
  };
93
147
  export default hook;
@@ -0,0 +1,10 @@
1
+ export interface NormalizationResult {
2
+ /** The normalized argv array to set on process.argv. */
3
+ argv: string[];
4
+ /** Optional metadata indicating the shim should exit early with a JSON envelope. */
5
+ exitWithJson?: {
6
+ exitCode: number;
7
+ envelope: Record<string, unknown>;
8
+ };
9
+ }
10
+ export declare function normalizeArgv(rawArgv: string[]): NormalizationResult;
@@ -0,0 +1,97 @@
1
+ import { buildEnvelope } from './envelope.js';
2
+ /**
3
+ * Normalizes process.argv (excluding Node executable and script path) so the
4
+ * oclif pipeline sees one consistent command structure.
5
+ */
6
+ function missingUsageJsonExit() {
7
+ return {
8
+ exitCode: 2,
9
+ envelope: buildEnvelope({
10
+ command: 'bonsai',
11
+ ok: false,
12
+ exitCode: 2,
13
+ stderr: 'Missing URL or command. Run bonsai --help for usage.',
14
+ data: null,
15
+ }),
16
+ };
17
+ }
18
+ const FLAGS_WITH_VALUES = new Set([
19
+ '--topic',
20
+ '-t',
21
+ '--tags',
22
+ '-g',
23
+ '--format',
24
+ '--tier',
25
+ '--ttl',
26
+ '--max-age',
27
+ '--storage',
28
+ '--file',
29
+ '-f',
30
+ '--input-format',
31
+ '--source-url',
32
+ '--freshness',
33
+ '--artifact-type',
34
+ '--capture-method',
35
+ '--older-than',
36
+ '--inactive',
37
+ '--domain',
38
+ '--remote',
39
+ '--limit',
40
+ ]);
41
+ export function normalizeArgv(rawArgv) {
42
+ const onlyJsonFlags = rawArgv.length > 0 && rawArgv.every((arg) => arg === '--json');
43
+ if (onlyJsonFlags) {
44
+ return {
45
+ // bin/cli.mjs exits before applying argv when exitWithJson is set.
46
+ argv: ['--json'],
47
+ exitWithJson: missingUsageJsonExit(),
48
+ };
49
+ }
50
+ // oclif's JSON flag is command-scoped, so `bonsai list --json` works but
51
+ // `bonsai --json list` is otherwise parsed as an unknown command named
52
+ // "--json". Collect every --json, append one copy after the command/URL, and
53
+ // dedupe repeats like `bonsai --json --json list`.
54
+ const jsonMode = rawArgv.includes('--json');
55
+ let tokens = rawArgv.filter((arg) => arg !== '--json');
56
+ // clig.dev: -h and --help must always work at any level.
57
+ tokens = tokens.map((arg) => (arg === '-h' ? '--help' : arg));
58
+ if (tokens[0] === 'help') {
59
+ tokens = [...tokens.slice(1), '--help'];
60
+ }
61
+ const helpRequested = tokens.includes('--help');
62
+ let core = tokens.filter((arg) => arg !== '--help');
63
+ // Treat URL-shaped tokens as the `fetch` shorthand. Match both `https://...` and scheme-only
64
+ // forms like `javascript:` or `data:` so fetch can reject unsupported protocols instead of oclif
65
+ // reporting a misleading "command not found". If the invocation begins with a flag, allow common
66
+ // flag-before-argument usage such as `bonsai --format detailed https://example.com`.
67
+ let firstNonFlagArgIndex = -1;
68
+ for (let i = 0; i < core.length; i++) {
69
+ const token = core[i];
70
+ if (token.startsWith('-')) {
71
+ if (FLAGS_WITH_VALUES.has(token)) {
72
+ i++; // skip the value
73
+ }
74
+ continue;
75
+ }
76
+ firstNonFlagArgIndex = i;
77
+ break;
78
+ }
79
+ const rootFetchShape = firstNonFlagArgIndex !== -1 && looksLikeUrl(core[firstNonFlagArgIndex]);
80
+ if (rootFetchShape) {
81
+ const url = core[firstNonFlagArgIndex];
82
+ core = [
83
+ 'fetch',
84
+ url,
85
+ ...core.slice(0, firstNonFlagArgIndex),
86
+ ...core.slice(firstNonFlagArgIndex + 1),
87
+ ];
88
+ }
89
+ if (helpRequested)
90
+ core.push('--help');
91
+ if (jsonMode)
92
+ core.push('--json');
93
+ return { argv: core };
94
+ }
95
+ function looksLikeUrl(arg) {
96
+ return arg.includes('://') || /^[a-z][a-z0-9+.-]*:/i.test(arg);
97
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Whether ANSI color should be emitted, per the clig.dev / no-color.org rules:
3
+ * - `FORCE_COLOR` (set to any value other than an explicit off) forces color on, ignoring detection.
4
+ * - `NO_COLOR` set to a non-empty value disables color, regardless of that value. An empty
5
+ * `NO_COLOR` is treated as unset (the spec keys off "present and not empty").
6
+ * - `TERM=dumb` cannot render ANSI, so color is disabled.
7
+ * - Otherwise color follows whether stdout is an interactive terminal.
8
+ * See https://clig.dev/#output and https://no-color.org/.
9
+ */
10
+ export declare function isColorEnabled(): boolean;
11
+ export declare function color(text: string, colorCode: string): string;
12
+ export declare const colors: {
13
+ bold: (t: string) => string;
14
+ green: (t: string) => string;
15
+ yellow: (t: string) => string;
16
+ red: (t: string) => string;
17
+ cyan: (t: string) => string;
18
+ gray: (t: string) => string;
19
+ magenta: (t: string) => string;
20
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Whether ANSI color should be emitted, per the clig.dev / no-color.org rules:
3
+ * - `FORCE_COLOR` (set to any value other than an explicit off) forces color on, ignoring detection.
4
+ * - `NO_COLOR` set to a non-empty value disables color, regardless of that value. An empty
5
+ * `NO_COLOR` is treated as unset (the spec keys off "present and not empty").
6
+ * - `TERM=dumb` cannot render ANSI, so color is disabled.
7
+ * - Otherwise color follows whether stdout is an interactive terminal.
8
+ * See https://clig.dev/#output and https://no-color.org/.
9
+ */
10
+ export function isColorEnabled() {
11
+ if (typeof process === 'undefined' || !process.env)
12
+ return false;
13
+ const env = process.env;
14
+ const force = env.FORCE_COLOR;
15
+ if (force !== undefined && force !== '' && force !== '0' && force !== 'false')
16
+ return true;
17
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '')
18
+ return false;
19
+ if (env.TERM === 'dumb')
20
+ return false;
21
+ return Boolean(process.stdout && process.stdout.isTTY);
22
+ }
23
+ export function color(text, colorCode) {
24
+ if (!isColorEnabled())
25
+ return text;
26
+ return `${colorCode}${text}\x1b[0m`;
27
+ }
28
+ export const colors = {
29
+ bold: (t) => color(t, '\x1b[1m'),
30
+ green: (t) => color(t, '\x1b[32m'),
31
+ yellow: (t) => color(t, '\x1b[33m'),
32
+ red: (t) => color(t, '\x1b[31m'),
33
+ cyan: (t) => color(t, '\x1b[36m'),
34
+ gray: (t) => color(t, '\x1b[90m'),
35
+ magenta: (t) => color(t, '\x1b[35m'),
36
+ };
@@ -0,0 +1,36 @@
1
+ /** Fields oclif attaches to CLIError that human pretty-print surfaces but JSON mode must mirror. */
2
+ export interface CliErrorShape {
3
+ message?: string;
4
+ code?: string;
5
+ suggestions?: string[];
6
+ ref?: string;
7
+ }
8
+ /** Map oclif parse-time errors to stable Bonsai codes when the throw site did not set one. */
9
+ export declare function stableErrorCodeFrom(err: unknown): string | undefined;
10
+ /** Strip oclif's generic help suffix from JSON stderr — agents already have structured codes. */
11
+ export declare function normalizeCliErrorMessage(message: string): string;
12
+ export interface EnvelopeParts {
13
+ command: string;
14
+ ok: boolean;
15
+ exitCode: number;
16
+ stderr: string;
17
+ data: unknown;
18
+ /** Stable machine-readable error code when the failure is a CLIError with `code`. */
19
+ code?: string;
20
+ suggestions?: string[];
21
+ ref?: string;
22
+ }
23
+ /**
24
+ * Formats a CLI error for the JSON envelope `stderr` field — same lines as human pretty-print,
25
+ * without ANSI or the leading "Error:" prefix (the message is already the primary line).
26
+ */
27
+ export declare function formatErrorForJson(err: CliErrorShape): string;
28
+ /**
29
+ * Builds the standard Bonsai CLI JSON envelope structure.
30
+ * This is the single source of truth for the output envelope schema.
31
+ */
32
+ export declare function buildEnvelope(parts: EnvelopeParts): Record<string, unknown>;
33
+ /**
34
+ * Formats a JSON envelope to a string with indentation.
35
+ */
36
+ export declare function formatEnvelope(parts: EnvelopeParts): string;
@@ -0,0 +1,82 @@
1
+ /** Map oclif parse-time errors to stable Bonsai codes when the throw site did not set one. */
2
+ export function stableErrorCodeFrom(err) {
3
+ const e = err;
4
+ if (typeof e?.code === 'string' && e.code)
5
+ return e.code;
6
+ switch (e?.constructor?.name) {
7
+ case 'RequiredArgsError':
8
+ return 'MISSING_ARGUMENT';
9
+ case 'FlagInvalidOptionError':
10
+ case 'ArgInvalidOptionError':
11
+ return 'INVALID_FLAG_VALUE';
12
+ case 'NonExistentFlagsError':
13
+ return 'UNKNOWN_FLAG';
14
+ case 'UnexpectedArgsError':
15
+ return 'UNEXPECTED_ARGUMENT';
16
+ }
17
+ // A flag supplied without its value throws a plain CLIError with no dedicated class to switch on,
18
+ // so it would otherwise reach agents with no stable code. oclif phrases it two ways: "expects a
19
+ // value" for a free-form flag, "expects one of these values: …" for an options-constrained flag.
20
+ if (typeof e?.message === 'string' &&
21
+ /^Flag --\S+ expects (a value$|one of these values:)/.test(e.message)) {
22
+ return 'MISSING_FLAG_VALUE';
23
+ }
24
+ return undefined;
25
+ }
26
+ /** Strip oclif's generic help suffix from JSON stderr — agents already have structured codes. */
27
+ export function normalizeCliErrorMessage(message) {
28
+ const suffix = '\nSee more help with --help';
29
+ return message.endsWith(suffix) ? message.slice(0, -suffix.length) : message;
30
+ }
31
+ /**
32
+ * Formats a CLI error for the JSON envelope `stderr` field — same lines as human pretty-print,
33
+ * without ANSI or the leading "Error:" prefix (the message is already the primary line).
34
+ */
35
+ export function formatErrorForJson(err) {
36
+ const lines = [];
37
+ if (err.message)
38
+ lines.push(err.message);
39
+ if (err.code)
40
+ lines.push(`Code: ${err.code}`);
41
+ if (err.suggestions?.length) {
42
+ if (err.suggestions.length === 1) {
43
+ lines.push(`Try this: ${err.suggestions[0]}`);
44
+ }
45
+ else {
46
+ lines.push('Try this:');
47
+ for (const suggestion of err.suggestions)
48
+ lines.push(`* ${suggestion}`);
49
+ }
50
+ }
51
+ if (err.ref)
52
+ lines.push(`Reference: ${err.ref}`);
53
+ return lines.join('\n');
54
+ }
55
+ /**
56
+ * Builds the standard Bonsai CLI JSON envelope structure.
57
+ * This is the single source of truth for the output envelope schema.
58
+ */
59
+ export function buildEnvelope(parts) {
60
+ const envelope = {
61
+ schemaVersion: 1,
62
+ command: parts.command,
63
+ ok: parts.ok,
64
+ exitCode: parts.exitCode,
65
+ stdout: '',
66
+ stderr: parts.stderr,
67
+ data: parts.data,
68
+ };
69
+ if (parts.code)
70
+ envelope.code = parts.code;
71
+ if (parts.suggestions?.length)
72
+ envelope.suggestions = parts.suggestions;
73
+ if (parts.ref)
74
+ envelope.ref = parts.ref;
75
+ return envelope;
76
+ }
77
+ /**
78
+ * Formats a JSON envelope to a string with indentation.
79
+ */
80
+ export function formatEnvelope(parts) {
81
+ return JSON.stringify(buildEnvelope(parts), null, 2);
82
+ }
@@ -0,0 +1,15 @@
1
+ type UnknownHelpResult = {
2
+ exitCode: 2;
3
+ kind: 'human';
4
+ message: string;
5
+ } | {
6
+ envelope: Record<string, unknown>;
7
+ exitCode: 2;
8
+ kind: 'json';
9
+ };
10
+ /**
11
+ * oclif resolves help before its command_not_found hook, so `bonsai config gett --help` otherwise
12
+ * falls back to a terse framework error. Catch that narrow case and reuse the normal typo guidance.
13
+ */
14
+ export declare function tryUnknownHelpOutput(argv: readonly string[], root: string): Promise<UnknownHelpResult | null>;
15
+ export {};
@@ -0,0 +1,81 @@
1
+ import { Config } from '@oclif/core';
2
+ import { buildEnvelope } from './envelope.js';
3
+ import { buildCommandNotFoundDetails } from '../hooks/command-not-found/suggest.js';
4
+ function commandId(parts) {
5
+ return parts.join(':');
6
+ }
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
+ function commandPrefixLength(tokens, config) {
21
+ let longest = 0;
22
+ for (let n = 1; n <= tokens.length; n++) {
23
+ if (config.commandIDs.includes(commandId(tokens.slice(0, n))))
24
+ longest = n;
25
+ }
26
+ return longest;
27
+ }
28
+ function branchPrefixLength(tokens, config) {
29
+ let longest = 0;
30
+ for (let n = 1; n <= tokens.length; n++) {
31
+ const id = commandId(tokens.slice(0, n));
32
+ if (config.commandIDs.some((command) => command.startsWith(`${id}:`)))
33
+ longest = n;
34
+ }
35
+ return longest;
36
+ }
37
+ function hasValidHelpTarget(tokens, config) {
38
+ const commandLength = commandPrefixLength(tokens, config);
39
+ const branchLength = branchPrefixLength(tokens, config);
40
+ if (commandLength === tokens.length || branchLength === tokens.length)
41
+ return true;
42
+ if (branchLength > 0 && tokens.length > branchLength) {
43
+ return commandLength > branchLength;
44
+ }
45
+ return commandLength > 0;
46
+ }
47
+ /**
48
+ * oclif resolves help before its command_not_found hook, so `bonsai config gett --help` otherwise
49
+ * falls back to a terse framework error. Catch that narrow case and reuse the normal typo guidance.
50
+ */
51
+ export async function tryUnknownHelpOutput(argv, root) {
52
+ if (!argv.includes('--help'))
53
+ return null;
54
+ const tokens = positionalTokens(argv);
55
+ if (tokens.length === 0)
56
+ return null;
57
+ const config = await Config.load({ root });
58
+ if (hasValidHelpTarget(tokens, config))
59
+ return null;
60
+ const attemptedId = commandId(tokens);
61
+ 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
+ return {
77
+ kind: 'human',
78
+ exitCode: 2,
79
+ message: `${details.message}\nCode: ${details.code}`,
80
+ };
81
+ }
@@ -0,0 +1,10 @@
1
+ /** True when argv asks for help or version together with --json. */
2
+ export declare function isJsonMetaRequest(argv: readonly string[]): boolean;
3
+ /**
4
+ * When `--json` is combined with `--help` or `--version`, oclif would print human text to stdout.
5
+ * Render the same content inside the standard Bonsai JSON envelope instead.
6
+ */
7
+ export declare function tryJsonMetaOutput(argv: readonly string[], root: string): Promise<{
8
+ exitCode: number;
9
+ envelope: Record<string, unknown>;
10
+ } | null>;