@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,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
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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,
|
package/dist/commands.d.ts
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 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(
|
|
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(
|
|
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;
|
package/dist/lib/argv.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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]));
|