@taurgis/bonsai 3.2.0 → 4.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 (173) hide show
  1. package/README.md +9 -6
  2. package/agents/agents/salesforce-docs-researcher.agent.md +17 -4
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +13 -18
  5. package/dist/base-command.d.ts +59 -12
  6. package/dist/base-command.js +179 -51
  7. package/dist/commands/config/base.js +12 -2
  8. package/dist/commands/config/get.js +8 -12
  9. package/dist/commands/config/index.js +2 -2
  10. package/dist/commands/config/list.js +11 -17
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +35 -28
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +26 -25
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +61 -401
  17. package/dist/commands/import.d.ts +4 -9
  18. package/dist/commands/import.js +56 -284
  19. package/dist/commands/inspect.d.ts +7 -1
  20. package/dist/commands/inspect.js +112 -52
  21. package/dist/commands/list.d.ts +18 -3
  22. package/dist/commands/list.js +121 -46
  23. package/dist/commands/prune.d.ts +6 -1
  24. package/dist/commands/prune.js +89 -78
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +62 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +2 -1
  28. package/dist/hooks/command-not-found/suggest.js +78 -41
  29. package/dist/lib/argv.d.ts +29 -3
  30. package/dist/lib/argv.js +147 -54
  31. package/dist/lib/batch.d.ts +41 -0
  32. package/dist/lib/batch.js +39 -0
  33. package/dist/lib/cache-view.d.ts +21 -0
  34. package/dist/lib/cache-view.js +30 -0
  35. package/dist/lib/cli-emit.d.ts +28 -0
  36. package/dist/lib/cli-emit.js +44 -0
  37. package/dist/lib/cli-error-policy.d.ts +57 -0
  38. package/dist/lib/cli-error-policy.js +74 -0
  39. package/dist/lib/cli-flag-manifest.d.ts +12 -0
  40. package/dist/lib/cli-flag-manifest.js +15 -0
  41. package/dist/lib/cli-presentation.d.ts +41 -0
  42. package/dist/lib/cli-presentation.js +53 -0
  43. package/dist/lib/cli-result-types.d.ts +112 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +23 -5
  46. package/dist/lib/config/index.js +60 -5
  47. package/dist/lib/config/io.d.ts +9 -0
  48. package/dist/lib/config/io.js +71 -21
  49. package/dist/lib/config/schema.d.ts +2 -0
  50. package/dist/lib/config/schema.js +7 -0
  51. package/dist/lib/config-persist.d.ts +28 -0
  52. package/dist/lib/config-persist.js +29 -0
  53. package/dist/lib/envelope.d.ts +41 -3
  54. package/dist/lib/envelope.js +154 -4
  55. package/dist/lib/help-preflight.d.ts +5 -8
  56. package/dist/lib/help-preflight.js +13 -32
  57. package/dist/lib/json-meta.d.ts +0 -2
  58. package/dist/lib/json-meta.js +21 -25
  59. package/dist/lib/limit-flag.d.ts +11 -1
  60. package/dist/lib/limit-flag.js +19 -6
  61. package/dist/lib/parse-error-ux.d.ts +5 -0
  62. package/dist/lib/parse-error-ux.js +73 -0
  63. package/dist/lib/prune-flags.d.ts +30 -0
  64. package/dist/lib/prune-flags.js +86 -0
  65. package/dist/lib/research/artifact-index.d.ts +11 -2
  66. package/dist/lib/research/artifact-index.js +14 -5
  67. package/dist/lib/research/artifact.js +20 -2
  68. package/dist/lib/research/browser.d.ts +53 -2
  69. package/dist/lib/research/browser.js +202 -58
  70. package/dist/lib/research/capture.d.ts +24 -2
  71. package/dist/lib/research/capture.js +24 -4
  72. package/dist/lib/research/cli-io.d.ts +40 -0
  73. package/dist/lib/research/cli-io.js +27 -0
  74. package/dist/lib/research/compress.d.ts +7 -0
  75. package/dist/lib/research/compress.js +7 -3
  76. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  77. package/dist/lib/research/docs/capabilities.js +5 -0
  78. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  79. package/dist/lib/research/docs/clean-dom.js +2 -0
  80. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  81. package/dist/lib/research/docs/code-blocks.js +2 -0
  82. package/dist/lib/research/docs/detect.d.ts +4 -0
  83. package/dist/lib/research/docs/detect.js +4 -0
  84. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  85. package/dist/lib/research/docs/machine-readable.js +10 -0
  86. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  87. package/dist/lib/research/docs/markdown-source.js +19 -2
  88. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  89. package/dist/lib/research/docs/quality-gates.js +4 -0
  90. package/dist/lib/research/docs/rsc.d.ts +9 -2
  91. package/dist/lib/research/docs/rsc.js +5 -2
  92. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  93. package/dist/lib/research/docs/section-artifacts.js +12 -0
  94. package/dist/lib/research/docs/sections.d.ts +18 -0
  95. package/dist/lib/research/docs/sections.js +17 -1
  96. package/dist/lib/research/docs/source-map.d.ts +28 -0
  97. package/dist/lib/research/docs/source-map.js +27 -0
  98. package/dist/lib/research/docs/validate.d.ts +35 -2
  99. package/dist/lib/research/docs/validate.js +47 -2
  100. package/dist/lib/research/extract.d.ts +16 -0
  101. package/dist/lib/research/extract.js +12 -1
  102. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  103. package/dist/lib/research/fetch-command-service.js +367 -0
  104. package/dist/lib/research/fetch-result.d.ts +114 -0
  105. package/dist/lib/research/fetch-result.js +212 -0
  106. package/dist/lib/research/fetcher.d.ts +46 -8
  107. package/dist/lib/research/fetcher.js +61 -31
  108. package/dist/lib/research/freshness.d.ts +54 -11
  109. package/dist/lib/research/freshness.js +91 -31
  110. package/dist/lib/research/import-command-service.d.ts +56 -0
  111. package/dist/lib/research/import-command-service.js +402 -0
  112. package/dist/lib/research/keywords.js +5 -1
  113. package/dist/lib/research/markdown.d.ts +12 -1
  114. package/dist/lib/research/markdown.js +13 -5
  115. package/dist/lib/research/metadata-filters.d.ts +18 -0
  116. package/dist/lib/research/metadata-filters.js +37 -0
  117. package/dist/lib/research/metadata-flags.d.ts +34 -0
  118. package/dist/lib/research/metadata-flags.js +45 -0
  119. package/dist/lib/research/persist-artifact.d.ts +23 -0
  120. package/dist/lib/research/persist-artifact.js +27 -0
  121. package/dist/lib/research/prompt-injection.d.ts +9 -0
  122. package/dist/lib/research/prompt-injection.js +59 -14
  123. package/dist/lib/research/proxy.d.ts +14 -8
  124. package/dist/lib/research/proxy.js +14 -10
  125. package/dist/lib/research/resolve-target.d.ts +2 -0
  126. package/dist/lib/research/resolve-target.js +1 -1
  127. package/dist/lib/research/revalidate.d.ts +49 -20
  128. package/dist/lib/research/revalidate.js +80 -15
  129. package/dist/lib/research/schema.d.ts +7 -0
  130. package/dist/lib/research/schema.js +2 -0
  131. package/dist/lib/research/secret-scan.d.ts +6 -0
  132. package/dist/lib/research/secret-scan.js +8 -0
  133. package/dist/lib/research/secure-write.d.ts +4 -0
  134. package/dist/lib/research/secure-write.js +31 -2
  135. package/dist/lib/research/storage.d.ts +9 -8
  136. package/dist/lib/research/storage.js +34 -28
  137. package/dist/lib/research/summarize.d.ts +15 -0
  138. package/dist/lib/research/summarize.js +11 -0
  139. package/dist/lib/research/url.d.ts +9 -6
  140. package/dist/lib/research/url.js +33 -21
  141. package/dist/lib/text.d.ts +19 -8
  142. package/dist/lib/text.js +32 -9
  143. package/dist/lib/value-flags.d.ts +14 -0
  144. package/dist/lib/value-flags.js +28 -0
  145. package/dist/sites/coveo.d.ts +10 -0
  146. package/dist/sites/coveo.js +10 -0
  147. package/dist/sites/index.d.ts +16 -0
  148. package/dist/sites/index.js +16 -4
  149. package/dist/sites/markdown-twin.d.ts +31 -0
  150. package/dist/sites/markdown-twin.js +56 -0
  151. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  152. package/dist/sites/salesforce/fetch-page.js +14 -3
  153. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  154. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  155. package/dist/sites/salesforce/index.d.ts +1 -0
  156. package/dist/sites/salesforce/index.js +1 -0
  157. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  158. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  159. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  160. package/dist/sites/salesforce-developer/index.js +1 -0
  161. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  162. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  163. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  164. package/dist/sites/salesforce-doc-fetch.js +52 -7
  165. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  166. package/dist/sites/salesforce-dom-probe.js +20 -2
  167. package/dist/sites/types.d.ts +14 -8
  168. package/dist/sites/types.js +3 -0
  169. package/oclif.manifest.json +216 -102
  170. package/package.json +20 -7
  171. package/agents/agents/official-docs-researcher.agent.md +0 -87
  172. package/agents/instructions/web-research.instructions.md +0 -44
  173. package/agents/skills/web-research/SKILL.md +0 -118
