@taurgis/bonsai 3.2.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +2 -2
  2. package/agents/agents/salesforce-docs-researcher.agent.md +4 -4
  3. package/bin/cli.mjs +7 -4
  4. package/dist/base-command.d.ts +37 -0
  5. package/dist/base-command.js +97 -6
  6. package/dist/commands/config/base.js +1 -0
  7. package/dist/commands/config/get.js +2 -2
  8. package/dist/commands/config/list.js +2 -2
  9. package/dist/commands/config/set.js +2 -2
  10. package/dist/commands/config/unset.js +2 -2
  11. package/dist/commands/fetch.js +4 -0
  12. package/dist/commands/inspect.js +11 -2
  13. package/dist/commands/list.d.ts +12 -9
  14. package/dist/commands/list.js +67 -39
  15. package/dist/commands/prune.d.ts +2 -0
  16. package/dist/commands/prune.js +35 -4
  17. package/dist/commands/status.js +10 -0
  18. package/dist/hooks/command-not-found/suggest.d.ts +1 -1
  19. package/dist/hooks/command-not-found/suggest.js +12 -2
  20. package/dist/lib/argv.d.ts +5 -0
  21. package/dist/lib/argv.js +27 -5
  22. package/dist/lib/cli-flag-manifest.d.ts +7 -0
  23. package/dist/lib/cli-flag-manifest.js +7 -0
  24. package/dist/lib/cli-presentation.d.ts +6 -4
  25. package/dist/lib/cli-presentation.js +7 -4
  26. package/dist/lib/cli-result-types.d.ts +25 -1
  27. package/dist/lib/config/index.d.ts +2 -2
  28. package/dist/lib/config/index.js +2 -2
  29. package/dist/lib/config/io.d.ts +9 -0
  30. package/dist/lib/config/io.js +71 -21
  31. package/dist/lib/config/schema.d.ts +2 -0
  32. package/dist/lib/config/schema.js +7 -0
  33. package/dist/lib/envelope.js +11 -4
  34. package/dist/lib/prune-flags.d.ts +2 -0
  35. package/dist/lib/prune-flags.js +18 -6
  36. package/dist/lib/research/artifact-index.js +7 -1
  37. package/dist/lib/research/artifact.js +20 -2
  38. package/dist/lib/research/browser.d.ts +12 -0
  39. package/dist/lib/research/browser.js +63 -8
  40. package/dist/lib/research/cli-io.d.ts +7 -0
  41. package/dist/lib/research/fetch-command-service.js +80 -23
  42. package/dist/lib/research/fetcher.js +3 -1
  43. package/dist/lib/research/import-command-service.js +22 -1
  44. package/dist/lib/research/metadata-filters.d.ts +18 -0
  45. package/dist/lib/research/metadata-filters.js +37 -0
  46. package/dist/lib/research/metadata-flags.d.ts +34 -0
  47. package/dist/lib/research/metadata-flags.js +45 -0
  48. package/dist/lib/research/prompt-injection.d.ts +2 -1
  49. package/dist/lib/research/prompt-injection.js +18 -2
  50. package/dist/lib/research/resolve-target.d.ts +2 -0
  51. package/dist/lib/research/resolve-target.js +1 -1
  52. package/dist/lib/research/storage.d.ts +4 -7
  53. package/dist/lib/research/storage.js +26 -25
  54. package/dist/lib/text.d.ts +14 -8
  55. package/dist/lib/text.js +21 -9
  56. package/oclif.manifest.json +120 -12
  57. package/package.json +2 -1
@@ -4,9 +4,10 @@ import { scanCacheDirs } from '../lib/research/storage.js';
4
4
  import { loadStoreRoots } from '../lib/research/store-roots.js';
5
5
  import { evaluateFreshness } from '../lib/research/freshness.js';
6
6
  import { ARTIFACT_TYPES, CAPTURE_METHODS, } from '../lib/research/schema.js';
7
- import { NO_TOPIC_LABEL, resultListHeading } from '../lib/text.js';
7
+ import { NO_TOPIC_LABEL, formatTip, resultListHeading, sanitizeForTerminal, } from '../lib/text.js';
8
8
  import { limitFlag } from '../lib/limit-flag.js';
9
9
  import { artifactMatchesUrlFilter, emptyUrlFilterError } from '../lib/research/url.js';
10
+ import { matchesTopicFilter, matchesTagsFilter, emptyTopicFilterError, emptyTagsFilterError, } from '../lib/research/metadata-filters.js';
10
11
  import { colors } from '../lib/color.js';
11
12
  import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
12
13
  // Listings are ordered newest-first, so the truncation word is "first"; --limit caps at this value.
@@ -14,21 +15,38 @@ const LIST_DEFAULT_MAX_LIMIT = 100;
14
15
  const LIST_LABELS = {
15
16
  noun: 'cached research',
16
17
  order: 'first',
17
- maxLimit: LIST_DEFAULT_MAX_LIMIT,
18
18
  };
19
19
  // `list` answers "what pages/notes do I have?" and deliberately omits section children (see
20
20
  // scanCacheDirForList), so `section` is not an offered filter — every other artifact type can appear.
