@taurgis/bonsai 1.0.1 → 1.0.3

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.
@@ -1,19 +1,31 @@
1
1
  ---
2
- description: 'Require the Official Docs Researcher subagent before technical repo changes.'
2
+ description: 'Verify current official docs before technical repo changes — inline via the web-research skill for minor research, via the Official Docs Researcher subagent for extensive research.'
3
3
  applyTo: '**'
4
4
  skills:
5
5
  - web-research
6
6
  metadata:
7
- version: '3.0.0'
7
+ version: '3.1.0'
8
8
  ---
9
9
 
10
10
  # Web Research Requirement
11
11
 
12
12
  ## Mandatory Pre-Step
13
13
 
14
- - Run the **Official Docs Researcher** subagent before creating, updating, refactoring, scaffolding, or deleting technical content.
15
- - Use its findings to apply current official documentation and references in the change.
16
- - The researcher keeps a freshness-tiered Bonsai cache (its data directory or project-local `.bonsai/research/`): it reuses fresh notes, cheaply revalidates stale ones, and re-fetches only on a miss. Re-running on a recent topic is cheap — invoke it rather than skipping research to "save" a fetch.
14
+ - Verify the relevant current official documentation **in the same task** before creating, updating, refactoring, scaffolding, or deleting technical content.
15
+ - Apply what you find — cite official source URLs when the change relies on platform behavior or standards.
16
+ - Research is the requirement; how you run it scales with scope (below). Training-data knowledge never satisfies this — only docs fetched in the current task do.
17
+
18
+ ## Choose How to Research by Scope
19
+
20
+ - **Minor research — run it inline.** For a single known page, one platform, or a quick flag/API/version check, the main agent invokes the **web-research** skill directly. A separate subagent is not required.
21
+ - **Extensive research — delegate to a subagent.** For multiple sources or platforms, unfamiliar territory, broad audits, or anything that produces verbose output you do not want in the main context, run the **Official Docs Researcher** subagent and apply its findings.
22
+ - The main agent may always use the **web-research** skill directly — choose the subagent to isolate large research, not because inline research is disallowed.
23
+ - When unsure, start inline and escalate to the subagent once the work spans several sources.
24
+
25
+ ## Shared Cache
26
+
27
+ - Both paths use the same freshness-tiered Bonsai cache (data directory or project-local `.bonsai/research/`): it reuses fresh notes, cheaply revalidates stale ones, and re-fetches only on a miss.
28
+ - Re-running on a recent topic is cheap — research the topic rather than skipping it to "save" a fetch.
17
29
 
18
30
  ## When Not to Use
19
31
 
@@ -23,7 +35,8 @@ metadata:
23
35
 
24
36
  ## Examples
25
37
 
26
- - ✅ Before editing instructions, prompts, agents, skills, workflows, or docs.
27
- - ✅ Before auditing or refactoring source — reading source code does not replace checking current platform guidance.
38
+ - ✅ Confirming one platform's current flag or frontmatter field before a small edit → run the web-research skill inline.
39
+ - ✅ Comparing conventions across several platforms, or auditing/refactoring source broadly → delegate to the Official Docs Researcher subagent.
40
+ - ✅ Before editing instructions, prompts, agents, skills, workflows, or docs — reading source code does not replace checking current platform guidance.
28
41
  - ✅ Include official source URLs when the change references platform behavior or standards.
29
- - ❌ Don't modify technical content without running the subagent first, or treat cached/training knowledge as equivalent — official guidance evolves and must be verified per task.
42
+ - ❌ Don't modify technical content without researching first, and don't treat cached or training knowledge as equivalent — official guidance evolves and must be verified per task.
@@ -1,14 +1,28 @@
1
1
  import { Command, Interfaces } from '@oclif/core';
2
+ /**
3
+ * Shared base for every Bonsai command. Enables oclif's native `--json` flag,
4
+ * parses args/flags once in `init()` so commands read `this.args`/`this.flags`
5
+ * directly in `run()`, and wraps `--json` output in the Bonsai envelope via the
6
+ * framework's `toSuccessJson`/`toErrorJson` hooks.
7
+ */
2
8
  export declare abstract class BaseCommand<T extends typeof Command> extends Command {
3
- static jsonEnvelope: boolean;
4
- static baseFlags: {
5
- json: Interfaces.BooleanFlag<boolean>;
6
- };
7
- flags: Interfaces.InferredFlags<T['flags'] & typeof BaseCommand.baseFlags>;
9
+ static enableJsonFlag: boolean;
10
+ flags: Interfaces.InferredFlags<T['flags']>;
8
11
  args: Interfaces.InferredArgs<T['args']>;
9
- protected requestedJson(): boolean;
12
+ init(): Promise<void>;
13
+ /**
14
+ * Always surface warnings on stderr, even under `--json`. oclif silences `warn()` in JSON mode,
15
+ * but our warnings (secret-redirect, stale-serve, remote-search fallback, prune failures) are
16
+ * security- and freshness-relevant side effects users must see. stderr never pollutes the stdout
17
+ * JSON envelope, so machine output stays clean.
18
+ */
19
+ warn(input: string | Error): string | Error;
20
+ /** Command id for the JSON envelope; falls back to the binary name when a command has no id. */
10
21
  protected envelopeCommandId(): string;
11
- protected _run<R>(): Promise<R>;
12
- abstract execute(): Promise<unknown>;
13
- run(): Promise<unknown>;
22
+ /** Single source of truth for the `--json` envelope shape, shared by success and error output. */
23
+ private envelope;
24
+ /** Wrap a command's return value in the machine-readable envelope emitted under `--json`. */
25
+ protected toSuccessJson(data: unknown): Record<string, unknown>;
26
+ /** Mirror the success envelope for failures so JSON consumers get one consistent shape. */
27
+ protected toErrorJson(err: unknown): Record<string, unknown>;
14
28
  }
