@taurgis/bonsai 1.0.5 → 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 (61) hide show
  1. package/README.md +53 -0
  2. package/agents/agents/official-docs-researcher.agent.md +10 -1
  3. package/agents/agents/salesforce-docs-researcher.agent.md +10 -1
  4. package/agents/instructions/salesforce-research.instructions.md +4 -2
  5. package/agents/instructions/web-research.instructions.md +4 -2
  6. package/agents/skills/web-research/SKILL.md +8 -3
  7. package/bin/cli.mjs +30 -13
  8. package/dist/base-command.d.ts +3 -0
  9. package/dist/base-command.js +40 -7
  10. package/dist/commands/config/base.d.ts +2 -0
  11. package/dist/commands/config/base.js +15 -1
  12. package/dist/commands/config/get.d.ts +1 -1
  13. package/dist/commands/config/get.js +4 -5
  14. package/dist/commands/config/index.d.ts +12 -0
  15. package/dist/commands/config/index.js +34 -0
  16. package/dist/commands/config/list.js +2 -2
  17. package/dist/commands/config/set.js +18 -12
  18. package/dist/commands/config/unset.d.ts +1 -1
  19. package/dist/commands/config/unset.js +9 -6
  20. package/dist/commands/fetch.js +44 -34
  21. package/dist/commands/import.d.ts +3 -0
  22. package/dist/commands/import.js +82 -52
  23. package/dist/commands/inspect.d.ts +4 -0
  24. package/dist/commands/inspect.js +24 -19
  25. package/dist/commands/list.d.ts +2 -2
  26. package/dist/commands/list.js +23 -15
  27. package/dist/commands/prune.d.ts +1 -1
  28. package/dist/commands/prune.js +30 -13
  29. package/dist/commands/search.d.ts +4 -0
  30. package/dist/commands/search.js +83 -21
  31. package/dist/commands/status.d.ts +6 -0
  32. package/dist/commands/status.js +50 -24
  33. package/dist/commands.d.ts +2 -0
  34. package/dist/commands.js +2 -0
  35. package/dist/hooks/command-not-found/suggest.js +37 -8
  36. package/dist/lib/argv.d.ts +10 -0
  37. package/dist/lib/argv.js +53 -0
  38. package/dist/lib/envelope.d.ts +36 -0
  39. package/dist/lib/envelope.js +76 -0
  40. package/dist/lib/json-meta.d.ts +10 -0
  41. package/dist/lib/json-meta.js +98 -0
  42. package/dist/lib/research/revalidate.js +3 -1
  43. package/dist/lib/research/schema.d.ts +5 -2
  44. package/dist/lib/research/schema.js +12 -1
  45. package/dist/lib/text.d.ts +2 -0
  46. package/dist/lib/text.js +2 -0
  47. package/dist/sites/algolia.d.ts +9 -0
  48. package/dist/sites/algolia.js +26 -0
  49. package/dist/sites/auto.d.ts +8 -0
  50. package/dist/sites/auto.js +30 -0
  51. package/dist/sites/index.js +17 -1
  52. package/dist/sites/nextjs/index.d.ts +2 -0
  53. package/dist/sites/nextjs/index.js +52 -0
  54. package/dist/sites/react/index.d.ts +2 -0
  55. package/dist/sites/react/index.js +17 -0
  56. package/dist/sites/tailwind/index.d.ts +2 -0
  57. package/dist/sites/tailwind/index.js +17 -0
  58. package/dist/sites/vue/index.d.ts +2 -0
  59. package/dist/sites/vue/index.js +17 -0
  60. package/oclif.manifest.json +149 -76
  61. package/package.json +8 -2
@@ -3,51 +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 { resultListHeading, truncationNotice } from '../lib/text.js';
6
+ import { ARTIFACT_TYPES, CAPTURE_METHODS } from '../lib/research/schema.js';
7
+ import { NO_TOPIC_LABEL, resultListHeading, truncationNotice, } from '../lib/text.js';
7
8
  // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at 100.
