@taurgis/bonsai 3.2.2 → 4.1.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 (100) hide show
  1. package/README.md +25 -2
  2. package/agents/agents/official-docs-researcher.agent.md +102 -0
  3. package/agents/agents/salesforce-docs-researcher.agent.md +17 -15
  4. package/agents/hooks/README.md +18 -2
  5. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +1 -1
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +1 -1
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +1 -1
  8. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +1 -1
  9. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +1 -1
  10. package/agents/instructions/salesforce-research.instructions.md +5 -4
  11. package/agents/instructions/web-research.instructions.md +45 -0
  12. package/agents/skills/web-research/SKILL.md +139 -0
  13. package/bin/cli.mjs +7 -4
  14. package/dist/base-command.d.ts +42 -0
  15. package/dist/base-command.js +106 -6
  16. package/dist/commands/config/base.d.ts +2 -7
  17. package/dist/commands/config/base.js +4 -17
  18. package/dist/commands/config/get.js +2 -2
  19. package/dist/commands/config/list.js +2 -2
  20. package/dist/commands/config/set.js +2 -2
  21. package/dist/commands/config/unset.js +2 -2
  22. package/dist/commands/context.d.ts +20 -0
  23. package/dist/commands/context.js +65 -0
  24. package/dist/commands/fetch.js +4 -0
  25. package/dist/commands/inspect.js +11 -2
  26. package/dist/commands/list.d.ts +23 -11
  27. package/dist/commands/list.js +110 -121
  28. package/dist/commands/prune.d.ts +2 -0
  29. package/dist/commands/prune.js +35 -4
  30. package/dist/commands/search.d.ts +43 -0
  31. package/dist/commands/search.js +233 -0
  32. package/dist/commands/setup.d.ts +27 -0
  33. package/dist/commands/setup.js +148 -0
  34. package/dist/commands/status.js +10 -0
  35. package/dist/commands.d.ts +6 -0
  36. package/dist/commands.js +6 -0
  37. package/dist/hooks/command-not-found/suggest.d.ts +1 -1
  38. package/dist/hooks/command-not-found/suggest.js +12 -2
  39. package/dist/lib/argv.d.ts +5 -0
  40. package/dist/lib/argv.js +30 -5
  41. package/dist/lib/cli-flag-manifest.d.ts +7 -0
  42. package/dist/lib/cli-flag-manifest.js +7 -0
  43. package/dist/lib/cli-presentation.d.ts +25 -4
  44. package/dist/lib/cli-presentation.js +34 -4
  45. package/dist/lib/cli-result-types.d.ts +84 -1
  46. package/dist/lib/color.d.ts +2 -0
  47. package/dist/lib/color.js +6 -0
  48. package/dist/lib/common-metadata-filter-flags.d.ts +14 -0
  49. package/dist/lib/common-metadata-filter-flags.js +37 -0
  50. package/dist/lib/config/index.d.ts +2 -2
  51. package/dist/lib/config/index.js +2 -2
  52. package/dist/lib/config/io.d.ts +9 -0
  53. package/dist/lib/config/io.js +71 -21
  54. package/dist/lib/config/schema.d.ts +2 -0
  55. package/dist/lib/config/schema.js +7 -0
  56. package/dist/lib/envelope.js +11 -4
  57. package/dist/lib/next-command.d.ts +15 -0
  58. package/dist/lib/next-command.js +40 -0
  59. package/dist/lib/prune-flags.d.ts +2 -0
  60. package/dist/lib/prune-flags.js +18 -6
  61. package/dist/lib/research/artifact-index.js +7 -1
  62. package/dist/lib/research/artifact.js +20 -2
  63. package/dist/lib/research/browser.d.ts +12 -0
  64. package/dist/lib/research/browser.js +63 -8
  65. package/dist/lib/research/cli-io.d.ts +7 -0
  66. package/dist/lib/research/context-dashboard.d.ts +19 -0
  67. package/dist/lib/research/context-dashboard.js +43 -0
  68. package/dist/lib/research/fetch-command-service.js +90 -23
  69. package/dist/lib/research/fetch-result.d.ts +1 -0
  70. package/dist/lib/research/fetch-result.js +4 -0
  71. package/dist/lib/research/fetcher.js +3 -1
  72. package/dist/lib/research/import-command-service.js +22 -1
  73. package/dist/lib/research/list-row.d.ts +15 -0
  74. package/dist/lib/research/list-row.js +31 -0
  75. package/dist/lib/research/metadata-filters.d.ts +39 -0
  76. package/dist/lib/research/metadata-filters.js +71 -0
  77. package/dist/lib/research/metadata-flags.d.ts +34 -0
  78. package/dist/lib/research/metadata-flags.js +45 -0
  79. package/dist/lib/research/prompt-injection.d.ts +2 -1
  80. package/dist/lib/research/prompt-injection.js +18 -2
  81. package/dist/lib/research/resolve-target.d.ts +2 -0
  82. package/dist/lib/research/resolve-target.js +1 -1
  83. package/dist/lib/research/schema.d.ts +2 -0
  84. package/dist/lib/research/schema.js +4 -0
  85. package/dist/lib/research/search-match.d.ts +39 -0
  86. package/dist/lib/research/search-match.js +134 -0
  87. package/dist/lib/research/storage.d.ts +4 -7
  88. package/dist/lib/research/storage.js +26 -25
  89. package/dist/lib/scope-flags.d.ts +19 -0
  90. package/dist/lib/scope-flags.js +27 -0
  91. package/dist/lib/setup/agents.d.ts +16 -0
  92. package/dist/lib/setup/agents.js +20 -0
  93. package/dist/lib/setup/bin-command.d.ts +14 -0
  94. package/dist/lib/setup/bin-command.js +42 -0
  95. package/dist/lib/setup/hook-file-io.d.ts +37 -0
  96. package/dist/lib/setup/hook-file-io.js +80 -0
  97. package/dist/lib/text.d.ts +20 -8
  98. package/dist/lib/text.js +32 -9
  99. package/oclif.manifest.json +406 -13
  100. package/package.json +2 -1
