@taurgis/bonsai 1.0.6 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +7 -13
  2. package/agents/agents/official-docs-researcher.agent.md +15 -35
  3. package/agents/agents/salesforce-docs-researcher.agent.md +17 -44
  4. package/agents/hooks/README.md +2 -9
  5. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +1 -1
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +1 -1
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +1 -1
  8. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +1 -1
  9. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +1 -1
  10. package/agents/instructions/salesforce-research.instructions.md +7 -8
  11. package/agents/instructions/web-research.instructions.md +3 -4
  12. package/agents/skills/web-research/SKILL.md +24 -37
  13. package/bin/cli.mjs +15 -2
  14. package/dist/base-command.d.ts +15 -2
  15. package/dist/base-command.js +32 -4
  16. package/dist/commands/fetch.d.ts +2 -0
  17. package/dist/commands/fetch.js +43 -29
  18. package/dist/commands/import.js +11 -3
  19. package/dist/commands/inspect.d.ts +4 -0
  20. package/dist/commands/inspect.js +48 -24
  21. package/dist/commands/list.d.ts +3 -1
  22. package/dist/commands/list.js +42 -25
  23. package/dist/commands/prune.d.ts +1 -0
  24. package/dist/commands/prune.js +35 -9
  25. package/dist/commands/status.d.ts +2 -0
  26. package/dist/commands/status.js +57 -25
  27. package/dist/commands.d.ts +0 -2
  28. package/dist/commands.js +0 -2
  29. package/dist/hooks/command-not-found/suggest.d.ts +7 -1
  30. package/dist/hooks/command-not-found/suggest.js +82 -57
  31. package/dist/lib/argv.js +51 -7
  32. package/dist/lib/color.d.ts +20 -0
  33. package/dist/lib/color.js +36 -0
  34. package/dist/lib/envelope.js +8 -2
  35. package/dist/lib/help-preflight.d.ts +15 -0
  36. package/dist/lib/help-preflight.js +81 -0
  37. package/dist/lib/limit-flag.d.ts +1 -0
  38. package/dist/lib/limit-flag.js +24 -0
  39. package/dist/lib/research/{search-index.d.ts → artifact-index.d.ts} +5 -5
  40. package/dist/lib/research/{search-index.js → artifact-index.js} +10 -10
  41. package/dist/lib/research/browser.d.ts +0 -8
  42. package/dist/lib/research/browser.js +20 -43
  43. package/dist/lib/research/regression.d.ts +18 -0
  44. package/dist/lib/research/regression.js +47 -0
  45. package/dist/lib/research/storage.d.ts +0 -1
  46. package/dist/lib/research/storage.js +15 -13
  47. package/dist/lib/research/url.d.ts +22 -0
  48. package/dist/lib/research/url.js +63 -1
  49. package/dist/lib/text.d.ts +3 -1
  50. package/dist/lib/text.js +9 -1
  51. package/dist/sites/coveo.d.ts +7 -0
  52. package/dist/sites/coveo.js +33 -0
  53. package/dist/sites/index.js +1 -18
  54. package/dist/sites/salesforce/fetch-page.js +1 -1
  55. package/dist/sites/salesforce/index.js +3 -8
  56. package/dist/sites/salesforce-developer/fetch-page.js +3 -1
  57. package/dist/sites/salesforce-developer/index.js +1 -2
  58. package/dist/sites/salesforce-doc-fetch.d.ts +11 -0
  59. package/dist/sites/salesforce-doc-fetch.js +117 -76
  60. package/dist/sites/salesforce-dom-probe.d.ts +16 -0
  61. package/dist/sites/salesforce-dom-probe.js +114 -0
  62. package/dist/sites/types.d.ts +0 -6
  63. package/oclif.manifest.json +31 -106
  64. package/package.json +32 -25
  65. package/dist/commands/index.d.ts +0 -35
  66. package/dist/commands/index.js +0 -306
  67. package/dist/commands/research/config/base.d.ts +0 -23
  68. package/dist/commands/research/config/base.js +0 -46
  69. package/dist/commands/research/config/get.d.ts +0 -20
  70. package/dist/commands/research/config/get.js +0 -45
  71. package/dist/commands/research/config/list.d.ts +0 -17
  72. package/dist/commands/research/config/list.js +0 -42
  73. package/dist/commands/research/config/set.d.ts +0 -31
  74. package/dist/commands/research/config/set.js +0 -94
  75. package/dist/commands/research/config/unset.d.ts +0 -21
  76. package/dist/commands/research/config/unset.js +0 -59
  77. package/dist/commands/research/import.d.ts +0 -38
  78. package/dist/commands/research/import.js +0 -313
  79. package/dist/commands/research/inspect.d.ts +0 -13
  80. package/dist/commands/research/inspect.js +0 -86
  81. package/dist/commands/research/list.d.ts +0 -25
  82. package/dist/commands/research/list.js +0 -143
  83. package/dist/commands/research/prune.d.ts +0 -24
  84. package/dist/commands/research/prune.js +0 -141
  85. package/dist/commands/research/search.d.ts +0 -32
  86. package/dist/commands/research/search.js +0 -334
  87. package/dist/commands/research/status.d.ts +0 -17
  88. package/dist/commands/research/status.js +0 -108
  89. package/dist/commands/research.d.ts +0 -36
  90. package/dist/commands/research.js +0 -304
  91. package/dist/commands/search.d.ts +0 -35
  92. package/dist/commands/search.js +0 -391
  93. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +0 -1
  94. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +0 -2
  95. package/dist/lib/research/docs/discovery-index.d.ts +0 -14
  96. package/dist/lib/research/docs/discovery-index.js +0 -112
  97. package/dist/lib/research/docs/remote-search-runner.d.ts +0 -21
  98. package/dist/lib/research/docs/remote-search-runner.js +0 -114
  99. package/dist/lib/research/docs/remote-search.d.ts +0 -14
  100. package/dist/lib/research/docs/remote-search.js +0 -101
  101. package/dist/lib/research/docs/search-index.d.ts +0 -13
  102. package/dist/lib/research/docs/search-index.js +0 -176
  103. package/dist/sites/algolia.d.ts +0 -9
  104. package/dist/sites/algolia.js +0 -26
  105. package/dist/sites/auto.d.ts +0 -8
  106. package/dist/sites/auto.js +0 -30
  107. package/dist/sites/nextjs/index.d.ts +0 -2
  108. package/dist/sites/nextjs/index.js +0 -52
  109. package/dist/sites/react/index.d.ts +0 -2
  110. package/dist/sites/react/index.js +0 -17
  111. package/dist/sites/salesforce/browser-search.d.ts +0 -7
  112. package/dist/sites/salesforce/browser-search.js +0 -82
  113. package/dist/sites/salesforce/coveo.d.ts +0 -23
  114. package/dist/sites/salesforce/coveo.js +0 -161
  115. package/dist/sites/salesforce/token.d.ts +0 -26
  116. package/dist/sites/salesforce/token.js +0 -154
  117. package/dist/sites/tailwind/index.d.ts +0 -2
  118. package/dist/sites/tailwind/index.js +0 -17
  119. package/dist/sites/vue/index.d.ts +0 -2
  120. package/dist/sites/vue/index.js +0 -17
