@taurgis/bonsai 3.2.0 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +7 -4
  2. package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +9 -17
  5. package/dist/base-command.d.ts +22 -12
  6. package/dist/base-command.js +86 -49
  7. package/dist/commands/config/base.js +11 -2
  8. package/dist/commands/config/get.js +8 -12
  9. package/dist/commands/config/index.js +2 -2
  10. package/dist/commands/config/list.js +10 -16
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +34 -27
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +25 -24
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +57 -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 +103 -52
  21. package/dist/commands/list.d.ts +13 -1
  22. package/dist/commands/list.js +79 -32
  23. package/dist/commands/prune.d.ts +4 -1
  24. package/dist/commands/prune.js +55 -75
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +52 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +1 -0
  28. package/dist/hooks/command-not-found/suggest.js +66 -39
  29. package/dist/lib/argv.d.ts +24 -3
  30. package/dist/lib/argv.js +124 -53
  31. package/dist/lib/batch.d.ts +41 -0
  32. package/dist/lib/batch.js +39 -0
  33. package/dist/lib/cache-view.d.ts +21 -0
  34. package/dist/lib/cache-view.js +30 -0
  35. package/dist/lib/cli-emit.d.ts +28 -0
  36. package/dist/lib/cli-emit.js +44 -0
  37. package/dist/lib/cli-error-policy.d.ts +57 -0
  38. package/dist/lib/cli-error-policy.js +74 -0
  39. package/dist/lib/cli-flag-manifest.d.ts +5 -0
  40. package/dist/lib/cli-flag-manifest.js +8 -0
  41. package/dist/lib/cli-presentation.d.ts +39 -0
  42. package/dist/lib/cli-presentation.js +50 -0
  43. package/dist/lib/cli-result-types.d.ts +88 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +21 -3
  46. package/dist/lib/config/index.js +58 -3
  47. package/dist/lib/config-persist.d.ts +28 -0
  48. package/dist/lib/config-persist.js +29 -0
  49. package/dist/lib/envelope.d.ts +41 -3
  50. package/dist/lib/envelope.js +147 -4
  51. package/dist/lib/help-preflight.d.ts +5 -8
  52. package/dist/lib/help-preflight.js +13 -32
  53. package/dist/lib/json-meta.d.ts +0 -2
  54. package/dist/lib/json-meta.js +21 -25
  55. package/dist/lib/limit-flag.d.ts +11 -1
  56. package/dist/lib/limit-flag.js +19 -6
  57. package/dist/lib/parse-error-ux.d.ts +5 -0
  58. package/dist/lib/parse-error-ux.js +73 -0
  59. package/dist/lib/prune-flags.d.ts +28 -0
  60. package/dist/lib/prune-flags.js +74 -0
  61. package/dist/lib/research/artifact-index.d.ts +11 -2
  62. package/dist/lib/research/artifact-index.js +7 -4
  63. package/dist/lib/research/browser.d.ts +41 -2
  64. package/dist/lib/research/browser.js +139 -50
  65. package/dist/lib/research/capture.d.ts +24 -2
  66. package/dist/lib/research/capture.js +24 -4
  67. package/dist/lib/research/cli-io.d.ts +33 -0
  68. package/dist/lib/research/cli-io.js +27 -0
  69. package/dist/lib/research/compress.d.ts +7 -0
  70. package/dist/lib/research/compress.js +7 -3
  71. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  72. package/dist/lib/research/docs/capabilities.js +5 -0
  73. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  74. package/dist/lib/research/docs/clean-dom.js +2 -0
  75. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  76. package/dist/lib/research/docs/code-blocks.js +2 -0
  77. package/dist/lib/research/docs/detect.d.ts +4 -0
  78. package/dist/lib/research/docs/detect.js +4 -0
  79. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  80. package/dist/lib/research/docs/machine-readable.js +10 -0
  81. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  82. package/dist/lib/research/docs/markdown-source.js +19 -2
  83. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  84. package/dist/lib/research/docs/quality-gates.js +4 -0
  85. package/dist/lib/research/docs/rsc.d.ts +9 -2
  86. package/dist/lib/research/docs/rsc.js +5 -2
  87. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  88. package/dist/lib/research/docs/section-artifacts.js +12 -0
  89. package/dist/lib/research/docs/sections.d.ts +18 -0
  90. package/dist/lib/research/docs/sections.js +17 -1
  91. package/dist/lib/research/docs/source-map.d.ts +28 -0
  92. package/dist/lib/research/docs/source-map.js +27 -0
  93. package/dist/lib/research/docs/validate.d.ts +35 -2
  94. package/dist/lib/research/docs/validate.js +47 -2
  95. package/dist/lib/research/extract.d.ts +16 -0
  96. package/dist/lib/research/extract.js +12 -1
  97. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  98. package/dist/lib/research/fetch-command-service.js +310 -0
  99. package/dist/lib/research/fetch-result.d.ts +114 -0
  100. package/dist/lib/research/fetch-result.js +212 -0
  101. package/dist/lib/research/fetcher.d.ts +46 -8
  102. package/dist/lib/research/fetcher.js +58 -30
  103. package/dist/lib/research/freshness.d.ts +54 -11
  104. package/dist/lib/research/freshness.js +91 -31
  105. package/dist/lib/research/import-command-service.d.ts +56 -0
  106. package/dist/lib/research/import-command-service.js +381 -0
  107. package/dist/lib/research/keywords.js +5 -1
  108. package/dist/lib/research/markdown.d.ts +12 -1
  109. package/dist/lib/research/markdown.js +13 -5
  110. package/dist/lib/research/persist-artifact.d.ts +23 -0
  111. package/dist/lib/research/persist-artifact.js +27 -0
  112. package/dist/lib/research/prompt-injection.d.ts +8 -0
  113. package/dist/lib/research/prompt-injection.js +42 -13
  114. package/dist/lib/research/proxy.d.ts +14 -8
  115. package/dist/lib/research/proxy.js +14 -10
  116. package/dist/lib/research/revalidate.d.ts +49 -20
  117. package/dist/lib/research/revalidate.js +80 -15
  118. package/dist/lib/research/schema.d.ts +7 -0
  119. package/dist/lib/research/schema.js +2 -0
  120. package/dist/lib/research/secret-scan.d.ts +6 -0
  121. package/dist/lib/research/secret-scan.js +8 -0
  122. package/dist/lib/research/secure-write.d.ts +4 -0
  123. package/dist/lib/research/secure-write.js +31 -2
  124. package/dist/lib/research/storage.d.ts +5 -1
  125. package/dist/lib/research/storage.js +8 -3
  126. package/dist/lib/research/summarize.d.ts +15 -0
  127. package/dist/lib/research/summarize.js +11 -0
  128. package/dist/lib/research/url.d.ts +9 -6
  129. package/dist/lib/research/url.js +33 -21
  130. package/dist/lib/text.d.ts +7 -2
  131. package/dist/lib/text.js +13 -2
  132. package/dist/lib/value-flags.d.ts +14 -0
  133. package/dist/lib/value-flags.js +28 -0
  134. package/dist/sites/coveo.d.ts +10 -0
  135. package/dist/sites/coveo.js +10 -0
  136. package/dist/sites/index.d.ts +16 -0
  137. package/dist/sites/index.js +16 -4
  138. package/dist/sites/markdown-twin.d.ts +31 -0
  139. package/dist/sites/markdown-twin.js +56 -0
  140. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  141. package/dist/sites/salesforce/fetch-page.js +14 -3
  142. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  143. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  144. package/dist/sites/salesforce/index.d.ts +1 -0
  145. package/dist/sites/salesforce/index.js +1 -0
  146. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  147. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  148. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  149. package/dist/sites/salesforce-developer/index.js +1 -0
  150. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  151. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  152. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  153. package/dist/sites/salesforce-doc-fetch.js +52 -7
  154. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  155. package/dist/sites/salesforce-dom-probe.js +20 -2
  156. package/dist/sites/types.d.ts +14 -8
  157. package/dist/sites/types.js +3 -0
  158. package/oclif.manifest.json +104 -98
  159. package/package.json +19 -7
  160. package/agents/agents/official-docs-researcher.agent.md +0 -87
  161. package/agents/instructions/web-research.instructions.md +0 -44
  162. package/agents/skills/web-research/SKILL.md +0 -118
