@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.
Files changed (100) hide show
  1. package/README.md +25 -2
  2. package/agents/agents/official-docs-researcher.agent.md +102 -0
  3. package/agents/agents/salesforce-docs-researcher.agent.md +17 -15
  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 +7 -4
  14. package/dist/base-command.d.ts +42 -0
  15. package/dist/base-command.js +106 -6
  16. package/dist/commands/config/base.d.ts +2 -7
  17. package/dist/commands/config/base.js +4 -17
  18. package/dist/commands/config/get.js +2 -2
  19. package/dist/commands/config/list.js +2 -2
  20. package/dist/commands/config/set.js +2 -2
  21. package/dist/commands/config/unset.js +2 -2
  22. package/dist/commands/context.d.ts +20 -0
  23. package/dist/commands/context.js +65 -0
  24. package/dist/commands/fetch.js +4 -0
  25. package/dist/commands/inspect.js +11 -2
  26. package/dist/commands/list.d.ts +23 -11
  27. package/dist/commands/list.js +110 -121
  28. package/dist/commands/prune.d.ts +2 -0
  29. package/dist/commands/prune.js +35 -4
  30. package/dist/commands/search.d.ts +43 -0
  31. package/dist/commands/search.js +233 -0
  32. package/dist/commands/setup.d.ts +27 -0
  33. package/dist/commands/setup.js +148 -0
  34. package/dist/commands/status.js +10 -0
  35. package/dist/commands.d.ts +6 -0
  36. package/dist/commands.js +6 -0
  37. package/dist/hooks/command-not-found/suggest.d.ts +1 -1
  38. package/dist/hooks/command-not-found/suggest.js +12 -2
  39. package/dist/lib/argv.d.ts +5 -0
  40. package/dist/lib/argv.js +30 -5
  41. package/dist/lib/cli-flag-manifest.d.ts +7 -0
  42. package/dist/lib/cli-flag-manifest.js +7 -0
  43. package/dist/lib/cli-presentation.d.ts +25 -4
  44. package/dist/lib/cli-presentation.js +34 -4
  45. package/dist/lib/cli-result-types.d.ts +84 -1
  46. package/dist/lib/color.d.ts +2 -0
  47. package/dist/lib/color.js +6 -0
  48. package/dist/lib/common-metadata-filter-flags.d.ts +14 -0
  49. package/dist/lib/common-metadata-filter-flags.js +37 -0
  50. package/dist/lib/config/index.d.ts +2 -2
  51. package/dist/lib/config/index.js +2 -2
  52. package/dist/lib/config/io.d.ts +9 -0
  53. package/dist/lib/config/io.js +71 -21
  54. package/dist/lib/config/schema.d.ts +2 -0
  55. package/dist/lib/config/schema.js +7 -0
  56. package/dist/lib/envelope.js +11 -4
  57. package/dist/lib/next-command.d.ts +15 -0
  58. package/dist/lib/next-command.js +40 -0
  59. package/dist/lib/prune-flags.d.ts +2 -0
  60. package/dist/lib/prune-flags.js +18 -6
  61. package/dist/lib/research/artifact-index.js +7 -1
  62. package/dist/lib/research/artifact.js +20 -2
  63. package/dist/lib/research/browser.d.ts +12 -0
  64. package/dist/lib/research/browser.js +63 -8
  65. package/dist/lib/research/cli-io.d.ts +7 -0
  66. package/dist/lib/research/context-dashboard.d.ts +19 -0
  67. package/dist/lib/research/context-dashboard.js +43 -0
  68. package/dist/lib/research/fetch-command-service.js +90 -23
  69. package/dist/lib/research/fetch-result.d.ts +1 -0
  70. package/dist/lib/research/fetch-result.js +4 -0
  71. package/dist/lib/research/fetcher.js +3 -1
  72. package/dist/lib/research/import-command-service.js +22 -1
  73. package/dist/lib/research/list-row.d.ts +15 -0
  74. package/dist/lib/research/list-row.js +31 -0
  75. package/dist/lib/research/metadata-filters.d.ts +39 -0
  76. package/dist/lib/research/metadata-filters.js +71 -0
  77. package/dist/lib/research/metadata-flags.d.ts +34 -0
  78. package/dist/lib/research/metadata-flags.js +45 -0
  79. package/dist/lib/research/prompt-injection.d.ts +2 -1
  80. package/dist/lib/research/prompt-injection.js +18 -2
  81. package/dist/lib/research/resolve-target.d.ts +2 -0
  82. package/dist/lib/research/resolve-target.js +1 -1
  83. package/dist/lib/research/schema.d.ts +2 -0
  84. package/dist/lib/research/schema.js +4 -0
  85. package/dist/lib/research/search-match.d.ts +39 -0
  86. package/dist/lib/research/search-match.js +134 -0
  87. package/dist/lib/research/storage.d.ts +4 -7
  88. package/dist/lib/research/storage.js +26 -25
  89. package/dist/lib/scope-flags.d.ts +19 -0
  90. package/dist/lib/scope-flags.js +27 -0
  91. package/dist/lib/setup/agents.d.ts +16 -0
  92. package/dist/lib/setup/agents.js +20 -0
  93. package/dist/lib/setup/bin-command.d.ts +14 -0
  94. package/dist/lib/setup/bin-command.js +42 -0
  95. package/dist/lib/setup/hook-file-io.d.ts +37 -0
  96. package/dist/lib/setup/hook-file-io.js +80 -0
  97. package/dist/lib/text.d.ts +20 -8
  98. package/dist/lib/text.js +32 -9
  99. package/oclif.manifest.json +406 -13
  100. package/package.json +2 -1
