@taurgis/bonsai 3.2.0 → 3.2.2

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 (162) hide show
  1. package/README.md +7 -4
  2. package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +9 -17
  5. package/dist/base-command.d.ts +22 -12
  6. package/dist/base-command.js +86 -49
  7. package/dist/commands/config/base.js +11 -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 +10 -16
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +34 -27
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +25 -24
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +57 -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 +103 -52
  21. package/dist/commands/list.d.ts +13 -1
  22. package/dist/commands/list.js +79 -32
  23. package/dist/commands/prune.d.ts +4 -1
  24. package/dist/commands/prune.js +55 -75
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +52 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +1 -0
  28. package/dist/hooks/command-not-found/suggest.js +66 -39
  29. package/dist/lib/argv.d.ts +24 -3
  30. package/dist/lib/argv.js +124 -53
  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 +5 -0
  40. package/dist/lib/cli-flag-manifest.js +8 -0
  41. package/dist/lib/cli-presentation.d.ts +39 -0
  42. package/dist/lib/cli-presentation.js +50 -0
  43. package/dist/lib/cli-result-types.d.ts +88 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +21 -3
  46. package/dist/lib/config/index.js +58 -3
  47. package/dist/lib/config-persist.d.ts +28 -0
  48. package/dist/lib/config-persist.js +29 -0
  49. package/dist/lib/envelope.d.ts +41 -3
  50. package/dist/lib/envelope.js +147 -4
  51. package/dist/lib/help-preflight.d.ts +5 -8
  52. package/dist/lib/help-preflight.js +13 -32
  53. package/dist/lib/json-meta.d.ts +0 -2
  54. package/dist/lib/json-meta.js +21 -25
  55. package/dist/lib/limit-flag.d.ts +11 -1
  56. package/dist/lib/limit-flag.js +19 -6
  57. package/dist/lib/parse-error-ux.d.ts +5 -0
  58. package/dist/lib/parse-error-ux.js +73 -0
  59. package/dist/lib/prune-flags.d.ts +28 -0
  60. package/dist/lib/prune-flags.js +74 -0
  61. package/dist/lib/research/artifact-index.d.ts +11 -2
  62. package/dist/lib/research/artifact-index.js +7 -4
  63. package/dist/lib/research/browser.d.ts +41 -2
  64. package/dist/lib/research/browser.js +139 -50
  65. package/dist/lib/research/capture.d.ts +24 -2
  66. package/dist/lib/research/capture.js +24 -4
  67. package/dist/lib/research/cli-io.d.ts +33 -0
  68. package/dist/lib/research/cli-io.js +27 -0
  69. package/dist/lib/research/compress.d.ts +7 -0
  70. package/dist/lib/research/compress.js +7 -3
  71. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  72. package/dist/lib/research/docs/capabilities.js +5 -0
  73. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  74. package/dist/lib/research/docs/clean-dom.js +2 -0
  75. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  76. package/dist/lib/research/docs/code-blocks.js +2 -0
  77. package/dist/lib/research/docs/detect.d.ts +4 -0
  78. package/dist/lib/research/docs/detect.js +4 -0
  79. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  80. package/dist/lib/research/docs/machine-readable.js +10 -0
  81. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  82. package/dist/lib/research/docs/markdown-source.js +19 -2
  83. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  84. package/dist/lib/research/docs/quality-gates.js +4 -0
  85. package/dist/lib/research/docs/rsc.d.ts +9 -2
  86. package/dist/lib/research/docs/rsc.js +5 -2
  87. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  88. package/dist/lib/research/docs/section-artifacts.js +12 -0
  89. package/dist/lib/research/docs/sections.d.ts +18 -0
  90. package/dist/lib/research/docs/sections.js +17 -1
  91. package/dist/lib/research/docs/source-map.d.ts +28 -0
  92. package/dist/lib/research/docs/source-map.js +27 -0
  93. package/dist/lib/research/docs/validate.d.ts +35 -2
  94. package/dist/lib/research/docs/validate.js +47 -2
  95. package/dist/lib/research/extract.d.ts +16 -0
  96. package/dist/lib/research/extract.js +12 -1
  97. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  98. package/dist/lib/research/fetch-command-service.js +310 -0
  99. package/dist/lib/research/fetch-result.d.ts +114 -0
  100. package/dist/lib/research/fetch-result.js +212 -0
  101. package/dist/lib/research/fetcher.d.ts +46 -8
  102. package/dist/lib/research/fetcher.js +58 -30
  103. package/dist/lib/research/freshness.d.ts +54 -11
  104. package/dist/lib/research/freshness.js +91 -31
  105. package/dist/lib/research/import-command-service.d.ts +56 -0
  106. package/dist/lib/research/import-command-service.js +381 -0
  107. package/dist/lib/research/keywords.js +5 -1
  108. package/dist/lib/research/markdown.d.ts +12 -1
  109. package/dist/lib/research/markdown.js +13 -5
  110. package/dist/lib/research/persist-artifact.d.ts +23 -0
  111. package/dist/lib/research/persist-artifact.js +27 -0
  112. package/dist/lib/research/prompt-injection.d.ts +8 -0
  113. package/dist/lib/research/prompt-injection.js +42 -13
  114. package/dist/lib/research/proxy.d.ts +14 -8
  115. package/dist/lib/research/proxy.js +14 -10
  116. package/dist/lib/research/revalidate.d.ts +49 -20
  117. package/dist/lib/research/revalidate.js +80 -15
  118. package/dist/lib/research/schema.d.ts +7 -0
  119. package/dist/lib/research/schema.js +2 -0
  120. package/dist/lib/research/secret-scan.d.ts +6 -0
  121. package/dist/lib/research/secret-scan.js +8 -0
  122. package/dist/lib/research/secure-write.d.ts +4 -0
  123. package/dist/lib/research/secure-write.js +31 -2
  124. package/dist/lib/research/storage.d.ts +5 -1
  125. package/dist/lib/research/storage.js +8 -3
  126. package/dist/lib/research/summarize.d.ts +15 -0
  127. package/dist/lib/research/summarize.js +11 -0
  128. package/dist/lib/research/url.d.ts +9 -6
  129. package/dist/lib/research/url.js +33 -21
  130. package/dist/lib/text.d.ts +7 -2
  131. package/dist/lib/text.js +13 -2
  132. package/dist/lib/value-flags.d.ts +14 -0
  133. package/dist/lib/value-flags.js +28 -0
  134. package/dist/sites/coveo.d.ts +10 -0
  135. package/dist/sites/coveo.js +10 -0
  136. package/dist/sites/index.d.ts +16 -0
  137. package/dist/sites/index.js +16 -4
  138. package/dist/sites/markdown-twin.d.ts +31 -0
  139. package/dist/sites/markdown-twin.js +56 -0
  140. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  141. package/dist/sites/salesforce/fetch-page.js +14 -3
  142. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  143. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  144. package/dist/sites/salesforce/index.d.ts +1 -0
  145. package/dist/sites/salesforce/index.js +1 -0
  146. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  147. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  148. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  149. package/dist/sites/salesforce-developer/index.js +1 -0
  150. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  151. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  152. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  153. package/dist/sites/salesforce-doc-fetch.js +52 -7
  154. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  155. package/dist/sites/salesforce-dom-probe.js +20 -2
  156. package/dist/sites/types.d.ts +14 -8
  157. package/dist/sites/types.js +3 -0
  158. package/oclif.manifest.json +104 -98
  159. package/package.json +19 -7
  160. package/agents/agents/official-docs-researcher.agent.md +0 -87
  161. package/agents/instructions/web-research.instructions.md +0 -44
  162. package/agents/skills/web-research/SKILL.md +0 -118