@@ -2,6 +2,7 @@ import { Command, Errors, toConfiguredId } from '@oclif/core';
2
2
  import { invalidEnvOverrideWarnings } from './lib/config/index.js';
3
3
  import { buildEnvelope, formatErrorForJson, normalizeCliErrorMessage, stableErrorCodeFrom, } from './lib/envelope.js';
4
4
  import { resolveResearchTarget, } from './lib/research/resolve-target.js';
5
+ import { looksLikeSchemelessUrl } from './lib/research/url.js';
5
6
  /**
6
7
  * Shared base for every Bonsai command. Enables oclif's native `--json` flag,
7
8
  * parses args/flags once in `init()` so commands read `this.args`/`this.flags`
@@ -12,9 +13,10 @@ export class BaseCommand extends Command {
12
13
  static enableJsonFlag = true;
13
14
  flags;
14
15
  args;
16
+ parsedArgv;
15
17
  async init() {
16
18
  await super.init();
17
- const { args, flags } = await this.parse({
19
+ const { args, flags, argv } = await this.parse({
18
20
  flags: this.ctor.flags,
19
21
  enableJsonFlag: this.ctor.enableJsonFlag,
20
22
  args: this.ctor.args,
@@ -22,6 +24,7 @@ export class BaseCommand extends Command {
22
24
  });
23
25
  this.flags = flags;
24
26
  this.args = args;
27
+ this.parsedArgv = argv;
25
28
  // Surface a set-but-invalid BONSAI_* override once per run. Resolution silently drops such a
26
29
  // value, so without this a typo'd env var would take no effect with no signal. Warnings go to
27
30
  // stderr (even under --json), so machine output stays clean.
@@ -30,7 +33,7 @@ export class BaseCommand extends Command {
30
33
  }
31
34
  /**
32
35
  * Always surface warnings on stderr, even under `--json`. oclif silences `warn()` in JSON mode,
33
- * but our warnings (secret-redirect, stale-serve, remote-search fallback, prune failures) are
36
+ * but our warnings (secret-redirect, stale-serve, prune failures) are
34
37
  * security- and freshness-relevant side effects users must see. stderr never pollutes the stdout
35
38
  * JSON envelope, so machine output stays clean.
36
39
  */
@@ -59,6 +62,15 @@ export class BaseCommand extends Command {
59
62
  // [UnparsedCommand] warning to stderr even under `--json`. CLIParseError subclasses carry `parse`.
60
63
  if (err && typeof err === 'object' && 'parse' in err)
61
64
  this.parsed = true;
65
+ // Attach the stable code to the error itself so oclif's human pretty-print renders the same
66
+ // `Code:` line that `--json` already reports via stableErrorCodeFrom. Without this, built-in
67
+ // oclif parse errors (invalid enum value, missing arg, unknown flag) printed a code under
68
+ // --json but not for humans — the two output modes disagreed on whether an error had a code.
69
+ if (err && typeof err === 'object' && !err.code) {
70
+ const code = stableErrorCodeFrom(err);
71
+ if (code)
72
+ err.code = code;
73
+ }
62
74
  process.exitCode = process.exitCode ?? BaseCommand.exitCodeOf(err);
63
75
  return super.catch(err);
64
76
  }
