@taurgis/bonsai 1.0.4 → 1.0.6

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 (93) hide show
  1. package/README.md +81 -2
  2. package/agents/agents/official-docs-researcher.agent.md +10 -1
  3. package/agents/agents/salesforce-docs-researcher.agent.md +126 -0
  4. package/agents/hooks/README.md +62 -0
  5. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +22 -0
  6. package/agents/hooks/claude-code/settings.json +16 -0
  7. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +18 -0
  8. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +22 -0
  9. package/agents/hooks/codex/hooks.json +28 -0
  10. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +21 -0
  11. package/agents/hooks/cursor/hooks.json +12 -0
  12. package/agents/hooks/github-copilot/bonsai-web-fetch.json +23 -0
  13. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +19 -0
  14. package/agents/instructions/salesforce-research.instructions.md +59 -0
  15. package/agents/instructions/web-research.instructions.md +5 -2
  16. package/agents/skills/web-research/SKILL.md +8 -3
  17. package/bin/cli.mjs +35 -8
  18. package/dist/base-command.d.ts +23 -0
  19. package/dist/base-command.js +67 -8
  20. package/dist/commands/config/base.d.ts +2 -0
  21. package/dist/commands/config/base.js +15 -1
  22. package/dist/commands/config/get.d.ts +1 -1
  23. package/dist/commands/config/get.js +6 -7
  24. package/dist/commands/config/index.d.ts +12 -0
  25. package/dist/commands/config/index.js +34 -0
  26. package/dist/commands/config/list.js +4 -4
  27. package/dist/commands/config/set.js +20 -14
  28. package/dist/commands/config/unset.d.ts +1 -1
  29. package/dist/commands/config/unset.js +11 -8
  30. package/dist/commands/fetch.d.ts +5 -0
  31. package/dist/commands/fetch.js +122 -47
  32. package/dist/commands/import.d.ts +4 -0
  33. package/dist/commands/import.js +99 -37
  34. package/dist/commands/inspect.d.ts +4 -0
  35. package/dist/commands/inspect.js +24 -19
  36. package/dist/commands/list.d.ts +2 -2
  37. package/dist/commands/list.js +39 -16
  38. package/dist/commands/prune.d.ts +1 -1
  39. package/dist/commands/prune.js +50 -17
  40. package/dist/commands/search.d.ts +4 -0
  41. package/dist/commands/search.js +102 -41
  42. package/dist/commands/status.d.ts +6 -0
  43. package/dist/commands/status.js +58 -31
  44. package/dist/commands.d.ts +2 -0
  45. package/dist/commands.js +2 -0
  46. package/dist/hooks/command-not-found/suggest.d.ts +9 -0
  47. package/dist/hooks/command-not-found/suggest.js +122 -0
  48. package/dist/lib/argv.d.ts +10 -0
  49. package/dist/lib/argv.js +53 -0
  50. package/dist/lib/config/index.d.ts +1 -1
  51. package/dist/lib/config/index.js +1 -1
  52. package/dist/lib/config/resolve.d.ts +7 -0
  53. package/dist/lib/config/resolve.js +23 -0
  54. package/dist/lib/envelope.d.ts +36 -0
  55. package/dist/lib/envelope.js +76 -0
  56. package/dist/lib/json-meta.d.ts +10 -0
  57. package/dist/lib/json-meta.js +98 -0
  58. package/dist/lib/research/browser.d.ts +12 -0
  59. package/dist/lib/research/browser.js +40 -3
  60. package/dist/lib/research/docs/capabilities.d.ts +1 -1
  61. package/dist/lib/research/docs/discovery-index.d.ts +14 -0
  62. package/dist/lib/research/docs/discovery-index.js +112 -0
  63. package/dist/lib/research/docs/machine-readable.d.ts +2 -2
  64. package/dist/lib/research/docs/machine-readable.js +3 -7
  65. package/dist/lib/research/docs/remote-search-runner.js +61 -1
  66. package/dist/lib/research/docs/sections.d.ts +1 -0
  67. package/dist/lib/research/docs/sections.js +19 -1
  68. package/dist/lib/research/freshness.d.ts +9 -1
  69. package/dist/lib/research/freshness.js +24 -2
  70. package/dist/lib/research/keywords.d.ts +10 -3
  71. package/dist/lib/research/keywords.js +32 -5
  72. package/dist/lib/research/revalidate.js +3 -1
  73. package/dist/lib/research/schema.d.ts +5 -2
  74. package/dist/lib/research/schema.js +12 -1
  75. package/dist/lib/research/secret-scan.js +4 -2
  76. package/dist/lib/research/url.js +5 -2
  77. package/dist/lib/text.d.ts +35 -0
  78. package/dist/lib/text.js +65 -0
  79. package/dist/sites/algolia.d.ts +9 -0
  80. package/dist/sites/algolia.js +26 -0
  81. package/dist/sites/auto.d.ts +8 -0
  82. package/dist/sites/auto.js +30 -0
  83. package/dist/sites/index.js +17 -1
  84. package/dist/sites/nextjs/index.d.ts +2 -0
  85. package/dist/sites/nextjs/index.js +52 -0
  86. package/dist/sites/react/index.d.ts +2 -0
  87. package/dist/sites/react/index.js +17 -0
  88. package/dist/sites/tailwind/index.d.ts +2 -0
  89. package/dist/sites/tailwind/index.js +17 -0
  90. package/dist/sites/vue/index.d.ts +2 -0
  91. package/dist/sites/vue/index.js +17 -0
  92. package/oclif.manifest.json +154 -81
  93. package/package.json +24 -4
