@taurgis/bonsai 1.0.5 → 3.0.0

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 (126) hide show
  1. package/README.md +59 -12
  2. package/agents/agents/official-docs-researcher.agent.md +15 -26
  3. package/agents/agents/salesforce-docs-researcher.agent.md +17 -35
  4. package/agents/hooks/README.md +2 -9
  5. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +1 -1
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +1 -1
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +1 -1
  8. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +1 -1
  9. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +1 -1
  10. package/agents/instructions/salesforce-research.instructions.md +7 -6
  11. package/agents/instructions/web-research.instructions.md +3 -2
  12. package/agents/skills/web-research/SKILL.md +24 -32
  13. package/bin/cli.mjs +44 -14
  14. package/dist/base-command.d.ts +17 -1
  15. package/dist/base-command.js +70 -9
  16. package/dist/commands/config/base.d.ts +2 -0
  17. package/dist/commands/config/base.js +15 -1
  18. package/dist/commands/config/get.d.ts +1 -1
  19. package/dist/commands/config/get.js +4 -5
  20. package/dist/commands/config/index.d.ts +12 -0
  21. package/dist/commands/config/index.js +34 -0
  22. package/dist/commands/config/list.js +2 -2
  23. package/dist/commands/config/set.js +18 -12
  24. package/dist/commands/config/unset.d.ts +1 -1
  25. package/dist/commands/config/unset.js +9 -6
  26. package/dist/commands/fetch.d.ts +2 -0
  27. package/dist/commands/fetch.js +69 -45
  28. package/dist/commands/import.d.ts +3 -0
  29. package/dist/commands/import.js +91 -53
  30. package/dist/commands/inspect.d.ts +8 -0
  31. package/dist/commands/inspect.js +59 -30
  32. package/dist/commands/list.d.ts +5 -3
  33. package/dist/commands/list.js +60 -35
  34. package/dist/commands/prune.d.ts +2 -1
  35. package/dist/commands/prune.js +62 -19
  36. package/dist/commands/status.d.ts +8 -0
  37. package/dist/commands/status.js +84 -26
  38. package/dist/commands.d.ts +2 -2
  39. package/dist/commands.js +2 -2
  40. package/dist/hooks/command-not-found/suggest.d.ts +7 -1
  41. package/dist/hooks/command-not-found/suggest.js +100 -46
  42. package/dist/lib/argv.d.ts +10 -0
  43. package/dist/lib/argv.js +97 -0
  44. package/dist/lib/color.d.ts +20 -0
  45. package/dist/lib/color.js +36 -0
  46. package/dist/lib/envelope.d.ts +36 -0
  47. package/dist/lib/envelope.js +82 -0
  48. package/dist/lib/help-preflight.d.ts +15 -0
  49. package/dist/lib/help-preflight.js +81 -0
  50. package/dist/lib/json-meta.d.ts +10 -0
  51. package/dist/lib/json-meta.js +98 -0
  52. package/dist/lib/limit-flag.d.ts +1 -0
  53. package/dist/lib/limit-flag.js +24 -0
  54. package/dist/lib/research/{search-index.d.ts → artifact-index.d.ts} +5 -5
  55. package/dist/lib/research/{search-index.js → artifact-index.js} +10 -10
  56. package/dist/lib/research/browser.d.ts +0 -8
  57. package/dist/lib/research/browser.js +20 -43
  58. package/dist/lib/research/regression.d.ts +18 -0
  59. package/dist/lib/research/regression.js +47 -0
  60. package/dist/lib/research/revalidate.js +3 -1
  61. package/dist/lib/research/schema.d.ts +5 -2
  62. package/dist/lib/research/schema.js +12 -1
  63. package/dist/lib/research/storage.d.ts +0 -1
  64. package/dist/lib/research/storage.js +15 -13
  65. package/dist/lib/research/url.d.ts +22 -0
  66. package/dist/lib/research/url.js +63 -1
  67. package/dist/lib/text.d.ts +5 -1
  68. package/dist/lib/text.js +11 -1
  69. package/dist/sites/coveo.d.ts +7 -0
  70. package/dist/sites/coveo.js +33 -0
  71. package/dist/sites/index.js +1 -2
  72. package/dist/sites/salesforce/fetch-page.js +1 -1
  73. package/dist/sites/salesforce/index.js +3 -8
  74. package/dist/sites/salesforce-developer/fetch-page.js +3 -1
  75. package/dist/sites/salesforce-developer/index.js +1 -2
  76. package/dist/sites/salesforce-doc-fetch.d.ts +11 -0
  77. package/dist/sites/salesforce-doc-fetch.js +117 -76
  78. package/dist/sites/salesforce-dom-probe.d.ts +16 -0
  79. package/dist/sites/salesforce-dom-probe.js +114 -0
  80. package/dist/sites/types.d.ts +0 -6
  81. package/oclif.manifest.json +153 -155
  82. package/package.json +38 -25
  83. package/dist/commands/index.d.ts +0 -35
  84. package/dist/commands/index.js +0 -306
  85. package/dist/commands/research/config/base.d.ts +0 -23
  86. package/dist/commands/research/config/base.js +0 -46
  87. package/dist/commands/research/config/get.d.ts +0 -20
  88. package/dist/commands/research/config/get.js +0 -45
  89. package/dist/commands/research/config/list.d.ts +0 -17
  90. package/dist/commands/research/config/list.js +0 -42
  91. package/dist/commands/research/config/set.d.ts +0 -31
  92. package/dist/commands/research/config/set.js +0 -94
  93. package/dist/commands/research/config/unset.d.ts +0 -21
  94. package/dist/commands/research/config/unset.js +0 -59
  95. package/dist/commands/research/import.d.ts +0 -38
  96. package/dist/commands/research/import.js +0 -313
  97. package/dist/commands/research/inspect.d.ts +0 -13
  98. package/dist/commands/research/inspect.js +0 -86
  99. package/dist/commands/research/list.d.ts +0 -25
  100. package/dist/commands/research/list.js +0 -143
  101. package/dist/commands/research/prune.d.ts +0 -24
  102. package/dist/commands/research/prune.js +0 -141
  103. package/dist/commands/research/search.d.ts +0 -32
  104. package/dist/commands/research/search.js +0 -334
  105. package/dist/commands/research/status.d.ts +0 -17
  106. package/dist/commands/research/status.js +0 -108
  107. package/dist/commands/research.d.ts +0 -36
  108. package/dist/commands/research.js +0 -304
  109. package/dist/commands/search.d.ts +0 -31
  110. package/dist/commands/search.js +0 -329
  111. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +0 -1
  112. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +0 -2
  113. package/dist/lib/research/docs/discovery-index.d.ts +0 -14
  114. package/dist/lib/research/docs/discovery-index.js +0 -112
  115. package/dist/lib/research/docs/remote-search-runner.d.ts +0 -21
  116. package/dist/lib/research/docs/remote-search-runner.js +0 -114
  117. package/dist/lib/research/docs/remote-search.d.ts +0 -14
  118. package/dist/lib/research/docs/remote-search.js +0 -101
  119. package/dist/lib/research/docs/search-index.d.ts +0 -13
  120. package/dist/lib/research/docs/search-index.js +0 -176
  121. package/dist/sites/salesforce/browser-search.d.ts +0 -7
  122. package/dist/sites/salesforce/browser-search.js +0 -82
  123. package/dist/sites/salesforce/coveo.d.ts +0 -23
  124. package/dist/sites/salesforce/coveo.js +0 -161
  125. package/dist/sites/salesforce/token.d.ts +0 -26
  126. package/dist/sites/salesforce/token.js +0 -154
