@taurgis/bonsai 1.0.6 → 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 (120) hide show
  1. package/README.md +7 -13
  2. package/agents/agents/official-docs-researcher.agent.md +15 -35
  3. package/agents/agents/salesforce-docs-researcher.agent.md +17 -44
  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 -8
  11. package/agents/instructions/web-research.instructions.md +3 -4
  12. package/agents/skills/web-research/SKILL.md +24 -37
  13. package/bin/cli.mjs +15 -2
  14. package/dist/base-command.d.ts +15 -2
  15. package/dist/base-command.js +32 -4
  16. package/dist/commands/fetch.d.ts +2 -0
  17. package/dist/commands/fetch.js +43 -29
  18. package/dist/commands/import.js +11 -3
  19. package/dist/commands/inspect.d.ts +4 -0
  20. package/dist/commands/inspect.js +48 -24
  21. package/dist/commands/list.d.ts +3 -1
  22. package/dist/commands/list.js +42 -25
  23. package/dist/commands/prune.d.ts +1 -0
  24. package/dist/commands/prune.js +35 -9
  25. package/dist/commands/status.d.ts +2 -0
  26. package/dist/commands/status.js +57 -25
  27. package/dist/commands.d.ts +0 -2
  28. package/dist/commands.js +0 -2
  29. package/dist/hooks/command-not-found/suggest.d.ts +7 -1
  30. package/dist/hooks/command-not-found/suggest.js +82 -57
  31. package/dist/lib/argv.js +51 -7
  32. package/dist/lib/color.d.ts +20 -0
  33. package/dist/lib/color.js +36 -0
  34. package/dist/lib/envelope.js +8 -2
  35. package/dist/lib/help-preflight.d.ts +15 -0
  36. package/dist/lib/help-preflight.js +81 -0
  37. package/dist/lib/limit-flag.d.ts +1 -0
  38. package/dist/lib/limit-flag.js +24 -0
  39. package/dist/lib/research/{search-index.d.ts → artifact-index.d.ts} +5 -5
  40. package/dist/lib/research/{search-index.js → artifact-index.js} +10 -10
  41. package/dist/lib/research/browser.d.ts +0 -8
  42. package/dist/lib/research/browser.js +20 -43
  43. package/dist/lib/research/regression.d.ts +18 -0
  44. package/dist/lib/research/regression.js +47 -0
  45. package/dist/lib/research/storage.d.ts +0 -1
  46. package/dist/lib/research/storage.js +15 -13
  47. package/dist/lib/research/url.d.ts +22 -0
  48. package/dist/lib/research/url.js +63 -1
  49. package/dist/lib/text.d.ts +3 -1
  50. package/dist/lib/text.js +9 -1
  51. package/dist/sites/coveo.d.ts +7 -0
  52. package/dist/sites/coveo.js +33 -0
  53. package/dist/sites/index.js +1 -18
  54. package/dist/sites/salesforce/fetch-page.js +1 -1
  55. package/dist/sites/salesforce/index.js +3 -8
  56. package/dist/sites/salesforce-developer/fetch-page.js +3 -1
  57. package/dist/sites/salesforce-developer/index.js +1 -2
  58. package/dist/sites/salesforce-doc-fetch.d.ts +11 -0
  59. package/dist/sites/salesforce-doc-fetch.js +117 -76
  60. package/dist/sites/salesforce-dom-probe.d.ts +16 -0
  61. package/dist/sites/salesforce-dom-probe.js +114 -0
  62. package/dist/sites/types.d.ts +0 -6
  63. package/oclif.manifest.json +31 -106
  64. package/package.json +32 -25
  65. package/dist/commands/index.d.ts +0 -35
  66. package/dist/commands/index.js +0 -306
  67. package/dist/commands/research/config/base.d.ts +0 -23
  68. package/dist/commands/research/config/base.js +0 -46
  69. package/dist/commands/research/config/get.d.ts +0 -20
  70. package/dist/commands/research/config/get.js +0 -45
  71. package/dist/commands/research/config/list.d.ts +0 -17
  72. package/dist/commands/research/config/list.js +0 -42
  73. package/dist/commands/research/config/set.d.ts +0 -31
  74. package/dist/commands/research/config/set.js +0 -94
  75. package/dist/commands/research/config/unset.d.ts +0 -21
  76. package/dist/commands/research/config/unset.js +0 -59
  77. package/dist/commands/research/import.d.ts +0 -38
  78. package/dist/commands/research/import.js +0 -313
  79. package/dist/commands/research/inspect.d.ts +0 -13
  80. package/dist/commands/research/inspect.js +0 -86
  81. package/dist/commands/research/list.d.ts +0 -25
  82. package/dist/commands/research/list.js +0 -143
  83. package/dist/commands/research/prune.d.ts +0 -24
  84. package/dist/commands/research/prune.js +0 -141
  85. package/dist/commands/research/search.d.ts +0 -32
  86. package/dist/commands/research/search.js +0 -334
  87. package/dist/commands/research/status.d.ts +0 -17
  88. package/dist/commands/research/status.js +0 -108
  89. package/dist/commands/research.d.ts +0 -36
  90. package/dist/commands/research.js +0 -304
  91. package/dist/commands/search.d.ts +0 -35
  92. package/dist/commands/search.js +0 -391
  93. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +0 -1
  94. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +0 -2
  95. package/dist/lib/research/docs/discovery-index.d.ts +0 -14
  96. package/dist/lib/research/docs/discovery-index.js +0 -112
  97. package/dist/lib/research/docs/remote-search-runner.d.ts +0 -21
  98. package/dist/lib/research/docs/remote-search-runner.js +0 -114
  99. package/dist/lib/research/docs/remote-search.d.ts +0 -14
  100. package/dist/lib/research/docs/remote-search.js +0 -101
  101. package/dist/lib/research/docs/search-index.d.ts +0 -13
  102. package/dist/lib/research/docs/search-index.js +0 -176
  103. package/dist/sites/algolia.d.ts +0 -9
  104. package/dist/sites/algolia.js +0 -26
  105. package/dist/sites/auto.d.ts +0 -8
  106. package/dist/sites/auto.js +0 -30
  107. package/dist/sites/nextjs/index.d.ts +0 -2
  108. package/dist/sites/nextjs/index.js +0 -52
  109. package/dist/sites/react/index.d.ts +0 -2
  110. package/dist/sites/react/index.js +0 -17
  111. package/dist/sites/salesforce/browser-search.d.ts +0 -7
  112. package/dist/sites/salesforce/browser-search.js +0 -82
  113. package/dist/sites/salesforce/coveo.d.ts +0 -23
  114. package/dist/sites/salesforce/coveo.js +0 -161
  115. package/dist/sites/salesforce/token.d.ts +0 -26
  116. package/dist/sites/salesforce/token.js +0 -154
  117. package/dist/sites/tailwind/index.d.ts +0 -2
  118. package/dist/sites/tailwind/index.js +0 -17
  119. package/dist/sites/vue/index.d.ts +0 -2
  120. package/dist/sites/vue/index.js +0 -17