@@ -1,35 +1,35 @@
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
35
  ];
@@ -37,41 +37,27 @@ export default class ResearchStatus extends BaseCommand {
37
37
  static args = {
38
38
  url: Args.string({
39
39
  required: true,
40
- description: 'the URL to check',
40
+ description: 'URL to check',
41
41
  }),
42
42
  };
43
43
  static flags = {
44
+ // No default: omitting --tier evaluates against the artifact's stored tier. A default of
45
+ // `standard` would silently re-grade stable/volatile entries and lie about planned action.
44
46
  tier: Flags.option({
45
- description: 'freshness tier policy to evaluate against',
47
+ description: CLI_FLAG_DESCRIPTIONS.statusFreshnessTierPolicy,
46
48
  options: ['stable', 'standard', 'volatile'],
47
- default: 'standard',
48
49
  })(),
49
50
  ttl: Flags.string({
50
51
  char: 'l',
51
- description: 'custom TTL duration to evaluate against (e.g. "2h", "7d")',
52
+ description: CLI_FLAG_DESCRIPTIONS.statusTtl,
52
53
  }),
53
54
  'max-age': Flags.string({
54
- description: 'maximum age of cache to accept (e.g. "1d", "30d")',
55
+ description: CLI_FLAG_DESCRIPTIONS.maxAge,
55
56
  }),
56
57
  };
57
58
  static stdoutIsPrimaryData = true;
58
- /** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
59
59
  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 };
60
+ return this.batchReadSuccessJson(data);
75
61
  }
76
62
  async run() {
77
63
  const urls = this.parsedArgv;
@@ -82,60 +68,22 @@ export default class ResearchStatus extends BaseCommand {
82
68
  if (msg)
83
69
  this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
84
70
  }
85
- const results = [];
86
- let hasMiss = false;
87
71
  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;
72
+ const results = this.mapUrlsAllowingBatchErrors(urls, (url) => this.checkSingleStatus(url, currentTime, { ttl, maxAge, tier }), urlValidationErrorRow);
73
+ return finalizeBatch(results, isBatchReadFailure);
99
74
  }
100
- checkSingleStatus(url, currentTime, ttl, maxAge, showSeparator) {
75
+ checkSingleStatus(url, currentTime, policy) {
101
76
  const target = this.resolveResearchTargetOrFail(url);
102
77
  const { cacheKey, located, normalizedUrl, roots } = target;
103
78
  const cached = located?.artifact ?? null;
104
- const result = describeCacheStatus(cached, currentTime, ttl, maxAge);
79
+ const result = describeCacheStatus(cached, currentTime, policy.ttl, policy.maxAge, policy.tier);
105
80
  const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
106
81
  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));
82
+ this.logStatusTable(target, artifactPath, result);
83
+ if (result.status === 'miss') {
84
+ this.warn(cacheMissHint(this.config.bin, normalizedUrl));
134
85
  }
135
86
  }
136
- if (result.status === 'miss' && !this.jsonEnabled()) {
137
- this.warn(`Cache miss — run: ${this.config.bin} ${normalizedUrl}`);
138
- }
139
87
  return {
140
88
  cacheKey,
141
89
  cachePath: artifactPath,
@@ -145,4 +93,29 @@ export default class ResearchStatus extends BaseCommand {
145
93
  action: result.action,
146
94
  };
147
95
  }
96
+ logStatusTable(target, artifactPath, result) {
97
+ const colorOf = (map, key) => map[key] ?? ((t) => t);
98
+ const statusColor = colorOf({ hit: colors.green, stale: colors.yellow, miss: colors.red }, result.status);
99
+ const freshnessColor = colorOf({
100
+ fresh: colors.green,
101
+ stale_grace: colors.yellow,
102
+ stale_expired: colors.red,
103
+ none: colors.gray,
104
+ }, result.freshness);
105
+ const actionColor = colorOf({
106
+ would_return_cached: colors.green,
107
+ would_revalidate: colors.yellow,
108
+ would_fetch: colors.red,
109
+ }, result.action);
110
+ for (const line of formatCacheTargetHeader(target, [
111
+ ['Status', statusColor(result.status)],
112
+ ['Freshness', freshnessColor(result.freshness)],
113
+ ['Action', actionColor(result.action)],
114
+ ], artifactPath)) {
115
+ this.log(line);
116
+ }
117
+ const sep = batchSeparator(this.parsedArgv.length > 1);
118
+ if (sep)
119
+ this.log(sep);
120
+ }
148
121
  }
@@ -4,6 +4,7 @@ export declare function buildCommandNotFoundDetails(id: string, argv: string[] |
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
2
  import { closestMatch, maxFuzzyDistance } from '../../lib/text.js';
3
- import { buildEnvelope, formatErrorForJson } from '../../lib/envelope.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,54 @@ 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
100
  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));
101
+ // Match against every loaded command id — including hidden ones that stay invokable
102
+ // (`fetch`, plugin internals). Root `--help` hides them; typo recovery should not.
103
+ const commandIds = config.commandIDs;
87
104
  // A scheme-less URL is the most common "not a command" mistake for this CLI, so steer the user to
88
105
  // the `bonsai <url>` shorthand with a scheme before falling back to nearest-command matching (which
89
106
  // never finds a command for a hostname). The correction is shown, never auto-run (clig.dev).
107
+ // Flag-only argv never reaches here — normalizeArgv early-exits those as MISSING_COMMAND.
90
108
  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
- }
109
+ if (bareUrl)
110
+ return missingUrlSchemeDetails([bareUrl], argv, config);
111
+ const bareUrls = bareUrlSegments(id);
112
+ if (bareUrls)
113
+ return missingUrlSchemeDetails(bareUrls, argv, config);
103
114
  const segments = id.split(':');
104
- const unexpected = exactZeroArgCommandPrefix(segments, visibleIds, config);
115
+ const unexpected = exactZeroArgCommandPrefix(segments, commandIds, config);
105
116
  if (unexpected) {
106
117
  const command = toConfiguredId(unexpected.commandId, config);
107
118
  return {
@@ -114,7 +125,7 @@ export function buildCommandNotFoundDetails(id, argv, config) {
114
125
  ].join('\n'),
115
126
  };
116
127
  }
117
- const { suggestion, commandSegments } = findSuggestion(segments, visibleIds);
128
+ const { suggestion, commandSegments } = findSuggestion(segments, commandIds);
118
129
  // Show only the segments that name the command, never the positional args oclif folded into the id
119
130
  // (which would otherwise glue the arg on and turn `://` into ` //`).
120
131
  const displaySegments = commandSegments || topicChainLength(segments, config);
@@ -123,6 +134,9 @@ export function buildCommandNotFoundDetails(id, argv, config) {
123
134
  // is the floor, and a concrete "Did you mean …?" correction — when we have one — goes below it.
124
135
  const lines = [`${attempted} is not a ${config.bin} command.`];
125
136
  lines.push(`Run ${config.bin} help for a list of available commands.`);
137
+ const suggestions = suggestion
138
+ ? [`${config.bin} ${toConfiguredId(suggestion, config)}`]
139
+ : undefined;
126
140
  if (suggestion)
127
141
  lines.push(`Did you mean ${toConfiguredId(suggestion, config)}?`);
128
142
  return {
@@ -130,6 +144,7 @@ export function buildCommandNotFoundDetails(id, argv, config) {
130
144
  command: attempted,
131
145
  jsonMode: isJsonMode(argv),
132
146
  message: lines.join('\n'),
147
+ suggestions,
133
148
  };
134
149
  }
135
150
  /**
@@ -140,8 +155,20 @@ export function buildCommandNotFoundDetails(id, argv, config) {
140
155
  */
141
156
  const hook = async function (opts) {
142
157
  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 });