@@ -1,3 +1,4 @@
1
+ import type { TokenEstimate } from './research/schema.js';
1
2
  /** Shared flag/help copy so identical flags read identically across commands. */
2
3
  export declare const CLI_FLAG_DESCRIPTIONS: {
3
4
  readonly freshnessTierPolicy: "freshness tier policy";
@@ -8,17 +9,23 @@ export declare const CLI_FLAG_DESCRIPTIONS: {
8
9
  readonly maxAge: "maximum cache age to accept (e.g. \"2h\", \"7d\", \"6m\")";
9
10
  readonly fetchStorage: "override where this result is cached (secrets always stored globally)";
10
11
  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";
12
+ readonly fetchTopic: "main research topic for metadata (max 200 chars)";
13
+ readonly importTopic: "main topic for this research note (max 200 chars)";
13
14
  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)";
15
+ readonly fetchTags: "taxonomic tags for this research (can be repeated, max 100 chars each)";
16
+ readonly importTags: "taxonomic tags (can be repeated, max 100 chars each)";
16
17
  readonly filterTags: "tags to require (must match all)";
17
18
  readonly format: "output format";
18
19
  readonly readOnly: "(alias: --plan) block filesystem writes/deletes; network fetches still run; also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE";
20
+ readonly toon: "emit the same envelope as --json, encoded as TOON (fewer tokens); cannot be combined with --json";
19
21
  readonly sourceUrlGlob: "source URL glob (case-insensitive, supports * wildcard)";
20
22
  readonly listArtifactType: "artifact type; section children are omitted from list - use inspect to see them";
21
23
  readonly pruneArtifactType: "artifact type to prune, including section children";
24
+ 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)";
25
+ readonly searchQuery: "keyword(s) to search across topic, tags, summary, and compressed content (case-insensitive); every term must match somewhere unless --match-any is set";
26
+ readonly searchMatchAny: "match any query term instead of requiring all of them (OR instead of AND)";
27
+ readonly searchArtifactType: "artifact type; section children are omitted from search - use inspect to see them";
28
+ readonly searchFull: "include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps) alongside score/matchedFields/snippet; default output is a minimal row (source URLs, topic, freshness, token estimate, score, matchedFields, snippet)";
22
29
  };
23
30
  /** One human-readable label/value pair for CLI stdout. */
24
31
  export type HumanField = readonly [label: string, value: string];
@@ -37,3 +44,17 @@ export declare function formatHumanField(label: string, value: string): string;
37
44
  * @returns Formatted lines in the same order.
38
45
  */
39
46
  export declare function formatHumanFields(fields: readonly HumanField[]): string[];
47
+ /**
48
+ * The "N. [Topic] Key: <key>" header line shared by `list`/`search` human-mode row rendering —
49
+ * same topic coloring, ANSI-sanitization, and "(no topic)" fallback either way.
50
+ *
51
+ * @param index - Zero-based row index; displayed as `index + 1`.
52
+ * @param topic - The row's topic, or `null` to show {@link NO_TOPIC_LABEL}.
53
+ * @param cacheKey - The row's cache key.
54
+ * @returns A single formatted line.
55
+ */
56
+ export declare function formatResultRowHeader(index: number, topic: string | null, cacheKey: string): string;
57
+ /** The "Tokens: compressed=N, detailed=N" line shared by `list`/`search` human-mode row rendering. */
58
+ export declare function formatResultRowTokens(tokenEstimate: TokenEstimate): string;
59
+ /** The "Source URLs: ..." line shared by `list`/`search` human-mode row rendering. */
60
+ export declare function formatResultRowSourceUrls(sourceUrls: readonly string[]): string;
@@ -1,4 +1,6 @@
1
1
  import { colors } from './color.js';
2
+ import { MAX_TAG_LENGTH, MAX_TOPIC_LENGTH } from './research/metadata-flags.js';
3
+ import { NO_TOPIC_LABEL, sanitizeForTerminal } from './text.js';
2
4
  const HUMAN_LABEL_WIDTH = 25;
