@taurgis/bonsai 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +9 -6
  2. package/agents/agents/salesforce-docs-researcher.agent.md +17 -4
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +13 -18
  5. package/dist/base-command.d.ts +59 -12
  6. package/dist/base-command.js +179 -51
  7. package/dist/commands/config/base.js +12 -2
  8. package/dist/commands/config/get.js +8 -12
  9. package/dist/commands/config/index.js +2 -2
  10. package/dist/commands/config/list.js +11 -17
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +35 -28
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +26 -25
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +61 -401
  17. package/dist/commands/import.d.ts +4 -9
  18. package/dist/commands/import.js +56 -284
  19. package/dist/commands/inspect.d.ts +7 -1
  20. package/dist/commands/inspect.js +112 -52
  21. package/dist/commands/list.d.ts +18 -3
  22. package/dist/commands/list.js +121 -46
  23. package/dist/commands/prune.d.ts +6 -1
  24. package/dist/commands/prune.js +89 -78
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +62 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +2 -1
  28. package/dist/hooks/command-not-found/suggest.js +78 -41
  29. package/dist/lib/argv.d.ts +29 -3
  30. package/dist/lib/argv.js +147 -54
  31. package/dist/lib/batch.d.ts +41 -0
  32. package/dist/lib/batch.js +39 -0
  33. package/dist/lib/cache-view.d.ts +21 -0
  34. package/dist/lib/cache-view.js +30 -0
  35. package/dist/lib/cli-emit.d.ts +28 -0
  36. package/dist/lib/cli-emit.js +44 -0
  37. package/dist/lib/cli-error-policy.d.ts +57 -0
  38. package/dist/lib/cli-error-policy.js +74 -0
  39. package/dist/lib/cli-flag-manifest.d.ts +12 -0
  40. package/dist/lib/cli-flag-manifest.js +15 -0
  41. package/dist/lib/cli-presentation.d.ts +41 -0
  42. package/dist/lib/cli-presentation.js +53 -0
  43. package/dist/lib/cli-result-types.d.ts +112 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +23 -5
  46. package/dist/lib/config/index.js +60 -5
  47. package/dist/lib/config/io.d.ts +9 -0
  48. package/dist/lib/config/io.js +71 -21
  49. package/dist/lib/config/schema.d.ts +2 -0
  50. package/dist/lib/config/schema.js +7 -0
  51. package/dist/lib/config-persist.d.ts +28 -0
  52. package/dist/lib/config-persist.js +29 -0
  53. package/dist/lib/envelope.d.ts +41 -3
  54. package/dist/lib/envelope.js +154 -4
  55. package/dist/lib/help-preflight.d.ts +5 -8
  56. package/dist/lib/help-preflight.js +13 -32
  57. package/dist/lib/json-meta.d.ts +0 -2
  58. package/dist/lib/json-meta.js +21 -25
  59. package/dist/lib/limit-flag.d.ts +11 -1
  60. package/dist/lib/limit-flag.js +19 -6
  61. package/dist/lib/parse-error-ux.d.ts +5 -0
  62. package/dist/lib/parse-error-ux.js +73 -0
  63. package/dist/lib/prune-flags.d.ts +30 -0
  64. package/dist/lib/prune-flags.js +86 -0
  65. package/dist/lib/research/artifact-index.d.ts +11 -2
  66. package/dist/lib/research/artifact-index.js +14 -5
  67. package/dist/lib/research/artifact.js +20 -2
  68. package/dist/lib/research/browser.d.ts +53 -2
  69. package/dist/lib/research/browser.js +202 -58
  70. package/dist/lib/research/capture.d.ts +24 -2
  71. package/dist/lib/research/capture.js +24 -4
  72. package/dist/lib/research/cli-io.d.ts +40 -0
  73. package/dist/lib/research/cli-io.js +27 -0
  74. package/dist/lib/research/compress.d.ts +7 -0
  75. package/dist/lib/research/compress.js +7 -3
  76. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  77. package/dist/lib/research/docs/capabilities.js +5 -0
  78. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  79. package/dist/lib/research/docs/clean-dom.js +2 -0
  80. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  81. package/dist/lib/research/docs/code-blocks.js +2 -0
  82. package/dist/lib/research/docs/detect.d.ts +4 -0
  83. package/dist/lib/research/docs/detect.js +4 -0
  84. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  85. package/dist/lib/research/docs/machine-readable.js +10 -0
  86. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  87. package/dist/lib/research/docs/markdown-source.js +19 -2
  88. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  89. package/dist/lib/research/docs/quality-gates.js +4 -0
  90. package/dist/lib/research/docs/rsc.d.ts +9 -2
  91. package/dist/lib/research/docs/rsc.js +5 -2
  92. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  93. package/dist/lib/research/docs/section-artifacts.js +12 -0
  94. package/dist/lib/research/docs/sections.d.ts +18 -0
  95. package/dist/lib/research/docs/sections.js +17 -1
  96. package/dist/lib/research/docs/source-map.d.ts +28 -0
  97. package/dist/lib/research/docs/source-map.js +27 -0
  98. package/dist/lib/research/docs/validate.d.ts +35 -2
  99. package/dist/lib/research/docs/validate.js +47 -2
  100. package/dist/lib/research/extract.d.ts +16 -0
  101. package/dist/lib/research/extract.js +12 -1
  102. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  103. package/dist/lib/research/fetch-command-service.js +367 -0
  104. package/dist/lib/research/fetch-result.d.ts +114 -0
  105. package/dist/lib/research/fetch-result.js +212 -0
  106. package/dist/lib/research/fetcher.d.ts +46 -8
  107. package/dist/lib/research/fetcher.js +61 -31
  108. package/dist/lib/research/freshness.d.ts +54 -11
  109. package/dist/lib/research/freshness.js +91 -31
  110. package/dist/lib/research/import-command-service.d.ts +56 -0
  111. package/dist/lib/research/import-command-service.js +402 -0
  112. package/dist/lib/research/keywords.js +5 -1
  113. package/dist/lib/research/markdown.d.ts +12 -1
  114. package/dist/lib/research/markdown.js +13 -5
  115. package/dist/lib/research/metadata-filters.d.ts +18 -0
  116. package/dist/lib/research/metadata-filters.js +37 -0
  117. package/dist/lib/research/metadata-flags.d.ts +34 -0
  118. package/dist/lib/research/metadata-flags.js +45 -0
  119. package/dist/lib/research/persist-artifact.d.ts +23 -0
  120. package/dist/lib/research/persist-artifact.js +27 -0
  121. package/dist/lib/research/prompt-injection.d.ts +9 -0
  122. package/dist/lib/research/prompt-injection.js +59 -14
  123. package/dist/lib/research/proxy.d.ts +14 -8
  124. package/dist/lib/research/proxy.js +14 -10
  125. package/dist/lib/research/resolve-target.d.ts +2 -0
  126. package/dist/lib/research/resolve-target.js +1 -1
  127. package/dist/lib/research/revalidate.d.ts +49 -20
  128. package/dist/lib/research/revalidate.js +80 -15
  129. package/dist/lib/research/schema.d.ts +7 -0
  130. package/dist/lib/research/schema.js +2 -0
  131. package/dist/lib/research/secret-scan.d.ts +6 -0
  132. package/dist/lib/research/secret-scan.js +8 -0
  133. package/dist/lib/research/secure-write.d.ts +4 -0
  134. package/dist/lib/research/secure-write.js +31 -2
  135. package/dist/lib/research/storage.d.ts +9 -8
  136. package/dist/lib/research/storage.js +34 -28
  137. package/dist/lib/research/summarize.d.ts +15 -0
  138. package/dist/lib/research/summarize.js +11 -0
  139. package/dist/lib/research/url.d.ts +9 -6
  140. package/dist/lib/research/url.js +33 -21
  141. package/dist/lib/text.d.ts +19 -8
  142. package/dist/lib/text.js +32 -9
  143. package/dist/lib/value-flags.d.ts +14 -0
  144. package/dist/lib/value-flags.js +28 -0
  145. package/dist/sites/coveo.d.ts +10 -0
  146. package/dist/sites/coveo.js +10 -0
  147. package/dist/sites/index.d.ts +16 -0
  148. package/dist/sites/index.js +16 -4
  149. package/dist/sites/markdown-twin.d.ts +31 -0
  150. package/dist/sites/markdown-twin.js +56 -0
  151. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  152. package/dist/sites/salesforce/fetch-page.js +14 -3
  153. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  154. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  155. package/dist/sites/salesforce/index.d.ts +1 -0
  156. package/dist/sites/salesforce/index.js +1 -0
  157. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  158. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  159. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  160. package/dist/sites/salesforce-developer/index.js +1 -0
  161. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  162. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  163. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  164. package/dist/sites/salesforce-doc-fetch.js +52 -7
  165. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  166. package/dist/sites/salesforce-dom-probe.js +20 -2
  167. package/dist/sites/types.d.ts +14 -8
  168. package/dist/sites/types.js +3 -0
  169. package/oclif.manifest.json +216 -102
  170. package/package.json +20 -7
  171. package/agents/agents/official-docs-researcher.agent.md +0 -87
  172. package/agents/instructions/web-research.instructions.md +0 -44
  173. package/agents/skills/web-research/SKILL.md +0 -118
