@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.
Files changed (61) hide show
  1. package/README.md +53 -0
  2. package/agents/agents/official-docs-researcher.agent.md +10 -1
  3. package/agents/agents/salesforce-docs-researcher.agent.md +10 -1
  4. package/agents/instructions/salesforce-research.instructions.md +4 -2
  5. package/agents/instructions/web-research.instructions.md +4 -2
  6. package/agents/skills/web-research/SKILL.md +8 -3
  7. package/bin/cli.mjs +30 -13
  8. package/dist/base-command.d.ts +3 -0
  9. package/dist/base-command.js +40 -7
  10. package/dist/commands/config/base.d.ts +2 -0
  11. package/dist/commands/config/base.js +15 -1
  12. package/dist/commands/config/get.d.ts +1 -1
  13. package/dist/commands/config/get.js +4 -5
  14. package/dist/commands/config/index.d.ts +12 -0
  15. package/dist/commands/config/index.js +34 -0
  16. package/dist/commands/config/list.js +2 -2
  17. package/dist/commands/config/set.js +18 -12
  18. package/dist/commands/config/unset.d.ts +1 -1
  19. package/dist/commands/config/unset.js +9 -6
  20. package/dist/commands/fetch.js +44 -34
  21. package/dist/commands/import.d.ts +3 -0
  22. package/dist/commands/import.js +82 -52
  23. package/dist/commands/inspect.d.ts +4 -0
  24. package/dist/commands/inspect.js +24 -19
  25. package/dist/commands/list.d.ts +2 -2
  26. package/dist/commands/list.js +23 -15
  27. package/dist/commands/prune.d.ts +1 -1
  28. package/dist/commands/prune.js +30 -13
  29. package/dist/commands/search.d.ts +4 -0
  30. package/dist/commands/search.js +83 -21
  31. package/dist/commands/status.d.ts +6 -0
  32. package/dist/commands/status.js +50 -24
  33. package/dist/commands.d.ts +2 -0
  34. package/dist/commands.js +2 -0
  35. package/dist/hooks/command-not-found/suggest.js +37 -8
  36. package/dist/lib/argv.d.ts +10 -0
  37. package/dist/lib/argv.js +53 -0
  38. package/dist/lib/envelope.d.ts +36 -0
  39. package/dist/lib/envelope.js +76 -0
  40. package/dist/lib/json-meta.d.ts +10 -0
  41. package/dist/lib/json-meta.js +98 -0
  42. package/dist/lib/research/revalidate.js +3 -1
  43. package/dist/lib/research/schema.d.ts +5 -2
  44. package/dist/lib/research/schema.js +12 -1
  45. package/dist/lib/text.d.ts +2 -0
  46. package/dist/lib/text.js +2 -0
  47. package/dist/sites/algolia.d.ts +9 -0
  48. package/dist/sites/algolia.js +26 -0
  49. package/dist/sites/auto.d.ts +8 -0
  50. package/dist/sites/auto.js +30 -0
  51. package/dist/sites/index.js +17 -1
  52. package/dist/sites/nextjs/index.d.ts +2 -0
  53. package/dist/sites/nextjs/index.js +52 -0
  54. package/dist/sites/react/index.d.ts +2 -0
  55. package/dist/sites/react/index.js +17 -0
  56. package/dist/sites/tailwind/index.d.ts +2 -0
  57. package/dist/sites/tailwind/index.js +17 -0
  58. package/dist/sites/vue/index.d.ts +2 -0
  59. package/dist/sites/vue/index.js +17 -0
  60. package/oclif.manifest.json +149 -76
  61. package/package.json +8 -2
@@ -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: 'Research a URL with detailed output, tagged with topic and tags',
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: 'Research a volatile page with compressed output and short TTL, returned as JSON',
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: 'The full HTTP/HTTPS URL of the web page to research.',
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: 'The main category/topic of the research for metadata tagging.',
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: 'Taxonomic tags for this research (can be repeated).',
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: 'Desired data density.',
55
+ description: 'desired data density',
57
56
  options: ['compressed', 'detailed'],
58
57
  default: 'compressed',
59
58
  })(),
60
59
  tier: Flags.option({
61
- description: 'Freshness tier policy.',
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: 'Predicted lifespan: number + h/d/w/m/y (m = months), e.g. "2h", "7d", "6m".',
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: 'Maximum age of cache to accept (e.g., "1d", "30d").',
69
+ description: 'maximum age of cache to accept (e.g., "1d", "30d")',
71
70
  }),
72
71
  force: Flags.boolean({
73
- description: 'Force a fresh fetch, ignoring any cached entries.',
72
+ description: 'force a fresh fetch, ignoring any cached entries',
74
73
  default: false,
75
74
  }),
76
75
  'dry-run': Flags.boolean({
77
- description: 'Perform validation and fetch without saving to local cache.',
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: 'Allow serving stale cache if the remote server is unreachable.',
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: 'Force using a browser-rendered scraping path for dynamic pages.',
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: 'Override where this result is cached (overrides configured default). Secret-bearing pages are always stored globally.',
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, { exit: 1, suggestions: guidance?.suggestions, ref: guidance?.ref });
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
- let target;
258
- try {
259
- target = resolveResearchTarget({
260
- configDir: this.config.configDir,
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: ${revalResult.error}`);
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: ${revalResult.error}`);
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;
@@ -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: 'Import detailed research for a single URL via stdin',
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: 'Import research synthesized from multiple source URLs',
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: 'Import research from a local Markdown file',
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: 'The single source URL of the page (only for single-source import).',
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: 'Read Markdown from stdin.',
46
+ description: 'read Markdown from stdin',
47
47
  default: false,
48
48
  }),
49
49
  file: Flags.string({
50
- description: 'Path to a Markdown file containing research notes to import.',
50
+ description: 'path to a Markdown file containing research notes to import',
51
51
  }),
52
52
  'input-format': Flags.option({
53
- description: 'Format of the input provided.',
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: 'The main topic for this research note.',
59
+ description: 'the main topic for this research note',
60
60
  }),
61
61
  tags: Flags.string({
62
62
  char: 'g',
63
- description: 'Taxonomic tags (can be repeated).',
63
+ description: 'taxonomic tags (can be repeated)',
64
64
  multiple: true,
65
65
  }),
66
66
  tier: Flags.option({
67
- description: 'Freshness tier policy.',
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: 'Repeated source URLs for multi-source import.',
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: 'Predicted lifespan of the data (e.g. "24h", "7d").',
77
+ description: 'predicted lifespan of the data (e.g. "24h", "7d")',
78
78
  }),
79
79
  storage: Flags.option({
80
- description: 'Override where this note is cached (overrides configured default). Secret-bearing notes are always stored globally.',
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.', { exit: 2 });
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.', { exit: 2 });
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.', { exit: 2 });
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
- // 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 = '';
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(`Cache Key: ${cacheKey}`);
315
- this.log(`Storage Path: ${storagePath}`);
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
  };
@@ -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: 'The URL to inspect.',
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
- let target;
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
- this.error(`No cached research found for URL: ${normalizedUrl}`, { exit: 1 });
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(`Cache Key: ${cacheKey}`);
39
- this.log(`Cache Path: ${artifactPath}`);
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(`${key}: ${JSON.stringify(val)}`);
48
+ this.log(`${paddedKey} ${JSON.stringify(val)}`);
44
49
  }
45
50
  else {
46
- this.log(`${key}: ${val}`);
51
+ this.log(`${paddedKey} ${val}`);
47
52
  }
48
53
  }
49
54
  if (sections.length) {
@@ -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;