@taurgis/bonsai 3.2.2 → 4.1.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 (100) hide show
  1. package/README.md +25 -2
  2. package/agents/agents/official-docs-researcher.agent.md +102 -0
  3. package/agents/agents/salesforce-docs-researcher.agent.md +17 -15
  4. package/agents/hooks/README.md +18 -2
  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 +5 -4
  11. package/agents/instructions/web-research.instructions.md +45 -0
  12. package/agents/skills/web-research/SKILL.md +139 -0
  13. package/bin/cli.mjs +7 -4
  14. package/dist/base-command.d.ts +42 -0
  15. package/dist/base-command.js +106 -6
  16. package/dist/commands/config/base.d.ts +2 -7
  17. package/dist/commands/config/base.js +4 -17
  18. package/dist/commands/config/get.js +2 -2
  19. package/dist/commands/config/list.js +2 -2
  20. package/dist/commands/config/set.js +2 -2
  21. package/dist/commands/config/unset.js +2 -2
  22. package/dist/commands/context.d.ts +20 -0
  23. package/dist/commands/context.js +65 -0
  24. package/dist/commands/fetch.js +4 -0
  25. package/dist/commands/inspect.js +11 -2
  26. package/dist/commands/list.d.ts +23 -11
  27. package/dist/commands/list.js +110 -121
  28. package/dist/commands/prune.d.ts +2 -0
  29. package/dist/commands/prune.js +35 -4
  30. package/dist/commands/search.d.ts +43 -0
  31. package/dist/commands/search.js +233 -0
  32. package/dist/commands/setup.d.ts +27 -0
  33. package/dist/commands/setup.js +148 -0
  34. package/dist/commands/status.js +10 -0
  35. package/dist/commands.d.ts +6 -0
  36. package/dist/commands.js +6 -0
  37. package/dist/hooks/command-not-found/suggest.d.ts +1 -1
  38. package/dist/hooks/command-not-found/suggest.js +12 -2
  39. package/dist/lib/argv.d.ts +5 -0
  40. package/dist/lib/argv.js +30 -5
  41. package/dist/lib/cli-flag-manifest.d.ts +7 -0
  42. package/dist/lib/cli-flag-manifest.js +7 -0
  43. package/dist/lib/cli-presentation.d.ts +25 -4
  44. package/dist/lib/cli-presentation.js +34 -4
  45. package/dist/lib/cli-result-types.d.ts +84 -1
  46. package/dist/lib/color.d.ts +2 -0
  47. package/dist/lib/color.js +6 -0
  48. package/dist/lib/common-metadata-filter-flags.d.ts +14 -0
  49. package/dist/lib/common-metadata-filter-flags.js +37 -0
  50. package/dist/lib/config/index.d.ts +2 -2
  51. package/dist/lib/config/index.js +2 -2
  52. package/dist/lib/config/io.d.ts +9 -0
  53. package/dist/lib/config/io.js +71 -21
  54. package/dist/lib/config/schema.d.ts +2 -0
  55. package/dist/lib/config/schema.js +7 -0
  56. package/dist/lib/envelope.js +11 -4
  57. package/dist/lib/next-command.d.ts +15 -0
  58. package/dist/lib/next-command.js +40 -0
  59. package/dist/lib/prune-flags.d.ts +2 -0
  60. package/dist/lib/prune-flags.js +18 -6
  61. package/dist/lib/research/artifact-index.js +7 -1
  62. package/dist/lib/research/artifact.js +20 -2
  63. package/dist/lib/research/browser.d.ts +12 -0
  64. package/dist/lib/research/browser.js +63 -8
  65. package/dist/lib/research/cli-io.d.ts +7 -0
  66. package/dist/lib/research/context-dashboard.d.ts +19 -0
  67. package/dist/lib/research/context-dashboard.js +43 -0
  68. package/dist/lib/research/fetch-command-service.js +90 -23
  69. package/dist/lib/research/fetch-result.d.ts +1 -0
  70. package/dist/lib/research/fetch-result.js +4 -0
  71. package/dist/lib/research/fetcher.js +3 -1
  72. package/dist/lib/research/import-command-service.js +22 -1
  73. package/dist/lib/research/list-row.d.ts +15 -0
  74. package/dist/lib/research/list-row.js +31 -0
  75. package/dist/lib/research/metadata-filters.d.ts +39 -0
  76. package/dist/lib/research/metadata-filters.js +71 -0
  77. package/dist/lib/research/metadata-flags.d.ts +34 -0
  78. package/dist/lib/research/metadata-flags.js +45 -0
  79. package/dist/lib/research/prompt-injection.d.ts +2 -1
  80. package/dist/lib/research/prompt-injection.js +18 -2
  81. package/dist/lib/research/resolve-target.d.ts +2 -0
  82. package/dist/lib/research/resolve-target.js +1 -1
  83. package/dist/lib/research/schema.d.ts +2 -0
  84. package/dist/lib/research/schema.js +4 -0
  85. package/dist/lib/research/search-match.d.ts +39 -0
  86. package/dist/lib/research/search-match.js +134 -0
  87. package/dist/lib/research/storage.d.ts +4 -7
  88. package/dist/lib/research/storage.js +26 -25
  89. package/dist/lib/scope-flags.d.ts +19 -0
  90. package/dist/lib/scope-flags.js +27 -0
  91. package/dist/lib/setup/agents.d.ts +16 -0
  92. package/dist/lib/setup/agents.js +20 -0
  93. package/dist/lib/setup/bin-command.d.ts +14 -0
  94. package/dist/lib/setup/bin-command.js +42 -0
  95. package/dist/lib/setup/hook-file-io.d.ts +37 -0
  96. package/dist/lib/setup/hook-file-io.js +80 -0
  97. package/dist/lib/text.d.ts +20 -8
  98. package/dist/lib/text.js +32 -9
  99. package/oclif.manifest.json +406 -13
  100. package/package.json +2 -1
