@taurgis/bonsai 1.0.4 → 1.0.5

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 (55) hide show
  1. package/README.md +28 -2
  2. package/agents/agents/salesforce-docs-researcher.agent.md +117 -0
  3. package/agents/hooks/README.md +62 -0
  4. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +22 -0
  5. package/agents/hooks/claude-code/settings.json +16 -0
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +18 -0
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +22 -0
  8. package/agents/hooks/codex/hooks.json +28 -0
  9. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +21 -0
  10. package/agents/hooks/cursor/hooks.json +12 -0
  11. package/agents/hooks/github-copilot/bonsai-web-fetch.json +23 -0
  12. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +19 -0
  13. package/agents/instructions/salesforce-research.instructions.md +57 -0
  14. package/agents/instructions/web-research.instructions.md +2 -1
  15. package/bin/cli.mjs +13 -3
  16. package/dist/base-command.d.ts +20 -0
  17. package/dist/base-command.js +27 -1
  18. package/dist/commands/config/get.js +2 -2
  19. package/dist/commands/config/list.js +2 -2
  20. package/dist/commands/config/set.js +2 -2
  21. package/dist/commands/config/unset.js +2 -2
  22. package/dist/commands/fetch.d.ts +5 -0
  23. package/dist/commands/fetch.js +81 -16
  24. package/dist/commands/import.d.ts +1 -0
  25. package/dist/commands/import.js +38 -6
  26. package/dist/commands/list.js +18 -3
  27. package/dist/commands/prune.js +22 -6
  28. package/dist/commands/search.js +21 -22
  29. package/dist/commands/status.js +10 -9
  30. package/dist/hooks/command-not-found/suggest.d.ts +9 -0
  31. package/dist/hooks/command-not-found/suggest.js +93 -0
  32. package/dist/lib/config/index.d.ts +1 -1
  33. package/dist/lib/config/index.js +1 -1
  34. package/dist/lib/config/resolve.d.ts +7 -0
  35. package/dist/lib/config/resolve.js +23 -0
  36. package/dist/lib/research/browser.d.ts +12 -0
  37. package/dist/lib/research/browser.js +40 -3
  38. package/dist/lib/research/docs/capabilities.d.ts +1 -1
  39. package/dist/lib/research/docs/discovery-index.d.ts +14 -0
  40. package/dist/lib/research/docs/discovery-index.js +112 -0
  41. package/dist/lib/research/docs/machine-readable.d.ts +2 -2
  42. package/dist/lib/research/docs/machine-readable.js +3 -7
  43. package/dist/lib/research/docs/remote-search-runner.js +61 -1
  44. package/dist/lib/research/docs/sections.d.ts +1 -0
  45. package/dist/lib/research/docs/sections.js +19 -1
  46. package/dist/lib/research/freshness.d.ts +9 -1
  47. package/dist/lib/research/freshness.js +24 -2
  48. package/dist/lib/research/keywords.d.ts +10 -3
  49. package/dist/lib/research/keywords.js +32 -5
  50. package/dist/lib/research/secret-scan.js +4 -2
  51. package/dist/lib/research/url.js +5 -2
  52. package/dist/lib/text.d.ts +33 -0
  53. package/dist/lib/text.js +63 -0
  54. package/oclif.manifest.json +8 -8
  55. package/package.json +17 -3