@@ -1,8 +1,15 @@
1
1
  import { BaseCommand } from '../base-command.js';
2
+ import type { ListRow } 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
+ * When `--json` results were capped by `--limit`, attached to the success envelope so agents can
10
+ * detect truncation without scraping prose (#91 / AUDIT_71_FINAL #73 deferred).
11
+ */
12
+ private jsonTruncation;
6
13
  static examples: {
7
14
  description: string;
8
15
  command: string;
@@ -21,6 +28,11 @@ export default class ResearchList extends BaseCommand<typeof ResearchList> {
21
28
  private matchesTags;
22
29
  private matchesFilters;
23
30
  private scanCacheDirForList;
31
+ private hasActiveFilters;
24
32
  private logListResults;
25
- run(): Promise<unknown>;
33
+ /** Empty-cache / no-match tip (human mode only; --json returns `data: []` with no messaging). */
34
+ private emitEmptyListGuidance;
35
+ run(): Promise<ListRow[]>;
36
+ /** Attach `truncation` when the returned `data` array was capped by `--limit`. */
37
+ protected toSuccessJson(data: unknown): Record<string, unknown>;
26
38
  }
@@ -3,64 +3,76 @@ 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, resultListHeading } 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
10
  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 };
11
+ import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
12
+ // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at this value.
13
+ const LIST_DEFAULT_MAX_LIMIT = 100;
14
+ const LIST_LABELS = {
15
+ noun: 'cached research',
16
+ order: 'first',
17
+ maxLimit: LIST_DEFAULT_MAX_LIMIT,
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
+ /** List cached research artifacts by metadata filters. */
16
23
  export default class ResearchList extends BaseCommand {
17
24
  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.';
25
+ 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.';
27
+ /**
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).
30
+ */
31
+ jsonTruncation = null;
20
32
  static examples = [
21
33
  {
22
- description: 'list all cached entries',
34
+ description: 'list cached entries',
23
35
  command: '<%= config.bin %> list',
24
36
  },
25
37
  {
26
- description: 'list cached entries for a specific topic with JSON output',
38
+ description: 'list entries for a topic as JSON',
27
39
  command: '<%= config.bin %> list --topic "React Suspense" --json',
28
40
  },
29
41
  {
30
- description: 'list only fresh entries filtered by tags',
42
+ description: 'list fresh entries matching tags',
31
43
  command: '<%= config.bin %> list --freshness fresh --tags node --tags url',
32
44
  },
33
45
  {
34
- description: 'list entries matching a specific URL glob',
46
+ description: 'list entries matching a URL glob',
35
47
  command: '<%= config.bin %> list --url "https://react.dev/*"',
36
48
  },
37
49
  ];
38
50
  static flags = {
39
51
  topic: Flags.string({
40
52
  char: 't',
41
- description: 'filter by exact topic (case-insensitive)',
53
+ description: CLI_FLAG_DESCRIPTIONS.filterTopic,
42
54
  }),
43
55
  tags: Flags.string({
44
56
  char: 'g',
45
- description: 'filter by tags (must match all tags specified)',
57
+ description: CLI_FLAG_DESCRIPTIONS.filterTags,
46
58
  multiple: true,
47
59
  }),
48
60
  url: Flags.string({
49
- description: 'filter by source URL glob pattern (case-insensitive, supports * wildcard)',
61
+ description: CLI_FLAG_DESCRIPTIONS.sourceUrlGlob,
50
62
  }),
51
63
  freshness: Flags.option({
52
- description: 'filter by freshness state',
64
+ description: 'freshness state',
53
65
  options: ['fresh', 'stale_grace', 'stale_expired'],
54
66
  })(),
55
67
  'artifact-type': Flags.option({
56
- description: 'filter by artifact type',
68
+ description: CLI_FLAG_DESCRIPTIONS.listArtifactType,
57
69
  options: LISTABLE_ARTIFACT_TYPES,
58
70
  })(),
59
71
  'capture-method': Flags.option({
60
- description: 'filter by capture method',
72
+ description: 'capture method',
61
73
  options: CAPTURE_METHODS,
62
74
  })(),
63
- limit: limitFlag(100, 50, 'maximum number of results to return (default 50, max 100)'),
75
+ limit: limitFlag(100, 50, 'result count (default 50, max 100)'),
64
76
  };
65
77
  static stdoutIsPrimaryData = true;
66
78
  matchesTopic(meta, topic) {
@@ -71,7 +83,7 @@ export default class ResearchList extends BaseCommand {
71
83
  matchesTags(meta, tags) {
72
84
  if (!tags || tags.length === 0)
73
85
  return true;
74
- const metaTagsLower = (meta.tags || []).map((t) => t.toLowerCase());
86
+ const metaTagsLower = meta.tags.map((t) => t.toLowerCase());
75
87
  return tags.every((t) => metaTagsLower.includes(t.toLowerCase()));
76
88
  }
77
89
  matchesFilters(meta, freshness) {
@@ -96,6 +108,7 @@ export default class ResearchList extends BaseCommand {
96
108
  return true;
97
109
  }
98
110
  scanCacheDirForList(readRoots, currentTime) {
111
+ // Honor effective read-only: listing must not persist the derived search-index sidecar.
99
112
  return scanCacheDirs(readRoots, (artifact, filePath) => {
100
113
  if (artifact.metadata.status !== 'active')
101
114
  return null;
@@ -124,16 +137,23 @@ export default class ResearchList extends BaseCommand {
124
137
  fetchedAt: artifact.metadata.fetched_at,
125
138
  validatedAt: artifact.metadata.validated_at,
126
139
  };
127
- });
140
+ }, { persistIndex: !this.readOnly });
141
+ }
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);
128
149
  }
129
150
  logListResults(finalResults, totalMatched) {
130
- if (this.jsonEnabled())
131
- return;
132
151
  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>')}`);
152
+ this.emitEmptyListGuidance();
135
153
  return;
136
154
  }
155
+ if (this.jsonEnabled())
156
+ return;
137
157
  this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
138
158
  finalResults.forEach((res, index) => {
139
159
  const topicStr = res.topic ? colors.cyan(res.topic) : colors.gray(NO_TOPIC_LABEL);
@@ -144,14 +164,32 @@ export default class ResearchList extends BaseCommand {
144
164
  stale_grace: colors.yellow,
145
165
  stale_expired: colors.red,
146
166
  };
147
- const freshnessColor = freshnessColorMap[res.freshness] || ((t) => t);
167
+ const freshnessColor = freshnessColorMap[res.freshness];
148
168
  const freshnessStr = freshnessColor(res.freshness);
149
169
  this.log(` Type: ${colors.bold(res.artifactType)} | Freshness: ${freshnessStr}`);
150
170
  this.log(` Tokens: compressed=${colors.bold(String(res.tokenEstimate?.compressed || 0))}, detailed=${colors.bold(String(res.tokenEstimate?.detailed || 0))}`);
151
171
  this.log(` Source URLs: ${colors.gray(res.sourceUrls.join(', '))}\n`);
152
172
  });
153
173
  }
