@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,233 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../base-command.js';
3
+ import { scanCacheDirs } from '../lib/research/storage.js';
4
+ import { loadStoreRoots } from '../lib/research/store-roots.js';
5
+ import { evaluateFreshness } from '../lib/research/freshness.js';
6
+ import { formatTip, resultListHeading, sanitizeForTerminal, } from '../lib/text.js';
7
+ import { limitFlag } from '../lib/limit-flag.js';
8
+ import { countByFreshness, toListRow } from '../lib/research/list-row.js';
9
+ import { emptyUrlFilterError } from '../lib/research/url.js';
10
+ import { matchesCommonMetadataFilters, hasActiveMetadataFilters, emptyTopicFilterError, emptyTagsFilterError, } from '../lib/research/metadata-filters.js';
11
+ import { commonMetadataFilterFlags } from '../lib/common-metadata-filter-flags.js';
12
+ import { buildNextLimitCommand } from '../lib/next-command.js';
13
+ import { tokenizeSearchQuery, emptySearchQueryError, scoreSearchMatch, extractSearchSnippet, } from '../lib/research/search-match.js';
14
+ import { colors, FRESHNESS_COLOR } from '../lib/color.js';
15
+ import { CLI_FLAG_DESCRIPTIONS, formatResultRowHeader, formatResultRowSourceUrls, formatResultRowTokens, } from '../lib/cli-presentation.js';
16
+ const SEARCH_DEFAULT_MAX_LIMIT = 100;
17
+ // A small default (matching `list`'s) keeps a broad query from flooding an agent's context;
18
+ // `summary.truncated`/`nextCommand` (and the human-mode tip) make raising --limit an explicit,
19
+ // deliberate next step rather than a silent, unbounded dump.
20
+ const SEARCH_DEFAULT_LIMIT = 10;
21
+ const NO_MATCH_SCORE = { score: 0, matchedFields: [] };
22
+ /** Project a full row down to the default minimal shape (see {@link SearchRowMinimal}). */
23
+ function toMinimalSearchRow(row) {
24
+ return {
25
+ sourceUrls: row.sourceUrls,
26
+ topic: row.topic,
27
+ freshness: row.freshness,
28
+ tokenEstimate: row.tokenEstimate,
29
+ score: row.score,
30
+ matchedFields: row.matchedFields,
31
+ snippet: row.snippet,
32
+ };
33
+ }
34
+ /** Search cached research artifacts by tag/content keywords and metadata filters. */
35
+ export default class ResearchSearch extends BaseCommand {
36
+ static id = 'search';
37
+ static summary = 'Search cached research artifacts by tags and content';
38
+ static description = 'Rank cached page-level artifacts by a --query matched against topic, tags, summary, and ' +
39
+ 'compressed content, plus the same metadata filters as `list` (topic, tags, URL, freshness, ' +
40
+ 'artifact type, capture method). Reads only the token-cheap indexed summary/compressed text, ' +
41
+ 'never the full detailed body, so ranking a large cache stays fast. Pass --full for every ' +
42
+ 'metadata field.';
43
+ /** Aggregate counts (and an explicit empty/truncation signal); see `list.ts`'s equivalent. */
44
+ jsonSummary = null;
45
+ static examples = [
46
+ {
47
+ description: 'search content and tags for a keyword',
48
+ command: '<%= config.bin %> search --query "suspense boundary"',
49
+ },
50
+ {
51
+ description: 'match any of several terms instead of requiring all',
52
+ command: '<%= config.bin %> search --query "suspense streaming" --match-any',
53
+ },
54
+ {
55
+ description: 'combine a query with metadata filters',
56
+ command: '<%= config.bin %> search --query cache --tags react --freshness fresh',
57
+ },
58
+ {
59
+ description: 'filter by tags/topic alone, ranked like list (no --query)',
60
+ command: '<%= config.bin %> search --tags node --json',
61
+ },
62
+ ];
63
+ static flags = {
64
+ query: Flags.string({
65
+ char: 'q',
66
+ description: CLI_FLAG_DESCRIPTIONS.searchQuery,
67
+ }),
68
+ 'match-any': Flags.boolean({
69
+ default: false,
70
+ description: CLI_FLAG_DESCRIPTIONS.searchMatchAny,
71
+ }),
72
+ ...commonMetadataFilterFlags(CLI_FLAG_DESCRIPTIONS.searchArtifactType),
73
+ limit: limitFlag(SEARCH_DEFAULT_MAX_LIMIT, SEARCH_DEFAULT_LIMIT, `result count (max ${SEARCH_DEFAULT_MAX_LIMIT}, default ${SEARCH_DEFAULT_LIMIT})`),
74
+ full: Flags.boolean({
75
+ default: false,
76
+ description: CLI_FLAG_DESCRIPTIONS.searchFull,
77
+ }),
78
+ };
79
+ static stdoutIsPrimaryData = true;
80
+ /** The shared filter flags as `matchesCommonMetadataFilters`/`hasActiveMetadataFilters` expect them. */
81
+ metadataFilterFlags() {
82
+ return {
83
+ topic: this.flags.topic,
84
+ tags: this.flags.tags,
85
+ url: this.flags.url,
86
+ artifactType: this.flags['artifact-type'],
87
+ captureMethod: this.flags['capture-method'],
88
+ freshness: this.flags.freshness,
89
+ };
90
+ }
91
+ matchesMetadataFilters(meta, freshness) {
92
+ return matchesCommonMetadataFilters(meta, freshness, this.metadataFilterFlags());
93
+ }
94
+ scanCacheDirForSearch(readRoots, currentTime, terms) {
95
+ const matchAny = this.flags['match-any'];
96
+ // Honor effective read-only: searching must not persist the derived search-index sidecar.
97
+ return scanCacheDirs(readRoots, (artifact, filePath) => {
98
+ if (artifact.metadata.status !== 'active')
99
+ return null;
100
+ // Page-level only, same reasoning as `list`: sections are sub-chunks of a page, not
101
+ // artifacts a user "has" — find them via `inspect` instead.
102
+ if (artifact.metadata.artifact_type === 'section')
103
+ return null;
104
+ const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
105
+ if (!this.matchesMetadataFilters(artifact.metadata, freshness))
106
+ return null;
107
+ let match = NO_MATCH_SCORE;
108
+ let snippet = null;
109
+ if (terms.length > 0) {
110
+ const scored = scoreSearchMatch({
111
+ topic: artifact.metadata.topic,
112
+ tags: artifact.metadata.tags,
113
+ summary: artifact.summary,
114
+ compressed: artifact.compressed,
115
+ }, terms, matchAny);
116
+ if (!scored)
117
+ return null;
118
+ match = scored;
119
+ snippet = extractSearchSnippet({ summary: artifact.summary, compressed: artifact.compressed }, terms);
120
+ }
121
+ return {
122
+ ...toListRow(artifact, filePath, freshness),
123
+ score: match.score,
124
+ matchedFields: match.matchedFields,
125
+ snippet,
126
+ };
127
+ }, { persistIndex: !this.readOnly });
128
+ }
129
+ /** Newest-first (no query) or highest-score-first (query given), both tie-broken by cache key. */
130
+ sortResults(results, queried) {
131
+ results.sort((a, b) => {
132
+ if (queried && b.score !== a.score)
133
+ return b.score - a.score;
134
+ const timeA = new Date(a.validatedAt || a.fetchedAt || 0).getTime();
135
+ const timeB = new Date(b.validatedAt || b.fetchedAt || 0).getTime();
136
+ if (timeB !== timeA)
137
+ return timeB - timeA;
138
+ return a.cacheKey.localeCompare(b.cacheKey);
139
+ });
140
+ }
141
+ hasActiveFilters() {
142
+ return Boolean(this.flags.query) || hasActiveMetadataFilters(this.metadataFilterFlags());
143
+ }
144
+ logSearchResults(finalResults, totalMatched, queried, nextCommand) {
145
+ if (finalResults.length === 0) {
146
+ this.emitEmptySearchGuidance();
147
+ return;
148
+ }
149
+ if (this.jsonEnabled())
150
+ return;
151
+ const labels = queried
152
+ ? { noun: 'matching cached research', order: 'top' }
153
+ : { noun: 'cached research', order: 'first' };
154
+ this.log(`${resultListHeading(totalMatched, finalResults.length, labels)}\n`);
155
+ finalResults.forEach((res, index) => {
156
+ this.log(formatResultRowHeader(index, res.topic, res.cacheKey));
157
+ const freshnessStr = FRESHNESS_COLOR[res.freshness](res.freshness);
158
+ const scoreStr = queried ? ` | Score: ${colors.bold(String(res.score))}` : '';
159
+ this.log(` Type: ${colors.bold(res.artifactType)} | Freshness: ${freshnessStr}${scoreStr}`);
160
+ if (queried && res.matchedFields.length > 0) {
161
+ this.log(` Matched: ${colors.gray(res.matchedFields.join(', '))}`);
162
+ }
163
+ if (res.snippet) {
164
+ this.log(` Snippet: ${colors.gray(sanitizeForTerminal(res.snippet))}`);
165
+ }
166
+ this.log(formatResultRowTokens(res.tokenEstimate));
167
+ this.log(formatResultRowSourceUrls(res.sourceUrls));
168
+ });
169
+ this.tipNextCommand(nextCommand);
170
+ }
171
+ /**
172
+ * Empty-cache / no-match tip (human mode only). Under `--json`/`--toon`, `data: []` is paired with
173
+ * `summary.empty: true` instead of prose — see {@link toSuccessJson}.
174
+ */
175
+ emitEmptySearchGuidance() {
176
+ if (this.jsonEnabled())
177
+ return;
178
+ const filtered = this.hasActiveFilters();
179
+ const headline = filtered
180
+ ? 'No cached research entries match the given search.'
181
+ : 'No cached research entries found.';
182
+ const tipCmd = filtered ? `${this.config.bin} search` : `${this.config.bin} <url>`;
183
+ const tipLead = filtered
184
+ ? 'try a broader --query, --match-any, or relax other filters: '
185
+ : 'populate the cache first: ';
186
+ this.log(headline);
187
+ this.log(`\n${formatTip(`${tipLead}${colors.cyan(tipCmd)}`)}`);
188
+ }
189
+ async run() {
190
+ const flagErr = emptyUrlFilterError(this.flags.url) ??
191
+ emptyTopicFilterError(this.flags.topic) ??
192
+ emptyTagsFilterError(this.flags.tags) ??
193
+ emptySearchQueryError(this.flags.query);
194
+ if (flagErr)
195
+ this.error(flagErr, { exit: 2, code: 'INVALID_FLAG_VALUE' });
196
+ const roots = loadStoreRoots({
197
+ configDir: this.config.configDir,
198
+ cwd: process.cwd(),
199
+ dataDir: this.config.dataDir,
200
+ });
201
+ const currentTime = new Date();
202
+ const terms = this.flags.query ? tokenizeSearchQuery(this.flags.query) : [];
203
+ const queried = terms.length > 0;
204
+ const results = this.scanCacheDirForSearch(roots.readRoots, currentTime, terms);
205
+ this.sortResults(results, queried);
206
+ const finalResults = results.slice(0, this.flags.limit);
207
+ const truncated = results.length > finalResults.length;
208
+ // Suggest exactly enough to see every match, capped at the command's own max — a caller who
209
+ // truncated at the default can raise --limit once and be done, rather than guessing a value.
210
+ const nextCommand = truncated
211
+ ? buildNextLimitCommand(this.config.bin, 'search', this.argv, Math.min(results.length, SEARCH_DEFAULT_MAX_LIMIT))
212
+ : null;
213
+ this.jsonSummary = {
214
+ total: results.length,
215
+ shown: finalResults.length,
216
+ limit: this.flags.limit,
217
+ truncated,
218
+ empty: results.length === 0,
219
+ byFreshness: countByFreshness(results),
220
+ queried,
221
+ nextCommand,
222
+ };
223
+ this.logSearchResults(finalResults, results.length, queried, nextCommand);
224
+ return this.flags.full ? finalResults : finalResults.map(toMinimalSearchRow);
225
+ }
226
+ /** Attach the aggregate `summary` computed in `run()` (see {@link SearchSummary}). */
227
+ toSuccessJson(data) {
228
+ const envelope = this.baseSuccessJson(data);
229
+ if (!this.jsonSummary)
230
+ return envelope;
231
+ return { ...envelope, summary: this.jsonSummary };
232
+ }
233
+ }
@@ -0,0 +1,27 @@
1
+ import { BaseCommand } from '../base-command.js';
2
+ import type { SetupResult } from '../lib/cli-result-types.js';
3
+ /**
4
+ * Installs (or repairs) a `SessionStart` hook that runs `bonsai context` at the start of every
5
+ * agent session, so the cache's current state is visible before the agent takes any action (AXI
6
+ * principle 7: ambient context via session integrations).
7
+ */
8
+ export default class Setup extends BaseCommand<typeof Setup> {
9
+ static id: string;
10
+ static summary: string;
11
+ static description: string;
12
+ static examples: {
13
+ description: string;
14
+ command: string;
15
+ }[];
16
+ static args: {
17
+ agent: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
18
+ };
19
+ static flags: {
20
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
+ global: import("@oclif/core/interfaces").BooleanFlag<boolean>;
22
+ local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
23
+ };
24
+ static stdoutIsPrimaryData: boolean;
25
+ private assertKnownAgent;
26
+ run(): Promise<SetupResult>;
27
+ }
@@ -0,0 +1,148 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { BaseCommand } from '../base-command.js';
6
+ import { atomicWriteFile } from '../lib/atomic-write.js';
7
+ import { resolveBinCommand } from '../lib/setup/bin-command.js';
8
+ import { SETUP_AGENTS, SETUP_AGENT_TARGETS, SETUP_HOOK_COMMAND_SUFFIX, SETUP_HOOK_MANAGED_BY, } from '../lib/setup/agents.js';
9
+ import { InvalidHookFileError, planSessionStartHookInstall, } from '../lib/setup/hook-file-io.js';
10
+ import { closestMatch, maxFuzzyDistance } from '../lib/text.js';
11
+ import { assertScopeFlagsExclusive, scopeFlags } from '../lib/scope-flags.js';
12
+ function isSetupAgent(value) {
13
+ return SETUP_AGENTS.includes(value);
14
+ }
15
+ /**
16
+ * Nearest known agent for a typo'd `agentArg`, or `null` if nothing plausible matches. Checks a
17
+ * prefix relationship first (either direction) so a short, valid-looking typo like `claude` for
18
+ * `claude-code` is caught even though its edit distance to the full name exceeds the general
19
+ * Levenshtein threshold; falls back to edit distance for closer misspellings like `codexx`.
20
+ */
21
+ function suggestAgent(agentArg) {
22
+ const prefixMatch = SETUP_AGENTS.find((agent) => agent.startsWith(agentArg) || agentArg.startsWith(agent));
23
+ if (prefixMatch)
24
+ return prefixMatch;
25
+ // closestMatch's candidates are exactly SETUP_AGENTS, so any non-null result is a SetupAgent.
26
+ return closestMatch(agentArg, SETUP_AGENTS, maxFuzzyDistance(agentArg));
27
+ }
28
+ /** Maps a plan outcome to the reported status, remapping write outcomes to `would_*` under dry-run. */
29
+ function reportSetupStatus(planStatus, dryRun) {
30
+ if (!dryRun)
31
+ return planStatus;
32
+ if (planStatus === 'unchanged')
33
+ return 'unchanged';
34
+ return planStatus === 'repaired' ? 'would_repair' : 'would_install';
35
+ }
36
+ const SETUP_STATUS_LABEL = {
37
+ installed: 'Installed',
38
+ repaired: 'Repaired',
39
+ unchanged: 'Already up to date:',
40
+ would_install: '[dry-run] Would install',
41
+ would_repair: '[dry-run] Would repair',
42
+ };
43
+ /**
44
+ * Installs (or repairs) a `SessionStart` hook that runs `bonsai context` at the start of every
45
+ * agent session, so the cache's current state is visible before the agent takes any action (AXI
46
+ * principle 7: ambient context via session integrations).
47
+ */
48
+ export default class Setup extends BaseCommand {
49
+ static id = 'setup';
50
+ static summary = 'Install a SessionStart hook that shows live cache context';
51
+ static description = 'Writes (or repairs) a SessionStart hook entry that runs `bonsai context` at the start of ' +
52
+ 'every session, so the agent sees the cache state before doing anything. Project-scoped by ' +
53
+ 'default (shareable via version control); pass --global for a user-level, machine-only install. ' +
54
+ 'Re-running is idempotent: an unchanged install is a no-op, and a stale executable path is repaired.';
55
+ static examples = [
56
+ {
57
+ description: 'install the hook for this project (Claude Code)',
58
+ command: '<%= config.bin %> setup claude-code',
59
+ },
60
+ {
61
+ description: 'install the hook for this project (Codex)',
62
+ command: '<%= config.bin %> setup codex',
63
+ },
64
+ {
65
+ description: 'preview a user-level install without writing anything',
66
+ command: '<%= config.bin %> setup claude-code --global --dry-run --json',
67
+ },
68
+ ];
69
+ static args = {
70
+ agent: Args.string({
71
+ required: true,
72
+ description: `agent to install the hook for (${SETUP_AGENTS.join(', ')})`,
73
+ }),
74
+ };
75
+ static flags = {
76
+ ...scopeFlags({
77
+ global: 'install to the user-level config instead of the project (default: project)',
78
+ local: 'install to the project config (default; explicit form of the default)',
79
+ }),
80
+ 'dry-run': Flags.boolean({
81
+ description: 'preview the install without writing anything',
82
+ default: false,
83
+ }),
84
+ };
85
+ static stdoutIsPrimaryData = true;
86
+ assertKnownAgent(agentArg) {
87
+ if (isSetupAgent(agentArg))
88
+ return;
89
+ if (agentArg === 'opencode') {
90
+ this.error('OpenCode is not supported by `setup` yet: its plugin hook signature for injecting ' +
91
+ "session-start context isn't confirmed in official docs, so Bonsai won't guess at one. " +
92
+ 'See the "Ambient session context" docs for status.', { exit: 2, code: 'UNKNOWN_AGENT', suggestions: [`Use one of: ${SETUP_AGENTS.join(', ')}`] });
93
+ }
94
+ const suggestion = suggestAgent(agentArg);
95
+ const hint = suggestion ? ` Did you mean "${suggestion}"?` : '';
96
+ this.error(`Unknown agent: "${agentArg}".${hint} Supported agents: ${SETUP_AGENTS.join(', ')}.`, {
97
+ exit: 2,
98
+ code: 'UNKNOWN_AGENT',
99
+ suggestions: suggestion
100
+ ? [`Use "${suggestion}": ${this.config.bin} setup ${suggestion}`]
101
+ : [`Use one of: ${SETUP_AGENTS.join(', ')}`],
102
+ });
103
+ }
104
+ async run() {
105
+ const agentArg = this.args.agent;
106
+ this.assertKnownAgent(agentArg);
107
+ assertScopeFlagsExclusive(this.error.bind(this), this.flags.global, this.flags.local);
108
+ const target = SETUP_AGENT_TARGETS[agentArg];
109
+ const scope = this.flags.global ? 'user' : 'project';
110
+ const root = scope === 'user' ? homedir() : process.cwd();
111
+ const path = join(root, target.relativePath);
112
+ const binCommand = resolveBinCommand(process.argv[1] ?? this.config.bin);
113
+ const command = `${binCommand} ${SETUP_HOOK_COMMAND_SUFFIX}`;
114
+ const existingContent = existsSync(path) ? readFileSync(path, 'utf-8') : null;
115
+ let plan;
116
+ try {
117
+ plan = planSessionStartHookInstall(existingContent, {
118
+ eventName: target.eventName,
119
+ matcher: target.matcher,
120
+ command,
121
+ timeout: target.timeout,
122
+ managedBy: SETUP_HOOK_MANAGED_BY,
123
+ });
124
+ }
125
+ catch (err) {
126
+ if (err instanceof InvalidHookFileError) {
127
+ this.error(`Cannot install into ${path}: ${err.message}.`, {
128
+ exit: 1,
129
+ code: 'INVALID_HOOK_FILE',
130
+ suggestions: [
131
+ 'Fix or remove the existing file so it contains a single JSON object, then re-run setup.',
132
+ ],
133
+ });
134
+ }
135
+ throw err;
136
+ }
137
+ const dryRun = this.effectiveDryRun(this.flags['dry-run']);
138
+ const status = reportSetupStatus(plan.status, dryRun);
139
+ if (!dryRun && plan.status !== 'unchanged') {
140
+ mkdirSync(dirname(path), { recursive: true });
141
+ atomicWriteFile(path, plan.content);
142
+ }
143
+ if (!this.jsonEnabled()) {
144
+ this.log(`${SETUP_STATUS_LABEL[status]} ${agentArg} SessionStart hook at ${path} (${scope})`);
145
+ }
146
+ return { agent: agentArg, scope, path, binCommand, status, dryRun };
147
+ }
148
+ }
@@ -3,18 +3,24 @@ import ConfigIndex from './commands/config/index.js';
3
3
  import ConfigList from './commands/config/list.js';
