@taurgis/bonsai 3.2.2 → 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.
- package/README.md +25 -2
- package/agents/agents/official-docs-researcher.agent.md +102 -0
- package/agents/agents/salesforce-docs-researcher.agent.md +17 -15
- package/agents/hooks/README.md +18 -2
- package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +1 -1
- package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +1 -1
- package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +1 -1
- package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +1 -1
- package/agents/hooks/github-copilot/bonsai-web-fetch.sh +1 -1
- package/agents/instructions/salesforce-research.instructions.md +5 -4
- package/agents/instructions/web-research.instructions.md +45 -0
- package/agents/skills/web-research/SKILL.md +139 -0
- package/bin/cli.mjs +7 -4
- package/dist/base-command.d.ts +42 -0
- package/dist/base-command.js +106 -6
- package/dist/commands/config/base.d.ts +2 -7
- package/dist/commands/config/base.js +4 -17
- package/dist/commands/config/get.js +2 -2
- package/dist/commands/config/list.js +2 -2
- package/dist/commands/config/set.js +2 -2
- package/dist/commands/config/unset.js +2 -2
- package/dist/commands/context.d.ts +20 -0
- package/dist/commands/context.js +65 -0
- package/dist/commands/fetch.js +4 -0
- package/dist/commands/inspect.js +11 -2
- package/dist/commands/list.d.ts +23 -11
- package/dist/commands/list.js +110 -121
- package/dist/commands/prune.d.ts +2 -0
- package/dist/commands/prune.js +35 -4
- package/dist/commands/search.d.ts +43 -0
- package/dist/commands/search.js +233 -0
- package/dist/commands/setup.d.ts +27 -0
- package/dist/commands/setup.js +148 -0
- package/dist/commands/status.js +10 -0
- package/dist/commands.d.ts +6 -0
- package/dist/commands.js +6 -0
- package/dist/hooks/command-not-found/suggest.d.ts +1 -1
- package/dist/hooks/command-not-found/suggest.js +12 -2
- package/dist/lib/argv.d.ts +5 -0
- package/dist/lib/argv.js +30 -5
- package/dist/lib/cli-flag-manifest.d.ts +7 -0
- package/dist/lib/cli-flag-manifest.js +7 -0
- package/dist/lib/cli-presentation.d.ts +25 -4
- package/dist/lib/cli-presentation.js +34 -4
- package/dist/lib/cli-result-types.d.ts +84 -1
- package/dist/lib/color.d.ts +2 -0
- package/dist/lib/color.js +6 -0
- package/dist/lib/common-metadata-filter-flags.d.ts +14 -0
- package/dist/lib/common-metadata-filter-flags.js +37 -0
- package/dist/lib/config/index.d.ts +2 -2
- package/dist/lib/config/index.js +2 -2
- package/dist/lib/config/io.d.ts +9 -0
- package/dist/lib/config/io.js +71 -21
- package/dist/lib/config/schema.d.ts +2 -0
- package/dist/lib/config/schema.js +7 -0
- package/dist/lib/envelope.js +11 -4
- package/dist/lib/next-command.d.ts +15 -0
- package/dist/lib/next-command.js +40 -0
- package/dist/lib/prune-flags.d.ts +2 -0
- package/dist/lib/prune-flags.js +18 -6
- package/dist/lib/research/artifact-index.js +7 -1
- package/dist/lib/research/artifact.js +20 -2
- package/dist/lib/research/browser.d.ts +12 -0
- package/dist/lib/research/browser.js +63 -8
- package/dist/lib/research/cli-io.d.ts +7 -0
- package/dist/lib/research/context-dashboard.d.ts +19 -0
- package/dist/lib/research/context-dashboard.js +43 -0
- package/dist/lib/research/fetch-command-service.js +90 -23
- package/dist/lib/research/fetch-result.d.ts +1 -0
- package/dist/lib/research/fetch-result.js +4 -0
- package/dist/lib/research/fetcher.js +3 -1
- package/dist/lib/research/import-command-service.js +22 -1
- package/dist/lib/research/list-row.d.ts +15 -0
- package/dist/lib/research/list-row.js +31 -0
- package/dist/lib/research/metadata-filters.d.ts +39 -0
- package/dist/lib/research/metadata-filters.js +71 -0
- package/dist/lib/research/metadata-flags.d.ts +34 -0
- package/dist/lib/research/metadata-flags.js +45 -0
- package/dist/lib/research/prompt-injection.d.ts +2 -1
- package/dist/lib/research/prompt-injection.js +18 -2
- package/dist/lib/research/resolve-target.d.ts +2 -0
- package/dist/lib/research/resolve-target.js +1 -1
- package/dist/lib/research/schema.d.ts +2 -0
- package/dist/lib/research/schema.js +4 -0
- package/dist/lib/research/search-match.d.ts +39 -0
- package/dist/lib/research/search-match.js +134 -0
- package/dist/lib/research/storage.d.ts +4 -7
- package/dist/lib/research/storage.js +26 -25
- package/dist/lib/scope-flags.d.ts +19 -0
- package/dist/lib/scope-flags.js +27 -0
- package/dist/lib/setup/agents.d.ts +16 -0
- package/dist/lib/setup/agents.js +20 -0
- package/dist/lib/setup/bin-command.d.ts +14 -0
- package/dist/lib/setup/bin-command.js +42 -0
- package/dist/lib/setup/hook-file-io.d.ts +37 -0
- package/dist/lib/setup/hook-file-io.js +80 -0
- package/dist/lib/text.d.ts +20 -8
- package/dist/lib/text.js +32 -9
- package/oclif.manifest.json +406 -13
- package/package.json +2 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { artifactMatchesUrlFilter } from './url.js';
|
|
2
|
+
/** Whether an artifact's topic matches a `--topic` filter (case-insensitive exact match). */
|
|
3
|
+
export function matchesTopicFilter(meta, topic) {
|
|
4
|
+
if (!topic)
|
|
5
|
+
return true;
|
|
6
|
+
return !!meta.topic && meta.topic.trim().toLowerCase() === topic.trim().toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
/** Whether an artifact's tags satisfy a `--tags` filter (case-insensitive, must match all). */
|
|
9
|
+
export function matchesTagsFilter(meta, tags) {
|
|
10
|
+
if (!tags || tags.length === 0)
|
|
11
|
+
return true;
|
|
12
|
+
const metaTagsLower = meta.tags.map((t) => t.toLowerCase());
|
|
13
|
+
return tags.every((t) => metaTagsLower.includes(t.toLowerCase()));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Reject an explicitly passed, whitespace-only `--topic` filter. Silently treating it as "no
|
|
17
|
+
* filter" (matching everything, as an empty/falsy value normally would in {@link matchesTopicFilter})
|
|
18
|
+
* would hide what's almost always a shell-quoting mistake — the same reasoning `--url` already
|
|
19
|
+
* applies via `emptyUrlFilterError`.
|
|
20
|
+
*/
|
|
21
|
+
export function emptyTopicFilterError(topic) {
|
|
22
|
+
if (topic === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (topic.trim() === '') {
|
|
25
|
+
return '--topic must be a non-empty value (e.g. "React Suspense").';
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Reject an explicitly passed, whitespace-only `--tags` filter entry. Unlike a missing/empty
|
|
31
|
+
* `--topic` (which reads as "no filter"), an empty tag string in {@link matchesTagsFilter} never
|
|
32
|
+
* matches any artifact's tags — silently returning zero results with no signal. Fail fast instead.
|
|
33
|
+
*/
|
|
34
|
+
export function emptyTagsFilterError(tags) {
|
|
35
|
+
if (!tags?.some((t) => t.trim() === ''))
|
|
36
|
+
return undefined;
|
|
37
|
+
return '--tags must be non-empty values (e.g. "react").';
|
|
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
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Longest `--topic` value accepted; topics are short labels, not free text. */
|
|
2
|
+
export declare const MAX_TOPIC_LENGTH = 200;
|
|
3
|
+
/** Longest single `--tags` value accepted; tags are short taxonomic labels. */
|
|
4
|
+
export declare const MAX_TAG_LENGTH = 100;
|
|
5
|
+
/**
|
|
6
|
+
* Reject a `--topic`/`--tags` value longer than its cap before it ever reaches the cache.
|
|
7
|
+
* Unbounded input here would still round-trip through YAML frontmatter and `list`/`inspect`
|
|
8
|
+
* output correctly, but a single unreasonably long value wraps a human-readable listing line
|
|
9
|
+
* across dozens of terminal rows, so cap it at the flag boundary the same way `metadataNewlineError`
|
|
10
|
+
* catches the newline case.
|
|
11
|
+
*
|
|
12
|
+
* @param flags - The command's parsed `topic`/`tags` flags.
|
|
13
|
+
* @returns An error message naming the offending flag and its length cap, or `null` when both fit.
|
|
14
|
+
*/
|
|
15
|
+
export declare function metadataLengthError(flags: {
|
|
16
|
+
topic?: string;
|
|
17
|
+
tags?: string[];
|
|
18
|
+
}): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Reject a `--topic`/`--tags` value containing a line break before it ever reaches the cache.
|
|
21
|
+
* Stored as-is, an embedded newline would corrupt the single-line-per-field frontmatter format
|
|
22
|
+
* (see `sanitizeFrontmatterLine` in `artifact.ts`): at best a silently dropped stray line, at
|
|
23
|
+
* worst a spoofed `key: value` pair or a bare `---` that closes the frontmatter fence early and
|
|
24
|
+
* splices the rest of the metadata into the body. Failing fast here — rather than letting the
|
|
25
|
+
* serializer silently rewrite the caller's input — gives the same actionable-error treatment as
|
|
26
|
+
* every other flag validator (`durationFlagError`, etc.).
|
|
27
|
+
*
|
|
28
|
+
* @param flags - The command's parsed `topic`/`tags` flags.
|
|
29
|
+
* @returns An error message naming the offending flag, or `null` when both are clean.
|
|
30
|
+
*/
|
|
31
|
+
export declare function metadataNewlineError(flags: {
|
|
32
|
+
topic?: string;
|
|
33
|
+
tags?: string[];
|
|
34
|
+
}): string | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Longest `--topic` value accepted; topics are short labels, not free text. */
|
|
2
|
+
export const MAX_TOPIC_LENGTH = 200;
|
|
3
|
+
/** Longest single `--tags` value accepted; tags are short taxonomic labels. */
|
|
4
|
+
export const MAX_TAG_LENGTH = 100;
|
|
5
|
+
/**
|
|
6
|
+
* Reject a `--topic`/`--tags` value longer than its cap before it ever reaches the cache.
|
|
7
|
+
* Unbounded input here would still round-trip through YAML frontmatter and `list`/`inspect`
|
|
8
|
+
* output correctly, but a single unreasonably long value wraps a human-readable listing line
|
|
9
|
+
* across dozens of terminal rows, so cap it at the flag boundary the same way `metadataNewlineError`
|
|
10
|
+
* catches the newline case.
|
|
11
|
+
*
|
|
12
|
+
* @param flags - The command's parsed `topic`/`tags` flags.
|
|
13
|
+
* @returns An error message naming the offending flag and its length cap, or `null` when both fit.
|
|
14
|
+
*/
|
|
15
|
+
export function metadataLengthError(flags) {
|
|
16
|
+
if (flags.topic !== undefined && flags.topic.length > MAX_TOPIC_LENGTH) {
|
|
17
|
+
return `--topic must be ${MAX_TOPIC_LENGTH} characters or fewer (got ${flags.topic.length}).`;
|
|
18
|
+
}
|
|
19
|
+
const longTag = flags.tags?.find((tag) => tag.length > MAX_TAG_LENGTH);
|
|
20
|
+
if (longTag !== undefined) {
|
|
21
|
+
return `--tags must be ${MAX_TAG_LENGTH} characters or fewer (got ${longTag.length}).`;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reject a `--topic`/`--tags` value containing a line break before it ever reaches the cache.
|
|
27
|
+
* Stored as-is, an embedded newline would corrupt the single-line-per-field frontmatter format
|
|
28
|
+
* (see `sanitizeFrontmatterLine` in `artifact.ts`): at best a silently dropped stray line, at
|
|
29
|
+
* worst a spoofed `key: value` pair or a bare `---` that closes the frontmatter fence early and
|
|
30
|
+
* splices the rest of the metadata into the body. Failing fast here — rather than letting the
|
|
31
|
+
* serializer silently rewrite the caller's input — gives the same actionable-error treatment as
|
|
32
|
+
* every other flag validator (`durationFlagError`, etc.).
|
|
33
|
+
*
|
|
34
|
+
* @param flags - The command's parsed `topic`/`tags` flags.
|
|
35
|
+
* @returns An error message naming the offending flag, or `null` when both are clean.
|
|
36
|
+
*/
|
|
37
|
+
export function metadataNewlineError(flags) {
|
|
38
|
+
if (flags.topic !== undefined && /[\r\n]/.test(flags.topic)) {
|
|
39
|
+
return '--topic cannot contain line breaks.';
|
|
40
|
+
}
|
|
41
|
+
if (flags.tags?.some((tag) => /[\r\n]/.test(tag))) {
|
|
42
|
+
return '--tags cannot contain line breaks.';
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* HTML comments are checked first; the body is then split on paragraph breaks and scanned line-by-line.
|
|
5
5
|
*
|
|
6
6
|
* @param markdown - Markdown content (possibly untrusted, sourced from fetched pages).
|
|
7
|
-
* @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder
|
|
7
|
+
* @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder,
|
|
8
|
+
* and ANSI/control bytes stripped so a cached read can never replay them to a terminal.
|
|
8
9
|
*/
|
|
9
10
|
export declare function sanitizePromptInjection(markdown: string): string;
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { CONTROL_CHAR_PATTERN } from '../text.js';
|
|
2
3
|
const REDACTION = '[Removed potentially unsafe agent instruction]';
|
|
3
4
|
const ZERO_WIDTH = /[\u200b-\u200f\ufeff]/g;
|
|
5
|
+
// Tab/LF/CR fall inside CONTROL_CHAR_PATTERN's Unicode "Control" category too, but they are
|
|
6
|
+
// legitimate Markdown whitespace, so the replacer below keeps those three and drops everything else.
|
|
7
|
+
const SAFE_CONTROL_CHARS = new Set(['\t', '\n', '\r']);
|
|
8
|
+
/**
|
|
9
|
+
* Removes ANSI escape codes and other control bytes a malicious page could embed in its visible
|
|
10
|
+
* text, while leaving Markdown-structural whitespace (tab/newline/CR) intact. Every fetch/import
|
|
11
|
+
* path renders its stored content directly to the terminal (`bonsai <url>`, cache hits, etc.), so
|
|
12
|
+
* raw control bytes surviving Readability/Turndown would replay as a terminal-injection attack on
|
|
13
|
+
* every subsequent read of the cache; this is the choke point all of those paths share.
|
|
14
|
+
*/
|
|
15
|
+
function stripUnsafeControlChars(text) {
|
|
16
|
+
return text.replace(CONTROL_CHAR_PATTERN, (char) => (SAFE_CONTROL_CHARS.has(char) ? char : ''));
|
|
17
|
+
}
|
|
4
18
|
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
|
|
5
19
|
const BASE64_TOKEN = /(?<![A-Za-z0-9+/_-])(?:[A-Za-z0-9+/_-]{16,}={0,2})(?![A-Za-z0-9+/_-])/g;
|
|
6
20
|
const HEX_TOKEN = /(?<![a-f0-9])(?:0x)?[a-f0-9]{24,}(?![a-f0-9])/gi;
|
|
@@ -159,10 +173,11 @@ function stripQuotes(span) {
|
|
|
159
173
|
* HTML comments are checked first; the body is then split on paragraph breaks and scanned line-by-line.
|
|
160
174
|
*
|
|
161
175
|
* @param markdown - Markdown content (possibly untrusted, sourced from fetched pages).
|
|
162
|
-
* @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder
|
|
176
|
+
* @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder,
|
|
177
|
+
* and ANSI/control bytes stripped so a cached read can never replay them to a terminal.
|
|
163
178
|
*/
|
|
164
179
|
export function sanitizePromptInjection(markdown) {
|
|
165
|
-
|
|
180
|
+
const redacted = markdown
|
|
166
181
|
.replace(HTML_COMMENT, (comment) => redactBlock(comment))
|
|
167
182
|
.split(/(\n{2,})/)
|
|
168
183
|
.map((block) => {
|
|
@@ -175,4 +190,5 @@ export function sanitizePromptInjection(markdown) {
|
|
|
175
190
|
return lineRedacted === block ? redactBlock(block) : lineRedacted;
|
|
176
191
|
})
|
|
177
192
|
.join('');
|
|
193
|
+
return stripUnsafeControlChars(redacted);
|
|
178
194
|
}
|
|
@@ -7,6 +7,8 @@ export interface ResolveResearchTargetOptions {
|
|
|
7
7
|
dataDir: string;
|
|
8
8
|
flagOverride?: StorageMode;
|
|
9
9
|
lookup?: boolean;
|
|
10
|
+
/** Read-only/plan mode: a corrupt entry hit during lookup is reported but never archived on disk. */
|
|
11
|
+
readOnly?: boolean;
|
|
10
12
|
url: string;
|
|
11
13
|
}
|
|
12
14
|
export interface ResolvedResearchTarget {
|
|
@@ -13,7 +13,7 @@ export function resolveResearchTarget(opts) {
|
|
|
13
13
|
});
|
|
14
14
|
return {
|
|
15
15
|
cacheKey,
|
|
16
|
-
located: opts.lookup === false ? null : locateArtifact(roots.readRoots, cacheKey),
|
|
16
|
+
located: opts.lookup === false ? null : locateArtifact(roots.readRoots, cacheKey, opts.readOnly),
|
|
17
17
|
normalizedUrl,
|
|
18
18
|
roots,
|
|
19
19
|
};
|
|
@@ -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
|
+
}
|
|
@@ -13,13 +13,10 @@ export declare function getArtifactPath(dataDir: string, key: string): string;
|
|
|
13
13
|
/**
|
|
14
14
|
* Scans the research storage directory, reading metadata of all valid markdown files
|
|
15
15
|
* to find the active artifact for the given cache key with the newest validated_at timestamp.
|
|
16
|
-
* If a corrupt artifact is found, it is skipped and archived as corrupt
|
|
16
|
+
* If a corrupt artifact is found, it is skipped and archived as corrupt — unless `readOnly` is set,
|
|
17
|
+
* in which case it is only reported, never renamed on disk.
|
|
17
18
|
*/
|
|
18
|
-
export declare function findArtifact(dataDir: string, key: string): ResearchArtifact | null;
|
|
19
|
-
/**
|
|
20
|
-
* Checks if a cached artifact exists on disk for a given cache key.
|
|
21
|
-
*/
|
|
22
|
-
export declare function hasArtifact(dataDir: string, key: string): boolean;
|
|
19
|
+
export declare function findArtifact(dataDir: string, key: string, readOnly?: boolean): ResearchArtifact | null;
|
|
23
20
|
/**
|
|
24
21
|
* Writes a ResearchArtifact to disk, creating the research storage folder if needed.
|
|
25
22
|
* Writes to a temporary file first, then atomically renames it.
|
|
@@ -42,7 +39,7 @@ export interface LocatedArtifact {
|
|
|
42
39
|
* returning the first match along with where it lives. Implements the project→global
|
|
43
40
|
* read fallback: a missing project entry still resolves against the global cache.
|
|
44
41
|
*/
|
|
45
|
-
export declare function locateArtifact(dataDirs: string[], key: string): LocatedArtifact | null;
|
|
42
|
+
export declare function locateArtifact(dataDirs: string[], key: string, readOnly?: boolean): LocatedArtifact | null;
|
|
46
43
|
/**
|
|
47
44
|
* Multi-root variant of {@link scanCacheDir}. Scans the `research/` dir of each data dir in
|
|
48
45
|
* order and deduplicates by cache key so an entry present in an earlier root (project) shadows
|
|
@@ -18,7 +18,14 @@ export function scanCacheDir(dir, fn) {
|
|
|
18
18
|
continue;
|
|
19
19
|
try {
|
|
20
20
|
const content = readFileSync(join(dir, file), 'utf-8');
|
|
21
|
-
const
|
|
21
|
+
const artifact = parseArtifact(content);
|
|
22
|
+
// parseArtifact defaults unrecognized frontmatter to a valid-looking shell (cache_key: '',
|
|
23
|
+
// status: 'active'), so a foreign or otherwise-garbled *.md file with `---`/`---` fences never
|
|
24
|
+
// throws here — it would otherwise surface as a phantom entry with no usable cache key. Every
|
|
25
|
+
// artifact Bonsai itself writes always has one, so an empty cache_key means "not ours."
|
|
26
|
+
if (!artifact.metadata.cache_key)
|
|
27
|
+
continue;
|
|
28
|
+
const result = fn(artifact, join(dir, file));
|
|
22
29
|
if (result !== null)
|
|
23
30
|
results.push(result);
|
|
24
31
|
}
|
|
@@ -36,7 +43,7 @@ export function getArtifactPath(dataDir, key) {
|
|
|
36
43
|
}
|
|
37
44
|
return join(dataDir, 'research', `${key}.md`);
|
|
38
45
|
}
|
|
39
|
-
function processFileForLookup(file, dir, key) {
|
|
46
|
+
function processFileForLookup(file, dir, key, readOnly) {
|
|
40
47
|
// Same file filter as scanCacheDir — superseded/corrupt/tmp archives must not resolve as active.
|
|
41
48
|
if (!isResearchFile(file)) {
|
|
42
49
|
return null;
|
|
@@ -51,13 +58,17 @@ function processFileForLookup(file, dir, key) {
|
|
|
51
58
|
}
|
|
52
59
|
catch (err) {
|
|
53
60
|
console.warn(`Warning: Corrupt research artifact found at "${filePath}": ${err.message}`);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
// Archiving renames the file on disk — a write a read-only/plan-mode lookup (e.g. `status`,
|
|
62
|
+
// `inspect --read-only`) must never perform, even incidentally while diagnosing a corrupt entry.
|
|
63
|
+
if (!readOnly) {
|
|
64
|
+
try {
|
|
65
|
+
const corruptPath = `${filePath}.corrupt.${Date.now()}`;
|
|
66
|
+
renameSync(filePath, corruptPath);
|
|
67
|
+
console.warn(`Archived corrupt artifact to "${corruptPath}"`);
|
|
68
|
+
}
|
|
69
|
+
catch (archiveErr) {
|
|
70
|
+
console.error(`Failed to archive corrupt artifact: ${archiveErr.message}`);
|
|
71
|
+
}
|
|
61
72
|
}
|
|
62
73
|
}
|
|
63
74
|
return null;
|
|
@@ -65,9 +76,10 @@ function processFileForLookup(file, dir, key) {
|
|
|
65
76
|
/**
|
|
66
77
|
* Scans the research storage directory, reading metadata of all valid markdown files
|
|
67
78
|
* to find the active artifact for the given cache key with the newest validated_at timestamp.
|
|
68
|
-
* If a corrupt artifact is found, it is skipped and archived as corrupt
|
|
79
|
+
* If a corrupt artifact is found, it is skipped and archived as corrupt — unless `readOnly` is set,
|
|
80
|
+
* in which case it is only reported, never renamed on disk.
|
|
69
81
|
*/
|
|
70
|
-
export function findArtifact(dataDir, key) {
|
|
82
|
+
export function findArtifact(dataDir, key, readOnly = false) {
|
|
71
83
|
const dir = join(dataDir, 'research');
|
|
72
84
|
if (!existsSync(dir)) {
|
|
73
85
|
return null;
|
|
@@ -76,7 +88,7 @@ export function findArtifact(dataDir, key) {
|
|
|
76
88
|
let bestValidatedAt = -1;
|
|
77
89
|
const files = readdirSync(dir);
|
|
78
90
|
for (const file of files) {
|
|
79
|
-
const artifact = processFileForLookup(file, dir, key);
|
|
91
|
+
const artifact = processFileForLookup(file, dir, key, readOnly);
|
|
80
92
|
if (!artifact) {
|
|
81
93
|
continue;
|
|
82
94
|
}
|
|
@@ -90,17 +102,6 @@ export function findArtifact(dataDir, key) {
|
|
|
90
102
|
}
|
|
91
103
|
return bestArtifact;
|
|
92
104
|
}
|
|
93
|
-
/**
|
|
94
|
-
* Checks if a cached artifact exists on disk for a given cache key.
|
|
95
|
-
*/
|
|
96
|
-
export function hasArtifact(dataDir, key) {
|
|
97
|
-
try {
|
|
98
|
-
return findArtifact(dataDir, key) !== null;
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
105
|
/**
|
|
105
106
|
* Writes a ResearchArtifact to disk, creating the research storage folder if needed.
|
|
106
107
|
* Writes to a temporary file first, then atomically renames it.
|
|
@@ -149,9 +150,9 @@ export function readArtifact(dataDir, key) {
|
|
|
149
150
|
* returning the first match along with where it lives. Implements the project→global
|
|
150
151
|
* read fallback: a missing project entry still resolves against the global cache.
|
|
151
152
|
*/
|
|
152
|
-
export function locateArtifact(dataDirs, key) {
|
|
153
|
+
export function locateArtifact(dataDirs, key, readOnly = false) {
|
|
153
154
|
for (const dataDir of dataDirs) {
|
|
154
|
-
const artifact = findArtifact(dataDir, key);
|
|
155
|
+
const artifact = findArtifact(dataDir, key, readOnly);
|
|
155
156
|
if (artifact) {
|
|
156
157
|
return { artifact, dataDir, path: getArtifactPath(dataDir, key) };
|
|
157
158
|
}
|
|
@@ -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;
|