174
+ /** Empty-cache / no-match tip (human mode only; --json returns `data: []` with no messaging). */
175
+ emitEmptyListGuidance() {
176
+ if (this.jsonEnabled())
177
+ return;
178
+ const filtered = this.hasActiveFilters();
179
+ const headline = filtered
180
+ ? 'No cached research entries match the given filters.'
181
+ : 'No cached research entries found.';
182
+ const tipCmd = filtered ? `${this.config.bin} list` : `${this.config.bin} <url>`;
183
+ const tipLead = filtered
184
+ ? 'try relaxing filters, or list everything: '
185
+ : 'populate the cache first: ';
186
+ this.log(headline);
187
+ this.log(`\nTip: ${tipLead}${colors.cyan(tipCmd)}`);
188
+ }
154
189
  async run() {
190
+ const urlErr = emptyUrlFilterError(this.flags.url);
191
+ if (urlErr)
192
+ this.error(urlErr, { exit: 2, code: 'INVALID_FLAG_VALUE' });
155
193
  const roots = loadStoreRoots({
156
194
  configDir: this.config.configDir,
157
195
  cwd: process.cwd(),
@@ -159,20 +197,29 @@ export default class ResearchList extends BaseCommand {
159
197
  });
160
198
  const currentTime = new Date();
161
199
  const results = this.scanCacheDirForList(roots.readRoots, currentTime);
162
- // Sort by validated_at or fetched_at descending (most recent first)
163
200
  results.sort((a, b) => {
164
201
  const timeA = new Date(a.validatedAt || a.fetchedAt || 0).getTime();
165
202
  const timeB = new Date(b.validatedAt || b.fetchedAt || 0).getTime();
166
203
  return timeB - timeA;
167
204
  });
168
205
  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);
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;
175
215
  this.logListResults(finalResults, results.length);
176
216
  return finalResults;
177
217
  }
