@taurgis/bonsai 1.0.4 → 1.0.5
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 +28 -2
- package/agents/agents/salesforce-docs-researcher.agent.md +117 -0
- package/agents/hooks/README.md +62 -0
- package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +22 -0
- package/agents/hooks/claude-code/settings.json +16 -0
- package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +18 -0
- package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +22 -0
- package/agents/hooks/codex/hooks.json +28 -0
- package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +21 -0
- package/agents/hooks/cursor/hooks.json +12 -0
- package/agents/hooks/github-copilot/bonsai-web-fetch.json +23 -0
- package/agents/hooks/github-copilot/bonsai-web-fetch.sh +19 -0
- package/agents/instructions/salesforce-research.instructions.md +57 -0
- package/agents/instructions/web-research.instructions.md +2 -1
- package/bin/cli.mjs +13 -3
- package/dist/base-command.d.ts +20 -0
- package/dist/base-command.js +27 -1
- 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/fetch.d.ts +5 -0
- package/dist/commands/fetch.js +81 -16
- package/dist/commands/import.d.ts +1 -0
- package/dist/commands/import.js +38 -6
- package/dist/commands/list.js +18 -3
- package/dist/commands/prune.js +22 -6
- package/dist/commands/search.js +21 -22
- package/dist/commands/status.js +10 -9
- package/dist/hooks/command-not-found/suggest.d.ts +9 -0
- package/dist/hooks/command-not-found/suggest.js +93 -0
- package/dist/lib/config/index.d.ts +1 -1
- package/dist/lib/config/index.js +1 -1
- package/dist/lib/config/resolve.d.ts +7 -0
- package/dist/lib/config/resolve.js +23 -0
- package/dist/lib/research/browser.d.ts +12 -0
- package/dist/lib/research/browser.js +40 -3
- package/dist/lib/research/docs/capabilities.d.ts +1 -1
- package/dist/lib/research/docs/discovery-index.d.ts +14 -0
- package/dist/lib/research/docs/discovery-index.js +112 -0
- package/dist/lib/research/docs/machine-readable.d.ts +2 -2
- package/dist/lib/research/docs/machine-readable.js +3 -7
- package/dist/lib/research/docs/remote-search-runner.js +61 -1
- package/dist/lib/research/docs/sections.d.ts +1 -0
- package/dist/lib/research/docs/sections.js +19 -1
- package/dist/lib/research/freshness.d.ts +9 -1
- package/dist/lib/research/freshness.js +24 -2
- package/dist/lib/research/keywords.d.ts +10 -3
- package/dist/lib/research/keywords.js +32 -5
- package/dist/lib/research/secret-scan.js +4 -2
- package/dist/lib/research/url.js +5 -2
- package/dist/lib/text.d.ts +33 -0
- package/dist/lib/text.js +63 -0
- package/oclif.manifest.json +8 -8
- package/package.json +17 -3
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { ConfigCommand, configScopeFlags } from './base.js';
|
|
3
|
-
import { writeUserConfig, writeProjectConfig } from '../../lib/config/index.js';
|
|
3
|
+
import { writeUserConfig, writeProjectConfig, validKeysHint } from '../../lib/config/index.js';
|
|
4
4
|
export default class ConfigUnset extends ConfigCommand {
|
|
5
5
|
static id = 'config unset';
|
|
6
6
|
static summary = 'Remove a research configuration key.';
|
|
7
|
-
static description =
|
|
7
|
+
static description = `Delete a key from user-level config (default) or project-level config (--local), restoring the built-in default.\n\nValid keys: ${validKeysHint()}.`;
|
|
8
8
|
static examples = [
|
|
9
9
|
{
|
|
10
10
|
description: 'Remove the user-level storage setting',
|
package/dist/commands/fetch.d.ts
CHANGED
|
@@ -30,8 +30,13 @@ export default class FetchCommand extends BaseCommand<typeof FetchCommand> {
|
|
|
30
30
|
private executeCacheMiss;
|
|
31
31
|
private buildResultData;
|
|
32
32
|
private validateDurationFlags;
|
|
33
|
+
private emitFetchError;
|
|
33
34
|
private resolveArtifact;
|
|
34
35
|
private persistFreshArtifact;
|
|
35
36
|
private persistSectionsIfFresh;
|
|
36
37
|
run(): Promise<unknown>;
|
|
37
38
|
}
|
|
39
|
+
export declare function fetchFailureGuidance(message: string, url: string): {
|
|
40
|
+
suggestions: string[];
|
|
41
|
+
ref?: string;
|
|
42
|
+
} | undefined;
|
package/dist/commands/fetch.js
CHANGED
|
@@ -6,7 +6,7 @@ import { BaseCommand } from '../base-command.js';
|
|
|
6
6
|
import { writeArtifact, getArtifactPath } from '../lib/research/storage.js';
|
|
7
7
|
import { writeArtifactSecurely } from '../lib/research/secure-write.js';
|
|
8
8
|
import { loadSummaryLevel } from '../lib/config/index.js';
|
|
9
|
-
import { evaluateFreshness,
|
|
9
|
+
import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
|
|
10
10
|
import { revalidateCache, createArtifactFromFetch } from '../lib/research/revalidate.js';
|
|
11
11
|
import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
|
|
12
12
|
import { fetchRenderedHtml } from '../lib/research/browser.js';
|
|
@@ -24,7 +24,7 @@ export default class FetchCommand extends BaseCommand {
|
|
|
24
24
|
static id = 'fetch';
|
|
25
25
|
static hidden = true;
|
|
26
26
|
static summary = 'An advanced, locally cached web research tool optimized for LLM ingestion.';
|
|
27
|
-
static description = 'Scrapes the specified URL, strips HTML boilerplate, converts the semantic payload into clean Markdown format, and caches the result locally using dynamic TTL rules
|
|
27
|
+
static description = 'Scrapes the specified URL, strips HTML boilerplate, converts the semantic payload into clean Markdown format, and caches the result locally using dynamic TTL rules.\n\nUsually invoked via the shorthand `bonsai <url>` rather than `bonsai fetch <url>`.';
|
|
28
28
|
static examples = [
|
|
29
29
|
{
|
|
30
30
|
description: 'Research a URL with detailed output, tagged with topic and tags',
|
|
@@ -64,7 +64,7 @@ export default class FetchCommand extends BaseCommand {
|
|
|
64
64
|
})(),
|
|
65
65
|
ttl: Flags.string({
|
|
66
66
|
char: 'l',
|
|
67
|
-
description: 'Predicted lifespan
|
|
67
|
+
description: 'Predicted lifespan: number + h/d/w/m/y (m = months), e.g. "2h", "7d", "6m".',
|
|
68
68
|
}),
|
|
69
69
|
'max-age': Flags.string({
|
|
70
70
|
description: 'Maximum age of cache to accept (e.g., "1d", "30d").',
|
|
@@ -183,20 +183,22 @@ export default class FetchCommand extends BaseCommand {
|
|
|
183
183
|
}
|
|
184
184
|
// Validates the duration flags up front, exiting with code 2 on a malformed value.
|
|
185
185
|
validateDurationFlags(ttl, maxAge) {
|
|
186
|
-
for (const [
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
]) {
|
|
190
|
-
if (!value)
|
|
191
|
-
continue;
|
|
192
|
-
try {
|
|
193
|
-
parseTtlToMs(value);
|
|
194
|
-
}
|
|
195
|
-
catch (err) {
|
|
196
|
-
this.error(`Invalid ${label}: ${err.message}`, { exit: 2 });
|
|
197
|
-
}
|
|
186
|
+
for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
|
|
187
|
+
if (msg)
|
|
188
|
+
this.error(msg, { exit: 2 });
|
|
198
189
|
}
|
|
199
190
|
}
|
|
191
|
+
// Re-emit a runtime fetch failure with actionable next steps. Deep fetch/extract code throws plain
|
|
192
|
+
// Errors that otherwise reach the user as a bare "what broke" line with no "what to do"; this
|
|
193
|
+
// attaches recovery hints (e.g. import an auth-blocked page). Everything reaching here is a runtime
|
|
194
|
+
// failure — validation errors (bad flags/URL) exit before the try — so the contract's runtime code
|
|
195
|
+
// (1) applies. Suggestions render on stderr for humans only; under --json the envelope carries just
|
|
196
|
+
// the message (toErrorJson), so machine output is unchanged.
|
|
197
|
+
emitFetchError(err, url) {
|
|
198
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
199
|
+
const guidance = fetchFailureGuidance(message, url);
|
|
200
|
+
this.error(message, { exit: 1, suggestions: guidance?.suggestions, ref: guidance?.ref });
|
|
201
|
+
}
|
|
200
202
|
// Serves the cache when a fresh/revalidatable entry exists, otherwise fetches fresh. Reads fall
|
|
201
203
|
// back project→global; the resolved artifact is returned with the dir it lives in / landed in.
|
|
202
204
|
async resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel) {
|
|
@@ -210,9 +212,12 @@ export default class FetchCommand extends BaseCommand {
|
|
|
210
212
|
}
|
|
211
213
|
const artifact = await this.executeCacheMiss(normalizedUrl, currentTime, cacheKey, summaryLevel);
|
|
212
214
|
const { dir, redirectedToGlobal } = this.persistFreshArtifact(roots, tmpDir, cacheKey, artifact);
|
|
215
|
+
// No entry existed at lookup, so there is no prior freshness to report. 'none' (not
|
|
216
|
+
// 'stale_expired') keeps the field honest — nothing expired; the page was simply uncached and
|
|
217
|
+
// has now been fetched fresh. Mirrors the `status` command's miss reporting.
|
|
213
218
|
return {
|
|
214
219
|
cacheStatus: 'miss',
|
|
215
|
-
freshnessState: '
|
|
220
|
+
freshnessState: 'none',
|
|
216
221
|
artifact,
|
|
217
222
|
storageDir: dir,
|
|
218
223
|
redirectedToGlobal,
|
|
@@ -277,6 +282,13 @@ export default class FetchCommand extends BaseCommand {
|
|
|
277
282
|
}
|
|
278
283
|
return resultData;
|
|
279
284
|
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
// Stale-serve (exit 5) signals via process.exitCode and never throws, so it bypasses this
|
|
287
|
+
// path; only genuine fetch/extract failures land here and get classified guidance. Use the
|
|
288
|
+
// normalized URL in hints so the copy-paste command is canonical (and never echoes raw,
|
|
289
|
+
// unsanitized user input back to the terminal).
|
|
290
|
+
this.emitFetchError(err, normalizedUrl);
|
|
291
|
+
}
|
|
280
292
|
finally {
|
|
281
293
|
if (tmpDir) {
|
|
282
294
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
@@ -284,6 +296,59 @@ export default class FetchCommand extends BaseCommand {
|
|
|
284
296
|
}
|
|
285
297
|
}
|
|
286
298
|
}
|
|
299
|
+
// Maps a runtime fetch failure to recovery steps keyed off its message. Returns undefined for
|
|
300
|
+
// unrecognized failures, which then surface with their original message and no extra hint. The
|
|
301
|
+
// patterns match the thrown text in fetcher.ts/browser.ts; keep them in sync if those messages
|
|
302
|
+
// change. Resolutions mirror the published troubleshooting guide.
|
|
303
|
+
export function fetchFailureGuidance(message, url) {
|
|
304
|
+
const ref = 'https://taurgis.github.io/bonsai/troubleshooting';
|
|
305
|
+
// Name the pipe step explicitly: bare `import --stdin` blocks waiting for input, so a reader (or
|
|
306
|
+
// an agent) running the hint verbatim would hang. Show the content being piped in from a file.
|
|
307
|
+
const importHint = `Open it in a browser, save the page, then import it: cat page.md | bonsai import ${url} --stdin`;
|
|
308
|
+
// 401/403: an auth wall or anti-scraping WAF. Bonsai has no authenticated-fetch path in v1.
|
|
309
|
+
if (/failed with status 40[13]\b/.test(message)) {
|
|
310
|
+
return {
|
|
311
|
+
suggestions: ['The page requires authentication or blocks automated requests.', importHint],
|
|
312
|
+
ref,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (/failed with status 404\b/.test(message)) {
|
|
316
|
+
return { suggestions: ['Check the URL is correct and the page still exists.'] };
|
|
317
|
+
}
|
|
318
|
+
if (/failed with status 5\d\d\b/.test(message)) {
|
|
319
|
+
return {
|
|
320
|
+
suggestions: ['The server returned an error. Retry later or verify the host is healthy.'],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
// Server returned a non-HTML body (JSON, binary, or no Content-Type at all). Scope the opener to
|
|
324
|
+
// the real failure: --rendered does produce HTML, so "only scrapes HTML" would read as misleading.
|
|
325
|
+
if (/Rejected content type|does not look like HTML/.test(message)) {
|
|
326
|
+
return {
|
|
327
|
+
suggestions: [
|
|
328
|
+
'The server returned a non-HTML response (e.g. JSON or binary).',
|
|
329
|
+
'If the page is rendered by client-side JavaScript, retry with --rendered.',
|
|
330
|
+
importHint,
|
|
331
|
+
],
|
|
332
|
+
ref,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (/DNS resolution failed/.test(message)) {
|
|
336
|
+
return {
|
|
337
|
+
suggestions: ['Check the hostname is spelled correctly and resolves on a public network.'],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
// A hostname that only resolves to a private/local IP at request time (literal private addresses
|
|
341
|
+
// are rejected earlier with exit 2). Blocked to prevent SSRF; only public hosts can be fetched.
|
|
342
|
+
if (/blocked local or private target/.test(message)) {
|
|
343
|
+
return {
|
|
344
|
+
suggestions: [
|
|
345
|
+
'The hostname resolves to a private or local address, which is blocked to prevent SSRF. Only public http(s) hosts can be fetched.',
|
|
346
|
+
],
|
|
347
|
+
ref,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
287
352
|
// Copies Phase 2 capability provenance from a capture outcome onto the artifact metadata.
|
|
288
353
|
function applyCaptureMetadata(artifact, capture) {
|
|
289
354
|
const meta = artifact.metadata;
|
|
@@ -23,6 +23,7 @@ export default class ResearchImport extends BaseCommand<typeof ResearchImport> {
|
|
|
23
23
|
storage: import("@oclif/core/interfaces").OptionFlag<"global" | "project" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
24
24
|
};
|
|
25
25
|
static stdoutIsPrimaryData: boolean;
|
|
26
|
+
protected stdinIsInteractive(): boolean;
|
|
26
27
|
private readStdin;
|
|
27
28
|
private getSourceUrls;
|
|
28
29
|
private validateSourceMode;
|
package/dist/commands/import.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Args, Flags } from '@oclif/core';
|
|
1
|
+
import { Args, Errors, Flags } from '@oclif/core';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
4
|
import * as path from 'node:path';
|
|
@@ -34,6 +34,10 @@ export default class ResearchImport extends BaseCommand {
|
|
|
34
34
|
static args = {
|
|
35
35
|
url: Args.string({
|
|
36
36
|
required: false,
|
|
37
|
+
// oclif fills an omitted optional arg from piped stdin unless ignoreStdin is set. Without
|
|
38
|
+
// this, the Markdown piped for `--stdin` is swallowed into `url`, making multi-source import
|
|
39
|
+
// (`import --stdin --source-url ...`) wrongly look like it also got a positional URL.
|
|
40
|
+
ignoreStdin: true,
|
|
37
41
|
description: 'The single source URL of the page (only for single-source import).',
|
|
38
42
|
}),
|
|
39
43
|
};
|
|
@@ -78,6 +82,11 @@ export default class ResearchImport extends BaseCommand {
|
|
|
78
82
|
})(),
|
|
79
83
|
};
|
|
80
84
|
static stdoutIsPrimaryData = true;
|
|
85
|
+
// Isolated so tests can force the interactive branch; `isTTY` is `true` only on a real terminal and
|
|
86
|
+
// falsy (undefined) for pipes, files, and /dev/null. See https://nodejs.org/api/process.html#processstdin
|
|
87
|
+
stdinIsInteractive() {
|
|
88
|
+
return process.stdin.isTTY === true;
|
|
89
|
+
}
|
|
81
90
|
async readStdin(limitBytes = 1024 * 1024) {
|
|
82
91
|
return new Promise((resolve, reject) => {
|
|
83
92
|
let data = '';
|
|
@@ -164,16 +173,34 @@ export default class ResearchImport extends BaseCommand {
|
|
|
164
173
|
}
|
|
165
174
|
async readAndValidateInput() {
|
|
166
175
|
if (this.flags.stdin) {
|
|
176
|
+
// On an interactive terminal with nothing piped, reading stdin blocks forever waiting for EOF —
|
|
177
|
+
// a silent hang with no feedback. Fail fast with a usage error instead; piped/redirected input
|
|
178
|
+
// (the agent and CI path) has isTTY falsy and reads normally.
|
|
179
|
+
if (this.stdinIsInteractive()) {
|
|
180
|
+
this.error('No data piped to --stdin.', {
|
|
181
|
+
exit: 2,
|
|
182
|
+
suggestions: [
|
|
183
|
+
'Pipe Markdown content, e.g. cat notes.md | bonsai import <url> --stdin',
|
|
184
|
+
'Or read from a file: bonsai import <url> --file notes.md',
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
// Initialized so the post-catch read below never depends on this.error()'s `never` return type
|
|
189
|
+
// for definite assignment; the empty-content guard rejects '' as a usage error anyway.
|
|
190
|
+
let rawInput = '';
|
|
167
191
|
try {
|
|
168
|
-
|
|
169
|
-
if (!rawInput.trim()) {
|
|
170
|
-
this.error('Empty stdin content provided.', { exit: 2 });
|
|
171
|
-
}
|
|
172
|
-
return rawInput;
|
|
192
|
+
rawInput = await this.readStdin();
|
|
173
193
|
}
|
|
174
194
|
catch (err) {
|
|
195
|
+
// Genuine stream/size failure reading stdin → I/O failure (exit 1).
|
|
175
196
|
this.error(err.message, { exit: 1 });
|
|
176
197
|
}
|
|
198
|
+
// Empty input is a usage error (exit 2), not an I/O failure — keep it out of the
|
|
199
|
+
// catch above so its exit code is not clobbered to 1.
|
|
200
|
+
if (!rawInput.trim()) {
|
|
201
|
+
this.error('Empty stdin content provided.', { exit: 2 });
|
|
202
|
+
}
|
|
203
|
+
return rawInput;
|
|
177
204
|
}
|
|
178
205
|
if (this.flags.file) {
|
|
179
206
|
const filePath = path.resolve(this.flags.file);
|
|
@@ -181,6 +208,11 @@ export default class ResearchImport extends BaseCommand {
|
|
|
181
208
|
return this.readAndValidateFile(filePath);
|
|
182
209
|
}
|
|
183
210
|
catch (err) {
|
|
211
|
+
// readAndValidateFile raises usage errors (missing/not-a-file/empty) via this.error,
|
|
212
|
+
// which throw CLIErrors carrying their own exit code and message — preserve those.
|
|
213
|
+
// Only an unexpected read failure becomes a wrapped I/O failure (exit 1).
|
|
214
|
+
if (err instanceof Errors.CLIError)
|
|
215
|
+
throw err;
|
|
184
216
|
this.error(`Failed to read file: ${err.message}`, { exit: 1 });
|
|
185
217
|
}
|
|
186
218
|
}
|
package/dist/commands/list.js
CHANGED
|
@@ -3,6 +3,9 @@ import { BaseCommand } from '../base-command.js';
|
|
|
3
3
|
import { scanCacheDirs } from '../lib/research/storage.js';
|
|
4
4
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
5
5
|
import { evaluateFreshness } from '../lib/research/freshness.js';
|
|
6
|
+
import { resultListHeading, truncationNotice } from '../lib/text.js';
|
|
7
|
+
// Listings are ordered newest-first, so the truncation word is "first"; --limit caps at 100.
|
|
8
|
+
const LIST_LABELS = { noun: 'cached research', order: 'first', maxLimit: 100 };
|
|
6
9
|
export default class ResearchList extends BaseCommand {
|
|
7
10
|
static id = 'list';
|
|
8
11
|
static summary = 'List cached research artifacts by metadata filters.';
|
|
@@ -82,6 +85,12 @@ export default class ResearchList extends BaseCommand {
|
|
|
82
85
|
return scanCacheDirs(readRoots, (artifact, filePath) => {
|
|
83
86
|
if (artifact.metadata.status !== 'active')
|
|
84
87
|
return null;
|
|
88
|
+
// Section children are sub-chunks of a page, not artifacts a user "has" — they would flood the
|
|
89
|
+
// listing (one page yields dozens) and aren't in the documented source/research_note contract.
|
|
90
|
+
// They stay discoverable through `search` (which ranks them) and `inspect` (which lists a
|
|
91
|
+
// page's sections). `list` answers "what pages/notes do I have?", so keep it page-level.
|
|
92
|
+
if (artifact.metadata.artifact_type === 'section')
|
|
93
|
+
return null;
|
|
85
94
|
const freshness = evaluateFreshness(artifact.metadata, currentTime, null);
|
|
86
95
|
if (!this.matchesFilters(artifact.metadata, freshness))
|
|
87
96
|
return null;
|
|
@@ -101,14 +110,14 @@ export default class ResearchList extends BaseCommand {
|
|
|
101
110
|
};
|
|
102
111
|
});
|
|
103
112
|
}
|
|
104
|
-
logListResults(finalResults) {
|
|
113
|
+
logListResults(finalResults, totalMatched) {
|
|
105
114
|
if (this.jsonEnabled())
|
|
106
115
|
return;
|
|
107
116
|
if (finalResults.length === 0) {
|
|
108
117
|
this.log('No cached research entries found matching filters.');
|
|
109
118
|
return;
|
|
110
119
|
}
|
|
111
|
-
this.log(
|
|
120
|
+
this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
|
|
112
121
|
finalResults.forEach((res, index) => {
|
|
113
122
|
this.log(`${index + 1}. [${res.topic || 'No Topic'}] Key: ${res.cacheKey}`);
|
|
114
123
|
this.log(` Type: ${res.artifactType} | Freshness: ${res.freshness}`);
|
|
@@ -132,7 +141,13 @@ export default class ResearchList extends BaseCommand {
|
|
|
132
141
|
return timeB - timeA;
|
|
133
142
|
});
|
|
134
143
|
const finalResults = results.slice(0, this.flags.limit);
|
|
135
|
-
|
|
144
|
+
// Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
|
|
145
|
+
// always emits, even in --json) without touching the stdout envelope. Human mode already shows
|
|
146
|
+
// it in the heading, so only warn under --json.
|
|
147
|
+
const notice = truncationNotice(results.length, finalResults.length, LIST_LABELS);
|
|
148
|
+
if (notice && this.jsonEnabled())
|
|
149
|
+
this.warn(notice);
|
|
150
|
+
this.logListResults(finalResults, results.length);
|
|
136
151
|
return finalResults;
|
|
137
152
|
}
|
|
138
153
|
}
|
package/dist/commands/prune.js
CHANGED
|
@@ -4,7 +4,8 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { BaseCommand } from '../base-command.js';
|
|
5
5
|
import { scanCacheDir } from '../lib/research/storage.js';
|
|
6
6
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
7
|
-
import { parseTtlToMs } from '../lib/research/freshness.js';
|
|
7
|
+
import { parseTtlToMs, durationFlagError } from '../lib/research/freshness.js';
|
|
8
|
+
import { pluralize } from '../lib/text.js';
|
|
8
9
|
export default class ResearchPrune extends BaseCommand {
|
|
9
10
|
static id = 'prune';
|
|
10
11
|
static summary = 'Clean up old or inactive research cache entries.';
|
|
@@ -48,6 +49,15 @@ export default class ResearchPrune extends BaseCommand {
|
|
|
48
49
|
if (!this.flags['dry-run'] && !this.flags.yes) {
|
|
49
50
|
this.error('Safety check: Please specify --yes to confirm pruning, or --dry-run to list files that would be deleted.', { exit: 2 });
|
|
50
51
|
}
|
|
52
|
+
// Validate durations up front: scanCacheDir swallows per-file errors, so a malformed
|
|
53
|
+
// --older-than/--inactive would otherwise be silently ignored and report "0 entries".
|
|
54
|
+
for (const msg of [
|
|
55
|
+
durationFlagError('--older-than', this.flags['older-than']),
|
|
56
|
+
durationFlagError('--inactive', this.flags.inactive),
|
|
57
|
+
]) {
|
|
58
|
+
if (msg)
|
|
59
|
+
this.error(msg, { exit: 2 });
|
|
60
|
+
}
|
|
51
61
|
}
|
|
52
62
|
shouldPrune(meta, currentTime) {
|
|
53
63
|
const fetched = meta.fetched_at ? new Date(meta.fetched_at).getTime() : 0;
|
|
@@ -109,24 +119,30 @@ export default class ResearchPrune extends BaseCommand {
|
|
|
109
119
|
const filesToPrune = this.findPruneCandidates(roots.readRoots, currentTime);
|
|
110
120
|
const dryRun = this.flags['dry-run'];
|
|
111
121
|
const count = filesToPrune.length;
|
|
122
|
+
// Track deletions actually performed so the JSON envelope and human output agree even when an
|
|
123
|
+
// unlink fails (e.g. a permission error). Reporting candidate count as `prunedCount` would
|
|
124
|
+
// overstate success and mislead an agent branching on the result.
|
|
125
|
+
let prunedCount = 0;
|
|
112
126
|
if (dryRun) {
|
|
113
127
|
if (!this.jsonEnabled()) {
|
|
114
|
-
|
|
128
|
+
const noun = pluralize(count, 'entry', 'entries');
|
|
129
|
+
this.log(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`);
|
|
115
130
|
filesToPrune.forEach((f) => {
|
|
116
131
|
this.log(`- [${f.topic || 'No Topic'}] Key: ${f.cacheKey} (${f.url || 'Imported note'})`);
|
|
117
132
|
});
|
|
118
133
|
}
|
|
119
134
|
}
|
|
120
135
|
else {
|
|
121
|
-
|
|
136
|
+
prunedCount = this.deletePruneCandidates(filesToPrune);
|
|
122
137
|
if (!this.jsonEnabled()) {
|
|
123
|
-
|
|
138
|
+
const noun = pluralize(count, 'entry', 'entries');
|
|
139
|
+
this.log(`Successfully pruned ${prunedCount} of ${count} research cache ${noun}.`);
|
|
124
140
|
}
|
|
125
141
|
}
|
|
126
142
|
return {
|
|
127
143
|
dryRun,
|
|
128
|
-
prunedCount
|
|
129
|
-
candidateCount:
|
|
144
|
+
prunedCount,
|
|
145
|
+
candidateCount: count,
|
|
130
146
|
files: filesToPrune.map((f) => ({
|
|
131
147
|
cacheKey: f.cacheKey,
|
|
132
148
|
path: f.path,
|
package/dist/commands/search.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
3
|
import { loadSearchableArtifacts } from '../lib/research/search-index.js';
|
|
4
|
+
import { levenshtein, resultListHeading, truncationNotice, } from '../lib/text.js';
|
|
4
5
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
5
6
|
import { evaluateFreshness } from '../lib/research/freshness.js';
|
|
6
7
|
import { detectSite } from '../sites/index.js';
|
|
7
8
|
import { fetchStaticHtml, fetchText, postJson } from '../lib/research/fetcher.js';
|
|
8
9
|
import { runRemoteDocsSearch, } from '../lib/research/docs/remote-search-runner.js';
|
|
10
|
+
// Results are ranked, so the truncation word is "top"; --limit caps at 50.
|
|
11
|
+
const SEARCH_LABELS = {
|
|
12
|
+
noun: 'matching cached research',
|
|
13
|
+
order: 'top',
|
|
14
|
+
maxLimit: 50,
|
|
15
|
+
};
|
|
9
16
|
const REMOTE_SEARCH_DEPS = {
|
|
10
17
|
fetchStatic: async (url) => {
|
|
11
18
|
const res = await fetchStaticHtml(url);
|
|
@@ -54,7 +61,7 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
54
61
|
description: 'Search a documentation site directly (e.g. help.salesforce.com) instead of the local cache. Requires a site module that implements search.',
|
|
55
62
|
}),
|
|
56
63
|
remote: Flags.string({
|
|
57
|
-
description: 'Discover uncached docs via a site’s public search (Algolia DocSearch, MkDocs/Sphinx/Just-the-Docs
|
|
64
|
+
description: 'Discover uncached docs via a site’s public search or index (Algolia DocSearch, MkDocs/Sphinx/Just-the-Docs, llms.txt, sitemap.xml). Pass a docs page URL; falls back to local cache search if no connector applies.',
|
|
58
65
|
}),
|
|
59
66
|
};
|
|
60
67
|
static stdoutIsPrimaryData = true;
|
|
@@ -182,10 +189,14 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
182
189
|
}
|
|
183
190
|
return results;
|
|
184
191
|
}
|
|
185
|
-
logSearchResults(finalResults) {
|
|
192
|
+
logSearchResults(finalResults, totalMatched) {
|
|
186
193
|
if (this.jsonEnabled())
|
|
187
194
|
return;
|
|
188
|
-
|
|
195
|
+
if (finalResults.length === 0) {
|
|
196
|
+
this.log('No matching cached research entries found.');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.log(`${resultListHeading(totalMatched, finalResults.length, SEARCH_LABELS)}\n`);
|
|
189
200
|
finalResults.forEach((res, index) => {
|
|
190
201
|
this.log(`${index + 1}. [${res.topic || 'No Topic'}] Score: ${res.score}`);
|
|
191
202
|
this.log(` Cache Key: ${res.cacheKey}`);
|
|
@@ -254,7 +265,13 @@ export default class ResearchSearch extends BaseCommand {
|
|
|
254
265
|
const results = this.scanCacheDirForResults(roots.readRoots, queryTerms, currentTime);
|
|
255
266
|
results.sort((a, b) => b.score - a.score);
|
|
256
267
|
const finalResults = results.slice(0, this.flags.limit);
|
|
257
|
-
|
|
268
|
+
// Under --json the human heading is suppressed, so surface truncation on stderr instead (warn
|
|
269
|
+
// always emits, even in --json) without touching the stdout envelope. Human mode already shows
|
|
270
|
+
// it in the heading, so only warn under --json.
|
|
271
|
+
const notice = truncationNotice(results.length, finalResults.length, SEARCH_LABELS);
|
|
272
|
+
if (notice && this.jsonEnabled())
|
|
273
|
+
this.warn(notice);
|
|
274
|
+
this.logSearchResults(finalResults, results.length);
|
|
258
275
|
return finalResults;
|
|
259
276
|
}
|
|
260
277
|
}
|
|
@@ -305,24 +322,6 @@ function scoreSingleTerm(term, topic, tags, sourceUrl, sourceUrls, summ, comp) {
|
|
|
305
322
|
}
|
|
306
323
|
return termScore;
|
|
307
324
|
}
|
|
308
|
-
function levenshtein(s1, s2) {
|
|
309
|
-
if (s1.length < s2.length)
|
|
310
|
-
return levenshtein(s2, s1);
|
|
311
|
-
if (s2.length === 0)
|
|
312
|
-
return s1.length;
|
|
313
|
-
let prevRow = Array.from({ length: s2.length + 1 }, (_, i) => i);
|
|
314
|
-
for (let i = 0; i < s1.length; i++) {
|
|
315
|
-
const currRow = [i + 1];
|
|
316
|
-
for (let j = 0; j < s2.length; j++) {
|
|
317
|
-
const deletions = prevRow[j + 1] + 1;
|
|
318
|
-
const insertions = currRow[j] + 1;
|
|
319
|
-
const substitutions = prevRow[j] + (s1[i] === s2[j] ? 0 : 1);
|
|
320
|
-
currRow.push(Math.min(deletions, insertions, substitutions));
|
|
321
|
-
}
|
|
322
|
-
prevRow = currRow;
|
|
323
|
-
}
|
|
324
|
-
return prevRow[s2.length];
|
|
325
|
-
}
|
|
326
325
|
function isFuzzyMatch(term, target) {
|
|
327
326
|
if (term.length < 4 || target.length < 4)
|
|
328
327
|
return false;
|
package/dist/commands/status.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
3
|
import { getArtifactPath } from '../lib/research/storage.js';
|
|
4
|
-
import { evaluateFreshness, checkMaxAgeExpired } from '../lib/research/freshness.js';
|
|
4
|
+
import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../lib/research/freshness.js';
|
|
5
5
|
import { resolveResearchTarget } from '../lib/research/resolve-target.js';
|
|
6
6
|
function resolveFreshness(cached, currentTime, ttl, maxAge) {
|
|
7
7
|
const isMaxAgeExpired = maxAge ? checkMaxAgeExpired(cached, currentTime, maxAge) : false;
|
|
@@ -11,7 +11,7 @@ function resolveFreshness(cached, currentTime, ttl, maxAge) {
|
|
|
11
11
|
}
|
|
12
12
|
function describeCacheStatus(cached, currentTime, ttl, maxAge) {
|
|
13
13
|
if (!cached) {
|
|
14
|
-
return { status: 'miss', freshness: '
|
|
14
|
+
return { status: 'miss', freshness: 'none', action: 'would_fetch' };
|
|
15
15
|
}
|
|
16
16
|
const freshness = resolveFreshness(cached, currentTime, ttl, maxAge);
|
|
17
17
|
return freshness === 'fresh'
|
|
@@ -46,6 +46,12 @@ export default class ResearchStatus extends BaseCommand {
|
|
|
46
46
|
async run() {
|
|
47
47
|
const { url } = this.args;
|
|
48
48
|
const { ttl, tier, 'max-age': maxAge } = this.flags;
|
|
49
|
+
// Validate the duration flags up front so a malformed value reports the exact flag that is
|
|
50
|
+
// wrong, rather than later surfacing as a misattributed parse failure.
|
|
51
|
+
for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
|
|
52
|
+
if (msg)
|
|
53
|
+
this.error(msg, { exit: 2 });
|
|
54
|
+
}
|
|
49
55
|
let target;
|
|
50
56
|
try {
|
|
51
57
|
target = resolveResearchTarget({
|
|
@@ -61,13 +67,8 @@ export default class ResearchStatus extends BaseCommand {
|
|
|
61
67
|
const { cacheKey, located, normalizedUrl, roots } = target;
|
|
62
68
|
const cached = located?.artifact ?? null;
|
|
63
69
|
const currentTime = new Date();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
result = describeCacheStatus(cached, currentTime, ttl, maxAge);
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
69
|
-
this.error(`Invalid max-age: ${err.message}`, { exit: 2 });
|
|
70
|
-
}
|
|
70
|
+
// Both duration flags were validated above, so freshness resolution cannot throw on a parse error.
|
|
71
|
+
const result = describeCacheStatus(cached, currentTime, ttl, maxAge);
|
|
71
72
|
// On a hit, report where it actually lives; on a miss, where a fetch would write it.
|
|
72
73
|
const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
|
|
73
74
|
if (!this.jsonEnabled()) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Hook } from '@oclif/core';
|
|
2
|
+
/**
|
|
3
|
+
* Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
|
|
4
|
+
* throwing here (via `this.error`) replaces that default with a more helpful message. A correction
|
|
5
|
+
* is offered only when the nearest command is a close match, and it is never auto-run — the user
|
|
6
|
+
* re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
|
|
7
|
+
*/
|
|
8
|
+
declare const hook: Hook<'command_not_found'>;
|
|
9
|
+
export default hook;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { toConfiguredId } from '@oclif/core';
|
|
2
|
+
import { closestMatch } from '../../lib/text.js';
|
|
3
|
+
// Nearest command must be within this edit distance to be offered as a correction. Bonsai's command
|
|
4
|
+
// ids are short, so 3 catches realistic typos ("statuss", "improt") without suggesting an unrelated
|
|
5
|
+
// command for input that resembles nothing.
|
|
6
|
+
const MAX_SUGGESTION_DISTANCE = 3;
|
|
7
|
+
/**
|
|
8
|
+
* The nearest visible command to a typo, plus how many leading segments of the attempted id name
|
|
9
|
+
* the command. With `topicSeparator: ' '`, oclif folds every leading token of an unknown command
|
|
10
|
+
* into one colon-delimited id, so `bonsai serch hello` arrives as `serch:hello`. Matching that whole
|
|
11
|
+
* string never finds a short command, so walk prefixes longest→shortest and suggest off the closest:
|
|
12
|
+
* `serch:hello` resolves to nothing, but its prefix `serch` resolves to `search`. The matched prefix
|
|
13
|
+
* length doubles as the command-segment count, so a folded-in positional arg is dropped from display.
|
|
14
|
+
*/
|
|
15
|
+
function findSuggestion(segments, visibleIds) {
|
|
16
|
+
for (let n = segments.length; n >= 1; n--) {
|
|
17
|
+
const suggestion = closestMatch(segments.slice(0, n).join(':'), visibleIds, MAX_SUGGESTION_DISTANCE);
|
|
18
|
+
if (suggestion)
|
|
19
|
+
return { suggestion, commandSegments: n };
|
|
20
|
+
}
|
|
21
|
+
return { suggestion: null, commandSegments: 0 };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* How many leading segments name the command when no suggestion exists: the known-topic chain plus
|
|
25
|
+
* the first unknown segment. Keeps `config frobnicate` from rendering as the misleading bare `config`
|
|
26
|
+
* (a real topic) while still dropping a positional arg from `frobnicate banana`.
|
|
27
|
+
*/
|
|
28
|
+
function topicChainLength(segments, config) {
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < segments.length - 1 && config.findTopic(segments.slice(0, i + 1).join(':')))
|
|
31
|
+
i++;
|
|
32
|
+
return i + 1;
|
|
33
|
+
}
|
|
34
|
+
// Dot-separated, non-empty labels: a real domain (`docs.nestjs.com`) or IPv4 (`192.168.1.1`). The
|
|
35
|
+
// URL parser also accepts a leading-dot filename (`.env`) or a relative path (`./x`) as a "host",
|
|
36
|
+
// whose suggestion would be a nonsense `https://.env`; requiring well-formed labels rejects those.
|
|
37
|
+
// Anchored with no overlapping quantifiers, so it is ReDoS-safe on the (already length-bounded) host.
|
|
38
|
+
const DOMAIN_OR_IPV4 = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
|
39
|
+
/**
|
|
40
|
+
* The input as-typed when it looks like a URL missing its scheme (`example.com`,
|
|
41
|
+
* `docs.nestjs.com/guide`), else null. `bonsai <url>` is the headline command, but bin/cli.mjs only
|
|
42
|
+
* routes args carrying a `://` scheme to the fetch shorthand — so a scheme-less URL falls through to
|
|
43
|
+
* `command_not_found`. A domain-shaped host separates a forgotten-scheme URL from an ordinary command
|
|
44
|
+
* typo: `statuss` has no dot and never matches, while `config:frobnicate` fails to parse (the second
|
|
45
|
+
* segment becomes a non-numeric port), so neither is misread as a URL.
|
|
46
|
+
*/
|
|
47
|
+
function bareUrlInput(id) {
|
|
48
|
+
if (id.includes('://'))
|
|
49
|
+
return null;
|
|
50
|
+
let hostname;
|
|
51
|
+
try {
|
|
52
|
+
hostname = new URL(`https://${id}`).hostname;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return DOMAIN_OR_IPV4.test(hostname) ? id : null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Typo-aware "command not found" handler. oclif fires this before printing its own terse error;
|
|
61
|
+
* throwing here (via `this.error`) replaces that default with a more helpful message. A correction
|
|
62
|
+
* is offered only when the nearest command is a close match, and it is never auto-run — the user
|
|
63
|
+
* re-runs the corrected command themselves, so they learn the right syntax (clig.dev guidance).
|
|
64
|
+
*/
|
|
65
|
+
const hook = async function (opts) {
|
|
66
|
+
const hiddenIds = new Set(opts.config.commands.filter((c) => c.hidden).map((c) => c.id));
|
|
67
|
+
const visibleIds = opts.config.commandIDs.filter((id) => !hiddenIds.has(id));
|
|
68
|
+
// A scheme-less URL is the most common "not a command" mistake for this CLI, so steer the user to
|
|
69
|
+
// the `bonsai <url>` shorthand with a scheme before falling back to nearest-command matching (which
|
|
70
|
+
// never finds a command for a hostname). The correction is shown, never auto-run (clig.dev).
|
|
71
|
+
const bareUrl = bareUrlInput(opts.id);
|
|
72
|
+
if (bareUrl) {
|
|
73
|
+
this.error([
|
|
74
|
+
`${bareUrl} is not a ${opts.config.bin} command.`,
|
|
75
|
+
`Run ${opts.config.bin} help for a list of available commands.`,
|
|
76
|
+
`Did you mean ${opts.config.bin} https://${bareUrl}? URLs need an http:// or https:// scheme.`,
|
|
77
|
+
].join('\n'), { exit: 2 });
|
|
78
|
+
}
|
|
79
|
+
const segments = opts.id.split(':');
|
|
80
|
+
const { suggestion, commandSegments } = findSuggestion(segments, visibleIds);
|
|
81
|
+
// Show only the segments that name the command, never the positional args oclif folded into the id
|
|
82
|
+
// (which would otherwise glue the arg on and turn `://` into ` //`).
|
|
83
|
+
const displaySegments = commandSegments || topicChainLength(segments, opts.config);
|
|
84
|
+
const attempted = toConfiguredId(segments.slice(0, displaySegments).join(':'), opts.config);
|
|
85
|
+
// Order so the most actionable line lands last, where the eye rests (clig.dev): the help pointer
|
|
86
|
+
// is the floor, and a concrete "Did you mean …?" correction — when we have one — goes below it.
|
|
87
|
+
const lines = [`${attempted} is not a ${opts.config.bin} command.`];
|
|
88
|
+
lines.push(`Run ${opts.config.bin} help for a list of available commands.`);
|
|
89
|
+
if (suggestion)
|
|
90
|
+
lines.push(`Did you mean ${toConfiguredId(suggestion, opts.config)}?`);
|
|
91
|
+
this.error(lines.join('\n'), { exit: 2 });
|
|
92
|
+
};
|
|
93
|
+
export default hook;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type SummaryLevel, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
2
2
|
export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
|
|
3
|
-
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
|
|
3
|
+
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, } from './resolve.js';
|
|
4
4
|
import type { ConfigValues, ResolvedConfig, StorageMode, SummaryLevel } from './schema.js';
|
|
5
5
|
/**
|
|
6
6
|
* Read both config files once and resolve the effective storage mode.
|
package/dist/lib/config/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
|
|
2
2
|
export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
|
|
3
|
-
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
|
|
3
|
+
export { resolveStorageMode, resolveSummaryLevel, parseEnvStorage, parseEnvSummary, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, } from './resolve.js';
|
|
4
4
|
import { readUserConfig, readProjectConfig } from './io.js';
|
|
5
5
|
import { resolveStorageMode, resolveSummaryLevel } from './resolve.js';
|
|
6
6
|
/**
|