@taurgis/bonsai 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +24 -1
  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/dist/base-command.d.ts +5 -0
  14. package/dist/base-command.js +9 -0
  15. package/dist/commands/config/base.d.ts +2 -7
  16. package/dist/commands/config/base.js +4 -18
  17. package/dist/commands/context.d.ts +20 -0
  18. package/dist/commands/context.js +65 -0
  19. package/dist/commands/list.d.ts +12 -3
  20. package/dist/commands/list.js +61 -100
  21. package/dist/commands/search.d.ts +43 -0
  22. package/dist/commands/search.js +233 -0
  23. package/dist/commands/setup.d.ts +27 -0
  24. package/dist/commands/setup.js +148 -0
  25. package/dist/commands.d.ts +6 -0
  26. package/dist/commands.js +6 -0
  27. package/dist/lib/argv.js +4 -1
  28. package/dist/lib/cli-presentation.d.ts +19 -0
  29. package/dist/lib/cli-presentation.js +27 -0
  30. package/dist/lib/cli-result-types.d.ts +59 -0
  31. package/dist/lib/color.d.ts +2 -0
  32. package/dist/lib/color.js +6 -0
  33. package/dist/lib/common-metadata-filter-flags.d.ts +14 -0
  34. package/dist/lib/common-metadata-filter-flags.js +37 -0
  35. package/dist/lib/next-command.d.ts +15 -0
  36. package/dist/lib/next-command.js +40 -0
  37. package/dist/lib/research/context-dashboard.d.ts +19 -0
  38. package/dist/lib/research/context-dashboard.js +43 -0
  39. package/dist/lib/research/fetch-command-service.js +10 -0
  40. package/dist/lib/research/fetch-result.d.ts +1 -0
  41. package/dist/lib/research/fetch-result.js +4 -0
  42. package/dist/lib/research/list-row.d.ts +15 -0
  43. package/dist/lib/research/list-row.js +31 -0
  44. package/dist/lib/research/metadata-filters.d.ts +22 -1
  45. package/dist/lib/research/metadata-filters.js +34 -0
  46. package/dist/lib/research/schema.d.ts +2 -0
  47. package/dist/lib/research/schema.js +4 -0
  48. package/dist/lib/research/search-match.d.ts +39 -0
  49. package/dist/lib/research/search-match.js +134 -0
  50. package/dist/lib/scope-flags.d.ts +19 -0
  51. package/dist/lib/scope-flags.js +27 -0
  52. package/dist/lib/setup/agents.d.ts +16 -0
  53. package/dist/lib/setup/agents.js +20 -0
  54. package/dist/lib/setup/bin-command.d.ts +14 -0
  55. package/dist/lib/setup/bin-command.js +42 -0
  56. package/dist/lib/setup/hook-file-io.d.ts +37 -0
  57. package/dist/lib/setup/hook-file-io.js +80 -0
  58. package/dist/lib/text.d.ts +6 -0
  59. package/dist/lib/text.js +11 -0
  60. package/oclif.manifest.json +288 -3
  61. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import type { ResearchArtifactMetadata } from './schema.js';
1
+ import type { ArtifactType, CaptureMethod, ResearchArtifactMetadata } from './schema.js';
2
2
  /** Whether an artifact's topic matches a `--topic` filter (case-insensitive exact match). */
3
3
  export declare function matchesTopicFilter(meta: Pick<ResearchArtifactMetadata, 'topic'>, topic: string | undefined): boolean;
4
4
  /** Whether an artifact's tags satisfy a `--tags` filter (case-insensitive, must match all). */
@@ -16,3 +16,24 @@ export declare function emptyTopicFilterError(topic: string | undefined): string
16
16
  * matches any artifact's tags — silently returning zero results with no signal. Fail fast instead.
17
17
  */
18
18
  export declare function emptyTagsFilterError(tags: string[] | undefined): string | undefined;
19
+ /** The metadata filter flags `list` and `search` both expose, beyond content/topic search. */
20
+ export interface CommonMetadataFilterFlags {
21
+ topic?: string;
22
+ tags?: string[];
23
+ url?: string;
24
+ artifactType?: ArtifactType;
25
+ captureMethod?: CaptureMethod;
26
+ freshness?: 'fresh' | 'stale_grace' | 'stale_expired';
27
+ }
28
+ /**
29
+ * Shared page-level metadata filter combinator for `list` and `search` (topic/tags/url/artifact
30
+ * type/capture method/freshness) — single source of truth so the two commands can never drift on
31
+ * what "matches the given filters" means.
32
+ */
33
+ export declare function matchesCommonMetadataFilters(meta: ResearchArtifactMetadata, freshness: 'fresh' | 'stale_grace' | 'stale_expired', flags: CommonMetadataFilterFlags): boolean;
34
+ /**
35
+ * Whether any of the shared metadata filter flags was passed — used to pick empty-result guidance
36
+ * ("no matches for your filters" vs. "the cache is empty"). `search` combines this with its own
37
+ * `--query` check; `list` has no filters beyond these, so it uses the result directly.
38
+ */
39
+ export declare function hasActiveMetadataFilters(flags: CommonMetadataFilterFlags): boolean;
@@ -1,3 +1,4 @@
1
+ import { artifactMatchesUrlFilter } from './url.js';
1
2
  /** Whether an artifact's topic matches a `--topic` filter (case-insensitive exact match). */