@@ -1,56 +1,64 @@
1
- import { Command, Flags } from '@oclif/core';
1
+ import { Command, Errors } from '@oclif/core';
2
+ /**
3
+ * Shared base for every Bonsai command. Enables oclif's native `--json` flag,
4
+ * parses args/flags once in `init()` so commands read `this.args`/`this.flags`
5
+ * directly in `run()`, and wraps `--json` output in the Bonsai envelope via the
6
+ * framework's `toSuccessJson`/`toErrorJson` hooks.
7
+ */
2
8
  export class BaseCommand extends Command {
3
- static jsonEnvelope = false;
4
- static baseFlags = {
5
- json: Flags.boolean({
6
- description: 'Return the output as a machine-readable JSON object.',
7
- }),
8
- };
9
+ static enableJsonFlag = true;
9
10
  flags;
10
11
  args;
11
- requestedJson() {
12
- return Boolean(this.flags?.json);
12
+ async init() {
13
+ await super.init();
14
+ const { args, flags } = await this.parse({
15
+ flags: this.ctor.flags,
16
+ enableJsonFlag: this.ctor.enableJsonFlag,
17
+ args: this.ctor.args,
18
+ strict: this.ctor.strict,
19
+ });
20
+ this.flags = flags;
21
+ this.args = args;
13
22
  }
23
+ /**
24
+ * Always surface warnings on stderr, even under `--json`. oclif silences `warn()` in JSON mode,
25
+ * but our warnings (secret-redirect, stale-serve, remote-search fallback, prune failures) are
26
+ * security- and freshness-relevant side effects users must see. stderr never pollutes the stdout
27
+ * JSON envelope, so machine output stays clean.
28
+ */
29
+ warn(input) {
30
+ Errors.warn(input);
31
+ return input;
32
+ }
33
+ /** Command id for the JSON envelope; falls back to the binary name when a command has no id. */
14
34
  envelopeCommandId() {
15
35
  return this.ctor.id || this.config.bin;
16
36
  }
17
- async _run() {
18
- // Check argv directly for --json because if parsing fails in init(),
19
- // we still want to output the failure in the JSON envelope format.
20
- const isJson = this.argv.includes('--json');
21
- if (isJson) {
22
- try {
23
- await this.init();
24
- const result = await this.run();
25
- const exitCode = process.exitCode || 0;
26
- console.log(JSON.stringify({
27
- schemaVersion: 1,
28
- command: this.envelopeCommandId(),
29
- ok: exitCode === 0 || exitCode === 5,
30
- exitCode,
31
- stdout: '',
32
- stderr: '',
33
- data: result,
34
- }, null, 2));
35
- }
36
- catch (error) {
37
- const exitCode = error.oclif?.exit ?? 1;
38
- console.log(JSON.stringify({
39
- schemaVersion: 1,
40
- command: this.envelopeCommandId(),
41
- ok: false,
42
- exitCode,
43
- stdout: '',
44
- stderr: error.message || String(error),
45
- data: null,
46
- }, null, 2));
47
- process.exitCode = exitCode;
48
- }
49
- return undefined;
50
- }
51
- return super._run();
37
+ /** Single source of truth for the `--json` envelope shape, shared by success and error output. */
38
+ envelope(parts) {
39
+ return {
40
+ schemaVersion: 1,
41
+ command: this.envelopeCommandId(),
42
+ ok: parts.ok,
43
+ exitCode: parts.exitCode,
44
+ stdout: '',
45
+ stderr: parts.stderr,
46
+ data: parts.data,
47
+ };
48
+ }
49
+ /** Wrap a command's return value in the machine-readable envelope emitted under `--json`. */
50
+ toSuccessJson(data) {
51
+ // ponytail: the stale-serve path signals "exit 5" by setting process.exitCode inside run()
52
+ // (e.g. fetch's handleStaleRevalidationResult); this reads it back to mark the envelope. If that
53
+ // signalling moves off process.exitCode, update here too. Number() normalizes Node's string codes.
54
+ const exitCode = Number(process.exitCode ?? 0);
55
+ // Exit 5 means "served stale" — a successful, usable result, so it reports ok.
56
+ return this.envelope({ ok: exitCode === 0 || exitCode === 5, exitCode, stderr: '', data });
52
57
  }
53
- async run() {
54
- return this.execute();
58
+ /** Mirror the success envelope for failures so JSON consumers get one consistent shape. */
59
+ toErrorJson(err) {
60
+ const e = err;
61
+ const exitCode = e?.oclif?.exit ?? e?.exitCode ?? 1;
62
+ return this.envelope({ ok: false, exitCode, stderr: e?.message ?? String(err), data: null });
55
63
  }
56
64
  }
@@ -15,6 +15,5 @@ export default class ConfigGet extends ConfigCommand<typeof ConfigGet> {
15
15
  local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
16
  };
17
17
  static stdoutIsPrimaryData: boolean;
18
- init(): Promise<void>;
19
- execute(): Promise<unknown>;
18
+ run(): Promise<unknown>;
20
19
  }
@@ -25,13 +25,7 @@ export default class ConfigGet extends ConfigCommand {
25
25
  }),
26
26
  };