3
5
  // Repeated verbatim across several flag descriptions; single-use wording lives inline below.
4
6
  const DURATION_EXAMPLES = 'e.g. "2h", "7d", "6m"';
@@ -13,17 +15,23 @@ export const CLI_FLAG_DESCRIPTIONS = {
13
15
  maxAge: `maximum cache age to accept (${DURATION_EXAMPLES})`,
14
16
  fetchStorage: `override where this result is cached (${SECRET_STORAGE_NOTE})`,
15
17
  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',
18
+ fetchTopic: `main research topic for metadata (max ${MAX_TOPIC_LENGTH} chars)`,
19
+ importTopic: `main topic for this research note (max ${MAX_TOPIC_LENGTH} chars)`,
18
20
  filterTopic: 'exact topic (case-insensitive)',
19
- fetchTags: 'taxonomic tags for this research (can be repeated)',
20
- importTags: 'taxonomic tags (can be repeated)',
21
+ fetchTags: `taxonomic tags for this research (can be repeated, max ${MAX_TAG_LENGTH} chars each)`,
22
+ importTags: `taxonomic tags (can be repeated, max ${MAX_TAG_LENGTH} chars each)`,
21
23
  filterTags: 'tags to require (must match all)',
22
24
  format: 'output format',
23
25
  readOnly: '(alias: --plan) block filesystem writes/deletes; network fetches still run; also honored via BONSAI_READ_ONLY/BONSAI_PLAN_MODE',
26
+ toon: 'emit the same envelope as --json, encoded as TOON (fewer tokens); cannot be combined with --json',
24
27
  sourceUrlGlob: 'source URL glob (case-insensitive, supports * wildcard)',
25
28
  listArtifactType: 'artifact type; section children are omitted from list - use inspect to see them',
26
29
  pruneArtifactType: 'artifact type to prune, including section children',
30
+ 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)',
31
+ searchQuery: 'keyword(s) to search across topic, tags, summary, and compressed content (case-insensitive); every term must match somewhere unless --match-any is set',
32
+ searchMatchAny: 'match any query term instead of requiring all of them (OR instead of AND)',
33
+ searchArtifactType: 'artifact type; section children are omitted from search - use inspect to see them',
34
+ searchFull: 'include every metadata field (cache key, path, artifact type, tags, capture method, quality notes, timestamps) alongside score/matchedFields/snippet; default output is a minimal row (source URLs, topic, freshness, token estimate, score, matchedFields, snippet)',
27
35
  };
