@thecat69/cache-ctrl 1.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.
package/README.md ADDED
@@ -0,0 +1,558 @@
1
+ # cache-ctrl
2
+
3
+ A CLI tool and native opencode plugin that manages the two AI agent caches (`.ai/external-context-gatherer_cache/` and `.ai/local-context-gatherer_cache/`) with a uniform interface.
4
+
5
+ It handles advisory locking for safe concurrent writes, keyword search across all entries, HTTP freshness checking for external URLs, and file-change detection for local scans.
6
+
7
+ ---
8
+
9
+ ## Quick Start
10
+
11
+ ### npm (recommended)
12
+
13
+ ```sh
14
+ npm install -g @thecat69/cache-ctrl
15
+ cache-ctrl install
16
+ ```
17
+
18
+ `cache-ctrl install` configures the OpenCode integration in one step:
19
+ - Writes an OpenCode tool wrapper at `~/.config/opencode/tools/cache_ctrl.ts` (Linux/macOS) or `%APPDATA%\opencode\tools\cache_ctrl.ts` (Windows).
20
+ - Copies 3 skill SKILL.md files to `~/.config/opencode/skills/`.
21
+
22
+ **Prerequisites**: `bun` ≥ 1.0.0 must be in `PATH` (Bun executes the TypeScript files natively — no build step).
23
+
24
+ ### Local development (from source)
25
+
26
+ Run from inside the `cache-ctrl/` directory:
27
+
28
+ ```zsh
29
+ zsh install.sh
30
+ ```
31
+
32
+ This creates two symlinks:
33
+ - `~/.local/bin/cache-ctrl` → `src/index.ts` — global CLI command (executed directly by Bun)
34
+ - `.opencode/tools/cache-ctrl.ts` → `cache_ctrl.ts` — auto-discovered by OpenCode as a native plugin
35
+
36
+ `install.sh` is for local development only. For end-user installation, use `npm install -g @thecat69/cache-ctrl`.
37
+
38
+ ---
39
+
40
+ ## Architecture
41
+
42
+ ```
43
+ CLI (cache-ctrl) opencode Plugin
44
+ src/index.ts cache_ctrl.ts
45
+ │ │
46
+ └──────────┬──────────────┘
47
+
48
+ Command Layer
49
+ src/commands/{list, inspect, flush,
50
+ invalidate, touch, prune,
51
+ checkFreshness, checkFiles, search,
52
+ write}.ts
53
+
54
+ Core Services
55
+ cacheManager ← read/write + advisory lock
56
+ externalCache ← external staleness logic
57
+ localCache ← local scan path logic
58
+ freshnessChecker ← HTTP HEAD requests
59
+ changeDetector ← mtime/hash comparison
60
+ keywordSearch ← scoring engine
61
+
62
+ Cache Directories (on disk)
63
+ .ai/external-context-gatherer_cache/
64
+ ├── <subject>.json
65
+ └── <subject>.json.lock (advisory)
66
+ .ai/local-context-gatherer_cache/
67
+ ├── context.json
68
+ └── context.json.lock (advisory)
69
+ ```
70
+
71
+ **Key design decisions:**
72
+ - All commands funnel through `cacheManager` for reads/writes — no direct filesystem access from command handlers.
73
+ - The CLI and plugin share the same command functions — no duplicated business logic.
74
+ - All operations return `Result<T, CacheError>` — nothing throws into the caller.
75
+ - `writeCache` defaults to merging updates onto the existing object (preserving unknown agent fields). Local writes use per-path merge — submitted `tracked_files` entries replace existing entries for those paths; entries for other paths are preserved; entries for files no longer present on disk are evicted automatically.
76
+
77
+ ---
78
+
79
+ ## CLI Reference
80
+
81
+ **Output format**: JSON (single line) by default. Add `--pretty` to any command for indented output.
82
+ **Errors**: Written to stderr as `{ "ok": false, "error": "...", "code": "..." }`. Exit code `1` on error, `2` on bad arguments.
83
+ **Help**: Run `cache-ctrl --help` or `cache-ctrl help` for the full command reference. Run `cache-ctrl help <command>` for per-command usage, arguments, and options. Help output is plain text written to stdout; exit code `0` on success, `1` for unknown command.
84
+
85
+ ---
86
+
87
+ ### `install`
88
+
89
+ ```
90
+ cache-ctrl install [--config-dir <path>]
91
+ ```
92
+
93
+ Configures OpenCode integration after `npm install -g @thecat69/cache-ctrl`. Does two things:
94
+
95
+ 1. **Generates an OpenCode tool wrapper** at `<opencode-config>/tools/cache_ctrl.ts` — a one-line re-export that points back to the installed package so Bun resolves all relative imports correctly.
96
+ 2. **Copies 3 skill files** (`cache-ctrl-caller`, `cache-ctrl-local`, `cache-ctrl-external`) to `<opencode-config>/skills/<name>/SKILL.md`.
97
+
98
+ Both operations are idempotent — re-running `cache-ctrl install` after `npm update -g @thecat69/cache-ctrl` regenerates the wrapper with the new package path.
99
+
100
+ **OpenCode config directory resolution** (in priority order):
101
+ 1. `--config-dir <path>` flag (explicit override)
102
+ 2. `$XDG_CONFIG_HOME/opencode` (Linux/macOS, if `XDG_CONFIG_HOME` is set)
103
+ 3. `~/.config/opencode` (Linux/macOS default)
104
+ 4. `%APPDATA%\opencode` (Windows)
105
+
106
+ **Options:**
107
+
108
+ | Flag | Description |
109
+ |---|---|
110
+ | `--config-dir <path>` | Override the detected OpenCode config directory |
111
+
112
+ ```jsonc
113
+ // cache-ctrl install --pretty
114
+ {
115
+ "ok": true,
116
+ "value": {
117
+ "configDir": "/home/user/.config/opencode",
118
+ "toolPath": "/home/user/.config/opencode/tools/cache_ctrl.ts",
119
+ "skillPaths": [
120
+ "/home/user/.config/opencode/skills/cache-ctrl-caller/SKILL.md",
121
+ "/home/user/.config/opencode/skills/cache-ctrl-local/SKILL.md",
122
+ "/home/user/.config/opencode/skills/cache-ctrl-external/SKILL.md"
123
+ ]
124
+ }
125
+ }
126
+ ```
127
+
128
+ **Error codes**: `FILE_WRITE_ERROR` if the tool wrapper or a skill file cannot be written.
129
+
130
+ ---
131
+
132
+ ### `help`
133
+
134
+ ```
135
+ cache-ctrl help [<command>]
136
+ cache-ctrl --help
137
+ ```
138
+
139
+ Prints human-readable usage information and exits. No JSON output.
140
+
141
+ - `cache-ctrl --help` — print full command reference (all commands with descriptions)
142
+ - `cache-ctrl help` — same as `--help`
143
+ - `cache-ctrl help <command>` — print per-command usage, arguments, and options
144
+ - `cache-ctrl help help` — same as `cache-ctrl help` (full reference)
145
+
146
+ Exit code: `0` on success, `1` if `<command>` is not recognized.
147
+
148
+ ---
149
+
150
+ ### `list`
151
+
152
+ ```
153
+ cache-ctrl list [--agent external|local|all] [--pretty]
154
+ ```
155
+
156
+ Lists all cache entries. Shows age, human-readable age string, and staleness flag.
157
+
158
+ - External entries are stale if `fetched_at` is empty or older than 24 hours.
159
+ - Local entries show `is_stale: true` only when `cache_ctrl_check_files` detects actual changes (changed files, new non-ignored files, or deleted files). A freshly-written cache with no subsequent file changes shows `is_stale: false`.
160
+
161
+ **Default**: `--agent all`
162
+
163
+ ```jsonc
164
+ // cache-ctrl list --pretty
165
+ {
166
+ "ok": true,
167
+ "value": [
168
+ {
169
+ "file": "/path/to/.ai/external-context-gatherer_cache/opencode-skills.json",
170
+ "agent": "external",
171
+ "subject": "opencode-skills",
172
+ "description": "opencode skill file index",
173
+ "fetched_at": "2026-04-04T10:00:00Z",
174
+ "age_human": "2 hours ago",
175
+ "is_stale": false
176
+ }
177
+ ]
178
+ }
179
+ ```
180
+
181
+ ---
182
+
183
+ ### `inspect`
184
+
185
+ ```
186
+ cache-ctrl inspect <agent> <subject-keyword> [--filter <kw>[,<kw>...]] [--folder <path>] [--search-facts <kw>[,<kw>...]] [--pretty]
187
+ ```
188
+
189
+ Prints the full JSON content of the best-matching cache entry. Uses the same keyword scoring as `search`. Returns `AMBIGUOUS_MATCH` if two results score identically.
190
+
191
+ Three complementary filters are available for `agent: "local"` — they are AND-ed when combined:
192
+
193
+ **`--filter <kw>[,<kw>...]`** (local agent only): restricts `facts` to entries whose **file path** contains at least one keyword (case-insensitive substring). Ignored for the external agent.
194
+
195
+ **`--folder <path>`** (local agent only): restricts `facts` to entries whose **file path** equals the given folder prefix or starts with `<folder>/` (recursive subtree match). Returns `INVALID_ARGS` if used with the external agent.
196
+
197
+ **`--search-facts <kw>[,<kw>...]`** (local agent only): restricts `facts` to entries where **at least one fact string** contains any keyword (case-insensitive substring). Silently ignored for the external agent.
198
+
199
+ `global_facts` and all other metadata fields are always included regardless of which filters are set.
200
+
201
+ **`tracked_files` is never returned** for `agent: "local"` — it is internal operational metadata consumed by `check-files` and is always stripped from inspect responses.
202
+
203
+ ```
204
+ cache-ctrl inspect external opencode-skills --pretty
205
+ cache-ctrl inspect local context --pretty
206
+ cache-ctrl inspect local context --filter lsp,nvim --pretty
207
+ cache-ctrl inspect local context --folder src/commands --pretty
208
+ cache-ctrl inspect local context --search-facts "Result<" --pretty
209
+ cache-ctrl inspect local context --folder src --filter commands --search-facts async --pretty
210
+ ```
211
+
212
+ ---
213
+
214
+ ### `flush`
215
+
216
+ ```
217
+ cache-ctrl flush <agent|all> --confirm [--pretty]
218
+ ```
219
+
220
+ Deletes cache files. The `--confirm` flag is **required** as a safeguard.
221
+
222
+ - `external` → deletes all `*.json` files in the external cache directory (not `.lock` files)
223
+ - `local` → deletes `context.json`
224
+ - `all` → both
225
+
226
+ ```
227
+ cache-ctrl flush external --confirm
228
+ cache-ctrl flush all --confirm --pretty
229
+ ```
230
+
231
+ ---
232
+
233
+ ### `invalidate`
234
+
235
+ ```
236
+ cache-ctrl invalidate <agent> [subject-keyword] [--pretty]
237
+ ```
238
+
239
+ Zeros out the timestamp (`fetched_at` for external, `timestamp` for local), marking the entry as stale without deleting its content. Agents will treat it as a cache miss on next run.
240
+
241
+ - With a keyword: invalidates the best-matching file.
242
+ - Without a keyword on `external`: invalidates **all** external entries.
243
+ - Without a keyword on `local`: invalidates `context.json`.
244
+
245
+ > If the local cache file does not exist, returns `FILE_NOT_FOUND` — the command is a no-op in that case.
246
+
247
+ ```
248
+ cache-ctrl invalidate external opencode-skills
249
+ cache-ctrl invalidate external # all external entries
250
+ cache-ctrl invalidate local
251
+ ```
252
+
253
+ ---
254
+
255
+ ### `touch`
256
+
257
+ ```
258
+ cache-ctrl touch <agent> [subject-keyword] [--pretty]
259
+ ```
260
+
261
+ Resets the timestamp to the current UTC time — the inverse of `invalidate`. Marks the entry as fresh.
262
+
263
+ - Without a keyword on `external`: touches **all** external entries.
264
+
265
+ ```
266
+ cache-ctrl touch external opencode-skills
267
+ cache-ctrl touch local
268
+ ```
269
+
270
+ ---
271
+
272
+ ### `prune`
273
+
274
+ ```
275
+ cache-ctrl prune [--agent external|local|all] [--max-age <duration>] [--delete] [--pretty]
276
+ ```
277
+
278
+ Finds entries older than `--max-age` and invalidates them (default) or deletes them (`--delete`).
279
+
280
+ **Duration format**: `<number><unit>` — `h` for hours, `d` for days. Examples: `24h`, `7d`, `1d`.
281
+
282
+ **Defaults**: `--agent all`, `--max-age 24h` for external. Local cache **always** matches (no TTL).
283
+
284
+ > If the local cache does not exist and `--delete` is not set, the local entry is skipped silently (not added to `matched`).
285
+
286
+ > ⚠️ `prune --agent all --delete` will **always** delete the local cache. Use `--agent external` to avoid this.
287
+
288
+ ```
289
+ cache-ctrl prune --agent external --max-age 7d
290
+ cache-ctrl prune --agent external --max-age 1d --delete
291
+ ```
292
+
293
+ ---
294
+
295
+ ### `check-freshness`
296
+
297
+ ```
298
+ cache-ctrl check-freshness <subject-keyword> [--url <url>] [--pretty]
299
+ ```
300
+
301
+ Sends HTTP HEAD requests to each URL in the matched external entry's `sources[]`. Uses conditional headers (`If-None-Match`, `If-Modified-Since`) from stored `header_metadata`. Updates `header_metadata` in-place after checking.
302
+
303
+ - HTTP 304 → `fresh`
304
+ - HTTP 200 → `stale` (resource changed)
305
+ - Network / 4xx / 5xx → `error` (does not update metadata for that URL)
306
+
307
+ With `--url`: checks only that specific URL (must exist in `sources[]`).
308
+
309
+ ```jsonc
310
+ // cache-ctrl check-freshness opencode-skills --pretty
311
+ {
312
+ "ok": true,
313
+ "value": {
314
+ "subject": "opencode-skills",
315
+ "sources": [
316
+ { "url": "https://example.com/docs", "status": "fresh", "http_status": 304 }
317
+ ],
318
+ "overall": "fresh"
319
+ }
320
+ }
321
+ ```
322
+
323
+ ---
324
+
325
+ ### `check-files`
326
+
327
+ ```
328
+ cache-ctrl check-files [--pretty]
329
+ ```
330
+
331
+ Reads `tracked_files[]` from the local cache and compares each file's current `mtime` (and `hash` if stored) against the saved values.
332
+
333
+ **Comparison logic:**
334
+ 1. Read current `mtime` via `lstat()` (reflects the symlink node itself, not the target).
335
+ 2. If stored `hash` is present and `mtime` changed → recompute SHA-256. Hash match → `unchanged` (touch-only). Hash differs → `changed`.
336
+ 3. No stored `hash` → mtime change alone marks the file as `changed`.
337
+ 4. File missing on disk → `missing`.
338
+
339
+ If `tracked_files` is absent or empty → returns `{ status: "unchanged", ... }` (not an error).
340
+
341
+ ```jsonc
342
+ // cache-ctrl check-files --pretty
343
+ {
344
+ "ok": true,
345
+ "value": {
346
+ "status": "unchanged",
347
+ "changed_files": [],
348
+ "unchanged_files": ["lua/plugins/ui/bufferline.lua"],
349
+ "missing_files": []
350
+ }
351
+ }
352
+ ```
353
+
354
+ ---
355
+
356
+ ### `search`
357
+
358
+ ```
359
+ cache-ctrl search <keyword> [<keyword>...] [--pretty]
360
+ ```
361
+
362
+ Searches all cache files across both namespaces. Case-insensitive. Returns results ranked by score (descending).
363
+
364
+ **Scoring matrix** (per keyword, additive across multiple keywords):
365
+
366
+ | Match type | Score |
367
+ |---|---|
368
+ | Exact match on file stem | 100 |
369
+ | Substring match on file stem | 80 |
370
+ | Exact word match on `subject`/`topic` | 70 |
371
+ | Substring match on `subject`/`topic` | 50 |
372
+ | Keyword match on `description` | 30 |
373
+
374
+ ```
375
+ cache-ctrl search opencode skills
376
+ cache-ctrl search neovim --pretty
377
+ ```
378
+
379
+ ---
380
+
381
+ ### `write`
382
+
383
+ ```
384
+ cache-ctrl write external <subject> --data '<json>' [--pretty]
385
+ cache-ctrl write local --data '<json>' [--pretty]
386
+ ```
387
+
388
+ Writes a validated cache entry to disk. The `--data` argument must be a valid JSON string matching the ExternalCacheFile or LocalCacheFile schema. Schema validation runs first — all required fields must be present in `--data` or the write is rejected with `VALIDATION_ERROR`.
389
+
390
+ - `external`: `subject` is required as a positional argument. After validation, unknown fields from the existing file on disk are preserved (merge write).
391
+ - `local`: no subject argument; `timestamp` is **auto-set** to the current UTC time server-side — any value supplied in `--data` is silently overridden. `mtime` for each entry in `tracked_files[]` is **auto-populated** by the write command via filesystem `lstat()` — agents do not need to supply it. Local writes use per-path merge: submitted `tracked_files` entries replace existing entries for the same path; entries for other paths are preserved; entries for files deleted from disk are evicted automatically. On cold start (no existing cache), submit all relevant files for a full write; on subsequent writes, submit only new or changed files.
392
+ - `local`: facts paths are validated against submitted `tracked_files` — submitting a facts key outside that set returns `VALIDATION_ERROR`.
393
+
394
+ > `VALIDATION_ERROR` messages include the offending field path (e.g., `facts.src/foo.ts.2: write concise observations, not file content (max 800 chars per fact)`), making it straightforward to locate the violating value.
395
+
396
+ > The `subject` parameter (external agent) must match `/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/` and be at most 128 characters. Returns `INVALID_ARGS` if it fails validation.
397
+
398
+ **Always use this command (or `cache_ctrl_write`) instead of writing cache files directly.** Direct writes skip schema validation and risk corrupting the cache.
399
+
400
+ ```json
401
+ // cache-ctrl write external mysubject --data '{"subject":"mysubject","description":"...","fetched_at":"2026-04-05T10:00:00Z","sources":[],"header_metadata":{}}' --pretty
402
+ { "ok": true, "value": { "file": "/path/to/.ai/external-context-gatherer_cache/mysubject.json" } }
403
+ ```
404
+
405
+ ---
406
+
407
+ ## opencode Plugin Tools
408
+
409
+ The plugin (`cache_ctrl.ts`) is auto-discovered via `~/.config/opencode/tools/cache_ctrl.ts` and registers 7 tools that call the same command functions as the CLI:
410
+
411
+ | Tool | Description |
412
+ |---|---|
413
+ | `cache_ctrl_search` | Search all cache entries by keyword |
414
+ | `cache_ctrl_list` | List entries with age and staleness flags |
415
+ | `cache_ctrl_inspect` | Return full content of a specific entry |
416
+ | `cache_ctrl_invalidate` | Zero out a cache entry's timestamp |
417
+ | `cache_ctrl_check_freshness` | HTTP HEAD check for external source URLs |
418
+ | `cache_ctrl_check_files` | Compare tracked files against stored mtime/hash |
419
+ | `cache_ctrl_write` | Write a validated cache entry; validates against ExternalCacheFile or LocalCacheFile schema |
420
+
421
+ No bash permission is required for agents that use the plugin tools directly.
422
+
423
+ All 7 plugin tool responses include a `server_time` field at the outer JSON level:
424
+
425
+ ```json
426
+ { "ok": true, "value": { ... }, "server_time": "2026-04-05T12:34:56.789Z" }
427
+ ```
428
+
429
+ Use `server_time` to assess how stale stored timestamps are without requiring bash or system access.
430
+
431
+ ---
432
+
433
+ ## Agent Integration
434
+
435
+ ### `external-context-gatherer`
436
+
437
+ ```zsh
438
+ # Before fetching — check if cache is still fresh
439
+ cache-ctrl list --agent external --pretty
440
+ # If is_stale: false → skip fetch
441
+
442
+ # For a precise HTTP freshness check on a borderline entry
443
+ cache-ctrl check-freshness <subject>
444
+ # If overall: "fresh" → skip re-fetch
445
+
446
+ # After writing new cache content — mark entry fresh
447
+ cache-ctrl touch external <subject>
448
+
449
+ # Force a re-fetch
450
+ cache-ctrl invalidate external <subject>
451
+ ```
452
+
453
+ ### `local-context-gatherer`
454
+
455
+ ```zsh
456
+ # Before deciding whether to re-scan
457
+ cache-ctrl check-files
458
+ # If status: "changed" → invalidate and re-scan
459
+ cache-ctrl invalidate local
460
+ # If status: "unchanged" → use cached context
461
+ ```
462
+
463
+ **Requirement**: The agent MUST populate `tracked_files[]` (with `path` and optionally `hash`) when writing its cache file. `mtime` per entry is auto-populated server-side via filesystem `lstat()` — agents do not need to supply it. `check-files` returns `unchanged` silently if `tracked_files` is absent.
464
+
465
+ ---
466
+
467
+ ## Cache File Schemas
468
+
469
+ ### External: `.ai/external-context-gatherer_cache/<subject>.json`
470
+
471
+ ```jsonc
472
+ {
473
+ "subject": "opencode-skills", // Must match the file stem
474
+ "description": "opencode skill index", // One-liner for keyword search
475
+ "fetched_at": "2026-04-04T12:00:00Z", // "" when invalidated
476
+ "sources": [
477
+ { "type": "github_api", "url": "https://..." }
478
+ ],
479
+ "header_metadata": {
480
+ "https://...": {
481
+ "etag": "\"abc123\"",
482
+ "last_modified": "Fri, 04 Apr 2026 10:00:00 GMT",
483
+ "checked_at": "2026-04-04T12:00:00Z",
484
+ "status": "fresh"
485
+ }
486
+ }
487
+ // Any additional agent fields are preserved unchanged
488
+ }
489
+ ```
490
+
491
+ ### Local: `.ai/local-context-gatherer_cache/context.json`
492
+
493
+ > `timestamp` is **auto-set** by the write command to the current UTC time. Do not include it in agent-supplied content — any value provided is silently overridden. `mtime` values in `tracked_files[]` are **auto-populated** by the write command via filesystem `lstat()` — agents only need to supply `path` (and optionally `hash`). Local writes use per-path merge: submitted `tracked_files` entries replace existing entries for the same path; entries for other paths are preserved; entries for files deleted from disk are evicted automatically. On cold start (no existing cache), submit all relevant files; on subsequent writes, submit only new or changed files.
494
+
495
+ ```jsonc
496
+ {
497
+ "timestamp": "2026-04-04T12:00:00Z", // auto-set on write; "" when invalidated
498
+ "topic": "neovim plugin configuration",
499
+ "description": "Scan of nvim lua plugins",
500
+ "cache_miss_reason": "files changed", // optional: why the previous cache was discarded
501
+ "tracked_files": [
502
+ { "path": "lua/plugins/ui/bufferline.lua", "mtime": 1743768000000, "hash": "sha256hex..." }
503
+ // mtime is auto-populated by the write command; agents only need to supply path (and optionally hash)
504
+ ],
505
+ "global_facts": [ // optional: repo-level facts; last-write-wins; max 20 entries, each ≤ 300 chars
506
+ "Kubuntu dotfiles repo",
507
+ "StyLua for Lua (140 col, 2-space indent)"
508
+ ],
509
+ "facts": { // optional: per-file facts; per-path merge; max 30 entries per file, each ≤ 800 chars
510
+ "lua/plugins/ui/bufferline.lua": ["lazy-loaded via ft = lua", "uses catppuccin mocha theme"]
511
+ // Facts for files deleted from disk are evicted automatically on the next write
512
+ }
513
+ // Any additional agent fields are preserved unchanged
514
+ }
515
+ ```
516
+
517
+ ---
518
+
519
+ ## Error Codes
520
+
521
+ | Code | Meaning |
522
+ |---|---|
523
+ | `FILE_NOT_FOUND` | Cache file does not exist |
524
+ | `FILE_READ_ERROR` | Cannot read file |
525
+ | `FILE_WRITE_ERROR` | Cannot write file |
526
+ | `PARSE_ERROR` | File is not valid JSON |
527
+ | `LOCK_TIMEOUT` | Could not acquire lock within 5 seconds |
528
+ | `LOCK_ERROR` | Unexpected lock file error |
529
+ | `INVALID_ARGS` | Missing or invalid CLI arguments |
530
+ | `CONFIRMATION_REQUIRED` | `flush` called without `--confirm` |
531
+ | `VALIDATION_ERROR` | Schema validation failed (e.g., missing required field or type mismatch in `write`) |
532
+ | `NO_MATCH` | No cache file matched the keyword |
533
+ | `AMBIGUOUS_MATCH` | Multiple files with identical top score |
534
+ | `HTTP_REQUEST_FAILED` | Network error during HEAD request |
535
+ | `URL_NOT_FOUND` | `--url` value not found in `sources[]` |
536
+ | `UNKNOWN` | Unexpected internal error |
537
+
538
+ ---
539
+
540
+ ## Development
541
+
542
+ ```zsh
543
+ # Run unit tests
544
+ bun run test
545
+
546
+ # Watch mode
547
+ bun run test:watch
548
+
549
+ # Run E2E tests (requires Docker)
550
+ bun run test:e2e
551
+
552
+ # Re-run installer (idempotent)
553
+ zsh install.sh
554
+ ```
555
+
556
+ Unit tests live in `tests/` and use Vitest. Filesystem operations use real temp directories; HTTP calls are mocked with `vi.mock`.
557
+
558
+ E2E tests live in `e2e/tests/` and run inside Docker via `docker compose -f e2e/docker-compose.yml run --rm e2e`. They spawn the actual CLI binary as a subprocess and verify exit codes, stdout/stderr JSON shape, and cross-command behaviour. Docker must be running; no other host dependencies are required.