@taurgis/bonsai 4.1.0 → 4.1.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.
package/README.md CHANGED
@@ -316,8 +316,6 @@ When run with `--json`, commands return a stable envelope:
316
316
  | `NO_COLOR` | Set to any non-empty value to disable all ANSI color output. An empty `NO_COLOR` is treated as unset. |
317
317
  | `FORCE_COLOR` | Set to any value other than an empty string, `0`, or `false` to force ANSI color on, even in non-TTY environments. |
318
318
  | `TERM=dumb` | Disables ANSI color output when the terminal cannot render escape sequences. |
319
- | `NO_UPDATE_NOTIFIER` | Suppress the "update available" notification. |
320
- | `CI` | Set to `1` or `true` to suppress the update notification in CI environments (detected automatically). |
321
319
  | `DEBUG` | Enable debug output for specific Bonsai namespaces, e.g. `DEBUG=bonsai:*`. |
322
320
 
323
321
  ---
package/bin/cli.mjs CHANGED
@@ -3,8 +3,6 @@
3
3
  import { execute } from '@oclif/core';
4
4
  import { dirname } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import updateNotifier from 'update-notifier';
7
- import { createRequire } from 'node:module';
8
6
 
9
7
  import { normalizeArgv } from '../dist/lib/argv.js';
10
8
  import { VALUE_TAKING_FLAG_TOKENS, KNOWN_COMMAND_ROOT_TOKENS } from '../dist/lib/cli-flag-manifest.js';
@@ -12,9 +10,6 @@ import { exitWithPreflight } from '../dist/lib/cli-emit.js';
12
10
  import { tryUnknownHelpOutput } from '../dist/lib/help-preflight.js';
13
11
  import { tryJsonMetaOutput } from '../dist/lib/json-meta.js';
14
12
 
15
- const req = createRequire(import.meta.url);
16
- const pkg = req('../package.json');
17
-
18
13
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
14
 
20
15
  // Normalize argv so the whole oclif pipeline sees one consistent command. oclif's
@@ -43,12 +38,6 @@ if (jsonMeta) {
43
38
  exitWithPreflight({ ...jsonMeta, json: true });
44
39
  }
45
40
 
46
- // Only notify after the earlyExit fast-path so the notifier never fires before
47
- // a usage-error envelope exits. Also skip under --json/--toon so agent callers never see stderr noise.
48
- if (!process.argv.includes('--json') && !process.argv.includes('--toon')) {
49
- updateNotifier({ pkg }).notify();
50
- }
51
-
52
41
  process.argv = [process.argv[0], process.argv[1], ...result.argv];
53
42
 