158
+ if (details.jsonMode) {
159
+ return writeCommandNotFoundJson({
160
+ command: details.command,
161
+ message: details.message,
162
+ code: details.code,
163
+ suggestions: details.suggestions,
164
+ });
165
+ }
166
+ // Hook context typings omit `suggestions` even though CLIError accepts them; cast keeps the
167
+ // human pretty-print "Try this:" lines aligned with the JSON envelope.
168
+ this.error(details.message, {
169
+ exit: 2,
170
+ code: details.code,
171
+ suggestions: details.suggestions,
172
+ });
146
173
  };
147
174
  export default hook;
@@ -1,10 +1,31 @@
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
+ /**
21
+ * Normalize raw process argv for oclif. `valueTakingFlags` must come from the composition root
22
+ * (`cli-flag-manifest`) — this module stays free of command registry imports.
23
+ */
24
+ export declare function normalizeArgv(rawArgv: string[], options: ArgvNormalizeOptions): NormalizationResult;
25
+ /**
26
+ * Non-flag positional tokens from a (typically already-normalized) argv. Skips `--help`/`--json`
27
+ * and any dash-prefixed flags so multi-segment command ids (`config get`) stay intact. Shared by
28
+ * help-preflight and json-meta — both run after `normalizeArgv` has already folded `help X` →
29
+ * `X --help` and `-h` → `--help`.
30
+ */
31
+ export declare function positionalArgvTokens(argv: readonly string[], valueTakingFlags: ReadonlySet<string>): string[];
package/dist/lib/argv.js CHANGED
@@ -1,52 +1,92 @@
1
- import { buildEnvelope } from './envelope.js';
1
+ import { EXIT_USAGE } from './cli-error-policy.js';
2
+ import { buildCliErrorEnvelope } from './envelope.js';
3
+ /** Shared MISSING_COMMAND copy for flag-only argv preflight. */
4
+ function missingCommandDetails(bin, swallowed) {
5
+ if (swallowed) {
6
+ return {
7
+ code: 'MISSING_COMMAND',
8
+ message: [
9
+ `Missing URL or command. ${swallowed.flag} consumed ${swallowed.url} as its value, so there was no URL left to fetch.`,
10
+ `Run ${bin} --help for usage.`,
11
+ ].join('\n'),
12
+ suggestions: [
13
+ `Pass the URL as the command (flags after): ${bin} ${swallowed.url}`,
14
+ `Or a named command: ${bin} list`,
15
+ ],
16
+ };
17
+ }
18
+ return {
19
+ code: 'MISSING_COMMAND',
20
+ message: `Missing URL or command. Run ${bin} --help for usage.`,
21
+ suggestions: [`Pass a URL: ${bin} https://example.com`, `Or a command: ${bin} list`],
22
+ };
23
+ }
24
+ /**
25
+ * Boolean flags registered on every command via BaseCommand.baseFlags. oclif only merges those after
26
+ * it picks a command, so `bonsai --read-only list` would otherwise treat `--read-only` as the
27
+ * command id. Strip and re-append them after the command/URL, same pattern as `--json`.
28
+ */
29
+ const GLOBAL_BOOLEAN_FLAGS = new Set(['--read-only', '--plan']);
2
30
  /**
3
- * Normalizes process.argv (excluding Node executable and script path) so the
4
- * oclif pipeline sees one consistent command structure.
31
+ * When a value-taking flag is the only "command" and its value looks like a URL, the user almost
32
+ * certainly put the URL where the flag value belongs (e.g. `bonsai --tags https://example.com`).
5
33
  */