28
36
  function formatHumanLabel(label) {
29
37
  const text = `${label}:`;
@@ -48,3 +56,25 @@ export function formatHumanField(label, value) {
48
56
  export function formatHumanFields(fields) {
49
57
  return fields.map(([label, value]) => formatHumanField(label, value));
50
58
  }
59
+ /**
60
+ * The "N. [Topic] Key: <key>" header line shared by `list`/`search` human-mode row rendering —
61
+ * same topic coloring, ANSI-sanitization, and "(no topic)" fallback either way.
62
+ *
63
+ * @param index - Zero-based row index; displayed as `index + 1`.
64
+ * @param topic - The row's topic, or `null` to show {@link NO_TOPIC_LABEL}.
65
+ * @param cacheKey - The row's cache key.
66
+ * @returns A single formatted line.
67
+ */
68
+ export function formatResultRowHeader(index, topic, cacheKey) {
69
+ const topicStr = topic ? colors.cyan(sanitizeForTerminal(topic)) : colors.gray(NO_TOPIC_LABEL);
70
+ const keyStr = colors.bold(cacheKey);
71
+ return `${index + 1}. [${topicStr}] Key: ${keyStr}`;
72
+ }
73
+ /** The "Tokens: compressed=N, detailed=N" line shared by `list`/`search` human-mode row rendering. */
74
+ export function formatResultRowTokens(tokenEstimate) {
75
+ return ` Tokens: compressed=${colors.bold(String(tokenEstimate?.compressed || 0))}, detailed=${colors.bold(String(tokenEstimate?.detailed || 0))}`;
76
+ }
77
+ /** The "Source URLs: ..." line shared by `list`/`search` human-mode row rendering. */
78
+ export function formatResultRowSourceUrls(sourceUrls) {
79
+ return ` Source URLs: ${colors.gray(sourceUrls.join(', '))}\n`;
80
+ }
@@ -1,5 +1,6 @@
1
1
  /** Shared machine-facing write/result shapes for CLI JSON envelopes. */
2
2
  import type { ArtifactType, CaptureMethod, ResearchArtifactMetadata, TokenEstimate } from './research/schema.js';
3
+ import type { SearchMatchField } from './research/search-match.js';
3
4
  /** Result of `config set` / `config unset` for the JSON envelope. */
4
5
  export interface ConfigWriteResult {
5
6
  key: string;
@@ -33,7 +34,7 @@ export type CacheHitStatus = 'hit' | 'miss' | 'stale' | 'refreshed' | 'revalidat
33
34
  export type FreshnessState = 'fresh' | 'stale_grace' | 'stale_expired' | 'none';
34
35
  /** Freshness values that appear on list rows (an entry always exists). */
35
36
  export type ListFreshness = Exclude<FreshnessState, 'none'>;
36
- /** One row in `list --json` output. */
37
+ /** One row in `list --json` output when `--full` is passed. */
37
38
  export interface ListRow {
38
39
  cacheKey: string;
39
40
  path: string;
@@ -48,6 +49,60 @@ export interface ListRow {
48
49
  fetchedAt: string | null;
49
50
  validatedAt: string | null;
50
51
  }
52
+ /**
53
+ * Default `list --json`/`--toon` row: the fields an agent needs to judge relevance and act next
54
+ * (fetch/inspect the source, gauge freshness, budget tokens) without the full metadata dump.
55
+ * Pass `--full` for every field on {@link ListRow}.
56
+ */
57
+ export interface ListRowMinimal {
58
+ sourceUrls: string[];
59
+ topic: string | null;
60
+ freshness: ListFreshness;
61
+ tokenEstimate: TokenEstimate;
62
+ }
63
+ /** Aggregate counts attached to `list`'s envelope so agents skip extra round trips. */
64
+ export interface ListSummary {
65
+ /** Entries matching the given filters, before `--limit` truncation. */
66
+ total: number;
67
+ /** Entries actually returned in `data` (after `--limit`). */
68
+ shown: number;
69
+ limit: number;
70
+ /** True when `total > shown`, i.e. more entries matched than `--limit` allowed through. */
71
+ truncated: boolean;
72
+ /** Explicit signal for `total === 0`, so an empty `data: []` is never ambiguous. */
73
+ empty: boolean;
74
+ byFreshness: Record<ListFreshness, number>;
75
+ /**
76
+ * A copy-pasteable next invocation that reproduces the caller's own filters with a raised
77
+ * `--limit`, so a truncated result never leaves an agent to guess the syntax for "show me more."
78
+ * `null` when `truncated` is false.
79
+ */
80
+ nextCommand: string | null;
81
+ }
82
+ /** One row in `search --json` output when `--full` is passed. */
83
+ export interface SearchRow extends ListRow {
84
+ /** Relevance score (0 when `--query` was omitted); see `search-match.ts` for the weighting. */
85
+ score: number;
86
+ /** Fields the query matched against; empty when `--query` was omitted. */
87
+ matchedFields: SearchMatchField[];
88
+ /** Excerpt around the first content match, or `null` when only topic/tags matched (or no query). */
89
+ snippet: string | null;
90
+ }
91
+ /**
92
+ * Default `search --json`/`--toon` row: enough to judge relevance (score, which fields matched, a
93
+ * short content excerpt) without the full metadata dump. Pass `--full` for every field on
94
+ * {@link SearchRow}.
95
+ */
96
+ export interface SearchRowMinimal extends ListRowMinimal {
97
+ score: number;
98
+ matchedFields: SearchMatchField[];
99
+ snippet: string | null;
100
+ }
101
+ /** Aggregate counts attached to `search`'s envelope so agents skip extra round trips. */
102
+ export interface SearchSummary extends ListSummary {
103
+ /** Whether `--query` was passed; when false every row scores 0 and results are recency-sorted. */
104
+ queried: boolean;
105
+ }
51
106
  /** Section child summary nested under an inspect hit. */
52
107
  export interface InspectSectionRow {
53
108
  cacheKey: string;
@@ -86,3 +141,31 @@ export interface StatusRow {
86
141
  freshness: FreshnessState;
87
142
  action: 'would_fetch' | 'would_revalidate' | 'would_return_cached';
88
143
  }
144
+ /** One entry in the `context` dashboard's recency-ordered preview. */
145
+ export interface ContextDashboardEntry {
146
+ topic: string | null;
147
+ sourceUrls: string[];
148
+ freshness: ListFreshness;
149
+ }
150
+ /**
151
+ * Directory-scoped cache summary for `context` (AXI principle 7: ambient session-start context).
152
+ * `entries` is capped to a small preview; `total`/`byFreshness` always cover every matched
153
+ * artifact so the cap never hides the true count.
154
+ */
155
+ export interface ContextDashboard {
156
+ total: number;
157
+ byFreshness: Record<ListFreshness, number>;
158
+ shown: number;
159
+ entries: ContextDashboardEntry[];
160
+ }
161
+ /** Agents `setup` knows how to install a SessionStart hook for. */
162
+ export type SetupAgent = 'claude-code' | 'codex';
163
+ /** Result of `setup <agent>` for the JSON envelope. */
164
+ export interface SetupResult {
165
+ agent: SetupAgent;
166
+ scope: 'user' | 'project';
167
+ path: string;
168
+ binCommand: string;
169
+ status: 'installed' | 'repaired' | 'unchanged' | 'would_install' | 'would_repair';
170
+ dryRun: boolean;
171
+ }
@@ -18,3 +18,5 @@ export declare const colors: {
18
18
  gray: (t: string) => string;
19
19
  magenta: (t: string) => string;
20
20
  };
21
+ /** Shared fresh/stale_grace/stale_expired → color mapping for `list` and `context`'s human output. */
22
+ export declare const FRESHNESS_COLOR: Record<'fresh' | 'stale_grace' | 'stale_expired', (t: string) => string>;
package/dist/lib/color.js CHANGED
@@ -34,3 +34,9 @@ export const colors = {
34
34
  gray: (t) => color(t, '\x1b[90m'),
35
35
  magenta: (t) => color(t, '\x1b[35m'),
36
36
  };
37
+ /** Shared fresh/stale_grace/stale_expired → color mapping for `list` and `context`'s human output. */
38
+ export const FRESHNESS_COLOR = {
39
+ fresh: colors.green,
40
+ stale_grace: colors.yellow,
41
+ stale_expired: colors.red,
42
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Metadata filter flags shared by `list` and `search` (topic/tags/url/freshness/artifact
3
+ * type/capture method) — single source of truth so the two commands' flag definitions can never
4
+ * drift apart. `artifactTypeDescription` is the one piece that differs: each command names itself
5
+ * in its own `--artifact-type` help text (`list`/`search` currently, via `CLI_FLAG_DESCRIPTIONS`).
6
+ */
7
+ export declare function commonMetadataFilterFlags(artifactTypeDescription: string): {
8
+ topic: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ tags: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ url: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ freshness: import("@oclif/core/interfaces").OptionFlag<"fresh" | "stale_grace" | "stale_expired" | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'artifact-type': import("@oclif/core/interfaces").OptionFlag<"source" | "research_note" | "index" | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'capture-method': import("@oclif/core/interfaces").OptionFlag<"static_fetch" | "browser_fallback" | "agent_supplied" | "route_markdown" | "github_source" | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ };
@@ -0,0 +1,37 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { CLI_FLAG_DESCRIPTIONS } from './cli-presentation.js';
3
+ import { CAPTURE_METHODS, PAGE_LEVEL_ARTIFACT_TYPES } from './research/schema.js';
4
+ /**
5
+ * Metadata filter flags shared by `list` and `search` (topic/tags/url/freshness/artifact
6
+ * type/capture method) — single source of truth so the two commands' flag definitions can never
7
+ * drift apart. `artifactTypeDescription` is the one piece that differs: each command names itself
8
+ * in its own `--artifact-type` help text (`list`/`search` currently, via `CLI_FLAG_DESCRIPTIONS`).
9
+ */
10
+ export function commonMetadataFilterFlags(artifactTypeDescription) {
11
+ return {
12
+ topic: Flags.string({
13
+ char: 't',
14
+ description: CLI_FLAG_DESCRIPTIONS.filterTopic,
15
+ }),
16
+ tags: Flags.string({
17
+ char: 'g',
18
+ description: CLI_FLAG_DESCRIPTIONS.filterTags,
19
+ multiple: true,
20
+ }),
21
+ url: Flags.string({
22
+ description: CLI_FLAG_DESCRIPTIONS.sourceUrlGlob,
23
+ }),
24
+ freshness: Flags.option({
25
+ description: 'freshness state',
26
+ options: ['fresh', 'stale_grace', 'stale_expired'],
27
+ })(),
28
+ 'artifact-type': Flags.option({
29
+ description: artifactTypeDescription,
30
+ options: PAGE_LEVEL_ARTIFACT_TYPES,
31
+ })(),
32
+ 'capture-method': Flags.option({
33
+ description: 'capture method',
34
+ options: CAPTURE_METHODS,
35
+ })(),
36
+ };
37
+ }
@@ -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;
@@ -5,43 +5,93 @@ import { KEY_META, ALL_KEYS } from './schema.js';
5
5
  /** Global, user-level config lives in the OCLIF config dir; project config sits at the repo root. */
6
6
  export const USER_CONFIG_FILENAME = 'config.json';
7
7
  export const PROJECT_CONFIG_FILENAME = '.bonsai.json';
8
- /** Parse a config file, keeping only known keys with valid values. Invalid input degrades to {}. */
9
- function parseConfigFile(raw) {
8
+ /**
9
+ * Parse a config file, keeping only known keys with valid values. Unparseable JSON, a non-object
10
+ * top level, or an invalid value for a known key degrades that part to "absent" rather than
11
+ * throwing — callers that only need values (`readUserConfig`/`readProjectConfig`) ignore
12
+ * `warnings`; {@link invalidConfigFileWarnings} surfaces them so the degradation is never silent.
13
+ */
14
+ function parseConfigFile(filePath, raw, scopeFlag, bin) {
15
+ let parsed;
10
16
  try {
11
- const parsed = JSON.parse(raw);
12
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
13
- return {};
14
- const obj = parsed;
15
- const result = {};
16
- for (const key of ALL_KEYS) {
17
- const val = obj[key];
18
- if (val !== undefined && KEY_META[key].isValid(val)) {
19
- Object.assign(result, { [key]: val });
20
- }
21
- }
22
- return result;
17
+ parsed = JSON.parse(raw);
23
18
  }
24
19
  catch {
25
- return {};
20
+ return {
21
+ values: {},
22
+ warnings: [
23
+ `Ignoring ${filePath}: not valid JSON. Using the configured value or default instead. ` +
24
+ `Fix the file by hand, or overwrite it with ${bin} config set <key> <value> ${scopeFlag}.`,
25
+ ],
26
+ };
27
+ }
28
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
29
+ return {
30
+ values: {},
31
+ warnings: [
32
+ `Ignoring ${filePath}: expected a JSON object at the top level. Using the configured value or default instead.`,
33
+ ],
34
+ };
35
+ }
36
+ const obj = parsed;
37
+ const values = {};
38
+ const warnings = [];
39
+ for (const key of ALL_KEYS) {
40
+ const val = obj[key];
41
+ if (val === undefined)
42
+ continue;
43
+ if (KEY_META[key].isValid(val)) {
44
+ Object.assign(values, { [key]: val });
45
+ }
46
+ else {
47
+ const validValues = KEY_META[key].values;
48
+ const validValuesHint = validValues ? ` Valid values: ${validValues.join(', ')}.` : '';
49
+ warnings.push(`Ignoring "${key}" in ${filePath}: invalid value ${JSON.stringify(val)}.${validValuesHint} ` +
50
+ `Using the configured value or default instead.`);
51
+ }
26
52
  }
53
+ return { values, warnings };
27
54
  }
28
- function readConfigFile(filePath) {
55
+ // Only the "fix it" suggestion in a warning message names the binary; every value-only caller
56
+ // below (readUserConfig/readProjectConfig, and transitively write*Config) discards warnings
57
+ // entirely, so this default never reaches a user — it's a placeholder for an unused code path,
58
+ // not a claim about the real bin name. invalidConfigFileWarnings passes the real `this.config.bin`.
59
+ const DEFAULT_BIN = 'bonsai';
60
+ function readConfigFile(filePath, scopeFlag, bin = DEFAULT_BIN) {
29
61
  if (!existsSync(filePath))
30
- return {};
62
+ return { values: {}, warnings: [] };
63
+ let raw;
31
64
  try {
32
- return parseConfigFile(readFileSync(filePath, 'utf-8'));
65
+ raw = readFileSync(filePath, 'utf-8');
33
66
  }
34
67
  catch {
35
- return {};
68
+ return { values: {}, warnings: [] };
36
69
  }
70
+ return parseConfigFile(filePath, raw, scopeFlag, bin);
37
71
  }
38
72
  export function readUserConfig(configDir) {
39
73
  if (!configDir)
40
74
  return {};
41
- return readConfigFile(join(configDir, USER_CONFIG_FILENAME));
75
+ return readConfigFile(join(configDir, USER_CONFIG_FILENAME), '--global').values;
42
76
  }
43
77
  export function readProjectConfig(cwd) {
44
- return readConfigFile(join(cwd, PROJECT_CONFIG_FILENAME));
78
+ return readConfigFile(join(cwd, PROJECT_CONFIG_FILENAME), '--local').values;
79
+ }
80
+ /**
81
+ * Warnings for the user and project config files, so a corrupted or hand-edited file that would
82
+ * otherwise silently degrade to `{}` (or drop just the offending key) is never a silent surprise —
83
+ * mirrors {@link invalidEnvOverrideWarnings} for env vars. Empty when both files are missing,
84
+ * unreadable, or valid.
85
+ *
86
+ * @param bin - CLI binary name (`this.config.bin`) for the "fix it" suggestion in a warning.
87
+ */
88
+ export function invalidConfigFileWarnings(configDir, cwd, bin) {
89
+ const warnings = [];
90
+ if (configDir) {
91
+ warnings.push(...readConfigFile(join(configDir, USER_CONFIG_FILENAME), '--global', bin).warnings);
92
+ }
93
+ warnings.push(...readConfigFile(join(cwd, PROJECT_CONFIG_FILENAME), '--local', bin).warnings);
94
+ return warnings;
45
95
  }
46
96
  function writeAtomically(filePath, data) {
47
97
  atomicWriteFile(filePath, JSON.stringify(data, null, 2) + '\n');
@@ -29,3 +29,5 @@ export declare function isKnownKey(key: string): key is ConfigKey;
29
29
  export declare function suggestKey(input: string): ConfigKey | undefined;
30
30
  /** Comma-joined valid key list for error messages, kept in one place. */
31
31
  export declare function validKeysHint(): string;
32
+ /** Valid keys with their accepted values, e.g. "storage (global|project), summary (…)", for `--help` descriptions. */
33
+ export declare function keyValuesHint(): string;
@@ -40,3 +40,10 @@ export function suggestKey(input) {
40
40
  export function validKeysHint() {
41
41
  return ALL_KEYS.join(', ');
42
42
  }
43
+ /** Valid keys with their accepted values, e.g. "storage (global|project), summary (…)", for `--help` descriptions. */
44
+ export function keyValuesHint() {
45
+ return ALL_KEYS.map((key) => {
46
+ const { values } = KEY_META[key];
47
+ return values ? `${key} (${values.join('|')})` : key;
48
+ }).join(', ');
49
+ }
@@ -1,4 +1,5 @@
1
1
  import { EXIT_RUNTIME_FAILURE } from './cli-error-policy.js';
2
+ import { pluralize } from './text.js';
2
3
  /** Map oclif parse-time errors to stable Bonsai codes when the throw site did not set one. */
3
4
  export function stableErrorCodeFrom(err) {
4
5
  const e = err;
@@ -117,8 +118,9 @@ export function enrichCacheMissEnvelope(envelope, data, bin) {
117
118
  exitCode: EXIT_RUNTIME_FAILURE,
118
119
  message: (first, failures) => {
119
120
  const url = first.normalizedUrl ?? '';
120
- return failures.length > 1
121
- ? `Cache miss for ${url} and ${failures.length - 1} other URLs`
121
+ const otherCount = failures.length - 1;
122
+ return otherCount > 0
123
+ ? `Cache miss for ${url} and ${otherCount} other ${pluralize(otherCount, 'URL', 'URLs')}`
122
124
  : `Cache miss for ${url}`;
123
125
  },
124
126
  // A URL that is already a secondary source of a different cached artifact must not be pointed
@@ -141,8 +143,13 @@ export function enrichRowErrorEnvelope(envelope, data) {
141
143
  return row.error ?? null;
142
144
  },
143
145
  code: (first) => first.code ?? 'FETCH_FAILED',
144
- message: (first, failures) => (first.message ?? '') +
145
- (failures.length > 1 ? `\n…and ${failures.length - 1} other URL failure(s)` : ''),
146
+ message: (first, failures) => {
147
+ const otherCount = failures.length - 1;
148
+ const suffix = otherCount > 0
149
+ ? `\n…and ${otherCount} other URL ${pluralize(otherCount, 'failure', 'failures')}`
150
+ : '';
151
+ return (first.message ?? '') + suffix;
152
+ },
146
153
  suggestions: (failures) => failures[0]?.suggestions,
147
154
  ref: (first) => first.ref,
148
155
  exitCode: EXIT_RUNTIME_FAILURE,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Builds a suggested next CLI invocation for a truncated `list`/`search` result — a copy-pasteable
3
+ * command that reproduces the caller's own filters with a raised `--limit`, so the caller never has
4
+ * to guess the syntax for "show me more." Surfaced as `summary.nextCommand` in the JSON/TOON
5
+ * envelope and as a human-mode tip.
6
+ */
7
+ /**
8
+ * @param bin - The CLI's own binary name (`this.config.bin`).
9
+ * @param commandId - The command to suggest (`list` or `search`).
10
+ * @param argv - The current invocation's raw argv (`this.argv`), reused as-is so the suggestion can
11
+ * never drift from whatever filters/flags the caller actually passed.
12
+ * @param newLimit - The `--limit` value to suggest.
13
+ * @returns A copy-pasteable shell command string.
14
+ */
15
+ export declare function buildNextLimitCommand(bin: string, commandId: string, argv: readonly string[], newLimit: number): string;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Builds a suggested next CLI invocation for a truncated `list`/`search` result — a copy-pasteable
3
+ * command that reproduces the caller's own filters with a raised `--limit`, so the caller never has
4
+ * to guess the syntax for "show me more." Surfaced as `summary.nextCommand` in the JSON/TOON
5
+ * envelope and as a human-mode tip.
6
+ */
7
+ // Never valid to suggest back to a caller: `--limit` is being replaced by the new value, and
8
+ // `--identity` is a hidden, undocumented flag only ever injected by the bare-invocation argv
9
+ // rewrite (see argv.ts) — carrying it into a suggested command would leak an internal detail.
10
+ const DROPPED_FLAGS = new Set(['--limit', '--identity']);
11
+ // Tokens made only of these characters are safe to print bare in a shell command; anything else
12
+ // (spaces, quotes, glob patterns like `*`) is double-quoted so the suggestion stays copy-pasteable.
13
+ const SAFE_BARE_TOKEN = /^[A-Za-z0-9_@%+.:,/-]+$/;
14
+ function quoteShellArgIfNeeded(token) {
15
+ if (SAFE_BARE_TOKEN.test(token))
16
+ return token;
17
+ return `"${token.replace(/(["\\$`])/g, '\\$1')}"`;
18
+ }
19
+ /**
20
+ * @param bin - The CLI's own binary name (`this.config.bin`).
21
+ * @param commandId - The command to suggest (`list` or `search`).
22
+ * @param argv - The current invocation's raw argv (`this.argv`), reused as-is so the suggestion can
23
+ * never drift from whatever filters/flags the caller actually passed.
24
+ * @param newLimit - The `--limit` value to suggest.
25
+ * @returns A copy-pasteable shell command string.
26
+ */
27
+ export function buildNextLimitCommand(bin, commandId, argv, newLimit) {
28
+ const kept = [];
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const token = argv[i];
31
+ if (token === '--limit') {
32
+ i++; // also drop its value
33
+ continue;
34
+ }
35
+ if (token.startsWith('--limit=') || DROPPED_FLAGS.has(token))
36
+ continue;
37
+ kept.push(quoteShellArgIfNeeded(token));
38
+ }
39
+ return [bin, commandId, ...kept, '--limit', String(newLimit)].join(' ');
40
+ }
@@ -14,6 +14,8 @@ export interface PruneFlagInput {
14
14
  inactive?: string;
15
15
  artifactType?: string;
16
16
  url?: string;
17
+ topic?: string;
18
+ tags?: string[];
17
19
  dryRun: boolean;
18
20
  yes: boolean;
19
21
  readOnly: boolean;
@@ -1,16 +1,22 @@
1
1
  import { emptyUrlFilterError } from './research/url.js';
2
2
  import { durationFlagError } from './research/freshness.js';
3
+ import { emptyTopicFilterError, emptyTagsFilterError } from './research/metadata-filters.js';
3
4
  function missingFilterError(input) {
4
5
  // Treat an explicitly passed empty string as "filter was attempted" so `--older-than ''`
5
6
  // is not misreported as MISSING_FILTER (durationError rejects it as INVALID_DURATION).
7
+ // `tags` is the one array-valued flag here: oclif never parses a bare `--tags` into `[]` (a
8
+ // multiple string flag requires a value per occurrence), so a non-empty array is the correct
9
+ // "was this filter attempted" test, mirroring the `!== undefined` check used for every scalar flag.
6
10
  if (input.olderThan !== undefined ||
7
11
  input.inactive !== undefined ||
8
12
  input.artifactType !== undefined ||
9
- input.url !== undefined) {
13
+ input.url !== undefined ||
14
+ input.topic !== undefined ||
15
+ (input.tags?.length ?? 0) > 0) {
10
16
  return null;
11
17
  }
12
18
  return {
13
- message: 'Must specify at least one pruning filter: --older-than, --inactive, --artifact-type, or --url.',
19
+ message: 'Must specify at least one pruning filter: --older-than, --inactive, --artifact-type, --url, --topic, or --tags.',
14
20
  code: 'MISSING_FILTER',
15
21
  suggestions: [`Preview age-based pruning: ${input.bin} prune --older-than 30d --dry-run`],
16
22
  };
@@ -41,10 +47,14 @@ function mutationSafetyError(input) {
41
47
  if (!input.readOnly && !input.dryRun && !input.yes) {
42
48
  const olderThanPart = input.olderThan ? ` --older-than ${input.olderThan}` : '';
43
49
  const urlPart = input.url ? ` --url "${input.url}"` : '';
50
+ const topicPart = input.topic ? ` --topic "${input.topic}"` : '';
51
+ const tagsPart = (input.tags ?? []).map((tag) => ` --tags "${tag}"`).join('');
44
52
  return {
45
53
  message: 'Safety check: use --yes to confirm pruning, or --dry-run to preview files that would be deleted.',
46
54
  code: 'SAFETY_CHECK_REQUIRED',
47
- suggestions: [`Preview first: ${input.bin} prune --dry-run${olderThanPart}${urlPart}`],
55
+ suggestions: [
56
+ `Preview first: ${input.bin} prune --dry-run${olderThanPart}${urlPart}${topicPart}${tagsPart}`,
57
+ ],
48
58
  };
49
59
  }
50
60
  return null;
@@ -66,9 +76,11 @@ function durationError(input) {
66
76
  * @returns Error descriptor for `this.error`, or `null`.
67
77
  */
68
78
  export function pruneFlagError(input) {
69
- const urlErr = emptyUrlFilterError(input.url);
70
- if (urlErr)
71
- return { message: urlErr, code: 'INVALID_FLAG_VALUE' };
79
+ const flagValueErr = emptyUrlFilterError(input.url) ??
80
+ emptyTopicFilterError(input.topic) ??
81
+ emptyTagsFilterError(input.tags);
82
+ if (flagValueErr)
83
+ return { message: flagValueErr, code: 'INVALID_FLAG_VALUE' };
72
84
  // Duration before missing-filter so `--older-than ''` reports INVALID_DURATION, not MISSING_FILTER.
73
85
  return durationError(input) ?? missingFilterError(input) ?? mutationSafetyError(input);
74
86
  }
@@ -39,7 +39,13 @@ function getOrUpdateIndexEntry(filePath, cached, onChanged) {
39
39
  return cached;
40
40
  }
41
41
  try {
42
- const entry = { sig, artifact: parseArtifactShallow(readFileSync(filePath, 'utf-8')) };
42
+ const artifact = parseArtifactShallow(readFileSync(filePath, 'utf-8'));
43
+ // Mirrors the same guard in storage.ts's scanCacheDir: parseArtifact never throws on a foreign
44
+ // or garbled *.md file that merely has `---`/`---` fences, it just defaults cache_key to ''. Treat
45
+ // that the same as a parse failure so it's skipped rather than indexed as a phantom entry.
46
+ if (!artifact.metadata.cache_key)
47
+ return undefined;
48
+ const entry = { sig, artifact };
43
49
  onChanged();
44
50
  return entry;
45
51
  }