@@ -1,34 +1,49 @@
1
1
  import { Flags } from '@oclif/core';
2
+ import { homedir } from 'node:os';
2
3
  import { BaseCommand } from '../base-command.js';
3
4
  import { scanCacheDirs } from '../lib/research/storage.js';
4
5
  import { loadStoreRoots } from '../lib/research/store-roots.js';
5
6
  import { evaluateFreshness } from '../lib/research/freshness.js';
6
- import { ARTIFACT_TYPES, CAPTURE_METHODS, } from '../lib/research/schema.js';
7
- import { NO_TOPIC_LABEL, resultListHeading } from '../lib/text.js';
7
+ import { collapseHomeDir, formatTip, resultListHeading, } from '../lib/text.js';
8
8
  import { limitFlag } from '../lib/limit-flag.js';
9
- import { artifactMatchesUrlFilter, emptyUrlFilterError } from '../lib/research/url.js';
10
- import { colors } from '../lib/color.js';
11
- import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
9
+ import { countByFreshness, toListRow } from '../lib/research/list-row.js';
10
+ import { emptyUrlFilterError } from '../lib/research/url.js';
11
+ import { matchesCommonMetadataFilters, hasActiveMetadataFilters, emptyTopicFilterError, emptyTagsFilterError, } from '../lib/research/metadata-filters.js';
12
+ import { commonMetadataFilterFlags } from '../lib/common-metadata-filter-flags.js';
13
+ import { buildNextLimitCommand } from '../lib/next-command.js';
14
+ import { colors, FRESHNESS_COLOR } from '../lib/color.js';
15
+ import { CLI_FLAG_DESCRIPTIONS, formatResultRowHeader, formatResultRowSourceUrls, formatResultRowTokens, } from '../lib/cli-presentation.js';
12
16
  // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at this value.
13
17
  const LIST_DEFAULT_MAX_LIMIT = 100;