@@ -10,13 +10,15 @@ export default class ResearchList extends BaseCommand<typeof ResearchList> {
10
10
  static flags: {
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
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
14
  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>;
15
+ 'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ 'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
17
  limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
17
18
  };
18
19
  static stdoutIsPrimaryData: boolean;
19
- private validateListFlags;
20
+ private matchesTopic;
21
+ private matchesTags;
20
22
  private matchesFilters;
21
23
  private scanCacheDirForList;
22
24
  private logListResults;
@@ -3,72 +3,86 @@ 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';
8
+ import { limitFlag } from '../lib/limit-flag.js';
9
+ import { artifactMatchesUrlFilter } from '../lib/research/url.js';
10
+ import { colors } from '../lib/color.js';
7
11
  // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at 100.
8
12
  const LIST_LABELS = { noun: 'cached research', order: 'first', maxLimit: 100 };
13
+ // `list` answers "what pages/notes do I have?" and deliberately omits section children (see
14
+ // scanCacheDirForList), so `section` is not an offered filter — every other artifact type can appear.
15
+ const LISTABLE_ARTIFACT_TYPES = ARTIFACT_TYPES.filter((type) => type !== 'section');
9
16
  export default class ResearchList extends BaseCommand {
10
17
  static id = 'list';
11
18
  static summary = 'List cached research artifacts by metadata filters.';
12
19
  static description = 'Lists cached research artifacts, including metadata details like path, source count, freshness, token estimates, and quality metrics without printing full content.';
13
20
  static examples = [
14
21
  {
15
- description: 'List all cached entries',
22
+ description: 'list all cached entries',
16
23
  command: '<%= config.bin %> list',
17
24
  },
18
25
  {
19
- description: 'List cached entries for a specific topic with JSON output',
26
+ description: 'list cached entries for a specific topic with JSON output',
20
27
  command: '<%= config.bin %> list --topic "React Suspense" --json',
21
28
  },
22
29
  {
23
- description: 'List only fresh entries filtered by tags',
30
+ description: 'list only fresh entries filtered by tags',
24
31
  command: '<%= config.bin %> list --freshness fresh --tags node --tags url',
25
32
  },
33
+ {
34
+ description: 'list entries matching a specific URL glob',
35
+ command: '<%= config.bin %> list --url "https://react.dev/*"',
36
+ },
26
37
  ];
27
38
  static flags = {
28
39
  topic: Flags.string({
29
40
  char: 't',
30
- description: 'Filter by exact topic (case-insensitive).',
41
+ description: 'filter by exact topic (case-insensitive)',
31
42
  }),
32
43
  tags: Flags.string({
33
44
  char: 'g',
34
- description: 'Filter by tags (must match all tags specified).',
45
+ description: 'filter by tags (must match all tags specified)',
35
46
  multiple: true,
36
47
  }),
48
+ url: Flags.string({
49
+ description: 'filter by source URL glob pattern (case-insensitive, supports * wildcard)',
50
+ }),
37
51
  freshness: Flags.option({
38
- description: 'Filter by freshness state.',
52
+ description: 'filter by freshness state',
39
53
  options: ['fresh', 'stale_grace', 'stale_expired'],
40
54
  })(),
41
55
  'artifact-type': Flags.option({
42
- description: 'Filter by artifact type.',
43
- options: ['source', 'research_note'],
56
+ description: 'filter by artifact type',
57
+ options: LISTABLE_ARTIFACT_TYPES,
44
58
  })(),
45
59
  'capture-method': Flags.option({
46
- description: 'Filter by capture method.',
47
- options: ['static_fetch', 'browser_fallback', 'agent_supplied'],
60
+ description: 'filter by capture method',
61
+ options: CAPTURE_METHODS,
48
62
  })(),
49
- limit: Flags.integer({
50
- description: 'Maximum number of results to return (default 50, max 100).',
51
- default: 50,
52
- }),
63
+ limit: limitFlag(100, 50, 'maximum number of results to return (default 50, max 100)'),
53
64
  };
54
65
  static stdoutIsPrimaryData = true;
55
- validateListFlags() {
56
- const limit = this.flags.limit;
57
- if (limit !== undefined && (limit < 1 || limit > 100)) {
58
- this.error('Limit must be between 1 and 100.', { exit: 2 });
59
- }
66
+ matchesTopic(meta, topic) {
67
+ if (!topic)
68
+ return true;
69
+ return !!meta.topic && meta.topic.trim().toLowerCase() === topic.trim().toLowerCase();
70
+ }
71
+ matchesTags(meta, tags) {
72
+ if (!tags || tags.length === 0)
73
+ return true;
74
+ const metaTagsLower = (meta.tags || []).map((t) => t.toLowerCase());
75
+ return tags.every((t) => metaTagsLower.includes(t.toLowerCase()));
60
76
  }
61
77
  matchesFilters(meta, freshness) {
62
- if (this.flags.topic &&
63
- (!meta.topic || meta.topic.trim().toLowerCase() !== this.flags.topic.trim().toLowerCase())) {
78
+ if (!this.matchesTopic(meta, this.flags.topic)) {
64
79
  return false;
65
80
  }
66
- if (this.flags.tags && this.flags.tags.length > 0) {
67
- const metaTagsLower = (meta.tags || []).map((t) => t.toLowerCase());
68
- const hasAllTags = this.flags.tags.every((t) => metaTagsLower.includes(t.toLowerCase()));
69
- if (!hasAllTags) {
70
- return false;
71
- }
81
+ if (!this.matchesTags(meta, this.flags.tags)) {
82
+ return false;
83
+ }
84
+ if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
85
+ return false;
72
86
  }
73
87
  if (this.flags['artifact-type'] && meta.artifact_type !== this.flags['artifact-type']) {
74
88
  return false;
@@ -87,8 +101,10 @@ export default class ResearchList extends BaseCommand {
87
101
  return null;
88
102
  // Section children are sub-chunks of a page, not artifacts a user "has" — they would flood the
89
103
  // 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.
104
+ // They stay discoverable through `inspect` (which lists a page's sections). `list` answers "what pages/notes do I have?", so keep it page-level. This
105
+ // unconditional guard owns the exclusion (the default no-filter case relies on it);
106
+ // LISTABLE_ARTIFACT_TYPES just hides `section` from --artifact-type so no one filters for a
107
+ // type list can never return. Keep both in sync if section handling ever changes.
92
108
  if (artifact.metadata.artifact_type === 'section')
93
109
  return null;
94
110
  const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
@@ -115,18 +131,27 @@ export default class ResearchList extends BaseCommand {
115
131
  return;
116
132
  if (finalResults.length === 0) {
117
133
  this.log('No cached research entries found matching filters.');
134
+ this.log(`\nTip: populate the cache first: ${colors.cyan(this.config.bin + ' <url>')}`);
118
135
  return;
119
136
  }
120
137
  this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
121
138
  finalResults.forEach((res, index) => {
122
- this.log(`${index + 1}. [${res.topic || 'No Topic'}] Key: ${res.cacheKey}`);
123
- this.log(` Type: ${res.artifactType} | Freshness: ${res.freshness}`);
124
- this.log(` Tokens: compressed=${res.tokenEstimate?.compressed || 0}, detailed=${res.tokenEstimate?.detailed || 0}`);
125
- this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
139
+ const topicStr = res.topic ? colors.cyan(res.topic) : colors.gray(NO_TOPIC_LABEL);
140
+ const keyStr = colors.bold(res.cacheKey);
141
+ this.log(`${index + 1}. [${topicStr}] Key: ${keyStr}`);
142
+ const freshnessColorMap = {
143
+ fresh: colors.green,
144
+ stale_grace: colors.yellow,
145
+ stale_expired: colors.red,
146
+ };
147
+ const freshnessColor = freshnessColorMap[res.freshness] || ((t) => t);
148
+ const freshnessStr = freshnessColor(res.freshness);
149
+ this.log(` Type: ${colors.bold(res.artifactType)} | Freshness: ${freshnessStr}`);
150
+ this.log(` Tokens: compressed=${colors.bold(String(res.tokenEstimate?.compressed || 0))}, detailed=${colors.bold(String(res.tokenEstimate?.detailed || 0))}`);
151
+ this.log(` Source URLs: ${colors.gray(res.sourceUrls.join(', '))}\n`);
126
152
  });
127
153
  }
128
154
  async run() {
129
- this.validateListFlags();
130
155
  const roots = loadStoreRoots({
131
156
  configDir: this.config.configDir,
132
157
  cwd: process.cwd(),
@@ -10,7 +10,8 @@ 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
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ 'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | "section" | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
16
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
17
  };
@@ -5,49 +5,84 @@ 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';
10
+ import { artifactMatchesUrlFilter } from '../lib/research/url.js';
11
+ import { colors } from '../lib/color.js';
9
12
  export default class ResearchPrune extends BaseCommand {
10
13
  static id = 'prune';
11
14
  static summary = 'Clean up old or inactive research cache entries.';
12
15
  static description = 'Prunes cached research entries based on age, inactivity, or artifact type to free up disk space.';
13
16
  static examples = [
14
17
  {
15
- description: 'Perform a dry run of pruning entries older than 90 days',
18
+ description: 'perform a dry run of pruning entries older than 90 days',
16
19
  command: '<%= config.bin %> prune --older-than 90d --dry-run',
17
20
  },
18
21
  {
19
- description: 'Actually prune entries older than 30 days that are source scrapes',
22
+ description: 'actually prune entries older than 30 days that are source scrapes',
20
23
  command: '<%= config.bin %> prune --older-than 30d --artifact-type source --yes',
21
24
  },
25
+ {
26
+ description: 'prune entries matching a URL glob pattern',
27
+ command: '<%= config.bin %> prune --url "https://react.dev/*" --yes',
28
+ },
22
29
  ];
23
30
  static flags = {
24
31
  'older-than': Flags.string({
25
- description: 'Prune entries older than duration (e.g. "30d", "90d").',
32
+ description: 'prune entries older than duration (e.g. "30d", "90d")',
26
33
  }),
27
34
  inactive: Flags.string({
28
- description: 'Prune entries inactive (unvalidated/unfetched) for duration (e.g. "14d", "30d").',
35
+ description: 'prune entries inactive (unvalidated/unfetched) for duration (e.g. "14d", "30d")',
36
+ }),
37
+ url: Flags.string({
38
+ description: 'filter pruning to source URL glob pattern (case-insensitive, supports * wildcard)',
29
39
  }),
30
40
  'artifact-type': Flags.option({
31
- description: 'Filter pruning to specific artifact type.',
32
- options: ['source', 'research_note'],
41
+ // Prune operates on every cached file, so it can target any artifact type — including the
42
+ // `section`/`index` children a page generates (e.g. to clear orphans left after a source is
43
+ // pruned with `--artifact-type source`).
44
+ description: 'filter pruning to specific artifact type',
45
+ options: ARTIFACT_TYPES,
33
46
  })(),
34
47
  'dry-run': Flags.boolean({
35
- description: 'List files that would be deleted without actually deleting them.',
48
+ description: 'list files that would be deleted without actually deleting them',
36
49
  default: false,
37
50
  }),
38
51
  yes: Flags.boolean({
39
52
  char: 'y',
40
- description: 'Skip confirmation prompt and prune files.',
53
+ description: 'confirm deletion and prune matched entries (required unless --dry-run)',
41
54
  default: false,
42
55
  }),
43
56
  };
44
57
  static stdoutIsPrimaryData = true;
45
58
  validatePruneFlags() {
46
- 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 });
59
+ if (!this.flags['older-than'] &&
60
+ !this.flags.inactive &&
61
+ !this.flags['artifact-type'] &&
62
+ !this.flags.url) {
63
+ this.error('Must specify at least one pruning filter: --older-than, --inactive, --artifact-type, or --url.', {
64
+ exit: 2,
65
+ code: 'MISSING_FILTER',
66
+ suggestions: [
67
+ `Preview age-based pruning: ${this.config.bin} prune --older-than 90d --dry-run`,
68
+ ],
69
+ });
70
+ }
71
+ if (this.flags['dry-run'] && this.flags.yes) {
72
+ this.error('--dry-run and --yes are mutually exclusive: --dry-run previews without deleting, --yes confirms deletion. Choose one.', { exit: 2, code: 'CONFLICTING_FLAGS' });
48
73
  }
49
74
  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 });
75
+ const olderThanPart = this.flags['older-than']
76
+ ? ` --older-than ${this.flags['older-than']}`
77
+ : '';
78
+ const urlPart = this.flags.url ? ` --url "${this.flags.url}"` : '';
79
+ this.error('Safety check: use --yes to confirm pruning, or --dry-run to preview files that would be deleted.', {
80
+ exit: 2,
81
+ code: 'SAFETY_CHECK_REQUIRED',
82
+ suggestions: [
83
+ `Preview first: ${this.config.bin} prune --dry-run${olderThanPart}${urlPart}`,
84
+ ],
85
+ });
51
86
  }
52
87
  // Validate durations up front: scanCacheDir swallows per-file errors, so a malformed
53
88
  // --older-than/--inactive would otherwise be silently ignored and report "0 entries".
@@ -56,7 +91,7 @@ export default class ResearchPrune extends BaseCommand {
56
91
  durationFlagError('--inactive', this.flags.inactive),
57
92
  ]) {
58
93
  if (msg)
59
- this.error(msg, { exit: 2 });
94
+ this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
60
95
  }
61
96
  }
62
97
  shouldPrune(meta, currentTime) {
@@ -67,6 +102,9 @@ export default class ResearchPrune extends BaseCommand {
67
102
  if (this.flags['artifact-type'] && meta.artifact_type !== this.flags['artifact-type']) {
68
103
  return false;
69
104
  }
105
+ if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
106
+ return false;
107
+ }
70
108
  if (this.flags['older-than']) {
71
109
  const olderThanMs = parseTtlToMs(this.flags['older-than']);
72
110
  if (ageMs < olderThanMs) {
@@ -125,18 +163,23 @@ export default class ResearchPrune extends BaseCommand {
125
163
  let prunedCount = 0;
126
164
  if (dryRun) {
127
165
  if (!this.jsonEnabled()) {
128
- const noun = pluralize(count, 'entry', 'entries');
129
- this.log(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`);
130
- filesToPrune.forEach((f) => {
131
- this.log(`- [${f.topic || 'No Topic'}] Key: ${f.cacheKey} (${f.url || 'Imported note'})`);
132
- });
166
+ if (count === 0) {
167
+ this.log(colors.yellow('[Dry Run] No research cache entries match the given filters.'));
168
+ }
169
+ else {
170
+ const noun = pluralize(count, 'entry', 'entries');
171
+ this.log(colors.yellow(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`));
172
+ filesToPrune.forEach((f) => {
173
+ this.log(`- [${f.topic ? colors.cyan(f.topic) : colors.gray(NO_TOPIC_LABEL)}] Key: ${colors.bold(f.cacheKey)} (${colors.gray(f.url || 'Imported note')})`);
174
+ });
175
+ }
133
176
  }
134
177
  }
135
178
  else {
136
179
  prunedCount = this.deletePruneCandidates(filesToPrune);
137
180
  if (!this.jsonEnabled()) {
138
181
  const noun = pluralize(count, 'entry', 'entries');
139
- this.log(`Successfully pruned ${prunedCount} of ${count} research cache ${noun}.`);
182
+ this.log(colors.green(`Successfully pruned ${prunedCount} of ${count} research cache ${noun}.`));
140
183
  }
141
184
  }
142
185
  return {
@@ -3,6 +3,11 @@ 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
+ }[];
10
+ static strict: boolean;
6
11
  static args: {
7
12
  url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
8
13
  };
@@ -12,5 +17,8 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
12
17
  'max-age': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
18
  };
14
19
  static stdoutIsPrimaryData: boolean;
20
+ /** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
21
+ protected toSuccessJson(data: unknown): Record<string, unknown>;
15
22
  run(): Promise<unknown>;
23
+ private checkSingleStatus;
16
24
  }
@@ -1,8 +1,9 @@
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
+ import { colors } from '../lib/color.js';
6
7
  function resolveFreshness(cached, currentTime, ttl, maxAge) {
7
8
  const isMaxAgeExpired = maxAge ? checkMaxAgeExpired(cached, currentTime, maxAge) : false;
8
9
  return isMaxAgeExpired
@@ -22,66 +23,123 @@ export default class ResearchStatus extends BaseCommand {
22
23
  static id = 'status';
23
24
  static summary = 'Check the cache status of a URL without fetching or writing.';
24
25
  static description = 'Reports whether the URL is cached, its freshness state, and what action (fetch, revalidate, or cached return) the root fetch command would take.';
26
+ static examples = [
27
+ {
28
+ description: 'check if a URL is already in the cache',
29
+ command: '<%= config.bin %> <%= command.id %> https://example.com/docs',
30
+ },
31
+ {
32
+ description: 'check status against a specific freshness tier and TTL',
33
+ command: '<%= config.bin %> <%= command.id %> https://example.com/docs --tier volatile --ttl 2h',
34
+ },
35
+ ];
36
+ static strict = false;
25
37
  static args = {
26
38
  url: Args.string({
27
39
  required: true,
28
- description: 'The URL to check.',
40
+ description: 'the URL to check',
29
41
  }),
30
42
  };
31
43
  static flags = {
32
44
  tier: Flags.option({
33
- description: 'Freshness tier policy to evaluate against.',
45
+ description: 'freshness tier policy to evaluate against',
34
46
  options: ['stable', 'standard', 'volatile'],
35
47
  default: 'standard',
36
48
  })(),
37
49
  ttl: Flags.string({
38
50
  char: 'l',
39
- description: 'Custom TTL duration to evaluate against (e.g. "2h", "7d").',
51
+ description: 'custom TTL duration to evaluate against (e.g. "2h", "7d")',
40
52
  }),
41
53
  'max-age': Flags.string({
42
- description: 'Maximum age of cache to accept (e.g. "1d", "30d").',
54
+ description: 'maximum age of cache to accept (e.g. "1d", "30d")',
43
55
  }),
44
56
  };
45
57
  static stdoutIsPrimaryData = true;
58
+ /** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
59
+ toSuccessJson(data) {
60
+ const envelope = super.toSuccessJson(data);
61
+ const list = Array.isArray(data) ? data : [data];
62
+ const misses = list.filter((d) => d && d.status === 'miss');
63
+ if (misses.length === 0)
64
+ return envelope;
65
+ const firstMiss = misses[0];
66
+ const normalizedUrl = firstMiss.normalizedUrl ?? '';
67
+ const suggestions = misses.map((m) => `Fetch and cache it first: ${this.config.bin} ${m.normalizedUrl}`);
68
+ const stderr = formatErrorForJson({
69
+ message: `Cache miss for ${normalizedUrl}` +
70
+ (misses.length > 1 ? ` and ${misses.length - 1} other URLs` : ''),
71
+ code: 'CACHE_MISS',
72
+ suggestions,
73
+ });
74
+ return { ...envelope, ok: false, exitCode: 1, stderr, code: 'CACHE_MISS', suggestions };
75
+ }
46
76
  async run() {
47
- const { url } = this.args;
77
+ const urls = this.parsedArgv;
48
78
  const { ttl, tier, 'max-age': maxAge } = this.flags;
49
79
  // Validate the duration flags up front so a malformed value reports the exact flag that is
50
80
  // wrong, rather than later surfacing as a misattributed parse failure.
51
81
  for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
52
82
  if (msg)
53
- this.error(msg, { exit: 2 });
83
+ this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
54
84
  }
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
- });
85
+ const results = [];
86
+ let hasMiss = false;
87
+ const currentTime = new Date();
88
+ for (const url of urls) {
89
+ const res = this.checkSingleStatus(url, currentTime, ttl, maxAge, urls.length > 1);
90
+ if (res.status === 'miss') {
91
+ hasMiss = true;
92
+ }
93
+ results.push(res);
63
94
  }
64
- catch (err) {
65
- this.error(`Invalid URL: ${err.message}`, { exit: 2 });
95
+ if (hasMiss) {
96
+ process.exitCode = 1;
66
97
  }
98
+ return urls.length === 1 ? results[0] : results;
99
+ }
100
+ checkSingleStatus(url, currentTime, ttl, maxAge, showSeparator) {
101
+ const target = this.resolveResearchTargetOrFail(url);
67
102
  const { cacheKey, located, normalizedUrl, roots } = target;
68
103
  const cached = located?.artifact ?? null;
69
- const currentTime = new Date();
70
- // Both duration flags were validated above, so freshness resolution cannot throw on a parse error.
71
104
  const result = describeCacheStatus(cached, currentTime, ttl, maxAge);
72
- // On a hit, report where it actually lives; on a miss, where a fetch would write it.
73
105
  const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
74
106
  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}`);
107
+ const statusColorMap = {
108
+ hit: colors.green,
109
+ stale: colors.yellow,
110
+ miss: colors.red,
111
+ };
112
+ const freshnessColorMap = {
113
+ fresh: colors.green,
114
+ stale_grace: colors.yellow,
115
+ stale_expired: colors.red,
116
+ none: colors.gray,
117
+ };
118
+ const actionColorMap = {
119
+ would_return_cached: colors.green,
120
+ would_revalidate: colors.yellow,
121
+ would_fetch: colors.red,
122
+ };
123
+ const statusColor = statusColorMap[result.status] || ((t) => t);
124
+ const freshnessColor = freshnessColorMap[result.freshness] || ((t) => t);
125
+ const actionColor = actionColorMap[result.action] || ((t) => t);
126
+ this.log(`${colors.cyan('URL:'.padEnd(25))} ${colors.bold(normalizedUrl)}`);
127
+ this.log(`${colors.cyan('Cache Key:'.padEnd(25))} ${colors.bold(cacheKey)}`);
128
+ this.log(`${colors.cyan('Cache Path:'.padEnd(25))} ${colors.gray(artifactPath)}`);
129
+ this.log(`${colors.cyan('Status:'.padEnd(25))} ${statusColor(result.status)}`);
130
+ this.log(`${colors.cyan('Freshness:'.padEnd(25))} ${freshnessColor(result.freshness)}`);
131
+ this.log(`${colors.cyan('Action:'.padEnd(25))} ${actionColor(result.action)}`);
132
+ if (showSeparator) {
133
+ this.log('-'.repeat(40));
134
+ }
135
+ }
136
+ if (result.status === 'miss' && !this.jsonEnabled()) {
137
+ this.warn(`Cache miss — run: ${this.config.bin} ${normalizedUrl}`);
81
138
  }
82
139
  return {
83
140
  cacheKey,
84
141
  cachePath: artifactPath,
142
+ normalizedUrl,
85
143
  status: result.status,
86
144
  freshness: result.freshness,
87
145
  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';
@@ -7,7 +8,6 @@ import ImportCommand from './commands/import.js';
7
8
  import InspectCommand from './commands/inspect.js';
8
9
  import ListCommand from './commands/list.js';
9
10
  import PruneCommand from './commands/prune.js';
10
- import SearchCommand from './commands/search.js';
11
11
  import StatusCommand from './commands/status.js';
12
12
  export declare const commands: {
13
13
  fetch: typeof FetchCommand;
@@ -15,8 +15,8 @@ export declare const commands: {
15
15
  inspect: typeof InspectCommand;
16
16
  list: typeof ListCommand;
17
17
  prune: typeof PruneCommand;
18
- search: typeof SearchCommand;
19
18
  status: typeof StatusCommand;
19
+ config: typeof ConfigIndex;
20
20
  'config:get': typeof ConfigGet;
21
21
  'config:list': typeof ConfigList;
22
22
  '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';
@@ -7,7 +8,6 @@ import ImportCommand from './commands/import.js';
7
8
  import InspectCommand from './commands/inspect.js';
8
9
  import ListCommand from './commands/list.js';
9
10
  import PruneCommand from './commands/prune.js';
10
- import SearchCommand from './commands/search.js';
11
11
  import StatusCommand from './commands/status.js';
12
12
  export const commands = {
13
13
  fetch: FetchCommand,
@@ -15,8 +15,8 @@ export const commands = {
15
15
  inspect: InspectCommand,
16
16
  list: ListCommand,
17
17
  prune: PruneCommand,
18
- search: SearchCommand,
19
18
  status: StatusCommand,
19
+ config: ConfigIndex,
20
20
  'config:get': ConfigGet,
21
21
  'config:list': ConfigList,
22
22
  'config:set': ConfigSet,
@@ -1,4 +1,10 @@
1
- import { type Hook } from '@oclif/core';
1
+ import { type Hook, type Interfaces } from '@oclif/core';
2
+ export declare function buildCommandNotFoundDetails(id: string, argv: string[] | undefined, config: Interfaces.Config): {
3
+ code: 'COMMAND_NOT_FOUND' | 'MISSING_URL_SCHEME' | 'UNEXPECTED_ARGUMENT';
4
+ command: string;
5
+ jsonMode: boolean;
6
+ message: string;
7
+ };
2
8
  /**
3
9
  * Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
4
10
  * throwing here (via `this.error`) replaces that default with a more helpful message. A correction