8
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');
9
13
  export default class ResearchList extends BaseCommand {
10
14
  static id = 'list';
11
15
  static summary = 'List cached research artifacts by metadata filters.';
12
16
  static description = 'Lists cached research artifacts, including metadata details like path, source count, freshness, token estimates, and quality metrics without printing full content.';
13
17
  static examples = [
14
18
  {
15
- description: 'List all cached entries',
19
+ description: 'list all cached entries',
16
20
  command: '<%= config.bin %> list',
17
21
  },
18
22
  {
19
- description: 'List cached entries for a specific topic with JSON output',
23
+ description: 'list cached entries for a specific topic with JSON output',
20
24
  command: '<%= config.bin %> list --topic "React Suspense" --json',
21
25
  },
22
26
  {
23
- description: 'List only fresh entries filtered by tags',
27
+ description: 'list only fresh entries filtered by tags',
24
28
  command: '<%= config.bin %> list --freshness fresh --tags node --tags url',
25
29
  },
26
30
  ];
27
31
  static flags = {
28
32
  topic: Flags.string({
29
33
  char: 't',
30
- description: 'Filter by exact topic (case-insensitive).',
34
+ description: 'filter by exact topic (case-insensitive)',
31
35
  }),
32
36
  tags: Flags.string({
33
37
  char: 'g',
34
- description: 'Filter by tags (must match all tags specified).',
38
+ description: 'filter by tags (must match all tags specified)',
35
39
  multiple: true,
36
40
  }),
37
41
  freshness: Flags.option({
38
- description: 'Filter by freshness state.',
42
+ description: 'filter by freshness state',
39
43
  options: ['fresh', 'stale_grace', 'stale_expired'],
40
44
  })(),
41
45
  'artifact-type': Flags.option({
42
- description: 'Filter by artifact type.',
43
- options: ['source', 'research_note'],
46
+ description: 'filter by artifact type',
47
+ options: LISTABLE_ARTIFACT_TYPES,
44
48
  })(),
45
49
  'capture-method': Flags.option({
46
- description: 'Filter by capture method.',
47
- options: ['static_fetch', 'browser_fallback', 'agent_supplied'],
50
+ description: 'filter by capture method',
51
+ options: CAPTURE_METHODS,
48
52
  })(),
49
53
  limit: Flags.integer({
50
- description: 'Maximum number of results to return (default 50, max 100).',
54
+ description: 'maximum number of results to return (default 50, max 100)',
51
55
  default: 50,
52
56
  }),
53
57
  };
@@ -55,7 +59,7 @@ export default class ResearchList extends BaseCommand {
55
59
  validateListFlags() {
56
60
  const limit = this.flags.limit;
57
61
  if (limit !== undefined && (limit < 1 || limit > 100)) {
58
- 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' });
59
63
  }
60
64
  }
61
65
  matchesFilters(meta, freshness) {
@@ -88,7 +92,10 @@ export default class ResearchList extends BaseCommand {
88
92
  // Section children are sub-chunks of a page, not artifacts a user "has" — they would flood the
89
93
  // listing (one page yields dozens) and aren't in the documented source/research_note contract.
90
94
  // 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.
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.
92
99
  if (artifact.metadata.artifact_type === 'section')
93
100
  return null;
94
101
  const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
@@ -115,11 +122,12 @@ export default class ResearchList extends BaseCommand {
115
122
  return;
116
123
  if (finalResults.length === 0) {
117
124
  this.log('No cached research entries found matching filters.');
125
+ this.log(`\nTip: populate the cache first: ${this.config.bin} <url>`);
118
126
  return;
119
127
  }
120
128
  this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
121
129
  finalResults.forEach((res, index) => {
122
- this.log(`${index + 1}. [${res.topic || 'No Topic'}] Key: ${res.cacheKey}`);
130
+ this.log(`${index + 1}. [${res.topic || NO_TOPIC_LABEL}] Key: ${res.cacheKey}`);
123
131
  this.log(` Type: ${res.artifactType} | Freshness: ${res.freshness}`);
124
132
  this.log(` Tokens: compressed=${res.tokenEstimate?.compressed || 0}, detailed=${res.tokenEstimate?.detailed || 0}`);
125
133
  this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
@@ -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
  };
@@ -5,49 +5,66 @@ 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
7
  import { parseTtlToMs, durationFlagError } from '../lib/research/freshness.js';
8
- import { pluralize } from '../lib/text.js';
8
+ import { ARTIFACT_TYPES } from '../lib/research/schema.js';
9
+ import { NO_TOPIC_LABEL, pluralize } from '../lib/text.js';
9
10
  export default class ResearchPrune extends BaseCommand {
10
11
  static id = 'prune';
11
12
  static summary = 'Clean up old or inactive research cache entries.';
12
13
  static description = 'Prunes cached research entries based on age, inactivity, or artifact type to free up disk space.';
13
14
  static examples = [
14
15
  {
15
- 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',
16
17
  command: '<%= config.bin %> prune --older-than 90d --dry-run',
17
18
  },
18
19
  {
19
- 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',
20
21
  command: '<%= config.bin %> prune --older-than 30d --artifact-type source --yes',
21
22
  },
22
23
  ];
23
24
  static flags = {
24
25
  'older-than': Flags.string({
25
- description: 'Prune entries older than duration (e.g. "30d", "90d").',
26
+ description: 'prune entries older than duration (e.g. "30d", "90d")',
26
27
  }),
27
28
  inactive: Flags.string({
28
- 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")',
29
30
  }),
30
31
  'artifact-type': Flags.option({
31
- description: 'Filter pruning to specific artifact type.',
32
- 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,
33
37
  })(),
34
38
  'dry-run': Flags.boolean({
35
- description: 'List files that would be deleted without actually deleting them.',
39
+ description: 'list files that would be deleted without actually deleting them',
36
40
  default: false,
37
41
  }),
38
42
  yes: Flags.boolean({
39
43
  char: 'y',
40
- description: 'Skip confirmation prompt and prune files.',
44
+ description: 'confirm deletion and prune matched entries (required unless --dry-run)',
41
45
  default: false,
42
46
  }),
43
47
  };
44
48
  static stdoutIsPrimaryData = true;
45
49
  validatePruneFlags() {
46
50
  if (!this.flags['older-than'] && !this.flags.inactive && !this.flags['artifact-type']) {
47
- 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
+ });
48
58
  }
49
59
  if (!this.flags['dry-run'] && !this.flags.yes) {
50
- 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
+ });
51
68
  }
52
69
  // Validate durations up front: scanCacheDir swallows per-file errors, so a malformed
53
70
  // --older-than/--inactive would otherwise be silently ignored and report "0 entries".
@@ -56,7 +73,7 @@ export default class ResearchPrune extends BaseCommand {
56
73
  durationFlagError('--inactive', this.flags.inactive),
57
74
  ]) {
58
75
  if (msg)
59
- this.error(msg, { exit: 2 });
76
+ this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
60
77
  }
61
78
  }
62
79
  shouldPrune(meta, currentTime) {
@@ -128,7 +145,7 @@ export default class ResearchPrune extends BaseCommand {
128
145
  const noun = pluralize(count, 'entry', 'entries');
129
146
  this.log(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`);
130
147
  filesToPrune.forEach((f) => {
131
- 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'})`);
132
149
  });
133
150
  }
134
151
  }