@@ -1,10 +1,10 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { ConfigCommand, configScopeFlags } from './base.js';
3
- import { writeUserConfig, writeProjectConfig } from '../../lib/config/index.js';
3
+ import { writeUserConfig, writeProjectConfig, validKeysHint } from '../../lib/config/index.js';
4
4
  export default class ConfigUnset extends ConfigCommand {
5
5
  static id = 'config unset';
6
6
  static summary = 'Remove a research configuration key.';
7
- static description = 'Delete a key from user-level config (default) or project-level config (--local), restoring the built-in default.\n\nValid keys: storage.';
7
+ static description = `Delete a key from user-level config (default) or project-level config (--local), restoring the built-in default.\n\nValid keys: ${validKeysHint()}.`;
8
8
  static examples = [
9
9
  {
10
10
  description: 'Remove the user-level storage setting',
@@ -30,8 +30,13 @@ export default class FetchCommand extends BaseCommand<typeof FetchCommand> {
30
30
  private executeCacheMiss;
31
31
  private buildResultData;
32
32
  private validateDurationFlags;
33
+ private emitFetchError;
33
34
  private resolveArtifact;
34
35
  private persistFreshArtifact;
35
36
  private persistSectionsIfFresh;
36
37
  run(): Promise<unknown>;
37
38
  }
39
+ export declare function fetchFailureGuidance(message: string, url: string): {
40
+ suggestions: string[];
41
+ ref?: string;
42
+ } | undefined;
@@ -6,7 +6,7 @@ import { BaseCommand } from '../base-command.js';
6
6
  import { writeArtifact, getArtifactPath } from '../lib/research/storage.js';
7
7
  import { writeArtifactSecurely } from '../lib/research/secure-write.js';
8
8
  import { loadSummaryLevel } from '../lib/config/index.js';
9
- import { evaluateFreshness, parseTtlToMs, checkMaxAgeExpired } from '../lib/research/freshness.js';
9
+ import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
10
10
  import { revalidateCache, createArtifactFromFetch } from '../lib/research/revalidate.js';
11
11
  import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
12
12
  import { fetchRenderedHtml } from '../lib/research/browser.js';
@@ -24,7 +24,7 @@ export default class FetchCommand extends BaseCommand {
24
24
  static id = 'fetch';
25
25
  static hidden = true;
26
26
  static summary = 'An advanced, locally cached web research tool optimized for LLM ingestion.';
27
- 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.';
27
+ 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>`.';
28
28
  static examples = [
29
29
  {
30
30
  description: 'Research a URL with detailed output, tagged with topic and tags',
@@ -64,7 +64,7 @@ export default class FetchCommand extends BaseCommand {
64
64
  })(),
65
65
  ttl: Flags.string({
66
66
  char: 'l',
67
- description: 'Predicted lifespan of the data (e.g., "2h", "7d", "3m").',
67
+ description: 'Predicted lifespan: number + h/d/w/m/y (m = months), e.g. "2h", "7d", "6m".',
68
68
  }),
69
69
  'max-age': Flags.string({
70
70
  description: 'Maximum age of cache to accept (e.g., "1d", "30d").',
@@ -183,20 +183,22 @@ export default class FetchCommand extends BaseCommand {
183
183
  }
184
184
  // Validates the duration flags up front, exiting with code 2 on a malformed value.
185
185
  validateDurationFlags(ttl, maxAge) {
186
- for (const [label, value] of [
187
- ['TTL', ttl],
188
- ['max-age', maxAge],
189
- ]) {
190
- if (!value)
191
- continue;
192
- try {
193
- parseTtlToMs(value);
194
- }
195
- catch (err) {
196
- this.error(`Invalid ${label}: ${err.message}`, { exit: 2 });
197
- }
186
+ for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
187
+ if (msg)
188
+ this.error(msg, { exit: 2 });
198
189
  }
199
190
  }
191
+ // Re-emit a runtime fetch failure with actionable next steps. Deep fetch/extract code throws plain
192
+ // Errors that otherwise reach the user as a bare "what broke" line with no "what to do"; this
193
+ // attaches recovery hints (e.g. import an auth-blocked page). Everything reaching here is a runtime
194
+ // failure — validation errors (bad flags/URL) exit before the try — so the contract's runtime code
195
+ // (1) applies. Suggestions render on stderr for humans only; under --json the envelope carries just
196
+ // the message (toErrorJson), so machine output is unchanged.
197
+ emitFetchError(err, url) {
198
+ const message = err instanceof Error ? err.message : String(err);
199
+ const guidance = fetchFailureGuidance(message, url);
200
+ this.error(message, { exit: 1, suggestions: guidance?.suggestions, ref: guidance?.ref });
201
+ }
200
202
  // Serves the cache when a fresh/revalidatable entry exists, otherwise fetches fresh. Reads fall
201
203
  // back project→global; the resolved artifact is returned with the dir it lives in / landed in.
202
204
  async resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel) {
@@ -210,9 +212,12 @@ export default class FetchCommand extends BaseCommand {
210
212
  }
211
213
  const artifact = await this.executeCacheMiss(normalizedUrl, currentTime, cacheKey, summaryLevel);
212
214
  const { dir, redirectedToGlobal } = this.persistFreshArtifact(roots, tmpDir, cacheKey, artifact);
215
+ // No entry existed at lookup, so there is no prior freshness to report. 'none' (not
216
+ // 'stale_expired') keeps the field honest — nothing expired; the page was simply uncached and
217
+ // has now been fetched fresh. Mirrors the `status` command's miss reporting.
213
218
  return {
214
219
  cacheStatus: 'miss',
215
- freshnessState: 'stale_expired',
220
+ freshnessState: 'none',
216
221
  artifact,
217
222
  storageDir: dir,
218
223
  redirectedToGlobal,
@@ -277,6 +282,13 @@ export default class FetchCommand extends BaseCommand {
277
282
  }
278
283
  return resultData;
279
284
  }
285
+ catch (err) {
286
+ // Stale-serve (exit 5) signals via process.exitCode and never throws, so it bypasses this
287
+ // path; only genuine fetch/extract failures land here and get classified guidance. Use the
288
+ // normalized URL in hints so the copy-paste command is canonical (and never echoes raw,
289
+ // unsanitized user input back to the terminal).
290
+ this.emitFetchError(err, normalizedUrl);
291
+ }
280
292
  finally {
281
293
  if (tmpDir) {
282
294
  rmSync(tmpDir, { recursive: true, force: true });
@@ -284,6 +296,59 @@ export default class FetchCommand extends BaseCommand {
284
296
  }
285
297
  }
286
298
  }
299
+ // Maps a runtime fetch failure to recovery steps keyed off its message. Returns undefined for
300
+ // unrecognized failures, which then surface with their original message and no extra hint. The
301
+ // patterns match the thrown text in fetcher.ts/browser.ts; keep them in sync if those messages
302
+ // change. Resolutions mirror the published troubleshooting guide.
303
+ export function fetchFailureGuidance(message, url) {
304
+ const ref = 'https://taurgis.github.io/bonsai/troubleshooting';
305
+ // Name the pipe step explicitly: bare `import --stdin` blocks waiting for input, so a reader (or
306
+ // an agent) running the hint verbatim would hang. Show the content being piped in from a file.
307
+ const importHint = `Open it in a browser, save the page, then import it: cat page.md | bonsai import ${url} --stdin`;
308
+ // 401/403: an auth wall or anti-scraping WAF. Bonsai has no authenticated-fetch path in v1.
309
+ if (/failed with status 40[13]\b/.test(message)) {
310
+ return {
311
+ suggestions: ['The page requires authentication or blocks automated requests.', importHint],
312
+ ref,
313
+ };
314
+ }
315
+ if (/failed with status 404\b/.test(message)) {
316
+ return { suggestions: ['Check the URL is correct and the page still exists.'] };
317
+ }
318
+ if (/failed with status 5\d\d\b/.test(message)) {
319
+ return {
320
+ suggestions: ['The server returned an error. Retry later or verify the host is healthy.'],
321
+ };
322
+ }
323
+ // Server returned a non-HTML body (JSON, binary, or no Content-Type at all). Scope the opener to
324
+ // the real failure: --rendered does produce HTML, so "only scrapes HTML" would read as misleading.
325
+ if (/Rejected content type|does not look like HTML/.test(message)) {
326
+ return {
327
+ suggestions: [
328
+ 'The server returned a non-HTML response (e.g. JSON or binary).',
329
+ 'If the page is rendered by client-side JavaScript, retry with --rendered.',
330
+ importHint,
331
+ ],
332
+ ref,
333
+ };
334
+ }
335
+ if (/DNS resolution failed/.test(message)) {
336
+ return {
337
+ suggestions: ['Check the hostname is spelled correctly and resolves on a public network.'],
338
+ };
339
+ }
340
+ // A hostname that only resolves to a private/local IP at request time (literal private addresses
341
+ // are rejected earlier with exit 2). Blocked to prevent SSRF; only public hosts can be fetched.
342
+ if (/blocked local or private target/.test(message)) {
343
+ return {
344
+ suggestions: [
345
+ 'The hostname resolves to a private or local address, which is blocked to prevent SSRF. Only public http(s) hosts can be fetched.',
346
+ ],
347
+ ref,
348
+ };
349
+ }
350
+ return undefined;
351
+ }
287
352
  // Copies Phase 2 capability provenance from a capture outcome onto the artifact metadata.
288
353
  function applyCaptureMetadata(artifact, capture) {
289
354
  const meta = artifact.metadata;
@@ -23,6 +23,7 @@ export default class ResearchImport extends BaseCommand<typeof ResearchImport> {
23
23
  storage: import("@oclif/core/interfaces").OptionFlag<"global" | "project" | undefined, import("@oclif/core/interfaces").CustomOptions>;
24
24
  };
25
25
  static stdoutIsPrimaryData: boolean;
26
+ protected stdinIsInteractive(): boolean;
26
27
  private readStdin;
27
28
  private getSourceUrls;
28
29
  private validateSourceMode;
@@ -1,4 +1,4 @@
1
- import { Args, Flags } from '@oclif/core';
1
+ import { Args, Errors, Flags } from '@oclif/core';
2
2
  import { createHash } from 'node:crypto';
3
3
  import * as fs from 'node:fs';
4
4
  import * as path from 'node:path';
@@ -34,6 +34,10 @@ export default class ResearchImport extends BaseCommand {
34
34
  static args = {
35
35
  url: Args.string({
36
36
  required: false,
37
+ // oclif fills an omitted optional arg from piped stdin unless ignoreStdin is set. Without
38
+ // this, the Markdown piped for `--stdin` is swallowed into `url`, making multi-source import
39
+ // (`import --stdin --source-url ...`) wrongly look like it also got a positional URL.
40
+ ignoreStdin: true,
37
41
  description: 'The single source URL of the page (only for single-source import).',
38
42
  }),
39
43
  };
@@ -78,6 +82,11 @@ export default class ResearchImport extends BaseCommand {
78
82
  })(),
79
83
  };
80
84
  static stdoutIsPrimaryData = true;
85
+ // Isolated so tests can force the interactive branch; `isTTY` is `true` only on a real terminal and
86
+ // falsy (undefined) for pipes, files, and /dev/null. See https://nodejs.org/api/process.html#processstdin
87
+ stdinIsInteractive() {
88
+ return process.stdin.isTTY === true;
89
+ }
81
90
  async readStdin(limitBytes = 1024 * 1024) {
82
91
  return new Promise((resolve, reject) => {
83
92
  let data = '';
@@ -164,16 +173,34 @@ export default class ResearchImport extends BaseCommand {
164
173
  }
165
174
  async readAndValidateInput() {
166
175
  if (this.flags.stdin) {
176
+ // On an interactive terminal with nothing piped, reading stdin blocks forever waiting for EOF —
177
+ // a silent hang with no feedback. Fail fast with a usage error instead; piped/redirected input
178
+ // (the agent and CI path) has isTTY falsy and reads normally.
179
+ if (this.stdinIsInteractive()) {
180
+ this.error('No data piped to --stdin.', {
181
+ exit: 2,
182
+ suggestions: [
183
+ 'Pipe Markdown content, e.g. cat notes.md | bonsai import <url> --stdin',
184
+ 'Or read from a file: bonsai import <url> --file notes.md',
185
+ ],
186
+ });
187
+ }
188
+ // Initialized so the post-catch read below never depends on this.error()'s `never` return type
189
+ // for definite assignment; the empty-content guard rejects '' as a usage error anyway.
190
+ let rawInput = '';
167
191
  try {
168
- const rawInput = await this.readStdin();
169
- if (!rawInput.trim()) {
170
- this.error('Empty stdin content provided.', { exit: 2 });
171
- }
172
- return rawInput;
192
+ rawInput = await this.readStdin();
173
193
  }
174
194
  catch (err) {
195
+ // Genuine stream/size failure reading stdin → I/O failure (exit 1).
175
196
  this.error(err.message, { exit: 1 });
176
197
  }
198
+ // Empty input is a usage error (exit 2), not an I/O failure — keep it out of the
199
+ // catch above so its exit code is not clobbered to 1.
200
+ if (!rawInput.trim()) {
201
+ this.error('Empty stdin content provided.', { exit: 2 });
202
+ }
203
+ return rawInput;
177
204
  }
178
205
  if (this.flags.file) {
179
206
  const filePath = path.resolve(this.flags.file);
@@ -181,6 +208,11 @@ export default class ResearchImport extends BaseCommand {
181
208
  return this.readAndValidateFile(filePath);
182
209
  }
183
210
  catch (err) {
211
+ // readAndValidateFile raises usage errors (missing/not-a-file/empty) via this.error,
212
+ // which throw CLIErrors carrying their own exit code and message — preserve those.
213
+ // Only an unexpected read failure becomes a wrapped I/O failure (exit 1).
214
+ if (err instanceof Errors.CLIError)
215
+ throw err;
184
216
  this.error(`Failed to read file: ${err.message}`, { exit: 1 });
185
217
  }
186
218
  }
@@ -3,6 +3,9 @@ import { BaseCommand } from '../base-command.js';
3
3
  import { scanCacheDirs } from '../lib/research/storage.js';
4
4
  import { loadStoreRoots } from '../lib/research/store-roots.js';
5
5
  import { evaluateFreshness } from '../lib/research/freshness.js';
6
+ import { resultListHeading, truncationNotice } from '../lib/text.js';
7
+ // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at 100.
8
+ const LIST_LABELS = { noun: 'cached research', order: 'first', maxLimit: 100 };
6
9
  export default class ResearchList extends BaseCommand {
7
10
  static id = 'list';
8
11
  static summary = 'List cached research artifacts by metadata filters.';
@@ -82,6 +85,12 @@ export default class ResearchList extends BaseCommand {
82
85
  return scanCacheDirs(readRoots, (artifact, filePath) => {
83
86
  if (artifact.metadata.status !== 'active')
84
87
  return null;
88
+ // Section children are sub-chunks of a page, not artifacts a user "has" — they would flood the
89
+ // listing (one page yields dozens) and aren't in the documented source/research_note contract.
90
+ // They stay discoverable through `search` (which ranks them) and `inspect` (which lists a
91
+ // page's sections). `list` answers "what pages/notes do I have?", so keep it page-level.
92
+ if (artifact.metadata.artifact_type === 'section')
93
+ return null;
85
94
  const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
86
95
  if (!this.matchesFilters(artifact.metadata, freshness))
87
96
  return null;
@@ -101,14 +110,14 @@ export default class ResearchList extends BaseCommand {
101
110
  };
102
111
  });
103
112
  }
104
- logListResults(finalResults) {
113
+ logListResults(finalResults, totalMatched) {
105
114
  if (this.jsonEnabled())
106
115
  return;
107
116
  if (finalResults.length === 0) {
108
117
  this.log('No cached research entries found matching filters.');
109
118
  return;
110
119
  }
111
- this.log(`Found ${finalResults.length} cached research entries:\n`);
120
+ this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
112
121
  finalResults.forEach((res, index) => {
113
122
  this.log(`${index + 1}. [${res.topic || 'No Topic'}] Key: ${res.cacheKey}`);
114
123
  this.log(` Type: ${res.artifactType} | Freshness: ${res.freshness}`);
@@ -132,7 +141,13 @@ export default class ResearchList extends BaseCommand {
132
141
  return timeB - timeA;
133
142
  });
134
143
  const finalResults = results.slice(0, this.flags.limit);
135
- this.logListResults(finalResults);
144
+ // Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
145
+ // always emits, even in --json) without touching the stdout envelope. Human mode already shows
146
+ // it in the heading, so only warn under --json.
147
+ const notice = truncationNotice(results.length, finalResults.length, LIST_LABELS);
148
+ if (notice && this.jsonEnabled())
149
+ this.warn(notice);
150
+ this.logListResults(finalResults, results.length);
136
151
  return finalResults;
137
152
  }
138
153
  }
@@ -4,7 +4,8 @@ import { join } from 'node:path';
4
4
  import { BaseCommand } from '../base-command.js';
5
5
  import { scanCacheDir } from '../lib/research/storage.js';
6
6
  import { loadStoreRoots } from '../lib/research/store-roots.js';
7
- import { parseTtlToMs } from '../lib/research/freshness.js';
7
+ import { parseTtlToMs, durationFlagError } from '../lib/research/freshness.js';
8
+ import { pluralize } from '../lib/text.js';
8
9
  export default class ResearchPrune extends BaseCommand {
9
10
  static id = 'prune';
10
11
  static summary = 'Clean up old or inactive research cache entries.';
@@ -48,6 +49,15 @@ export default class ResearchPrune extends BaseCommand {
48
49
  if (!this.flags['dry-run'] && !this.flags.yes) {
49
50
  this.error('Safety check: Please specify --yes to confirm pruning, or --dry-run to list files that would be deleted.', { exit: 2 });
50
51
  }
52
+ // Validate durations up front: scanCacheDir swallows per-file errors, so a malformed
53
+ // --older-than/--inactive would otherwise be silently ignored and report "0 entries".
54
+ for (const msg of [
55
+ durationFlagError('--older-than', this.flags['older-than']),
56
+ durationFlagError('--inactive', this.flags.inactive),
57
+ ]) {
58
+ if (msg)
59
+ this.error(msg, { exit: 2 });
60
+ }
51
61
  }
52
62
  shouldPrune(meta, currentTime) {
53
63
  const fetched = meta.fetched_at ? new Date(meta.fetched_at).getTime() : 0;
@@ -109,24 +119,30 @@ export default class ResearchPrune extends BaseCommand {
109
119
  const filesToPrune = this.findPruneCandidates(roots.readRoots, currentTime);
110
120
  const dryRun = this.flags['dry-run'];
111
121
  const count = filesToPrune.length;
122
+ // Track deletions actually performed so the JSON envelope and human output agree even when an
123
+ // unlink fails (e.g. a permission error). Reporting candidate count as `prunedCount` would
124
+ // overstate success and mislead an agent branching on the result.
125
+ let prunedCount = 0;
112
126
  if (dryRun) {
113
127
  if (!this.jsonEnabled()) {
114
- this.log(`[Dry Run] Found ${count} research cache entries that would be pruned:\n`);
128
+ const noun = pluralize(count, 'entry', 'entries');
129
+ this.log(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`);
115
130
  filesToPrune.forEach((f) => {
116
131
  this.log(`- [${f.topic || 'No Topic'}] Key: ${f.cacheKey} (${f.url || 'Imported note'})`);
117
132
  });
118
133
  }
119
134
  }
120
135
  else {
121
- const prunedCount = this.deletePruneCandidates(filesToPrune);
136
+ prunedCount = this.deletePruneCandidates(filesToPrune);
122
137
  if (!this.jsonEnabled()) {
123
- this.log(`Successfully pruned ${prunedCount} of ${count} research cache entries.`);
138
+ const noun = pluralize(count, 'entry', 'entries');
139
+ this.log(`Successfully pruned ${prunedCount} of ${count} research cache ${noun}.`);
124
140
  }
125
141
  }
126
142
  return {
127
143
  dryRun,
128
- prunedCount: dryRun ? 0 : filesToPrune.length,
129
- candidateCount: filesToPrune.length,
144
+ prunedCount,
145
+ candidateCount: count,
130
146
  files: filesToPrune.map((f) => ({
131
147
  cacheKey: f.cacheKey,
132
148
  path: f.path,
@@ -1,11 +1,18 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
3
  import { loadSearchableArtifacts } from '../lib/research/search-index.js';
4
+ import { levenshtein, resultListHeading, truncationNotice, } from '../lib/text.js';
4
5
  import { loadStoreRoots } from '../lib/research/store-roots.js';
5
6
  import { evaluateFreshness } from '../lib/research/freshness.js';
6
7
  import { detectSite } from '../sites/index.js';
7
8
  import { fetchStaticHtml, fetchText, postJson } from '../lib/research/fetcher.js';
8
9
  import { runRemoteDocsSearch, } from '../lib/research/docs/remote-search-runner.js';
10
+ // Results are ranked, so the truncation word is "top"; --limit caps at 50.
11
+ const SEARCH_LABELS = {
12
+ noun: 'matching cached research',
13
+ order: 'top',
14
+ maxLimit: 50,
15
+ };
9
16
  const REMOTE_SEARCH_DEPS = {
10
17
  fetchStatic: async (url) => {
11
18
  const res = await fetchStaticHtml(url);
@@ -54,7 +61,7 @@ export default class ResearchSearch extends BaseCommand {
54
61
  description: 'Search a documentation site directly (e.g. help.salesforce.com) instead of the local cache. Requires a site module that implements search.',
55
62
  }),
56
63
  remote: Flags.string({
57
- description: 'Discover uncached docs via a site’s public search (Algolia DocSearch, MkDocs/Sphinx/Just-the-Docs index). Pass a docs page URL; falls back to local cache search if no connector applies.',
64
+ description: 'Discover uncached docs via a site’s public search or index (Algolia DocSearch, MkDocs/Sphinx/Just-the-Docs, llms.txt, sitemap.xml). Pass a docs page URL; falls back to local cache search if no connector applies.',
58
65
  }),
59
66
  };
60
67
  static stdoutIsPrimaryData = true;
@@ -182,10 +189,14 @@ export default class ResearchSearch extends BaseCommand {
182
189
  }
183
190
  return results;
184
191
  }
185
- logSearchResults(finalResults) {
192
+ logSearchResults(finalResults, totalMatched) {
186
193
  if (this.jsonEnabled())
187
194
  return;
188
- this.log(`Found ${finalResults.length} matching cached research entries:\n`);
195
+ if (finalResults.length === 0) {
196
+ this.log('No matching cached research entries found.');
197
+ return;
198
+ }
199
+ this.log(`${resultListHeading(totalMatched, finalResults.length, SEARCH_LABELS)}\n`);
189
200
  finalResults.forEach((res, index) => {
190
201
  this.log(`${index + 1}. [${res.topic || 'No Topic'}] Score: ${res.score}`);
191
202
  this.log(` Cache Key: ${res.cacheKey}`);
@@ -254,7 +265,13 @@ export default class ResearchSearch extends BaseCommand {
254
265
  const results = this.scanCacheDirForResults(roots.readRoots, queryTerms, currentTime);
255
266
  results.sort((a, b) => b.score - a.score);
256
267
  const finalResults = results.slice(0, this.flags.limit);
257
- this.logSearchResults(finalResults);
268
+ // Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
269
+ // always emits, even in --json) without touching the stdout envelope. Human mode already shows
270
+ // it in the heading, so only warn under --json.
271
+ const notice = truncationNotice(results.length, finalResults.length, SEARCH_LABELS);
272
+ if (notice && this.jsonEnabled())
273
+ this.warn(notice);
274
+ this.logSearchResults(finalResults, results.length);
258
275
  return finalResults;
259
276
  }
260
277
  }
@@ -305,24 +322,6 @@ function scoreSingleTerm(term, topic, tags, sourceUrl, sourceUrls, summ, comp) {
305
322
  }
306
323
  return termScore;
307
324
  }
308
- function levenshtein(s1, s2) {
309
- if (s1.length < s2.length)
310
- return levenshtein(s2, s1);
311
- if (s2.length === 0)
312
- return s1.length;
313
- let prevRow = Array.from({ length: s2.length + 1 }, (_, i) => i);
314
- for (let i = 0; i < s1.length; i++) {
315
- const currRow = [i + 1];
316
- for (let j = 0; j < s2.length; j++) {
317
- const deletions = prevRow[j + 1] + 1;
318
- const insertions = currRow[j] + 1;
319
- const substitutions = prevRow[j] + (s1[i] === s2[j] ? 0 : 1);
320
- currRow.push(Math.min(deletions, insertions, substitutions));
321
- }
322
- prevRow = currRow;
323
- }
324
- return prevRow[s2.length];
325
- }
326
325
  function isFuzzyMatch(term, target) {
327
326
  if (term.length < 4 || target.length < 4)
328
327
  return false;
@@ -1,7 +1,7 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
3
  import { getArtifactPath } from '../lib/research/storage.js';
4
- import { evaluateFreshness, checkMaxAgeExpired } from '../lib/research/freshness.js';
4
+ import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
5
5
  import { resolveResearchTarget } from '../lib/research/resolve-target.js';
6
6
  function resolveFreshness(cached, currentTime, ttl, maxAge) {
7
7
  const isMaxAgeExpired = maxAge ? checkMaxAgeExpired(cached, currentTime, maxAge) : false;
@@ -11,7 +11,7 @@ function resolveFreshness(cached, currentTime, ttl, maxAge) {
11
11
  }
12
12
  function describeCacheStatus(cached, currentTime, ttl, maxAge) {
13
13
  if (!cached) {
14
- return { status: 'miss', freshness: 'stale_expired', action: 'would_fetch' };
14
+ return { status: 'miss', freshness: 'none', action: 'would_fetch' };
15
15
  }
16
16
  const freshness = resolveFreshness(cached, currentTime, ttl, maxAge);
17
17
  return freshness === 'fresh'
@@ -46,6 +46,12 @@ export default class ResearchStatus extends BaseCommand {
46
46
  async run() {
47
47
  const { url } = this.args;
48
48
  const { ttl, tier, 'max-age': maxAge } = this.flags;
49
+ // Validate the duration flags up front so a malformed value reports the exact flag that is
50
+ // wrong, rather than later surfacing as a misattributed parse failure.
51
+ for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
52
+ if (msg)
53
+ this.error(msg, { exit: 2 });
54
+ }
49
55
  let target;
50
56
  try {
51
57
  target = resolveResearchTarget({
@@ -61,13 +67,8 @@ export default class ResearchStatus extends BaseCommand {
61
67
  const { cacheKey, located, normalizedUrl, roots } = target;
62
68
  const cached = located?.artifact ?? null;
63
69
  const currentTime = new Date();
64
- let result;
65
- try {
66
- result = describeCacheStatus(cached, currentTime, ttl, maxAge);
67
- }
68
- catch (err) {
69
- this.error(`Invalid max-age: ${err.message}`, { exit: 2 });
70
- }
70
+ // Both duration flags were validated above, so freshness resolution cannot throw on a parse error.
71
+ const result = describeCacheStatus(cached, currentTime, ttl, maxAge);
71
72
  // On a hit, report where it actually lives; on a miss, where a fetch would write it.
72
73
  const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
73
74
  if (!this.jsonEnabled()) {
@@ -0,0 +1,9 @@
1
+ import { type Hook } from '@oclif/core';
2
+ /**
3
+ * Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
4
+ * throwing here (via `this.error`) replaces that default with a more helpful message. A correction
5
+ * is offered only when the nearest command is a close match, and it is never auto-run — the user
6
+ * re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
7
+ */
8
+ declare const hook: Hook<'command_not_found'>;
9
+ export default hook;
@@ -0,0 +1,93 @@
1
+ import { toConfiguredId } from '@oclif/core';
2
+ import { closestMatch } from '../../lib/text.js';
3
+ // Nearest command must be within this edit distance to be offered as a correction. Bonsai's command
4
+ // ids are short, so 3 catches realistic typos ("statuss", "improt") without suggesting an unrelated
5
+ // command for input that resembles nothing.
6
+ const MAX_SUGGESTION_DISTANCE = 3;
7
+ /**
8
+ * The nearest visible command to a typo, plus how many leading segments of the attempted id name
9
+ * the command. With `topicSeparator: ' '`, oclif folds every leading token of an unknown command
10
+ * into one colon-delimited id, so `bonsai serch hello` arrives as `serch:hello`. Matching that whole
11
+ * string never finds a short command, so walk prefixes longest→shortest and suggest off the closest:
12
+ * `serch:hello` resolves to nothing, but its prefix `serch` resolves to `search`. The matched prefix
13
+ * length doubles as the command-segment count, so a folded-in positional arg is dropped from display.
14
+ */
15
+ function findSuggestion(segments, visibleIds) {
16
+ for (let n = segments.length; n >= 1; n--) {
17
+ const suggestion = closestMatch(segments.slice(0, n).join(':'), visibleIds, MAX_SUGGESTION_DISTANCE);
18
+ if (suggestion)
19
+ return { suggestion, commandSegments: n };
20
+ }
21
+ return { suggestion: null, commandSegments: 0 };
22
+ }
23
+ /**
24
+ * How many leading segments name the command when no suggestion exists: the known-topic chain plus
25
+ * the first unknown segment. Keeps `config frobnicate` from rendering as the misleading bare `config`
26
+ * (a real topic) while still dropping a positional arg from `frobnicate banana`.
27
+ */
28
+ function topicChainLength(segments, config) {
29
+ let i = 0;
30
+ while (i < segments.length - 1 && config.findTopic(segments.slice(0, i + 1).join(':')))
31
+ i++;
32
+ return i + 1;
33
+ }
34
+ // Dot-separated, non-empty labels: a real domain (`docs.nestjs.com`) or IPv4 (`192.168.1.1`). The
35
+ // URL parser also accepts a leading-dot filename (`.env`) or a relative path (`./x`) as a "host",
36
+ // whose suggestion would be a nonsense `https://.env`; requiring well-formed labels rejects those.
37
+ // Anchored with no overlapping quantifiers, so it is ReDoS-safe on the (already length-bounded) host.
38
+ const DOMAIN_OR_IPV4 = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
39
+ /**
40
+ * The input as-typed when it looks like a URL missing its scheme (`example.com`,
41
+ * `docs.nestjs.com/guide`), else null. `bonsai <url>` is the headline command, but bin/cli.mjs only
42
+ * routes args carrying a `://` scheme to the fetch shorthand — so a scheme-less URL falls through to
43
+ * `command_not_found`. A domain-shaped host separates a forgotten-scheme URL from an ordinary command
44
+ * typo: `statuss` has no dot and never matches, while `config:frobnicate` fails to parse (the second
45
+ * segment becomes a non-numeric port), so neither is misread as a URL.
46
+ */
47
+ function bareUrlInput(id) {
48
+ if (id.includes('://'))
49
+ return null;
50
+ let hostname;
51
+ try {
52
+ hostname = new URL(`https://${id}`).hostname;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ return DOMAIN_OR_IPV4.test(hostname) ? id : null;
58
+ }
59
+ /**
60
+ * Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
61
+ * throwing here (via `this.error`) replaces that default with a more helpful message. A correction
62
+ * is offered only when the nearest command is a close match, and it is never auto-run — the user
63
+ * re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
64
+ */
65
+ const hook = async function (opts) {
66
+ const hiddenIds = new Set(opts.config.commands.filter((c) => c.hidden).map((c) => c.id));
67
+ const visibleIds = opts.config.commandIDs.filter((id) => !hiddenIds.has(id));
68
+ // A scheme-less URL is the most common "not a command" mistake for this CLI, so steer the user to
69
+ // the `bonsai <url>` shorthand with a scheme before falling back to nearest-command matching (which
70
+ // never finds a command for a hostname). The correction is shown, never auto-run (clig.dev).
71
+ const bareUrl = bareUrlInput(opts.id);
72
+ if (bareUrl) {
73
+ this.error([
74
+ `${bareUrl} is not a ${opts.config.bin} command.`,
75
+ `Run ${opts.config.bin} help for a list of available commands.`,
76
+ `Did you mean ${opts.config.bin} https://${bareUrl}? URLs need an http:// or https:// scheme.`,
77
+ ].join('\n'), { exit: 2 });
78
+ }
79
+ const segments = opts.id.split(':');
80
+ const { suggestion, commandSegments } = findSuggestion(segments, visibleIds);
81
+ // Show only the segments that name the command, never the positional args oclif folded into the id
82
+ // (which would otherwise glue the arg on and turn `://` into ` //`).
83
+ const displaySegments = commandSegments || topicChainLength(segments, opts.config);
84
+ const attempted = toConfiguredId(segments.slice(0, displaySegments).join(':'), opts.config);
85
+ // Order so the most actionable line lands last, where the eye rests (clig.dev): the help pointer
86
+ // is the floor, and a concrete "Did you mean …?" correction — when we have one — goes below it.
87
+ const lines = [`${attempted} is not a ${opts.config.bin} command.`];
88
+ lines.push(`Run ${opts.config.bin} help for a list of available commands.`);
89
+ if (suggestion)
90
+ lines.push(`Did you mean ${toConfiguredId(suggestion, opts.config)}?`);
91
+ this.error(lines.join('\n'), { exit: 2 });
92
+ };
93
+ export default hook;
@@ -1,6 +1,6 @@
1
1
  export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type SummaryLevel, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
2
2
  export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
3
- export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
3
+ export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
4
4
  import type { ConfigValues, ResolvedConfig, StorageMode, SummaryLevel } from './schema.js';
5
5
  /**
6
6
  * Read both config files once and resolve the effective storage mode.
@@ -1,6 +1,6 @@
1
1
  export { SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
2
2
  export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
3
- export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
3
+ export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
4
4
  import { readUserConfig, readProjectConfig } from './io.js';
5
5
  import { resolveStorageMode, resolveSummaryLevel } from './resolve.js';
6
6
  /**