@@ -1,49 +1,54 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
3
  import { scanCacheDirs } from '../lib/research/storage.js';
4
- import { resolveResearchTarget } from '../lib/research/resolve-target.js';
5
4
  export default class ResearchInspect extends BaseCommand {
6
5
  static id = 'inspect';
7
6
  static summary = 'Inspect cached research metadata for a URL.';
8
7
  static description = 'Inspects local storage and displays full frontmatter metadata for the URL (without printing the content body).';
8
+ static examples = [
9
+ {
10
+ description: 'inspect cached research metadata for a specific URL',
11
+ command: '<%= config.bin %> <%= command.id %> https://example.com/docs',
12
+ },
13
+ {
14
+ description: 'output the full metadata as JSON for use by other tools',
15
+ command: '<%= config.bin %> <%= command.id %> https://example.com/docs --json',
16
+ },
17
+ ];
9
18
  static args = {
10
19
  url: Args.string({
11
20
  required: true,
12
- description: 'The URL to inspect.',
21
+ description: 'the URL to inspect',
13
22
  }),
14
23
  };
15
24
  static stdoutIsPrimaryData = true;
16
25
  async run() {
17
26
  const { url } = this.args;
18
- let target;
19
- try {
20
- target = resolveResearchTarget({
21
- configDir: this.config.configDir,
22
- cwd: process.cwd(),
23
- dataDir: this.config.dataDir,
24
- url,
25
- });
26
- }
27
- catch (err) {
28
- this.error(`Invalid URL: ${err.message}`, { exit: 2 });
29
- }
27
+ const target = this.resolveResearchTargetOrFail(url);
30
28
  const { cacheKey, located, normalizedUrl, roots } = target;
31
29
  if (!located) {
32
- this.error(`No cached research found for URL: ${normalizedUrl}`, { exit: 1 });
30
+ // inspect makes no network call, so the bare message reads like the network-failure case the
31
+ // exit-1 contract describes. Point at the fix instead: fetch the page so it lands in the cache.
32
+ this.error(`No cached research found for URL: ${normalizedUrl}`, {
33
+ exit: 1,
34
+ code: 'CACHE_MISS',
35
+ suggestions: [`Fetch and cache it first: ${this.config.bin} ${normalizedUrl}`],
36
+ });
33
37
  }
34
38
  const cached = located.artifact;
35
39
  const artifactPath = located.path;
36
40
  const sections = this.findSections(roots.readRoots, cacheKey);
37
41
  if (!this.jsonEnabled()) {
38
- this.log(`Cache Key: ${cacheKey}`);
39
- this.log(`Cache Path: ${artifactPath}`);
42
+ this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
43
+ this.log(`${'Cache Path:'.padEnd(25)} ${artifactPath}`);
40
44
  this.log(`--- Metadata ---`);
41
45
  for (const [key, val] of Object.entries(cached.metadata)) {
46
+ const paddedKey = (key + ':').padEnd(Math.max(25, key.length + 2));
42
47
  if (typeof val === 'object' && val !== null) {
43
- this.log(`${key}: ${JSON.stringify(val)}`);
48
+ this.log(`${paddedKey} ${JSON.stringify(val)}`);
44
49
  }
45
50
  else {
46
- this.log(`${key}: ${val}`);
51
+ this.log(`${paddedKey} ${val}`);
47
52
  }
48
53
  }
49
54
  if (sections.length) {
@@ -11,8 +11,8 @@ export default class ResearchList extends BaseCommand<typeof ResearchList> {
11
11
  topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
13
  freshness: import("@oclif/core/interfaces").OptionFlag<"fresh" | "stale_grace" | "stale_expired" | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
- 'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
- 'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ 'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
+ 'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
16
  limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
17
17
  };
18
18
  static stdoutIsPrimaryData: boolean;
@@ -3,48 +3,55 @@ 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 { ARTIFACT_TYPES, CAPTURE_METHODS } from '../lib/research/schema.js';
7
+ import { NO_TOPIC_LABEL, resultListHeading, truncationNotice, } from '../lib/text.js';
8
+ // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at 100.
9
+ const LIST_LABELS = { noun: 'cached research', order: 'first', maxLimit: 100 };
10
+ // `list` answers "what pages/notes do I have?" and deliberately omits section children (see
11
+ // scanCacheDirForList), so `section` is not an offered filter — every other artifact type can appear.
12
+ const LISTABLE_ARTIFACT_TYPES = ARTIFACT_TYPES.filter((type) => type !== 'section');
6
13
  export default class ResearchList extends BaseCommand {
7
14
  static id = 'list';
8
15
  static summary = 'List cached research artifacts by metadata filters.';
9
16
  static description = 'Lists cached research artifacts, including metadata details like path, source count, freshness, token estimates, and quality metrics without printing full content.';
10
17
  static examples = [
11
18
  {
12
- description: 'List all cached entries',
19
+ description: 'list all cached entries',
13
20
  command: '<%= config.bin %> list',
14
21
  },
15
22
  {
16
- description: 'List cached entries for a specific topic with JSON output',
23
+ description: 'list cached entries for a specific topic with JSON output',
17
24
  command: '<%= config.bin %> list --topic "React Suspense" --json',
18
25
  },
19
26
  {
20
- description: 'List only fresh entries filtered by tags',
27
+ description: 'list only fresh entries filtered by tags',
21
28
  command: '<%= config.bin %> list --freshness fresh --tags node --tags url',
22
29
  },
23
30
  ];
24
31
  static flags = {
25
32
  topic: Flags.string({
26
33
  char: 't',
27
- description: 'Filter by exact topic (case-insensitive).',
34
+ description: 'filter by exact topic (case-insensitive)',
28
35
  }),
29
36
  tags: Flags.string({
30
37
  char: 'g',
31
- description: 'Filter by tags (must match all tags specified).',
38
+ description: 'filter by tags (must match all tags specified)',
32
39
  multiple: true,
33
40
  }),
34
41
  freshness: Flags.option({
35
- description: 'Filter by freshness state.',
42
+ description: 'filter by freshness state',
36
43
  options: ['fresh', 'stale_grace', 'stale_expired'],
37
44
  })(),
38
45
  'artifact-type': Flags.option({
39
- description: 'Filter by artifact type.',
40
- options: ['source', 'research_note'],
46
+ description: 'filter by artifact type',
47
+ options: LISTABLE_ARTIFACT_TYPES,
41
48
  })(),
42
49
  'capture-method': Flags.option({
43
- description: 'Filter by capture method.',
44
- options: ['static_fetch', 'browser_fallback', 'agent_supplied'],
50
+ description: 'filter by capture method',
51
+ options: CAPTURE_METHODS,
45
52
  })(),
46
53
  limit: Flags.integer({
47
- description: 'Maximum number of results to return (default 50, max 100).',
54
+ description: 'maximum number of results to return (default 50, max 100)',
48
55
  default: 50,
49
56
  }),
50
57
  };
@@ -52,7 +59,7 @@ export default class ResearchList extends BaseCommand {
52
59
  validateListFlags() {
53
60
  const limit = this.flags.limit;
54
61
  if (limit !== undefined && (limit < 1 || limit > 100)) {
55
- this.error('Limit must be between 1 and 100.', { exit: 2 });
62
+ this.error('Limit must be between 1 and 100.', { exit: 2, code: 'INVALID_LIMIT' });
56
63
  }
57
64
  }
58
65
  matchesFilters(meta, freshness) {
@@ -82,6 +89,15 @@ export default class ResearchList extends BaseCommand {
82
89
  return scanCacheDirs(readRoots, (artifact, filePath) => {
83
90
  if (artifact.metadata.status !== 'active')
84
91
  return null;
92
+ // Section children are sub-chunks of a page, not artifacts a user "has" — they would flood the
93
+ // listing (one page yields dozens) and aren't in the documented source/research_note contract.
94
+ // They stay discoverable through `search` (which ranks them) and `inspect` (which lists a
95
+ // page's sections). `list` answers "what pages/notes do I have?", so keep it page-level. This
96
+ // unconditional guard owns the exclusion (the default no-filter case relies on it);
97
+ // LISTABLE_ARTIFACT_TYPES just hides `section` from --artifact-type so no one filters for a
98
+ // type list can never return. Keep both in sync if section handling ever changes.
99
+ if (artifact.metadata.artifact_type === 'section')
100
+ return null;
85
101
  const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
86
102
  if (!this.matchesFilters(artifact.metadata, freshness))
87
103
  return null;
@@ -101,16 +117,17 @@ export default class ResearchList extends BaseCommand {
101
117
  };
102
118
  });
103
119
  }
104
- logListResults(finalResults) {
120
+ logListResults(finalResults, totalMatched) {
105
121
  if (this.jsonEnabled())
106
122
  return;
107
123
  if (finalResults.length === 0) {
108
124
  this.log('No cached research entries found matching filters.');
125
+ this.log(`\nTip: populate the cache first: ${this.config.bin} <url>`);
109
126
  return;
110
127
  }
111
- this.log(`Found ${finalResults.length} cached research entries:\n`);
128
+ this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
112
129
  finalResults.forEach((res, index) => {
113
- this.log(`${index + 1}. [${res.topic || 'No Topic'}] Key: ${res.cacheKey}`);
130
+ this.log(`${index + 1}. [${res.topic || NO_TOPIC_LABEL}] Key: ${res.cacheKey}`);
114
131
  this.log(` Type: ${res.artifactType} | Freshness: ${res.freshness}`);
115
132
  this.log(` Tokens: compressed=${res.tokenEstimate?.compressed || 0}, detailed=${res.tokenEstimate?.detailed || 0}`);
116
133
  this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
@@ -132,7 +149,13 @@ export default class ResearchList extends BaseCommand {
132
149
  return timeB - timeA;
133
150
  });
134
151
  const finalResults = results.slice(0, this.flags.limit);
135
- this.logListResults(finalResults);
152
+ // Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
153
+ // always emits, even in --json) without touching the stdout envelope. Human mode already shows
154
+ // it in the heading, so only warn under --json.
155
+ const notice = truncationNotice(results.length, finalResults.length, LIST_LABELS);
156
+ if (notice && this.jsonEnabled())
157
+ this.warn(notice);
158
+ this.logListResults(finalResults, results.length);
136
159
  return finalResults;
137
160
  }
138
161
  }
@@ -10,7 +10,7 @@ export default class ResearchPrune extends BaseCommand<typeof ResearchPrune> {
10
10
  static flags: {
11
11
  'older-than': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  inactive: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
- 'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | "section" | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
14
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
15
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
16
  };
@@ -4,49 +4,76 @@ 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 { ARTIFACT_TYPES } from '../lib/research/schema.js';
9
+ import { NO_TOPIC_LABEL, pluralize } from '../lib/text.js';
8
10
  export default class ResearchPrune extends BaseCommand {
9
11
  static id = 'prune';
10
12
  static summary = 'Clean up old or inactive research cache entries.';
11
13
  static description = 'Prunes cached research entries based on age, inactivity, or artifact type to free up disk space.';
12
14
  static examples = [
13
15
  {
14
- description: 'Perform a dry run of pruning entries older than 90 days',
16
+ description: 'perform a dry run of pruning entries older than 90 days',
15
17
  command: '<%= config.bin %> prune --older-than 90d --dry-run',
16
18
  },
17
19
  {
18
- description: 'Actually prune entries older than 30 days that are source scrapes',
20
+ description: 'actually prune entries older than 30 days that are source scrapes',
19
21
  command: '<%= config.bin %> prune --older-than 30d --artifact-type source --yes',
20
22
  },
21
23
  ];
22
24
  static flags = {
23
25
  'older-than': Flags.string({
24
- description: 'Prune entries older than duration (e.g. "30d", "90d").',
26
+ description: 'prune entries older than duration (e.g. "30d", "90d")',
25
27
  }),
26
28
  inactive: Flags.string({
27
- description: 'Prune entries inactive (unvalidated/unfetched) for duration (e.g. "14d", "30d").',
29
+ description: 'prune entries inactive (unvalidated/unfetched) for duration (e.g. "14d", "30d")',
28
30
  }),
29
31
  'artifact-type': Flags.option({
30
- description: 'Filter pruning to specific artifact type.',
31
- options: ['source', 'research_note'],
32
+ // Prune operates on every cached file, so it can target any artifact type — including the
33
+ // `section`/`index` children a page generates (e.g. to clear orphans left after a source is
34
+ // pruned with `--artifact-type source`).
35
+ description: 'filter pruning to specific artifact type',
36
+ options: ARTIFACT_TYPES,
32
37
  })(),
33
38
  'dry-run': Flags.boolean({
34
- description: 'List files that would be deleted without actually deleting them.',
39
+ description: 'list files that would be deleted without actually deleting them',
35
40
  default: false,
36
41
  }),
37
42
  yes: Flags.boolean({
38
43
  char: 'y',
39
- description: 'Skip confirmation prompt and prune files.',
44
+ description: 'confirm deletion and prune matched entries (required unless --dry-run)',
40
45
  default: false,
41
46
  }),
42
47
  };
43
48
  static stdoutIsPrimaryData = true;
44
49
  validatePruneFlags() {
45
50
  if (!this.flags['older-than'] && !this.flags.inactive && !this.flags['artifact-type']) {
46
- this.error('Must specify at least one pruning filter: --older-than, --inactive, or --artifact-type.', { exit: 2 });
51
+ this.error('Must specify at least one pruning filter: --older-than, --inactive, or --artifact-type.', {
52
+ exit: 2,
53
+ code: 'MISSING_FILTER',
54
+ suggestions: [
55
+ `Preview age-based pruning: ${this.config.bin} prune --older-than 90d --dry-run`,
56
+ ],
57
+ });
47
58
  }
48
59
  if (!this.flags['dry-run'] && !this.flags.yes) {
49
- this.error('Safety check: Please specify --yes to confirm pruning, or --dry-run to list files that would be deleted.', { exit: 2 });
60
+ const olderThanPart = this.flags['older-than']
61
+ ? ` --older-than ${this.flags['older-than']}`
62
+ : '';
63
+ this.error('Safety check: use --yes to confirm pruning, or --dry-run to preview files that would be deleted.', {
64
+ exit: 2,
65
+ code: 'SAFETY_CHECK_REQUIRED',
66
+ suggestions: [`Preview first: ${this.config.bin} prune --dry-run${olderThanPart}`],
67
+ });
68
+ }
69
+ // Validate durations up front: scanCacheDir swallows per-file errors, so a malformed
70
+ // --older-than/--inactive would otherwise be silently ignored and report "0 entries".
71
+ for (const msg of [
72
+ durationFlagError('--older-than', this.flags['older-than']),
73
+ durationFlagError('--inactive', this.flags.inactive),
74
+ ]) {
75
+ if (msg)
76
+ this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
50
77
  }
51
78
  }
52
79
  shouldPrune(meta, currentTime) {
@@ -109,24 +136,30 @@ export default class ResearchPrune extends BaseCommand {
109
136
  const filesToPrune = this.findPruneCandidates(roots.readRoots, currentTime);
110
137
  const dryRun = this.flags['dry-run'];
111
138
  const count = filesToPrune.length;
139
+ // Track deletions actually performed so the JSON envelope and human output agree even when an
140
+ // unlink fails (e.g. a permission error). Reporting candidate count as `prunedCount` would
141
+ // overstate success and mislead an agent branching on the result.
142
+ let prunedCount = 0;
112
143
  if (dryRun) {
113
144
  if (!this.jsonEnabled()) {
114
- this.log(`[Dry Run] Found ${count} research cache entries that would be pruned:\n`);
145
+ const noun = pluralize(count, 'entry', 'entries');
146
+ this.log(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`);
115
147
  filesToPrune.forEach((f) => {
116
- this.log(`- [${f.topic || 'No Topic'}] Key: ${f.cacheKey} (${f.url || 'Imported note'})`);
148
+ this.log(`- [${f.topic || NO_TOPIC_LABEL}] Key: ${f.cacheKey} (${f.url || 'Imported note'})`);
117
149
  });
118
150
  }
119
151
  }
120
152
  else {
121
- const prunedCount = this.deletePruneCandidates(filesToPrune);
153
+ prunedCount = this.deletePruneCandidates(filesToPrune);
122
154
  if (!this.jsonEnabled()) {
123
- this.log(`Successfully pruned ${prunedCount} of ${count} research cache entries.`);
155
+ const noun = pluralize(count, 'entry', 'entries');
156
+ this.log(`Successfully pruned ${prunedCount} of ${count} research cache ${noun}.`);
124
157
  }
125
158
  }
126
159
  return {
127
160
  dryRun,
128
- prunedCount: dryRun ? 0 : filesToPrune.length,
129
- candidateCount: filesToPrune.length,
161
+ prunedCount,
162
+ candidateCount: count,
130
163
  files: filesToPrune.map((f) => ({
131
164
  cacheKey: f.cacheKey,
132
165
  path: f.path,
@@ -3,6 +3,10 @@ export default class ResearchSearch extends BaseCommand<typeof ResearchSearch> {
3
3
  static id: string;
4
4
  static summary: string;
5
5
  static description: string;
6
+ static examples: {
7
+ description: string;
8
+ command: string;
9
+ }[];
6
10
  static args: {
7
11
  query: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
8
12
  };
@@ -1,11 +1,20 @@
1
- import { Args, Flags } from '@oclif/core';
1
+ import { Args, Flags, ux } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
3
  import { loadSearchableArtifacts } from '../lib/research/search-index.js';
4
+ import { levenshtein, NO_TOPIC_LABEL, 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';
7
+ import { ARTIFACT_TYPES } from '../lib/research/schema.js';
6
8
  import { detectSite } from '../sites/index.js';
7
9
  import { fetchStaticHtml, fetchText, postJson } from '../lib/research/fetcher.js';
10
+ import { normalizeUrl } from '../lib/research/url.js';
8
11
  import { runRemoteDocsSearch, } from '../lib/research/docs/remote-search-runner.js';
12
+ // Results are ranked, so the truncation word is "top"; --limit caps at 50.
13
+ const SEARCH_LABELS = {
14
+ noun: 'matching cached research',
15
+ order: 'top',
16
+ maxLimit: 50,
17
+ };
9
18
  const REMOTE_SEARCH_DEPS = {
10
19
  fetchStatic: async (url) => {
11
20
  const res = await fetchStaticHtml(url);
@@ -22,52 +31,66 @@ export default class ResearchSearch extends BaseCommand {
22
31
  static id = 'search';
23
32
  static summary = 'Search locally cached research artifacts by keywords.';
24
33
  static description = 'Scans the local research cache database and ranks matching entries based on topic, tags, source URLs, summary, and content.';
34
+ static examples = [
35
+ {
36
+ description: 'search for cached notes containing specific keywords',
37
+ command: '<%= config.bin %> <%= command.id %> "authentication flow"',
38
+ },
39
+ {
40
+ description: 'search with remote docs fallback',
41
+ command: '<%= config.bin %> <%= command.id %> "router" --remote https://react.dev',
42
+ },
43
+ {
44
+ description: 'search local cache with tag filtering',
45
+ command: '<%= config.bin %> <%= command.id %> "setup" --tags node --tags tutorial',
46
+ },
47
+ ];
25
48
  static args = {
26
49
  query: Args.string({
27
50
  required: true,
28
- description: 'The search query containing terms to match.',
51
+ description: 'the search query containing terms to match',
29
52
  }),
30
53
  };
31
54
  static flags = {
32
55
  topic: Flags.string({
33
56
  char: 't',
34
- description: 'Filter results by exact topic (case-insensitive).',
57
+ description: 'filter results by exact topic (case-insensitive)',
35
58
  }),
36
59
  tags: Flags.string({
37
60
  char: 'g',
38
- description: 'Filter results by tags (must match all tags specified).',
61
+ description: 'filter results by tags (must match all tags specified)',
39
62
  multiple: true,
40
63
  }),
41
64
  'artifact-type': Flags.option({
42
- description: 'Filter results by artifact type.',
43
- options: ['source', 'research_note', 'index', 'section'],
65
+ description: 'filter results by artifact type',
66
+ options: ARTIFACT_TYPES,
44
67
  })(),
45
68
  limit: Flags.integer({
46
- description: 'Maximum number of results to return (default 10, max 50).',
69
+ description: 'maximum number of results to return (default 10, max 50)',
47
70
  default: 10,
48
71
  }),
49
72
  'include-stale': Flags.boolean({
50
- description: 'Include stale expired cache entries in the search results.',
73
+ description: 'include stale expired cache entries in the search results',
51
74
  default: false,
52
75
  }),
53
76
  domain: Flags.string({
54
- description: 'Search a documentation site directly (e.g. help.salesforce.com) instead of the local cache. Requires a site module that implements search.',
77
+ description: 'search a documentation site directly (requires compatible site module)',
55
78
  }),
56
79
  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.',
80
+ description: 'discover uncached docs via public search or index (falls back to local cache)',
58
81
  }),
59
82
  };
60
83
  static stdoutIsPrimaryData = true;
61
84
  validateSearchFlags() {
62
85
  const limit = this.flags.limit;
63
86
  if (limit !== undefined && (limit < 1 || limit > 50)) {
64
- this.error('Limit must be between 1 and 50.', { exit: 2 });
87
+ this.error('Limit must be between 1 and 50.', { exit: 2, code: 'INVALID_LIMIT' });
65
88
  }
66
89
  }
67
90
  getSearchQueryTerms(query) {
68
91
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
69
92
  if (terms.length === 0) {
70
- this.error('Query string cannot be empty.', { exit: 2 });
93
+ this.error('Query string cannot be empty.', { exit: 2, code: 'EMPTY_QUERY' });
71
94
  }
72
95
  return terms;
73
96
  }
@@ -182,12 +205,17 @@ export default class ResearchSearch extends BaseCommand {
182
205
  }
183
206
  return results;
184
207
  }
185
- logSearchResults(finalResults) {
208
+ logSearchResults(finalResults, totalMatched) {
186
209
  if (this.jsonEnabled())
187
210
  return;
188
- this.log(`Found ${finalResults.length} matching cached research entries:\n`);
211
+ if (finalResults.length === 0) {
212
+ this.log('No matching cached research entries found.');
213
+ this.log(`\nTip: populate the cache first: ${this.config.bin} <url>`);
214
+ return;
215
+ }
216
+ this.log(`${resultListHeading(totalMatched, finalResults.length, SEARCH_LABELS)}\n`);
189
217
  finalResults.forEach((res, index) => {
190
- this.log(`${index + 1}. [${res.topic || 'No Topic'}] Score: ${res.score}`);
218
+ this.log(`${index + 1}. [${res.topic || NO_TOPIC_LABEL}] Score: ${res.score}`);
191
219
  this.log(` Cache Key: ${res.cacheKey}`);
192
220
  this.log(` Snippet: ${res.snippet}`);
193
221
  this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
@@ -209,24 +237,47 @@ export default class ResearchSearch extends BaseCommand {
209
237
  async executeSiteSearch(query, domain) {
210
238
  const siteModule = detectSite(`https://${domain}`);
211
239
  if (!siteModule) {
212
- this.error(`No site module registered for domain: ${domain}`, { exit: 2 });
240
+ this.error(`No site module registered for domain: ${domain}`, {
241
+ exit: 2,
242
+ code: 'UNSUPPORTED_DOMAIN',
243
+ });
213
244
  }
214
245
  if (!siteModule.search) {
215
- this.error(`Site module '${siteModule.id}' does not implement search.`, { exit: 2 });
246
+ this.error(`Site module '${siteModule.id}' does not implement search.`, {
247
+ exit: 2,
248
+ code: 'SEARCH_NOT_SUPPORTED',
249
+ });
250
+ }
251
+ try {
252
+ if (!this.jsonEnabled())
253
+ ux.action.start(`Searching ${domain}`);
254
+ const results = await siteModule.search(query);
255
+ if (!this.jsonEnabled())
256
+ ux.action.stop();
257
+ this.printResults(`Found ${results.length} results from ${siteModule.name}:\n`, results);
258
+ return results.map((r) => ({ ...r, site_module_id: siteModule.id }));
259
+ }
260
+ catch (err) {
261
+ if (!this.jsonEnabled())
262
+ ux.action.stop('failed');
263
+ throw err;
216
264
  }
217
- const results = await siteModule.search(query);
218
- this.printResults(`Found ${results.length} results from ${siteModule.name}:\n`, results);
219
- return results.map((r) => ({ ...r, site_module_id: siteModule.id }));
220
265
  }
221
266
  // Remote docs discovery. On any connector failure, degrade to local cache search with a warning
222
267
  // (T-20). Returns discovery results tagged remote: true so callers can tell them from cache hits.
223
268
  async executeRemoteSearch(query, docsUrl) {
224
269
  try {
270
+ if (!this.jsonEnabled())
271
+ ux.action.start(`Searching ${docsUrl}`);
225
272
  const { provider, results } = await runRemoteDocsSearch(docsUrl, query, REMOTE_SEARCH_DEPS);
273
+ if (!this.jsonEnabled())
274
+ ux.action.stop();
226
275
  this.printResults(`Found ${results.length} remote results via ${provider}:\n`, results);
227
276
  return results.map((r) => ({ ...r, remote: true }));
228
277
  }
229
278
  catch (err) {
279
+ if (!this.jsonEnabled())
280
+ ux.action.stop('failed');
230
281
  this.warn(`Remote docs search unavailable, using local cache: ${err.message}`);
231
282
  return undefined;
232
283
  }
@@ -234,14 +285,36 @@ export default class ResearchSearch extends BaseCommand {
234
285
  async run() {
235
286
  const { query } = this.args;
236
287
  const { domain, remote } = this.flags;
288
+ // Reject whitespace-only queries on every path (local, --domain, --remote).
289
+ this.getSearchQueryTerms(query);
290
+ if (domain && remote) {
291
+ this.error('--domain and --remote are mutually exclusive. Use one discovery mode at a time.', {
292
+ exit: 2,
293
+ code: 'CONFLICTING_FLAGS',
294
+ suggestions: [
295
+ `Local site API: ${this.config.bin} search "${query}" --domain ${domain}`,
296
+ `Remote index: ${this.config.bin} search "${query}" --remote ${remote}`,
297
+ ],
298
+ });
299
+ }
237
300
  if (domain) {
238
301
  return this.executeSiteSearch(query, domain);
239
302
  }
240
303
  if (remote) {
241
- const remoteResults = await this.executeRemoteSearch(query, remote);
304
+ let normalizedRemote;
305
+ try {
306
+ normalizedRemote = normalizeUrl(remote);
307
+ }
308
+ catch (err) {
309
+ this.error(`Invalid --remote URL: ${err.message}`, {
310
+ exit: 2,
311
+ code: 'INVALID_URL',
312
+ });
313
+ }
314
+ const remoteResults = await this.executeRemoteSearch(query, normalizedRemote);
242
315
  if (remoteResults !== undefined)
243
316
  return remoteResults;
244
- // else: fall through to local cache search
317
+ // else: fall through to local cache search (connector/network failure only)
245
318
  }
246
319
  this.validateSearchFlags();
247
320
  const queryTerms = this.getSearchQueryTerms(query);
@@ -254,7 +327,13 @@ export default class ResearchSearch extends BaseCommand {
254
327
  const results = this.scanCacheDirForResults(roots.readRoots, queryTerms, currentTime);
255
328
  results.sort((a, b) => b.score - a.score);
256
329
  const finalResults = results.slice(0, this.flags.limit);
257
- this.logSearchResults(finalResults);
330
+ // Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
331
+ // always emits, even in --json) without touching the stdout envelope. Human mode already shows
332
+ // it in the heading, so only warn under --json.
333
+ const notice = truncationNotice(results.length, finalResults.length, SEARCH_LABELS);
334
+ if (notice && this.jsonEnabled())
335
+ this.warn(notice);
336
+ this.logSearchResults(finalResults, results.length);
258
337
  return finalResults;
259
338
  }
260
339
  }
@@ -305,24 +384,6 @@ function scoreSingleTerm(term, topic, tags, sourceUrl, sourceUrls, summ, comp) {
305
384
  }
306
385
  return termScore;
307
386
  }
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
387
  function isFuzzyMatch(term, target) {
327
388
  if (term.length < 4 || target.length < 4)
328
389
  return false;
@@ -3,6 +3,10 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
3
3
  static id: string;
4
4
  static summary: string;
5
5
  static description: string;
6
+ static examples: {
7
+ description: string;
8
+ command: string;
9
+ }[];
6
10
  static args: {
7
11
  url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
8
12
  };
@@ -12,5 +16,7 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
12
16
  'max-age': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
17
  };
14
18
  static stdoutIsPrimaryData: boolean;
19
+ /** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
20
+ protected toSuccessJson(data: unknown): Record<string, unknown>;
15
21
  run(): Promise<unknown>;
16
22
  }