4
4
  import ConfigSet from './commands/config/set.js';
5
5
  import ConfigUnset from './commands/config/unset.js';
6
+ import ContextCommand from './commands/context.js';
6
7
  import FetchCommand from './commands/fetch.js';
7
8
  import ImportCommand from './commands/import.js';
8
9
  import InspectCommand from './commands/inspect.js';
9
10
  import ListCommand from './commands/list.js';
10
11
  import PruneCommand from './commands/prune.js';
12
+ import SearchCommand from './commands/search.js';
13
+ import SetupCommand from './commands/setup.js';
11
14
  import StatusCommand from './commands/status.js';
12
15
  export declare const commands: {
16
+ context: typeof ContextCommand;
13
17
  fetch: typeof FetchCommand;
14
18
  import: typeof ImportCommand;
15
19
  inspect: typeof InspectCommand;
16
20
  list: typeof ListCommand;
17
21
  prune: typeof PruneCommand;
22
+ search: typeof SearchCommand;
23
+ setup: typeof SetupCommand;
18
24
  status: typeof StatusCommand;
19
25
  config: typeof ConfigIndex;
20
26
  'config:get': typeof ConfigGet;
package/dist/commands.js CHANGED
@@ -3,18 +3,24 @@ import ConfigIndex from './commands/config/index.js';
3
3
  import ConfigList from './commands/config/list.js';
4
4
  import ConfigSet from './commands/config/set.js';
5
5
  import ConfigUnset from './commands/config/unset.js';
6
+ import ContextCommand from './commands/context.js';
6
7
  import FetchCommand from './commands/fetch.js';
7
8
  import ImportCommand from './commands/import.js';
8
9
  import InspectCommand from './commands/inspect.js';
9
10
  import ListCommand from './commands/list.js';
10
11
  import PruneCommand from './commands/prune.js';
12
+ import SearchCommand from './commands/search.js';
13
+ import SetupCommand from './commands/setup.js';
11
14
  import StatusCommand from './commands/status.js';
12
15
  export const commands = {
16
+ context: ContextCommand,
13
17
  fetch: FetchCommand,
14
18
  import: ImportCommand,
15
19
  inspect: InspectCommand,
16
20
  list: ListCommand,
17
21
  prune: PruneCommand,
22
+ search: SearchCommand,
23
+ setup: SetupCommand,
18
24
  status: StatusCommand,
19
25
  config: ConfigIndex,
20
26
  'config:get': ConfigGet,
package/dist/lib/argv.js CHANGED
@@ -99,8 +99,11 @@ export function normalizeArgv(rawArgv, options) {
99
99
  // explicit path to the command reference (all of them are non-empty argv and skip this branch).
100
100
  // oclif has no built-in "default command" concept for empty argv (see oclif/oclif#277), so this
101
101
  // is a one-line argv rewrite, the same technique the URL-shorthand rewrite below already uses.
102
+ // The hidden `--identity` flag (AXI principle 10) tells `list` to also identify the tool (bin
103
+ // path, one-sentence description) before the live data — only the true bare invocation gets it,
104
+ // not an explicit `bonsai list`.
102
105
  if (rawArgv.length === 0) {
103
- return { argv: ['list'] };
106
+ return { argv: ['list', '--identity'] };
104
107
  }
105
108
  // oclif's JSON flag is command-scoped, so `bonsai list --json` works but
106
109
  // `bonsai --json list` is otherwise parsed as an unknown command named
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Composition-root flag manifest: every CLI token that consumes a following argv value.
3
- * Built from command class metadata so argv normalization stays free of command imports.
2
+ * Every CLI token that consumes a following argv value, from the shipped oclif manifest.
3
+ * Argv normalization stays off the command-class import graph.
4
4
  */
5
5
  export declare const VALUE_TAKING_FLAG_TOKENS: ReadonlySet<string>;
6
6
  /**
@@ -1,15 +1,23 @@
1
- import { BaseCommand } from '../base-command.js';
2
- import { commands } from '../commands.js';
3
- import { valueTakingFlagTokensFromCommands } from './value-flags.js';
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { valueTakingFlagTokensFromFlagMaps } from './value-flags.js';
5
+ // Startup reads the generated manifest instead of importing every command class. Importing the
6
+ // registry pulls the fetch stack (Readability, Turndown, the browser fallback) into --help.
7
+ function readOclifManifest() {
8
+ const manifestPath = join(dirname(fileURLToPath(import.meta.url)), '../../oclif.manifest.json');
9
+ return JSON.parse(readFileSync(manifestPath, 'utf8'));
10
+ }
11
+ const manifest = readOclifManifest();
4
12
  /**
5
- * Composition-root flag manifest: every CLI token that consumes a following argv value.
6
- * Built from command class metadata so argv normalization stays free of command imports.
13
+ * Every CLI token that consumes a following argv value, from the shipped oclif manifest.
14
+ * Argv normalization stays off the command-class import graph.
7
15
  */
8
- export const VALUE_TAKING_FLAG_TOKENS = valueTakingFlagTokensFromCommands(commands, BaseCommand.baseFlags);
16
+ export const VALUE_TAKING_FLAG_TOKENS = valueTakingFlagTokensFromFlagMaps(Object.values(manifest.commands).map((command) => command.flags));
9
17
  /**
10
18
  * Root segment of every registered command id (`config:get` → `config`). oclif joins namespaced
11
19
  * command ids with `:` regardless of the display-only `topicSeparator` setting, so a token like
12
20
  * `config:get` is a real command, not a URL — argv normalization checks this set before assuming
13
21
  * `word:` is a URL scheme.
14
22
  */
15
- export const KNOWN_COMMAND_ROOT_TOKENS = new Set(Object.keys(commands).map((id) => id.split(':')[0]));
23
+ export const KNOWN_COMMAND_ROOT_TOKENS = new Set(Object.keys(manifest.commands).map((id) => id.split(':')[0]));
@@ -1,3 +1,4 @@
1
+ import type { TokenEstimate } from './research/schema.js';
1
2
  /** Shared flag/help copy so identical flags read identically across commands. */
2
3
  export declare const CLI_FLAG_DESCRIPTIONS: {
3
4
  readonly freshnessTierPolicy: "freshness tier policy";
@@ -21,6 +22,10 @@ export declare const CLI_FLAG_DESCRIPTIONS: {
21
22
  readonly listArtifactType: "artifact type; section children are omitted from list - use inspect to see them";
22
23
  readonly pruneArtifactType: "artifact type to prune, including section children";
23
24
  readonly listFull: "include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps); default output is a minimal row (source URLs, topic, freshness, token estimate)";
25
+ readonly searchQuery: "keyword(s) to search across topic, tags, summary, and compressed content (case-insensitive); every term must match somewhere unless --match-any is set";
26
+ readonly searchMatchAny: "match any query term instead of requiring all of them (OR instead of AND)";
27
+ readonly searchArtifactType: "artifact type; section children are omitted from search - use inspect to see them";
28
+ readonly searchFull: "include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps) alongside score/matchedFields/snippet; default output is a minimal row (source URLs, topic, freshness, token estimate, score, matchedFields, snippet)";
24
29
  };
25
30
  /** One human-readable label/value pair for CLI stdout. */
26
31
  export type HumanField = readonly [label: string, value: string];
@@ -39,3 +44,17 @@ export declare function formatHumanField(label: string, value: string): string;
39
44
  * @returns Formatted lines in the same order.
40
45
  */
41
46
  export declare function formatHumanFields(fields: readonly HumanField[]): string[];
47
+ /**
48
+ * The "N. [Topic] Key: <key>" header line shared by `list`/`search` human-mode row rendering —
49
+ * same topic coloring, ANSI-sanitization, and "(no topic)" fallback either way.
50
+ *
51
+ * @param index - Zero-based row index; displayed as `index + 1`.
52
+ * @param topic - The row's topic, or `null` to show {@link NO_TOPIC_LABEL}.
53
+ * @param cacheKey - The row's cache key.
54
+ * @returns A single formatted line.
55
+ */
56
+ export declare function formatResultRowHeader(index: number, topic: string | null, cacheKey: string): string;
57
+ /** The "Tokens: compressed=N, detailed=N" line shared by `list`/`search` human-mode row rendering. */
58
+ export declare function formatResultRowTokens(tokenEstimate: TokenEstimate): string;
59
+ /** The "Source URLs: ..." line shared by `list`/`search` human-mode row rendering. */
60
+ export declare function formatResultRowSourceUrls(sourceUrls: readonly string[]): string;
@@ -1,5 +1,6 @@
1
1
  import { colors } from './color.js';
2
2
  import { MAX_TAG_LENGTH, MAX_TOPIC_LENGTH } from './research/metadata-flags.js';
3
+ import { NO_TOPIC_LABEL, sanitizeForTerminal } from './text.js';
3
4
  const HUMAN_LABEL_WIDTH = 25;
4
5
  // Repeated verbatim across several flag descriptions; single-use wording lives inline below.
5
6
  const DURATION_EXAMPLES = 'e.g. "2h", "7d", "6m"';
@@ -27,6 +28,10 @@ export const CLI_FLAG_DESCRIPTIONS = {
27
28
  listArtifactType: 'artifact type; section children are omitted from list - use inspect to see them',
28
29
  pruneArtifactType: 'artifact type to prune, including section children',
29
30
  listFull: 'include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps); default output is a minimal row (source URLs, topic, freshness, token estimate)',
31
+ searchQuery: 'keyword(s) to search across topic, tags, summary, and compressed content (case-insensitive); every term must match somewhere unless --match-any is set',
32
+ searchMatchAny: 'match any query term instead of requiring all of them (OR instead of AND)',
33
+ searchArtifactType: 'artifact type; section children are omitted from search - use inspect to see them',
34
+ searchFull: 'include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps) alongside score/matchedFields/snippet; default output is a minimal row (source URLs, topic, freshness, token estimate, score, matchedFields, snippet)',
30
35
  };
31
36
  function formatHumanLabel(label) {
32
37
  const text = `${label}:`;
@@ -51,3 +56,25 @@ export function formatHumanField(label, value) {
51
56
  export function formatHumanFields(fields) {
52
57
  return fields.map(([label, value]) => formatHumanField(label, value));
53
58
  }
59
+ /**
60
+ * The "N. [Topic] Key: <key>" header line shared by `list`/`search` human-mode row rendering —
61
+ * same topic coloring, ANSI-sanitization, and "(no topic)" fallback either way.
62
+ *
63
+ * @param index - Zero-based row index; displayed as `index + 1`.
64
+ * @param topic - The row's topic, or `null` to show {@link NO_TOPIC_LABEL}.
65
+ * @param cacheKey - The row's cache key.
66
+ * @returns A single formatted line.
67
+ */
68
+ export function formatResultRowHeader(index, topic, cacheKey) {
69
+ const topicStr = topic ? colors.cyan(sanitizeForTerminal(topic)) : colors.gray(NO_TOPIC_LABEL);
70
+ const keyStr = colors.bold(cacheKey);
71
+ return `${index + 1}. [${topicStr}] Key: ${keyStr}`;
72
+ }
73
+ /** The "Tokens: compressed=N, detailed=N" line shared by `list`/`search` human-mode row rendering. */
74
+ export function formatResultRowTokens(tokenEstimate) {
75
+ return ` Tokens: compressed=${colors.bold(String(tokenEstimate?.compressed || 0))}, detailed=${colors.bold(String(tokenEstimate?.detailed || 0))}`;
76
+ }
77
+ /** The "Source URLs: ..." line shared by `list`/`search` human-mode row rendering. */
78
+ export function formatResultRowSourceUrls(sourceUrls) {
79
+ return ` Source URLs: ${colors.gray(sourceUrls.join(', '))}\n`;
80
+ }