@@ -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,13 @@
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, resultListHeading, truncationNotice, } from '../lib/text.js';
4
+ import { levenshtein, NO_TOPIC_LABEL, resultListHeading, truncationNotice, } from '../lib/text.js';
5
5
  import { loadStoreRoots } from '../lib/research/store-roots.js';
6
6
  import { evaluateFreshness } from '../lib/research/freshness.js';
7
+ import { ARTIFACT_TYPES } from '../lib/research/schema.js';
7
8
  import { detectSite } from '../sites/index.js';
8
9
  import { fetchStaticHtml, fetchText, postJson } from '../lib/research/fetcher.js';
10
+ import { normalizeUrl } from '../lib/research/url.js';
9
11
  import { runRemoteDocsSearch, } from '../lib/research/docs/remote-search-runner.js';
10
12
  // Results are ranked, so the truncation word is "top"; --limit caps at 50.
11
13
  const SEARCH_LABELS = {
@@ -29,52 +31,66 @@ export default class ResearchSearch extends BaseCommand {
29
31
  static id = 'search';
30
32
  static summary = 'Search locally cached research artifacts by keywords.';
31
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
+ ];
32
48
  static args = {
33
49
  query: Args.string({
34
50
  required: true,
35
- description: 'The search query containing terms to match.',
51
+ description: 'the search query containing terms to match',
36
52
  }),
37
53
  };
38
54
  static flags = {
39
55
  topic: Flags.string({
40
56
  char: 't',
41
- description: 'Filter results by exact topic (case-insensitive).',
57
+ description: 'filter results by exact topic (case-insensitive)',
42
58
  }),
43
59
  tags: Flags.string({
44
60
  char: 'g',
45
- description: 'Filter results by tags (must match all tags specified).',
61
+ description: 'filter results by tags (must match all tags specified)',
46
62
  multiple: true,
47
63
  }),
48
64
  'artifact-type': Flags.option({
49
- description: 'Filter results by artifact type.',
50
- options: ['source', 'research_note', 'index', 'section'],
65
+ description: 'filter results by artifact type',
66
+ options: ARTIFACT_TYPES,
51
67
  })(),
52
68
  limit: Flags.integer({
53
- description: 'Maximum number of results to return (default 10, max 50).',
69
+ description: 'maximum number of results to return (default 10, max 50)',
54
70
  default: 10,
55
71
  }),
56
72
  'include-stale': Flags.boolean({
57
- description: 'Include stale expired cache entries in the search results.',
73
+ description: 'include stale expired cache entries in the search results',
58
74
  default: false,
59
75
  }),
60
76
  domain: Flags.string({
61
- 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)',
62
78
  }),
63
79
  remote: Flags.string({
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.',
80
+ description: 'discover uncached docs via public search or index (falls back to local cache)',
65
81
  }),
66
82
  };