@@ -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
+ }
@@ -32,6 +32,10 @@ export default class ResearchStatus extends BaseCommand {
32
32
  description: 'evaluate status with a specific tier and TTL',
33
33
  command: '<%= config.bin %> <%= command.id %> https://example.com/docs --tier volatile --ttl 2h',
34
34
  },
35
+ {
36
+ description: 'check multiple URLs in one batch (space-separated)',
37
+ command: '<%= config.bin %> <%= command.id %> https://example.com/docs https://example.com/api',
38
+ },
35
39
  ];
36
40
  static strict = false;
37
41
  static args = {
@@ -83,6 +87,12 @@ export default class ResearchStatus extends BaseCommand {
83
87
  if (result.status === 'miss') {
84
88
  this.warn(cacheMissHint(this.config.bin, normalizedUrl));
85
89
  }
90
+ else if (result.action === 'would_revalidate') {
91
+ this.tip(`${this.config.bin} ${normalizedUrl} to revalidate.`);
92
+ }
93
+ else {
94
+ this.tip(`${this.config.bin} inspect ${normalizedUrl} for full metadata.`);
95
+ }
86
96
  }
87
97
  return {
88
98
  cacheKey,
@@ -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,
@@ -1,5 +1,5 @@
1
1
  import { type Hook, type Interfaces } from '@oclif/core';
2
- export declare function buildCommandNotFoundDetails(id: string, argv: string[] | undefined, config: Interfaces.Config): {
2
+ export declare function buildCommandNotFoundDetails(rawId: string, argv: string[] | undefined, config: Interfaces.Config): {
3
3
  code: 'COMMAND_NOT_FOUND' | 'MISSING_URL_SCHEME' | 'UNEXPECTED_ARGUMENT';
4
4
  command: string;
5
5
  jsonMode: boolean;
@@ -1,5 +1,5 @@
1
1
  import { toConfiguredId } from '@oclif/core';
2
- import { closestMatch, maxFuzzyDistance } from '../../lib/text.js';
2
+ import { closestMatch, maxFuzzyDistance, sanitizeForTerminal } from '../../lib/text.js';
3
3
  import { writeCommandNotFoundJson } from '../../lib/cli-emit.js';
4
4
  import { looksLikeSchemelessUrl } from '../../lib/research/url.js';
5
5
  /**
@@ -97,7 +97,17 @@ function missingUrlSchemeDetails(urls, argv, config) {
97
97
  suggestions: [suggestion],
98
98
  };
99
99
  }
100
- export function buildCommandNotFoundDetails(id, argv, config) {
100
+ export function buildCommandNotFoundDetails(rawId, argv, config) {
101
+ // The attempted id is raw user input echoed straight back into this error's message and
102
+ // suggestions — untrusted per the repo's trust-boundary rules, same reasoning as
103
+ // BaseCommand.error(). Sanitize once, up front, so every derived value below (segments, the
104
+ // display-only "attempted" string, a bare-URL correction) is already safe; a real command/topic id
105
+ // never contains control bytes, so this only affects input that could never have matched one
106
+ // anyway. Gate on jsonMode exactly like BaseCommand.error() does, so `--json` keeps
107
+ // byte-for-byte fidelity — this hook fires before a Command instance exists, so there's no
108
+ // `jsonEnabled()` to call, but the same boolean is cheap to compute directly from argv.
109
+ const jsonMode = isJsonMode(argv);
110
+ const id = jsonMode ? rawId : sanitizeForTerminal(rawId);
101
111
  // Match against every loaded command id — including hidden ones that stay invokable
102
112
  // (`fetch`, plugin internals). Root `--help` hides them; typo recovery should not.
103
113
  const commandIds = config.commandIDs;
@@ -16,6 +16,11 @@ export interface NormalizationResult {
16
16
  export interface ArgvNormalizeOptions {
17
17
  /** Tokens that consume the next argv value (`--topic`, `-t`, …). From cli-flag-manifest. */
18
18
  valueTakingFlags: ReadonlySet<string>;
19
+ /**
20
+ * Root segment of every registered command id (`config:get` → `config`). From cli-flag-manifest.
21
+ * A `word:` token whose root is in this set is a command (`config:get`), never a URL scheme.
22
+ */
23
+ knownCommandRoots: ReadonlySet<string>;
19
24
  }
20
25
  /**
21
26
  * Normalize raw process argv for oclif. `valueTakingFlags` must come from the composition root
package/dist/lib/argv.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { EXIT_USAGE } from './cli-error-policy.js';
2
2
  import { buildCliErrorEnvelope } from './envelope.js';
3
+ import { sanitizeForTerminal } from './text.js';
3
4
  /** Shared MISSING_COMMAND copy for flag-only argv preflight. */
4
5
  function missingCommandDetails(bin, swallowed) {
5
6
  if (swallowed) {
@@ -26,7 +27,7 @@ function missingCommandDetails(bin, swallowed) {
26
27
  * it picks a command, so `bonsai --read-only list` would otherwise treat `--read-only` as the
27
28
  * command id. Strip and re-append them after the command/URL, same pattern as `--json`.
28
29
  */
29
- const GLOBAL_BOOLEAN_FLAGS = new Set(['--read-only', '--plan']);
30
+ const GLOBAL_BOOLEAN_FLAGS = new Set(['--read-only', '--plan', '--toon']);
30
31
  /**
31
32
  * When a value-taking flag is the only "command" and its value looks like a URL, the user almost
32
33
  * certainly put the URL where the flag value belongs (e.g. `bonsai --tags https://example.com`).
@@ -47,7 +48,13 @@ function findSwallowedUrlFlag(argv, valueTakingFlags) {
47
48
  return null;
48
49
  }
49
50
  function missingUsageExit(json, argv, valueTakingFlags) {
50
- const details = missingCommandDetails('bonsai', findSwallowedUrlFlag(argv, valueTakingFlags));
51
+ const swallowed = findSwallowedUrlFlag(argv, valueTakingFlags);
52
+ // `swallowed.url` is raw user input echoed straight back into the MISSING_COMMAND message and its
53
+ // "pass the URL as the command" suggestion — untrusted per the repo's trust-boundary rules, same
54
+ // reasoning as BaseCommand.error(). This preflight path fires before oclif ever dispatches a
55
+ // command, so there is no `this.jsonEnabled()` to call — but `json` is already known here, so gate
56
+ // on it the same way: sanitize only the human-mode message, keep `--json` byte-for-byte.
57
+ const details = missingCommandDetails('bonsai', swallowed && !json ? { ...swallowed, url: sanitizeForTerminal(swallowed.url) } : swallowed);
51
58
  return {
52
59
  exitCode: EXIT_USAGE,
53
60
  json,
@@ -86,7 +93,18 @@ function hasCommandToken(argv, valueTakingFlags) {
86
93
  * (`cli-flag-manifest`) — this module stays free of command registry imports.
87
94
  */
88
95
  export function normalizeArgv(rawArgv, options) {
89
- const { valueTakingFlags } = options;
96
+ const { valueTakingFlags, knownCommandRoots } = options;
97
+ // AXI "content first": a truly bare invocation (`bonsai`, no args/flags at all) shows live cache
98
+ // data instead of oclif's default root help — `bonsai help`, `--help`, and `-h` remain the
99
+ // explicit path to the command reference (all of them are non-empty argv and skip this branch).
100
+ // oclif has no built-in "default command" concept for empty argv (see oclif/oclif#277), so this
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`.
105
+ if (rawArgv.length === 0) {
106
+ return { argv: ['list', '--identity'] };
107
+ }
90
108
  // oclif's JSON flag is command-scoped, so `bonsai list --json` works but
91
109
  // `bonsai --json list` is otherwise parsed as an unknown command named
92
110
  // "--json". Collect every --json, append one copy after the command/URL, and
@@ -113,8 +131,15 @@ export function normalizeArgv(rawArgv, options) {
113
131
  // forms like `javascript:` or `data:` so fetch can reject unsupported protocols instead of oclif
114
132
  // reporting a misleading "command not found". If the invocation begins with a flag, allow common
115
133
  // flag-before-argument usage such as `bonsai --format detailed https://example.com`.
134
+ // Exclude tokens whose part before the first `:` names a real command (`config:get`) — oclif
135
+ // joins namespaced command ids with `:` regardless of the display-only topicSeparator, so those
136
+ // are commands, not URL schemes, and must reach normal command dispatch instead of a misleading
137
+ // "Invalid URL" error.
116
138
  const firstNonFlagArgIndex = firstPositionalIndex(core, valueTakingFlags);
117
- const rootFetchShape = firstNonFlagArgIndex !== -1 && looksLikeUrl(core[firstNonFlagArgIndex]);
139
+ const firstArg = firstNonFlagArgIndex !== -1 ? core[firstNonFlagArgIndex] : undefined;
140
+ const rootFetchShape = firstArg !== undefined &&
141
+ looksLikeUrl(firstArg) &&
142
+ !knownCommandRoots.has(firstArg.split(':')[0]);
118
143
  if (rootFetchShape) {
119
144
  const url = core[firstNonFlagArgIndex];
120
145
  core = [
@@ -131,7 +156,7 @@ export function normalizeArgv(rawArgv, options) {
131
156
  core.push('--json');
132
157
  // Flag-only argv (`--json`, `--read-only`, `--tags https://…`) never resolves a command.
133
158
  // Exit here so command_not_found does not special-case dash-prefixed ids.
134
- // Empty argv and `--help`/`--version` stay with oclif.
159
+ // Truly empty argv already returned above; `--help`/`--version` stay with oclif here.
135
160
  if (!helpRequested && core.length > 0 && !hasCommandToken(core, valueTakingFlags)) {
136
161
  return {
137
162
  argv: jsonMode ? ['--json'] : [],
@@ -3,3 +3,10 @@
3
3
  * Built from command class metadata so argv normalization stays free of command imports.
4
4
  */
5
5
  export declare const VALUE_TAKING_FLAG_TOKENS: ReadonlySet<string>;
6
+ /**
7
+ * Root segment of every registered command id (`config:get` → `config`). oclif joins namespaced
8
+ * command ids with `:` regardless of the display-only `topicSeparator` setting, so a token like
9
+ * `config:get` is a real command, not a URL — argv normalization checks this set before assuming
10
+ * `word:` is a URL scheme.
11
+ */
12
+ export declare const KNOWN_COMMAND_ROOT_TOKENS: ReadonlySet<string>;
@@ -6,3 +6,10 @@ import { valueTakingFlagTokensFromCommands } from './value-flags.js';
6
6
  * Built from command class metadata so argv normalization stays free of command imports.
7
7
  */
8
8
  export const VALUE_TAKING_FLAG_TOKENS = valueTakingFlagTokensFromCommands(commands, BaseCommand.baseFlags);
9
+ /**
10
+ * Root segment of every registered command id (`config:get` → `config`). oclif joins namespaced
11
+ * command ids with `:` regardless of the display-only `topicSeparator` setting, so a token like
12
+ * `config:get` is a real command, not a URL — argv normalization checks this set before assuming
13
+ * `word:` is a URL scheme.
14
+ */
15
+ export const KNOWN_COMMAND_ROOT_TOKENS = new Set(Object.keys(commands).map((id) => id.split(':')[0]));