@@ -7,6 +7,7 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
7
7
  description: string;
8
8
  command: string;
9
9
  }[];
10
+ static strict: boolean;
10
11
  static args: {
11
12
  url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
12
13
  };
@@ -19,4 +20,5 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
19
20
  /** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
20
21
  protected toSuccessJson(data: unknown): Record<string, unknown>;
21
22
  run(): Promise<unknown>;
23
+ private checkSingleStatus;
22
24
  }
@@ -3,6 +3,7 @@ import { BaseCommand } from '../base-command.js';
3
3
  import { formatErrorForJson } from '../lib/envelope.js';
4
4
  import { getArtifactPath } from '../lib/research/storage.js';
5
5
  import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
6
+ import { colors } from '../lib/color.js';
6
7
  function resolveFreshness(cached, currentTime, ttl, maxAge) {
7
8
  const isMaxAgeExpired = maxAge ? checkMaxAgeExpired(cached, currentTime, maxAge) : false;
8
9
  return isMaxAgeExpired
@@ -32,6 +33,7 @@ export default class ResearchStatus extends BaseCommand {
32
33
  command: '<%= config.bin %> <%= command.id %> https://example.com/docs --tier volatile --ttl 2h',
33
34
  },
34
35
  ];
36
+ static strict = false;
35
37
  static args = {
36
38
  url: Args.string({
37
39
  required: true,
@@ -56,20 +58,23 @@ export default class ResearchStatus extends BaseCommand {
56
58
  /** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
57
59
  toSuccessJson(data) {
58
60
  const envelope = super.toSuccessJson(data);
59
- const d = data;
60
- if (d?.status !== 'miss')
61
+ const list = Array.isArray(data) ? data : [data];
62
+ const misses = list.filter((d) => d && d.status === 'miss');
63
+ if (misses.length === 0)
61
64
  return envelope;
62
- const normalizedUrl = d.normalizedUrl ?? '';
63
- const suggestions = [`Fetch and cache it first: ${this.config.bin} ${normalizedUrl}`];
65
+ const firstMiss = misses[0];
66
+ const normalizedUrl = firstMiss.normalizedUrl ?? '';
67
+ const suggestions = misses.map((m) => `Fetch and cache it first: ${this.config.bin} ${m.normalizedUrl}`);
64
68
  const stderr = formatErrorForJson({
65
- message: `Cache miss for ${normalizedUrl}`,
69
+ message: `Cache miss for ${normalizedUrl}` +
70
+ (misses.length > 1 ? ` and ${misses.length - 1} other URLs` : ''),
66
71
  code: 'CACHE_MISS',
67
72
  suggestions,
68
73
  });
69
74
  return { ...envelope, ok: false, exitCode: 1, stderr, code: 'CACHE_MISS', suggestions };
70
75
  }
71
76
  async run() {
72
- const { url } = this.args;
77
+ const urls = this.parsedArgv;
73
78
  const { ttl, tier, 'max-age': maxAge } = this.flags;
74
79
  // Validate the duration flags up front so a malformed value reports the exact flag that is
75
80
  // wrong, rather than later surfacing as a misattributed parse failure.
@@ -77,32 +82,59 @@ export default class ResearchStatus extends BaseCommand {
77
82
  if (msg)
78
83
  this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
79
84
  }
85
+ const results = [];
86
+ let hasMiss = false;
87
+ const currentTime = new Date();
88
+ for (const url of urls) {
89
+ const res = this.checkSingleStatus(url, currentTime, ttl, maxAge, urls.length > 1);
90
+ if (res.status === 'miss') {
91
+ hasMiss = true;
92
+ }
93
+ results.push(res);
94
+ }
95
+ if (hasMiss) {
96
+ process.exitCode = 1;
97
+ }
98
+ return urls.length === 1 ? results[0] : results;
99
+ }
100
+ checkSingleStatus(url, currentTime, ttl, maxAge, showSeparator) {
80
101
  const target = this.resolveResearchTargetOrFail(url);
81
102
  const { cacheKey, located, normalizedUrl, roots } = target;
82
103
  const cached = located?.artifact ?? null;
83
- const currentTime = new Date();
84
- // Both duration flags were validated above, so freshness resolution cannot throw on a parse error.
85
104
  const result = describeCacheStatus(cached, currentTime, ttl, maxAge);
86
- // On a hit, report where it actually lives; on a miss, where a fetch would write it.
87
105
  const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
88
106
  if (!this.jsonEnabled()) {
89
- this.log(`${'URL:'.padEnd(25)} ${normalizedUrl}`);
90
- this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
91
- this.log(`${'Cache Path:'.padEnd(25)} ${artifactPath}`);
92
- this.log(`${'Status:'.padEnd(25)} ${result.status}`);
93
- this.log(`${'Freshness:'.padEnd(25)} ${result.freshness}`);
94
- this.log(`${'Action:'.padEnd(25)} ${result.action}`);
95
- }
96
- // Align with inspect's CACHE_MISS contract: a miss is actionable, not a usage error. Exit 1
97
- // lets scripts branch on $? while JSON still returns the structured status payload. Unlike
98
- // inspect, we do not throw here — status must always return structured data even on a miss.
99
- if (result.status === 'miss') {
100
- // Human mode: actionable hint on stderr. JSON mode: same info lives in the envelope (see
101
- // toSuccessJson) so agents get code/suggestions without parsing warn() line wraps.
102
- if (!this.jsonEnabled()) {
103
- this.warn(`Cache miss — run: ${this.config.bin} ${normalizedUrl}`);
107
+ const statusColorMap = {
108
+ hit: colors.green,
109
+ stale: colors.yellow,
110
+ miss: colors.red,
111
+ };
112
+ const freshnessColorMap = {
113
+ fresh: colors.green,
114
+ stale_grace: colors.yellow,
115
+ stale_expired: colors.red,
116
+ none: colors.gray,
117
+ };
118
+ const actionColorMap = {
119
+ would_return_cached: colors.green,
120
+ would_revalidate: colors.yellow,
121
+ would_fetch: colors.red,
122
+ };
123
+ const statusColor = statusColorMap[result.status] || ((t) => t);
124
+ const freshnessColor = freshnessColorMap[result.freshness] || ((t) => t);
125
+ const actionColor = actionColorMap[result.action] || ((t) => t);
126
+ this.log(`${colors.cyan('URL:'.padEnd(25))} ${colors.bold(normalizedUrl)}`);
127
+ this.log(`${colors.cyan('Cache Key:'.padEnd(25))} ${colors.bold(cacheKey)}`);
128
+ this.log(`${colors.cyan('Cache Path:'.padEnd(25))} ${colors.gray(artifactPath)}`);
129
+ this.log(`${colors.cyan('Status:'.padEnd(25))} ${statusColor(result.status)}`);
130
+ this.log(`${colors.cyan('Freshness:'.padEnd(25))} ${freshnessColor(result.freshness)}`);
131
+ this.log(`${colors.cyan('Action:'.padEnd(25))} ${actionColor(result.action)}`);
132
+ if (showSeparator) {
133
+ this.log('-'.repeat(40));
104
134
  }
105
- process.exitCode = 1;
135
+ }
136
+ if (result.status === 'miss' && !this.jsonEnabled()) {
137
+ this.warn(`Cache miss — run: ${this.config.bin} ${normalizedUrl}`);
106
138
  }
107
139
  return {
108
140
  cacheKey,
@@ -8,7 +8,6 @@ import ImportCommand from './commands/import.js';
8
8
  import InspectCommand from './commands/inspect.js';
9
9
  import ListCommand from './commands/list.js';
10
10
  import PruneCommand from './commands/prune.js';
11
- import SearchCommand from './commands/search.js';
12
11
  import StatusCommand from './commands/status.js';
13
12
  export declare const commands: {
14
13
  fetch: typeof FetchCommand;
@@ -16,7 +15,6 @@ export declare const commands: {
16
15
  inspect: typeof InspectCommand;
17
16
  list: typeof ListCommand;
18
17
  prune: typeof PruneCommand;
19
- search: typeof SearchCommand;
20
18
  status: typeof StatusCommand;
21
19
  config: typeof ConfigIndex;
22
20
  'config:get': typeof ConfigGet;
package/dist/commands.js CHANGED
@@ -8,7 +8,6 @@ import ImportCommand from './commands/import.js';
8
8
  import InspectCommand from './commands/inspect.js';
9
9
  import ListCommand from './commands/list.js';
10
10
  import PruneCommand from './commands/prune.js';
11
- import SearchCommand from './commands/search.js';
12
11
  import StatusCommand from './commands/status.js';
13
12
  export const commands = {
14
13
  fetch: FetchCommand,
@@ -16,7 +15,6 @@ export const commands = {
16
15
  inspect: InspectCommand,
17
16
  list: ListCommand,
18
17
  prune: PruneCommand,
19
- search: SearchCommand,
20
18
  status: StatusCommand,
21
19
  config: ConfigIndex,
22
20
  'config:get': ConfigGet,
@@ -1,4 +1,10 @@
1
- import { type Hook } from '@oclif/core';
1
+ import { type Hook, type Interfaces } from '@oclif/core';
2
+ export declare function buildCommandNotFoundDetails(id: string, argv: string[] | undefined, config: Interfaces.Config): {
3
+ code: 'COMMAND_NOT_FOUND' | 'MISSING_URL_SCHEME' | 'UNEXPECTED_ARGUMENT';
4
+ command: string;
5
+ jsonMode: boolean;
6
+ message: string;
7
+ };
2
8
  /**
3
9
  * Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
4
10
  * throwing here (via `this.error`) replaces that default with a more helpful message. A correction
@@ -1,27 +1,24 @@
1
1
  import { toConfiguredId } from '@oclif/core';
2
- import { closestMatch } from '../../lib/text.js';
2
+ import { closestMatch, maxFuzzyDistance } from '../../lib/text.js';
3
3
  import { buildEnvelope, formatErrorForJson } from '../../lib/envelope.js';
4
- // Nearest command must be close enough to be useful. A fixed threshold of 3 is too generous for
5
- // very short inputs (`wat` should not suggest `list`), while still reasonable for longer topic ids.
6
- function maxSuggestionDistance(input) {
7
- if (input.length <= 3)
8
- return 1;
9
- if (input.length <= 5)
10
- return 2;
11
- return 3;
12
- }
4
+ import { looksLikeSchemelessUrl } from '../../lib/research/url.js';
13
5
  /**
14
6
  * The nearest visible command to a typo, plus how many leading segments of the attempted id name
15
7
  * the command. With `topicSeparator: ' '`, oclif folds every leading token of an unknown command
16
- * 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
17
9
  * string never finds a short command, so walk prefixes longest→shortest and suggest off the closest:
18
- * `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
19
11
  * length doubles as the command-segment count, so a folded-in positional arg is dropped from display.
20
12
  */