67
83
  static stdoutIsPrimaryData = true;
68
84
  validateSearchFlags() {
69
85
  const limit = this.flags.limit;
70
86
  if (limit !== undefined && (limit < 1 || limit > 50)) {
71
- 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' });
72
88
  }
73
89
  }
74
90
  getSearchQueryTerms(query) {
75
91
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
76
92
  if (terms.length === 0) {
77
- this.error('Query string cannot be empty.', { exit: 2 });
93
+ this.error('Query string cannot be empty.', { exit: 2, code: 'EMPTY_QUERY' });
78
94
  }
79
95
  return terms;
80
96
  }
@@ -194,11 +210,12 @@ export default class ResearchSearch extends BaseCommand {
194
210
  return;
195
211
  if (finalResults.length === 0) {
196
212
  this.log('No matching cached research entries found.');
213
+ this.log(`\nTip: populate the cache first: ${this.config.bin} <url>`);
197
214
  return;
198
215
  }
199
216
  this.log(`${resultListHeading(totalMatched, finalResults.length, SEARCH_LABELS)}\n`);
200
217
  finalResults.forEach((res, index) => {
201
- this.log(`${index + 1}. [${res.topic || 'No Topic'}] Score: ${res.score}`);
218
+ this.log(`${index + 1}. [${res.topic || NO_TOPIC_LABEL}] Score: ${res.score}`);
202
219
  this.log(` Cache Key: ${res.cacheKey}`);
203
220
  this.log(` Snippet: ${res.snippet}`);
204
221
  this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
@@ -220,24 +237,47 @@ export default class ResearchSearch extends BaseCommand {
220
237
  async executeSiteSearch(query, domain) {
221
238
  const siteModule = detectSite(`https://${domain}`);
222
239
  if (!siteModule) {
223
- 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
+ });
224
244
  }
225
245
  if (!siteModule.search) {
226
- 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;
227
264
  }
228
- const results = await siteModule.search(query);
229
- this.printResults(`Found ${results.length} results from ${siteModule.name}:\n`, results);
230
- return results.map((r) => ({ ...r, site_module_id: siteModule.id }));
231
265
  }
232
266
  // Remote docs discovery. On any connector failure, degrade to local cache search with a warning
233
267
  // (T-20). Returns discovery results tagged remote: true so callers can tell them from cache hits.
234
268
  async executeRemoteSearch(query, docsUrl) {
235
269
  try {
270
+ if (!this.jsonEnabled())
271
+ ux.action.start(`Searching ${docsUrl}`);
236
272
  const { provider, results } = await runRemoteDocsSearch(docsUrl, query, REMOTE_SEARCH_DEPS);
273
+ if (!this.jsonEnabled())
274
+ ux.action.stop();
237
275
  this.printResults(`Found ${results.length} remote results via ${provider}:\n`, results);
238
276
  return results.map((r) => ({ ...r, remote: true }));
239
277
  }
240
278
  catch (err) {
279
+ if (!this.jsonEnabled())
280
+ ux.action.stop('failed');
241
281
  this.warn(`Remote docs search unavailable, using local cache: ${err.message}`);
242
282
  return undefined;
243
283
  }
@@ -245,14 +285,36 @@ export default class ResearchSearch extends BaseCommand {
245
285
  async run() {
246
286
  const { query } = this.args;
247
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
+ }
248
300
  if (domain) {
249
301
  return this.executeSiteSearch(query, domain);
250
302
  }
251
303
  if (remote) {
252
- 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);
253
315
  if (remoteResults !== undefined)
254
316
  return remoteResults;
255
- // else: fall through to local cache search
317
+ // else: fall through to local cache search (connector/network failure only)
256
318
  }
257
319
  this.validateSearchFlags();
258
320
  const queryTerms = this.getSearchQueryTerms(query);
@@ -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
  }
@@ -1,8 +1,8 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
+ import { formatErrorForJson } from '../lib/envelope.js';
3
4
  import { getArtifactPath } from '../lib/research/storage.js';
4
5
  import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
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;
8
8
  return isMaxAgeExpired
@@ -22,27 +22,52 @@ export default class ResearchStatus extends BaseCommand {
22
22
  static id = 'status';
23
23
  static summary = 'Check the cache status of a URL without fetching or writing.';
24
24
  static description = 'Reports whether the URL is cached, its freshness state, and what action (fetch, revalidate, or cached return) the root fetch command would take.';
25
+ static examples = [
26
+ {
27
+ description: 'check if a URL is already in the cache',
28
+ command: '<%= config.bin %> <%= command.id %> https://example.com/docs',
29
+ },
30
+ {
31
+ description: 'check status against a specific freshness tier and TTL',
32
+ command: '<%= config.bin %> <%= command.id %> https://example.com/docs --tier volatile --ttl 2h',
33
+ },
34
+ ];
25
35
  static args = {
26
36
  url: Args.string({
27
37
  required: true,
28
- description: 'The URL to check.',
38
+ description: 'the URL to check',
29
39
  }),
30
40
  };
31
41
  static flags = {
32
42
  tier: Flags.option({
33
- description: 'Freshness tier policy to evaluate against.',
43
+ description: 'freshness tier policy to evaluate against',
34
44
  options: ['stable', 'standard', 'volatile'],
35
45
  default: 'standard',
36
46
  })(),
37
47
  ttl: Flags.string({
38
48
  char: 'l',
39
- description: 'Custom TTL duration to evaluate against (e.g. "2h", "7d").',
49
+ description: 'custom TTL duration to evaluate against (e.g. "2h", "7d")',
40
50
  }),
41
51
  'max-age': Flags.string({
42
- description: 'Maximum age of cache to accept (e.g. "1d", "30d").',
52
+ description: 'maximum age of cache to accept (e.g. "1d", "30d")',
43
53
  }),
44
54
  };
45
55
  static stdoutIsPrimaryData = true;
56
+ /** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
57
+ toSuccessJson(data) {
58
+ const envelope = super.toSuccessJson(data);
59
+ const d = data;
60
+ if (d?.status !== 'miss')
61
+ return envelope;
62
+ const normalizedUrl = d.normalizedUrl ?? '';
63
+ const suggestions = [`Fetch and cache it first: ${this.config.bin} ${normalizedUrl}`];
64
+ const stderr = formatErrorForJson({
65
+ message: `Cache miss for ${normalizedUrl}`,
66
+ code: 'CACHE_MISS',
67
+ suggestions,
68
+ });
69
+ return { ...envelope, ok: false, exitCode: 1, stderr, code: 'CACHE_MISS', suggestions };
70
+ }
46
71
  async run() {
47
72
  const { url } = this.args;
48
73
  const { ttl, tier, 'max-age': maxAge } = this.flags;
@@ -50,20 +75,9 @@ export default class ResearchStatus extends BaseCommand {
50
75
  // wrong, rather than later surfacing as a misattributed parse failure.
51
76
  for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
52
77
  if (msg)
53
- this.error(msg, { exit: 2 });
54
- }
55
- let target;
56
- try {
57
- target = resolveResearchTarget({
58
- configDir: this.config.configDir,
59
- cwd: process.cwd(),
60
- dataDir: this.config.dataDir,
61
- url,
62
- });
63
- }
64
- catch (err) {
65
- this.error(`Invalid URL: ${err.message}`, { exit: 2 });
78
+ this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
66
79
  }
80
+ const target = this.resolveResearchTargetOrFail(url);
67
81
  const { cacheKey, located, normalizedUrl, roots } = target;
68
82
  const cached = located?.artifact ?? null;
69
83
  const currentTime = new Date();
@@ -72,16 +86,28 @@ export default class ResearchStatus extends BaseCommand {
72
86
  // On a hit, report where it actually lives; on a miss, where a fetch would write it.
73
87
  const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
74
88
  if (!this.jsonEnabled()) {
75
- this.log(`URL: ${normalizedUrl}`);
76
- this.log(`Cache Key: ${cacheKey}`);
77
- this.log(`Cache Path: ${artifactPath}`);
78
- this.log(`Status: ${result.status}`);
79
- this.log(`Freshness: ${result.freshness}`);
80
- this.log(`Action: ${result.action}`);
89
+ this.log(`${'URL:'.padEnd(25)} ${normalizedUrl}`);
90
+ this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
91
+ this.log(`${'Cache Path:'.padEnd(25)} ${artifactPath}`);
92
+ this.log(`${'Status:'.padEnd(25)} ${result.status}`);
93
+ this.log(`${'Freshness:'.padEnd(25)} ${result.freshness}`);
94
+ this.log(`${'Action:'.padEnd(25)} ${result.action}`);
95
+ }
96
+ // Align with inspect's CACHE_MISS contract: a miss is actionable, not a usage error. Exit 1
97
+ // lets scripts branch on $? while JSON still returns the structured status payload. Unlike
98
+ // inspect, we do not throw here — status must always return structured data even on a miss.
99
+ if (result.status === 'miss') {
100
+ // Human mode: actionable hint on stderr. JSON mode: same info lives in the envelope (see
101
+ // toSuccessJson) so agents get code/suggestions without parsing warn() line wraps.
102
+ if (!this.jsonEnabled()) {
103
+ this.warn(`Cache miss — run: ${this.config.bin} ${normalizedUrl}`);
104
+ }
105
+ process.exitCode = 1;
81
106
  }
82
107
  return {
83
108
  cacheKey,
84
109
  cachePath: artifactPath,
110
+ normalizedUrl,
85
111
  status: result.status,
86
112
  freshness: result.freshness,
87
113
  action: result.action,
@@ -1,4 +1,5 @@
1
1
  import ConfigGet from './commands/config/get.js';
2
+ import ConfigIndex from './commands/config/index.js';
2
3
  import ConfigList from './commands/config/list.js';
3
4
  import ConfigSet from './commands/config/set.js';
4
5
  import ConfigUnset from './commands/config/unset.js';
@@ -17,6 +18,7 @@ export declare const commands: {
17
18
  prune: typeof PruneCommand;
18
19
  search: typeof SearchCommand;
19
20
  status: typeof StatusCommand;
21
+ config: typeof ConfigIndex;
20
22
  'config:get': typeof ConfigGet;
21
23
  'config:list': typeof ConfigList;
22
24
  'config:set': typeof ConfigSet;
package/dist/commands.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import ConfigGet from './commands/config/get.js';
2
+ import ConfigIndex from './commands/config/index.js';
2
3
  import ConfigList from './commands/config/list.js';
3
4
  import ConfigSet from './commands/config/set.js';
4
5
  import ConfigUnset from './commands/config/unset.js';
@@ -17,6 +18,7 @@ export const commands = {
17
18
  prune: PruneCommand,
18
19
  search: SearchCommand,
19
20
  status: StatusCommand,
21
+ config: ConfigIndex,
20
22
  'config:get': ConfigGet,
21
23
  'config:list': ConfigList,
22
24
  'config:set': ConfigSet,