@@ -1,8 +1,16 @@
1
1
  import { BaseCommand } from '../base-command.js';
2
+ import type { ListRow, ListRowMinimal } from '../lib/cli-result-types.js';
3
+ /** List cached research artifacts by metadata filters. */
2
4
  export default class ResearchList extends BaseCommand<typeof ResearchList> {
3
5
  static id: string;
4
6
  static summary: string;
5
7
  static description: string;
8
+ /**
9
+ * Aggregate counts (and an explicit empty/truncation signal) attached to the success envelope so
10
+ * agents get a cache-wide summary without a separate round trip (AXI principles: pre-computed
11
+ * aggregates, definitive empty states). Always set by `run()` before it returns.
12
+ */
13
+ private jsonSummary;
6
14
  static examples: {
7
15
  description: string;
8
16
  command: string;
@@ -15,12 +23,19 @@ export default class ResearchList extends BaseCommand<typeof ResearchList> {
15
23
  'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
24
  'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
25
  limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
26
+ full: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
27
  };
19
28
  static stdoutIsPrimaryData: boolean;
20
- private matchesTopic;
21
- private matchesTags;
22
29
  private matchesFilters;
23
30
  private scanCacheDirForList;
31
+ private hasActiveFilters;
24
32
  private logListResults;
25
- run(): Promise<unknown>;
33
+ /**
34
+ * Empty-cache / no-match tip (human mode only). Under `--json`/`--toon`, `data: []` is paired with
35
+ * `summary.empty: true` instead of prose — see {@link toSuccessJson}.
36
+ */
37
+ private emitEmptyListGuidance;
38
+ run(): Promise<ListRow[] | ListRowMinimal[]>;
39
+ /** Attach the aggregate `summary` computed in `run()` (see {@link ListSummary}). */
40
+ protected toSuccessJson(data: unknown): Record<string, unknown>;
26
41
  }
@@ -3,82 +3,109 @@ import { BaseCommand } from '../base-command.js';
3
3
  import { scanCacheDirs } from '../lib/research/storage.js';
4
4
  import { loadStoreRoots } from '../lib/research/store-roots.js';
5
5
  import { evaluateFreshness } from '../lib/research/freshness.js';
6
- import { ARTIFACT_TYPES, CAPTURE_METHODS } from '../lib/research/schema.js';
7
- import { NO_TOPIC_LABEL, resultListHeading, truncationNotice, } from '../lib/text.js';
6
+ import { ARTIFACT_TYPES, CAPTURE_METHODS, } from '../lib/research/schema.js';
7
+ import { NO_TOPIC_LABEL, formatTip, resultListHeading, sanitizeForTerminal, } from '../lib/text.js';
8
8
  import { limitFlag } from '../lib/limit-flag.js';
9
- import { artifactMatchesUrlFilter } from '../lib/research/url.js';
9
+ import { artifactMatchesUrlFilter, emptyUrlFilterError } from '../lib/research/url.js';
10
+ import { matchesTopicFilter, matchesTagsFilter, emptyTopicFilterError, emptyTagsFilterError, } from '../lib/research/metadata-filters.js';
10
11
  import { colors } from '../lib/color.js';
11
- // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at 100.
12
- const LIST_LABELS = { noun: 'cached research', order: 'first', maxLimit: 100 };
12
+ import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
13
+ // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at this value.
14
+ const LIST_DEFAULT_MAX_LIMIT = 100;
15
+ const LIST_LABELS = {
16
+ noun: 'cached research',
17
+ order: 'first',
18
+ };
13
19
  // `list` answers "what pages/notes do I have?" and deliberately omits section children (see
14
20
  // scanCacheDirForList), so `section` is not an offered filter — every other artifact type can appear.
15
21
  const LISTABLE_ARTIFACT_TYPES = ARTIFACT_TYPES.filter((type) => type !== 'section');
22
+ /** Project a full row down to the default minimal shape (see {@link ListRowMinimal}). */
23
+ function toMinimalListRow(row) {
24
+ return {
25
+ sourceUrls: row.sourceUrls,
26
+ topic: row.topic,
27
+ freshness: row.freshness,
28
+ tokenEstimate: row.tokenEstimate,
29
+ };
30
+ }
31
+ /** Count matched rows per freshness tier for the envelope's `summary.byFreshness`. */
32
+ function countByFreshness(rows) {
33
+ const counts = { fresh: 0, stale_grace: 0, stale_expired: 0 };
34
+ for (const row of rows)
35
+ counts[row.freshness]++;
36
+ return counts;
37
+ }
38
+ /** List cached research artifacts by metadata filters. */
16
39
  export default class ResearchList extends BaseCommand {
17
40
  static id = 'list';
18
- static summary = 'List cached research artifacts by metadata filters.';
19
- static description = 'Lists cached research artifacts, including metadata details like path, source count, freshness, token estimates, and quality metrics without printing full content.';
41
+ static summary = 'List cached research artifacts by metadata';
42
+ static description = 'List page-level cached artifacts by source URL, topic, freshness, and token estimate. ' +
43
+ 'Pass --full for every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps).';
44
+ /**
45
+ * Aggregate counts (and an explicit empty/truncation signal) attached to the success envelope so
46
+ * agents get a cache-wide summary without a separate round trip (AXI principles: pre-computed
47
+ * aggregates, definitive empty states). Always set by `run()` before it returns.
48
+ */
49
+ jsonSummary = null;
20
50
  static examples = [
21
51
  {
22
- description: 'list all cached entries',
52
+ description: 'list cached entries',
23
53
  command: '<%= config.bin %> list',
24
54
  },
25
55
  {
26
- description: 'list cached entries for a specific topic with JSON output',
56
+ description: 'list entries for a topic as JSON',
27
57
  command: '<%= config.bin %> list --topic "React Suspense" --json',
28
58
  },
29
59
  {
30
- description: 'list only fresh entries filtered by tags',
60
+ description: 'list fresh entries matching tags',
31
61
  command: '<%= config.bin %> list --freshness fresh --tags node --tags url',
32
62
  },
33
63
  {
34
- description: 'list entries matching a specific URL glob',
64
+ description: 'list entries matching a URL glob',
35
65
  command: '<%= config.bin %> list --url "https://react.dev/*"',
36
66
  },
67
+ {
68
+ description: 'list every metadata field instead of the minimal default row',
69
+ command: '<%= config.bin %> list --full --json',
70
+ },
37
71
  ];
38
72
  static flags = {
39
73
  topic: Flags.string({
40
74
  char: 't',
41
- description: 'filter by exact topic (case-insensitive)',
75
+ description: CLI_FLAG_DESCRIPTIONS.filterTopic,
42
76
  }),
43
77
  tags: Flags.string({
44
78
  char: 'g',
45
- description: 'filter by tags (must match all tags specified)',
79
+ description: CLI_FLAG_DESCRIPTIONS.filterTags,
46
80
  multiple: true,
47
81
  }),
48
82
  url: Flags.string({
49
- description: 'filter by source URL glob pattern (case-insensitive, supports * wildcard)',
83
+ description: CLI_FLAG_DESCRIPTIONS.sourceUrlGlob,
50
84
  }),
51
85
  freshness: Flags.option({
52
- description: 'filter by freshness state',
86
+ description: 'freshness state',
53
87
  options: ['fresh', 'stale_grace', 'stale_expired'],
54
88
  })(),
55
89
  'artifact-type': Flags.option({
56
- description: 'filter by artifact type',
90
+ description: CLI_FLAG_DESCRIPTIONS.listArtifactType,
57
91
  options: LISTABLE_ARTIFACT_TYPES,
58
92
  })(),
59
93
  'capture-method': Flags.option({
60
- description: 'filter by capture method',
94
+ description: 'capture method',
61
95
  options: CAPTURE_METHODS,
62
96
  })(),
63
- limit: limitFlag(100, 50, 'maximum number of results to return (default 50, max 100)'),
97
+ limit: limitFlag(LIST_DEFAULT_MAX_LIMIT, 50, `result count (max ${LIST_DEFAULT_MAX_LIMIT})`),
98
+ full: Flags.boolean({
99
+ default: false,
100
+ description: CLI_FLAG_DESCRIPTIONS.listFull,
101
+ }),
64
102
  };
65
103
  static stdoutIsPrimaryData = true;
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()));
76
- }
77
104
  matchesFilters(meta, freshness) {
78
- if (!this.matchesTopic(meta, this.flags.topic)) {
105
+ if (!matchesTopicFilter(meta, this.flags.topic)) {
79
106
  return false;
80
107
  }
81
- if (!this.matchesTags(meta, this.flags.tags)) {
108
+ if (!matchesTagsFilter(meta, this.flags.tags)) {
82
109
  return false;
83
110
  }
84
111
  if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
@@ -96,6 +123,7 @@ export default class ResearchList extends BaseCommand {
96
123
  return true;
97
124
  }
98
125
  scanCacheDirForList(readRoots, currentTime) {
126
+ // Honor effective read-only: listing must not persist the derived search-index sidecar.
99
127
  return scanCacheDirs(readRoots, (artifact, filePath) => {
100
128
  if (artifact.metadata.status !== 'active')
101
129
  return null;
@@ -124,19 +152,28 @@ export default class ResearchList extends BaseCommand {
124
152
  fetchedAt: artifact.metadata.fetched_at,
125
153
  validatedAt: artifact.metadata.validated_at,
126
154
  };
127
- });
155
+ }, { persistIndex: !this.readOnly });
156
+ }
157
+ hasActiveFilters() {
158
+ return Boolean(this.flags.topic ||
159
+ (this.flags.tags && this.flags.tags.length > 0) ||
160
+ this.flags.freshness ||
161
+ this.flags['artifact-type'] ||
162
+ this.flags['capture-method'] ||
163
+ this.flags.url);
128
164
  }
129
165
  logListResults(finalResults, totalMatched) {
130
- if (this.jsonEnabled())
131
- return;
132
166
  if (finalResults.length === 0) {
133
- this.log('No cached research entries found matching filters.');
134
- this.log(`\nTip: populate the cache first: ${colors.cyan(this.config.bin + ' <url>')}`);
167
+ this.emitEmptyListGuidance();
135
168
  return;
136
169
  }
170
+ if (this.jsonEnabled())
171
+ return;
137
172
  this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
138
173
  finalResults.forEach((res, index) => {
139
- const topicStr = res.topic ? colors.cyan(res.topic) : colors.gray(NO_TOPIC_LABEL);
174
+ const topicStr = res.topic
175
+ ? colors.cyan(sanitizeForTerminal(res.topic))
176
+ : colors.gray(NO_TOPIC_LABEL);
140
177
  const keyStr = colors.bold(res.cacheKey);
141
178
  this.log(`${index + 1}. [${topicStr}] Key: ${keyStr}`);
142
179
  const freshnessColorMap = {
@@ -144,14 +181,42 @@ export default class ResearchList extends BaseCommand {
144
181
  stale_grace: colors.yellow,
145
182
  stale_expired: colors.red,
146
183
  };
147
- const freshnessColor = freshnessColorMap[res.freshness] || ((t) => t);
184
+ const freshnessColor = freshnessColorMap[res.freshness];
148
185
  const freshnessStr = freshnessColor(res.freshness);
149
186
  this.log(` Type: ${colors.bold(res.artifactType)} | Freshness: ${freshnessStr}`);
150
187
  this.log(` Tokens: compressed=${colors.bold(String(res.tokenEstimate?.compressed || 0))}, detailed=${colors.bold(String(res.tokenEstimate?.detailed || 0))}`);
151
188
  this.log(` Source URLs: ${colors.gray(res.sourceUrls.join(', '))}\n`);
152
189
  });
153
190
  }
191
+ /**
192
+ * Empty-cache / no-match tip (human mode only). Under `--json`/`--toon`, `data: []` is paired with
193
+ * `summary.empty: true` instead of prose — see {@link toSuccessJson}.
194
+ */
195
+ emitEmptyListGuidance() {
196
+ if (this.jsonEnabled())
197
+ return;
198
+ const filtered = this.hasActiveFilters();
199
+ const headline = filtered
200
+ ? 'No cached research entries match the given filters.'
201
+ : 'No cached research entries found.';
202
+ const tipCmd = filtered ? `${this.config.bin} list` : `${this.config.bin} <url>`;
203
+ const tipLead = filtered
204
+ ? 'try relaxing filters, or list everything: '
205
+ : 'populate the cache first: ';
206
+ this.log(headline);
207
+ this.log(`\n${formatTip(`${tipLead}${colors.cyan(tipCmd)}`)}`);
208
+ // Bare `bonsai` (no args) redirects here (content-first default), so an empty, unfiltered cache
209
+ // is also the first thing a brand-new user sees — point at --help so command discovery isn't lost.
210
+ if (!filtered) {
211
+ this.log(formatTip(`see every command: ${colors.cyan(`${this.config.bin} --help`)}`));
212
+ }
213
+ }
154
214
  async run() {
215
+ const flagErr = emptyUrlFilterError(this.flags.url) ??
216
+ emptyTopicFilterError(this.flags.topic) ??
217
+ emptyTagsFilterError(this.flags.tags);
218
+ if (flagErr)
219
+ this.error(flagErr, { exit: 2, code: 'INVALID_FLAG_VALUE' });
155
220
  const roots = loadStoreRoots({
156
221
  configDir: this.config.configDir,
157
222
  cwd: process.cwd(),
@@ -159,20 +224,30 @@ export default class ResearchList extends BaseCommand {
159
224
  });
160
225
  const currentTime = new Date();
161
226
  const results = this.scanCacheDirForList(roots.readRoots, currentTime);
162
- // Sort by validated_at or fetched_at descending (most recent first)
163
227
  results.sort((a, b) => {
164
228
  const timeA = new Date(a.validatedAt || a.fetchedAt || 0).getTime();
165
229
  const timeB = new Date(b.validatedAt || b.fetchedAt || 0).getTime();
166
230
  return timeB - timeA;
167
231
  });
168
232
  const finalResults = results.slice(0, this.flags.limit);
169
- // Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
170
- // always emits, even in --json) without touching the stdout envelope. Human mode already shows
171
- // it in the heading, so only warn under --json.
172
- const notice = truncationNotice(results.length, finalResults.length, LIST_LABELS);
173
- if (notice && this.jsonEnabled())
174
- this.warn(notice);
233
+ // Human heading carries truncation inline; --json/--toon get the same counts (plus a freshness
234
+ // breakdown and an explicit `empty` flag) as an always-present envelope `summary` object.
235
+ this.jsonSummary = {
236
+ total: results.length,
237
+ shown: finalResults.length,
238
+ limit: this.flags.limit,
239
+ truncated: results.length > finalResults.length,
240
+ empty: results.length === 0,
241
+ byFreshness: countByFreshness(results),
242
+ };
175
243
  this.logListResults(finalResults, results.length);
176
- return finalResults;
244
+ return this.flags.full ? finalResults : finalResults.map(toMinimalListRow);
245
+ }
246
+ /** Attach the aggregate `summary` computed in `run()` (see {@link ListSummary}). */
247
+ toSuccessJson(data) {
248
+ const envelope = this.baseSuccessJson(data);
249
+ if (!this.jsonSummary)
250
+ return envelope;
251
+ return { ...envelope, summary: this.jsonSummary };
177
252
  }
178
253
  }
@@ -1,4 +1,6 @@
1
1
  import { BaseCommand } from '../base-command.js';
2
+ import type { PruneWriteResult } from '../lib/cli-result-types.js';
3
+ /** Prune cached artifacts by age, idle time, or filters. */
2
4
  export default class ResearchPrune extends BaseCommand<typeof ResearchPrune> {
3
5
  static id: string;
4
6
  static summary: string;
@@ -11,14 +13,17 @@ export default class ResearchPrune extends BaseCommand<typeof ResearchPrune> {
11
13
  'older-than': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
14
  inactive: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
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>;
14
18
  'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | "section" | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
19
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
20
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
21
  };
18
22
  static stdoutIsPrimaryData: boolean;
23
+ protected toSuccessJson(data: unknown): Record<string, unknown>;
19
24
  private validatePruneFlags;
20
25
  private shouldPrune;
21
26
  private findPruneCandidates;
22
27
  private deletePruneCandidates;
23
- run(): Promise<unknown>;
28
+ run(): Promise<PruneWriteResult>;
24
29
  }
@@ -2,131 +2,119 @@ import { Flags } from '@oclif/core';
2
2
  import { unlinkSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { BaseCommand } from '../base-command.js';
5
+ import { enrichPrunePartialEnvelope } from '../lib/envelope.js';
5
6
  import { scanCacheDir } from '../lib/research/storage.js';
6
7
  import { loadStoreRoots } from '../lib/research/store-roots.js';
7
- import { parseTtlToMs, durationFlagError } from '../lib/research/freshness.js';
8
+ import { parseTtlToMs } from '../lib/research/freshness.js';
8
9
  import { ARTIFACT_TYPES } from '../lib/research/schema.js';
9
- import { NO_TOPIC_LABEL, pluralize } from '../lib/text.js';
10
+ import { NO_TOPIC_LABEL, pluralize, sanitizeForTerminal } from '../lib/text.js';
10
11
  import { artifactMatchesUrlFilter } from '../lib/research/url.js';
12
+ import { matchesTopicFilter, matchesTagsFilter } from '../lib/research/metadata-filters.js';
13
+ import { pruneFlagError } from '../lib/prune-flags.js';
11
14
  import { colors } from '../lib/color.js';
15
+ import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
16
+ /** Prune cached artifacts by age, idle time, or filters. */
12
17
  export default class ResearchPrune extends BaseCommand {
13
18
  static id = 'prune';
14
- static summary = 'Clean up old or inactive research cache entries.';
15
- static description = 'Prunes cached research entries based on age, inactivity, or artifact type to free up disk space.';
19
+ static summary = 'Prune cached research artifacts';
20
+ static description = 'Delete cached artifacts by content age, idle time, URL glob, artifact type, topic, or tags.';
16
21
  static examples = [
17
22
  {
18
- description: 'perform a dry run of pruning entries older than 90 days',
19
- command: '<%= config.bin %> prune --older-than 90d --dry-run',
23
+ description: 'preview pruning entries older than 30 days',
24
+ command: '<%= config.bin %> prune --older-than 30d --dry-run',
20
25
  },
21
26
  {
22
- description: 'actually prune entries older than 30 days that are source scrapes',
27
+ description: 'prune source artifacts older than 30 days',
23
28
  command: '<%= config.bin %> prune --older-than 30d --artifact-type source --yes',
24
29
  },
25
30
  {
26
- description: 'prune entries matching a URL glob pattern',
31
+ description: 'prune entries matching a URL glob',
27
32
  command: '<%= config.bin %> prune --url "https://react.dev/*" --yes',
28
33
  },
34
+ {
35
+ description: 'prune entries tagged as deprecated',
36
+ command: '<%= config.bin %> prune --tags deprecated --yes',
37
+ },
29
38
  ];
30
39
  static flags = {
31
40
  'older-than': Flags.string({
32
- description: 'prune entries older than duration (e.g. "30d", "90d")',
41
+ description: 'content age threshold (fetched_at, else validated_at), e.g. "30d"',
33
42
  }),
34
43
  inactive: Flags.string({
35
- description: 'prune entries inactive (unvalidated/unfetched) for duration (e.g. "14d", "30d")',
44
+ description: 'idle time threshold (validated_at, else fetched_at), e.g. "14d"',
36
45
  }),
37
46
  url: Flags.string({
38
- description: 'filter pruning to source URL glob pattern (case-insensitive, supports * wildcard)',
47
+ description: CLI_FLAG_DESCRIPTIONS.sourceUrlGlob,
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,
39
57
  }),
40
58
  'artifact-type': Flags.option({
41
59
  // Prune operates on every cached file, so it can target any artifact type — including the
42
60
  // `section`/`index` children a page generates (e.g. to clear orphans left after a source is
43
61
  // pruned with `--artifact-type source`).
44
- description: 'filter pruning to specific artifact type',
62
+ description: CLI_FLAG_DESCRIPTIONS.pruneArtifactType,
45
63
  options: ARTIFACT_TYPES,
46
64
  })(),
47
65
  'dry-run': Flags.boolean({
48
- description: 'list files that would be deleted without actually deleting them',
66
+ description: 'preview files without deleting',
49
67
  default: false,
50
68
  }),
51
69
  yes: Flags.boolean({
52
70
  char: 'y',
53
- description: 'confirm deletion and prune matched entries (required unless --dry-run)',
71
+ description: 'confirm deletion (required unless --dry-run)',
54
72
  default: false,
55
73
  }),
56
74
  };
57
75
  static stdoutIsPrimaryData = true;
76
+ toSuccessJson(data) {
77
+ return enrichPrunePartialEnvelope(this.baseSuccessJson(data), data);
78
+ }
58
79
  validatePruneFlags() {
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
- // Checked before the generic --dry-run/--yes conflict below so a combination like
72
- // `--dry-run --yes --read-only` reports the more specific READ_ONLY_MODE code, not
73
- // CONFLICTING_FLAGS — read-only mode is the more fundamental constraint being violated.
74
- if (this.readOnly && this.flags.yes) {
75
- this.error('--yes cannot be used while read-only mode is active (--read-only/--plan or BONSAI_READ_ONLY/BONSAI_PLAN_MODE): mutations are disabled.', {
76
- exit: 2,
77
- code: 'READ_ONLY_MODE',
78
- suggestions: [`Preview instead: ${this.config.bin} prune --dry-run`],
79
- });
80
- }
81
- if (this.flags['dry-run'] && this.flags.yes) {
82
- this.error('--dry-run and --yes are mutually exclusive: --dry-run previews without deleting, --yes confirms deletion. Choose one.', { exit: 2, code: 'CONFLICTING_FLAGS' });
83
- }
84
- // Read-only mode implicitly previews, so the usual "pick one" safety check is redundant here.
85
- if (!this.readOnly && !this.flags['dry-run'] && !this.flags.yes) {
86
- const olderThanPart = this.flags['older-than']
87
- ? ` --older-than ${this.flags['older-than']}`
88
- : '';
89
- const urlPart = this.flags.url ? ` --url "${this.flags.url}"` : '';
90
- this.error('Safety check: use --yes to confirm pruning, or --dry-run to preview files that would be deleted.', {
91
- exit: 2,
92
- code: 'SAFETY_CHECK_REQUIRED',
93
- suggestions: [
94
- `Preview first: ${this.config.bin} prune --dry-run${olderThanPart}${urlPart}`,
95
- ],
96
- });
97
- }
98
- // Validate durations up front: scanCacheDir swallows per-file errors, so a malformed
99
- // --older-than/--inactive would otherwise be silently ignored and report "0 entries".
100
- for (const msg of [
101
- durationFlagError('--older-than', this.flags['older-than']),
102
- durationFlagError('--inactive', this.flags.inactive),
103
- ]) {
104
- if (msg)
105
- this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
106
- }
80
+ const err = pruneFlagError({
81
+ olderThan: this.flags['older-than'],
82
+ inactive: this.flags.inactive,
83
+ artifactType: this.flags['artifact-type'],
84
+ url: this.flags.url,
85
+ topic: this.flags.topic,
86
+ tags: this.flags.tags,
87
+ dryRun: this.flags['dry-run'],
88
+ yes: this.flags.yes,
89
+ readOnly: this.readOnly,
90
+ bin: this.config.bin,
91
+ });
92
+ if (err)
93
+ this.error(err.message, { exit: 2, code: err.code, suggestions: err.suggestions });
107
94
  }
108
95
  shouldPrune(meta, currentTime) {
109
- const fetched = meta.fetched_at ? new Date(meta.fetched_at).getTime() : 0;
110
- const validated = meta.validated_at ? new Date(meta.validated_at).getTime() : 0;
111
- const baseTime = Math.max(fetched, validated);
112
- const ageMs = currentTime.getTime() - baseTime;
113
96
  if (this.flags['artifact-type'] && meta.artifact_type !== this.flags['artifact-type']) {
114
97
  return false;
115
98
  }
116
99
  if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
117
100
  return false;
118
101
  }
119
- if (this.flags['older-than']) {
120
- const olderThanMs = parseTtlToMs(this.flags['older-than']);
121
- if (ageMs < olderThanMs) {
122
- return false;
123
- }
102
+ if (!matchesTopicFilter(meta, this.flags.topic)) {
103
+ return false;
124
104
  }
125
- if (this.flags.inactive) {
126
- const inactiveMs = parseTtlToMs(this.flags.inactive);
127
- if (ageMs < inactiveMs) {
128
- return false;
129
- }
105
+ if (!matchesTagsFilter(meta, this.flags.tags)) {
106
+ return false;
107
+ }
108
+ const now = currentTime.getTime();
109
+ // Content age (fetched_at, else validated_at) vs idle time (validated_at, else fetched_at) are
110
+ // deliberately distinct so a recently revalidated but originally-old page can match --older-than
111
+ // without also being treated as inactive.
112
+ if (this.flags['older-than'] &&
113
+ ageMs(meta, now, 'content') < parseTtlToMs(this.flags['older-than'])) {
114
+ return false;
115
+ }
116
+ if (this.flags.inactive && ageMs(meta, now, 'idle') < parseTtlToMs(this.flags.inactive)) {
117
+ return false;
130
118
  }
131
119
  return true;
132
120
  }
@@ -152,7 +140,12 @@ export default class ResearchPrune extends BaseCommand {
152
140
  prunedCount++;
153
141
  }
154
142
  catch (err) {
155
- 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
+ });
156
149
  }
157
150
  }
158
151
  return prunedCount;
@@ -181,8 +174,12 @@ export default class ResearchPrune extends BaseCommand {
181
174
  const noun = pluralize(count, 'entry', 'entries');
182
175
  this.log(colors.yellow(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`));
183
176
  filesToPrune.forEach((f) => {
184
- 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')})`);
185
181
  });
182
+ this.tip('re-run with --yes to prune these entries.');
186
183
  }
187
184
  }