6
- function missingUsageJsonExit() {
34
+ function findSwallowedUrlFlag(argv, valueTakingFlags) {
35
+ if (!argv?.length)
36
+ return null;
37
+ for (let i = 0; i < argv.length - 1; i++) {
38
+ const flag = argv[i];
39
+ if (!valueTakingFlags.has(flag))
40
+ continue;
41
+ const value = argv[i + 1];
42
+ if (value.startsWith('-'))
43
+ continue;
44
+ if (looksLikeUrl(value))
45
+ return { flag, url: value };
46
+ }
47
+ return null;
48
+ }
49
+ function missingUsageExit(json, argv, valueTakingFlags) {
50
+ const details = missingCommandDetails('bonsai', findSwallowedUrlFlag(argv, valueTakingFlags));
7
51
  return {
8
- exitCode: 2,
9
- envelope: buildEnvelope({
52
+ exitCode: EXIT_USAGE,
53
+ json,
54
+ envelope: buildCliErrorEnvelope({
10
55
  command: 'bonsai',
11
- ok: false,
12
- exitCode: 2,
13
- stderr: 'Missing URL or command. Run bonsai --help for usage.',
14
- data: null,
56
+ message: details.message,
57
+ code: details.code,
58
+ suggestions: details.suggestions,
15
59
  }),
16
60
  };
17
61
  }
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
- };
62
+ /**
63
+ * Index of the first positional command/URL token, skipping flags (and value-flag operands)
64
+ * plus root meta tokens that never name a command (`--help`, `--json`, globals).
65
+ */
66
+ function firstPositionalIndex(argv, valueTakingFlags) {
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const token = argv[i];
69
+ if (token === '--help' || token === '--json' || GLOBAL_BOOLEAN_FLAGS.has(token))
70
+ continue;
71
+ if (token.startsWith('-')) {
72
+ if (valueTakingFlags.has(token))
73
+ i++;
74
+ continue;
75
+ }
76
+ return i;
49
77
  }
78
+ return -1;
79
+ }
80
+ /** True when argv selects a command, URL, or root `--version` action (not flag-only usage). */
81
+ function hasCommandToken(argv, valueTakingFlags) {
82
+ return argv.includes('--version') || firstPositionalIndex(argv, valueTakingFlags) !== -1;
83
+ }
84
+ /**
85
+ * Normalize raw process argv for oclif. `valueTakingFlags` must come from the composition root
86
+ * (`cli-flag-manifest`) — this module stays free of command registry imports.
87
+ */
88
+ export function normalizeArgv(rawArgv, options) {
89
+ const { valueTakingFlags } = options;
50
90
  // oclif's JSON flag is command-scoped, so `bonsai list --json` works but
51
91
  // `bonsai --json list` is otherwise parsed as an unknown command named
52
92
  // "--json". Collect every --json, append one copy after the command/URL, and
@@ -60,22 +100,20 @@ export function normalizeArgv(rawArgv) {
60
100
  }
61
101
  const helpRequested = tokens.includes('--help');
62
102
  let core = tokens.filter((arg) => arg !== '--help');
103
+ // Relocate base boolean flags so they survive command resolution (see GLOBAL_BOOLEAN_FLAGS).
104
+ const relocatedGlobals = [];
105
+ core = core.filter((arg) => {
106
+ if (!GLOBAL_BOOLEAN_FLAGS.has(arg))
107
+ return true;
108
+ if (!relocatedGlobals.includes(arg))
109
+ relocatedGlobals.push(arg);
110
+ return false;
111
+ });
63
112
  // Treat URL-shaped tokens as the `fetch` shorthand. Match both `https://...` and scheme-only
64
113
  // forms like `javascript:` or `data:` so fetch can reject unsupported protocols instead of oclif
65
114
  // reporting a misleading "command not found". If the invocation begins with a flag, allow common
66
115
  // 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
- }
116
+ const firstNonFlagArgIndex = firstPositionalIndex(core, valueTakingFlags);
79
117
  const rootFetchShape = firstNonFlagArgIndex !== -1 && looksLikeUrl(core[firstNonFlagArgIndex]);
80
118
  if (rootFetchShape) {
81
119
  const url = core[firstNonFlagArgIndex];
@@ -88,10 +126,43 @@ export function normalizeArgv(rawArgv) {
88
126
  }
89
127
  if (helpRequested)
90
128
  core.push('--help');
129
+ core.push(...relocatedGlobals);
91
130
  if (jsonMode)
92
131
  core.push('--json');
132
+ // Flag-only argv (`--json`, `--read-only`, `--tags https://…`) never resolves a command.
133
+ // Exit here so command_not_found does not special-case dash-prefixed ids.
134
+ // Empty argv and `--help`/`--version` stay with oclif.
135
+ if (!helpRequested && core.length > 0 && !hasCommandToken(core, valueTakingFlags)) {
136
+ return {
137
+ argv: jsonMode ? ['--json'] : [],
138
+ earlyExit: missingUsageExit(jsonMode, rawArgv, valueTakingFlags),
139
+ };
140
+ }
93
141
  return { argv: core };
94
142
  }
143
+ /**
144
+ * Non-flag positional tokens from a (typically already-normalized) argv. Skips `--help`/`--json`
145
+ * and any dash-prefixed flags so multi-segment command ids (`config get`) stay intact. Shared by
146
+ * help-preflight and json-meta — both run after `normalizeArgv` has already folded `help X` →
147
+ * `X --help` and `-h` → `--help`.
148
+ */
149
+ export function positionalArgvTokens(argv, valueTakingFlags) {
150
+ const tokens = [];
151
+ for (let i = 0; i < argv.length; i++) {
152
+ const arg = argv[i];
153
+ if (arg === '--')
154
+ break;
155
+ if (arg === '--help' || arg === '--json')
156
+ continue;
157
+ if (arg.startsWith('-')) {
158
+ if (!arg.includes('=') && valueTakingFlags.has(arg))
159
+ i++;
160
+ continue;
161
+ }
162
+ tokens.push(arg);
163
+ }
164
+ return tokens;
165
+ }
95
166
  function looksLikeUrl(arg) {
96
167
  return arg.includes('://') || /^[a-z][a-z0-9+.-]*:/i.test(arg);
97
168
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Collapse a multi-URL command's per-URL rows into the CLI's single-vs-array return
3
+ * contract, and mark {@link EXIT_RUNTIME_FAILURE} when any row is a failure outcome.
4
+ *
5
+ * @param results - Per-URL result rows.
6
+ * @param isFailure - Predicate marking a row as a failure outcome.
7
+ * @returns The sole row when there is one URL; otherwise the full array.
8
+ */
9
+ export declare function finalizeBatch<T>(results: T[], isFailure: (row: T) => boolean): T | T[];
10
+ /**
11
+ * status/inspect batch rows: a miss or a validation error row is a failure outcome.
12
+ *
13
+ * @param row - Batch row with a `status` field.
14
+ * @returns `true` when the row should fail the batch exit code.
15
+ */
16
+ export declare function isBatchReadFailure(row: {
17
+ status: string;
18
+ }): boolean;
19
+ /**
20
+ * Sparse validation-failure row for multi-URL status/inspect. Only the fields agents need —
21
+ * no fake cacheKey/freshness/metadata padding.
22
+ *
23
+ * @param url - Input URL that failed validation.
24
+ * @param err - Error-like object with optional code/message/suggestions/ref.
25
+ * @returns Sparse error row for the batch result array.
26
+ */
27
+ export declare function urlValidationErrorRow(url: string, err: {
28
+ code?: string;
29
+ message?: string;
30
+ suggestions?: string[];
31
+ ref?: string;
32
+ }): {
33
+ status: "error";
34
+ normalizedUrl: string;
35
+ error: {
36
+ code: string;
37
+ message: string;
38
+ suggestions?: string[];
39
+ ref?: string;
40
+ };
41
+ };