@taurgis/bonsai 1.0.6 → 3.0.1

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 (127) 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 +3 -0
  17. package/dist/commands/fetch.js +96 -30
  18. package/dist/commands/import.js +14 -5
  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 +1 -8
  42. package/dist/lib/research/browser.js +32 -46
  43. package/dist/lib/research/docs/markdown-source.js +2 -1
  44. package/dist/lib/research/extract.js +2 -1
  45. package/dist/lib/research/fetcher.js +92 -48
  46. package/dist/lib/research/prompt-injection.d.ts +1 -0
  47. package/dist/lib/research/prompt-injection.js +149 -0
  48. package/dist/lib/research/proxy.d.ts +35 -0
  49. package/dist/lib/research/proxy.js +107 -0
  50. package/dist/lib/research/regression.d.ts +18 -0
  51. package/dist/lib/research/regression.js +47 -0
  52. package/dist/lib/research/storage.d.ts +0 -1
  53. package/dist/lib/research/storage.js +15 -13
  54. package/dist/lib/research/url.d.ts +22 -0
  55. package/dist/lib/research/url.js +63 -1
  56. package/dist/lib/text.d.ts +3 -1
  57. package/dist/lib/text.js +9 -1
  58. package/dist/sites/coveo.d.ts +7 -0
  59. package/dist/sites/coveo.js +33 -0
  60. package/dist/sites/index.js +1 -18
  61. package/dist/sites/salesforce/fetch-page.js +1 -1
  62. package/dist/sites/salesforce/index.js +3 -8
  63. package/dist/sites/salesforce-developer/fetch-page.js +3 -1
  64. package/dist/sites/salesforce-developer/index.js +1 -2
  65. package/dist/sites/salesforce-doc-fetch.d.ts +11 -0
  66. package/dist/sites/salesforce-doc-fetch.js +117 -76
  67. package/dist/sites/salesforce-dom-probe.d.ts +16 -0
  68. package/dist/sites/salesforce-dom-probe.js +114 -0
  69. package/dist/sites/types.d.ts +0 -6
  70. package/oclif.manifest.json +31 -106
  71. package/package.json +35 -26
  72. package/dist/commands/index.d.ts +0 -35
  73. package/dist/commands/index.js +0 -306
  74. package/dist/commands/research/config/base.d.ts +0 -23
  75. package/dist/commands/research/config/base.js +0 -46
  76. package/dist/commands/research/config/get.d.ts +0 -20
  77. package/dist/commands/research/config/get.js +0 -45
  78. package/dist/commands/research/config/list.d.ts +0 -17
  79. package/dist/commands/research/config/list.js +0 -42
  80. package/dist/commands/research/config/set.d.ts +0 -31
  81. package/dist/commands/research/config/set.js +0 -94
  82. package/dist/commands/research/config/unset.d.ts +0 -21
  83. package/dist/commands/research/config/unset.js +0 -59
  84. package/dist/commands/research/import.d.ts +0 -38
  85. package/dist/commands/research/import.js +0 -313
  86. package/dist/commands/research/inspect.d.ts +0 -13
  87. package/dist/commands/research/inspect.js +0 -86
  88. package/dist/commands/research/list.d.ts +0 -25
  89. package/dist/commands/research/list.js +0 -143
  90. package/dist/commands/research/prune.d.ts +0 -24
  91. package/dist/commands/research/prune.js +0 -141
  92. package/dist/commands/research/search.d.ts +0 -32
  93. package/dist/commands/research/search.js +0 -334
  94. package/dist/commands/research/status.d.ts +0 -17
  95. package/dist/commands/research/status.js +0 -108
  96. package/dist/commands/research.d.ts +0 -36
  97. package/dist/commands/research.js +0 -304
  98. package/dist/commands/search.d.ts +0 -35
  99. package/dist/commands/search.js +0 -391
  100. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +0 -1
  101. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +0 -2
  102. package/dist/lib/research/docs/discovery-index.d.ts +0 -14
  103. package/dist/lib/research/docs/discovery-index.js +0 -112
  104. package/dist/lib/research/docs/remote-search-runner.d.ts +0 -21
  105. package/dist/lib/research/docs/remote-search-runner.js +0 -114
  106. package/dist/lib/research/docs/remote-search.d.ts +0 -14
  107. package/dist/lib/research/docs/remote-search.js +0 -101
  108. package/dist/lib/research/docs/search-index.d.ts +0 -13
  109. package/dist/lib/research/docs/search-index.js +0 -176
  110. package/dist/sites/algolia.d.ts +0 -9
  111. package/dist/sites/algolia.js +0 -26
  112. package/dist/sites/auto.d.ts +0 -8
  113. package/dist/sites/auto.js +0 -30
  114. package/dist/sites/nextjs/index.d.ts +0 -2
  115. package/dist/sites/nextjs/index.js +0 -52
  116. package/dist/sites/react/index.d.ts +0 -2
  117. package/dist/sites/react/index.js +0 -17
  118. package/dist/sites/salesforce/browser-search.d.ts +0 -7
  119. package/dist/sites/salesforce/browser-search.js +0 -82
  120. package/dist/sites/salesforce/coveo.d.ts +0 -23
  121. package/dist/sites/salesforce/coveo.js +0 -161
  122. package/dist/sites/salesforce/token.d.ts +0 -26
  123. package/dist/sites/salesforce/token.js +0 -154
  124. package/dist/sites/tailwind/index.d.ts +0 -2
  125. package/dist/sites/tailwind/index.js +0 -17
  126. package/dist/sites/vue/index.d.ts +0 -2
  127. 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,7 +36,9 @@ 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
  }