54
43
  await execute({
@@ -1,4 +1,4 @@
1
- import { ConfigCommand } from './base.js';
1
+ import { ConfigCommand } from '../../lib/config-command.js';
2
2
  export default class ConfigGet extends ConfigCommand<typeof ConfigGet> {
3
3
  static id: string;
4
4
  static summary: string;
@@ -1,5 +1,5 @@
1
1
  import { Args } from '@oclif/core';
2
- import { ConfigCommand, configScopeFlags } from './base.js';
2
+ import { ConfigCommand, configScopeFlags } from '../../lib/config-command.js';
3
3
  import { formatConfigEntry, resolveConfigEntry, keyValuesHint } from '../../lib/config/index.js';
4
4
  export default class ConfigGet extends ConfigCommand {
5
5
  static id = 'config get';
@@ -1,4 +1,4 @@
1
- import { ConfigCommand } from './base.js';
1
+ import { ConfigCommand } from '../../lib/config-command.js';
2
2
  export default class ConfigList extends ConfigCommand<typeof ConfigList> {
3
3
  static id: string;
4
4
  static summary: string;
@@ -1,4 +1,4 @@
1
- import { ConfigCommand, configScopeFlags } from './base.js';
1
+ import { ConfigCommand, configScopeFlags } from '../../lib/config-command.js';
2
2
  import { formatConfigEntry, resolveConfigEntries, keyValuesHint } from '../../lib/config/index.js';
3
3
  export default class ConfigList extends ConfigCommand {
4
4
  static id = 'config list';
@@ -1,4 +1,4 @@
1
- import { ConfigCommand } from './base.js';
1
+ import { ConfigCommand } from '../../lib/config-command.js';
2
2
  import type { ConfigWriteResult } from '../../lib/cli-result-types.js';
3
3
  export default class ConfigSet extends ConfigCommand<typeof ConfigSet> {
4
4
  static id: string;
@@ -1,5 +1,5 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { ConfigCommand, configScopeFlags } from './base.js';
2
+ import { ConfigCommand, configScopeFlags } from '../../lib/config-command.js';
3
3
  import { KEY_META, keyValuesHint } from '../../lib/config/index.js';
4
4
  import { persistConfigPatch } from '../../lib/config-persist.js';
5
5
  export default class ConfigSet extends ConfigCommand {
@@ -1,4 +1,4 @@
1
- import { ConfigCommand } from './base.js';
1
+ import { ConfigCommand } from '../../lib/config-command.js';
2
2
  import type { ConfigWriteResult } from '../../lib/cli-result-types.js';
3
3
  export default class ConfigUnset extends ConfigCommand<typeof ConfigUnset> {
4
4
  static id: string;
@@ -1,5 +1,5 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
- import { ConfigCommand, configScopeFlags } from './base.js';
2
+ import { ConfigCommand, configScopeFlags } from '../../lib/config-command.js';
3
3
  import { keyValuesHint } from '../../lib/config/index.js';
4
4
  import { persistConfigPatch } from '../../lib/config-persist.js';
5
5
  export default class ConfigUnset extends ConfigCommand {
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Composition-root flag manifest: every CLI token that consumes a following argv value.
3
- * Built from command class metadata so argv normalization stays free of command imports.
2
+ * Every CLI token that consumes a following argv value, from the shipped oclif manifest.
3
+ * Argv normalization stays off the command-class import graph.
4
4
  */
5
5
  export declare const VALUE_TAKING_FLAG_TOKENS: ReadonlySet<string>;
6
6
  /**
@@ -1,15 +1,23 @@
1
- import { BaseCommand } from '../base-command.js';
2
- import { commands } from '../commands.js';
3
- import { valueTakingFlagTokensFromCommands } from './value-flags.js';
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { valueTakingFlagTokensFromFlagMaps } from './value-flags.js';
5
+ // Startup reads the generated manifest instead of importing every command class. Importing the
6
+ // registry pulls the fetch stack (Readability, Turndown, the browser fallback) into --help.
7
+ function readOclifManifest() {
8
+ const manifestPath = join(dirname(fileURLToPath(import.meta.url)), '../../oclif.manifest.json');
9
+ return JSON.parse(readFileSync(manifestPath, 'utf8'));
10
+ }
11
+ const manifest = readOclifManifest();
4
12
  /**
5
- * Composition-root flag manifest: every CLI token that consumes a following argv value.
6
- * Built from command class metadata so argv normalization stays free of command imports.
13
+ * Every CLI token that consumes a following argv value, from the shipped oclif manifest.
14
+ * Argv normalization stays off the command-class import graph.
7
15
  */
8
- export const VALUE_TAKING_FLAG_TOKENS = valueTakingFlagTokensFromCommands(commands, BaseCommand.baseFlags);
16
+ export const VALUE_TAKING_FLAG_TOKENS = valueTakingFlagTokensFromFlagMaps(Object.values(manifest.commands).map((command) => command.flags));
9
17
  /**
10
18
  * Root segment of every registered command id (`config:get` → `config`). oclif joins namespaced
11
19
  * command ids with `:` regardless of the display-only `topicSeparator` setting, so a token like
12
20
  * `config:get` is a real command, not a URL — argv normalization checks this set before assuming
13
21
  * `word:` is a URL scheme.
14
22
  */
15
- export const KNOWN_COMMAND_ROOT_TOKENS = new Set(Object.keys(commands).map((id) => id.split(':')[0]));
23
+ export const KNOWN_COMMAND_ROOT_TOKENS = new Set(Object.keys(manifest.commands).map((id) => id.split(':')[0]));
@@ -1,10 +1,13 @@
1
1
  import { Command } from '@oclif/core';
2
- import { BaseCommand } from '../../base-command.js';
3
- import type { ConfigKey, ConfigScope } from '../../lib/config/index.js';
4
- import { scopeFlags } from '../../lib/scope-flags.js';
2
+ import { BaseCommand } from '../base-command.js';
3
+ import type { ConfigKey, ConfigScope } from './config/index.js';
4
+ import { scopeFlags } from './scope-flags.js';
5
5
  /**
6
6
  * Shared base for the four `config` subcommands: the mutually-exclusive
7
7
  * scope-flag guard, the binary write-scope helper, and known-key validation.
8
+ *
9
+ * Lives outside `src/commands` so oclif's file-based command discovery does not
10
+ * publish it as `config base`.
8
11
  */
9
12
  export declare abstract class ConfigCommand<T extends typeof Command> extends BaseCommand<T> {
10
13
  protected assertScopeFlagsExclusive(global?: boolean, local?: boolean): void;
@@ -1,10 +1,13 @@
1
1
  import { toConfiguredId } from '@oclif/core';
2
- import { BaseCommand } from '../../base-command.js';
3
- import { isKnownKey, suggestKey, validKeysHint } from '../../lib/config/index.js';
4
- import { assertScopeFlagsExclusive, scopeFlags } from '../../lib/scope-flags.js';
2
+ import { BaseCommand } from '../base-command.js';
3
+ import { isKnownKey, suggestKey, validKeysHint } from './config/index.js';
4
+ import { assertScopeFlagsExclusive, scopeFlags } from './scope-flags.js';
5
5
  /**
6
6
  * Shared base for the four `config` subcommands: the mutually-exclusive
7
7
  * scope-flag guard, the binary write-scope helper, and known-key validation.
8
+ *
9
+ * Lives outside `src/commands` so oclif's file-based command discovery does not
10
+ * publish it as `config base`.
8
11
  */
9
12
  export class ConfigCommand extends BaseCommand {
10
13
  assertScopeFlagsExclusive(global, local) {
@@ -65,3 +65,14 @@ export declare function fetchStaticHtml(url: string, options?: FetchOptions): Pr
65
65
  * @throws {Error} On unsafe DNS, HTTP failure, or redirect limits.
66
66
  */
67
67
  export declare function fetchText(url: string, options?: FetchOptions): Promise<FetchResult>;
68
+ /**
69
+ * Same DNS, redirect, and status checks as {@link fetchText}, but the body is cancelled once the
70
+ * status line is known. Use this to reject HTTP 404 before a browser render without downloading
71
+ * the document.
72
+ *
73
+ * @param url - Absolute URL to probe.
74
+ * @param options - Optional timeout, redirect cap, and headers.
75
+ * @returns The final HTTP status.
76
+ * @throws {Error} On unsafe DNS, HTTP failure, or redirect limits.
77
+ */
78
+ export declare function fetchStatus(url: string, options?: FetchOptions): Promise<number>;
@@ -264,3 +264,29 @@ export async function fetchStaticHtml(url, options = {}) {
264
264
  export async function fetchText(url, options = {}) {
265
265
  return fetchWithRedirects(url, options, processTextResponse);
266
266
  }
267
+ /**
268
+ * Same DNS, redirect, and status checks as {@link fetchText}, but the body is cancelled once the
269
+ * status line is known. Use this to reject HTTP 404 before a browser render without downloading
270
+ * the document.
271
+ *
272
+ * @param url - Absolute URL to probe.
273
+ * @param options - Optional timeout, redirect cap, and headers.
274
+ * @returns The final HTTP status.
275
+ * @throws {Error} On unsafe DNS, HTTP failure, or redirect limits.
276
+ */
277
+ export async function fetchStatus(url, options = {}) {
278
+ const result = await fetchWithRedirects(url, options, async (res, _limit, currentUrl) => {
279
+ await res.body?.cancel().catch(() => { });
280
+ assertOk(res);
281
+ return {
282
+ status: res.status,
283
+ contentType: null,
284
+ etag: null,
285
+ lastModified: null,
286
+ finalUrl: currentUrl,
287
+ responseSize: 0,
288
+ content: '',
289
+ };
290
+ });
291
+ return result.status;
292
+ }
@@ -117,7 +117,12 @@ const LEAKAGE_PATTERNS = [
117
117
  { label: 'base64-image', re: /data:image\/[a-z]+;base64,/i },
118
118
  { label: 'skip-to-content', re: /skip to (?:main )?content/i },
119
119
  { label: 'on-this-page', re: /^on this page$/im },
120
- { label: 'theme-toggle', re: /(?:toggle|switch to) (?:dark|light|the)? ?(?:mode|theme)/i },
120
+ // "dark"/"light" or the word "theme" is required. An optional group also matched
121
+ // "switch to mode" after inline code was stripped from "switch to `history` mode".
122
+ {
123
+ label: 'theme-toggle',
124
+ re: /(?:toggle|switch to) (?:the )?(?:dark|light)(?: mode)?|(?:toggle|switch to) (?:the )?theme/i,
125
+ },
121
126
  {
122
127
  label: 'cookie-consent',
123
128
  re: /(?:accept all cookies|we use cookies|cookie (?:policy|preferences|settings))/i,
@@ -11,4 +11,6 @@ type FlagDef = {
11
11
  * cannot drift from the real command surface.
12
12
  */
13
13
  export declare function valueTakingFlagTokensFromCommands(commands: Record<string, typeof Command>, extraBaseFlags?: Record<string, FlagDef>): Set<string>;
14
+ /** Same token set, read from a generated oclif manifest instead of command classes. */
15
+ export declare function valueTakingFlagTokensFromFlagMaps(flagMaps: Array<Record<string, FlagDef> | undefined>): Set<string>;
14
16
  export {};
@@ -14,10 +14,18 @@ function flagTokens(name, flag) {
14
14
  * cannot drift from the real command surface.
15
15
  */
16
16
  export function valueTakingFlagTokensFromCommands(commands, extraBaseFlags = {}) {
17
+ const maps = Object.values(commands).map((command) => ({
18
+ ...extraBaseFlags,
19
+ ...command.baseFlags,
20
+ ...command.flags,
21
+ }));
22
+ return valueTakingFlagTokensFromFlagMaps(maps);
23
+ }
24
+ /** Same token set, read from a generated oclif manifest instead of command classes. */
25
+ export function valueTakingFlagTokensFromFlagMaps(flagMaps) {
17
26
  const tokens = new Set();
18
- for (const command of Object.values(commands)) {
19
- const flags = { ...extraBaseFlags, ...command.baseFlags, ...command.flags };
20
- for (const [name, flag] of Object.entries(flags)) {
27
+ for (const flags of flagMaps) {
28
+ for (const [name, flag] of Object.entries(flags ?? {})) {
21
29
  if (!flag || flag.type === 'boolean')
22
30
  continue;
23
31
  for (const token of flagTokens(name, flag))
@@ -3,7 +3,7 @@ import type { SiteFetchResult } from '../types.js';
3
3
  * Fetches a developer.salesforce.com doc page. Supported articles publish a Markdown twin at the
4
4
  * derived `.md` route ("View as Markdown"), which is preferred: one static request instead of a
5
5
  * browser render, and it returns the article's source. Pages without a validated twin (API
6
- * reference, atlas.* books, landing pages) fall back to the shared Lightning Web Runtime (LWR)
6
+ * reference, atlas books whose `.md` route is still an HTML shell, landing pages) fall back to the shared Lightning Web Runtime (LWR)
7
7
  * shadow-DOM fetcher.
8
8
  *
9
9
  * @param url - The developer.salesforce.com doc page URL.
@@ -5,7 +5,9 @@ import { fetchDeveloperRouteMarkdown } from './route-markdown.js';
5
5
  const CONTENT_SELECTORS = [
6
6
  // API-reference pages: doc-amf-reference holds the method/type tables. doc-content-layout only
7
7
  // carries the shared deprecation banner and must not win selector priority on those pages.
8
+ // doc-redoc-reference is the Connect REST (B2B Commerce and platform) three-panel host.
8
9
  'doc-amf-reference',
10
+ 'doc-redoc-reference',
9
11
  'doc-content-layout',
10
12
  '.markdown-content',
11
13
  'main article',
@@ -16,7 +18,7 @@ const CONTENT_SELECTORS = [
16
18
  * Fetches a developer.salesforce.com doc page. Supported articles publish a Markdown twin at the
17
19
  * derived `.md` route ("View as Markdown"), which is preferred: one static request instead of a
18
20
  * browser render, and it returns the article's source. Pages without a validated twin (API
19
- * reference, atlas.* books, landing pages) fall back to the shared Lightning Web Runtime (LWR)
21
+ * reference, atlas books whose `.md` route is still an HTML shell, landing pages) fall back to the shared Lightning Web Runtime (LWR)
20
22
  * shadow-DOM fetcher.
21
23
  *
22
24
  * @param url - The developer.salesforce.com doc page URL.
@@ -2,8 +2,9 @@ import { fetchText } from '../../lib/research/fetcher.js';
2
2
  import type { SiteFetchResult } from '../types.js';
3
3
  /**
4
4
  * Derives the candidate `.md` route for a developer.salesforce.com doc URL, or null when the URL
5
- * can't serve one (wrong host, not under /docs/, or an atlas.* book — verified to answer `.md`
6
- * with an HTML shell, never Markdown). Query/hash are view state and are dropped.
5
+ * can't serve one (wrong host, or not under /docs/). Atlas books are included: a shell is
6
+ * rejected later by content-type, and a redirect to Markdown is kept. Query/hash are view
7
+ * state and are dropped.
7
8
  *
8
9
  * @param url - A developer.salesforce.com doc URL (or any URL; non-matching returns null).
9
10
  * @returns The derived `.md` URL, or null when no `.md` route exists for this URL.
@@ -7,9 +7,10 @@ import { probeMarkdownTwin } from '../markdown-twin.js';
7
7
  // shadow-DOM render and returns the article's actual source (code fences, admonitions intact).
8
8
  //
9
9
  // Coverage is rolling out per doc set, so the probe result is only a candidate (T-19/T-24):
10
- // unsupported articles 404, and atlas.* books answer the .md route with an HTTP 200 HTML SPA
11
- // shell — which makes the strict content-type + body validation in probeMarkdownTwin
12
- // load-bearing, not defensive garnish.
10
+ // unsupported articles 404, and some atlas.* books still answer the .md route with an HTTP 200
11
+ // HTML SPA shell. Others (REST API, Bulk API's successor) now redirect that same .md route to a
12
+ // real text/markdown article under /docs/platform. Content-type validation rejects the shells;
13
+ // skipping atlas.* up front would miss the books that moved.
13
14
  const DEVELOPER_HOST = 'developer.salesforce.com';
14
15
  // Salesforce's WAF answers 403 unless the User-Agent LEADS with a recognized HTTP-tool token
15
16
  // (curl/wget/python-requests pass; bare product names and browser UAs on a non-browser TLS stack
@@ -20,8 +21,9 @@ const PROBE_HEADERS = {
20
21
  };
21
22
  /**
22
23
  * Derives the candidate `.md` route for a developer.salesforce.com doc URL, or null when the URL
23
- * can't serve one (wrong host, not under /docs/, or an atlas.* book — verified to answer `.md`
24
- * with an HTML shell, never Markdown). Query/hash are view state and are dropped.
24
+ * can't serve one (wrong host, or not under /docs/). Atlas books are included: a shell is
25
+ * rejected later by content-type, and a redirect to Markdown is kept. Query/hash are view
26
+ * state and are dropped.
25
27
  *
26
28
  * @param url - A developer.salesforce.com doc URL (or any URL; non-matching returns null).
27
29
  * @returns The derived `.md` URL, or null when no `.md` route exists for this URL.
@@ -37,7 +39,7 @@ export function deriveMarkdownUrl(url) {
37
39
  if (parsed.hostname.toLowerCase() !== DEVELOPER_HOST)
38
40
  return null;
39
41
  let path = parsed.pathname.replace(/\/+$/, '');
40
- if (!path.startsWith('/docs/') || path.startsWith('/docs/atlas.'))
42
+ if (!path.startsWith('/docs/'))
41
43
  return null;
42
44
  if (path.endsWith('.md'))
43
45
  return `https://${DEVELOPER_HOST}${path}`;
@@ -1,16 +1,12 @@
1
1
  import type { SiteFetchResult } from './types.js';
2
- /** True when the extracted text is an error/not-found/loading shell rather than real content.
3
- *
4
- * @param text - Extracted Markdown text to check.
5
- * @returns True when the text matches a Salesforce error/not-found pattern.
6
- */
7
- export declare function looksLikeSalesforceError(text: string): boolean;
2
+ import { looksLikeSalesforceError } from './salesforce-dom-probe.js';
3
+ export { looksLikeSalesforceError };
8
4
  /**
9
- * Removes known boilerplate lines from converted Salesforce Markdown: the feedback widget text,
10
- * "View as Markdown" toolbar labels, and empty icon-anchor lines.
5
+ * Removes known boilerplate from converted Salesforce Markdown: the feedback widget, doc-chrome
6
+ * toolbar labels, the atlas release switcher, and empty icon-anchor lines.
11
7
  *
12
8
  * @param markdown - Markdown output from the DOM or content API conversion.
13
- * @returns Markdown with boilerplate lines stripped and excess blank lines collapsed.
9
+ * @returns Markdown with boilerplate stripped and excess blank lines collapsed.
14
10
  */
15
11
  export declare function stripBoilerplate(markdown: string): string;
16
12
  /** Parses the Salesforce private content API JSON payload.
@@ -45,6 +41,21 @@ export interface SalesforceDocOptions {
45
41
  contentSelectors: string[];
46
42
  removeSelectors?: string[];
47
43
  }
44
+ /** Article identity for a Salesforce doc URL: Help `id`, otherwise the last path segment.
45
+ * Underscores and hyphens match so a REST rename (`intro_what_is_rest_api` →
46
+ * `intro-what-is-rest-api`) stays the same article.
47
+ *
48
+ * @param url - Requested or post-navigation URL.
49
+ * @returns Normalized article key, or null when the URL has no article identity.
50
+ */
51
+ export declare function salesforceArticleKey(url: string): string | null;
52
+ /** True when both URLs name the same Salesforce article, or either key can't be read.
53
+ *
54
+ * @param requested - URL the caller asked for.
55
+ * @param finalUrl - URL the browser landed on.
56
+ * @returns False only when both keys exist and differ.
57
+ */
58
+ export declare function sameSalesforceArticle(requested: string, finalUrl: string): boolean;
48
59
  /**
49
60
  * Renders a Salesforce Lightning Web Runtime (LWR) doc page, serializes its shadow-DOM content
50
61
  * container, and converts it to Markdown. Validates the host before any network access.
@@ -53,7 +64,8 @@ export interface SalesforceDocOptions {
53
64
  * @param options - Allowed host, priority content selectors, and optional per-site remove selectors.
54
65
  * @returns Extracted Markdown, fetch metadata, and browser-capture provenance.
55
66
  * @throws {Error} When the URL is invalid, the host is not the allowed host, DNS is unsafe,
56
- * Chrome/CDP navigation or evaluation fails, the response body exceeds the size limit,
57
- * or the captured content is too short / looks like an error page.
67
+ * the document itself is HTTP 404/410, Chrome/CDP navigation or evaluation fails, the page
68
+ * redirects to a different article, the response body exceeds the size limit, or the captured
69
+ * content is too short / looks like an error page.
58
70
  */
59
71
  export declare function fetchSalesforceDoc(url: string, { allowedHost, contentSelectors, removeSelectors }: SalesforceDocOptions): Promise<SiteFetchResult>;
@@ -1,9 +1,10 @@
1
1
  import { openCdpPage, waitForLoad, ResponseCapture, describeNavigationFailure, } from '../lib/research/browser.js';
2
- import { checkDnsSafety } from '../lib/research/fetcher.js';
2
+ import { checkDnsSafety, fetchStatus } from '../lib/research/fetcher.js';
3
3
  import { htmlToMarkdown } from '../lib/research/markdown.js';
4
4
  import { sanitizePromptInjection } from '../lib/research/prompt-injection.js';
5
5
  import { contentMetrics, contentRicherThan } from '../lib/research/regression.js';
6
- import { SALESFORCE_SHADOW_DOM_HELPERS, buildPriorityContainerPickBlock, pollSalesforceContentReady, } from './salesforce-dom-probe.js';
6
+ import { SALESFORCE_SHADOW_DOM_HELPERS, buildPriorityContainerPickBlock, pollSalesforceContentReady, looksLikeSalesforceError, } from './salesforce-dom-probe.js';
7
+ export { looksLikeSalesforceError };
7
8
  // Both help.salesforce.com and developer.salesforce.com are LWR sites that render doc content
8
9
  // client-side inside web components (shadow DOM). document.documentElement.outerHTML captures
9
10
  // only the "Loading…" shell, and Readability prunes the structured result to a single block —
@@ -19,20 +20,11 @@ const BODY_LIMIT_BYTES = 6 * 1024 * 1024;
19
20
  /** Grace period after DOM settle for late /docs/get_document_content/ responses on guide pages. */
20
21
  const DOCS_API_GRACE_MS = 500;
21
22
  const ACCEPT_CONSENT_EXPRESSION = "(() => { const b = document.querySelector('#onetrust-accept-btn-handler'); if (b) b.click(); })()";
22
- const ERROR_PATTERNS = [
23
- /page\s*(not\s*found|doesn'?t\s*exist|can'?t\s*be\s*found)/i,
24
- /we\s*couldn'?t\s*find/i,
25
- /we looked high and low/i,
26
- /sorry to interrupt/i,
27
- ];
28
- /** True when the extracted text is an error/not-found/loading shell rather than real content.
29
- *
30
- * @param text - Extracted Markdown text to check.
31
- * @returns True when the text matches a Salesforce error/not-found pattern.
32
- */
33
- export function looksLikeSalesforceError(text) {
34
- return ERROR_PATTERNS.some((re) => re.test(text));
35
- }
23
+ // Salesforce's WAF answers 403 unless the User-Agent leads with a recognized HTTP-tool token.
24
+ // Same token the Markdown probe sends, so a missing-doc check sees the real status.
25
+ const DOC_PROBE_HEADERS = {
26
+ 'user-agent': 'curl/8.7.1 bonsai-research-cache (+https://bonsai.rhino-inquisitor.com)',
27
+ };
36
28
  // Chrome present on every LWR doc page; stripped before serialization. Per-site noise (e.g.
37
29
  // Help's article-feedback widget) is appended via SalesforceDocOptions.removeSelectors.
38
30
  const BASE_REMOVE_SELECTORS = [
@@ -69,25 +61,28 @@ function confidenceFor(length) {
69
61
  // page. Help wraps it in a removable component (see fetch-page.ts), but Developer renders it
70
62
  // as bare divs, so we also strip its lines from the converted Markdown for both sites.
71
63
  const BOILERPLATE_LINE = /(did this article solve your issue|let us know so we can improve|share your feedback)/i;
72
- // Toolbar of the "View as Markdown" feature on Developer articles. When the .md route probe
73
- // succeeds the browser never runs, but rollout/network asymmetries can still land such a page
74
- // here, and the toolbar labels serialize as bare paragraph lines.
75
- const MARKDOWN_TOOLBAR_LINE = /^(copy as markdown|view as markdown|copy url to markdown)$/i;
64
+ // Toolbar labels that serialize as their own paragraph: "View as Markdown" on articles that
65
+ // missed the .md probe, and the llms.txt / "Open in ChatGPT" cluster on the newer guide chrome.
66
+ const DOC_CHROME_LINE = /^(copy as markdown|view as markdown|copy url to markdown|view llms\.txt|copy url to llms\.txt|open in chatgpt|open in claude|open in perplexity|this page|all documentation|more actions)$/i;
76
67
  // A link with empty anchor text — e.g. an icon-only "home" logo link. It carries no readable
77
68
  // content and renders as a bare `[](url)`. (Images `![](url)` are intentionally not matched.)
78
69
  const EMPTY_LINK_LINE = /^\[\]\([^)]*\)$/;
70
+ // Atlas books render a release switcher as one link per season. Turndown splits the anchor
71
+ // text across lines, so the link is not a single line. Drop the whole link; the article follows.
72
+ const VERSION_PICKER_LINK = /\[\s*(?:Spring|Summer|Winter|Fall)\s+'\d{2}\s+\(API version\s+[\d.]+\)\s*\]\([^)\n]*\)/gi;
79
73
  /**
80
- * Removes known boilerplate lines from converted Salesforce Markdown: the feedback widget text,
81
- * "View as Markdown" toolbar labels, and empty icon-anchor lines.
74
+ * Removes known boilerplate from converted Salesforce Markdown: the feedback widget, doc-chrome
75
+ * toolbar labels, the atlas release switcher, and empty icon-anchor lines.
82
76
  *
83
77
  * @param markdown - Markdown output from the DOM or content API conversion.
84
- * @returns Markdown with boilerplate lines stripped and excess blank lines collapsed.
78
+ * @returns Markdown with boilerplate stripped and excess blank lines collapsed.
85
79
  */
86
80
  export function stripBoilerplate(markdown) {
87
81
  return markdown
82
+ .replace(VERSION_PICKER_LINK, '')
88
83
  .split('\n')
89
84
  .filter((line) => !BOILERPLATE_LINE.test(line) &&
90
- !MARKDOWN_TOOLBAR_LINE.test(line.trim()) &&
85
+ !DOC_CHROME_LINE.test(line.trim()) &&
91
86
  !EMPTY_LINK_LINE.test(line.trim()))
92
87
  .join('\n')
93
88
  .replace(/\n{3,}/g, '\n\n')
@@ -286,6 +281,65 @@ function buildCaptureScript(contentSelectors, extraRemove) {
286
281
  return { html: serialize(container), title };
287
282
  })()`;
288
283
  }
284
+ /** Article identity for a Salesforce doc URL: Help `id`, otherwise the last path segment.
285
+ * Underscores and hyphens match so a REST rename (`intro_what_is_rest_api` →
286
+ * `intro-what-is-rest-api`) stays the same article.
287
+ *
288
+ * @param url - Requested or post-navigation URL.
289
+ * @returns Normalized article key, or null when the URL has no article identity.
290
+ */
291
+ export function salesforceArticleKey(url) {
292
+ let parsed;
293
+ try {
294
+ parsed = new URL(url);
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ const id = parsed.searchParams.get('id');
300
+ const raw = (id ?? parsed.pathname.replace(/\/+$/, '').split('/').pop() ?? '').trim();
301
+ const stripped = raw.replace(/\.(html?|md)$/i, '');
302
+ if (!stripped)
303
+ return null;
304
+ return stripped.toLowerCase().replace(/_/g, '-');
305
+ }
306
+ /** True when both URLs name the same Salesforce article, or either key can't be read.
307
+ *
308
+ * @param requested - URL the caller asked for.
309
+ * @param finalUrl - URL the browser landed on.
310
+ * @returns False only when both keys exist and differ.
311
+ */
312
+ export function sameSalesforceArticle(requested, finalUrl) {
313
+ const requestedKey = salesforceArticleKey(requested);
314
+ const finalKey = salesforceArticleKey(finalUrl);
315
+ if (!requestedKey || !finalKey)
316
+ return true;
317
+ return requestedKey === finalKey;
318
+ }
319
+ async function assertSalesforceDocumentExists(url) {
320
+ try {
321
+ await fetchStatus(url, { headers: DOC_PROBE_HEADERS, timeoutMs: 8_000 });
322
+ }
323
+ catch (err) {
324
+ const message = err instanceof Error ? err.message : '';
325
+ // Any other failure (WAF 403, timeout) still goes to the browser. Only a real
326
+ // not-found status is enough to skip Chrome.
327
+ if (/\bstatus (404|410)\b/.test(message)) {
328
+ throw new Error(`Salesforce doc not found: ${url}`);
329
+ }
330
+ }
331
+ }
332
+ async function assertLandedOnRequestedArticle(page, url) {
333
+ const result = await page.client
334
+ .send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, page.sessionId)
335
+ .catch(() => null);
336
+ const finalHref = result?.result?.value;
337
+ if (typeof finalHref !== 'string' || !finalHref)
338
+ return;
339
+ if (!sameSalesforceArticle(url, finalHref)) {
340
+ throw new Error(`Salesforce doc ${url} redirected to a different article (${finalHref}).`);
341
+ }
342
+ }
289
343
  function parseSalesforceDocUrl(url, allowedHost) {
290
344
  let parsed;
291
345
  try {
@@ -300,7 +354,10 @@ function parseSalesforceDocUrl(url, allowedHost) {
300
354
  return parsed;
301
355
  }
302
356
  function assertReadableSalesforceContent(detailedMarkdown, url) {
303
- if (detailedMarkdown.length < MIN_CONTAINER_CHARS || looksLikeSalesforceError(detailedMarkdown)) {
357
+ if (looksLikeSalesforceError(detailedMarkdown)) {
358
+ throw new Error(`Salesforce doc not found: ${url}`);
359
+ }
360
+ if (detailedMarkdown.length < MIN_CONTAINER_CHARS) {
304
361
  throw new Error(`Salesforce returned no readable content for ${url} (page may be missing, gated, or still loading).`);
305
362
  }
306
363
  }
@@ -351,6 +408,9 @@ async function captureSalesforceAttempt(page, url, captureScript, contentSelecto
351
408
  }
352
409
  let detailedMarkdown = stripBoilerplate(htmlToMarkdown(html));
353
410
  let usedDocsApi = false;
411
+ // Retired atlas leaves (apex_qs_hello.htm) stay HTTP 200, then the SPA swaps in the book
412
+ // home. Storing that home under the requested URL would cache the wrong article.
413
+ await assertLandedOnRequestedArticle(page, url);
354
414
  if (docsApiHtml) {
355
415
  const picked = preferRicherMarkdown(detailedMarkdown, docsApiHtml, title, docsApiTitle);
356
416
  detailedMarkdown = picked.markdown;
@@ -393,12 +453,14 @@ function buildSalesforceFetchResult(url, html, title, detailedMarkdown, qualityN
393
453
  * @param options - Allowed host, priority content selectors, and optional per-site remove selectors.
394
454
  * @returns Extracted Markdown, fetch metadata, and browser-capture provenance.
395
455
  * @throws {Error} When the URL is invalid, the host is not the allowed host, DNS is unsafe,
396
- * Chrome/CDP navigation or evaluation fails, the response body exceeds the size limit,
397
- * or the captured content is too short / looks like an error page.
456
+ * the document itself is HTTP 404/410, Chrome/CDP navigation or evaluation fails, the page
457
+ * redirects to a different article, the response body exceeds the size limit, or the captured
458
+ * content is too short / looks like an error page.
398
459
  */
399
460
  export async function fetchSalesforceDoc(url, { allowedHost, contentSelectors, removeSelectors = [] }) {
400
461
  const parsed = parseSalesforceDocUrl(url, allowedHost);
401
462
  await checkDnsSafety(parsed.hostname);
463
+ await assertSalesforceDocumentExists(url);
402
464
  const captureScript = buildCaptureScript(contentSelectors, removeSelectors);
403
465
  const page = await openCdpPage();
404
466
  try {
@@ -1,6 +1,12 @@
1
1
  import type { CdpPage } from '../lib/research/browser.js';
2
2
  /** Shared in-page helpers for traversing Salesforce Lightning Web Runtime (LWR) open shadow roots. */
3
3
  export declare const SALESFORCE_SHADOW_DOM_HELPERS = "\nconst deepElements = (root) => {\n const out = [];\n const visit = (node) => {\n if (node.shadowRoot) visit(node.shadowRoot);\n for (const child of Array.from(node.children || [])) {\n out.push(child);\n visit(child);\n }\n };\n visit(root);\n return out;\n};\n\nconst deepText = (root) => {\n let txt = '';\n const visit = (node) => {\n if (node.nodeType === 3) {\n txt += node.textContent || '';\n } else if (node.nodeType === 1 || node.nodeType === 9 || node.nodeType === 11) {\n const tag = node.tagName ? node.tagName.toLowerCase() : '';\n if (tag !== 'style' && tag !== 'script') {\n if (node.shadowRoot) visit(node.shadowRoot);\n for (const child of Array.from(node.childNodes || [])) visit(child);\n }\n }\n };\n visit(root);\n return txt;\n};\n\nconst textLenFor = (el) =>\n Math.max((el.innerText || '').trim().length, deepText(el).trim().length);\n";
4
+ /** True when extracted text is a Salesforce not-found or error shell rather than an article.
5
+ *
6
+ * @param text - Extracted Markdown or visible page text.
7
+ * @returns True when the text matches a Salesforce error/not-found pattern.
8
+ */
9
+ export declare function looksLikeSalesforceError(text: string): boolean;
4
10
  /**
5
11
  * In-page block: pick the highest-priority content container that exists on the page.
6
12
  * Does not fall through to lower-priority selectors when a higher-priority host is present
@@ -33,6 +33,23 @@ const deepText = (root) => {
33
33
  const textLenFor = (el) =>
34
34
  Math.max((el.innerText || '').trim().length, deepText(el).trim().length);
35
35
  `;
36
+ // Shells Salesforce renders for a missing article. Shared with the readiness probe so a
37
+ // not-found page ends the wait instead of burning the full content timeout.
38
+ const SALESFORCE_ERROR_PATTERNS = [
39
+ /page\s*(not\s*found|doesn'?t\s*exist|can'?t\s*be\s*found)/i,
40
+ /we\s*couldn'?t\s*find/i,
41
+ /we looked high and low/i,
42
+ /sorry to interrupt/i,
43
+ /head back to the space station/i,
44
+ ];
45
+ /** True when extracted text is a Salesforce not-found or error shell rather than an article.
46
+ *
47
+ * @param text - Extracted Markdown or visible page text.
48
+ * @returns True when the text matches a Salesforce error/not-found pattern.
49
+ */
50
+ export function looksLikeSalesforceError(text) {
51
+ return SALESFORCE_ERROR_PATTERNS.some((re) => re.test(text));
52
+ }
36
53
  /**
37
54
  * In-page block: pick the highest-priority content container that exists on the page.
38
55
  * Does not fall through to lower-priority selectors when a higher-priority host is present
@@ -64,6 +81,20 @@ export function buildPriorityContainerPickBlock(selectors, minChars, containerVa
64
81
  }
65
82
  }
66
83
  ${containerVar} = ${containerVar} || document.body || document.documentElement;
84
+ // A Redoc hash (#tag/…/operation/…) names one operation inside the reference.
85
+ // getElementById stops at the shadow boundary, so match the id while walking.
86
+ try {
87
+ const hashId = decodeURIComponent((location.hash || '').replace(/^#/, ''));
88
+ if (hashId) {
89
+ let hashBest = null, hashLen = 0;
90
+ for (const el of deepElements(document)) {
91
+ if (el.id !== hashId) continue;
92
+ const len = textLenFor(el);
93
+ if (len > hashLen) { hashBest = el; hashLen = len; }
94
+ }
95
+ if (hashBest && hashLen >= MIN_CHARS) ${containerVar} = hashBest;
96
+ }
97
+ } catch (e) {}
67
98
  `;
68
99
  }
69
100
  /**
@@ -75,8 +106,14 @@ export function buildPriorityContainerPickBlock(selectors, minChars, containerVa
75
106
  * @returns A self-invoking JS expression string that evaluates to `{ has: boolean, len: number }`.
76
107
  */
77
108
  export function buildContentReadyProbeExpression(selectors, minChars) {
109
+ const errorSource = SALESFORCE_ERROR_PATTERNS.map((re) => re.source).join('|');
78
110
  return `(() => {
79
111
  ${SALESFORCE_SHADOW_DOM_HELPERS}
112
+ const errorRe = new RegExp(${JSON.stringify(errorSource)}, 'i');
113
+ const pageText = deepText(document.body || document.documentElement);
114
+ if (errorRe.test(pageText)) {
115
+ return { has: true, len: pageText.length, error: true };
116
+ }
80
117
  const SELS = ${JSON.stringify(selectors)};
81
118
  const MIN = ${minChars};
82
119
  let has = false;
@@ -119,6 +156,9 @@ export async function pollSalesforceContentReady(page, selectors, minChars, time
119
156
  .send('Runtime.evaluate', { expression, returnByValue: true }, page.sessionId)
120
157
  .catch(() => null);
121
158
  const value = rawResult?.result?.value;
159
+ // A not-found shell is ready now; waiting for it to "settle" only delays the failure.
160
+ if (value?.error)
161
+ return;
122
162
  if (value?.has) {
123
163
  const grew = Math.abs((value.len || 0) - previousLength) > Math.max(40, previousLength * 0.02);
124
164
  if (!grew && ++stablePolls >= 2)
@@ -47,7 +47,13 @@
47
47
  "strict": true,
48
48
  "summary": "Show a compact cache dashboard for ambient session context",
49
49
  "enableJsonFlag": true,
50
- "stdoutIsPrimaryData": true
50
+ "stdoutIsPrimaryData": true,
51
+ "isESM": true,
52
+ "relativePath": [
53
+ "dist",
54
+ "commands",
55
+ "context.js"
56
+ ]
51
57
  },
52
58
  "fetch": {
53
59
  "aliases": [],
@@ -199,7 +205,13 @@
199
205
  "strict": false,
200
206
  "summary": "Fetch and cache URL research Markdown",
201
207
  "enableJsonFlag": true,
202
- "stdoutIsPrimaryData": true
208
+ "stdoutIsPrimaryData": true,
209
+ "isESM": true,
210
+ "relativePath": [
211
+ "dist",
212
+ "commands",
213
+ "fetch.js"
214
+ ]
203
215
  },
204
216
  "import": {
205
217
  "aliases": [],
@@ -349,7 +361,13 @@
349
361
  "strict": true,
350
362
  "summary": "Import agent-supplied Markdown into cache",
351
363
  "enableJsonFlag": true,
352
- "stdoutIsPrimaryData": true
364
+ "stdoutIsPrimaryData": true,
365
+ "isESM": true,
366
+ "relativePath": [
367
+ "dist",
368
+ "commands",
369
+ "import.js"
370
+ ]
353
371
  },
354
372
  "inspect": {
355
373
  "aliases": [],
@@ -408,7 +426,13 @@
408
426
  "strict": false,
409
427
  "summary": "Inspect cached research metadata for a URL",
410
428
  "enableJsonFlag": true,
411
- "stdoutIsPrimaryData": true
429
+ "stdoutIsPrimaryData": true,
430
+ "isESM": true,
431
+ "relativePath": [
432
+ "dist",
433
+ "commands",
434
+ "inspect.js"
435
+ ]
412
436
  },
413
437
  "list": {
414
438
  "aliases": [],
@@ -550,7 +574,13 @@
550
574
  "strict": true,
551
575
  "summary": "List cached research artifacts by metadata",
552
576
  "enableJsonFlag": true,
553
- "stdoutIsPrimaryData": true
577
+ "stdoutIsPrimaryData": true,
578
+ "isESM": true,
579
+ "relativePath": [
580
+ "dist",
581
+ "commands",
582
+ "list.js"
583
+ ]
554
584
  },
555
585
  "prune": {
556
586
  "aliases": [],
@@ -670,7 +700,13 @@
670
700
  "strict": true,
671
701
  "summary": "Prune cached research artifacts",
672
702
  "enableJsonFlag": true,
673
- "stdoutIsPrimaryData": true
703
+ "stdoutIsPrimaryData": true,
704
+ "isESM": true,
705
+ "relativePath": [
706
+ "dist",
707
+ "commands",
708
+ "prune.js"
709
+ ]
674
710
  },
675
711
  "search": {
676
712
  "aliases": [],
@@ -816,7 +852,13 @@
816
852
  "strict": true,
817
853
  "summary": "Search cached research artifacts by tags and content",
818
854
  "enableJsonFlag": true,
819
- "stdoutIsPrimaryData": true
855
+ "stdoutIsPrimaryData": true,
856
+ "isESM": true,
857
+ "relativePath": [
858
+ "dist",
859
+ "commands",
860
+ "search.js"
861
+ ]
820
862
  },
821
863
  "setup": {
822
864
  "aliases": [],
@@ -900,7 +942,13 @@
900
942
  "strict": true,
901
943
  "summary": "Install a SessionStart hook that shows live cache context",
902
944
  "enableJsonFlag": true,
903
- "stdoutIsPrimaryData": true
945
+ "stdoutIsPrimaryData": true,
946
+ "isESM": true,
947
+ "relativePath": [
948
+ "dist",
949
+ "commands",
950
+ "setup.js"
951
+ ]
904
952
  },
905
953
  "status": {
906
954
  "aliases": [],
@@ -986,20 +1034,32 @@
986
1034
  "strict": false,
987
1035
  "summary": "Check cache status without fetching or writing",
988
1036
  "enableJsonFlag": true,
989
- "stdoutIsPrimaryData": true
1037
+ "stdoutIsPrimaryData": true,
1038
+ "isESM": true,
1039
+ "relativePath": [
1040
+ "dist",
1041
+ "commands",
1042
+ "status.js"
1043
+ ]
990
1044
  },
991
- "config": {
1045
+ "config:get": {
992
1046
  "aliases": [],
993
- "args": {},
994
- "description": "Show config subcommands for reading and writing Bonsai research cache configuration.",
1047
+ "args": {
1048
+ "key": {
1049
+ "description": "the configuration key to read",
1050
+ "name": "key",
1051
+ "required": false
1052
+ }
1053
+ },
1054
+ "description": "Print a config value. Without --global/--local, shows the merged effective value, in precedence order: a per-command override flag (e.g. `fetch --storage`) > env var > project file > user file > built-in default.\n\nValid keys: storage (global|project), summary (conservative|balanced|aggressive).",
995
1055
  "examples": [
996
1056
  {
997
- "description": "get a configuration value",
998
- "command": "<%= config.bin %> <%= command.id %> get storage"
1057
+ "description": "get the effective storage mode",
1058
+ "command": "<%= config.bin %> config get storage"
999
1059
  },
1000
1060
  {
1001
- "description": "set project storage mode",
1002
- "command": "<%= config.bin %> <%= command.id %> set storage project --local"
1061
+ "description": "read only the project-level value",
1062
+ "command": "<%= config.bin %> config get storage --local"
1003
1063
  }
1004
1064
  ],
1005
1065
  "flags": {
@@ -1024,37 +1084,56 @@
1024
1084
  "name": "toon",
1025
1085
  "allowNo": false,
1026
1086
  "type": "boolean"
1087
+ },
1088
+ "global": {
1089
+ "char": "g",
1090
+ "description": "read user-level config only",
1091
+ "name": "global",
1092
+ "allowNo": false,
1093
+ "type": "boolean"
1094
+ },
1095
+ "local": {
1096
+ "aliases": [
1097
+ "project"
1098
+ ],
1099
+ "charAliases": [
1100
+ "p"
1101
+ ],
1102
+ "description": "read project-level config only",
1103
+ "name": "local",
1104
+ "allowNo": false,
1105
+ "type": "boolean"
1027
1106
  }
1028
1107
  },
1029
1108
  "hasDynamicHelp": false,
1030
1109
  "hiddenAliases": [],
1031
- "id": "config",
1110
+ "id": "config:get",
1032
1111
  "pluginAlias": "@taurgis/bonsai",
1033
1112
  "pluginName": "@taurgis/bonsai",
1034
1113
  "pluginType": "core",
1035
1114
  "strict": true,
1036
- "summary": "Manage research cache configuration",
1037
- "enableJsonFlag": true,
1038
- "stdoutIsPrimaryData": true
1115
+ "summary": "Get a research configuration value",
1116
+ "stdoutIsPrimaryData": true,
1117
+ "isESM": true,
1118
+ "relativePath": [
1119
+ "dist",
1120
+ "commands",
1121
+ "config",
1122
+ "get.js"
1123
+ ]
1039
1124
  },
1040
- "config:get": {
1125
+ "config": {
1041
1126
  "aliases": [],
1042
- "args": {
1043
- "key": {
1044
- "description": "the configuration key to read",
1045
- "name": "key",
1046
- "required": false
1047
- }
1048
- },
1049
- "description": "Print a config value. Without --global/--local, shows the merged effective value, in precedence order: a per-command override flag (e.g. `fetch --storage`) > env var > project file > user file > built-in default.\n\nValid keys: storage (global|project), summary (conservative|balanced|aggressive).",
1127
+ "args": {},
1128
+ "description": "Show config subcommands for reading and writing Bonsai research cache configuration.",
1050
1129
  "examples": [
1051
1130
  {
1052
- "description": "get the effective storage mode",
1053
- "command": "<%= config.bin %> config get storage"
1131
+ "description": "get a configuration value",
1132
+ "command": "<%= config.bin %> <%= command.id %> get storage"
1054
1133
  },
1055
1134
  {
1056
- "description": "read only the project-level value",
1057
- "command": "<%= config.bin %> config get storage --local"
1135
+ "description": "set project storage mode",
1136
+ "command": "<%= config.bin %> <%= command.id %> set storage project --local"
1058
1137
  }
1059
1138
  ],
1060
1139
  "flags": {
@@ -1079,36 +1158,25 @@
1079
1158
  "name": "toon",
1080
1159
  "allowNo": false,
1081
1160
  "type": "boolean"
1082
- },
1083
- "global": {
1084
- "char": "g",
1085
- "description": "read user-level config only",
1086
- "name": "global",
1087
- "allowNo": false,
1088
- "type": "boolean"
1089
- },
1090
- "local": {
1091
- "aliases": [
1092
- "project"
1093
- ],
1094
- "charAliases": [
1095
- "p"
1096
- ],
1097
- "description": "read project-level config only",
1098
- "name": "local",
1099
- "allowNo": false,
1100
- "type": "boolean"
1101
1161
  }
1102
1162
  },
1103
1163
  "hasDynamicHelp": false,
1104
1164
  "hiddenAliases": [],
1105
- "id": "config:get",
1165
+ "id": "config",
1106
1166
  "pluginAlias": "@taurgis/bonsai",
1107
1167
  "pluginName": "@taurgis/bonsai",
1108
1168
  "pluginType": "core",
1109
1169
  "strict": true,
1110
- "summary": "Get a research configuration value",
1111
- "stdoutIsPrimaryData": true
1170
+ "summary": "Manage research cache configuration",
1171
+ "enableJsonFlag": true,
1172
+ "stdoutIsPrimaryData": true,
1173
+ "isESM": true,
1174
+ "relativePath": [
1175
+ "dist",
1176
+ "commands",
1177
+ "config",
1178
+ "index.js"
1179
+ ]
1112
1180
  },
1113
1181
  "config:list": {
1114
1182
  "aliases": [],
@@ -1175,7 +1243,14 @@
1175
1243
  "pluginType": "core",
1176
1244
  "strict": true,
1177
1245
  "summary": "List research configuration values",
1178
- "stdoutIsPrimaryData": true
1246
+ "stdoutIsPrimaryData": true,
1247
+ "isESM": true,
1248
+ "relativePath": [
1249
+ "dist",
1250
+ "commands",
1251
+ "config",
1252
+ "list.js"
1253
+ ]
1179
1254
  },
1180
1255
  "config:set": {
1181
1256
  "aliases": [],
@@ -1259,7 +1334,14 @@
1259
1334
  "pluginType": "core",
1260
1335
  "strict": true,
1261
1336
  "summary": "Set a research configuration key",
1262
- "stdoutIsPrimaryData": true
1337
+ "stdoutIsPrimaryData": true,
1338
+ "isESM": true,
1339
+ "relativePath": [
1340
+ "dist",
1341
+ "commands",
1342
+ "config",
1343
+ "set.js"
1344
+ ]
1263
1345
  },
1264
1346
  "config:unset": {
1265
1347
  "aliases": [],
@@ -1338,8 +1420,15 @@
1338
1420
  "pluginType": "core",
1339
1421
  "strict": true,
1340
1422
  "summary": "Remove a research configuration key",
1341
- "stdoutIsPrimaryData": true
1423
+ "stdoutIsPrimaryData": true,
1424
+ "isESM": true,
1425
+ "relativePath": [
1426
+ "dist",
1427
+ "commands",
1428
+ "config",
1429
+ "unset.js"
1430
+ ]
1342
1431
  }
1343
1432
  },
1344
- "version": "4.1.0"
1433
+ "version": "4.1.1"
1345
1434
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taurgis/bonsai",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "description": "Bonsai is a standalone local research cache CLI for AI agents",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,27 +35,23 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@mozilla/readability": "^0.5.0",
38
- "@oclif/core": "^4.11.4",
39
- "@oclif/plugin-autocomplete": "^3.2.52",
38
+ "@oclif/core": "^5.1.8",
39
+ "@oclif/plugin-autocomplete": "^4.0.4",
40
40
  "@toon-format/toon": "^4.0.0",
41
- "linkedom": "^0.18.12",
41
+ "linkedom": "^0.18.13",
42
42
  "turndown": "^7.2.0",
43
- "undici": "^8.7.0",
44
- "update-notifier": "^7.3.1"
43
+ "undici": "^8.12.2"
45
44
  },
46
45
  "devDependencies": {
47
46
  "@changesets/cli": "^2.31.0",
48
- "@types/jsdom": "^21.1.7",
49
- "@types/mozilla-readability": "^0.2.1",
50
47
  "@types/node": "^22.10.0",
51
48
  "@types/turndown": "^5.0.5",
52
49
  "@vitest/coverage-istanbul": "^4.0.18",
53
50
  "eslint": "^10.7.0",
54
51
  "fallow": "^2.102.0",
55
52
  "husky": "^9.1.7",
56
- "jsdom": "^26.0.0",
57
53
  "lint-staged": "^16.2.7",
58
- "oclif": "^4.23.21",
54
+ "oclif": "^6.0.8",
59
55
  "prettier": "^3.8.1",
60
56
  "typescript": "^5.9.3",
61
57
  "typescript-eslint": "^8.64.0",
@@ -78,9 +74,8 @@
78
74
  "@oclif/plugin-autocomplete"
79
75
  ],
80
76
  "commands": {
81
- "strategy": "explicit",
82
- "target": "./dist/commands.js",
83
- "identifier": "commands"
77
+ "strategy": "pattern",
78
+ "target": "./dist/commands"
84
79
  },
85
80
  "topicSeparator": " ",
86
81
  "hooks": {