@taurgis/bonsai 4.0.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.
Files changed (85) hide show
  1. package/README.md +24 -3
  2. package/agents/agents/official-docs-researcher.agent.md +102 -0
  3. package/agents/agents/salesforce-docs-researcher.agent.md +16 -14
  4. package/agents/hooks/README.md +18 -2
  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 +5 -4
  11. package/agents/instructions/web-research.instructions.md +45 -0
  12. package/agents/skills/web-research/SKILL.md +139 -0
  13. package/bin/cli.mjs +0 -11
  14. package/dist/base-command.d.ts +5 -0
  15. package/dist/base-command.js +9 -0
  16. package/dist/commands/config/get.d.ts +1 -1
  17. package/dist/commands/config/get.js +1 -1
  18. package/dist/commands/config/list.d.ts +1 -1
  19. package/dist/commands/config/list.js +1 -1
  20. package/dist/commands/config/set.d.ts +1 -1
  21. package/dist/commands/config/set.js +1 -1
  22. package/dist/commands/config/unset.d.ts +1 -1
  23. package/dist/commands/config/unset.js +1 -1
  24. package/dist/commands/context.d.ts +20 -0
  25. package/dist/commands/context.js +65 -0
  26. package/dist/commands/list.d.ts +12 -3
  27. package/dist/commands/list.js +61 -100
  28. package/dist/commands/search.d.ts +43 -0
  29. package/dist/commands/search.js +233 -0
  30. package/dist/commands/setup.d.ts +27 -0
  31. package/dist/commands/setup.js +148 -0
  32. package/dist/commands.d.ts +6 -0
  33. package/dist/commands.js +6 -0
  34. package/dist/lib/argv.js +4 -1
  35. package/dist/lib/cli-flag-manifest.d.ts +2 -2
  36. package/dist/lib/cli-flag-manifest.js +15 -7
  37. package/dist/lib/cli-presentation.d.ts +19 -0
  38. package/dist/lib/cli-presentation.js +27 -0
  39. package/dist/lib/cli-result-types.d.ts +59 -0
  40. package/dist/lib/color.d.ts +2 -0
  41. package/dist/lib/color.js +6 -0
  42. package/dist/lib/common-metadata-filter-flags.d.ts +14 -0
  43. package/dist/lib/common-metadata-filter-flags.js +37 -0
  44. package/dist/{commands/config/base.d.ts → lib/config-command.d.ts} +7 -9
  45. package/dist/{commands/config/base.js → lib/config-command.js} +9 -20
  46. package/dist/lib/next-command.d.ts +15 -0
  47. package/dist/lib/next-command.js +40 -0
  48. package/dist/lib/research/context-dashboard.d.ts +19 -0
  49. package/dist/lib/research/context-dashboard.js +43 -0
  50. package/dist/lib/research/fetch-command-service.js +10 -0
  51. package/dist/lib/research/fetch-result.d.ts +1 -0
  52. package/dist/lib/research/fetch-result.js +4 -0
  53. package/dist/lib/research/fetcher.d.ts +11 -0
  54. package/dist/lib/research/fetcher.js +26 -0
  55. package/dist/lib/research/list-row.d.ts +15 -0
  56. package/dist/lib/research/list-row.js +31 -0
  57. package/dist/lib/research/metadata-filters.d.ts +22 -1
  58. package/dist/lib/research/metadata-filters.js +34 -0
  59. package/dist/lib/research/regression.js +6 -1
  60. package/dist/lib/research/schema.d.ts +2 -0
  61. package/dist/lib/research/schema.js +4 -0
  62. package/dist/lib/research/search-match.d.ts +39 -0
  63. package/dist/lib/research/search-match.js +134 -0
  64. package/dist/lib/scope-flags.d.ts +19 -0
  65. package/dist/lib/scope-flags.js +27 -0
  66. package/dist/lib/setup/agents.d.ts +16 -0
  67. package/dist/lib/setup/agents.js +20 -0
  68. package/dist/lib/setup/bin-command.d.ts +14 -0
  69. package/dist/lib/setup/bin-command.js +42 -0
  70. package/dist/lib/setup/hook-file-io.d.ts +37 -0
  71. package/dist/lib/setup/hook-file-io.js +80 -0
  72. package/dist/lib/text.d.ts +6 -0
  73. package/dist/lib/text.js +11 -0
  74. package/dist/lib/value-flags.d.ts +2 -0
  75. package/dist/lib/value-flags.js +11 -3
  76. package/dist/sites/salesforce-developer/fetch-page.d.ts +1 -1
  77. package/dist/sites/salesforce-developer/fetch-page.js +3 -1
  78. package/dist/sites/salesforce-developer/route-markdown.d.ts +3 -2
  79. package/dist/sites/salesforce-developer/route-markdown.js +8 -6
  80. package/dist/sites/salesforce-doc-fetch.d.ts +23 -11
  81. package/dist/sites/salesforce-doc-fetch.js +89 -27
  82. package/dist/sites/salesforce-dom-probe.d.ts +6 -0
  83. package/dist/sites/salesforce-dom-probe.js +40 -0
  84. package/oclif.manifest.json +428 -54
  85. package/package.json +8 -13
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Keyword scoring and snippet extraction for `search --query`. Matches only against fields already
3
+ * cheap to read from the search-index sidecar (topic, tags, summary, compressed) — see
4
+ * `artifact-index.ts` — so `search` never pays the cost of parsing the (often much larger)
5
+ * `detailed` body just to rank results.
6
+ */
7
+ /** Per-term score contribution when it matches the artifact's topic. */
8
+ const SCORE_WEIGHT_TOPIC = 100;
9
+ /** Per matching tag, per term. */
10
+ const SCORE_WEIGHT_TAG = 50;
11
+ /** Per occurrence in `summary`, per term (capped by {@link SUMMARY_OCCURRENCE_CAP}). */
12
+ const SCORE_WEIGHT_SUMMARY = 10;
13
+ /** Per occurrence in `compressed`, per term (capped by {@link COMPRESSED_OCCURRENCE_CAP}). */
14
+ const SCORE_WEIGHT_COMPRESSED = 2;
15
+ // Repeated boilerplate (e.g. a term that happens to be a common word in a large compressed body)
16
+ // must not let one field dominate the score just by occurring many times; capping occurrences
17
+ // counted keeps the topic/tag weights meaningfully dominant regardless of body length.
18
+ const SUMMARY_OCCURRENCE_CAP = 5;
19
+ const COMPRESSED_OCCURRENCE_CAP = 10;
20
+ /** Characters of original-case text kept on each side of a matched term in an excerpt. */
21
+ const SNIPPET_CONTEXT_CHARS = 90;
22
+ /** Field priority order for a deterministic `matchedFields` list, independent of term iteration order. */
23
+ const FIELD_PRIORITY = ['topic', 'tags', 'summary', 'compressed'];
24
+ /** Split a `--query` value into lowercase, deduplicated terms on whitespace. */
25
+ export function tokenizeSearchQuery(query) {
26
+ const terms = query
27
+ .trim()
28
+ .split(/\s+/)
29
+ .map((term) => term.toLowerCase());
30
+ return [...new Set(terms)];
31
+ }
32
+ /**
33
+ * Reject a whitespace-only `--query` instead of silently matching nothing (an empty term list would
34
+ * either match every artifact or none, both surprising) — same reasoning as `--topic`/`--tags`/`--url`.
35
+ */
36
+ export function emptySearchQueryError(query) {
37
+ if (query === undefined)
38
+ return undefined;
39
+ if (query.trim() === '') {
40
+ return '--query must be a non-empty value (e.g. "suspense boundary").';
41
+ }
42
+ return undefined;
43
+ }
44
+ /** Count non-overlapping occurrences of `needle` in `haystack` (both already lowercased). */
45
+ function countOccurrences(haystack, needle) {
46
+ let count = 0;
47
+ let fromIndex = 0;
48
+ let index;
49
+ while ((index = haystack.indexOf(needle, fromIndex)) !== -1) {
50
+ count++;
51
+ fromIndex = index + needle.length;
52
+ }
53
+ return count;
54
+ }
55
+ /**
56
+ * Scores one artifact against tokenized query terms. Returns `null` when the artifact does not
57
+ * satisfy the match mode (every term must match somewhere, unless `matchAny` allows just one).
58
+ */
59
+ export function scoreSearchMatch(fields, terms, matchAny) {
60
+ const topicLower = (fields.topic ?? '').toLowerCase();
61
+ const tagsLower = fields.tags.map((tag) => tag.toLowerCase());
62
+ const summaryLower = fields.summary.toLowerCase();
63
+ const compressedLower = fields.compressed.toLowerCase();
64
+ let score = 0;
65
+ let matchedTermCount = 0;
66
+ const matchedFieldSet = new Set();
67
+ for (const term of terms) {
68
+ let termMatched = false;
69
+ if (topicLower.includes(term)) {
70
+ score += SCORE_WEIGHT_TOPIC;
71
+ matchedFieldSet.add('topic');
72
+ termMatched = true;
73
+ }
74
+ const tagHits = tagsLower.filter((tag) => tag.includes(term)).length;
75
+ if (tagHits > 0) {
76
+ score += SCORE_WEIGHT_TAG * tagHits;
77
+ matchedFieldSet.add('tags');
78
+ termMatched = true;
79
+ }
80
+ const summaryHits = Math.min(countOccurrences(summaryLower, term), SUMMARY_OCCURRENCE_CAP);
81
+ if (summaryHits > 0) {
82
+ score += SCORE_WEIGHT_SUMMARY * summaryHits;
83
+ matchedFieldSet.add('summary');
84
+ termMatched = true;
85
+ }
86
+ const compressedHits = Math.min(countOccurrences(compressedLower, term), COMPRESSED_OCCURRENCE_CAP);
87
+ if (compressedHits > 0) {
88
+ score += SCORE_WEIGHT_COMPRESSED * compressedHits;
89
+ matchedFieldSet.add('compressed');
90
+ termMatched = true;
91
+ }
92
+ if (termMatched)
93
+ matchedTermCount++;
94
+ }
95
+ const satisfied = matchAny ? matchedTermCount > 0 : matchedTermCount === terms.length;
96
+ if (!satisfied)
97
+ return null;
98
+ return { score, matchedFields: FIELD_PRIORITY.filter((field) => matchedFieldSet.has(field)) };
99
+ }
100
+ /** Earliest (leftmost) occurrence of any term in already-lowercased `textLower`, or `null`. */
101
+ function findEarliestMatch(textLower, terms) {
102
+ let best = null;
103
+ for (const term of terms) {
104
+ const index = textLower.indexOf(term);
105
+ if (index !== -1 && (best === null || index < best.index)) {
106
+ best = { index, length: term.length };
107
+ }
108
+ }
109
+ return best;
110
+ }
111
+ /** Windowed excerpt of `text` around `match`, collapsed to one line with ellipsis on truncation. */
112
+ function buildSnippetWindow(text, match) {
113
+ const start = Math.max(0, match.index - SNIPPET_CONTEXT_CHARS);
114
+ const end = Math.min(text.length, match.index + match.length + SNIPPET_CONTEXT_CHARS);
115
+ const windowed = text.slice(start, end).replace(/\s+/g, ' ').trim();
116
+ const prefix = start > 0 ? '…' : '';
117
+ const suffix = end < text.length ? '…' : '';
118
+ return `${prefix}${windowed}${suffix}`;
119
+ }
120
+ /**
121
+ * A short excerpt around the first query-term match in `compressed` (falling back to `summary`),
122
+ * so an agent can judge relevance from the token-cheap index alone before deciding to `inspect` or
123
+ * re-fetch the full page. Returns `null` when no term appears in either — e.g. the match was only
124
+ * against `topic`/`tags` — rather than an unrelated excerpt with no bearing on the query.
125
+ */
126
+ export function extractSearchSnippet(fields, terms) {
127
+ const compressedMatch = findEarliestMatch(fields.compressed.toLowerCase(), terms);
128
+ if (compressedMatch)
129
+ return buildSnippetWindow(fields.compressed, compressedMatch);
130
+ const summaryMatch = findEarliestMatch(fields.summary.toLowerCase(), terms);
131
+ if (summaryMatch)
132
+ return buildSnippetWindow(fields.summary, summaryMatch);
133
+ return null;
134
+ }
@@ -0,0 +1,19 @@
1
+ /** Build the `--global`/`--local` scope-flag pair with per-command descriptions. */
2
+ export declare function scopeFlags(descriptions: {
3
+ global: string;
4
+ local: string;
5
+ }): {
6
+ global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ };
9
+ /**
10
+ * Rejects `--global`/`--local` passed together. Shared by every command with this scope-flag pair
11
+ * (`config`, `setup`) so the guard, its message, and its error code can't drift between them.
12
+ *
13
+ * @param error - The command's own `this.error`, bound by the caller (it always throws, hence `never`).
14
+ */
15
+ export declare function assertScopeFlagsExclusive(error: (message: string, options: {
16
+ exit: 2;
17
+ code: string;
18
+ suggestions: string[];
19
+ }) => never, global: boolean | undefined, local: boolean | undefined): void;
@@ -0,0 +1,27 @@
1
+ import { Flags } from '@oclif/core';
2
+ /** Build the `--global`/`--local` scope-flag pair with per-command descriptions. */
3
+ export function scopeFlags(descriptions) {
4
+ return {
5
+ global: Flags.boolean({ char: 'g', description: descriptions.global }),
6
+ local: Flags.boolean({
7
+ description: descriptions.local,
8
+ aliases: ['project'],
9
+ charAliases: ['p'],
10
+ }),
11
+ };
12
+ }
13
+ /**
14
+ * Rejects `--global`/`--local` passed together. Shared by every command with this scope-flag pair
15
+ * (`config`, `setup`) so the guard, its message, and its error code can't drift between them.
16
+ *
17
+ * @param error - The command's own `this.error`, bound by the caller (it always throws, hence `never`).
18
+ */
19
+ export function assertScopeFlagsExclusive(error, global, local) {
20
+ if (global && local) {
21
+ error('--global and --local are mutually exclusive.', {
22
+ exit: 2,
23
+ code: 'CONFLICTING_FLAGS',
24
+ suggestions: ['Pass --global or --local, not both.'],
25
+ });
26
+ }
27
+ }
@@ -0,0 +1,16 @@
1
+ import type { SetupAgent } from '../cli-result-types.js';
2
+ /** Every agent `setup` currently knows how to install a SessionStart hook for. */
3
+ export declare const SETUP_AGENTS: readonly SetupAgent[];
4
+ /** Where a SessionStart hook lives for one agent, and how Bonsai's entry is shaped. */
5
+ export interface SetupAgentTarget {
6
+ /** Hook-config file path, relative to the project root or the user's home directory. */
7
+ relativePath: string;
8
+ eventName: string;
9
+ matcher: string;
10
+ timeout: number;
11
+ }
12
+ /** Subcommand `setup` invokes at session start; appended to the resolved bin command. */
13
+ export declare const SETUP_HOOK_COMMAND_SUFFIX = "context";
14
+ /** Tag value stamped on every handler `setup` installs — see {@link SessionStartHookSpec.managedBy}. */
15
+ export declare const SETUP_HOOK_MANAGED_BY = "bonsai";
16
+ export declare const SETUP_AGENT_TARGETS: Record<SetupAgent, SetupAgentTarget>;
@@ -0,0 +1,20 @@
1
+ /** Every agent `setup` currently knows how to install a SessionStart hook for. */
2
+ export const SETUP_AGENTS = ['claude-code', 'codex'];
3
+ // Both agents accept plain stdout as SessionStart context and share the same `source` matcher
4
+ // values (startup/resume/clear/compact) — see the cached docs at
5
+ // developers.openai.com/codex/hooks#sessionstart (.bonsai/research/4f69debe...) and
6
+ // code.claude.com/docs/en/hooks (.bonsai/research/20106b0f3bee09dd540039d6a1a5294fbfbe0ae0b182affd4138cf3caa31e0c6.md,
7
+ // imported rather than fetched — Bonsai's own fetch times out on this host in this environment).
8
+ const SESSION_START = {
9
+ eventName: 'SessionStart',
10
+ matcher: 'startup|resume|clear|compact',
11
+ timeout: 10,
12
+ };
13
+ /** Subcommand `setup` invokes at session start; appended to the resolved bin command. */
14
+ export const SETUP_HOOK_COMMAND_SUFFIX = 'context';
15
+ /** Tag value stamped on every handler `setup` installs — see {@link SessionStartHookSpec.managedBy}. */
16
+ export const SETUP_HOOK_MANAGED_BY = 'bonsai';
17
+ export const SETUP_AGENT_TARGETS = {
18
+ 'claude-code': { ...SESSION_START, relativePath: '.claude/settings.json' },
19
+ codex: { ...SESSION_START, relativePath: '.codex/hooks.json' },
20
+ };
@@ -0,0 +1,14 @@
1
+ /** `which`/`where` lookup for a command name; `null` when not found on `PATH`. */
2
+ export declare function findOnPath(command: string): string | null;
3
+ /**
4
+ * Resolves the command a hook config should invoke to run this CLI (AXI rule: "hook commands
5
+ * should use a PATH-verified binary name when it resolves to the current executable, and fall
6
+ * back to the full absolute path otherwise"). This keeps a global install portable across
7
+ * machines while guaranteeing a hook never silently runs a different `bonsai` than the one that
8
+ * installed it.
9
+ *
10
+ * @param scriptPath - The currently running script (`process.argv[1]`).
11
+ * @param lookup - `PATH` lookup, injectable for tests.
12
+ * @returns `"bonsai"` when `PATH` resolves to this same script, otherwise `node "<absolute path>"`.
13
+ */
14
+ export declare function resolveBinCommand(scriptPath: string, lookup?: typeof findOnPath): string;
@@ -0,0 +1,42 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { realpathSync } from 'node:fs';
3
+ /** `which`/`where` lookup for a command name; `null` when not found on `PATH`. */
4
+ export function findOnPath(command) {
5
+ const finder = process.platform === 'win32' ? 'where' : 'which';
6
+ try {
7
+ const output = execFileSync(finder, [command], {
8
+ encoding: 'utf-8',
9
+ stdio: ['ignore', 'pipe', 'ignore'],
10
+ });
11
+ // `where` on Windows can print multiple matches, one per line; the first is the one PATH resolves to.
12
+ return output.split(/\r?\n/).find((line) => line.trim() !== '') ?? null;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ /**
19
+ * Resolves the command a hook config should invoke to run this CLI (AXI rule: "hook commands
20
+ * should use a PATH-verified binary name when it resolves to the current executable, and fall
21
+ * back to the full absolute path otherwise"). This keeps a global install portable across
22
+ * machines while guaranteeing a hook never silently runs a different `bonsai` than the one that
23
+ * installed it.
24
+ *
25
+ * @param scriptPath - The currently running script (`process.argv[1]`).
26
+ * @param lookup - `PATH` lookup, injectable for tests.
27
+ * @returns `"bonsai"` when `PATH` resolves to this same script, otherwise `node "<absolute path>"`.
28
+ */
29
+ export function resolveBinCommand(scriptPath, lookup = findOnPath) {
30
+ const realScript = realpathSync(scriptPath);
31
+ const onPath = lookup('bonsai');
32
+ if (onPath) {
33
+ try {
34
+ if (realpathSync(onPath) === realScript)
35
+ return 'bonsai';
36
+ }
37
+ catch {
38
+ // onPath didn't resolve to a real file (stale PATH entry) — fall through to the absolute path.
39
+ }
40
+ }
41
+ return `node "${realScript}"`;
42
+ }
@@ -0,0 +1,37 @@
1
+ /** The SessionStart hook Bonsai wants installed. */
2
+ export interface SessionStartHookSpec {
3
+ /** Hook event name, e.g. `"SessionStart"`. */
4
+ eventName: string;
5
+ matcher: string;
6
+ /** Full command to run (already resolved to a PATH-verified name or absolute path). */
7
+ command: string;
8
+ timeout: number;
9
+ /**
10
+ * Tag stamped on the installed handler (e.g. `"bonsai"`) so a later run can find *this exact*
11
+ * entry to repair or leave alone, without guessing from `command` text — a user's own hook could
12
+ * otherwise end in the same word Bonsai's command does (e.g. a wrapped `... && bonsai context`)
13
+ * and get silently claimed and overwritten.
14
+ */
15
+ managedBy: string;
16
+ }
17
+ /** Outcome of planning a hook-file install: what changed, and the file content to persist. */
18
+ export interface HookInstallPlan {
19
+ status: 'installed' | 'repaired' | 'unchanged';
20
+ content: string;
21
+ }
22
+ /** Thrown when an existing hook file can't be safely merged into (not JSON, or not an object). */
23
+ export declare class InvalidHookFileError extends Error {
24
+ }
25
+ /**
26
+ * Plans installing or repairing a Bonsai-managed `SessionStart` hook entry inside an existing (or
27
+ * not-yet-created) hook-config file, without touching any other hooks already in that file (AXI
28
+ * rules: explicit opt-in install, path repair, idempotent). Pure — callers own reading the
29
+ * existing file and persisting `content` (see `setup.ts`).
30
+ *
31
+ * - No existing Bonsai entry for this event → append a new matcher group (`installed`).
32
+ * - An existing Bonsai entry with a stale `command` → update it in place (`repaired`).
33
+ * - An existing Bonsai entry with the same `command` → no-op, original bytes returned (`unchanged`).
34
+ *
35
+ * @throws {InvalidHookFileError} When `existingContent` isn't a mergeable JSON object.
36
+ */
37
+ export declare function planSessionStartHookInstall(existingContent: string | null, spec: SessionStartHookSpec): HookInstallPlan;
@@ -0,0 +1,80 @@
1
+ /** Thrown when an existing hook file can't be safely merged into (not JSON, or not an object). */
2
+ export class InvalidHookFileError extends Error {
3
+ }
4
+ function isPlainObject(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function isBonsaiHandler(handler, managedBy) {
8
+ return isPlainObject(handler) && handler.managedBy === managedBy;
9
+ }
10
+ function serialize(root) {
11
+ return `${JSON.stringify(root, null, 2)}\n`;
12
+ }
13
+ /**
14
+ * Parses an existing hook-file's content into its root JSON object, or `{}` when the file doesn't
15
+ * exist yet. Deliberately refuses (rather than silently discarding) content that isn't a JSON
16
+ * object — the file may carry a user's own unrelated hooks, and Bonsai must never clobber content
17
+ * it can't understand.
18
+ *
19
+ * @throws {InvalidHookFileError} When `existingContent` is set but isn't a JSON object.
20
+ */
21
+ function parseHookFileRoot(existingContent) {
22
+ if (existingContent === null || existingContent.trim() === '')
23
+ return {};
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(existingContent);
27
+ }
28
+ catch (err) {
29
+ throw new InvalidHookFileError(`not valid JSON: ${err.message}`);
30
+ }
31
+ if (!isPlainObject(parsed)) {
32
+ throw new InvalidHookFileError('expected a JSON object at the top level');
33
+ }
34
+ return parsed;
35
+ }
36
+ /**
37
+ * Plans installing or repairing a Bonsai-managed `SessionStart` hook entry inside an existing (or
38
+ * not-yet-created) hook-config file, without touching any other hooks already in that file (AXI
39
+ * rules: explicit opt-in install, path repair, idempotent). Pure — callers own reading the
40
+ * existing file and persisting `content` (see `setup.ts`).
41
+ *
42
+ * - No existing Bonsai entry for this event → append a new matcher group (`installed`).
43
+ * - An existing Bonsai entry with a stale `command` → update it in place (`repaired`).
44
+ * - An existing Bonsai entry with the same `command` → no-op, original bytes returned (`unchanged`).
45
+ *
46
+ * @throws {InvalidHookFileError} When `existingContent` isn't a mergeable JSON object.
47
+ */
48
+ export function planSessionStartHookInstall(existingContent, spec) {
49
+ const root = parseHookFileRoot(existingContent);
50
+ const hooks = isPlainObject(root.hooks) ? { ...root.hooks } : {};
51
+ const eventGroups = Array.isArray(hooks[spec.eventName])
52
+ ? [...hooks[spec.eventName]]
53
+ : [];
54
+ for (const group of eventGroups) {
55
+ if (!isPlainObject(group) || !Array.isArray(group.hooks))
56
+ continue;
57
+ const index = group.hooks.findIndex((handler) => isBonsaiHandler(handler, spec.managedBy));
58
+ if (index === -1)
59
+ continue;
60
+ const existingHandler = group.hooks[index];
61
+ if (existingHandler.command === spec.command) {
62
+ // Byte-identical no-op: return the original content rather than a reserialized copy, so a
63
+ // repeated install never churns the file's formatting.
64
+ return { status: 'unchanged', content: existingContent ?? serialize(root) };
65
+ }
66
+ group.hooks[index] = { ...existingHandler, command: spec.command };
67
+ hooks[spec.eventName] = eventGroups;
68
+ root.hooks = hooks;
69
+ return { status: 'repaired', content: serialize(root) };
70
+ }
71
+ eventGroups.push({
72
+ matcher: spec.matcher,
73
+ hooks: [
74
+ { type: 'command', command: spec.command, timeout: spec.timeout, managedBy: spec.managedBy },
75
+ ],
76
+ });
77
+ hooks[spec.eventName] = eventGroups;
78
+ root.hooks = hooks;
79
+ return { status: 'installed', content: serialize(root) };
80
+ }
@@ -1,5 +1,11 @@
1
1
  /** Display label when an artifact has no topic — not a filterable topic value. */
2
2
  export declare const NO_TOPIC_LABEL = "(no topic)";
3
+ /**
4
+ * Collapse a leading home-directory prefix to `~`, matching how shells and tools like `git`
5
+ * display paths. Used for the home-view "bin:" identity line (AXI principle 10) so the executable
6
+ * path stays readable instead of a long absolute path.
7
+ */
8
+ export declare function collapseHomeDir(path: string, homeDir: string): string;
3
9
  export declare const CONTROL_CHAR_PATTERN: RegExp;
4
10
  /**
5
11
  * Strip ANSI escape codes and other control characters from a cached metadata string before it
package/dist/lib/text.js CHANGED
@@ -1,5 +1,16 @@
1
1
  /** Display label when an artifact has no topic — not a filterable topic value. */
2
2
  export const NO_TOPIC_LABEL = '(no topic)';
3
+ /**
4
+ * Collapse a leading home-directory prefix to `~`, matching how shells and tools like `git`
5
+ * display paths. Used for the home-view "bin:" identity line (AXI principle 10) so the executable
6
+ * path stays readable instead of a long absolute path.
7
+ */
8
+ export function collapseHomeDir(path, homeDir) {
9
+ if (homeDir && (path === homeDir || path.startsWith(`${homeDir}/`))) {
10
+ return `~${path.slice(homeDir.length)}`;
11
+ }
12
+ return path;
13
+ }
3
14
  // Unicode "Control" category (C0 + C1) covers ANSI/terminal escape sequences: the ESC control
4
15
  // character that starts them is a member, so stripping this category is what keeps injected
5
16
  // control bytes from reaching a real terminal. Exported so `prompt-injection.ts`'s
@@ -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>;