@@ -66,7 +78,12 @@ export class BaseCommand extends Command {
66
78
  envelopeCommandId() {
67
79
  return this.ctor.id ? toConfiguredId(this.ctor.id, this.config) : this.config.bin;
68
80
  }
69
- /** Resolve a URL against the research cache, exiting with INVALID_URL on normalization failure. */
81
+ /**
82
+ * Resolve a URL against the research cache, exiting on a normalization failure. A scheme-less but
83
+ * domain-shaped input reports MISSING_URL_SCHEME — the same code the root `bonsai <url>` shorthand
84
+ * uses — so an agent sees one stable code for "forgot https://" everywhere; truly unparseable input
85
+ * stays INVALID_URL.
86
+ */
70
87
  resolveResearchTargetOrFail(url, extra) {
71
88
  try {
72
89
  return resolveResearchTarget({
@@ -78,8 +95,19 @@ export class BaseCommand extends Command {
78
95
  });
79
96
  }
80
97
  catch (err) {
81
- this.error(`Invalid URL: ${err.message}`, { exit: 2, code: 'INVALID_URL' });
98
+ this.failInvalidUrl(url, err.message);
99
+ }
100
+ }
101
+ /**
102
+ * Single exit point for a URL that failed normalization, shared by every URL-accepting command.
103
+ * The scheme-less message is already self-contained and actionable, so it is surfaced as-is;
104
+ * other parse failures keep the "Invalid URL:" prefix for context.
105
+ */
106
+ failInvalidUrl(url, message) {
107
+ if (looksLikeSchemelessUrl(url)) {
108
+ this.error(message, { exit: 2, code: 'MISSING_URL_SCHEME' });
82
109
  }
110
+ this.error(`Invalid URL: ${message}`, { exit: 2, code: 'INVALID_URL' });
83
111
  }
84
112
  /** Single source of truth for the `--json` envelope shape, shared by success and error output. */
85
113
  envelope(parts) {
@@ -8,6 +8,7 @@ export default class FetchCommand extends BaseCommand<typeof FetchCommand> {
8
8
  description: string;
9
9
  command: string;
10
10
  }[];
11
+ static strict: boolean;
11
12
  static args: {
12
13
  url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
13
14
  };
@@ -35,6 +36,7 @@ export default class FetchCommand extends BaseCommand<typeof FetchCommand> {
35
36
  private persistFreshArtifact;
36
37
  private persistSectionsIfFresh;
37
38
  run(): Promise<unknown>;
39
+ private fetchSingleTarget;
38
40
  }
39
41
  export declare function fetchFailureGuidance(message: string, url: string): {
40
42
  suggestions: string[];
@@ -1,4 +1,4 @@
1
- import { Args, Flags, ux } from '@oclif/core';
1
+ import { Args, Flags, ux, Errors } from '@oclif/core';
2
2
  import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
@@ -34,6 +34,7 @@ export default class FetchCommand extends BaseCommand {
34
34
  command: '<%= config.bin %> https://news.ycombinator.com/ --format compressed --ttl 2h --json',
35
35
  },
36
36
  ];
37
+ static strict = false;
37
38
  static args = {
38
39
  url: Args.string({
39
40
  required: true,
@@ -255,49 +256,31 @@ export default class FetchCommand extends BaseCommand {
255
256
  }
256
257
  }
257
258
  async run() {
258
- const { url } = this.args;
259
+ const urls = this.parsedArgv;
259
260
  const { format, ttl, 'max-age': maxAge, 'dry-run': dryRun } = this.flags;
260
261
  this.validateDurationFlags(ttl, maxAge);
261
- const target = this.resolveResearchTargetOrFail(url, {
262
- flagOverride: this.flags.storage,
263
- lookup: !this.flags.force,
264
- });
265
- const { cacheKey, located, normalizedUrl, roots } = target;
266
262
  const summaryLevel = loadSummaryLevel(this.config.configDir, process.cwd());
267
263
  const tmpDir = dryRun ? mkdtempSync(join(tmpdir(), 'fnr-dry-run-')) : null;
268
264
  const currentTime = new Date();
265
+ const results = [];
269
266
  try {
270
- if (!this.jsonEnabled()) {
271
- ux.action.start('Fetching ' + normalizedUrl);
272
- }
273
- const { cacheStatus, freshnessState, artifact, storageDir, redirectedToGlobal } = await this.resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel);
274
- this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus, summaryLevel);
275
- if (!this.jsonEnabled()) {
276
- const CACHE_STATUS_LABEL = {
277
- hit: 'cached',
278
- miss: 'done',
279
- refreshed: 'refreshed',
280
- revalidated: 'revalidated',
281
- stale: 'served stale',
282
- };
283
- ux.action.stop(CACHE_STATUS_LABEL[cacheStatus] ?? cacheStatus);
284
- }
285
- const content = format === 'compressed' ? artifact.compressed : artifact.detailed;
286
- const resultData = this.buildResultData(url, normalizedUrl, cacheKey, storageDir, roots.mode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal);
287
- if (!this.jsonEnabled()) {
288
- this.log(content);
267
+ for (const url of urls) {
268
+ results.push(await this.fetchSingleTarget(url, currentTime, tmpDir, summaryLevel, format, urls.length > 1));
289
269
  }
290
- return resultData;
270
+ return urls.length === 1 ? results[0] : results;
291
271
  }
292
272
  catch (err) {
293
273
  if (!this.jsonEnabled()) {
294
274
  ux.action.stop('failed');
295
275
  }
276
+ if (err instanceof Errors.CLIError)
277
+ throw err;
296
278
  // Stale-serve (exit 5) signals via process.exitCode and never throws, so it bypasses this
297
279
  // path; only genuine fetch/extract failures land here and get classified guidance. Use the
298
280
  // normalized URL in hints so the copy-paste command is canonical (and never echoes raw,
299
281
  // unsanitized user input back to the terminal).
300
- this.emitFetchError(err, normalizedUrl);
282
+ const failedUrl = urls[results.length] || urls[0] || '';
283
+ this.emitFetchError(err, failedUrl);
301
284
  }
302
285
  finally {
303
286
  if (tmpDir) {
@@ -305,13 +288,44 @@ export default class FetchCommand extends BaseCommand {
305
288
  }
306
289
  }
307
290
  }
291
+ async fetchSingleTarget(url, currentTime, tmpDir, summaryLevel, format, showSeparator) {
292
+ const target = this.resolveResearchTargetOrFail(url, {
293
+ flagOverride: this.flags.storage,
294
+ lookup: !this.flags.force,
295
+ });
296
+ const { cacheKey, located, normalizedUrl, roots } = target;
297
+ if (!this.jsonEnabled()) {
298
+ ux.action.start('Fetching ' + normalizedUrl);
299
+ }
300
+ const { cacheStatus, freshnessState, artifact, storageDir, redirectedToGlobal } = await this.resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel);
301
+ this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus, summaryLevel);
302
+ if (!this.jsonEnabled()) {
303
+ const CACHE_STATUS_LABEL = {
304
+ hit: 'cached',
305
+ miss: 'done',
306
+ refreshed: 'refreshed',
307
+ revalidated: 'revalidated',
308
+ stale: 'served stale',
309
+ };
310
+ ux.action.stop(CACHE_STATUS_LABEL[cacheStatus] ?? cacheStatus);
311
+ }
312
+ const content = format === 'compressed' ? artifact.compressed : artifact.detailed;
313
+ const resultData = this.buildResultData(url, normalizedUrl, cacheKey, storageDir, roots.mode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal);
314
+ if (!this.jsonEnabled()) {
315
+ this.log(content);
316
+ if (showSeparator) {
317
+ this.log('\n' + '='.repeat(40) + '\n');
318
+ }
319
+ }
320
+ return resultData;
321
+ }
308
322
  }
309
323
  // Maps a runtime fetch failure to recovery steps keyed off its message. Returns undefined for
310
324
  // unrecognized failures, which then surface with their original message and no extra hint. The
311
325
  // patterns match the thrown text in fetcher.ts/browser.ts; keep them in sync if those messages
312
326
  // change. Resolutions mirror the published troubleshooting guide.
313
327
  export function fetchFailureGuidance(message, url) {
314
- const ref = 'https://taurgis.github.io/bonsai/troubleshooting';
328
+ const ref = 'https://bonsai.rhino-inquisitor.com/troubleshooting';
315
329
  // Name the pipe step explicitly: bare `import --stdin` blocks waiting for input, so a reader (or
316
330
  // an agent) running the hint verbatim would hang. Show the content being piped in from a file.
317
331
  const importHint = `Open it in a browser, save the page, then import it: cat page.md | bonsai import ${url} --stdin`;
@@ -30,6 +30,10 @@ export default class ResearchImport extends BaseCommand {
30
30
  description: 'import research from a local Markdown file',
31
31
  command: '<%= config.bin %> import https://example.com/docs --file path/to/notes.md',
32
32
  },
33
+ {
34
+ description: 'import research from stdin using the standard file placeholder',
35
+ command: 'cat notes.md | <%= config.bin %> import https://example.com/docs --file -',
36
+ },
33
37
  ];
34
38
  static args = {
35
39
  url: Args.string({
@@ -47,7 +51,7 @@ export default class ResearchImport extends BaseCommand {
47
51
  default: false,
48
52
  }),
49
53
  file: Flags.string({
50
- description: 'path to a Markdown file containing research notes to import',
54
+ description: 'path to a Markdown file containing research notes to import, or "-" for stdin',
51
55
  }),
52
56
  'input-format': Flags.option({
53
57
  description: 'format of the input provided',
@@ -121,7 +125,9 @@ export default class ResearchImport extends BaseCommand {
121
125
  return normalizeUrl(u);
122
126
  }
123
127
  catch (err) {
124
- this.error(`Invalid URL: ${err.message}`, { exit: 2, code: 'INVALID_URL' });
128
+ // Shared exit point: a forgotten scheme reports MISSING_URL_SCHEME like every other command,
129
+ // while genuinely malformed input stays INVALID_URL.
130
+ this.failInvalidUrl(u, err.message);
125
131
  }
126
132
  });
127
133
  return hasMulti ? normalized.sort() : normalized;
@@ -168,6 +174,7 @@ export default class ResearchImport extends BaseCommand {
168
174
  const bin = this.config.bin;
169
175
  return [
170
176
  `Pipe Markdown content, e.g. cat notes.md | ${bin} import <url> --stdin`,
177
+ `Or use the standard stdin placeholder: cat notes.md | ${bin} import <url> --file -`,
171
178
  `Or read from a file: ${bin} import <url> --file notes.md`,
172
179
  ];
173
180
  }
@@ -222,7 +229,8 @@ export default class ResearchImport extends BaseCommand {
222
229
  return content;
223
230
  }
224
231
  async readAndValidateInput() {
225
- if (this.flags.stdin) {
232
+ const fileReadsStdin = this.flags.file === '-';
233
+ if (this.flags.stdin || fileReadsStdin) {
226
234
  const rawInput = await this.readStdinWithGuard();
227
235
  if (!rawInput.trim()) {
228
236
  this.error('Empty stdin content provided.', { exit: 2, code: 'EMPTY_INPUT' });
@@ -7,10 +7,14 @@ export default class ResearchInspect extends BaseCommand<typeof ResearchInspect>
7
7
  description: string;
8
8
  command: string;
9
9
  }[];
10
+ static strict: boolean;
10
11
  static args: {
11
12
  url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
12
13
  };
13
14
  static stdoutIsPrimaryData: boolean;
14
15
  run(): Promise<unknown>;
16
+ private logMetadata;
17
+ private logSections;
18
+ private inspectSingleTarget;
15
19
  private findSections;
16
20
  }
@@ -1,6 +1,7 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { BaseCommand } from '../base-command.js';
3
3
  import { scanCacheDirs } from '../lib/research/storage.js';
4
+ import { colors } from '../lib/color.js';
4
5
  export default class ResearchInspect extends BaseCommand {
5
6
  static id = 'inspect';
6
7
  static summary = 'Inspect cached research metadata for a URL.';
@@ -15,6 +16,7 @@ export default class ResearchInspect extends BaseCommand {
15
16
  command: '<%= config.bin %> <%= command.id %> https://example.com/docs --json',
16
17
  },
17
18
  ];
19
+ static strict = false;
18
20
  static args = {
19
21
  url: Args.string({
20
22
  required: true,
@@ -23,39 +25,61 @@ export default class ResearchInspect extends BaseCommand {
23
25
  };
24
26
  static stdoutIsPrimaryData = true;
25
27
  async run() {
26
- const { url } = this.args;
27
- const target = this.resolveResearchTargetOrFail(url);
28
- const { cacheKey, located, normalizedUrl, roots } = target;
29
- if (!located) {
30
- // inspect makes no network call, so the bare message reads like the network-failure case the
31
- // exit-1 contract describes. Point at the fix instead: fetch the page so it lands in the cache.
32
- this.error(`No cached research found for URL: ${normalizedUrl}`, {
28
+ const urls = this.parsedArgv;
29
+ const results = [];
30
+ const missingUrls = [];
31
+ for (const url of urls) {
32
+ const target = this.resolveResearchTargetOrFail(url);
33
+ if (!target.located) {
34
+ missingUrls.push(target.normalizedUrl);
35
+ continue;
36
+ }
37
+ results.push(this.inspectSingleTarget(target, urls.length > 1));
38
+ }
39
+ if (missingUrls.length > 0) {
40
+ const firstMissing = missingUrls[0];
41
+ const suggestions = missingUrls.map((u) => `Fetch and cache it first: ${this.config.bin} ${u}`);
42
+ this.error(`No cached research found for URL: ${firstMissing}` +
43
+ (missingUrls.length > 1 ? ` and ${missingUrls.length - 1} other URLs` : ''), {
33
44
  exit: 1,
34
45
  code: 'CACHE_MISS',
35
- suggestions: [`Fetch and cache it first: ${this.config.bin} ${normalizedUrl}`],
46
+ suggestions,
36
47
  });
37
48
  }
49
+ return urls.length === 1 ? results[0] : results;
50
+ }
51
+ logMetadata(metadata) {
52
+ this.log(colors.cyan(`--- Metadata ---`));
53
+ for (const [key, val] of Object.entries(metadata)) {
54
+ const paddedKey = colors.cyan((key + ':').padEnd(Math.max(25, key.length + 2)));
55
+ if (typeof val === 'object' && val !== null) {
56
+ this.log(`${paddedKey} ${colors.bold(JSON.stringify(val))}`);
57
+ }
58
+ else {
59
+ this.log(`${paddedKey} ${colors.bold(String(val))}`);
60
+ }
61
+ }
62
+ }
63
+ logSections(sections) {
64
+ this.log(colors.cyan(`--- Sections (${sections.length}) ---`));
65
+ for (const s of sections) {
66
+ this.log(`${colors.cyan(s.headingPath || '')} [${colors.yellow(s.anchor || '')}] (${colors.magenta(String(s.tokenEstimate.detailed || 0))} tokens) ${colors.gray(s.cacheKey)}`);
67
+ }
68
+ }
69
+ inspectSingleTarget(target, showSeparator) {
70
+ const { cacheKey, located, roots } = target;
38
71
  const cached = located.artifact;
39
72
  const artifactPath = located.path;
40
73
  const sections = this.findSections(roots.readRoots, cacheKey);
41
74
  if (!this.jsonEnabled()) {
42
- this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
43
- this.log(`${'Cache Path:'.padEnd(25)} ${artifactPath}`);
44
- this.log(`--- Metadata ---`);
45
- for (const [key, val] of Object.entries(cached.metadata)) {
46
- const paddedKey = (key + ':').padEnd(Math.max(25, key.length + 2));
47
- if (typeof val === 'object' && val !== null) {
48
- this.log(`${paddedKey} ${JSON.stringify(val)}`);
49
- }
50
- else {
51
- this.log(`${paddedKey} ${val}`);
52
- }
53
- }
75
+ this.log(`${colors.cyan('Cache Key:'.padEnd(25))} ${colors.bold(cacheKey)}`);
76
+ this.log(`${colors.cyan('Cache Path:'.padEnd(25))} ${colors.gray(artifactPath)}`);
77
+ this.logMetadata(cached.metadata);
54
78
  if (sections.length) {
55
- this.log(`--- Sections (${sections.length}) ---`);
56
- for (const s of sections) {
57
- this.log(`${s.headingPath} [${s.anchor}] (${s.tokenEstimate.detailed} tokens) ${s.cacheKey}`);
58
- }
79
+ this.logSections(sections);
80
+ }
81
+ if (showSeparator) {
82
+ this.log('='.repeat(40));
59
83
  }
60
84
  }
61
85
  return {
@@ -10,13 +10,15 @@ export default class ResearchList extends BaseCommand<typeof ResearchList> {
10
10
  static flags: {
11
11
  topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
14
  freshness: import("@oclif/core/interfaces").OptionFlag<"fresh" | "stale_grace" | "stale_expired" | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
16
  'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
17
  limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
17
18
  };
18
19
  static stdoutIsPrimaryData: boolean;
19
- private validateListFlags;
20
+ private matchesTopic;
21
+ private matchesTags;
20
22
  private matchesFilters;
21
23
  private scanCacheDirForList;
22
24
  private logListResults;
@@ -5,6 +5,9 @@ import { loadStoreRoots } from '../lib/research/store-roots.js';
5
5
  import { evaluateFreshness } from '../lib/research/freshness.js';
6
6
  import { ARTIFACT_TYPES, CAPTURE_METHODS } from '../lib/research/schema.js';
7
7
  import { NO_TOPIC_LABEL, resultListHeading, truncationNotice, } from '../lib/text.js';
8
+ import { limitFlag } from '../lib/limit-flag.js';
9
+ import { artifactMatchesUrlFilter } from '../lib/research/url.js';
10
+ import { colors } from '../lib/color.js';
8
11
  // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at 100.
9
12
  const LIST_LABELS = { noun: 'cached research', order: 'first', maxLimit: 100 };
10
13
  // `list` answers "what pages/notes do I have?" and deliberately omits section children (see
@@ -27,6 +30,10 @@ export default class ResearchList extends BaseCommand {
27
30
  description: 'list only fresh entries filtered by tags',
28
31
  command: '<%= config.bin %> list --freshness fresh --tags node --tags url',
29
32
  },
33
+ {
34
+ description: 'list entries matching a specific URL glob',
35
+ command: '<%= config.bin %> list --url "https://react.dev/*"',
36
+ },
30
37
  ];
31
38
  static flags = {
32
39
  topic: Flags.string({
@@ -38,6 +45,9 @@ export default class ResearchList extends BaseCommand {
38
45
  description: 'filter by tags (must match all tags specified)',
39
46
  multiple: true,
40
47
  }),
48
+ url: Flags.string({
49
+ description: 'filter by source URL glob pattern (case-insensitive, supports * wildcard)',
50
+ }),
41
51
  freshness: Flags.option({
42
52
  description: 'filter by freshness state',
43
53
  options: ['fresh', 'stale_grace', 'stale_expired'],
@@ -50,29 +60,29 @@ export default class ResearchList extends BaseCommand {
50
60
  description: 'filter by capture method',
51
61
  options: CAPTURE_METHODS,
52
62
  })(),
53
- limit: Flags.integer({
54
- description: 'maximum number of results to return (default 50, max 100)',
55
- default: 50,
56
- }),
63
+ limit: limitFlag(100, 50, 'maximum number of results to return (default 50, max 100)'),
57
64
  };
58
65
  static stdoutIsPrimaryData = true;
59
- validateListFlags() {
60
- const limit = this.flags.limit;
61
- if (limit !== undefined && (limit < 1 || limit > 100)) {
62
- this.error('Limit must be between 1 and 100.', { exit: 2, code: 'INVALID_LIMIT' });
63
- }
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()));
64
76
  }
65
77
  matchesFilters(meta, freshness) {
66
- if (this.flags.topic &&
67
- (!meta.topic || meta.topic.trim().toLowerCase() !== this.flags.topic.trim().toLowerCase())) {
78
+ if (!this.matchesTopic(meta, this.flags.topic)) {
68
79
  return false;
69
80
  }
70
- if (this.flags.tags && this.flags.tags.length > 0) {
71
- const metaTagsLower = (meta.tags || []).map((t) => t.toLowerCase());
72
- const hasAllTags = this.flags.tags.every((t) => metaTagsLower.includes(t.toLowerCase()));
73
- if (!hasAllTags) {
74
- return false;
75
- }
81
+ if (!this.matchesTags(meta, this.flags.tags)) {
82
+ return false;
83
+ }
84
+ if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
85
+ return false;
76
86
  }
77
87
  if (this.flags['artifact-type'] && meta.artifact_type !== this.flags['artifact-type']) {
78
88
  return false;
@@ -91,8 +101,7 @@ export default class ResearchList extends BaseCommand {
91
101
  return null;
92
102
  // Section children are sub-chunks of a page, not artifacts a user "has" — they would flood the
93
103
  // listing (one page yields dozens) and aren't in the documented source/research_note contract.
94
- // They stay discoverable through `search` (which ranks them) and `inspect` (which lists a
95
- // page's sections). `list` answers "what pages/notes do I have?", so keep it page-level. This
104
+ // They stay discoverable through `inspect` (which lists a page's sections). `list` answers "what pages/notes do I have?", so keep it page-level. This
96
105
  // unconditional guard owns the exclusion (the default no-filter case relies on it);
97
106
  // LISTABLE_ARTIFACT_TYPES just hides `section` from --artifact-type so no one filters for a
98
107
  // type list can never return. Keep both in sync if section handling ever changes.
@@ -122,19 +131,27 @@ export default class ResearchList extends BaseCommand {
122
131
  return;
123
132
  if (finalResults.length === 0) {
124
133
  this.log('No cached research entries found matching filters.');
125
- this.log(`\nTip: populate the cache first: ${this.config.bin} <url>`);
134
+ this.log(`\nTip: populate the cache first: ${colors.cyan(this.config.bin + ' <url>')}`);
126
135
  return;
127
136
  }
128
137
  this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
129
138
  finalResults.forEach((res, index) => {
130
- this.log(`${index + 1}. [${res.topic || NO_TOPIC_LABEL}] Key: ${res.cacheKey}`);
131
- this.log(` Type: ${res.artifactType} | Freshness: ${res.freshness}`);
132
- this.log(` Tokens: compressed=${res.tokenEstimate?.compressed || 0}, detailed=${res.tokenEstimate?.detailed || 0}`);
133
- this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
139
+ const topicStr = res.topic ? colors.cyan(res.topic) : colors.gray(NO_TOPIC_LABEL);
140
+ const keyStr = colors.bold(res.cacheKey);
141
+ this.log(`${index + 1}. [${topicStr}] Key: ${keyStr}`);
142
+ const freshnessColorMap = {
143
+ fresh: colors.green,
144
+ stale_grace: colors.yellow,
145
+ stale_expired: colors.red,
146
+ };
147
+ const freshnessColor = freshnessColorMap[res.freshness] || ((t) => t);
148
+ const freshnessStr = freshnessColor(res.freshness);
149
+ this.log(` Type: ${colors.bold(res.artifactType)} | Freshness: ${freshnessStr}`);
150
+ this.log(` Tokens: compressed=${colors.bold(String(res.tokenEstimate?.compressed || 0))}, detailed=${colors.bold(String(res.tokenEstimate?.detailed || 0))}`);
151
+ this.log(` Source URLs: ${colors.gray(res.sourceUrls.join(', '))}\n`);
134
152
  });
135
153
  }
136
154
  async run() {
137
- this.validateListFlags();
138
155
  const roots = loadStoreRoots({
139
156
  configDir: this.config.configDir,
140
157
  cwd: process.cwd(),
@@ -10,6 +10,7 @@ export default class ResearchPrune extends BaseCommand<typeof ResearchPrune> {
10
10
  static flags: {
11
11
  'older-than': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
12
  inactive: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
14
  'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | "section" | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
16
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -7,6 +7,8 @@ import { loadStoreRoots } from '../lib/research/store-roots.js';
7
7
  import { parseTtlToMs, durationFlagError } from '../lib/research/freshness.js';
8
8
  import { ARTIFACT_TYPES } from '../lib/research/schema.js';
9
9
  import { NO_TOPIC_LABEL, pluralize } from '../lib/text.js';
10
+ import { artifactMatchesUrlFilter } from '../lib/research/url.js';
11
+ import { colors } from '../lib/color.js';
10
12
  export default class ResearchPrune extends BaseCommand {
11
13
  static id = 'prune';
12
14
  static summary = 'Clean up old or inactive research cache entries.';
@@ -20,6 +22,10 @@ export default class ResearchPrune extends BaseCommand {
20
22
  description: 'actually prune entries older than 30 days that are source scrapes',
21
23
  command: '<%= config.bin %> prune --older-than 30d --artifact-type source --yes',
22
24
  },
25
+ {
26
+ description: 'prune entries matching a URL glob pattern',
27
+ command: '<%= config.bin %> prune --url "https://react.dev/*" --yes',
28
+ },
23
29
  ];
24
30
  static flags = {
25
31
  'older-than': Flags.string({
@@ -28,6 +34,9 @@ export default class ResearchPrune extends BaseCommand {
28
34
  inactive: Flags.string({
29
35
  description: 'prune entries inactive (unvalidated/unfetched) for duration (e.g. "14d", "30d")',
30
36
  }),
37
+ url: Flags.string({
38
+ description: 'filter pruning to source URL glob pattern (case-insensitive, supports * wildcard)',
39
+ }),
31
40
  'artifact-type': Flags.option({
32
41
  // Prune operates on every cached file, so it can target any artifact type — including the
33
42
  // `section`/`index` children a page generates (e.g. to clear orphans left after a source is
@@ -47,8 +56,11 @@ export default class ResearchPrune extends BaseCommand {
47
56
  };
48
57
  static stdoutIsPrimaryData = true;
49
58
  validatePruneFlags() {
50
- if (!this.flags['older-than'] && !this.flags.inactive && !this.flags['artifact-type']) {
51
- this.error('Must specify at least one pruning filter: --older-than, --inactive, or --artifact-type.', {
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.', {
52
64
  exit: 2,
53
65
  code: 'MISSING_FILTER',
54
66
  suggestions: [
@@ -56,14 +68,20 @@ export default class ResearchPrune extends BaseCommand {
56
68
  ],
57
69
  });
58
70
  }
71
+ if (this.flags['dry-run'] && this.flags.yes) {
72
+ this.error('--dry-run and --yes are mutually exclusive: --dry-run previews without deleting, --yes confirms deletion. Choose one.', { exit: 2, code: 'CONFLICTING_FLAGS' });
73
+ }
59
74
  if (!this.flags['dry-run'] && !this.flags.yes) {
60
75
  const olderThanPart = this.flags['older-than']
61
76
  ? ` --older-than ${this.flags['older-than']}`
62
77
  : '';
78
+ const urlPart = this.flags.url ? ` --url "${this.flags.url}"` : '';
63
79
  this.error('Safety check: use --yes to confirm pruning, or --dry-run to preview files that would be deleted.', {
64
80
  exit: 2,
65
81
  code: 'SAFETY_CHECK_REQUIRED',
66
- suggestions: [`Preview first: ${this.config.bin} prune --dry-run${olderThanPart}`],
82
+ suggestions: [
83
+ `Preview first: ${this.config.bin} prune --dry-run${olderThanPart}${urlPart}`,
84
+ ],
67
85
  });
68
86
  }
69
87
  // Validate durations up front: scanCacheDir swallows per-file errors, so a malformed
@@ -84,6 +102,9 @@ export default class ResearchPrune extends BaseCommand {
84
102
  if (this.flags['artifact-type'] && meta.artifact_type !== this.flags['artifact-type']) {
85
103
  return false;
86
104
  }
105
+ if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
106
+ return false;
107
+ }
87
108
  if (this.flags['older-than']) {
88
109
  const olderThanMs = parseTtlToMs(this.flags['older-than']);
89
110
  if (ageMs < olderThanMs) {
@@ -142,18 +163,23 @@ export default class ResearchPrune extends BaseCommand {
142
163
  let prunedCount = 0;
143
164
  if (dryRun) {
144
165
  if (!this.jsonEnabled()) {
145
- const noun = pluralize(count, 'entry', 'entries');
146
- this.log(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`);
147
- filesToPrune.forEach((f) => {
148
- this.log(`- [${f.topic || NO_TOPIC_LABEL}] Key: ${f.cacheKey} (${f.url || 'Imported note'})`);
149
- });
166
+ if (count === 0) {
167
+ this.log(colors.yellow('[Dry Run] No research cache entries match the given filters.'));
168
+ }
169
+ else {
170
+ const noun = pluralize(count, 'entry', 'entries');
171
+ this.log(colors.yellow(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`));
172
+ filesToPrune.forEach((f) => {
173
+ this.log(`- [${f.topic ? colors.cyan(f.topic) : colors.gray(NO_TOPIC_LABEL)}] Key: ${colors.bold(f.cacheKey)} (${colors.gray(f.url || 'Imported note')})`);
174
+ });
175
+ }
150
176
  }
151
177
  }
152
178
  else {
153
179
  prunedCount = this.deletePruneCandidates(filesToPrune);
154
180
  if (!this.jsonEnabled()) {
155
181
  const noun = pluralize(count, 'entry', 'entries');
156
- this.log(`Successfully pruned ${prunedCount} of ${count} research cache ${noun}.`);
182
+ this.log(colors.green(`Successfully pruned ${prunedCount} of ${count} research cache ${noun}.`));
157
183
  }
158
184
  }
159
185
  return {