21
21
  const LISTABLE_ARTIFACT_TYPES = ARTIFACT_TYPES.filter((type) => type !== 'section');
22
+ /** Project a full row down to the default minimal shape (see {@link ListRowMinimal}). */
23
+ function toMinimalListRow(row) {
24
+ return {
25
+ sourceUrls: row.sourceUrls,
26
+ topic: row.topic,
27
+ freshness: row.freshness,
28
+ tokenEstimate: row.tokenEstimate,
29
+ };
30
+ }
31
+ /** Count matched rows per freshness tier for the envelope's `summary.byFreshness`. */
32
+ function countByFreshness(rows) {
33
+ const counts = { fresh: 0, stale_grace: 0, stale_expired: 0 };
34
+ for (const row of rows)
35
+ counts[row.freshness]++;
36
+ return counts;
37
+ }
22
38
  /** List cached research artifacts by metadata filters. */
23
39
  export default class ResearchList extends BaseCommand {
24
40
  static id = 'list';
25
41
  static summary = 'List cached research artifacts by metadata';
26
- static description = 'List page-level cached artifacts with source URLs, topic, tags, freshness, capture method, and token estimates.';
42
+ static description = 'List page-level cached artifacts by source URL, topic, freshness, and token estimate. ' +
43
+ 'Pass --full for every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps).';
27
44
  /**
28
- * When `--json` results were capped by `--limit`, attached to the success envelope so agents can
29
- * detect truncation without scraping prose (#91 / AUDIT_71_FINAL #73 deferred).
45
+ * Aggregate counts (and an explicit empty/truncation signal) attached to the success envelope so
46
+ * agents get a cache-wide summary without a separate round trip (AXI principles: pre-computed
47
+ * aggregates, definitive empty states). Always set by `run()` before it returns.
30
48
  */
31
- jsonTruncation = null;
49
+ jsonSummary = null;
32
50
  static examples = [
33
51
  {
34
52
  description: 'list cached entries',
@@ -46,6 +64,10 @@ export default class ResearchList extends BaseCommand {
46
64
  description: 'list entries matching a URL glob',
47
65
  command: '<%= config.bin %> list --url "https://react.dev/*"',
48
66
  },
67
+ {
68
+ description: 'list every metadata field instead of the minimal default row',
69
+ command: '<%= config.bin %> list --full --json',
70
+ },
49
71
  ];
50
72
  static flags = {
51
73
  topic: Flags.string({
@@ -72,25 +94,18 @@ export default class ResearchList extends BaseCommand {
72
94
  description: 'capture method',
73
95
  options: CAPTURE_METHODS,
74
96
  })(),
75
- limit: limitFlag(100, 50, 'result count (default 50, max 100)'),
97
+ limit: limitFlag(LIST_DEFAULT_MAX_LIMIT, 50, `result count (max ${LIST_DEFAULT_MAX_LIMIT})`),
98
+ full: Flags.boolean({
99
+ default: false,
100
+ description: CLI_FLAG_DESCRIPTIONS.listFull,
101
+ }),
76
102
  };
77
103
  static stdoutIsPrimaryData = true;
78
- matchesTopic(meta, topic) {
79
- if (!topic)
80
- return true;
81
- return !!meta.topic && meta.topic.trim().toLowerCase() === topic.trim().toLowerCase();
82
- }
83
- matchesTags(meta, tags) {
84
- if (!tags || tags.length === 0)
85
- return true;
86
- const metaTagsLower = meta.tags.map((t) => t.toLowerCase());
87
- return tags.every((t) => metaTagsLower.includes(t.toLowerCase()));
88
- }
89
104
  matchesFilters(meta, freshness) {
90
- if (!this.matchesTopic(meta, this.flags.topic)) {
105
+ if (!matchesTopicFilter(meta, this.flags.topic)) {
91
106
  return false;
92
107
  }
93
- if (!this.matchesTags(meta, this.flags.tags)) {
108
+ if (!matchesTagsFilter(meta, this.flags.tags)) {
94
109
  return false;
95
110
  }
96
111
  if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
@@ -156,7 +171,9 @@ export default class ResearchList extends BaseCommand {
156
171
  return;
157
172
  this.log(`${resultListHeading(totalMatched, finalResults.length, LIST_LABELS)}\n`);
158
173
  finalResults.forEach((res, index) => {
159
- const topicStr = res.topic ? colors.cyan(res.topic) : colors.gray(NO_TOPIC_LABEL);
174
+ const topicStr = res.topic
175
+ ? colors.cyan(sanitizeForTerminal(res.topic))
176
+ : colors.gray(NO_TOPIC_LABEL);
160
177
  const keyStr = colors.bold(res.cacheKey);
161
178
  this.log(`${index + 1}. [${topicStr}] Key: ${keyStr}`);
162
179
  const freshnessColorMap = {
@@ -171,7 +188,10 @@ export default class ResearchList extends BaseCommand {
171
188
  this.log(` Source URLs: ${colors.gray(res.sourceUrls.join(', '))}\n`);
172
189
  });
173
190
  }
174
- /** Empty-cache / no-match tip (human mode only; --json returns `data: []` with no messaging). */
191
+ /**
192
+ * Empty-cache / no-match tip (human mode only). Under `--json`/`--toon`, `data: []` is paired with
193
+ * `summary.empty: true` instead of prose — see {@link toSuccessJson}.
194
+ */
175
195
  emitEmptyListGuidance() {
176
196
  if (this.jsonEnabled())
177
197
  return;
@@ -184,12 +204,19 @@ export default class ResearchList extends BaseCommand {
184
204
  ? 'try relaxing filters, or list everything: '
185
205
  : 'populate the cache first: ';
186
206
  this.log(headline);
187
- this.log(`\nTip: ${tipLead}${colors.cyan(tipCmd)}`);
207
+ this.log(`\n${formatTip(`${tipLead}${colors.cyan(tipCmd)}`)}`);
208
+ // Bare `bonsai` (no args) redirects here (content-first default), so an empty, unfiltered cache
209
+ // is also the first thing a brand-new user sees — point at --help so command discovery isn't lost.
210
+ if (!filtered) {
211
+ this.log(formatTip(`see every command: ${colors.cyan(`${this.config.bin} --help`)}`));
212
+ }
188
213
  }
189
214
  async run() {
190
- const urlErr = emptyUrlFilterError(this.flags.url);
191
- if (urlErr)
192
- this.error(urlErr, { exit: 2, code: 'INVALID_FLAG_VALUE' });
215
+ const flagErr = emptyUrlFilterError(this.flags.url) ??
216
+ emptyTopicFilterError(this.flags.topic) ??
217
+ emptyTagsFilterError(this.flags.tags);
218
+ if (flagErr)
219
+ this.error(flagErr, { exit: 2, code: 'INVALID_FLAG_VALUE' });
193
220
  const roots = loadStoreRoots({
194
221
  configDir: this.config.configDir,
195
222
  cwd: process.cwd(),
@@ -203,23 +230,24 @@ export default class ResearchList extends BaseCommand {
203
230
  return timeB - timeA;
204
231
  });
205
232
  const finalResults = results.slice(0, this.flags.limit);
206
- // Human heading carries truncation; --json gets an envelope `truncation` object when capped.
207
- this.jsonTruncation =
208
- results.length > finalResults.length
209
- ? {
210
- totalMatched: results.length,
211
- shown: finalResults.length,
212
- limit: this.flags.limit,
213
- }
214
- : null;
233
+ // Human heading carries truncation inline; --json/--toon get the same counts (plus a freshness
234
+ // breakdown and an explicit `empty` flag) as an always-present envelope `summary` object.
235
+ this.jsonSummary = {
236
+ total: results.length,
237
+ shown: finalResults.length,
238
+ limit: this.flags.limit,
239
+ truncated: results.length > finalResults.length,
240
+ empty: results.length === 0,
241
+ byFreshness: countByFreshness(results),
242
+ };
215
243
  this.logListResults(finalResults, results.length);
216
- return finalResults;
244
+ return this.flags.full ? finalResults : finalResults.map(toMinimalListRow);
217
245
  }
218
- /** Attach `truncation` when the returned `data` array was capped by `--limit`. */
246
+ /** Attach the aggregate `summary` computed in `run()` (see {@link ListSummary}). */
219
247
  toSuccessJson(data) {
220
248
  const envelope = this.baseSuccessJson(data);
221
- if (!this.jsonTruncation)
249
+ if (!this.jsonSummary)
222
250
  return envelope;
223
- return { ...envelope, truncation: this.jsonTruncation };
251
+ return { ...envelope, summary: this.jsonSummary };
224
252
  }
225
253
  }
@@ -13,6 +13,8 @@ export default class ResearchPrune extends BaseCommand<typeof ResearchPrune> {
13
13
  'older-than': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
14
  inactive: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
15
  url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
18
  'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | "section" | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
19
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
20
  yes: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -7,8 +7,9 @@ import { scanCacheDir } from '../lib/research/storage.js';
7
7
  import { loadStoreRoots } from '../lib/research/store-roots.js';
8
8
  import { parseTtlToMs } from '../lib/research/freshness.js';
9
9
  import { ARTIFACT_TYPES } from '../lib/research/schema.js';
10
- import { NO_TOPIC_LABEL, pluralize } from '../lib/text.js';
10
+ import { NO_TOPIC_LABEL, pluralize, sanitizeForTerminal } from '../lib/text.js';
11
11
  import { artifactMatchesUrlFilter } from '../lib/research/url.js';
12
+ import { matchesTopicFilter, matchesTagsFilter } from '../lib/research/metadata-filters.js';
12
13
  import { pruneFlagError } from '../lib/prune-flags.js';
13
14
  import { colors } from '../lib/color.js';
14
15
  import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
@@ -16,7 +17,7 @@ import { CLI_FLAG_DESCRIPTIONS } from '../lib/cli-presentation.js';
16
17
  export default class ResearchPrune extends BaseCommand {
17
18
  static id = 'prune';
18
19
  static summary = 'Prune cached research artifacts';
19
- static description = 'Delete cached artifacts by content age, idle time, URL glob, or artifact type.';
20
+ static description = 'Delete cached artifacts by content age, idle time, URL glob, artifact type, topic, or tags.';
20
21
  static examples = [
21
22
  {
22
23
  description: 'preview pruning entries older than 30 days',
@@ -30,6 +31,10 @@ export default class ResearchPrune extends BaseCommand {
30
31
  description: 'prune entries matching a URL glob',
31
32
  command: '<%= config.bin %> prune --url "https://react.dev/*" --yes',
32
33
  },
34
+ {
35
+ description: 'prune entries tagged as deprecated',
36
+ command: '<%= config.bin %> prune --tags deprecated --yes',
37
+ },
33
38
  ];
34
39
  static flags = {
35
40
  'older-than': Flags.string({
@@ -41,6 +46,15 @@ export default class ResearchPrune extends BaseCommand {
41
46
  url: Flags.string({
42
47
  description: CLI_FLAG_DESCRIPTIONS.sourceUrlGlob,
43
48
  }),
49
+ topic: Flags.string({
50
+ char: 't',
51
+ description: CLI_FLAG_DESCRIPTIONS.filterTopic,
52
+ }),
53
+ tags: Flags.string({
54
+ char: 'g',
55
+ description: CLI_FLAG_DESCRIPTIONS.filterTags,
56
+ multiple: true,
57
+ }),
44
58
  'artifact-type': Flags.option({
45
59
  // Prune operates on every cached file, so it can target any artifact type — including the
46
60
  // `section`/`index` children a page generates (e.g. to clear orphans left after a source is
@@ -68,6 +82,8 @@ export default class ResearchPrune extends BaseCommand {
68
82
  inactive: this.flags.inactive,
69
83
  artifactType: this.flags['artifact-type'],
70
84
  url: this.flags.url,
85
+ topic: this.flags.topic,
86
+ tags: this.flags.tags,
71
87
  dryRun: this.flags['dry-run'],
72
88
  yes: this.flags.yes,
73
89
  readOnly: this.readOnly,
@@ -83,6 +99,12 @@ export default class ResearchPrune extends BaseCommand {
83
99
  if (this.flags.url && !artifactMatchesUrlFilter(meta, this.flags.url)) {
84
100
  return false;
85
101
  }
102
+ if (!matchesTopicFilter(meta, this.flags.topic)) {
103
+ return false;
104
+ }
105
+ if (!matchesTagsFilter(meta, this.flags.tags)) {
106
+ return false;
107
+ }
86
108
  const now = currentTime.getTime();
87
109
  // Content age (fetched_at, else validated_at) vs idle time (validated_at, else fetched_at) are
88
110
  // deliberately distinct so a recently revalidated but originally-old page can match --older-than
@@ -118,7 +140,12 @@ export default class ResearchPrune extends BaseCommand {
118
140
  prunedCount++;
119
141
  }
120
142
  catch (err) {
121
- this.warn(`Failed to delete cache file ${f.path}: ${err.message}`);
143
+ // A failed unlink still flips the command to exit 1 (see the caller's comment), so it must
144
+ // render as an Error, not a Warning — this.error's exit:false form prints the same "Error: …"
145
+ // line and returns instead of throwing, so the remaining candidates still get processed.
146
+ this.error(`Failed to delete cache file ${f.path}: ${err.message}`, {
147
+ exit: false,
148
+ });
122
149
  }
123
150
  }
124
151
  return prunedCount;
@@ -147,8 +174,12 @@ export default class ResearchPrune extends BaseCommand {
147
174
  const noun = pluralize(count, 'entry', 'entries');
148
175
  this.log(colors.yellow(`[Dry Run] Found ${count} research cache ${noun} that would be pruned:\n`));
149
176
  filesToPrune.forEach((f) => {
150
- this.log(`- [${f.topic ? colors.cyan(f.topic) : colors.gray(NO_TOPIC_LABEL)}] Key: ${colors.bold(f.cacheKey)} (${colors.gray(f.url || 'Imported note')})`);
177
+ const topicStr = f.topic
178
+ ? colors.cyan(sanitizeForTerminal(f.topic))
179
+ : colors.gray(NO_TOPIC_LABEL);
180
+ this.log(`- [${topicStr}] Key: ${colors.bold(f.cacheKey)} (${colors.gray(f.url || 'Imported note')})`);
151
181
  });
182
+ this.tip('re-run with --yes to prune these entries.');
152
183
  }
153
184
  }
154
185
  }
@@ -32,6 +32,10 @@ export default class ResearchStatus extends BaseCommand {
32
32
  description: 'evaluate status with a specific tier and TTL',
33
33
  command: '<%= config.bin %> <%= command.id %> https://example.com/docs --tier volatile --ttl 2h',
34
34
  },
35
+ {
36
+ description: 'check multiple URLs in one batch (space-separated)',
37
+ command: '<%= config.bin %> <%= command.id %> https://example.com/docs https://example.com/api',
38
+ },
35
39
  ];
36
40
  static strict = false;
37
41
  static args = {
@@ -83,6 +87,12 @@ export default class ResearchStatus extends BaseCommand {
83
87
  if (result.status === 'miss') {
84
88
  this.warn(cacheMissHint(this.config.bin, normalizedUrl));
85
89
  }
90
+ else if (result.action === 'would_revalidate') {
91
+ this.tip(`${this.config.bin} ${normalizedUrl} to revalidate.`);
92
+ }
93
+ else {
94
+ this.tip(`${this.config.bin} inspect ${normalizedUrl} for full metadata.`);
95
+ }
86
96
  }
87
97
  return {
88
98
  cacheKey,
@@ -1,5 +1,5 @@
1
1
  import { type Hook, type Interfaces } from '@oclif/core';
2
- export declare function buildCommandNotFoundDetails(id: string, argv: string[] | undefined, config: Interfaces.Config): {
2
+ export declare function buildCommandNotFoundDetails(rawId: string, argv: string[] | undefined, config: Interfaces.Config): {
3
3
  code: 'COMMAND_NOT_FOUND' | 'MISSING_URL_SCHEME' | 'UNEXPECTED_ARGUMENT';
4
4
  command: string;
5
5
  jsonMode: boolean;
@@ -1,5 +1,5 @@
1
1
  import { toConfiguredId } from '@oclif/core';
2
- import { closestMatch, maxFuzzyDistance } from '../../lib/text.js';
2
+ import { closestMatch, maxFuzzyDistance, sanitizeForTerminal } from '../../lib/text.js';
3
3
  import { writeCommandNotFoundJson } from '../../lib/cli-emit.js';
4
4
  import { looksLikeSchemelessUrl } from '../../lib/research/url.js';
5
5
  /**
@@ -97,7 +97,17 @@ function missingUrlSchemeDetails(urls, argv, config) {
97
97
  suggestions: [suggestion],
98
98
  };
99
99
  }
100
- export function buildCommandNotFoundDetails(id, argv, config) {
100
+ export function buildCommandNotFoundDetails(rawId, argv, config) {
101
+ // The attempted id is raw user input echoed straight back into this error's message and
102
+ // suggestions — untrusted per the repo's trust-boundary rules, same reasoning as
103
+ // BaseCommand.error(). Sanitize once, up front, so every derived value below (segments, the
104
+ // display-only "attempted" string, a bare-URL correction) is already safe; a real command/topic id
105
+ // never contains control bytes, so this only affects input that could never have matched one
106
+ // anyway. Gate on jsonMode exactly like BaseCommand.error() does, so `--json` keeps
107
+ // byte-for-byte fidelity — this hook fires before a Command instance exists, so there's no
108
+ // `jsonEnabled()` to call, but the same boolean is cheap to compute directly from argv.
109
+ const jsonMode = isJsonMode(argv);
110
+ const id = jsonMode ? rawId : sanitizeForTerminal(rawId);
101
111
  // Match against every loaded command id — including hidden ones that stay invokable
102
112
  // (`fetch`, plugin internals). Root `--help` hides them; typo recovery should not.
103
113
  const commandIds = config.commandIDs;
@@ -16,6 +16,11 @@ export interface NormalizationResult {
16
16
  export interface ArgvNormalizeOptions {
17
17
  /** Tokens that consume the next argv value (`--topic`, `-t`, …). From cli-flag-manifest. */
18
18
  valueTakingFlags: ReadonlySet<string>;
19
+ /**
20
+ * Root segment of every registered command id (`config:get` → `config`). From cli-flag-manifest.
21
+ * A `word:` token whose root is in this set is a command (`config:get`), never a URL scheme.
22
+ */
23
+ knownCommandRoots: ReadonlySet<string>;
19
24
  }
20
25
  /**
21
26
  * Normalize raw process argv for oclif. `valueTakingFlags` must come from the composition root
package/dist/lib/argv.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { EXIT_USAGE } from './cli-error-policy.js';
2
2
  import { buildCliErrorEnvelope } from './envelope.js';
3
+ import { sanitizeForTerminal } from './text.js';
3
4
  /** Shared MISSING_COMMAND copy for flag-only argv preflight. */
4
5
  function missingCommandDetails(bin, swallowed) {
5
6
  if (swallowed) {
@@ -26,7 +27,7 @@ function missingCommandDetails(bin, swallowed) {
26
27
  * it picks a command, so `bonsai --read-only list` would otherwise treat `--read-only` as the
27
28
  * command id. Strip and re-append them after the command/URL, same pattern as `--json`.
28
29
  */
29
- const GLOBAL_BOOLEAN_FLAGS = new Set(['--read-only', '--plan']);
30
+ const GLOBAL_BOOLEAN_FLAGS = new Set(['--read-only', '--plan', '--toon']);
30
31
  /**
31
32
  * When a value-taking flag is the only "command" and its value looks like a URL, the user almost
32
33
  * certainly put the URL where the flag value belongs (e.g. `bonsai --tags https://example.com`).
@@ -47,7 +48,13 @@ function findSwallowedUrlFlag(argv, valueTakingFlags) {
47
48
  return null;
48
49
  }
49
50
  function missingUsageExit(json, argv, valueTakingFlags) {
50
- const details = missingCommandDetails('bonsai', findSwallowedUrlFlag(argv, valueTakingFlags));
51
+ const swallowed = findSwallowedUrlFlag(argv, valueTakingFlags);
52
+ // `swallowed.url` is raw user input echoed straight back into the MISSING_COMMAND message and its
53
+ // "pass the URL as the command" suggestion — untrusted per the repo's trust-boundary rules, same
54
+ // reasoning as BaseCommand.error(). This preflight path fires before oclif ever dispatches a
55
+ // command, so there is no `this.jsonEnabled()` to call — but `json` is already known here, so gate
56
+ // on it the same way: sanitize only the human-mode message, keep `--json` byte-for-byte.
57
+ const details = missingCommandDetails('bonsai', swallowed && !json ? { ...swallowed, url: sanitizeForTerminal(swallowed.url) } : swallowed);
51
58
  return {
52
59
  exitCode: EXIT_USAGE,
53
60
  json,
@@ -86,7 +93,15 @@ function hasCommandToken(argv, valueTakingFlags) {
86
93
  * (`cli-flag-manifest`) — this module stays free of command registry imports.
87
94
  */
88
95
  export function normalizeArgv(rawArgv, options) {
89
- const { valueTakingFlags } = options;
96
+ const { valueTakingFlags, knownCommandRoots } = options;
97
+ // AXI "content first": a truly bare invocation (`bonsai`, no args/flags at all) shows live cache
98
+ // data instead of oclif's default root help — `bonsai help`, `--help`, and `-h` remain the
99
+ // explicit path to the command reference (all of them are non-empty argv and skip this branch).
100
+ // oclif has no built-in "default command" concept for empty argv (see oclif/oclif#277), so this
101
+ // is a one-line argv rewrite, the same technique the URL-shorthand rewrite below already uses.
102
+ if (rawArgv.length === 0) {
103
+ return { argv: ['list'] };
104
+ }
90
105
  // oclif's JSON flag is command-scoped, so `bonsai list --json` works but
91
106
  // `bonsai --json list` is otherwise parsed as an unknown command named
92
107
  // "--json". Collect every --json, append one copy after the command/URL, and
@@ -113,8 +128,15 @@ export function normalizeArgv(rawArgv, options) {
113
128
  // forms like `javascript:` or `data:` so fetch can reject unsupported protocols instead of oclif
114
129
  // reporting a misleading "command not found". If the invocation begins with a flag, allow common
115
130
  // flag-before-argument usage such as `bonsai --format detailed https://example.com`.
131
+ // Exclude tokens whose part before the first `:` names a real command (`config:get`) — oclif
132
+ // joins namespaced command ids with `:` regardless of the display-only topicSeparator, so those
133
+ // are commands, not URL schemes, and must reach normal command dispatch instead of a misleading
134
+ // "Invalid URL" error.
116
135
  const firstNonFlagArgIndex = firstPositionalIndex(core, valueTakingFlags);
117
- const rootFetchShape = firstNonFlagArgIndex !== -1 && looksLikeUrl(core[firstNonFlagArgIndex]);
136
+ const firstArg = firstNonFlagArgIndex !== -1 ? core[firstNonFlagArgIndex] : undefined;
137
+ const rootFetchShape = firstArg !== undefined &&
138
+ looksLikeUrl(firstArg) &&
139
+ !knownCommandRoots.has(firstArg.split(':')[0]);
118
140
  if (rootFetchShape) {
119
141
  const url = core[firstNonFlagArgIndex];
120
142
  core = [
@@ -131,7 +153,7 @@ export function normalizeArgv(rawArgv, options) {
131
153
  core.push('--json');
132
154
  // Flag-only argv (`--json`, `--read-only`, `--tags https://…`) never resolves a command.
133
155
  // Exit here so command_not_found does not special-case dash-prefixed ids.
134
- // Empty argv and `--help`/`--version` stay with oclif.
156
+ // Truly empty argv already returned above; `--help`/`--version` stay with oclif here.
135
157
  if (!helpRequested && core.length > 0 && !hasCommandToken(core, valueTakingFlags)) {
136
158
  return {
137
159
  argv: jsonMode ? ['--json'] : [],
@@ -3,3 +3,10 @@
3
3
  * Built from command class metadata so argv normalization stays free of command imports.
4
4
  */
5
5
  export declare const VALUE_TAKING_FLAG_TOKENS: ReadonlySet<string>;
6
+ /**
7
+ * Root segment of every registered command id (`config:get` → `config`). oclif joins namespaced
8
+ * command ids with `:` regardless of the display-only `topicSeparator` setting, so a token like
9
+ * `config:get` is a real command, not a URL — argv normalization checks this set before assuming
10
+ * `word:` is a URL scheme.
11
+ */
12
+ export declare const KNOWN_COMMAND_ROOT_TOKENS: ReadonlySet<string>;
@@ -6,3 +6,10 @@ import { valueTakingFlagTokensFromCommands } from './value-flags.js';
6
6
  * Built from command class metadata so argv normalization stays free of command imports.
7
7
  */
8
8
  export const VALUE_TAKING_FLAG_TOKENS = valueTakingFlagTokensFromCommands(commands, BaseCommand.baseFlags);
9
+ /**
10
+ * Root segment of every registered command id (`config:get` → `config`). oclif joins namespaced
11
+ * command ids with `:` regardless of the display-only `topicSeparator` setting, so a token like
12
+ * `config:get` is a real command, not a URL — argv normalization checks this set before assuming
13
+ * `word:` is a URL scheme.
14
+ */
15
+ export const KNOWN_COMMAND_ROOT_TOKENS = new Set(Object.keys(commands).map((id) => id.split(':')[0]));
@@ -8,17 +8,19 @@ export declare const CLI_FLAG_DESCRIPTIONS: {
8
8
  readonly maxAge: "maximum cache age to accept (e.g. \"2h\", \"7d\", \"6m\")";
9
9
  readonly fetchStorage: "override where this result is cached (secrets always stored globally)";
10
10
  readonly importStorage: "override where this note is cached (secrets always stored globally)";
11
- readonly fetchTopic: "main research topic for metadata";
12
- readonly importTopic: "main topic for this research note";
11
+ readonly fetchTopic: "main research topic for metadata (max 200 chars)";
12
+ readonly importTopic: "main topic for this research note (max 200 chars)";
13
13
  readonly filterTopic: "exact topic (case-insensitive)";
14
- readonly fetchTags: "taxonomic tags for this research (can be repeated)";
15
- readonly importTags: "taxonomic tags (can be repeated)";
14
+ readonly fetchTags: "taxonomic tags for this research (can be repeated, max 100 chars each)";
15
+ readonly importTags: "taxonomic tags (can be repeated, max 100 chars each)";
16
16
  readonly filterTags: "tags to require (must match all)";
17
17
  readonly format: "output format";
18
18
  readonly readOnly: "(alias: --plan) block filesystem writes/deletes; network fetches still run; also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE";
19
+ readonly toon: "emit the same envelope as --json, encoded as TOON (fewer tokens); cannot be combined with --json";
19
20
  readonly sourceUrlGlob: "source URL glob (case-insensitive, supports * wildcard)";
20
21
  readonly listArtifactType: "artifact type; section children are omitted from list - use inspect to see them";
21
22
  readonly pruneArtifactType: "artifact type to prune, including section children";
23
+ readonly listFull: "include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps); default output is a minimal row (source URLs, topic, freshness, token estimate)";
22
24
  };
23
25
  /** One human-readable label/value pair for CLI stdout. */
24
26
  export type HumanField = readonly [label: string, value: string];
@@ -1,4 +1,5 @@
1
1
  import { colors } from './color.js';
2
+ import { MAX_TAG_LENGTH, MAX_TOPIC_LENGTH } from './research/metadata-flags.js';
2
3
  const HUMAN_LABEL_WIDTH = 25;
3
4
  // Repeated verbatim across several flag descriptions; single-use wording lives inline below.
4
5
  const DURATION_EXAMPLES = 'e.g. "2h", "7d", "6m"';
@@ -13,17 +14,19 @@ export const CLI_FLAG_DESCRIPTIONS = {
13
14
  maxAge: `maximum cache age to accept (${DURATION_EXAMPLES})`,
14
15
  fetchStorage: `override where this result is cached (${SECRET_STORAGE_NOTE})`,
15
16
  importStorage: `override where this note is cached (${SECRET_STORAGE_NOTE})`,
16
- fetchTopic: 'main research topic for metadata',
17
- importTopic: 'main topic for this research note',
17
+ fetchTopic: `main research topic for metadata (max ${MAX_TOPIC_LENGTH} chars)`,
18
+ importTopic: `main topic for this research note (max ${MAX_TOPIC_LENGTH} chars)`,
18
19
  filterTopic: 'exact topic (case-insensitive)',
19
- fetchTags: 'taxonomic tags for this research (can be repeated)',
20
- importTags: 'taxonomic tags (can be repeated)',
20
+ fetchTags: `taxonomic tags for this research (can be repeated, max ${MAX_TAG_LENGTH} chars each)`,
21
+ importTags: `taxonomic tags (can be repeated, max ${MAX_TAG_LENGTH} chars each)`,
21
22
  filterTags: 'tags to require (must match all)',
22
23
  format: 'output format',
23
24
  readOnly: '(alias: --plan) block filesystem writes/deletes; network fetches still run; also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE',
25
+ toon: 'emit the same envelope as --json, encoded as TOON (fewer tokens); cannot be combined with --json',
24
26
  sourceUrlGlob: 'source URL glob (case-insensitive, supports * wildcard)',
25
27
  listArtifactType: 'artifact type; section children are omitted from list - use inspect to see them',
26
28
  pruneArtifactType: 'artifact type to prune, including section children',
29
+ listFull: 'include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps); default output is a minimal row (source URLs, topic, freshness, token estimate)',
27
30
  };
28
31
  function formatHumanLabel(label) {
29
32
  const text = `${label}:`;
@@ -33,7 +33,7 @@ export type CacheHitStatus = 'hit' | 'miss' | 'stale' | 'refreshed' | 'revalidat
33
33
  export type FreshnessState = 'fresh' | 'stale_grace' | 'stale_expired' | 'none';
34
34
  /** Freshness values that appear on list rows (an entry always exists). */
35
35
  export type ListFreshness = Exclude<FreshnessState, 'none'>;
36
- /** One row in `list --json` output. */
36
+ /** One row in `list --json` output when `--full` is passed. */
37
37
  export interface ListRow {
38
38
  cacheKey: string;
39
39
  path: string;
@@ -48,6 +48,30 @@ export interface ListRow {
48
48
  fetchedAt: string | null;
49
49
  validatedAt: string | null;
50
50
  }
51
+ /**
52
+ * Default `list --json`/`--toon` row: the fields an agent needs to judge relevance and act next
53
+ * (fetch/inspect the source, gauge freshness, budget tokens) without the full metadata dump.
54
+ * Pass `--full` for every field on {@link ListRow}.
55
+ */
56
+ export interface ListRowMinimal {
57
+ sourceUrls: string[];
58
+ topic: string | null;
59
+ freshness: ListFreshness;
60
+ tokenEstimate: TokenEstimate;
61
+ }
62
+ /** Aggregate counts attached to `list`'s envelope so agents skip extra round trips. */
63
+ export interface ListSummary {
64
+ /** Entries matching the given filters, before `--limit` truncation. */
65
+ total: number;
66
+ /** Entries actually returned in `data` (after `--limit`). */
67
+ shown: number;
68
+ limit: number;
69
+ /** True when `total > shown`, i.e. more entries matched than `--limit` allowed through. */
70
+ truncated: boolean;
71
+ /** Explicit signal for `total === 0`, so an empty `data: []` is never ambiguous. */
72
+ empty: boolean;
73
+ byFreshness: Record<ListFreshness, number>;
74
+ }
51
75
  /** Section child summary nested under an inspect hit. */
52
76
  export interface InspectSectionRow {
53
77
  cacheKey: string;
@@ -1,5 +1,5 @@
1
- export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type SummaryLevel, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
2
- export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
1
+ export { type ConfigValues, type ResolvedConfig, type ConfigKey, type StorageMode, type SummaryLevel, type KeyMeta, SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, keyValuesHint, } from './schema.js';
2
+ export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, invalidConfigFileWarnings, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
3
3
  export { resolveStorageMode, resolveSummaryLevel, resolveReadOnly, parseEnvStorage, parseEnvSummary, parseEnvBoolean, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, READ_ONLY_ENV_VAR, PLAN_MODE_ENV_VAR, type ResolveStorageInput, type ResolveSummaryInput, type ResolveReadOnlyInput, } from './resolve.js';
4
4
  import { type ConfigKey, type ConfigValues, type ResolvedConfig, type StorageMode, type SummaryLevel } from './schema.js';
5
5
  /**
@@ -1,5 +1,5 @@
1
- export { SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, } from './schema.js';
2
- export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
1
+ export { SCHEMA_VERSION, STORAGE_MODES, SUMMARY_LEVELS, ALL_KEYS, BUILT_IN_DEFAULTS, KEY_META, isKnownKey, suggestKey, validKeysHint, keyValuesHint, } from './schema.js';
2
+ export { readUserConfig, readProjectConfig, writeUserConfig, writeProjectConfig, invalidConfigFileWarnings, USER_CONFIG_FILENAME, PROJECT_CONFIG_FILENAME, } from './io.js';
3
3
  export { resolveStorageMode, resolveSummaryLevel, resolveReadOnly, parseEnvStorage, parseEnvSummary, parseEnvBoolean, invalidEnvOverrideWarnings, STORAGE_ENV_VAR, SUMMARY_ENV_VAR, READ_ONLY_ENV_VAR, PLAN_MODE_ENV_VAR, } from './resolve.js';
4
4
  import { readUserConfig, readProjectConfig } from './io.js';
5
5
  import { parseEnvStorage, parseEnvSummary, resolveStorageMode, resolveSummaryLevel, } from './resolve.js';
@@ -4,5 +4,14 @@ export declare const USER_CONFIG_FILENAME = "config.json";
4
4
  export declare const PROJECT_CONFIG_FILENAME = ".bonsai.json";
5
5
  export declare function readUserConfig(configDir: string | undefined): Partial<ConfigValues>;
6
6
  export declare function readProjectConfig(cwd: string): Partial<ConfigValues>;
7
+ /**
8
+ * Warnings for the user and project config files, so a corrupted or hand-edited file that would
9
+ * otherwise silently degrade to `{}` (or drop just the offending key) is never a silent surprise —
10
+ * mirrors {@link invalidEnvOverrideWarnings} for env vars. Empty when both files are missing,
11
+ * unreadable, or valid.
12
+ *
13
+ * @param bin - CLI binary name (`this.config.bin`) for the "fix it" suggestion in a warning.
14
+ */
15
+ export declare function invalidConfigFileWarnings(configDir: string | undefined, cwd: string, bin: string): string[];
7
16
  export declare function writeUserConfig(configDir: string, patch: Partial<ConfigValues>): void;
8
17
  export declare function writeProjectConfig(cwd: string, patch: Partial<ConfigValues>): void;