21
13
  function findSuggestion(segments, visibleIds) {
22
14
  for (let n = segments.length; n >= 1; n--) {
23
15
  const attempted = segments.slice(0, n).join(':');
24
- const suggestion = closestMatch(attempted, visibleIds, maxSuggestionDistance(attempted));
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
+ }
25
22
  if (suggestion)
26
23
  return { suggestion, commandSegments: n };
27
24
  }
@@ -38,30 +35,35 @@ function topicChainLength(segments, config) {
38
35
  i++;
39
36
  return i + 1;
40
37
  }
41
- // Dot-separated, non-empty labels: a real domain (`docs.nestjs.com`) or IPv4 (`192.168.1.1`). The
42
- // URL parser also accepts a leading-dot filename (`.env`) or a relative path (`./x`) as a "host",
43
- // whose suggestion would be a nonsense `https://.env`; requiring well-formed labels rejects those.
44
- // Anchored with no overlapping quantifiers, so it is ReDoS-safe on the (already length-bounded) host.
45
- 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
+ }
46
56
  /**
47
57
  * The input as-typed when it looks like a URL missing its scheme (`example.com`,
48
58
  * `docs.nestjs.com/guide`), else null. `bonsai <url>` is the headline command, but bin/cli.mjs only
49
59
  * routes args carrying a `://` scheme to the fetch shorthand — so a scheme-less URL falls through to
50
60
  * `command_not_found`. A domain-shaped host separates a forgotten-scheme URL from an ordinary command
51
61
  * typo: `statuss` has no dot and never matches, while `config:frobnicate` fails to parse (the second
52
- * 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.
53
64
  */
