@taurgis/bonsai 3.1.0 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +21 -4
  2. package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +9 -17
  5. package/dist/base-command.d.ts +37 -10
  6. package/dist/base-command.js +113 -46
  7. package/dist/commands/config/base.js +11 -2
  8. package/dist/commands/config/get.js +8 -12
  9. package/dist/commands/config/index.js +2 -2
  10. package/dist/commands/config/list.js +10 -16
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +34 -27
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +25 -24
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +58 -401
  17. package/dist/commands/import.d.ts +4 -9
  18. package/dist/commands/import.js +56 -280
  19. package/dist/commands/inspect.d.ts +7 -1
  20. package/dist/commands/inspect.js +103 -52
  21. package/dist/commands/list.d.ts +13 -1
  22. package/dist/commands/list.js +79 -32
  23. package/dist/commands/prune.d.ts +4 -1
  24. package/dist/commands/prune.js +56 -65
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +52 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +1 -0
  28. package/dist/hooks/command-not-found/suggest.js +66 -39
  29. package/dist/lib/argv.d.ts +24 -3
  30. package/dist/lib/argv.js +124 -53
  31. package/dist/lib/batch.d.ts +41 -0
  32. package/dist/lib/batch.js +39 -0
  33. package/dist/lib/cache-view.d.ts +21 -0
  34. package/dist/lib/cache-view.js +30 -0
  35. package/dist/lib/cli-emit.d.ts +28 -0
  36. package/dist/lib/cli-emit.js +44 -0
  37. package/dist/lib/cli-error-policy.d.ts +57 -0
  38. package/dist/lib/cli-error-policy.js +74 -0
  39. package/dist/lib/cli-flag-manifest.d.ts +5 -0
  40. package/dist/lib/cli-flag-manifest.js +8 -0
  41. package/dist/lib/cli-presentation.d.ts +39 -0
  42. package/dist/lib/cli-presentation.js +50 -0
  43. package/dist/lib/cli-result-types.d.ts +88 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +22 -4
  46. package/dist/lib/config/index.js +59 -4
  47. package/dist/lib/config/resolve.d.ts +21 -0
  48. package/dist/lib/config/resolve.js +55 -6
  49. package/dist/lib/config-persist.d.ts +28 -0
  50. package/dist/lib/config-persist.js +29 -0
  51. package/dist/lib/envelope.d.ts +41 -3
  52. package/dist/lib/envelope.js +147 -4
  53. package/dist/lib/help-preflight.d.ts +5 -8
  54. package/dist/lib/help-preflight.js +13 -32
  55. package/dist/lib/json-meta.d.ts +0 -2
  56. package/dist/lib/json-meta.js +21 -25
  57. package/dist/lib/limit-flag.d.ts +11 -1
  58. package/dist/lib/limit-flag.js +19 -6
  59. package/dist/lib/parse-error-ux.d.ts +5 -0
  60. package/dist/lib/parse-error-ux.js +73 -0
  61. package/dist/lib/prune-flags.d.ts +28 -0
  62. package/dist/lib/prune-flags.js +74 -0
  63. package/dist/lib/research/artifact-index.d.ts +11 -2
  64. package/dist/lib/research/artifact-index.js +7 -4
  65. package/dist/lib/research/browser.d.ts +41 -2
  66. package/dist/lib/research/browser.js +139 -50
  67. package/dist/lib/research/capture.d.ts +24 -2
  68. package/dist/lib/research/capture.js +24 -4
  69. package/dist/lib/research/cli-io.d.ts +33 -0
  70. package/dist/lib/research/cli-io.js +27 -0
  71. package/dist/lib/research/compress.d.ts +7 -0
  72. package/dist/lib/research/compress.js +7 -3
  73. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  74. package/dist/lib/research/docs/capabilities.js +5 -0
  75. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  76. package/dist/lib/research/docs/clean-dom.js +2 -0
  77. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  78. package/dist/lib/research/docs/code-blocks.js +2 -0
  79. package/dist/lib/research/docs/detect.d.ts +4 -0
  80. package/dist/lib/research/docs/detect.js +4 -0
  81. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  82. package/dist/lib/research/docs/machine-readable.js +10 -0
  83. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  84. package/dist/lib/research/docs/markdown-source.js +19 -2
  85. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  86. package/dist/lib/research/docs/quality-gates.js +4 -0
  87. package/dist/lib/research/docs/rsc.d.ts +9 -2
  88. package/dist/lib/research/docs/rsc.js +5 -2
  89. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  90. package/dist/lib/research/docs/section-artifacts.js +12 -0
  91. package/dist/lib/research/docs/sections.d.ts +18 -0
  92. package/dist/lib/research/docs/sections.js +17 -1
  93. package/dist/lib/research/docs/source-map.d.ts +28 -0
  94. package/dist/lib/research/docs/source-map.js +27 -0
  95. package/dist/lib/research/docs/validate.d.ts +35 -2
  96. package/dist/lib/research/docs/validate.js +47 -2
  97. package/dist/lib/research/extract.d.ts +16 -0
  98. package/dist/lib/research/extract.js +12 -1
  99. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  100. package/dist/lib/research/fetch-command-service.js +310 -0
  101. package/dist/lib/research/fetch-result.d.ts +114 -0
  102. package/dist/lib/research/fetch-result.js +212 -0
  103. package/dist/lib/research/fetcher.d.ts +46 -8
  104. package/dist/lib/research/fetcher.js +58 -30
  105. package/dist/lib/research/freshness.d.ts +54 -11
  106. package/dist/lib/research/freshness.js +91 -31
  107. package/dist/lib/research/import-command-service.d.ts +56 -0
  108. package/dist/lib/research/import-command-service.js +381 -0
  109. package/dist/lib/research/keywords.js +5 -1
  110. package/dist/lib/research/markdown.d.ts +12 -1
  111. package/dist/lib/research/markdown.js +13 -5
  112. package/dist/lib/research/persist-artifact.d.ts +23 -0
  113. package/dist/lib/research/persist-artifact.js +27 -0
  114. package/dist/lib/research/prompt-injection.d.ts +8 -0
  115. package/dist/lib/research/prompt-injection.js +42 -13
  116. package/dist/lib/research/proxy.d.ts +14 -8
  117. package/dist/lib/research/proxy.js +14 -10
  118. package/dist/lib/research/revalidate.d.ts +49 -20
  119. package/dist/lib/research/revalidate.js +80 -15
  120. package/dist/lib/research/schema.d.ts +7 -0
  121. package/dist/lib/research/schema.js +2 -0
  122. package/dist/lib/research/secret-scan.d.ts +6 -0
  123. package/dist/lib/research/secret-scan.js +8 -0
  124. package/dist/lib/research/secure-write.d.ts +11 -1
  125. package/dist/lib/research/secure-write.js +38 -7
  126. package/dist/lib/research/storage.d.ts +5 -1
  127. package/dist/lib/research/storage.js +8 -3
  128. package/dist/lib/research/summarize.d.ts +15 -0
  129. package/dist/lib/research/summarize.js +11 -0
  130. package/dist/lib/research/url.d.ts +9 -6
  131. package/dist/lib/research/url.js +33 -21
  132. package/dist/lib/text.d.ts +7 -2
  133. package/dist/lib/text.js +13 -2
  134. package/dist/lib/value-flags.d.ts +14 -0
  135. package/dist/lib/value-flags.js +28 -0
  136. package/dist/sites/coveo.d.ts +10 -0
  137. package/dist/sites/coveo.js +10 -0
  138. package/dist/sites/index.d.ts +16 -0
  139. package/dist/sites/index.js +16 -4
  140. package/dist/sites/markdown-twin.d.ts +31 -0
  141. package/dist/sites/markdown-twin.js +56 -0
  142. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  143. package/dist/sites/salesforce/fetch-page.js +14 -3
  144. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  145. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  146. package/dist/sites/salesforce/index.d.ts +1 -0
  147. package/dist/sites/salesforce/index.js +1 -0
  148. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  149. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  150. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  151. package/dist/sites/salesforce-developer/index.js +1 -0
  152. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  153. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  154. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  155. package/dist/sites/salesforce-doc-fetch.js +52 -7
  156. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  157. package/dist/sites/salesforce-dom-probe.js +20 -2
  158. package/dist/sites/types.d.ts +14 -8
  159. package/dist/sites/types.js +3 -0
  160. package/oclif.manifest.json +192 -87
  161. package/package.json +19 -7
  162. package/agents/agents/official-docs-researcher.agent.md +0 -87
  163. package/agents/instructions/web-research.instructions.md +0 -44
  164. package/agents/skills/web-research/SKILL.md +0 -118