41
+ export declare function describeError(err: unknown): string;
39
42
  export declare function fetchFailureGuidance(message: string, url: string): {
40
43
  suggestions: string[];
41
44
  ref?: 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';
@@ -9,6 +9,7 @@ import { loadSummaryLevel } from '../lib/config/index.js';
9
9
  import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
10
10
  import { revalidateCache, createArtifactFromFetch } from '../lib/research/revalidate.js';
11
11
  import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
12
+ import { PROXY_TUNNEL_REJECTION_PATTERN } from '../lib/research/proxy.js';
12
13
  import { fetchRenderedHtml } from '../lib/research/browser.js';
13
14
  import { capturePage } from '../lib/research/capture.js';
14
15
  import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
@@ -34,6 +35,7 @@ export default class FetchCommand extends BaseCommand {
34
35
  command: '<%= config.bin %> https://news.ycombinator.com/ --format compressed --ttl 2h --json',
35
36
  },
36
37
  ];
38
+ static strict = false;
37
39
  static args = {
38
40
  url: Args.string({
39
41
  required: true,
@@ -193,8 +195,14 @@ export default class FetchCommand extends BaseCommand {
193
195
  // failure — validation errors (bad flags/URL) exit before the try — so the contract's runtime code
194
196
  // (1) applies. Suggestions render on stderr for humans only; under --json the envelope carries just
195
197
  // the message (toErrorJson), so machine output is unchanged.
198
+ //
199
+ // Uses `describeError` (below) rather than `err.message` alone: Node/undici's fetch wraps
200
+ // transport failures (DNS, refused connections, a proxy that won't tunnel to the host) in a
201
+ // generic "fetch failed" TypeError with the real reason nested in `.cause`, so reading only the
202
+ // top message would surface a content-free "fetch failed" for exactly the failures a user most
203
+ // needs a hint for.
196
204
  emitFetchError(err, url) {
197
- const message = err instanceof Error ? err.message : String(err);
205
+ const message = describeError(err);
198
206
  const guidance = fetchFailureGuidance(message, url);
199
207
  this.error(message, {
200
208
  exit: 1,
@@ -255,49 +263,31 @@ export default class FetchCommand extends BaseCommand {
255
263
  }
256
264
  }
257
265
  async run() {
258
- const { url } = this.args;
266
+ const urls = this.parsedArgv;
259
267
  const { format, ttl, 'max-age': maxAge, 'dry-run': dryRun } = this.flags;
260
268
  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
269
  const summaryLevel = loadSummaryLevel(this.config.configDir, process.cwd());
267
270
  const tmpDir = dryRun ? mkdtempSync(join(tmpdir(), 'fnr-dry-run-')) : null;
268
271
  const currentTime = new Date();
272
+ const results = [];
269
273
  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);
274
+ for (const url of urls) {
275
+ results.push(await this.fetchSingleTarget(url, currentTime, tmpDir, summaryLevel, format, urls.length > 1));
289
276
  }
290
- return resultData;
277
+ return urls.length === 1 ? results[0] : results;
291
278
  }
292
279
  catch (err) {
293
280
  if (!this.jsonEnabled()) {
294
281
  ux.action.stop('failed');
295
282
  }
283
+ if (err instanceof Errors.CLIError)
284
+ throw err;
296
285
  // Stale-serve (exit 5) signals via process.exitCode and never throws, so it bypasses this
297
286
  // path; only genuine fetch/extract failures land here and get classified guidance. Use the
298
287
  // normalized URL in hints so the copy-paste command is canonical (and never echoes raw,
299
288
  // unsanitized user input back to the terminal).
300
- this.emitFetchError(err, normalizedUrl);
289
+ const failedUrl = urls[results.length] || urls[0] || '';
290
+ this.emitFetchError(err, failedUrl);
301
291
  }
302
292
  finally {
303
293
  if (tmpDir) {
@@ -305,13 +295,63 @@ export default class FetchCommand extends BaseCommand {
305
295
  }
306
296
  }
307
297
  }
298
+ async fetchSingleTarget(url, currentTime, tmpDir, summaryLevel, format, showSeparator) {
299
+ const target = this.resolveResearchTargetOrFail(url, {
300
+ flagOverride: this.flags.storage,
301
+ lookup: !this.flags.force,
302
+ });
303
+ const { cacheKey, located, normalizedUrl, roots } = target;
304
+ if (!this.jsonEnabled()) {
305
+ ux.action.start('Fetching ' + normalizedUrl);
306
+ }
307
+ const { cacheStatus, freshnessState, artifact, storageDir, redirectedToGlobal } = await this.resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel);
308
+ this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus, summaryLevel);
309
+ if (!this.jsonEnabled()) {
310
+ const CACHE_STATUS_LABEL = {
311
+ hit: 'cached',
312
+ miss: 'done',
313
+ refreshed: 'refreshed',
314
+ revalidated: 'revalidated',
315
+ stale: 'served stale',
316
+ };
317
+ ux.action.stop(CACHE_STATUS_LABEL[cacheStatus] ?? cacheStatus);
318
+ }
319
+ const content = format === 'compressed' ? artifact.compressed : artifact.detailed;
320
+ const resultData = this.buildResultData(url, normalizedUrl, cacheKey, storageDir, roots.mode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal);
321
+ if (!this.jsonEnabled()) {
322
+ this.log(content);
323
+ if (showSeparator) {
324
+ this.log('\n' + '='.repeat(40) + '\n');
325
+ }
326
+ }
327
+ return resultData;
328
+ }
329
+ }
330
+ // How many `.cause` levels to walk. Real undici/Node transport errors nest at most 1-2 deep;
331
+ // the cap exists so a self-referential or cyclic `.cause` chain (Error.cause is an arbitrary,
332
+ // unvalidated property — nothing prevents `e.cause = e`, whether from a bug in this codebase or
333
+ // a dependency) can't hang the process, rather than being a limit expected to matter in practice.
334
+ const MAX_CAUSE_DEPTH = 10;
335
+ // Walks an Error's `.cause` chain and joins every message, deepest last. Node/undici's fetch
336
+ // throws a generic `TypeError: fetch failed` for transport-level failures, with the actionable
337
+ // detail (e.g. a proxy's tunnel rejection) nested one or two `.cause` levels down.
338
+ export function describeError(err) {
339
+ if (!(err instanceof Error))
340
+ return String(err);
341
+ const parts = [err.message];
342
+ let cause = err.cause;
343
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && cause instanceof Error; depth++) {
344
+ parts.push(cause.message);
345
+ cause = cause.cause;
346
+ }
347
+ return parts.join(': ');
308
348
  }
309
349
  // Maps a runtime fetch failure to recovery steps keyed off its message. Returns undefined for
310
350
  // unrecognized failures, which then surface with their original message and no extra hint. The
311
351
  // patterns match the thrown text in fetcher.ts/browser.ts; keep them in sync if those messages
312
352
  // change. Resolutions mirror the published troubleshooting guide.
313
353
  export function fetchFailureGuidance(message, url) {
314
- const ref = 'https://taurgis.github.io/bonsai/troubleshooting';
354
+ const ref = 'https://bonsai.rhino-inquisitor.com/troubleshooting';
315
355
  // Name the pipe step explicitly: bare `import --stdin` blocks waiting for input, so a reader (or
316
356
  // an agent) running the hint verbatim would hang. Show the content being piped in from a file.
317
357
  const importHint = `Open it in a browser, save the page, then import it: cat page.md | bonsai import ${url} --stdin`;
@@ -347,6 +387,32 @@ export function fetchFailureGuidance(message, url) {
347
387
  suggestions: ['Check the hostname is spelled correctly and resolves on a public network.'],
348
388
  };
349
389
  }
390
+ // A configured proxy (HTTPS_PROXY/HTTP_PROXY) refused to tunnel to this host — distinct from
391
+ // the destination's own 401/403 above, which is the site itself rejecting the request. Shared
392
+ // with fetcher.ts's retry-fallback check so the two stay in sync by construction.
393
+ if (PROXY_TUNNEL_REJECTION_PATTERN.test(message)) {
394
+ return {
395
+ suggestions: [
396
+ "The configured proxy (HTTPS_PROXY/HTTP_PROXY) won't reach this host. Check its allowlist, or add the host to NO_PROXY to bypass the proxy for it.",
397
+ importHint,
398
+ ],
399
+ ref,
400
+ };
401
+ }
402
+ // The connection failed before any HTTP response was received — network-down, DNS, TLS, or a
403
+ // proxy issue not specifically matched above. Node/undici's fetch reports all of these as a
404
+ // generic "fetch failed" TypeError with no further suffix; the negative lookahead keeps this
405
+ // from also matching assertOk's "Fetch failed with status NNN ..." (a real HTTP response was
406
+ // received in that case, so it isn't a transport failure at all).
407
+ if (/^fetch failed\b(?!\s+with status)/i.test(message)) {
408
+ return {
409
+ suggestions: [
410
+ 'The connection failed before a response was received. Check network connectivity and any HTTPS_PROXY/HTTP_PROXY/NO_PROXY settings.',
411
+ importHint,
412
+ ],
413
+ ref,
414
+ };
415
+ }
350
416
  // A hostname that only resolves to a private/local IP at request time (literal private addresses
351
417
  // are rejected earlier with exit 2). Blocked to prevent SSRF; only public hosts can be fetched.
352
418
  if (/blocked local or private target/.test(message)) {
@@ -11,6 +11,7 @@ import { writeArtifactSecurely } from '../lib/research/secure-write.js';
11
11
  import { loadSummaryLevel } from '../lib/config/index.js';
12
12
  import { durationFlagError, getPolicy } from '../lib/research/freshness.js';
13
13
  import { buildCompressed } from '../lib/research/compress.js';
14
+ import { sanitizePromptInjection } from '../lib/research/prompt-injection.js';
14
15
  import { applyAutoTags } from '../lib/research/keywords.js';
15
16
  import { estimateTokens } from '../lib/research/token-estimate.js';
16
17
  export default class ResearchImport extends BaseCommand {
@@ -30,6 +31,10 @@ export default class ResearchImport extends BaseCommand {
30
31
  description: 'import research from a local Markdown file',
31
32
  command: '<%= config.bin %> import https://example.com/docs --file path/to/notes.md',
32
33
  },
34
+ {
35
+ description: 'import research from stdin using the standard file placeholder',
36
+ command: 'cat notes.md | <%= config.bin %> import https://example.com/docs --file -',
37
+ },
33
38
  ];
34
39
  static args = {
35
40
  url: Args.string({
@@ -47,7 +52,7 @@ export default class ResearchImport extends BaseCommand {
47
52
  default: false,
48
53
  }),
49
54
  file: Flags.string({
50
- description: 'path to a Markdown file containing research notes to import',
55
+ description: 'path to a Markdown file containing research notes to import, or "-" for stdin',
51
56
  }),
52
57
  'input-format': Flags.option({
53
58
  description: 'format of the input provided',
@@ -121,7 +126,9 @@ export default class ResearchImport extends BaseCommand {
121
126
  return normalizeUrl(u);
122
127
  }
123
128
  catch (err) {
124
- this.error(`Invalid URL: ${err.message}`, { exit: 2, code: 'INVALID_URL' });
129
+ // Shared exit point: a forgotten scheme reports MISSING_URL_SCHEME like every other command,
130
+ // while genuinely malformed input stays INVALID_URL.
131
+ this.failInvalidUrl(u, err.message);
125
132
  }
126
133
  });
127
134
  return hasMulti ? normalized.sort() : normalized;
@@ -168,6 +175,7 @@ export default class ResearchImport extends BaseCommand {
168
175
  const bin = this.config.bin;
169
176
  return [
170
177
  `Pipe Markdown content, e.g. cat notes.md | ${bin} import <url> --stdin`,
178
+ `Or use the standard stdin placeholder: cat notes.md | ${bin} import <url> --file -`,
171
179
  `Or read from a file: ${bin} import <url> --file notes.md`,
172
180
  ];
173
181
  }
@@ -222,7 +230,8 @@ export default class ResearchImport extends BaseCommand {
222
230
  return content;
223
231
  }
224
232
  async readAndValidateInput() {
225
- if (this.flags.stdin) {
233
+ const fileReadsStdin = this.flags.file === '-';
234
+ if (this.flags.stdin || fileReadsStdin) {
226
235
  const rawInput = await this.readStdinWithGuard();
227
236
  if (!rawInput.trim()) {
228
237
  this.error('Empty stdin content provided.', { exit: 2, code: 'EMPTY_INPUT' });
@@ -260,11 +269,11 @@ export default class ResearchImport extends BaseCommand {
260
269
  const { freshWindowMs } = getPolicy(tier, ttl);
261
270
  const staleAfterTime = new Date(currentTime.getTime() + freshWindowMs);
262
271
  const inputFormat = this.flags['input-format'];
263
- const detailed = rawInput;
272
+ const detailed = sanitizePromptInjection(rawInput);
264
273
  // A caller-supplied compressed input is trusted as-is; only a detailed import is condensed here,
265
274
  // and it shares the same buildCompressed policy (structural pass + extractive fallback) as fetch.
266
275
  const compressed = inputFormat === 'detailed'
267
- ? buildCompressed(rawInput, loadSummaryLevel(this.config.configDir, process.cwd()))
276
+ ? buildCompressed(detailed, loadSummaryLevel(this.config.configDir, process.cwd()))
268
277
  : rawInput;
269
278
  const contentHash = createHash('sha256').update(detailed).digest('hex');
270
279
  const metadata = {
@@ -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>;