54
65
  function bareUrlInput(id) {
55
- if (id.includes('://'))
56
- return null;
57
- let hostname;
58
- try {
59
- hostname = new URL(`https://${id}`).hostname;
60
- }
61
- catch {
62
- return null;
63
- }
64
- return DOMAIN_OR_IPV4.test(hostname) ? id : null;
66
+ return looksLikeSchemelessUrl(id) ? id : null;
65
67
  }
66
68
  function isJsonMode(argv) {
67
69
  return argv?.includes('--json') ?? false;
@@ -79,44 +81,67 @@ function emitJsonError(command, message, code) {
79
81
  console.log(JSON.stringify(envelope, null, 2));
80
82
  return envelope;
81
83
  }
82
- /**
83
- * Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
84
- * throwing here (via `this.error`) replaces that default with a more helpful message. A correction
85
- * is offered only when the nearest command is a close match, and it is never auto-run — the user
86
- * re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
87
- */
88
- const hook = async function (opts) {
89
- const hiddenIds = new Set(opts.config.commands.filter((c) => c.hidden).map((c) => c.id));
90
- const visibleIds = opts.config.commandIDs.filter((id) => !hiddenIds.has(id));
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));
91
87
  // A scheme-less URL is the most common "not a command" mistake for this CLI, so steer the user to