@@ -1,38 +1,21 @@
1
- import { Args, Flags, ux, Errors } from '@oclif/core';
2
- import { mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
1
+ import { Args, Flags, ux } from '@oclif/core';
5
2
  import { BaseCommand } from '../base-command.js';
6
- import { writeArtifact, getArtifactPath } from '../lib/research/storage.js';
7
- import { writeArtifactSecurely } from '../lib/research/secure-write.js';
8
- import { loadSummaryLevel } from '../lib/config/index.js';
9
- import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
10
- import { revalidateCache, createArtifactFromFetch } from '../lib/research/revalidate.js';
11
- import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
12
- import { PROXY_TUNNEL_REJECTION_PATTERN } from '../lib/research/proxy.js';
13
- import { fetchRenderedHtml, SANDBOX_EGRESS_ERROR_MARKER } from '../lib/research/browser.js';
14
- import { capturePage } from '../lib/research/capture.js';
15
- import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
16
- import { applyAutoTags } from '../lib/research/keywords.js';
17
- import { detectSite } from '../sites/index.js';
18
- import { applySiteFetchProvenance } from '../sites/types.js';
19
- const CAPTURE_DEPS = {
20
- fetchStatic: (url) => fetchStaticHtml(url),
21
- fetchRendered: (url) => fetchRenderedHtml(url),
22
- fetchText: (url) => fetchText(url),
23
- };
3
+ import { enrichRowErrorEnvelope } from '../lib/envelope.js';
4
+ import { runFetchCommandService, validateFetchCommandFlags, } from '../lib/research/fetch-command-service.js';
5
+ import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
6
+ /** Hidden named `fetch` command; usually invoked as `bonsai <url>`. */
24
7
  export default class FetchCommand extends BaseCommand {
25
8
  static id = 'fetch';
26
9
  static hidden = true;
27
- static summary = 'An advanced, locally cached web research tool optimized for LLM ingestion.';
28
- static description = 'Scrapes the specified URL, strips HTML boilerplate, converts the semantic payload into clean Markdown format, and caches the result locally using dynamic TTL rules.\n\nUsually invoked via the shorthand `bonsai <url>` rather than `bonsai fetch <url>`.';
10
+ static summary = 'Fetch and cache URL research Markdown';
11
+ static description = 'Fetch a URL, convert the main content to Markdown, and cache compressed and detailed variants.\n\nUsually invoked as `bonsai <url>`; run `bonsai help fetch` for URL-form flags.';
29
12
  static examples = [
30
13
  {
31
- description: 'research a URL with detailed output, tagged with topic and tags',
32
- command: '<%= config.bin %> https://docs.nestjs.com/ --topic "Backend Frameworks" --tags "Node" --tags "NestJS" --format detailed --ttl 30d',
14
+ description: 'cache docs with detailed output, topic, tags, and TTL',
15
+ command: '<%= config.bin %> https://docs.nestjs.com/ --topic "Backend Frameworks" --tags "Node" --tags "NestJS" --format detailed --ttl 7d',
33
16
  },
34
17
  {
35
- description: 'research a volatile page with compressed output and short TTL, returned as JSON',
18
+ description: 'cache a volatile page with compressed JSON output',
36
19
  command: '<%= config.bin %> https://news.ycombinator.com/ --format compressed --ttl 2h --json',
37
20
  },
38
21
  ];
@@ -40,425 +23,99 @@ export default class FetchCommand extends BaseCommand {
40
23
  static args = {
41
24
  url: Args.string({
42
25
  required: true,
43
- description: 'the full HTTP/HTTPS URL of the web page to research',
26
+ description: 'HTTP(S) URL to research',
44
27
  }),
45
28
  };
46
29
  static flags = {
47
30
  topic: Flags.string({
48
31
  char: 't',
49
- description: 'the main category/topic of the research for metadata tagging',
32
+ description: CLI_FLAG_DESCRIPTIONS.fetchTopic,
50
33
  }),
51
34
  tags: Flags.string({
52
35
  char: 'g',
53
- description: 'taxonomic tags for this research (can be repeated)',
36
+ description: CLI_FLAG_DESCRIPTIONS.fetchTags,
54
37
  multiple: true,
55
38
  }),
56
39
  format: Flags.option({
57
40
  char: 'f',
58
- description: 'desired data density',
41
+ description: CLI_FLAG_DESCRIPTIONS.format,
59
42
  options: ['compressed', 'detailed'],
60
43
  default: 'compressed',
61
44
  })(),
62
45
  tier: Flags.option({
63
- description: 'freshness tier policy',
46
+ description: CLI_FLAG_DESCRIPTIONS.freshnessTierPolicy,
64
47
  options: ['stable', 'standard', 'volatile'],
65
48
  default: 'standard',
66
49
  })(),
67
50
  ttl: Flags.string({
68
51
  char: 'l',
69
- description: 'predicted lifespan: number + h/d/w/m/y (m = months), e.g. "2h", "7d", "6m"',
52
+ description: CLI_FLAG_DESCRIPTIONS.fetchTtl,
70
53
  }),
71
54
  'max-age': Flags.string({
72
- description: 'maximum age of cache to accept (e.g., "1d", "30d")',
55
+ description: CLI_FLAG_DESCRIPTIONS.maxAge,
73
56
  }),
74
57
  force: Flags.boolean({
75
- description: 'force a fresh fetch, ignoring any cached entries',
58
+ description: 'fetch fresh content, ignoring cached entries',
76
59
  default: false,
77
60
  }),
78
61
  'dry-run': Flags.boolean({
79
- description: 'perform validation and fetch without saving to local cache',
62
+ description: 'fetch and validate without writing cache',
80
63
  default: false,
81
64
  }),
82
65
  'allow-stale': Flags.boolean({
83
- description: 'allow serving stale cache if the remote server is unreachable',
66
+ // Stale content within the grace window is served either way after a failed revalidation;
67
+ // this only suppresses the exit-5 "served stale" signal. It has no effect once an entry is
68
+ // past its grace window (that failure is always a hard error). See README's Freshness
69
+ // section and docs/concepts/caching-and-freshness.md for the full decision flow.
70
+ description: 'suppress exit code 5 when stale cache is served within grace after failed revalidation',
84
71
  default: false,
85
72
  }),
86
73
  rendered: Flags.boolean({
87
- description: 'force using a browser-rendered scraping path for dynamic pages',
74
+ description: 'use browser-rendered capture for dynamic pages',
88
75
  default: false,
89
76
  }),
90
77
  storage: Flags.option({
91
- description: 'override where this result is cached (secrets always stored globally)',
78
+ description: CLI_FLAG_DESCRIPTIONS.fetchStorage,
92
79
  options: ['global', 'project'],
93
80
  })(),
94
81
  };
95
82
  static stdoutIsPrimaryData = true;
96
- envelopeCommandId() {
97
- return this.config.bin;
98
- }
99
- async executeCacheHit(cached, targetDir, currentTime, summaryLevel) {
100
- const { ttl, 'max-age': maxAge, 'allow-stale': allowStale, rendered } = this.flags;
101
- const isExpired = checkMaxAgeExpired(cached, currentTime, maxAge);
102
- const freshnessState = isExpired
103
- ? 'stale_expired'
104
- : evaluateFreshness(cached.metadata, currentTime, ttl);
105
- if (freshnessState === 'fresh') {
106
- return { cacheStatus: 'hit', freshnessState, artifact: cached };
107
- }
108
- const revalResult = await revalidateCache(targetDir, cached, currentTime, {
109
- allowStale: Boolean(allowStale),
110
- ttlOverride: ttl,
111
- rendered: Boolean(rendered),
112
- summaryLevel,
113
- });
114
- handleStaleRevalidationResult(this, revalResult);
83
+ /**
84
+ * When a multi-URL batch has any per-URL failure, keep the result array (including hits) and
85
+ * surface FETCH_FAILED on the envelope — same batch contract as status/inspect CACHE_MISS.
86
+ */
87
+ toSuccessJson(data) {
88
+ return enrichRowErrorEnvelope(this.baseSuccessJson(data), data);
89
+ }
90
+ serviceFlags() {
115
91
  return {
116
- cacheStatus: revalResult.status,
117
- freshnessState,
118
- artifact: revalResult.artifact,
92
+ topic: this.flags.topic,
93
+ tags: this.flags.tags,
94
+ format: this.flags.format,
95
+ tier: this.flags.tier,
96
+ ttl: this.flags.ttl,
97
+ maxAge: this.flags['max-age'],
98
+ force: this.flags.force,
99
+ allowStale: this.flags['allow-stale'],
100
+ rendered: this.flags.rendered,
101
+ storage: this.flags.storage,
119
102
  };
120
103
  }
121
- async executeCacheMiss(normalizedUrl, currentTime, cacheKey, summaryLevel) {
122
- const { topic, tags, tier, ttl, rendered } = this.flags;
123
- const siteModule = detectSite(normalizedUrl);
124
- const useRendered = rendered || Boolean(siteModule?.defaults?.rendered);
125
- let fetchResult;
126
- let extraction;
127
- let capture = null;
128
- let siteFetch = null;
129
- if (siteModule?.fetchPage) {
130
- // Custom site modules own their fetch/extract strategy; the generic capture path is skipped.
131
- siteFetch = await siteModule.fetchPage(normalizedUrl);
132
- ({ fetchResult, extraction } = siteFetch);
133
- }
134
- else {
135
- capture = await capturePage(normalizedUrl, { forceRendered: useRendered }, CAPTURE_DEPS);
136
- fetchResult = capture.fetchResult;
137
- extraction = capture.extraction;
138
- }
139
- const artifact = createArtifactFromFetch(normalizedUrl, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl || null, currentTime, summaryLevel);
140
- artifact.metadata.topic = topic || null;
141
- artifact.metadata.tags = tags || [];
142
- artifact.metadata.site_module_id = siteModule?.id ?? null;
143
- if (capture) {
144
- applyCaptureMetadata(artifact, capture);
145
- }
146
- else if (siteFetch?.captureMethod) {
147
- applySiteFetchProvenance(artifact.metadata, siteFetch);
148
- }
149
- else if (useRendered) {
150
- artifact.metadata.capture_method = 'browser_fallback';
151
- }
152
- // Auto-tag from the fetched content when the caller supplied none, keeping cached pages
153
- // searchable by keyword. Explicit --tags always win (handled in applyAutoTags).
154
- return applyAutoTags(artifact);
155
- }
156
- buildResultData(url, normalizedUrl, cacheKey, storageDir, storageMode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal) {
157
- return {
158
- schemaVersion: 1,
159
- command: this.config.bin,
160
- cache: {
161
- key: cacheKey,
162
- status: cacheStatus,
163
- freshness: freshnessState,
164
- path: getArtifactPath(storageDir, cacheKey),
165
- storage: storageMode,
166
- redirectedToGlobal,
167
- },
168
- source: {
169
- url,
170
- normalizedUrl,
171
- captureMethod: artifact.metadata.capture_method,
172
- extractionStatus: artifact.metadata.extraction_status,
173
- extractionConfidence: artifact.metadata.extraction_confidence,
174
- qualityNotes: artifact.metadata.quality_notes,
175
- fetchedAt: artifact.metadata.fetched_at,
176
- validatedAt: artifact.metadata.validated_at,
177
- staleAfter: artifact.metadata.stale_after,
178
- },
179
- artifactType: artifact.metadata.artifact_type,
180
- docsEngine: artifact.metadata.docs_engine,
181
- docsFramework: artifact.metadata.docs_framework,
182
- sourceDocUrl: artifact.metadata.source_doc_url,
183
- searchProvider: artifact.metadata.search_provider,
184
- format,
185
- tokenEstimate: format === 'compressed'
186
- ? artifact.metadata.token_estimate.compressed
187
- : artifact.metadata.token_estimate.detailed,
188
- content,
189
- };
190
- }
191
- // Validates the duration flags up front, exiting with code 2 on a malformed value.
192
- validateDurationFlags(ttl, maxAge) {
193
- for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
194
- if (msg)
195
- this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
196
- }
197
- }
198
- // Re-emit a runtime fetch failure with actionable next steps. Deep fetch/extract code throws plain
199
- // Errors that otherwise reach the user as a bare "what broke" line with no "what to do"; this
200
- // attaches recovery hints (e.g. import an auth-blocked page). Everything reaching here is a runtime
201
- // failure — validation errors (bad flags/URL) exit before the try — so the contract's runtime code
202
- // (1) applies. Suggestions render on stderr for humans only; under --json the envelope carries just
203
- // the message (toErrorJson), so machine output is unchanged.
204
- //
205
- // Uses `describeError` (below) rather than `err.message` alone: Node/undici's fetch wraps
206
- // transport failures (DNS, refused connections, a proxy that won't tunnel to the host) in a
207
- // generic "fetch failed" TypeError with the real reason nested in `.cause`, so reading only the
208
- // top message would surface a content-free "fetch failed" for exactly the failures a user most
209
- // needs a hint for.
210
- emitFetchError(err, url) {
211
- const message = describeError(err);
212
- const guidance = fetchFailureGuidance(message, url);
213
- this.error(message, {
214
- exit: 1,
215
- code: 'FETCH_FAILED',
216
- suggestions: guidance?.suggestions,
217
- ref: guidance?.ref,
218
- });
219
- }
220
- // Serves the cache when a fresh/revalidatable entry exists, otherwise fetches fresh. Reads fall
221
- // back project→global; the resolved artifact is returned with the dir it lives in / landed in.
222
- async resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel) {
223
- if (located) {
224
- // Revalidate where the entry already lives; on dry-run use the throwaway dir so the cache is
225
- // never mutated. ponytail: revalidation rewrites in place, so a refreshed project entry that
226
- // gains a secret is not re-routed — only first-time project writes are scanned.
227
- const revalDir = tmpDir ?? located.dataDir;
228
- const hit = await this.executeCacheHit(located.artifact, revalDir, currentTime, summaryLevel);
229
- return { ...hit, storageDir: located.dataDir, redirectedToGlobal: false };
230
- }
231
- const artifact = await this.executeCacheMiss(normalizedUrl, currentTime, cacheKey, summaryLevel);
232
- const { dir, redirectedToGlobal } = this.persistFreshArtifact(roots, tmpDir, cacheKey, artifact);
233
- // No entry existed at lookup, so there is no prior freshness to report. 'none' (not
234
- // 'stale_expired') keeps the field honest — nothing expired; the page was simply uncached and
235
- // has now been fetched fresh. Mirrors the `status` command's miss reporting.
236
- return {
237
- cacheStatus: 'miss',
238
- freshnessState: 'none',
239
- artifact,
240
- storageDir: dir,
241
- redirectedToGlobal,
242
- };
243
- }
244
- // Writes a freshly fetched artifact, honoring dry-run (throwaway dir) and the secret-safety
245
- // redirect (project→global). Returns the data dir reported to the user and whether a redirect
246
- // occurred (so the JSON envelope mirrors `import`).
247
- persistFreshArtifact(roots, tmpDir, cacheKey, artifact) {
248
- if (tmpDir) {
249
- writeArtifact(tmpDir, cacheKey, artifact);
250
- // Report the real would-be location, not the throwaway dir that is about to be deleted.
251
- return { dir: roots.writeRoot, redirectedToGlobal: false };
252
- }
253
- const result = writeArtifactSecurely(roots, cacheKey, artifact);
254
- if (result.redirected) {
255
- this.warn(`Detected ${result.secretLabel} in the page content; stored in the global cache instead of the project to avoid committing secrets.`);
256
- }
257
- return { dir: result.dataDir, redirectedToGlobal: result.redirected };
258
- }
259
- // Long references are split into searchable/inspectable section children whenever the page artifact
260
- // is freshly written (T-22). Best-effort: never let chunking break the main result.
261
- persistSectionsIfFresh(targetDir, artifact, currentTime, cacheStatus, summaryLevel) {
262
- if (cacheStatus !== 'miss' && cacheStatus !== 'refreshed')
263
- return;
264
- try {
265
- persistSectionArtifacts(targetDir, artifact, currentTime, summaryLevel);
266
- }
267
- catch {
268
- /* section generation is non-essential; ignore failures */
269
- }
270
- }
271
104
  async run() {
272
- const urls = this.parsedArgv;
273
- const { format, ttl, 'max-age': maxAge, 'dry-run': dryRun } = this.flags;
274
- this.validateDurationFlags(ttl, maxAge);
275
- const summaryLevel = loadSummaryLevel(this.config.configDir, process.cwd());
276
- const tmpDir = dryRun ? mkdtempSync(join(tmpdir(), 'fnr-dry-run-')) : null;
277
- const currentTime = new Date();
278
- const results = [];
279
- try {
280
- for (const url of urls) {
281
- results.push(await this.fetchSingleTarget(url, currentTime, tmpDir, summaryLevel, format, urls.length > 1));
282
- }
283
- return urls.length === 1 ? results[0] : results;
284
- }
285
- catch (err) {
286
- if (!this.jsonEnabled()) {
287
- ux.action.stop('failed');
288
- }
289
- if (err instanceof Errors.CLIError)
290
- throw err;
291
- // Stale-serve (exit 5) signals via process.exitCode and never throws, so it bypasses this
292
- // path; only genuine fetch/extract failures land here and get classified guidance. Use the
293
- // normalized URL in hints so the copy-paste command is canonical (and never echoes raw,
294
- // unsanitized user input back to the terminal).
295
- const failedUrl = urls[results.length] || urls[0] || '';
296
- this.emitFetchError(err, failedUrl);
297
- }
298
- finally {
299
- if (tmpDir) {
300
- rmSync(tmpDir, { recursive: true, force: true });
301
- }
302
- }
303
- }
304
- async fetchSingleTarget(url, currentTime, tmpDir, summaryLevel, format, showSeparator) {
305
- const target = this.resolveResearchTargetOrFail(url, {
306
- flagOverride: this.flags.storage,
307
- lookup: !this.flags.force,
105
+ const io = this.cliIo();
106
+ const flags = this.serviceFlags();
107
+ validateFetchCommandFlags(io, flags);
108
+ const dryRun = this.effectiveDryRun(this.flags['dry-run']);
109
+ return runFetchCommandService({
110
+ urls: this.parsedArgv,
111
+ flags,
112
+ dryRun,
113
+ io,
114
+ spinner: {
115
+ running: () => Boolean(ux.action.running),
116
+ start: (msg) => ux.action.start(msg),
117
+ stop: (msg) => ux.action.stop(msg),
118
+ },
308
119
  });
309
- const { cacheKey, located, normalizedUrl, roots } = target;
310
- if (!this.jsonEnabled()) {
311
- ux.action.start('Fetching ' + normalizedUrl);
312
- }
313
- const { cacheStatus, freshnessState, artifact, storageDir, redirectedToGlobal } = await this.resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel);
314
- this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus, summaryLevel);
315
- if (!this.jsonEnabled()) {
316
- const CACHE_STATUS_LABEL = {
317
- hit: 'cached',
318
- miss: 'done',
319
- refreshed: 'refreshed',
320
- revalidated: 'revalidated',
321
- stale: 'served stale',
322
- };
323
- ux.action.stop(CACHE_STATUS_LABEL[cacheStatus] ?? cacheStatus);
324
- }
325
- const content = format === 'compressed' ? artifact.compressed : artifact.detailed;
326
- const resultData = this.buildResultData(url, normalizedUrl, cacheKey, storageDir, roots.mode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal);
327
- if (!this.jsonEnabled()) {
328
- this.log(content);
329
- if (showSeparator) {
330
- this.log('\n' + '='.repeat(40) + '\n');
331
- }
332
- }
333
- return resultData;
334
- }
335
- }
336
- // How many `.cause` levels to walk. Real undici/Node transport errors nest at most 1-2 deep;
337
- // the cap exists so a self-referential or cyclic `.cause` chain (Error.cause is an arbitrary,
338
- // unvalidated property — nothing prevents `e.cause = e`, whether from a bug in this codebase or
339
- // a dependency) can't hang the process, rather than being a limit expected to matter in practice.
340
- const MAX_CAUSE_DEPTH = 10;
341
- // Walks an Error's `.cause` chain and joins every message, deepest last. Node/undici's fetch
342
- // throws a generic `TypeError: fetch failed` for transport-level failures, with the actionable
343
- // detail (e.g. a proxy's tunnel rejection) nested one or two `.cause` levels down.
344
- export function describeError(err) {
345
- if (!(err instanceof Error))
346
- return String(err);
347
- const parts = [err.message];
348
- let cause = err.cause;
349
- for (let depth = 0; depth < MAX_CAUSE_DEPTH && cause instanceof Error; depth++) {
350
- parts.push(cause.message);
351
- cause = cause.cause;
352
- }
353
- return parts.join(': ');
354
- }
355
- // Maps a runtime fetch failure to recovery steps keyed off its message. Returns undefined for
356
- // unrecognized failures, which then surface with their original message and no extra hint. The
357
- // patterns match the thrown text in fetcher.ts/browser.ts; keep them in sync if those messages
358
- // change. Resolutions mirror the published troubleshooting guide.
359
- export function fetchFailureGuidance(message, url) {
360
- const ref = 'https://bonsai.rhino-inquisitor.com/troubleshooting';
361
- // Name the pipe step explicitly: bare `import --stdin` blocks waiting for input, so a reader (or
362
- // an agent) running the hint verbatim would hang. Show the content being piped in from a file.
363
- const importHint = `Open it in a browser, save the page, then import it: cat page.md | bonsai import ${url} --stdin`;
364
- // 401/403: an auth wall or anti-scraping WAF. Bonsai has no authenticated-fetch path in v1.
365
- if (/failed with status 40[13]\b/.test(message)) {
366
- return {
367
- suggestions: ['The page requires authentication or blocks automated requests.', importHint],
368
- ref,
369
- };
370
- }
371
- if (/failed with status 404\b/.test(message)) {
372
- return { suggestions: ['Check the URL is correct and the page still exists.'] };
373
- }
374
- if (/failed with status 5\d\d\b/.test(message)) {
375
- return {
376
- suggestions: ['The server returned an error. Retry later or verify the host is healthy.'],
377
- };
378
- }
379
- // Server returned a non-HTML body (JSON, binary, or no Content-Type at all). Scope the opener to
380
- // the real failure: --rendered does produce HTML, so "only scrapes HTML" would read as misleading.
381
- if (/Rejected content type|does not look like HTML/.test(message)) {
382
- return {
383
- suggestions: [
384
- 'The server returned a non-HTML response (e.g. JSON or binary).',
385
- 'If the page is rendered by client-side JavaScript, retry with --rendered.',
386
- importHint,
387
- ],
388
- ref,
389
- };
390
- }
391
- if (/DNS resolution failed/.test(message)) {
392
- return {
393
- suggestions: ['Check the hostname is spelled correctly and resolves on a public network.'],
394
- };
395
- }
396
- // Chrome's own network stack hit a proxy-enforced sandbox egress block (browser.ts's
397
- // describeNavigationFailure already explains the cause and the fix); only a workaround and the
398
- // docs ref are added here.
399
- if (message.includes(SANDBOX_EGRESS_ERROR_MARKER)) {
400
- return { suggestions: [importHint], ref };
401
- }
402
- // A configured proxy (HTTPS_PROXY/HTTP_PROXY) refused to tunnel to this host — distinct from
403
- // the destination's own 401/403 above, which is the site itself rejecting the request. Shared
404
- // with fetcher.ts's retry-fallback check so the two stay in sync by construction.
405
- if (PROXY_TUNNEL_REJECTION_PATTERN.test(message)) {
406
- return {
407
- suggestions: [
408
- "The configured proxy (HTTPS_PROXY/HTTP_PROXY) won't reach this host. Check its allowlist, or add the host to NO_PROXY to bypass the proxy for it.",
409
- importHint,
410
- ],
411
- ref,
412
- };
413
- }
414
- // The connection failed before any HTTP response was received — network-down, DNS, TLS, or a
415
- // proxy issue not specifically matched above. Node/undici's fetch reports all of these as a
416
- // generic "fetch failed" TypeError with no further suffix; the negative lookahead keeps this
417
- // from also matching assertOk's "Fetch failed with status NNN ..." (a real HTTP response was
418
- // received in that case, so it isn't a transport failure at all).
419
- if (/^fetch failed\b(?!\s+with status)/i.test(message)) {
420
- return {
421
- suggestions: [
422
- 'The connection failed before a response was received. Check network connectivity and any HTTPS_PROXY/HTTP_PROXY/NO_PROXY settings.',
423
- importHint,
424
- ],
425
- ref,
426
- };
427
- }
428
- // A hostname that only resolves to a private/local IP at request time (literal private addresses
429
- // are rejected earlier with exit 2). Blocked to prevent SSRF; only public hosts can be fetched.
430
- if (/blocked local or private target/.test(message)) {
431
- return {
432
- suggestions: [
433
- 'The hostname resolves to a private or local address, which is blocked to prevent SSRF. Only public http(s) hosts can be fetched.',
434
- ],
435
- ref,
436
- };
437
- }
438
- return undefined;
439
- }
440
- // Copies Phase 2 capability provenance from a capture outcome onto the artifact metadata.
441
- function applyCaptureMetadata(artifact, capture) {
442
- const meta = artifact.metadata;
443
- meta.capture_method = capture.captureMethod;
444
- meta.docs_engine = capture.capabilities.docsEngine ?? null;
445
- meta.docs_framework = capture.capabilities.framework ?? null;
446
- meta.source_doc_url = capture.sourceDocUrl;
447
- meta.search_provider = capture.capabilities.search?.provider ?? null;
448
- if (capture.extraction.isIndexHub)
449
- meta.artifact_type = 'index';
450
- if (capture.sourceDocUrl && !meta.source_urls.includes(capture.sourceDocUrl)) {
451
- meta.source_urls.push(capture.sourceDocUrl);
452
- }
453
- }
454
- function handleStaleRevalidationResult(command, revalResult) {
455
- if (revalResult.status !== 'stale')
456
- return;
457
- if (revalResult.allowed) {
458
- command.warn(`Serving stale content within grace period: revalidation failed (${revalResult.error}).`);
459
- }
460
- else {
461
- command.warn(`Serving stale content within grace period (exit 5): revalidation failed (${revalResult.error}).`);
462
- process.exitCode = 5;
463
120
  }
464
121
  }
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import { BaseCommand } from '../base-command.js';
3
+ /** Import research notes from a file or stdin. */
3
4
  export default class ResearchImport extends BaseCommand<typeof ResearchImport> {
4
5
  static id: string;
5
6
  static summary: string;
@@ -20,22 +21,16 @@ export default class ResearchImport extends BaseCommand<typeof ResearchImport> {
20
21
  tier: import("@oclif/core/interfaces").OptionFlag<"stable" | "standard" | "volatile", import("@oclif/core/interfaces").CustomOptions>;
21
22
  'source-url': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
23
  ttl: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
24
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
23
25
  storage: import("@oclif/core/interfaces").OptionFlag<"global" | "project" | undefined, import("@oclif/core/interfaces").CustomOptions>;
24
26
  };
25
27
  static stdoutIsPrimaryData: boolean;
26
28
  protected stdinIsInteractive(): boolean;
27
29
  private readStdin;
28
- private getSourceUrls;
29
- private validateSourceMode;
30
30
  protected fsExistsSync(filePath: string): boolean;
31
31
  protected fsStatSync(filePath: string): fs.Stats;
32
32
  protected fsReadFileSync(filePath: string): string;
33
- private stdinImportSuggestions;
34
- /** Non-interactive stdin that never receives data (open pipe with no writer) must not hang forever. */
35
- private readStdinWithGuard;
36
- private readAndValidateFile;
37
- private readAndValidateInput;
38
- private deriveImportCacheKey;
39
- private buildImportArtifact;
33
+ private serviceArgs;
34
+ private serviceFlags;
40
35
  run(): Promise<unknown>;
41
36
  }