188
185
  }
@@ -192,9 +189,16 @@ export default class ResearchPrune extends BaseCommand {
192
189
  const noun = pluralize(count, 'entry', 'entries');
193
190
  this.log(colors.green(`Successfully pruned ${prunedCount} of ${count} research cache ${noun}.`));
194
191
  }
192
+ // Partial unlink failure is a runtime outcome agents must see: exit 1 with the counts still
193
+ // in `data` so callers can tell candidates from successes without parsing stderr warnings.
194
+ if (count > 0 && prunedCount < count) {
195
+ process.exitCode = 1;
196
+ }
195
197
  }
196
198
  return {
197
199
  dryRun,
200
+ status: dryRun ? 'would_prune' : 'pruned',
201
+ wouldPruneCount: dryRun ? count : 0,
198
202
  prunedCount,
199
203
  candidateCount: count,
200
204
  files: filesToPrune.map((f) => ({
@@ -204,3 +208,10 @@ export default class ResearchPrune extends BaseCommand {
204
208
  };
205
209
  }
206
210
  }
211
+ /** Age in ms for prune filters. `content` prefers fetched_at; `idle` prefers validated_at. */
212
+ function ageMs(meta, now, kind) {
213
+ const fetched = meta.fetched_at ? new Date(meta.fetched_at).getTime() : 0;
214
+ const validated = meta.validated_at ? new Date(meta.validated_at).getTime() : 0;
215
+ const base = kind === 'content' ? fetched || validated : validated || fetched;
216
+ return now - base;
217
+ }
@@ -1,4 +1,5 @@
1
1
  import { BaseCommand } from '../base-command.js';
2
+ /** Report cache hit/miss and freshness without writing. */
2
3
  export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
3
4
  static id: string;
4
5
  static summary: string;
@@ -12,13 +13,13 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
12
13
  url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
13
14
  };
14
15
  static flags: {
15
- tier: import("@oclif/core/interfaces").OptionFlag<"stable" | "standard" | "volatile", import("@oclif/core/interfaces").CustomOptions>;
16
+ tier: import("@oclif/core/interfaces").OptionFlag<"stable" | "standard" | "volatile" | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
17
  ttl: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
18
  'max-age': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
19
  };
19
20
  static stdoutIsPrimaryData: boolean;
20
- /** Enrich cache-miss envelopes with the same CACHE_MISS code and suggestions as inspect. */
21
21
  protected toSuccessJson(data: unknown): Record<string, unknown>;
22
22
  run(): Promise<unknown>;
23
23
  private checkSingleStatus;
24
+ private logStatusTable;
24
25
  }