92
88
  // the `bonsai <url>` shorthand with a scheme before falling back to nearest-command matching (which
93
89
  // never finds a command for a hostname). The correction is shown, never auto-run (clig.dev).
94
- const bareUrl = bareUrlInput(opts.id);
90
+ const bareUrl = bareUrlInput(id);
95
91
  if (bareUrl) {
96
- const message = [
97
- `${bareUrl} is not a ${opts.config.bin} command.`,
98
- `Run ${opts.config.bin} help for a list of available commands.`,
99
- `Did you mean ${opts.config.bin} https://${bareUrl}? URLs need an http:// or https:// scheme.`,
100
- ].join('\n');
101
- if (isJsonMode(opts.argv))
102
- return emitJsonError(bareUrl, message, 'MISSING_URL_SCHEME');
103
- this.error(message, { 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
+ };
104
116
  }
105
- const segments = opts.id.split(':');
106
117
  const { suggestion, commandSegments } = findSuggestion(segments, visibleIds);
107
118
  // Show only the segments that name the command, never the positional args oclif folded into the id
108
119
  // (which would otherwise glue the arg on and turn `://` into ` //`).
109
- const displaySegments = commandSegments || topicChainLength(segments, opts.config);
110
- 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);
111
122
  // Order so the most actionable line lands last, where the eye rests (clig.dev): the help pointer