@@ -1,77 +1,67 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
- import { formatErrorForJson } from '../lib/envelope.js';
3
+ import { finalizeBatch, isBatchReadFailure, urlValidationErrorRow } from '../lib/batch.js';
4
4
  import { getArtifactPath } from '../lib/research/storage.js';
5
- import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
5
+ import { durationFlagError, evaluateFreshnessWithMaxAge } from '../lib/research/freshness.js';
6
+ import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
7
+ import { batchSeparator, cacheMissHint, formatCacheTargetHeader } from '../lib/cache-view.js';
6
8
  import { colors } from '../lib/color.js';
7
- function resolveFreshness(cached, currentTime, ttl, maxAge) {
8
- const isMaxAgeExpired = maxAge ? checkMaxAgeExpired(cached, currentTime, maxAge) : false;
9
- return isMaxAgeExpired
10
- ? 'stale_expired'
11
- : evaluateFreshness(cached.metadata, currentTime, ttl || cached.metadata.ttl);
9
+ function resolveFreshness(cached, currentTime, ttl, maxAge, tier) {
10
+ return evaluateFreshnessWithMaxAge(cached, currentTime, { ttl, maxAge, tier });
12
11
  }
13
- function describeCacheStatus(cached, currentTime, ttl, maxAge) {
12
+ function describeCacheStatus(cached, currentTime, ttl, maxAge, tier) {
14
13
  if (!cached) {
15
14
  return { status: 'miss', freshness: 'none', action: 'would_fetch' };
16
15
  }
17
- const freshness = resolveFreshness(cached, currentTime, ttl, maxAge);
16
+ const freshness = resolveFreshness(cached, currentTime, ttl, maxAge, tier);
18
17
  return freshness === 'fresh'
19
18
  ? { status: 'hit', freshness, action: 'would_return_cached' }
20
19
  : { status: 'stale', freshness, action: 'would_revalidate' };
21
20
  }
21
+ /** Report cache hit/miss and freshness without writing. */
22
22
  export default class ResearchStatus extends BaseCommand {
23
23
  static id = 'status';
24
- static summary = 'Check the cache status of a URL without fetching or writing.';
25
- static description = 'Reports whether the URL is cached, its freshness state, and what action (fetch, revalidate, or cached return) the root fetch command would take.';
24
+ static summary = 'Check cache status without fetching or writing';
25
+ static description = 'Report cache hit/miss state, freshness, and the action the URL shorthand would take.';
26
26
  static examples = [
27
27
  {
28
- description: 'check if a URL is already in the cache',
28
+ description: 'check whether a URL is cached',
29
29
  command: '<%= config.bin %> <%= command.id %> https://example.com/docs',
30
30
  },
31
31
  {
32
- description: 'check status against a specific freshness tier and TTL',
32
+ description: 'evaluate status with a specific tier and TTL',
33
33
  command: '<%= config.bin %> <%= command.id %> https://example.com/docs --tier volatile --ttl 2h',
34
34
  },
35
+ {
36
+ description: 'check multiple URLs in one batch (space-separated)',
37
+ command: '<%= config.bin %> <%= command.id %> https://example.com/docs https://example.com/api',
38
+ },
35
39
  ];
36
40
  static strict = false;
37
41
  static args = {
38
42
  url: Args.string({
39
43
  required: true,
40
- description: 'the URL to check',
44
+ description: 'URL to check',
41
45
  }),
42
46
  };
43
47
  static flags = {
48
+ // No default: omitting --tier evaluates against the artifact's stored tier. A default of
49
+ // `standard` would silently re-grade stable/volatile entries and lie about planned action.
44
50
  tier: Flags.option({
45
- description: 'freshness tier policy to evaluate against',
51
+ description: CLI_FLAG_DESCRIPTIONS.statusFreshnessTierPolicy,
46
52
  options: ['stable', 'standard', 'volatile'],
47
- default: 'standard',
48
53
  })(),
49
54
  ttl: Flags.string({
50
55
  char: 'l',
51
- description: 'custom TTL duration to evaluate against (e.g. "2h", "7d")',
56
+ description: CLI_FLAG_DESCRIPTIONS.statusTtl,
52
57
  }),
53
58
  'max-age': Flags.string({
54
- description: 'maximum age of cache to accept (e.g. "1d", "30d")',
59
+ description: CLI_FLAG_DESCRIPTIONS.maxAge,
55
60
  }),
56
61
  };
57
62
  static stdoutIsPrimaryData = true;
58
- /** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
59
63
  toSuccessJson(data) {
60
- const envelope = super.toSuccessJson(data);
61
- const list = Array.isArray(data) ? data : [data];
62
- const misses = list.filter((d) => d && d.status === 'miss');
63
- if (misses.length === 0)
64
- return envelope;
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}`);
68
- const stderr = formatErrorForJson({
69
- message: `Cache miss for ${normalizedUrl}` +
70
- (misses.length > 1 ? ` and ${misses.length - 1} other URLs` : ''),
71
- code: 'CACHE_MISS',
72
- suggestions,
73
- });
74
- return { ...envelope, ok: false, exitCode: 1, stderr, code: 'CACHE_MISS', suggestions };
64
+ return this.batchReadSuccessJson(data);
75
65
  }
76
66
  async run() {
77
67
  const urls = this.parsedArgv;
@@ -82,59 +72,27 @@ export default class ResearchStatus extends BaseCommand {
82
72
  if (msg)
83
73
  this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
84
74
  }
85
- const results = [];
86
- let hasMiss = false;
87
75
  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;
76
+ const results = this.mapUrlsAllowingBatchErrors(urls, (url) => this.checkSingleStatus(url, currentTime, { ttl, maxAge, tier }), urlValidationErrorRow);
77
+ return finalizeBatch(results, isBatchReadFailure);
99
78
  }
100
- checkSingleStatus(url, currentTime, ttl, maxAge, showSeparator) {
79
+ checkSingleStatus(url, currentTime, policy) {
101
80
  const target = this.resolveResearchTargetOrFail(url);
102
81
  const { cacheKey, located, normalizedUrl, roots } = target;
103
82
  const cached = located?.artifact ?? null;
104
- const result = describeCacheStatus(cached, currentTime, ttl, maxAge);
83
+ const result = describeCacheStatus(cached, currentTime, policy.ttl, policy.maxAge, policy.tier);
105
84
  const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
106
85
  if (!this.jsonEnabled()) {
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));
86
+ this.logStatusTable(target, artifactPath, result);
87
+ if (result.status === 'miss') {
88
+ this.warn(cacheMissHint(this.config.bin, normalizedUrl));
89
+ }
90
+ else if (result.action === 'would_revalidate') {
91
+ this.tip(`${this.config.bin} ${normalizedUrl} to revalidate.`);
92
+ }
93
+ else {
94
+ this.tip(`${this.config.bin} inspect ${normalizedUrl} for full metadata.`);
134
95
  }
135
- }
136
- if (result.status === 'miss' && !this.jsonEnabled()) {
137
- this.warn(`Cache miss — run: ${this.config.bin} ${normalizedUrl}`);
138
96
  }
139
97
  return {
140
98
  cacheKey,
@@ -145,4 +103,29 @@ export default class ResearchStatus extends BaseCommand {
145
103
  action: result.action,
146
104
  };
147
105
  }
106
+ logStatusTable(target, artifactPath, result) {
107
+ const colorOf = (map, key) => map[key] ?? ((t) => t);
108
+ const statusColor = colorOf({ hit: colors.green, stale: colors.yellow, miss: colors.red }, result.status);
109
+ const freshnessColor = colorOf({
110
+ fresh: colors.green,
111
+ stale_grace: colors.yellow,
112
+ stale_expired: colors.red,
113
+ none: colors.gray,
114
+ }, result.freshness);
115
+ const actionColor = colorOf({
116
+ would_return_cached: colors.green,
117
+ would_revalidate: colors.yellow,
118
+ would_fetch: colors.red,
119
+ }, result.action);
120
+ for (const line of formatCacheTargetHeader(target, [
121
+ ['Status', statusColor(result.status)],
122
+ ['Freshness', freshnessColor(result.freshness)],
123
+ ['Action', actionColor(result.action)],
124
+ ], artifactPath)) {
125
+ this.log(line);
126
+ }
127
+ const sep = batchSeparator(this.parsedArgv.length > 1);
128
+ if (sep)
129
+ this.log(sep);
130
+ }
148
131
  }
@@ -1,9 +1,10 @@
1
1
  import { type Hook, type Interfaces } from '@oclif/core';
2
- export declare function buildCommandNotFoundDetails(id: string, argv: string[] | undefined, config: Interfaces.Config): {
2
+ export declare function buildCommandNotFoundDetails(rawId: string, argv: string[] | undefined, config: Interfaces.Config): {
3
3
  code: 'COMMAND_NOT_FOUND' | 'MISSING_URL_SCHEME' | 'UNEXPECTED_ARGUMENT';
4
4
  command: string;
5
5
  jsonMode: boolean;
6
6
  message: string;
7
+ suggestions?: string[];
7
8
  };
8
9
  /**
9
10
  * Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
@@ -1,6 +1,6 @@
1
1
  import { toConfiguredId } from '@oclif/core';
2
- import { closestMatch, maxFuzzyDistance } from '../../lib/text.js';
3
- import { buildEnvelope, formatErrorForJson } from '../../lib/envelope.js';
2
+ import { closestMatch, maxFuzzyDistance, sanitizeForTerminal } from '../../lib/text.js';
3
+ import { writeCommandNotFoundJson } from '../../lib/cli-emit.js';
4
4
  import { looksLikeSchemelessUrl } from '../../lib/research/url.js';
5
5
  /**
6
6
  * The nearest visible command to a typo, plus how many leading segments of the attempted id name
@@ -10,13 +10,13 @@ import { looksLikeSchemelessUrl } from '../../lib/research/url.js';
10
10
  * `lisst:extra` resolves to nothing, but its prefix `lisst` resolves to `list`. The matched prefix
11
11
  * length doubles as the command-segment count, so a folded-in positional arg is dropped from display.
12
12
  */
13
- function findSuggestion(segments, visibleIds) {
13
+ function findSuggestion(segments, commandIds) {
14
14
  for (let n = segments.length; n >= 1; n--) {
15
15
  const attempted = segments.slice(0, n).join(':');
16
- const suggestion = closestMatch(attempted, visibleIds, maxFuzzyDistance(attempted));
16
+ const suggestion = closestMatch(attempted, commandIds, maxFuzzyDistance(attempted));
17
17
  if (suggestion === attempted &&
18
18
  n < segments.length &&
19
- visibleIds.some((id) => id.startsWith(`${attempted}:`))) {
19
+ commandIds.some((id) => id.startsWith(`${attempted}:`))) {
20
20
  continue;
21
21
  }
22
22
  if (suggestion)
@@ -35,12 +35,12 @@ function topicChainLength(segments, config) {
35
35
  i++;
36
36
  return i + 1;
37
37
  }
38
- function exactZeroArgCommandPrefix(segments, visibleIds, config) {
38
+ function exactZeroArgCommandPrefix(segments, commandIds, config) {
39
39
  for (let n = segments.length - 1; n >= 1; n--) {
40
40
  const candidate = segments.slice(0, n).join(':');
41
- if (!visibleIds.includes(candidate))
41
+ if (!commandIds.includes(candidate))
42
42
  continue;
43
- if (visibleIds.some((id) => id.startsWith(`${candidate}:`)))
43
+ if (commandIds.some((id) => id.startsWith(`${candidate}:`)))
44
44
  continue;
45
45
  const command = config.commands.find((entry) => entry.id === candidate);
46
46
  const args = Object.keys(command?.args ?? {});
@@ -65,43 +65,64 @@ function exactZeroArgCommandPrefix(segments, visibleIds, config) {
65
65
  function bareUrlInput(id) {
66
66
  return looksLikeSchemelessUrl(id) ? id : null;
67
67
  }
68
+ /**
69
+ * The id's colon-joined segments when every one individually looks like a URL missing its scheme,
70
+ * else null. Covers the multi-URL batch-fetch typo (`bonsai example.com example.org` folds into one
71
+ * id, "example.com:example.org") that {@link bareUrlInput} misses: parsed as a single URL, the colon
72
+ * between two hostnames reads as an invalid port, so the whole-string check fails even though each
73
+ * segment on its own is a plain scheme-less host.
74
+ */
75
+ function bareUrlSegments(id) {
76
+ const segments = id.split(':');
77
+ return segments.length > 1 && segments.every((segment) => looksLikeSchemelessUrl(segment))
78
+ ? segments
79
+ : null;
80
+ }
68
81
  function isJsonMode(argv) {
69
- return argv?.includes('--json') ?? false;
82
+ return (argv?.includes('--json') ?? false) || process.argv.includes('--json');
70
83
  }
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;
84
+ /** Shared "did you mean https://…?" response for one or several scheme-less URLs. */
85
+ function missingUrlSchemeDetails(urls, argv, config) {
86
+ const attempted = urls.join(' ');
87
+ const suggestion = `${config.bin} ${urls.map((url) => `https://${url}`).join(' ')}`;
88
+ return {
89
+ code: 'MISSING_URL_SCHEME',
90
+ command: attempted,
91
+ jsonMode: isJsonMode(argv),
92
+ message: [
93
+ `${attempted} is not a ${config.bin} command.`,
94
+ `Run ${config.bin} help for a list of available commands.`,
95
+ `Did you mean ${suggestion}? URLs need an http:// or https:// scheme.`,
96
+ ].join('\n'),
97
+ suggestions: [suggestion],
98
+ };
83
99
  }
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));
100
+ export function buildCommandNotFoundDetails(rawId, argv, config) {
101
+ // The attempted id is raw user input echoed straight back into this error's message and
102
+ // suggestions — untrusted per the repo's trust-boundary rules, same reasoning as
103
+ // BaseCommand.error(). Sanitize once, up front, so every derived value below (segments, the
104
+ // display-only "attempted" string, a bare-URL correction) is already safe; a real command/topic id
105
+ // never contains control bytes, so this only affects input that could never have matched one
106
+ // anyway. Gate on jsonMode exactly like BaseCommand.error() does, so `--json` keeps
107
+ // byte-for-byte fidelity — this hook fires before a Command instance exists, so there's no
108
+ // `jsonEnabled()` to call, but the same boolean is cheap to compute directly from argv.
109
+ const jsonMode = isJsonMode(argv);
110
+ const id = jsonMode ? rawId : sanitizeForTerminal(rawId);
111
+ // Match against every loaded command id — including hidden ones that stay invokable
112
+ // (`fetch`, plugin internals). Root `--help` hides them; typo recovery should not.
113
+ const commandIds = config.commandIDs;
87
114
  // A scheme-less URL is the most common "not a command" mistake for this CLI, so steer the user to
88
115
  // the `bonsai <url>` shorthand with a scheme before falling back to nearest-command matching (which
89
116
  // never finds a command for a hostname). The correction is shown, never auto-run (clig.dev).
117
+ // Flag-only argv never reaches here — normalizeArgv early-exits those as MISSING_COMMAND.
90
118
  const bareUrl = bareUrlInput(id);
91
- if (bareUrl) {
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
- }
119
+ if (bareUrl)
120
+ return missingUrlSchemeDetails([bareUrl], argv, config);
121
+ const bareUrls = bareUrlSegments(id);
122
+ if (bareUrls)
123
+ return missingUrlSchemeDetails(bareUrls, argv, config);
103
124
  const segments = id.split(':');
104
- const unexpected = exactZeroArgCommandPrefix(segments, visibleIds, config);
125
+ const unexpected = exactZeroArgCommandPrefix(segments, commandIds, config);
105
126
  if (unexpected) {
106
127
  const command = toConfiguredId(unexpected.commandId, config);
107
128
  return {
@@ -114,7 +135,7 @@ export function buildCommandNotFoundDetails(id, argv, config) {
114
135
  ].join('\n'),
115
136
  };
116
137
  }
117
- const { suggestion, commandSegments } = findSuggestion(segments, visibleIds);
138
+ const { suggestion, commandSegments } = findSuggestion(segments, commandIds);
118
139
  // Show only the segments that name the command, never the positional args oclif folded into the id
119
140
  // (which would otherwise glue the arg on and turn `://` into ` //`).
120
141
  const displaySegments = commandSegments || topicChainLength(segments, config);
@@ -123,6 +144,9 @@ export function buildCommandNotFoundDetails(id, argv, config) {
123
144
  // is the floor, and a concrete "Did you mean …?" correction — when we have one — goes below it.
124
145
  const lines = [`${attempted} is not a ${config.bin} command.`];
125
146
  lines.push(`Run ${config.bin} help for a list of available commands.`);
147
+ const suggestions = suggestion
148
+ ? [`${config.bin} ${toConfiguredId(suggestion, config)}`]
149
+ : undefined;
126
150
  if (suggestion)
127
151
  lines.push(`Did you mean ${toConfiguredId(suggestion, config)}?`);
128
152
  return {
@@ -130,6 +154,7 @@ export function buildCommandNotFoundDetails(id, argv, config) {
130
154
  command: attempted,
131
155
  jsonMode: isJsonMode(argv),
132
156
  message: lines.join('\n'),
157
+ suggestions,
133
158
  };
134
159
  }
135
160
  /**
@@ -140,8 +165,20 @@ export function buildCommandNotFoundDetails(id, argv, config) {
140
165
  */
141
166
  const hook = async function (opts) {
142
167
  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 });
168
+ if (details.jsonMode) {
169
+ return writeCommandNotFoundJson({
170
+ command: details.command,
171
+ message: details.message,
172
+ code: details.code,
173
+ suggestions: details.suggestions,
174
+ });
175
+ }
176
+ // Hook context typings omit `suggestions` even though CLIError accepts them; cast keeps the
177
+ // human pretty-print "Try this:" lines aligned with the JSON envelope.
178
+ this.error(details.message, {
179
+ exit: 2,
180
+ code: details.code,
181
+ suggestions: details.suggestions,
182
+ });
146
183
  };
147
184
  export default hook;
@@ -1,10 +1,36 @@
1
+ /** Result of argv normalization before oclif command dispatch. */
1
2
  export interface NormalizationResult {
2
3
  /** The normalized argv array to set on process.argv. */
3
4
  argv: string[];
4
- /** Optional metadata indicating the shim should exit early with a JSON envelope. */
5
- exitWithJson?: {
5
+ /**
6
+ * Exit before oclif runs. Used for flag-only / bare `--json` usage errors so human and JSON
7
+ * callers share one path (no "leave a flag token for command_not_found" hack).
8
+ */
9
+ earlyExit?: {
6
10
  exitCode: number;
7
11
  envelope: Record<string, unknown>;
12
+ json: boolean;
8
13
  };
9
14
  }
10
- export declare function normalizeArgv(rawArgv: string[]): NormalizationResult;
15
+ /** Options for {@link normalizeArgv}. */
16
+ export interface ArgvNormalizeOptions {
17
+ /** Tokens that consume the next argv value (`--topic`, `-t`, …). From cli-flag-manifest. */
18
+ valueTakingFlags: ReadonlySet<string>;
19
+ /**
20
+ * Root segment of every registered command id (`config:get` → `config`). From cli-flag-manifest.
21
+ * A `word:` token whose root is in this set is a command (`config:get`), never a URL scheme.
22
+ */
23
+ knownCommandRoots: ReadonlySet<string>;
24
+ }
25
+ /**
26
+ * Normalize raw process argv for oclif. `valueTakingFlags` must come from the composition root
27
+ * (`cli-flag-manifest`) — this module stays free of command registry imports.
28
+ */
29
+ export declare function normalizeArgv(rawArgv: string[], options: ArgvNormalizeOptions): NormalizationResult;
30
+ /**
31
+ * Non-flag positional tokens from a (typically already-normalized) argv. Skips `--help`/`--json`
32
+ * and any dash-prefixed flags so multi-segment command ids (`config get`) stay intact. Shared by
33
+ * help-preflight and json-meta — both run after `normalizeArgv` has already folded `help X` →
34
+ * `X --help` and `-h` → `--help`.
35
+ */
36
+ export declare function positionalArgvTokens(argv: readonly string[], valueTakingFlags: ReadonlySet<string>): string[];