27
27
  static stdoutIsPrimaryData = true;
28
- async init() {
29
- await super.init();
30
- const { args, flags } = await this.parse(ConfigGet);
31
- this.args = args;
32
- this.flags = flags;
33
- }
34
- async execute() {
28
+ async run() {
35
29
  const key = this.args.key;
36
30
  this.assertKnownKey(key);
37
31
  this.assertScopeFlagsExclusive(this.flags.global, this.flags.local);
@@ -41,7 +35,7 @@ export default class ConfigGet extends ConfigCommand {
41
35
  const displayValue = value !== undefined ? value : BUILT_IN_DEFAULTS[key];
42
36
  const formatted = meta.format(displayValue);
43
37
  const suffix = value === undefined ? ' (not configured)' : '';
44
- if (!this.requestedJson())
38
+ if (!this.jsonEnabled())
45
39
  this.log(formatted + suffix);
46
40
  return { key, value: displayValue };
47
41
  }
@@ -12,6 +12,5 @@ export default class ConfigList extends ConfigCommand<typeof ConfigList> {
12
12
  local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
13
  };
14
14
  static stdoutIsPrimaryData: boolean;
15
- init(): Promise<void>;
16
- execute(): Promise<unknown>;
15
+ run(): Promise<unknown>;
17
16
  }
@@ -21,16 +21,11 @@ export default class ConfigList extends ConfigCommand {
21
21
  }),
22
22
  };
23
23
  static stdoutIsPrimaryData = true;