18
+ // A small default keeps an unfiltered `list` from flooding an agent's context with everything ever
19
+ // cached; `summary.truncated`/`nextCommand` (and the human-mode tip) make raising --limit an
20
+ // explicit, deliberate next step rather than a silent, unbounded dump.
21
+ const LIST_DEFAULT_LIMIT = 10;
14
22
  const LIST_LABELS = {
15
23
  noun: 'cached research',
16
24
  order: 'first',
17
- maxLimit: LIST_DEFAULT_MAX_LIMIT,
18
25
  };
19
- // `list` answers "what pages/notes do I have?" and deliberately omits section children (see
20
- // scanCacheDirForList), so `section` is not an offered filter — every other artifact type can appear.
21
- const LISTABLE_ARTIFACT_TYPES = ARTIFACT_TYPES.filter((type) => type !== 'section');
26
+ /** Project a full row down to the default minimal shape (see {@link ListRowMinimal}). */
27
+ function toMinimalListRow(row) {
28
+ return {
29
+ sourceUrls: row.sourceUrls,
30
+ topic: row.topic,
31
+ freshness: row.freshness,
32
+ tokenEstimate: row.tokenEstimate,
33
+ };
34
+ }
22
35
  /** List cached research artifacts by metadata filters. */
23
36
  export default class ResearchList extends BaseCommand {
24
37
  static id = 'list';
25
38
  static summary = 'List cached research artifacts by metadata';
26
- static description = 'List page-level cached artifacts with source URLs, topic, tags, freshness, capture method, and token estimates.';
39
+ static description = 'List page-level cached artifacts by source URL, topic, freshness, and token estimate. ' +
40
+ 'Pass --full for every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps).';
27
41
  /**
28
- * When `--json` results were capped by `--limit`, attached to the success envelope so agents can
29
- * detect truncation without scraping prose (#91 / AUDIT_71_FINAL #73 deferred).
42
+ * Aggregate counts (and an explicit empty/truncation signal) attached to the success envelope so
43
+ * agents get a cache-wide summary without a separate round trip (AXI principles: pre-computed
44
+ * aggregates, definitive empty states). Always set by `run()` before it returns.
30
45
  */
31
- jsonTruncation = null;
46
+ jsonSummary = null;
32
47
  static examples = [
33
48
  {
34
49
  description: 'list cached entries',
@@ -46,66 +61,36 @@ export default class ResearchList extends BaseCommand {
46
61
  description: 'list entries matching a URL glob',
47
62
  command: '<%= config.bin %> list --url "https://react.dev/*"',
48
63
  },
64
+ {
65
+ description: 'list every metadata field instead of the minimal default row',
66
+ command: '<%= config.bin %> list --full --json',
67
+ },
49
68
  ];
50
69
  static flags = {
51
- topic: Flags.string({
52
- char: 't',
53
- description: CLI_FLAG_DESCRIPTIONS.filterTopic,
54
- }),
55
- tags: Flags.string({
56
- char: 'g',
57
- description: CLI_FLAG_DESCRIPTIONS.filterTags,
58
- multiple: true,
70
+ ...commonMetadataFilterFlags(CLI_FLAG_DESCRIPTIONS.listArtifactType),
71
+ limit: limitFlag(LIST_DEFAULT_MAX_LIMIT, LIST_DEFAULT_LIMIT, `result count (max ${LIST_DEFAULT_MAX_LIMIT}, default ${LIST_DEFAULT_LIMIT})`),
72
+ full: Flags.boolean({
73
+ default: false,
74
+ description: CLI_FLAG_DESCRIPTIONS.listFull,
59
75
  }),
60
- url: Flags.string({
61
- description: CLI_FLAG_DESCRIPTIONS.sourceUrlGlob,
62
- }),
63
- freshness: Flags.option({
64
- description: 'freshness state',
65
- options: ['fresh', 'stale_grace', 'stale_expired'],
66
- })(),
67
- 'artifact-type': Flags.option({
68
- description: CLI_FLAG_DESCRIPTIONS.listArtifactType,
69
- options: LISTABLE_ARTIFACT_TYPES,
70
- })(),
71
- 'capture-method': Flags.option({
72
- description: 'capture method',
73
- options: CAPTURE_METHODS,
74
- })(),
75
- limit: limitFlag(100, 50, 'result count (default 50, max 100)'),
76
+ // Only ever set by the argv rewrite for a truly bare `bonsai` invocation (see normalizeArgv);
77
+ // hidden because it is not documented or intended for direct use.
78
+ identity: Flags.boolean({ default: false, hidden: true }),
76
79
  };
77
80
  static stdoutIsPrimaryData = true;
78
- matchesTopic(meta, topic) {
79
- if (!topic)
80
- return true;
81
- return !!meta.topic && meta.topic.trim().toLowerCase() === topic.trim().toLowerCase();
82
- }
83
- matchesTags(meta, tags) {
84
- if (!tags || tags.length === 0)
85
- return true;
86
- const metaTagsLower = meta.tags.map((t) => t.toLowerCase());
87
- return tags.every((t) => metaTagsLower.includes(t.toLowerCase()));
81
+ /** The shared filter flags as `matchesCommonMetadataFilters`/`hasActiveMetadataFilters` expect them. */
82
+ metadataFilterFlags() {
83
+ return {
84
+ topic: this.flags.topic,
85
+ tags: this.flags.tags,
86
+ url: this.flags.url,
87
+ artifactType: this.flags['artifact-type'],
88
+ captureMethod: this.flags['capture-method'],
89
+ freshness: this.flags.freshness,
90
+ };
88
91
  }
89
92
  matchesFilters(meta, freshness) {
90
- if (!this.matchesTopic(meta, this.flags.topic)) {
91
- return false;
92
- }
93
- if (!this.matchesTags(meta, this.flags.tags)) {
94
- return false;
95
- }
96
- if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
97
- return false;
98
- }
99
- if (this.flags['artifact-type'] && meta.artifact_type !== this.flags['artifact-type']) {
100
- return false;
101
- }
102
- if (this.flags['capture-method'] && meta.capture_method !== this.flags['capture-method']) {
103
- return false;
104
- }
105
- if (this.flags.freshness && freshness !== this.flags.freshness) {
106
- return false;
107
- }
108
- return true;
93
+ return matchesCommonMetadataFilters(meta, freshness, this.metadataFilterFlags());
109
94
  }
110
95
  scanCacheDirForList(readRoots, currentTime) {
111
96
  // Honor effective read-only: listing must not persist the derived search-index sidecar.
@@ -116,38 +101,30 @@ export default class ResearchList extends BaseCommand {
116
101
  // listing (one page yields dozens) and aren't in the documented source/research_note contract.
117
102
  // 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
118
103
  // unconditional guard owns the exclusion (the default no-filter case relies on it);
119
- // LISTABLE_ARTIFACT_TYPES just hides `section` from --artifact-type so no one filters for a
104
+ // PAGE_LEVEL_ARTIFACT_TYPES just hides `section` from --artifact-type so no one filters for a
120
105
  // type list can never return. Keep both in sync if section handling ever changes.
121
106
  if (artifact.metadata.artifact_type === 'section')
122
107
  return null;
123
108
  const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
124
109
  if (!this.matchesFilters(artifact.metadata, freshness))
125
110
  return null;
126
- return {
127
- cacheKey: artifact.metadata.cache_key,
128
- path: filePath,
129
- artifactType: artifact.metadata.artifact_type,
130
- sourceUrls: artifact.metadata.source_urls,
131
- topic: artifact.metadata.topic,
132
- tags: artifact.metadata.tags,
133
- freshness,
134
- captureMethod: artifact.metadata.capture_method,
135
- tokenEstimate: artifact.metadata.token_estimate,
136
- qualityNotes: artifact.metadata.quality_notes,
137
- fetchedAt: artifact.metadata.fetched_at,
138
- validatedAt: artifact.metadata.validated_at,
139
- };
111
+ return toListRow(artifact, filePath, freshness);
140
112
  }, { persistIndex: !this.readOnly });
141
113
  }
142
- hasActiveFilters() {
143
- return Boolean(this.flags.topic ||
144
- (this.flags.tags && this.flags.tags.length > 0) ||
145
- this.flags.freshness ||
146
- this.flags['artifact-type'] ||
147
- this.flags['capture-method'] ||
148
- this.flags.url);
114
+ /**
115
+ * Identity header for the AXI "home view" (bare `bonsai`, no args): the tool's own bin path and
116
+ * one-sentence description, shown before the live data so an agent orients on what it's looking
117
+ * at without a separate `--help` round trip. Human mode only — the hidden `--identity` flag is
118
+ * only ever set by the argv rewrite for a bare invocation; it is not documented for direct use.
119
+ */
120
+ logIdentityHeader() {
121
+ if (!this.flags.identity || this.jsonEnabled())
122
+ return;
123
+ const binPath = collapseHomeDir(process.argv[1] ?? this.config.bin, homedir());
124
+ this.log(`bin: ${binPath}`);
125
+ this.log(`description: ${this.config.pjson.description ?? this.config.bin}\n`);
149
126
  }
150
- logListResults(finalResults, totalMatched) {
127
+ logListResults(finalResults, totalMatched, nextCommand) {
151
128
  if (finalResults.length === 0) {
152
129
  this.emitEmptyListGuidance();
153
130
  return;
@@ -156,26 +133,22 @@ export default class ResearchList extends BaseCommand {
156
133
  return;
157
134
  this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
158
135
  finalResults.forEach((res, index) => {
159
- const topicStr = res.topic ? colors.cyan(res.topic) : colors.gray(NO_TOPIC_LABEL);
160
- const keyStr = colors.bold(res.cacheKey);
161
- this.log(`${index + 1}. [${topicStr}] Key: ${keyStr}`);
162
- const freshnessColorMap = {
163
- fresh: colors.green,
164
- stale_grace: colors.yellow,
165
- stale_expired: colors.red,
166
- };
167
- const freshnessColor = freshnessColorMap[res.freshness];
168
- const freshnessStr = freshnessColor(res.freshness);
136
+ this.log(formatResultRowHeader(index, res.topic, res.cacheKey));
137
+ const freshnessStr = FRESHNESS_COLOR[res.freshness](res.freshness);
169
138
  this.log(` Type: ${colors.bold(res.artifactType)} | Freshness: ${freshnessStr}`);
170
- this.log(` Tokens: compressed=${colors.bold(String(res.tokenEstimate?.compressed || 0))}, detailed=${colors.bold(String(res.tokenEstimate?.detailed || 0))}`);
171
- this.log(` Source URLs: ${colors.gray(res.sourceUrls.join(', '))}\n`);
139
+ this.log(formatResultRowTokens(res.tokenEstimate));
140
+ this.log(formatResultRowSourceUrls(res.sourceUrls));
172
141
  });
142
+ this.tipNextCommand(nextCommand);
173
143
  }
174
- /** Empty-cache / no-match tip (human mode only; --json returns `data: []` with no messaging). */
144
+ /**
145
+ * Empty-cache / no-match tip (human mode only). Under `--json`/`--toon`, `data: []` is paired with
146
+ * `summary.empty: true` instead of prose — see {@link toSuccessJson}.
147
+ */
175
148
  emitEmptyListGuidance() {
176
149
  if (this.jsonEnabled())
177
150
  return;
178
- const filtered = this.hasActiveFilters();
151
+ const filtered = hasActiveMetadataFilters(this.metadataFilterFlags());
179
152
  const headline = filtered
180
153
  ? 'No cached research entries match the given filters.'
181
154
  : 'No cached research entries found.';
@@ -184,12 +157,20 @@ export default class ResearchList extends BaseCommand {
184
157
  ? 'try relaxing filters, or list everything: '
185
158
  : 'populate the cache first: ';
186
159
  this.log(headline);
187
- this.log(`\nTip: ${tipLead}${colors.cyan(tipCmd)}`);
160
+ this.log(`\n${formatTip(`${tipLead}${colors.cyan(tipCmd)}`)}`);
161
+ // Bare `bonsai` (no args) redirects here (content-first default), so an empty, unfiltered cache
162
+ // is also the first thing a brand-new user sees — point at --help so command discovery isn't lost.
163
+ if (!filtered) {
164
+ this.log(formatTip(`see every command: ${colors.cyan(`${this.config.bin} --help`)}`));
165
+ }
188
166
  }
189
167
  async run() {
190
- const urlErr = emptyUrlFilterError(this.flags.url);
191
- if (urlErr)
192
- this.error(urlErr, { exit: 2, code: 'INVALID_FLAG_VALUE' });
168
+ const flagErr = emptyUrlFilterError(this.flags.url) ??
169
+ emptyTopicFilterError(this.flags.topic) ??
170
+ emptyTagsFilterError(this.flags.tags);
171
+ if (flagErr)
172
+ this.error(flagErr, { exit: 2, code: 'INVALID_FLAG_VALUE' });
173
+ this.logIdentityHeader();
193
174
  const roots = loadStoreRoots({
194
175
  configDir: this.config.configDir,
195
176
  cwd: process.cwd(),
@@ -203,23 +184,31 @@ export default class ResearchList extends BaseCommand {
203
184
  return timeB - timeA;
204
185
  });
205
186
  const finalResults = results.slice(0, this.flags.limit);
206
- // Human heading carries truncation; --json gets an envelope `truncation` object when capped.
207
- this.jsonTruncation =
208
- results.length > finalResults.length
209
- ? {
210
- totalMatched: results.length,
211
- shown: finalResults.length,
212
- limit: this.flags.limit,
213
- }
214
- : null;
215
- this.logListResults(finalResults, results.length);
216
- return finalResults;
187
+ const truncated = results.length > finalResults.length;
188
+ // Suggest exactly enough to see every match, capped at the command's own max — a caller who
189
+ // truncated at the default can raise --limit once and be done, rather than guessing a value.
190
+ const nextCommand = truncated
191
+ ? buildNextLimitCommand(this.config.bin, 'list', this.argv, Math.min(results.length, LIST_DEFAULT_MAX_LIMIT))
192
+ : null;
193
+ // Human heading carries truncation inline; --json/--toon get the same counts (plus a freshness
194
+ // breakdown and an explicit `empty` flag) as an always-present envelope `summary` object.
195
+ this.jsonSummary = {
196
+ total: results.length,
197
+ shown: finalResults.length,
198
+ limit: this.flags.limit,
199
+ truncated,
200
+ empty: results.length === 0,
201
+ byFreshness: countByFreshness(results),
202
+ nextCommand,
203
+ };
204
+ this.logListResults(finalResults, results.length, nextCommand);
205
+ return this.flags.full ? finalResults : finalResults.map(toMinimalListRow);
217
206
  }
218
- /** Attach `truncation` when the returned `data` array was capped by `--limit`. */
207
+ /** Attach the aggregate `summary` computed in `run()` (see {@link ListSummary}). */
219
208
  toSuccessJson(data) {
220
209
  const envelope = this.baseSuccessJson(data);
221
- if (!this.jsonTruncation)
210
+ if (!this.jsonSummary)
222
211
  return envelope;
223
- return { ...envelope, truncation: this.jsonTruncation };
212
+ return { ...envelope, summary: this.jsonSummary };
224
213
  }
225
214
  }
@@ -13,6 +13,8 @@ export default class ResearchPrune extends BaseCommand<typeof ResearchPrune> {
13
13
  'older-than': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
14
  inactive: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
15
  url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
18
  'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | "section" | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
19
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
20
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -7,8 +7,9 @@ import { scanCacheDir } from '../lib/research/storage.js';
7
7
  import { loadStoreRoots } from '../lib/research/store-roots.js';
8
8
  import { parseTtlToMs } from '../lib/research/freshness.js';
9
9
  import { ARTIFACT_TYPES } from '../lib/research/schema.js';
10
- import { NO_TOPIC_LABEL, pluralize } from '../lib/text.js';
10
+ import { NO_TOPIC_LABEL, pluralize, sanitizeForTerminal } from '../lib/text.js';
11
11
  import { artifactMatchesUrlFilter } from '../lib/research/url.js';
12
+ import { matchesTopicFilter, matchesTagsFilter } from '../lib/research/metadata-filters.js';
12
13
  import { pruneFlagError } from '../lib/prune-flags.js';
13
14
  import { colors } from '../lib/color.js';
14
15
  import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
@@ -16,7 +17,7 @@ import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
16
17
  export default class ResearchPrune extends BaseCommand {
17
18
  static id = 'prune';
18
19
  static summary = 'Prune cached research artifacts';
19
- static description = 'Delete cached artifacts by content age, idle time, URL glob, or artifact type.';
20
+ static description = 'Delete cached artifacts by content age, idle time, URL glob, artifact type, topic, or tags.';
20
21
  static examples = [
21
22
  {
22
23
  description: 'preview pruning entries older than 30 days',
@@ -30,6 +31,10 @@ export default class ResearchPrune extends BaseCommand {
30
31
  description: 'prune entries matching a URL glob',
31
32
  command: '<%= config.bin %> prune --url "https://react.dev/*" --yes',
32
33
  },
34
+ {
35
+ description: 'prune entries tagged as deprecated',
36
+ command: '<%= config.bin %> prune --tags deprecated --yes',
37
+ },
33
38
  ];
34
39
  static flags = {
35
40
  'older-than': Flags.string({
@@ -41,6 +46,15 @@ export default class ResearchPrune extends BaseCommand {
41
46
  url: Flags.string({
42
47
  description: CLI_FLAG_DESCRIPTIONS.sourceUrlGlob,
43
48
  }),
49
+ topic: Flags.string({
50
+ char: 't',
51
+ description: CLI_FLAG_DESCRIPTIONS.filterTopic,
52
+ }),
53
+ tags: Flags.string({
54
+ char: 'g',
55
+ description: CLI_FLAG_DESCRIPTIONS.filterTags,
56
+ multiple: true,
57
+ }),
44
58
  'artifact-type': Flags.option({
45
59
  // Prune operates on every cached file, so it can target any artifact type — including the
46
60
  // `section`/`index` children a page generates (e.g. to clear orphans left after a source is
@@ -68,6 +82,8 @@ export default class ResearchPrune extends BaseCommand {
68
82
  inactive: this.flags.inactive,
69
83
  artifactType: this.flags['artifact-type'],
70
84
  url: this.flags.url,
85
+ topic: this.flags.topic,
86
+ tags: this.flags.tags,
71
87
  dryRun: this.flags['dry-run'],
72
88
  yes: this.flags.yes,
73
89
  readOnly: this.readOnly,
@@ -83,6 +99,12 @@ export default class ResearchPrune extends BaseCommand {
83
99
  if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
84
100
  return false;
85
101
  }
102
+ if (!matchesTopicFilter(meta, this.flags.topic)) {
103
+ return false;
104
+ }
105
+ if (!matchesTagsFilter(meta, this.flags.tags)) {
106
+ return false;
107
+ }
86
108
  const now = currentTime.getTime();
87
109
  // Content age (fetched_at, else validated_at) vs idle time (validated_at, else fetched_at) are
88
110
  // deliberately distinct so a recently revalidated but originally-old page can match --older-than
@@ -118,7 +140,12 @@ export default class ResearchPrune extends BaseCommand {
118
140
  prunedCount++;
119
141
  }
120
142
  catch (err) {
121
- this.warn(`Failed to delete cache file ${f.path}: ${err.message}`);
143
+ // A failed unlink still flips the command to exit 1 (see the caller's comment), so it must
144
+ // render as an Error, not a Warning — this.error's exit:false form prints the same "Error: …"
145
+ // line and returns instead of throwing, so the remaining candidates still get processed.
146
+ this.error(`Failed to delete cache file ${f.path}: ${err.message}`, {
147
+ exit: false,
148
+ });
122
149
  }
123
150
  }
124
151
  return prunedCount;
@@ -147,8 +174,12 @@ export default class ResearchPrune extends BaseCommand {
147
174
  const noun = pluralize(count, 'entry', 'entries');
148
175
  this.log(colors.yellow(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`));
149
176
  filesToPrune.forEach((f) => {
150
- this.log(`- [${f.topic ? colors.cyan(f.topic) : colors.gray(NO_TOPIC_LABEL)}] Key: ${colors.bold(f.cacheKey)} (${colors.gray(f.url || 'Imported note')})`);
177
+ const topicStr = f.topic
178
+ ? colors.cyan(sanitizeForTerminal(f.topic))
179
+ : colors.gray(NO_TOPIC_LABEL);
180
+ this.log(`- [${topicStr}] Key: ${colors.bold(f.cacheKey)} (${colors.gray(f.url || 'Imported note')})`);
151
181
  });
182
+ this.tip('re-run with --yes to prune these entries.');
152
183
  }
153
184
  }
154
185
  }
@@ -0,0 +1,43 @@
1
+ import { BaseCommand } from '../base-command.js';
2
+ import type { SearchRow, SearchRowMinimal } from '../lib/cli-result-types.js';
3
+ /** Search cached research artifacts by tag/content keywords and metadata filters. */
4
+ export default class ResearchSearch extends BaseCommand<typeof ResearchSearch> {
5
+ static id: string;
6
+ static summary: string;
7
+ static description: string;
8
+ /** Aggregate counts (and an explicit empty/truncation signal); see `list.ts`'s equivalent. */
9
+ private jsonSummary;
10
+ static examples: {
11
+ description: string;
12
+ command: string;
13
+ }[];
14
+ static flags: {
15
+ limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
16
+ full: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
+ tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
19
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
+ freshness: import("@oclif/core/interfaces").OptionFlag<"fresh" | "stale_grace" | "stale_expired" | undefined, import("@oclif/core/interfaces").CustomOptions>;
21
+ 'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
22
+ 'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
23
+ query: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
24
+ 'match-any': import("@oclif/core/interfaces").BooleanFlag<boolean>;
25
+ };
26
+ static stdoutIsPrimaryData: boolean;
27
+ /** The shared filter flags as `matchesCommonMetadataFilters`/`hasActiveMetadataFilters` expect them. */
28
+ private metadataFilterFlags;
29
+ private matchesMetadataFilters;
30
+ private scanCacheDirForSearch;
31
+ /** Newest-first (no query) or highest-score-first (query given), both tie-broken by cache key. */
32
+ private sortResults;
33
+ private hasActiveFilters;
34
+ private logSearchResults;
35
+ /**
36
+ * Empty-cache / no-match tip (human mode only). Under `--json`/`--toon`, `data: []` is paired with
37
+ * `summary.empty: true` instead of prose — see {@link toSuccessJson}.
38
+ */
39
+ private emitEmptySearchGuidance;
40
+ run(): Promise<SearchRow[] | SearchRowMinimal[]>;
41
+ /** Attach the aggregate `summary` computed in `run()` (see {@link SearchSummary}). */
42
+ protected toSuccessJson(data: unknown): Record<string, unknown>;
43
+ }