@taurgis/bonsai 1.0.5 → 1.0.6
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 +53 -0
- package/agents/agents/official-docs-researcher.agent.md +10 -1
- package/agents/agents/salesforce-docs-researcher.agent.md +10 -1
- package/agents/instructions/salesforce-research.instructions.md +4 -2
- package/agents/instructions/web-research.instructions.md +4 -2
- package/agents/skills/web-research/SKILL.md +8 -3
- package/bin/cli.mjs +30 -13
- package/dist/base-command.d.ts +3 -0
- package/dist/base-command.js +40 -7
- package/dist/commands/config/base.d.ts +2 -0
- package/dist/commands/config/base.js +15 -1
- package/dist/commands/config/get.d.ts +1 -1
- package/dist/commands/config/get.js +4 -5
- package/dist/commands/config/index.d.ts +12 -0
- package/dist/commands/config/index.js +34 -0
- package/dist/commands/config/list.js +2 -2
- package/dist/commands/config/set.js +18 -12
- package/dist/commands/config/unset.d.ts +1 -1
- package/dist/commands/config/unset.js +9 -6
- package/dist/commands/fetch.js +44 -34
- package/dist/commands/import.d.ts +3 -0
- package/dist/commands/import.js +82 -52
- package/dist/commands/inspect.d.ts +4 -0
- package/dist/commands/inspect.js +24 -19
- package/dist/commands/list.d.ts +2 -2
- package/dist/commands/list.js +23 -15
- package/dist/commands/prune.d.ts +1 -1
- package/dist/commands/prune.js +30 -13
- package/dist/commands/search.d.ts +4 -0
- package/dist/commands/search.js +83 -21
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +50 -24
- package/dist/commands.d.ts +2 -0
- package/dist/commands.js +2 -0
- package/dist/hooks/command-not-found/suggest.js +37 -8
- package/dist/lib/argv.d.ts +10 -0
- package/dist/lib/argv.js +53 -0
- package/dist/lib/envelope.d.ts +36 -0
- package/dist/lib/envelope.js +76 -0
- package/dist/lib/json-meta.d.ts +10 -0
- package/dist/lib/json-meta.js +98 -0
- package/dist/lib/research/revalidate.js +3 -1
- package/dist/lib/research/schema.d.ts +5 -2
- package/dist/lib/research/schema.js +12 -1
- package/dist/lib/text.d.ts +2 -0
- package/dist/lib/text.js +2 -0
- package/dist/sites/algolia.d.ts +9 -0
- package/dist/sites/algolia.js +26 -0
- package/dist/sites/auto.d.ts +8 -0
- package/dist/sites/auto.js +30 -0
- package/dist/sites/index.js +17 -1
- package/dist/sites/nextjs/index.d.ts +2 -0
- package/dist/sites/nextjs/index.js +52 -0
- package/dist/sites/react/index.d.ts +2 -0
- package/dist/sites/react/index.js +17 -0
- package/dist/sites/tailwind/index.d.ts +2 -0
- package/dist/sites/tailwind/index.js +17 -0
- package/dist/sites/vue/index.d.ts +2 -0
- package/dist/sites/vue/index.js +17 -0
- package/oclif.manifest.json +149 -76
- package/package.json +8 -2
package/dist/commands/fetch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Args, Flags } from '@oclif/core';
|
|
1
|
+
import { Args, Flags, ux } from '@oclif/core';
|
|
2
2
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -12,7 +12,6 @@ import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
|
|
|
12
12
|
import { fetchRenderedHtml } from '../lib/research/browser.js';
|
|
13
13
|
import { capturePage } from '../lib/research/capture.js';
|
|
14
14
|
import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
|
|
15
|
-
import { resolveResearchTarget } from '../lib/research/resolve-target.js';
|
|
16
15
|
import { applyAutoTags } from '../lib/research/keywords.js';
|
|
17
16
|
import { detectSite } from '../sites/index.js';
|
|
18
17
|
const CAPTURE_DEPS = {
|
|
@@ -27,66 +26,66 @@ export default class FetchCommand extends BaseCommand {
|
|
|
27
26
|
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
27
|
static examples = [
|
|
29
28
|
{
|
|
30
|
-
description: '
|
|
29
|
+
description: 'research a URL with detailed output, tagged with topic and tags',
|
|
31
30
|
command: '<%= config.bin %> https://docs.nestjs.com/ --topic "Backend Frameworks" --tags "Node" --tags "NestJS" --format detailed --ttl 30d',
|
|
32
31
|
},
|
|
33
32
|
{
|
|
34
|
-
description: '
|
|
33
|
+
description: 'research a volatile page with compressed output and short TTL, returned as JSON',
|
|
35
34
|
command: '<%= config.bin %> https://news.ycombinator.com/ --format compressed --ttl 2h --json',
|
|
36
35
|
},
|
|
37
36
|
];
|
|
38
37
|
static args = {
|
|
39
38
|
url: Args.string({
|
|
40
39
|
required: true,
|
|
41
|
-
description: '
|
|
40
|
+
description: 'the full HTTP/HTTPS URL of the web page to research',
|
|
42
41
|
}),
|
|
43
42
|
};
|
|
44
43
|
static flags = {
|
|
45
44
|
topic: Flags.string({
|
|
46
45
|
char: 't',
|
|
47
|
-
description: '
|
|
46
|
+
description: 'the main category/topic of the research for metadata tagging',
|
|
48
47
|
}),
|
|
49
48
|
tags: Flags.string({
|
|
50
49
|
char: 'g',
|
|
51
|
-
description: '
|
|
50
|
+
description: 'taxonomic tags for this research (can be repeated)',
|
|
52
51
|
multiple: true,
|
|
53
52
|
}),
|
|
54
53
|
format: Flags.option({
|
|
55
54
|
char: 'f',
|
|
56
|
-
description: '
|
|
55
|
+
description: 'desired data density',
|
|
57
56
|
options: ['compressed', 'detailed'],
|
|
58
57
|
default: 'compressed',
|
|
59
58
|
})(),
|
|
60
59
|
tier: Flags.option({
|
|
61
|
-
description: '
|
|
60
|
+
description: 'freshness tier policy',
|
|
62
61
|
options: ['stable', 'standard', 'volatile'],
|
|
63
62
|
default: 'standard',
|
|
64
63
|
})(),
|
|
65
64
|
ttl: Flags.string({
|
|
66
65
|
char: 'l',
|
|
67
|
-
description: '
|
|
66
|
+
description: 'predicted lifespan: number + h/d/w/m/y (m = months), e.g. "2h", "7d", "6m"',
|
|
68
67
|
}),
|
|
69
68
|
'max-age': Flags.string({
|
|
70
|
-
description: '
|
|
69
|
+
description: 'maximum age of cache to accept (e.g., "1d", "30d")',
|
|
71
70
|
}),
|
|
72
71
|
force: Flags.boolean({
|
|
73
|
-
description: '
|
|
72
|
+
description: 'force a fresh fetch, ignoring any cached entries',
|
|
74
73
|
default: false,
|
|
75
74
|
}),
|
|
76
75
|
'dry-run': Flags.boolean({
|
|
77
|
-
description: '
|
|
76
|
+
description: 'perform validation and fetch without saving to local cache',
|
|
78
77
|
default: false,
|
|
79
78
|
}),
|
|
80
79
|
'allow-stale': Flags.boolean({
|
|
81
|
-
description: '
|
|
80
|
+
description: 'allow serving stale cache if the remote server is unreachable',
|
|
82
81
|
default: false,
|
|
83
82
|
}),
|
|
84
83
|
rendered: Flags.boolean({
|
|
85
|
-
description: '
|
|
84
|
+
description: 'force using a browser-rendered scraping path for dynamic pages',
|
|
86
85
|
default: false,
|
|
87
86
|
}),
|
|
88
87
|
storage: Flags.option({
|
|
89
|
-
description: '
|
|
88
|
+
description: 'override where this result is cached (secrets always stored globally)',
|
|
90
89
|
options: ['global', 'project'],
|
|
91
90
|
})(),
|
|
92
91
|
};
|
|
@@ -185,7 +184,7 @@ export default class FetchCommand extends BaseCommand {
|
|
|
185
184
|
validateDurationFlags(ttl, maxAge) {
|
|
186
185
|
for (const msg of [durationFlagError('--ttl', ttl), durationFlagError('--max-age', maxAge)]) {
|
|
187
186
|
if (msg)
|
|
188
|
-
this.error(msg, { exit: 2 });
|
|
187
|
+
this.error(msg, { exit: 2, code: 'INVALID_DURATION' });
|
|
189
188
|
}
|
|
190
189
|
}
|
|
191
190
|
// Re-emit a runtime fetch failure with actionable next steps. Deep fetch/extract code throws plain
|
|
@@ -197,7 +196,12 @@ export default class FetchCommand extends BaseCommand {
|
|
|
197
196
|
emitFetchError(err, url) {
|
|
198
197
|
const message = err instanceof Error ? err.message : String(err);
|
|
199
198
|
const guidance = fetchFailureGuidance(message, url);
|
|
200
|
-
this.error(message, {
|
|
199
|
+
this.error(message, {
|
|
200
|
+
exit: 1,
|
|
201
|
+
code: 'FETCH_FAILED',
|
|
202
|
+
suggestions: guidance?.suggestions,
|
|
203
|
+
ref: guidance?.ref,
|
|
204
|
+
});
|
|
201
205
|
}
|
|
202
206
|
// Serves the cache when a fresh/revalidatable entry exists, otherwise fetches fresh. Reads fall
|
|
203
207
|
// back project→global; the resolved artifact is returned with the dir it lives in / landed in.
|
|
@@ -254,27 +258,30 @@ export default class FetchCommand extends BaseCommand {
|
|
|
254
258
|
const { url } = this.args;
|
|
255
259
|
const { format, ttl, 'max-age': maxAge, 'dry-run': dryRun } = this.flags;
|
|
256
260
|
this.validateDurationFlags(ttl, maxAge);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
cwd: process.cwd(),
|
|
262
|
-
dataDir: this.config.dataDir,
|
|
263
|
-
flagOverride: this.flags.storage,
|
|
264
|
-
lookup: !this.flags.force,
|
|
265
|
-
url,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
catch (err) {
|
|
269
|
-
this.error(`Invalid URL: ${err.message}`, { exit: 2 });
|
|
270
|
-
}
|
|
261
|
+
const target = this.resolveResearchTargetOrFail(url, {
|
|
262
|
+
flagOverride: this.flags.storage,
|
|
263
|
+
lookup: !this.flags.force,
|
|
264
|
+
});
|
|
271
265
|
const { cacheKey, located, normalizedUrl, roots } = target;
|
|
272
266
|
const summaryLevel = loadSummaryLevel(this.config.configDir, process.cwd());
|
|
273
267
|
const tmpDir = dryRun ? mkdtempSync(join(tmpdir(), 'fnr-dry-run-')) : null;
|
|
274
268
|
const currentTime = new Date();
|
|
275
269
|
try {
|
|
270
|
+
if (!this.jsonEnabled()) {
|
|
271
|
+
ux.action.start('Fetching ' + normalizedUrl);
|
|
272
|
+
}
|
|
276
273
|
const { cacheStatus, freshnessState, artifact, storageDir, redirectedToGlobal } = await this.resolveArtifact(normalizedUrl, cacheKey, roots, tmpDir, currentTime, located, summaryLevel);
|
|
277
274
|
this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus, summaryLevel);
|
|
275
|
+
if (!this.jsonEnabled()) {
|
|
276
|
+
const CACHE_STATUS_LABEL = {
|
|
277
|
+
hit: 'cached',
|
|
278
|
+
miss: 'done',
|
|
279
|
+
refreshed: 'refreshed',
|
|
280
|
+
revalidated: 'revalidated',
|
|
281
|
+
stale: 'served stale',
|
|
282
|
+
};
|
|
283
|
+
ux.action.stop(CACHE_STATUS_LABEL[cacheStatus] ?? cacheStatus);
|
|
284
|
+
}
|
|
278
285
|
const content = format === 'compressed' ? artifact.compressed : artifact.detailed;
|
|
279
286
|
const resultData = this.buildResultData(url, normalizedUrl, cacheKey, storageDir, roots.mode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal);
|
|
280
287
|
if (!this.jsonEnabled()) {
|
|
@@ -283,6 +290,9 @@ export default class FetchCommand extends BaseCommand {
|
|
|
283
290
|
return resultData;
|
|
284
291
|
}
|
|
285
292
|
catch (err) {
|
|
293
|
+
if (!this.jsonEnabled()) {
|
|
294
|
+
ux.action.stop('failed');
|
|
295
|
+
}
|
|
286
296
|
// Stale-serve (exit 5) signals via process.exitCode and never throws, so it bypasses this
|
|
287
297
|
// path; only genuine fetch/extract failures land here and get classified guidance. Use the
|
|
288
298
|
// normalized URL in hints so the copy-paste command is canonical (and never echoes raw,
|
|
@@ -367,10 +377,10 @@ function handleStaleRevalidationResult(command, revalResult) {
|
|
|
367
377
|
if (revalResult.status !== 'stale')
|
|
368
378
|
return;
|
|
369
379
|
if (revalResult.allowed) {
|
|
370
|
-
command.warn(`Serving stale content: revalidation failed
|
|
380
|
+
command.warn(`Serving stale content within grace period: revalidation failed (${revalResult.error}).`);
|
|
371
381
|
}
|
|
372
382
|
else {
|
|
373
|
-
command.warn(`Serving stale content (exit 5): revalidation failed
|
|
383
|
+
command.warn(`Serving stale content within grace period (exit 5): revalidation failed (${revalResult.error}).`);
|
|
374
384
|
process.exitCode = 5;
|
|
375
385
|
}
|
|
376
386
|
}
|
|
@@ -30,6 +30,9 @@ export default class ResearchImport extends BaseCommand<typeof ResearchImport> {
|
|
|
30
30
|
protected fsExistsSync(filePath: string): boolean;
|
|
31
31
|
protected fsStatSync(filePath: string): fs.Stats;
|
|
32
32
|
protected fsReadFileSync(filePath: string): string;
|
|
33
|
+
private stdinImportSuggestions;
|
|
34
|
+
/** Non-interactive stdin that never receives data (open pipe with no writer) must not hang forever. */
|
|
35
|
+
private readStdinWithGuard;
|
|
33
36
|
private readAndValidateFile;
|
|
34
37
|
private readAndValidateInput;
|
|
35
38
|
private deriveImportCacheKey;
|
package/dist/commands/import.js
CHANGED
|
@@ -9,7 +9,7 @@ import { getArtifactPath } from '../lib/research/storage.js';
|
|
|
9
9
|
import { loadStoreRoots } from '../lib/research/store-roots.js';
|
|
10
10
|
import { writeArtifactSecurely } from '../lib/research/secure-write.js';
|
|
11
11
|
import { loadSummaryLevel } from '../lib/config/index.js';
|
|
12
|
-
import { getPolicy } from '../lib/research/freshness.js';
|
|
12
|
+
import { durationFlagError, getPolicy } from '../lib/research/freshness.js';
|
|
13
13
|
import { buildCompressed } from '../lib/research/compress.js';
|
|
14
14
|
import { applyAutoTags } from '../lib/research/keywords.js';
|
|
15
15
|
import { estimateTokens } from '../lib/research/token-estimate.js';
|
|
@@ -19,15 +19,15 @@ export default class ResearchImport extends BaseCommand {
|
|
|
19
19
|
static description = 'Accepts a synthesized Markdown note via stdin and registers it under the single target URL or multiple source URLs.';
|
|
20
20
|
static examples = [
|
|
21
21
|
{
|
|
22
|
-
description: '
|
|
22
|
+
description: 'import detailed research for a single URL via stdin',
|
|
23
23
|
command: 'echo "# My Article" | <%= config.bin %> import https://example.com/docs --stdin',
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
|
-
description: '
|
|
26
|
+
description: 'import research synthesized from multiple source URLs',
|
|
27
27
|
command: 'echo "# Synthesized" | <%= config.bin %> import --stdin --topic "React docs" --source-url https://react.dev/a --source-url https://react.dev/b',
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
|
-
description: '
|
|
30
|
+
description: 'import research from a local Markdown file',
|
|
31
31
|
command: '<%= config.bin %> import https://example.com/docs --file path/to/notes.md',
|
|
32
32
|
},
|
|
33
33
|
];
|
|
@@ -38,46 +38,46 @@ export default class ResearchImport extends BaseCommand {
|
|
|
38
38
|
// this, the Markdown piped for `--stdin` is swallowed into `url`, making multi-source import
|
|
39
39
|
// (`import --stdin --source-url ...`) wrongly look like it also got a positional URL.
|
|
40
40
|
ignoreStdin: true,
|
|
41
|
-
description: '
|
|
41
|
+
description: 'the single source URL of the page (only for single-source import)',
|
|
42
42
|
}),
|
|
43
43
|
};
|
|
44
44
|
static flags = {
|
|
45
45
|
stdin: Flags.boolean({
|
|
46
|
-
description: '
|
|
46
|
+
description: 'read Markdown from stdin',
|
|
47
47
|
default: false,
|
|
48
48
|
}),
|
|
49
49
|
file: Flags.string({
|
|
50
|
-
description: '
|
|
50
|
+
description: 'path to a Markdown file containing research notes to import',
|
|
51
51
|
}),
|
|
52
52
|
'input-format': Flags.option({
|
|
53
|
-
description: '
|
|
53
|
+
description: 'format of the input provided',
|
|
54
54
|
options: ['compressed', 'detailed'],
|
|
55
55
|
default: 'detailed',
|
|
56
56
|
})(),
|
|
57
57
|
topic: Flags.string({
|
|
58
58
|
char: 't',
|
|
59
|
-
description: '
|
|
59
|
+
description: 'the main topic for this research note',
|
|
60
60
|
}),
|
|
61
61
|
tags: Flags.string({
|
|
62
62
|
char: 'g',
|
|
63
|
-
description: '
|
|
63
|
+
description: 'taxonomic tags (can be repeated)',
|
|
64
64
|
multiple: true,
|
|
65
65
|
}),
|
|
66
66
|
tier: Flags.option({
|
|
67
|
-
description: '
|
|
67
|
+
description: 'freshness tier policy',
|
|
68
68
|
options: ['stable', 'standard', 'volatile'],
|
|
69
69
|
default: 'standard',
|
|
70
70
|
})(),
|
|
71
71
|
'source-url': Flags.string({
|
|
72
|
-
description: '
|
|
72
|
+
description: 'repeated source URLs for multi-source import',
|
|
73
73
|
multiple: true,
|
|
74
74
|
}),
|
|
75
75
|
ttl: Flags.string({
|
|
76
76
|
char: 'l',
|
|
77
|
-
description: '
|
|
77
|
+
description: 'predicted lifespan of the data (e.g. "24h", "7d")',
|
|
78
78
|
}),
|
|
79
79
|
storage: Flags.option({
|
|
80
|
-
description: '
|
|
80
|
+
description: 'override where this note is cached (secrets always stored globally)',
|
|
81
81
|
options: ['global', 'project'],
|
|
82
82
|
})(),
|
|
83
83
|
};
|
|
@@ -121,28 +121,38 @@ export default class ResearchImport extends BaseCommand {
|
|
|
121
121
|
return normalizeUrl(u);
|
|
122
122
|
}
|
|
123
123
|
catch (err) {
|
|
124
|
-
this.error(`Invalid URL: ${err.message}`, { exit: 2 });
|
|
124
|
+
this.error(`Invalid URL: ${err.message}`, { exit: 2, code: 'INVALID_URL' });
|
|
125
125
|
}
|
|
126
126
|
});
|
|
127
127
|
return hasMulti ? normalized.sort() : normalized;
|
|
128
128
|
}
|
|
129
129
|
validateSourceMode(hasSingle, hasMulti, multiUrls) {
|
|
130
130
|
if (hasSingle && hasMulti) {
|
|
131
|
-
this.error('Cannot specify both positional <url> and --source-url flags.', {
|
|
131
|
+
this.error('Cannot specify both positional <url> and --source-url flags.', {
|
|
132
|
+
exit: 2,
|
|
133
|
+
code: 'CONFLICTING_FLAGS',
|
|
134
|
+
});
|
|
132
135
|
}
|
|
133
136
|
if (!hasSingle && !hasMulti) {
|
|
134
|
-
this.error('Must specify either positional <url> (for single-source) or --source-url (for multi-source) import.', { exit: 2 });
|
|
137
|
+
this.error('Must specify either positional <url> (for single-source) or --source-url (for multi-source) import.', { exit: 2, code: 'MISSING_URL' });
|
|
135
138
|
}
|
|
136
139
|
if (hasMulti && !this.flags.topic) {
|
|
137
|
-
this.error('Multi-source import requires the --topic flag.', {
|
|
140
|
+
this.error('Multi-source import requires the --topic flag.', {
|
|
141
|
+
exit: 2,
|
|
142
|
+
code: 'MISSING_TOPIC',
|
|
143
|
+
});
|
|
138
144
|
}
|
|
139
145
|
if (!this.flags.stdin && !this.flags.file) {
|
|
140
146
|
this.error('Either --stdin or --file <path> must be specified to import content.', {
|
|
141
147
|
exit: 2,
|
|
148
|
+
code: 'MISSING_INPUT',
|
|
142
149
|
});
|
|
143
150
|
}
|
|
144
151
|
if (this.flags.stdin && this.flags.file) {
|
|
145
|
-
this.error('Cannot specify both --stdin and --file <path>. Choose one.', {
|
|
152
|
+
this.error('Cannot specify both --stdin and --file <path>. Choose one.', {
|
|
153
|
+
exit: 2,
|
|
154
|
+
code: 'CONFLICTING_FLAGS',
|
|
155
|
+
});
|
|
146
156
|
}
|
|
147
157
|
}
|
|
148
158
|
fsExistsSync(filePath) {
|
|
@@ -154,51 +164,68 @@ export default class ResearchImport extends BaseCommand {
|
|
|
154
164
|
fsReadFileSync(filePath) {
|
|
155
165
|
return fs.readFileSync(filePath, 'utf8');
|
|
156
166
|
}
|
|
167
|
+
stdinImportSuggestions() {
|
|
168
|
+
const bin = this.config.bin;
|
|
169
|
+
return [
|
|
170
|
+
`Pipe Markdown content, e.g. cat notes.md | ${bin} import <url> --stdin`,
|
|
171
|
+
`Or read from a file: ${bin} import <url> --file notes.md`,
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
/** Non-interactive stdin that never receives data (open pipe with no writer) must not hang forever. */
|
|
175
|
+
async readStdinWithGuard(limitBytes = 1024 * 1024) {
|
|
176
|
+
if (this.stdinIsInteractive()) {
|
|
177
|
+
this.error('No data piped to --stdin.', {
|
|
178
|
+
exit: 2,
|
|
179
|
+
code: 'MISSING_STDIN',
|
|
180
|
+
suggestions: this.stdinImportSuggestions(),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const timeoutMs = 1000;
|
|
184
|
+
let rawInput = '';
|
|
185
|
+
try {
|
|
186
|
+
rawInput = await Promise.race([
|
|
187
|
+
this.readStdin(limitBytes),
|
|
188
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('STDIN_TIMEOUT')), timeoutMs)),
|
|
189
|
+
]);
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (err.message === 'STDIN_TIMEOUT') {
|
|
193
|
+
this.error('No stdin data received. Pipe content or use --file.', {
|
|
194
|
+
exit: 2,
|
|
195
|
+
code: 'MISSING_STDIN',
|
|
196
|
+
suggestions: this.stdinImportSuggestions(),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (err.message.includes('stdin size limit exceeded')) {
|
|
200
|
+
this.error(err.message, { exit: 1, code: 'STDIN_TOO_LARGE' });
|
|
201
|
+
}
|
|
202
|
+
// Genuine stream failure reading stdin → I/O failure (exit 1).
|
|
203
|
+
this.error(err.message, { exit: 1, code: 'IO_ERROR' });
|
|
204
|
+
}
|
|
205
|
+
return rawInput;
|
|
206
|
+
}
|
|
157
207
|
readAndValidateFile(filePath) {
|
|
158
208
|
if (!this.fsExistsSync(filePath)) {
|
|
159
|
-
this.error(`File does not exist: ${filePath}`, { exit: 2 });
|
|
209
|
+
this.error(`File does not exist: ${filePath}`, { exit: 2, code: 'FILE_NOT_FOUND' });
|
|
160
210
|
}
|
|
161
211
|
const stat = this.fsStatSync(filePath);
|
|
162
212
|
if (!stat.isFile()) {
|
|
163
|
-
this.error(`Path is not a file: ${filePath}`, { exit: 2 });
|
|
213
|
+
this.error(`Path is not a file: ${filePath}`, { exit: 2, code: 'NOT_A_FILE' });
|
|
164
214
|
}
|
|
165
215
|
if (stat.size > 1024 * 1024) {
|
|
166
|
-
this.error('File size limit exceeded (max 1 MiB).', { exit: 1 });
|
|
216
|
+
this.error('File size limit exceeded (max 1 MiB).', { exit: 1, code: 'FILE_TOO_LARGE' });
|
|
167
217
|
}
|
|
168
218
|
const content = this.fsReadFileSync(filePath);
|
|
169
219
|
if (!content.trim()) {
|
|
170
|
-
this.error('Empty file content provided.', { exit: 2 });
|
|
220
|
+
this.error('Empty file content provided.', { exit: 2, code: 'EMPTY_INPUT' });
|
|
171
221
|
}
|
|
172
222
|
return content;
|
|
173
223
|
}
|
|
174
224
|
async readAndValidateInput() {
|
|
175
225
|
if (this.flags.stdin) {
|
|
176
|
-
|
|
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 = '';
|
|
191
|
-
try {
|
|
192
|
-
rawInput = await this.readStdin();
|
|
193
|
-
}
|
|
194
|
-
catch (err) {
|
|
195
|
-
// Genuine stream/size failure reading stdin → I/O failure (exit 1).
|
|
196
|
-
this.error(err.message, { exit: 1 });
|
|
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.
|
|
226
|
+
const rawInput = await this.readStdinWithGuard();
|
|
200
227
|
if (!rawInput.trim()) {
|
|
201
|
-
this.error('Empty stdin content provided.', { exit: 2 });
|
|
228
|
+
this.error('Empty stdin content provided.', { exit: 2, code: 'EMPTY_INPUT' });
|
|
202
229
|
}
|
|
203
230
|
return rawInput;
|
|
204
231
|
}
|
|
@@ -213,10 +240,10 @@ export default class ResearchImport extends BaseCommand {
|
|
|
213
240
|
// Only an unexpected read failure becomes a wrapped I/O failure (exit 1).
|
|
214
241
|
if (err instanceof Errors.CLIError)
|
|
215
242
|
throw err;
|
|
216
|
-
this.error(`Failed to read file: ${err.message}`, { exit: 1 });
|
|
243
|
+
this.error(`Failed to read file: ${err.message}`, { exit: 1, code: 'IO_ERROR' });
|
|
217
244
|
}
|
|
218
245
|
}
|
|
219
|
-
this.error('No input source specified.', { exit: 2 });
|
|
246
|
+
this.error('No input source specified.', { exit: 2, code: 'MISSING_INPUT' });
|
|
220
247
|
}
|
|
221
248
|
deriveImportCacheKey(hasSingle, singleNormalizedUrl, sourceUrls) {
|
|
222
249
|
if (hasSingle) {
|
|
@@ -292,6 +319,9 @@ export default class ResearchImport extends BaseCommand {
|
|
|
292
319
|
const hasSingle = Boolean(this.args.url);
|
|
293
320
|
const multiUrls = this.flags['source-url'] || [];
|
|
294
321
|
const hasMulti = multiUrls.length > 0;
|
|
322
|
+
const ttlErr = durationFlagError('--ttl', this.flags.ttl);
|
|
323
|
+
if (ttlErr)
|
|
324
|
+
this.error(ttlErr, { exit: 2, code: 'INVALID_DURATION' });
|
|
295
325
|
this.validateSourceMode(hasSingle, hasMulti, multiUrls);
|
|
296
326
|
const rawInput = await this.readAndValidateInput();
|
|
297
327
|
const sourceUrls = this.getSourceUrls(hasMulti, multiUrls, this.args.url);
|
|
@@ -311,8 +341,8 @@ export default class ResearchImport extends BaseCommand {
|
|
|
311
341
|
}
|
|
312
342
|
if (!this.jsonEnabled()) {
|
|
313
343
|
this.log(`Successfully imported research artifact.`);
|
|
314
|
-
this.log(
|
|
315
|
-
this.log(
|
|
344
|
+
this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
|
|
345
|
+
this.log(`${'Storage Path:'.padEnd(25)} ${storagePath}`);
|
|
316
346
|
}
|
|
317
347
|
const inputFormat = this.flags['input-format'];
|
|
318
348
|
return {
|
|
@@ -3,6 +3,10 @@ export default class ResearchInspect extends BaseCommand<typeof ResearchInspect>
|
|
|
3
3
|
static id: string;
|
|
4
4
|
static summary: string;
|
|
5
5
|
static description: string;
|
|
6
|
+
static examples: {
|
|
7
|
+
description: string;
|
|
8
|
+
command: string;
|
|
9
|
+
}[];
|
|
6
10
|
static args: {
|
|
7
11
|
url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
8
12
|
};
|
package/dist/commands/inspect.js
CHANGED
|
@@ -1,49 +1,54 @@
|
|
|
1
1
|
import { Args } from '@oclif/core';
|
|
2
2
|
import { BaseCommand } from '../base-command.js';
|
|
3
3
|
import { scanCacheDirs } from '../lib/research/storage.js';
|
|
4
|
-
import { resolveResearchTarget } from '../lib/research/resolve-target.js';
|
|
5
4
|
export default class ResearchInspect extends BaseCommand {
|
|
6
5
|
static id = 'inspect';
|
|
7
6
|
static summary = 'Inspect cached research metadata for a URL.';
|
|
8
7
|
static description = 'Inspects local storage and displays full frontmatter metadata for the URL (without printing the content body).';
|
|
8
|
+
static examples = [
|
|
9
|
+
{
|
|
10
|
+
description: 'inspect cached research metadata for a specific URL',
|
|
11
|
+
command: '<%= config.bin %> <%= command.id %> https://example.com/docs',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
description: 'output the full metadata as JSON for use by other tools',
|
|
15
|
+
command: '<%= config.bin %> <%= command.id %> https://example.com/docs --json',
|
|
16
|
+
},
|
|
17
|
+
];
|
|
9
18
|
static args = {
|
|
10
19
|
url: Args.string({
|
|
11
20
|
required: true,
|
|
12
|
-
description: '
|
|
21
|
+
description: 'the URL to inspect',
|
|
13
22
|
}),
|
|
14
23
|
};
|
|
15
24
|
static stdoutIsPrimaryData = true;
|
|
16
25
|
async run() {
|
|
17
26
|
const { url } = this.args;
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
target = resolveResearchTarget({
|
|
21
|
-
configDir: this.config.configDir,
|
|
22
|
-
cwd: process.cwd(),
|
|
23
|
-
dataDir: this.config.dataDir,
|
|
24
|
-
url,
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
catch (err) {
|
|
28
|
-
this.error(`Invalid URL: ${err.message}`, { exit: 2 });
|
|
29
|
-
}
|
|
27
|
+
const target = this.resolveResearchTargetOrFail(url);
|
|
30
28
|
const { cacheKey, located, normalizedUrl, roots } = target;
|
|
31
29
|
if (!located) {
|
|
32
|
-
|
|
30
|
+
// inspect makes no network call, so the bare message reads like the network-failure case the
|
|
31
|
+
// exit-1 contract describes. Point at the fix instead: fetch the page so it lands in the cache.
|
|
32
|
+
this.error(`No cached research found for URL: ${normalizedUrl}`, {
|
|
33
|
+
exit: 1,
|
|
34
|
+
code: 'CACHE_MISS',
|
|
35
|
+
suggestions: [`Fetch and cache it first: ${this.config.bin} ${normalizedUrl}`],
|
|
36
|
+
});
|
|
33
37
|
}
|
|
34
38
|
const cached = located.artifact;
|
|
35
39
|
const artifactPath = located.path;
|
|
36
40
|
const sections = this.findSections(roots.readRoots, cacheKey);
|
|
37
41
|
if (!this.jsonEnabled()) {
|
|
38
|
-
this.log(
|
|
39
|
-
this.log(
|
|
42
|
+
this.log(`${'Cache Key:'.padEnd(25)} ${cacheKey}`);
|
|
43
|
+
this.log(`${'Cache Path:'.padEnd(25)} ${artifactPath}`);
|
|
40
44
|
this.log(`--- Metadata ---`);
|
|
41
45
|
for (const [key, val] of Object.entries(cached.metadata)) {
|
|
46
|
+
const paddedKey = (key + ':').padEnd(Math.max(25, key.length + 2));
|
|
42
47
|
if (typeof val === 'object' && val !== null) {
|
|
43
|
-
this.log(`${
|
|
48
|
+
this.log(`${paddedKey} ${JSON.stringify(val)}`);
|
|
44
49
|
}
|
|
45
50
|
else {
|
|
46
|
-
this.log(`${
|
|
51
|
+
this.log(`${paddedKey} ${val}`);
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
if (sections.length) {
|
package/dist/commands/list.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export default class ResearchList extends BaseCommand<typeof ResearchList> {
|
|
|
11
11
|
topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
12
|
tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
13
|
freshness: import("@oclif/core/interfaces").OptionFlag<"fresh" | "stale_grace" | "stale_expired" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
-
'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
-
'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
16
|
limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
17
|
};
|
|
18
18
|
static stdoutIsPrimaryData: boolean;
|