2
3
  export function matchesTopicFilter(meta, topic) {
3
4
  if (!topic)
@@ -35,3 +36,36 @@ export function emptyTagsFilterError(tags) {
35
36
  return undefined;
36
37
  return '--tags must be non-empty values (e.g. "react").';
37
38
  }
39
+ /**
40
+ * Shared page-level metadata filter combinator for `list` and `search` (topic/tags/url/artifact
41
+ * type/capture method/freshness) — single source of truth so the two commands can never drift on
42
+ * what "matches the given filters" means.
43
+ */
44
+ export function matchesCommonMetadataFilters(meta, freshness, flags) {
45
+ if (!matchesTopicFilter(meta, flags.topic))
46
+ return false;
47
+ if (!matchesTagsFilter(meta, flags.tags))
48
+ return false;
49
+ if (flags.url && !artifactMatchesUrlFilter(meta, flags.url))
50
+ return false;
51
+ if (flags.artifactType && meta.artifact_type !== flags.artifactType)
52
+ return false;
53
+ if (flags.captureMethod && meta.capture_method !== flags.captureMethod)
54
+ return false;
55
+ if (flags.freshness && freshness !== flags.freshness)
56
+ return false;
57
+ return true;
58
+ }
59
+ /**
60
+ * Whether any of the shared metadata filter flags was passed — used to pick empty-result guidance
61
+ * ("no matches for your filters" vs. "the cache is empty"). `search` combines this with its own
62
+ * `--query` check; `list` has no filters beyond these, so it uses the result directly.
63
+ */
64
+ export function hasActiveMetadataFilters(flags) {
65
+ return Boolean(flags.topic ||
66
+ (flags.tags && flags.tags.length > 0) ||
67
+ flags.freshness ||
68
+ flags.artifactType ||
69
+ flags.captureMethod ||
70
+ flags.url);
71
+ }
@@ -11,6 +11,8 @@ export type CaptureMethod = (typeof CAPTURE_METHODS)[number];
11
11
  export declare const ARTIFACT_TYPES: readonly ["source", "research_note", "index", "section"];
12
12
  /** Classification of a research artifact's role. */
13
13
  export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
14
+ /** Artifact types `list`/`search` report: every type except the heading-level `section` child. */
15
+ export declare const PAGE_LEVEL_ARTIFACT_TYPES: ("source" | "research_note" | "index")[];
14
16
  /** Full metadata block stored alongside every cached research artifact. */
15
17
  export interface ResearchArtifactMetadata {
16
18
  schema_version: number;
@@ -12,3 +12,7 @@ export const CAPTURE_METHODS = [
12
12
  // index; `section` = a heading-level child of a page. Command flag `options` derive from this.
13
13
  /** All recognized artifact types. `index` = navigation hub or llms.txt; `section` = heading-level child of a page. */
14
14
  export const ARTIFACT_TYPES = ['source', 'research_note', 'index', 'section'];
15
+ // Shared by `list` and `search`: both report pages, never the `section` chunks a page is split
16
+ // into (a single fetch can yield dozens) — `inspect` is where a page's sections are discoverable.
17
+ /** Artifact types `list`/`search` report: every type except the heading-level `section` child. */
18
+ export const PAGE_LEVEL_ARTIFACT_TYPES = ARTIFACT_TYPES.filter((type) => type !== 'section');
@@ -0,0 +1,39 @@
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
+ /** A field a query term can match against, in priority order (most to least specific). */
8
+ export type SearchMatchField = 'topic' | 'tags' | 'summary' | 'compressed';
9
+ /** Fields of an artifact that `search --query` matches against. */
10
+ export interface SearchableFields {
11
+ topic: string | null;
12
+ tags: string[];
13
+ summary: string;
14
+ compressed: string;
15
+ }
16
+ /** Result of scoring one artifact against a tokenized query. */
17
+ export interface SearchMatch {
18
+ score: number;
19
+ matchedFields: SearchMatchField[];
20
+ }
21
+ /** Split a `--query` value into lowercase, deduplicated terms on whitespace. */
22
+ export declare function tokenizeSearchQuery(query: string): string[];
23
+ /**
24
+ * Reject a whitespace-only `--query` instead of silently matching nothing (an empty term list would
25
+ * either match every artifact or none, both surprising) — same reasoning as `--topic`/`--tags`/`--url`.
26
+ */
27
+ export declare function emptySearchQueryError(query: string | undefined): string | undefined;
28
+ /**
29
+ * Scores one artifact against tokenized query terms. Returns `null` when the artifact does not
30
+ * satisfy the match mode (every term must match somewhere, unless `matchAny` allows just one).
31
+ */
32
+ export declare function scoreSearchMatch(fields: SearchableFields, terms: readonly string[], matchAny: boolean): SearchMatch | null;
33
+ /**
34
+ * A short excerpt around the first query-term match in `compressed` (falling back to `summary`),
35
+ * so an agent can judge relevance from the token-cheap index alone before deciding to `inspect` or
36
+ * re-fetch the full page. Returns `null` when no term appears in either — e.g. the match was only
37
+ * against `topic`/`tags` — rather than an unrelated excerpt with no bearing on the query.
38
+ */
39
+ export declare function extractSearchSnippet(fields: Pick<SearchableFields, 'summary' | 'compressed'>, terms: readonly string[]): string | null;
@@ -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