218
+ /** Attach `truncation` when the returned `data` array was capped by `--limit`. */
219
+ toSuccessJson(data) {
220
+ const envelope = this.baseSuccessJson(data);
221
+ if (!this.jsonTruncation)
222
+ return envelope;
223
+ return { ...envelope, truncation: this.jsonTruncation };
224
+ }
178
225
  }
@@ -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;
@@ -16,9 +18,10 @@ export default class ResearchPrune extends BaseCommand<typeof ResearchPrune> {
16
18
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
19
  };
18
20
  static stdoutIsPrimaryData: boolean;
21
+ protected toSuccessJson(data: unknown): Record<string, unknown>;
19
22
  private validatePruneFlags;
20
23
  private shouldPrune;
21
24
  private findPruneCandidates;
22
25
  private deletePruneCandidates;
23
- run(): Promise<unknown>;
26
+ run(): Promise<PruneWriteResult>;
24
27
  }
@@ -2,131 +2,97 @@ 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
10
  import { NO_TOPIC_LABEL, pluralize } from '../lib/text.js';
10
11
  import { artifactMatchesUrlFilter } from '../lib/research/url.js';
12
+ import { pruneFlagError } from '../lib/prune-flags.js';
11
13
  import { colors } from '../lib/color.js';
14
+ import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
15
+ /** Prune cached artifacts by age, idle time, or filters. */
12
16
  export default class ResearchPrune extends BaseCommand {
13
17
  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.';
18
+ static summary = 'Prune cached research artifacts';
19
+ static description = 'Delete cached artifacts by content age, idle time, URL glob, or artifact type.';
16
20
  static examples = [
17
21
  {
18
- description: 'perform a dry run of pruning entries older than 90 days',
19
- command: '<%= config.bin %> prune --older-than 90d --dry-run',
22
+ description: 'preview pruning entries older than 30 days',
23
+ command: '<%= config.bin %> prune --older-than 30d --dry-run',
20
24
  },
21
25
  {
22
- description: 'actually prune entries older than 30 days that are source scrapes',
26
+ description: 'prune source artifacts older than 30 days',
23
27
  command: '<%= config.bin %> prune --older-than 30d --artifact-type source --yes',
24
28
  },
25
29
  {
26
- description: 'prune entries matching a URL glob pattern',
30
+ description: 'prune entries matching a URL glob',
27
31
  command: '<%= config.bin %> prune --url "https://react.dev/*" --yes',
28
32
  },
29
33
  ];
30
34
  static flags = {
31
35
  'older-than': Flags.string({
32
- description: 'prune entries older than duration (e.g. "30d", "90d")',
36
+ description: 'content age threshold (fetched_at, else validated_at), e.g. "30d"',
33
37
  }),
34
38
  inactive: Flags.string({
35
- description: 'prune entries inactive (unvalidated/unfetched) for duration (e.g. "14d", "30d")',
39
+ description: 'idle time threshold (validated_at, else fetched_at), e.g. "14d"',
36
40
  }),
37
41
  url: Flags.string({
38
- description: 'filter pruning to source URL glob pattern (case-insensitive, supports * wildcard)',
42
+ description: CLI_FLAG_DESCRIPTIONS.sourceUrlGlob,
39
43
  }),
40
44
  'artifact-type': Flags.option({
41
45
  // Prune operates on every cached file, so it can target any artifact type — including the
42
46
  // `section`/`index` children a page generates (e.g. to clear orphans left after a source is
43
47
  // pruned with `--artifact-type source`).
44
- description: 'filter pruning to specific artifact type',
48
+ description: CLI_FLAG_DESCRIPTIONS.pruneArtifactType,
45
49
  options: ARTIFACT_TYPES,
46
50
  })(),
47
51
  'dry-run': Flags.boolean({
48
- description: 'list files that would be deleted without actually deleting them',
52
+ description: 'preview files without deleting',
49
53
  default: false,
50
54
  }),
51
55
  yes: Flags.boolean({
52
56
  char: 'y',
53
- description: 'confirm deletion and prune matched entries (required unless --dry-run)',
57
+ description: 'confirm deletion (required unless --dry-run)',
54
58
  default: false,
55
59
  }),
56
60
  };
57
61
  static stdoutIsPrimaryData = true;
62
+ toSuccessJson(data) {
63
+ return enrichPrunePartialEnvelope(this.baseSuccessJson(data), data);
64
+ }
58
65
  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
- }
66
+ const err = pruneFlagError({
67
+ olderThan: this.flags['older-than'],
68
+ inactive: this.flags.inactive,
69
+ artifactType: this.flags['artifact-type'],
70
+ url: this.flags.url,
71
+ dryRun: this.flags['dry-run'],
72
+ yes: this.flags.yes,
73
+ readOnly: this.readOnly,
74
+ bin: this.config.bin,
75
+ });
76
+ if (err)
77
+ this.error(err.message, { exit: 2, code: err.code, suggestions: err.suggestions });
107
78
  }