112
123
  // is the floor, and a concrete "Did you mean …?" correction — when we have one — goes below it.
113
- const lines = [`${attempted} is not a ${opts.config.bin} command.`];
114
- 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.`);
115
126
  if (suggestion)
116
- lines.push(`Did you mean ${toConfiguredId(suggestion, opts.config)}?`);
117
- const message = lines.join('\n');
118
- if (isJsonMode(opts.argv))
119
- return emitJsonError(attempted, message, 'COMMAND_NOT_FOUND');
120
- this.error(message, { 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 });
121
146
  };
122
147
  export default hook;
package/dist/lib/argv.js CHANGED
@@ -15,6 +15,29 @@ function missingUsageJsonExit() {
15
15
  }),
16
16
  };
17
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
+ ]);
18
41
  export function normalizeArgv(rawArgv) {
19
42
  const onlyJsonFlags = rawArgv.length > 0 && rawArgv.every((arg) => arg === '--json');
20
43
  if (onlyJsonFlags) {
@@ -37,13 +60,31 @@ export function normalizeArgv(rawArgv) {
37
60
  }
38
61
  const helpRequested = tokens.includes('--help');
39
62
  let core = tokens.filter((arg) => arg !== '--help');
40
- const firstArg = core[0];
41
- // Treat any first arg that looks like a URL (carries a scheme) as the `fetch` shorthand.
42
- // Match both `https://…` and scheme-only forms like `javascript:` or `data:` so fetch can
43
- // reject unsupported protocols instead of oclif reporting a misleading "command not found".
44
- const looksLikeUrl = firstArg != null && (firstArg.includes('://') || /^[a-z][a-z0-9+.-]*:/i.test(firstArg));
45
- if (looksLikeUrl) {
46
- core = ['fetch', ...core];
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
+ ];
47
88
  }
48
89
  if (helpRequested)
49
90
  core.push('--help');
@@ -51,3 +92,6 @@ export function normalizeArgv(rawArgv) {
51
92
  core.push('--json');
52
93
  return { argv: core };
53
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
+ };
@@ -13,9 +13,15 @@ export function stableErrorCodeFrom(err) {
13
13
  return 'UNKNOWN_FLAG';
14
14
  case 'UnexpectedArgsError':
15
15
  return 'UNEXPECTED_ARGUMENT';
16
- default:
17
- return undefined;
18
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;
19
25
  }
20
26
  /** Strip oclif's generic help suffix from JSON stderr — agents already have structured codes. */
21
27
  export function normalizeCliErrorMessage(message) {
@@ -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 @@
1
+ export declare function limitFlag(max: number, defaultValue: number, description: string): import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
@@ -0,0 +1,24 @@
1
+ import { Errors, Flags } from '@oclif/core';
2
+ export function limitFlag(max, defaultValue, description) {
3
+ return Flags.custom({
4
+ parse: async (input) => {
5
+ if (!/^\d+$/.test(input)) {
6
+ throw new Errors.CLIError(`Limit must be an integer between 1 and ${max}.`, {
7
+ exit: 2,
8
+ code: 'INVALID_LIMIT',
9
+ });
10
+ }
11
+ 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,
15
+ code: 'INVALID_LIMIT',
16
+ });
17
+ }
18
+ return value;
19
+ },
20
+ })({
21
+ default: defaultValue,
22
+ description,
23
+ });
24
+ }