24
- async init() {
25
- await super.init();
26
- const { flags } = await this.parse(ConfigList);
27
- this.flags = flags;
28
- }
29
- async execute() {
24
+ async run() {
30
25
  this.assertScopeFlagsExclusive(this.flags.global, this.flags.local);
31
26
  const scope = this.readScope(this.flags.global, this.flags.local);
32
27
  const scoped = readScopedConfig(scope, this.config.configDir, process.cwd());
33
- if (!this.requestedJson()) {
28
+ if (!this.jsonEnabled()) {
34
29
  const width = Math.max(...ALL_KEYS.map((k) => k.length)) + 2;
35
30
  for (const key of ALL_KEYS) {
36
31
  const raw = scoped[key];
@@ -17,6 +17,5 @@ export default class ConfigSet extends ConfigCommand<typeof ConfigSet> {
17
17
  local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
18
  };
19
19
  static stdoutIsPrimaryData: boolean;
20
- init(): Promise<void>;
21
- execute(): Promise<unknown>;
20
+ run(): Promise<unknown>;
22
21
  }
@@ -31,13 +31,7 @@ export default class ConfigSet extends ConfigCommand {
31
31
  }),
32
32
  };
33
33
  static stdoutIsPrimaryData = true;
34
- async init() {
35
- await super.init();
36
- const { args, flags } = await this.parse(ConfigSet);
37
- this.args = args;
38
- this.flags = flags;
39
- }
40
- async execute() {
34
+ async run() {
41
35
  const { keyArg, valueArg } = splitInlineKeyValue(this.args.key, this.args.value);
42
36
  if (!keyArg) {
43
37
  this.error('Missing required argument: key', { exit: 2 });
@@ -56,7 +50,7 @@ export default class ConfigSet extends ConfigCommand {
56
50
  const scope = this.writeScope(this.flags.local);
57
51
  const formatted = meta.format(parsed);
58
52
  if (this.flags['dry-run']) {
59
- if (!this.requestedJson())
53
+ if (!this.jsonEnabled())
60
54
  this.log(`[dry-run] Would set ${keyArg} = ${formatted} (${scope})`);
61
55
  return { key: keyArg, value: parsed, scope, dryRun: true };
62
56
  }
@@ -73,7 +67,7 @@ export default class ConfigSet extends ConfigCommand {
73
67
  }
74
68
  writeUserConfig(configDir, patch);
75
69
  }
76
- if (!this.requestedJson())
70
+ if (!this.jsonEnabled())
77
71
  this.log(`Set ${keyArg} = ${formatted} (${scope})`);
78
72
  return { key: keyArg, value: parsed, scope, dryRun: false };
79
73
  }
@@ -16,6 +16,5 @@ export default class ConfigUnset extends ConfigCommand<typeof ConfigUnset> {
16
16
  local: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
17
  };
18
18
  static stdoutIsPrimaryData: boolean;
19
- init(): Promise<void>;
20
- execute(): Promise<unknown>;
19
+ run(): Promise<unknown>;
21
20
  }
@@ -29,19 +29,13 @@ export default class ConfigUnset extends ConfigCommand {
29
29
  }),
30
30
  };
31
31
  static stdoutIsPrimaryData = true;
32
- async init() {
33
- await super.init();
34
- const { args, flags } = await this.parse(ConfigUnset);
35
- this.args = args;
36
- this.flags = flags;
37
- }
38
- async execute() {
32
+ async run() {
39
33
  const key = this.args.key;
40
34
  this.assertKnownKey(key);
41
35
  this.assertScopeFlagsExclusive(this.flags.global, this.flags.local);
42
36
  const scope = this.writeScope(this.flags.local);
43
37
  if (this.flags['dry-run']) {
44
- if (!this.requestedJson())
38
+ if (!this.jsonEnabled())
45
39
  this.log(`[dry-run] Would unset ${key} (${scope})`);
46
40
  return { key, scope, dryRun: true };
47
41
  }
@@ -58,7 +52,7 @@ export default class ConfigUnset extends ConfigCommand {
58
52
  }
59
53
  writeUserConfig(configDir, patch);
60
54
  }
61
- if (!this.requestedJson())
55
+ if (!this.jsonEnabled())
62
56
  this.log(`Unset ${key} (${scope})`);
63
57
  return { key, scope, dryRun: false };
64
58
  }
@@ -26,7 +26,6 @@ export default class FetchCommand extends BaseCommand<typeof FetchCommand> {
26
26
  };
27
27
  static stdoutIsPrimaryData: boolean;
28
28
  protected envelopeCommandId(): string;
29
- init(): Promise<void>;
30
29
  private executeCacheHit;
31
30
  private executeCacheMiss;
32
31
  private buildResultData;
@@ -34,5 +33,5 @@ export default class FetchCommand extends BaseCommand<typeof FetchCommand> {
34
33
  private resolveArtifact;
35
34
  private persistFreshArtifact;
36
35
  private persistSectionsIfFresh;
37
- execute(): Promise<unknown>;
36
+ run(): Promise<unknown>;
38
37
  }
@@ -92,12 +92,6 @@ export default class FetchCommand extends BaseCommand {
92
92
  envelopeCommandId() {
93
93
  return this.config.bin;
94
94
  }
95
- async init() {
96
- await super.init();
97
- const { args, flags } = await this.parse(FetchCommand);
98
- this.args = args;
99
- this.flags = flags;
100
- }
101
95
  async executeCacheHit(cached, targetDir, currentTime) {
102
96
  const { ttl, 'max-age': maxAge, 'allow-stale': allowStale, rendered } = this.flags;
103
97
  const isExpired = checkMaxAgeExpired(cached, currentTime, maxAge);
@@ -246,7 +240,7 @@ export default class FetchCommand extends BaseCommand {
246
240
  /* section generation is non-essential; ignore failures */
247
241
  }
248
242
  }
249
- async execute() {
243
+ async run() {
250
244
  const { url } = this.args;
251
245
  const { format, ttl, 'max-age': maxAge, 'dry-run': dryRun } = this.flags;
252
246
  this.validateDurationFlags(ttl, maxAge);
@@ -272,7 +266,7 @@ export default class FetchCommand extends BaseCommand {
272
266
  this.persistSectionsIfFresh(tmpDir ?? storageDir, artifact, currentTime, cacheStatus);
273
267
  const content = format === 'compressed' ? artifact.compressed : artifact.detailed;
274
268
  const resultData = this.buildResultData(url, normalizedUrl, cacheKey, storageDir, roots.mode, cacheStatus, freshnessState, format, artifact, content, redirectedToGlobal);
275
- if (!this.requestedJson()) {
269
+ if (!this.jsonEnabled()) {
276
270
  this.log(content);
277
271
  }
278
272
  return resultData;
@@ -23,7 +23,6 @@ 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
- init(): Promise<void>;
27
26
  private readStdin;
28
27
  private getSourceUrls;
29
28
  private validateSourceMode;
@@ -34,5 +33,5 @@ export default class ResearchImport extends BaseCommand<typeof ResearchImport> {
34
33
  private readAndValidateInput;
35
34
  private deriveImportCacheKey;
36
35
  private buildImportArtifact;
37
- execute(): Promise<unknown>;
36
+ run(): Promise<unknown>;
38
37
  }
@@ -76,12 +76,6 @@ export default class ResearchImport extends BaseCommand {
76
76
  })(),
77
77
  };
78
78
  static stdoutIsPrimaryData = true;
79
- async init() {
80
- await super.init();
81
- const { args, flags } = await this.parse(ResearchImport);
82
- this.args = args;
83
- this.flags = flags;
84
- }
85
79
  async readStdin(limitBytes = 1024 * 1024) {
86
80
  return new Promise((resolve, reject) => {
87
81
  let data = '';
@@ -254,7 +248,7 @@ export default class ResearchImport extends BaseCommand {
254
248
  provenance: `Imported via agent-supplied research at ${currentTime.toISOString()}`,
255
249
  };
256
250
  }
257
- async execute() {
251
+ async run() {
258
252
  const hasSingle = Boolean(this.args.url);
259
253
  const multiUrls = this.flags['source-url'] || [];
260
254
  const hasMulti = multiUrls.length > 0;
@@ -275,7 +269,7 @@ export default class ResearchImport extends BaseCommand {
275
269
  if (writeResult.redirected) {
276
270
  this.warn(`Detected ${writeResult.secretLabel} in the imported content; stored in the global cache instead of the project to avoid committing secrets.`);
277
271
  }
278
- if (!this.requestedJson()) {
272
+ if (!this.jsonEnabled()) {
279
273
  this.log(`Successfully imported research artifact.`);
280
274
  this.log(`Cache Key: ${cacheKey}`);
281
275
  this.log(`Storage Path: ${storagePath}`);
@@ -7,7 +7,6 @@ export default class ResearchInspect extends BaseCommand<typeof ResearchInspect>
7
7
  url: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
8
8
  };
9
9
  static stdoutIsPrimaryData: boolean;
10
- init(): Promise<void>;
11
- execute(): Promise<unknown>;
10
+ run(): Promise<unknown>;
12
11
  private findSections;
13
12
  }
@@ -13,12 +13,7 @@ export default class ResearchInspect extends BaseCommand {
13
13
  }),
14
14
  };
15
15
  static stdoutIsPrimaryData = true;
16
- async init() {
17
- await super.init();
18
- const { args } = await this.parse(ResearchInspect);
19
- this.args = args;
20
- }
21
- async execute() {
16
+ async run() {
22
17
  const { url } = this.args;
23
18
  let target;
24
19
  try {
@@ -39,7 +34,7 @@ export default class ResearchInspect extends BaseCommand {
39
34
  const cached = located.artifact;
40
35
  const artifactPath = located.path;
41
36
  const sections = this.findSections(roots.readRoots, cacheKey);
42
- if (!this.requestedJson()) {
37
+ if (!this.jsonEnabled()) {
43
38
  this.log(`Cache Key: ${cacheKey}`);
44
39
  this.log(`Cache Path: ${artifactPath}`);
45
40
  this.log(`--- Metadata ---`);
@@ -16,10 +16,9 @@ export default class ResearchList extends BaseCommand<typeof ResearchList> {
16
16
  limit: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
17
17
  };
18
18
  static stdoutIsPrimaryData: boolean;
19
- init(): Promise<void>;
20
19
  private validateListFlags;
21
20
  private matchesFilters;
22
21
  private scanCacheDirForList;
23
22
  private logListResults;
24
- execute(): Promise<unknown>;
23
+ run(): Promise<unknown>;
25
24
  }
@@ -49,11 +49,6 @@ export default class ResearchList extends BaseCommand {
49
49
  }),
50
50
  };
51
51
  static stdoutIsPrimaryData = true;
52
- async init() {
53
- await super.init();
54
- const { flags } = await this.parse(ResearchList);
55
- this.flags = flags;
56
- }
57
52
  validateListFlags() {
58
53
  const limit = this.flags.limit;
59
54
  if (limit !== undefined && (limit < 1 || limit > 100)) {
@@ -107,7 +102,7 @@ export default class ResearchList extends BaseCommand {
107
102
  });
108
103
  }
109
104
  logListResults(finalResults) {
110
- if (this.requestedJson())
105
+ if (this.jsonEnabled())
111
106
  return;
112
107
  if (finalResults.length === 0) {
113
108
  this.log('No cached research entries found matching filters.');
@@ -121,7 +116,7 @@ export default class ResearchList extends BaseCommand {
121
116
  this.log(` Source URLs: ${res.sourceUrls.join(', ')}\n`);
122
117
  });
123
118
  }
124
- async execute() {
119
+ async run() {
125
120
  this.validateListFlags();
126
121
  const roots = loadStoreRoots({
127
122
  configDir: this.config.configDir,
@@ -15,10 +15,9 @@ export default class ResearchPrune extends BaseCommand<typeof ResearchPrune> {
15
15
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
16
  };
17
17
  static stdoutIsPrimaryData: boolean;
18
- init(): Promise<void>;
19
18
  private validatePruneFlags;
20
19
  private shouldPrune;
21
20
  private findPruneCandidates;
22
21
  private deletePruneCandidates;
23
- execute(): Promise<unknown>;
22
+ run(): Promise<unknown>;
24
23
  }
@@ -41,11 +41,6 @@ export default class ResearchPrune extends BaseCommand {
41
41
  }),
42
42
  };
43
43
  static stdoutIsPrimaryData = true;
44
- async init() {
45
- await super.init();
46
- const { flags } = await this.parse(ResearchPrune);
47
- this.flags = flags;
48
- }
49
44
  validatePruneFlags() {
50
45
  if (!this.flags['older-than'] && !this.flags.inactive && !this.flags['artifact-type']) {
51
46
  this.error('Must specify at least one pruning filter: --older-than, --inactive, or --artifact-type.', { exit: 2 });
@@ -103,7 +98,7 @@ export default class ResearchPrune extends BaseCommand {
103
98
  }
104
99
  return prunedCount;
105
100
  }
106
- async execute() {
101
+ async run() {
107
102
  this.validatePruneFlags();
108
103
  const roots = loadStoreRoots({
109
104
  configDir: this.config.configDir,
@@ -115,7 +110,7 @@ export default class ResearchPrune extends BaseCommand {
115
110
  const dryRun = this.flags['dry-run'];
116
111
  const count = filesToPrune.length;
117
112
  if (dryRun) {
118
- if (!this.requestedJson()) {
113
+ if (!this.jsonEnabled()) {
119
114
  this.log(`[Dry Run] Found ${count} research cache entries that would be pruned:\n`);
120
115
  filesToPrune.forEach((f) => {
121
116
  this.log(`- [${f.topic || 'No Topic'}] Key: ${f.cacheKey} (${f.url || 'Imported note'})`);
@@ -124,7 +119,7 @@ export default class ResearchPrune extends BaseCommand {
124
119
  }
125
120
  else {
126
121
  const prunedCount = this.deletePruneCandidates(filesToPrune);
127
- if (!this.requestedJson()) {
122
+ if (!this.jsonEnabled()) {
128
123
  this.log(`Successfully pruned ${prunedCount} of ${count} research cache entries.`);
129
124
  }
130
125
  }
@@ -16,7 +16,6 @@ export default class ResearchSearch extends BaseCommand<typeof ResearchSearch> {
16
16
  remote: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
17
  };
18
18
  static stdoutIsPrimaryData: boolean;
19
- init(): Promise<void>;
20
19
  private validateSearchFlags;
21
20
  private getSearchQueryTerms;
22
21
  private matchesFilters;
@@ -28,5 +27,5 @@ export default class ResearchSearch extends BaseCommand<typeof ResearchSearch> {
28
27
  private printResults;
29
28
  private executeSiteSearch;
30
29
  private executeRemoteSearch;
31
- execute(): Promise<unknown>;
30
+ run(): Promise<unknown>;
32
31
  }
@@ -58,12 +58,6 @@ export default class ResearchSearch extends BaseCommand {
58
58
  }),
59
59
  };
60
60
  static stdoutIsPrimaryData = true;
61
- async init() {
62
- await super.init();
63
- const { args, flags } = await this.parse(ResearchSearch);
64
- this.args = args;
65
- this.flags = flags;
66
- }
67
61
  validateSearchFlags() {
68
62
  const limit = this.flags.limit;
69
63
  if (limit !== undefined && (limit < 1 || limit > 50)) {
@@ -187,7 +181,7 @@ export default class ResearchSearch extends BaseCommand {
187
181
  });
188
182
  }
189
183
  logSearchResults(finalResults) {
190
- if (this.requestedJson())
184
+ if (this.jsonEnabled())
191
185
  return;
192
186
  this.log(`Found ${finalResults.length} matching cached research entries:\n`);
193
187
  finalResults.forEach((res, index) => {
@@ -200,7 +194,7 @@ export default class ResearchSearch extends BaseCommand {
200
194
  // Prints a numbered result list to stdout for human (non-JSON) output. Shared by site and remote
201
195
  // search so the two paths can't drift in formatting.
202
196
  printResults(heading, results) {
203
- if (this.requestedJson())
197
+ if (this.jsonEnabled())
204
198
  return;
205
199
  this.log(heading);
206
200
  results.forEach((r, i) => {
@@ -235,7 +229,7 @@ export default class ResearchSearch extends BaseCommand {
235
229
  return undefined;
236
230
  }
237
231
  }
238
- async execute() {
232
+ async run() {
239
233
  const { query } = this.args;
240
234
  const { domain, remote } = this.flags;
241
235
  if (domain) {
@@ -12,6 +12,5 @@ export default class ResearchStatus extends BaseCommand<typeof ResearchStatus> {
12
12
  'max-age': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
13
  };
14
14
  static stdoutIsPrimaryData: boolean;
15
- init(): Promise<void>;
16
- execute(): Promise<unknown>;
15
+ run(): Promise<unknown>;
17
16
  }
@@ -43,13 +43,7 @@ export default class ResearchStatus extends BaseCommand {
43
43
  }),
44
44
  };
45
45
  static stdoutIsPrimaryData = true;
46
- async init() {
47
- await super.init();
48
- const { args, flags } = await this.parse(ResearchStatus);
49
- this.args = args;
50
- this.flags = flags;
51
- }
52
- async execute() {
46
+ async run() {
53
47
  const { url } = this.args;
54
48
  const { ttl, tier, 'max-age': maxAge } = this.flags;
55
49
  let target;
@@ -76,7 +70,7 @@ export default class ResearchStatus extends BaseCommand {
76
70
  }
77
71
  // On a hit, report where it actually lives; on a miss, where a fetch would write it.
78
72
  const artifactPath = located?.path ?? getArtifactPath(roots.writeRoot, cacheKey);
79
- if (!this.requestedJson()) {
73
+ if (!this.jsonEnabled()) {
80
74
  this.log(`URL: ${normalizedUrl}`);
81
75
  this.log(`Cache Key: ${cacheKey}`);
82
76
  this.log(`Cache Path: ${artifactPath}`);
@@ -6,6 +6,9 @@ const ERROR_PAGE_PATTERNS = [
6
6
  /\bnot\s*found\b/i,
7
7
  /there\s*isn'?t\s*a\s*github\s*pages\s*site\s*here/i,
8
8
  /site\s*not\s*found/i,
9
+ // Client-rendered (SPA) failure shells that return HTTP 200 but render only an error notice.
10
+ /something\s+went\s+wrong/i,
11
+ /an?\s+error\s+occurred/i,
9
12
  ];
10
13
  /** True when short text reads like an error/soft-404 page rather than real content. */
11
14
  export function looksLikeErrorPage(text) {
@@ -7,6 +7,7 @@ import { evaluateFreshness, getPolicy } from './freshness.js';
7
7
  import { compressMarkdown } from './compress.js';
8
8
  import { estimateTokens } from './token-estimate.js';
9
9
  import { fetchRenderedHtml } from './browser.js';
10
+ import { looksLikeErrorPage } from './docs/validate.js';
10
11
  function buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed) {
11
12
  const contentHash = createHash('sha256').update(extraction.detailedMarkdown).digest('hex');
12
13
  const staleAfterTime = new Date(currentTime);
@@ -51,10 +52,39 @@ function buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, ti
51
52
  section_heading_path: null,
52
53
  };
53
54
  }
55
+ // Builds a compact error marker instead of caching a full error page. Subsequent lookups serve this
56
+ // tiny marker (a handful of tokens) instead of re-fetching, and revalidation re-checks it when stale
57
+ // so a transient failure recovers into a real artifact.
58
+ function buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl, currentTime, reason) {
59
+ const marker = `Error: ${url} could not be cached — ${reason}. ` +
60
+ 'The page returned an error, so its content was not stored; it will be re-fetched when this entry goes stale.';
61
+ const extraction = {
62
+ title: `Error: ${reason}`,
63
+ detailedMarkdown: marker,
64
+ confidence: 'low',
65
+ qualityNotes: [`error: ${reason}`],
66
+ };
67
+ const metadata = buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, marker);
68
+ metadata.extraction_status = 'failed';
69
+ metadata.extraction_confidence = null;
70
+ return {
71
+ metadata,
72
+ summary: extraction.title,
73
+ compressed: marker,
74
+ detailed: marker,
75
+ provenance: `Fetched from ${url} on ${currentTime.toISOString()} (error page; content not cached)`,
76
+ };
77
+ }
54
78
  /**
55
79
  * Helper to construct a ResearchArtifact from a fresh HTML fetch.
56
80
  */
57
81
  export function createArtifactFromFetch(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime) {
82
+ // A managed platform / SPA can return HTTP 200 but render only a "not found" / "something went
83
+ // wrong" shell. Cache a compact marker for those instead of the full error markdown, so repeat
84
+ // lookups cost a few tokens and revalidation still re-checks the page when the entry goes stale.
85
+ if (looksLikeErrorPage(extraction.detailedMarkdown)) {
86
+ return buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl, currentTime, 'page reported an error or was not found');
87
+ }
58
88
  const compressed = compressMarkdown(extraction.detailedMarkdown);
59
89
  const metadata = buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed);
60
90
  return {
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, renameSync } from 'node:fs';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, renameSync, } from 'node:fs';
3
3
  import { atomicWriteFile } from '../atomic-write.js';
4
4
  import { parseArtifact, serializeArtifact } from './artifact.js';
5
5
  function isResearchFile(file) {
@@ -22,7 +22,8 @@
22
22
  ],
23
23
  "flags": {
24
24
  "json": {
25
- "description": "Return the output as a machine-readable JSON object.",
25
+ "description": "Format output as json.",
26
+ "helpGroup": "GLOBAL",
26
27
  "name": "json",
27
28
  "allowNo": false,
28
29
  "type": "boolean"
@@ -129,8 +130,7 @@
129
130
  "pluginType": "core",
130
131
  "strict": true,
131
132
  "summary": "An advanced, locally cached web research tool optimized for LLM ingestion.",
132
- "enableJsonFlag": false,
133
- "jsonEnvelope": false,
133
+ "enableJsonFlag": true,
134
134
  "stdoutIsPrimaryData": true
135
135
  },
136
136
  "import": {
@@ -159,7 +159,8 @@
159
159
  ],
160
160
  "flags": {
161
161
  "json": {
162
- "description": "Return the output as a machine-readable JSON object.",
162
+ "description": "Format output as json.",
163
+ "helpGroup": "GLOBAL",
163
164
  "name": "json",
164
165
  "allowNo": false,
165
166
  "type": "boolean"
@@ -253,8 +254,7 @@
253
254
  "pluginType": "core",
254
255
  "strict": true,
255
256
  "summary": "Import agent-supplied clean Markdown notes into the local cache.",
256
- "enableJsonFlag": false,
257
- "jsonEnvelope": false,
257
+ "enableJsonFlag": true,
258
258
  "stdoutIsPrimaryData": true
259
259
  },
260
260
  "inspect": {
@@ -269,7 +269,8 @@
269
269
  "description": "Inspects local storage and displays full frontmatter metadata for the URL (without printing the content body).",
270
270
  "flags": {
271
271
  "json": {
272
- "description": "Return the output as a machine-readable JSON object.",
272
+ "description": "Format output as json.",
273
+ "helpGroup": "GLOBAL",
273
274
  "name": "json",
274
275
  "allowNo": false,
275
276
  "type": "boolean"
@@ -283,8 +284,7 @@
283
284
  "pluginType": "core",
284
285
  "strict": true,
285
286
  "summary": "Inspect cached research metadata for a URL.",
286
- "enableJsonFlag": false,
287
- "jsonEnvelope": false,
287
+ "enableJsonFlag": true,
288
288
  "stdoutIsPrimaryData": true
289
289
  },
290
290
  "list": {
@@ -307,7 +307,8 @@
307
307
  ],
308
308
  "flags": {
309
309
  "json": {
310
- "description": "Return the output as a machine-readable JSON object.",
310
+ "description": "Format output as json.",
311
+ "helpGroup": "GLOBAL",
311
312
  "name": "json",
312
313
  "allowNo": false,
313
314
  "type": "boolean"
@@ -380,8 +381,7 @@
380
381
  "pluginType": "core",
381
382
  "strict": true,
382
383
  "summary": "List cached research artifacts by metadata filters.",
383
- "enableJsonFlag": false,
384
- "jsonEnvelope": false,
384
+ "enableJsonFlag": true,
385
385
  "stdoutIsPrimaryData": true
386
386
  },
387
387
  "prune": {
@@ -400,7 +400,8 @@
400
400
  ],
401
401
  "flags": {
402
402
  "json": {
403
- "description": "Return the output as a machine-readable JSON object.",
403
+ "description": "Format output as json.",
404
+ "helpGroup": "GLOBAL",
404
405
  "name": "json",
405
406
  "allowNo": false,
406
407
  "type": "boolean"
@@ -452,8 +453,7 @@
452
453
  "pluginType": "core",
453
454
  "strict": true,
454
455
  "summary": "Clean up old or inactive research cache entries.",
455
- "enableJsonFlag": false,
456
- "jsonEnvelope": false,
456
+ "enableJsonFlag": true,
457
457
  "stdoutIsPrimaryData": true
458
458
  },
459
459
  "search": {
@@ -468,7 +468,8 @@
468
468
  "description": "Scans the local research cache database and ranks matching entries based on topic, tags, source URLs, summary, and content.",
469
469
  "flags": {
470
470
  "json": {
471
- "description": "Return the output as a machine-readable JSON object.",
471
+ "description": "Format output as json.",
472
+ "helpGroup": "GLOBAL",
472
473
  "name": "json",
473
474
  "allowNo": false,
474
475
  "type": "boolean"
@@ -539,8 +540,7 @@
539
540
  "pluginType": "core",
540
541
  "strict": true,
541
542
  "summary": "Search locally cached research artifacts by keywords.",
542
- "enableJsonFlag": false,
543
- "jsonEnvelope": false,
543
+ "enableJsonFlag": true,
544
544
  "stdoutIsPrimaryData": true
545
545
  },
546
546
  "status": {
@@ -555,7 +555,8 @@
555
555
  "description": "Reports whether the URL is cached, its freshness state, and what action (fetch, revalidate, or cached return) the root fetch command would take.",
556
556
  "flags": {
557
557
  "json": {
558
- "description": "Return the output as a machine-readable JSON object.",
558
+ "description": "Format output as json.",
559
+ "helpGroup": "GLOBAL",
559
560
  "name": "json",
560
561
  "allowNo": false,
561
562
  "type": "boolean"
@@ -597,8 +598,7 @@
597
598
  "pluginType": "core",
598
599
  "strict": true,
599
600
  "summary": "Check the cache status of a URL without fetching or writing.",
600
- "enableJsonFlag": false,
601
- "jsonEnvelope": false,
601
+ "enableJsonFlag": true,
602
602
  "stdoutIsPrimaryData": true
603
603
  },
604
604
  "config:get": {
@@ -623,7 +623,8 @@
623
623
  ],
624
624
  "flags": {
625
625
  "json": {
626
- "description": "Return the output as a machine-readable JSON object.",
626
+ "description": "Format output as json.",
627
+ "helpGroup": "GLOBAL",
627
628
  "name": "json",
628
629
  "allowNo": false,
629
630
  "type": "boolean"
@@ -674,7 +675,8 @@
674
675
  ],
675
676
  "flags": {
676
677
  "json": {
677
- "description": "Return the output as a machine-readable JSON object.",
678
+ "description": "Format output as json.",
679
+ "helpGroup": "GLOBAL",
678
680
  "name": "json",
679
681
  "allowNo": false,
680
682
  "type": "boolean"
@@ -736,7 +738,8 @@
736
738
  ],
737
739
  "flags": {
738
740
  "json": {
739
- "description": "Return the output as a machine-readable JSON object.",
741
+ "description": "Format output as json.",
742
+ "helpGroup": "GLOBAL",
740
743
  "name": "json",
741
744
  "allowNo": false,
742
745
  "type": "boolean"
@@ -799,7 +802,8 @@
799
802
  ],
800
803
  "flags": {
801
804
  "json": {
802
- "description": "Return the output as a machine-readable JSON object.",
805
+ "description": "Format output as json.",
806
+ "helpGroup": "GLOBAL",
803
807
  "name": "json",
804
808
  "allowNo": false,
805
809
  "type": "boolean"
@@ -841,5 +845,5 @@
841
845
  "stdoutIsPrimaryData": true
842
846
  }
843
847
  },
844
- "version": "1.0.1"
848
+ "version": "1.0.3"
845
849
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taurgis/bonsai",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Bonsai is a standalone local research cache CLI for AI agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,6 +26,7 @@
26
26
  "regression:suite": "tsc -b && node testing/regression/run-suite.mjs",
27
27
  "regression:promote": "tsc -b && node testing/regression/run-suite.mjs --promote",
28
28
  "regression:check": "tsc -b && node testing/regression/run-suite.mjs --strict",
29
+ "nexus:sync": "npx forward-nexus update && npx forward-nexus sync",
29
30
  "test": "vitest",
30
31
  "test:coverage": "vitest run --coverage",
31
32
  "type-check": "tsc --noEmit"