108
79
  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
80
  if (this.flags['artifact-type'] && meta.artifact_type !== this.flags['artifact-type']) {
114
81
  return false;
115
82
  }
116
83
  if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
117
84
  return false;
118
85
  }
119
- if (this.flags['older-than']) {
120
- const olderThanMs = parseTtlToMs(this.flags['older-than']);
121
- if (ageMs < olderThanMs) {
122
- return false;
123
- }
86
+ const now = currentTime.getTime();
87
+ // Content age (fetched_at, else validated_at) vs idle time (validated_at, else fetched_at) are
88
+ // deliberately distinct so a recently revalidated but originally-old page can match --older-than
89
+ // without also being treated as inactive.
90
+ if (this.flags['older-than'] &&
91
+ ageMs(meta, now, 'content') < parseTtlToMs(this.flags['older-than'])) {
92
+ return false;
124
93
  }
125
- if (this.flags.inactive) {
126
- const inactiveMs = parseTtlToMs(this.flags.inactive);
127
- if (ageMs < inactiveMs) {
128
- return false;
129
- }
94
+ if (this.flags.inactive && ageMs(meta, now, 'idle') < parseTtlToMs(this.flags.inactive)) {
95
+ return false;
130
96
  }
131
97
  return true;
132
98
  }
@@ -192,9 +158,16 @@ export default class ResearchPrune extends BaseCommand {
192
158
  const noun = pluralize(count, 'entry', 'entries');
193
159
  this.log(colors.green(`Successfully pruned ${prunedCount} of ${count} research cache ${noun}.`));
194
160
  }
161
+ // Partial unlink failure is a runtime outcome agents must see: exit 1 with the counts still
162
+ // in `data` so callers can tell candidates from successes without parsing stderr warnings.
163
+ if (count > 0 && prunedCount < count) {
164
+ process.exitCode = 1;
165
+ }
195
166
  }
196
167
  return {
197
168
  dryRun,
169
+ status: dryRun ? 'would_prune' : 'pruned',
170
+ wouldPruneCount: dryRun ? count : 0,
198
171
  prunedCount,
199
172
  candidateCount: count,
200
173
  files: filesToPrune.map((f) => ({
@@ -204,3 +177,10 @@ export default class ResearchPrune extends BaseCommand {
204
177
  };
205
178
  }
206
179
  }
180
+ /** Age in ms for prune filters. `content` prefers fetched_at; `idle` prefers validated_at. */
181
+ function ageMs(meta, now, kind) {
182
+ const fetched = meta.fetched_at ? new Date(meta.fetched_at).getTime() : 0;
183
+ const validated = meta.validated_at ? new Date(meta.validated_at).getTime() : 0;
184
+ const base = kind === 'content' ? fetched || validated : validated || fetched;
185
+ return now - base;
186
+ }
@@ -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
  }