@botdocs/cli 0.3.2 → 0.5.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 (91) hide show
  1. package/README.md +145 -36
  2. package/dist/commands/backups.d.ts +4 -0
  3. package/dist/commands/backups.js +291 -0
  4. package/dist/commands/edit.js +16 -8
  5. package/dist/commands/ingest.d.ts +2 -0
  6. package/dist/commands/ingest.js +162 -28
  7. package/dist/commands/install.d.ts +4 -0
  8. package/dist/commands/install.js +40 -3
  9. package/dist/commands/login.d.ts +7 -0
  10. package/dist/commands/login.js +240 -75
  11. package/dist/commands/sync.d.ts +16 -0
  12. package/dist/commands/sync.js +337 -25
  13. package/dist/commands/team.d.ts +2 -0
  14. package/dist/commands/team.js +251 -0
  15. package/dist/commands/undo.d.ts +19 -0
  16. package/dist/commands/undo.js +88 -0
  17. package/dist/commands/views/conflict-prompt.d.ts +24 -0
  18. package/dist/commands/views/conflict-prompt.js +19 -0
  19. package/dist/commands/views/login-app.d.ts +30 -0
  20. package/dist/commands/views/login-app.js +57 -0
  21. package/dist/commands/views/sync-app.d.ts +27 -0
  22. package/dist/commands/views/sync-app.js +147 -0
  23. package/dist/commands/views/sync-state.d.ts +84 -0
  24. package/dist/commands/views/sync-state.js +93 -0
  25. package/dist/commands/views/theme.d.ts +16 -0
  26. package/dist/commands/views/theme.js +16 -0
  27. package/dist/commands/whoami.js +13 -13
  28. package/dist/index.js +46 -39
  29. package/dist/lib/api.d.ts +2 -3
  30. package/dist/lib/api.js +14 -7
  31. package/dist/lib/auto-detect.js +46 -0
  32. package/dist/lib/backup.d.ts +121 -0
  33. package/dist/lib/backup.js +387 -0
  34. package/dist/lib/canonical.d.ts +1 -1
  35. package/dist/lib/canonical.js +43 -1
  36. package/dist/lib/config.d.ts +8 -1
  37. package/dist/lib/config.js +18 -9
  38. package/dist/lib/lockfile.d.ts +9 -0
  39. package/dist/lib/prompts.d.ts +10 -0
  40. package/dist/lib/prompts.js +36 -12
  41. package/package.json +27 -7
  42. package/templates/agents.md +60 -47
  43. package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
  44. package/templates/ecosystem-prompts/compile-copilot.md +14 -0
  45. package/templates/ecosystem-prompts/compile-gemini.md +14 -0
  46. package/templates/ecosystem-prompts/compile-opencode.md +13 -0
  47. package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
  48. package/dist/commands/check-updates.test.d.ts +0 -1
  49. package/dist/commands/check-updates.test.js +0 -128
  50. package/dist/commands/clone.d.ts +0 -3
  51. package/dist/commands/clone.js +0 -70
  52. package/dist/commands/compile.test.d.ts +0 -1
  53. package/dist/commands/compile.test.js +0 -110
  54. package/dist/commands/diff.d.ts +0 -3
  55. package/dist/commands/diff.js +0 -65
  56. package/dist/commands/edit.test.d.ts +0 -1
  57. package/dist/commands/edit.test.js +0 -102
  58. package/dist/commands/endorse.d.ts +0 -7
  59. package/dist/commands/endorse.js +0 -70
  60. package/dist/commands/ingest.test.d.ts +0 -1
  61. package/dist/commands/ingest.test.js +0 -109
  62. package/dist/commands/install.test.d.ts +0 -1
  63. package/dist/commands/install.test.js +0 -253
  64. package/dist/commands/list.test.d.ts +0 -1
  65. package/dist/commands/list.test.js +0 -51
  66. package/dist/commands/publish.test.d.ts +0 -1
  67. package/dist/commands/publish.test.js +0 -138
  68. package/dist/commands/pull.d.ts +0 -3
  69. package/dist/commands/pull.js +0 -78
  70. package/dist/commands/sync.test.d.ts +0 -1
  71. package/dist/commands/sync.test.js +0 -263
  72. package/dist/commands/uninstall.test.d.ts +0 -1
  73. package/dist/commands/uninstall.test.js +0 -67
  74. package/dist/lib/auto-detect.test.d.ts +0 -1
  75. package/dist/lib/auto-detect.test.js +0 -58
  76. package/dist/lib/canonical.test.d.ts +0 -1
  77. package/dist/lib/canonical.test.js +0 -48
  78. package/dist/lib/diff.test.d.ts +0 -1
  79. package/dist/lib/diff.test.js +0 -28
  80. package/dist/lib/library-sync.test.d.ts +0 -1
  81. package/dist/lib/library-sync.test.js +0 -63
  82. package/dist/lib/llm.test.d.ts +0 -1
  83. package/dist/lib/llm.test.js +0 -72
  84. package/dist/lib/lockfile.test.d.ts +0 -1
  85. package/dist/lib/lockfile.test.js +0 -99
  86. package/dist/lib/manifest.test.d.ts +0 -1
  87. package/dist/lib/manifest.test.js +0 -72
  88. package/dist/lib/shell-hook.test.d.ts +0 -1
  89. package/dist/lib/shell-hook.test.js +0 -68
  90. package/dist/test-utils.d.ts +0 -43
  91. package/dist/test-utils.js +0 -101
package/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # botdocs
2
2
 
3
- The official CLI for [BotDocs](https://botdocs.ai) — clone, search,
4
- publish, and endorse concept specifications.
3
+ The official CLI for [BotDocs](https://botdocs.ai) — author, publish,
4
+ install, and sync agent skills.
5
5
 
6
- A BotDoc is a structured spec an AI agent can build from. This CLI is the
7
- fastest way to pull one onto your machine, ship one of your own, or report
8
- back after you've built something on top.
6
+ BotDocs is the registry teams use to share agent skills across the
7
+ agents their developers run (Claude Code, Cursor, Codex, ChatGPT). This
8
+ CLI is the fastest way to install a team's skills, stay in sync, and
9
+ publish your own.
9
10
 
10
11
  ## Install
11
12
 
@@ -30,54 +31,66 @@ Requires Node.js 20 or newer.
30
31
  ## Quick start
31
32
 
32
33
  ```bash
33
- # scaffold a new spec in ./my-spec/
34
- botdocs init my-spec
35
-
36
- # validate before publishing
37
- botdocs validate my-spec/
38
-
39
- # log in (GitHub device code, one time)
34
+ # log in (opens your browser, sign in with any provider, one time)
40
35
  botdocs login
41
36
 
42
- # publish from a directory
43
- botdocs publish my-spec/
37
+ # install a team's shared skills
38
+ botdocs install @teamco/eng-skills
44
39
 
45
- # clone someone else's spec
46
- botdocs clone @alice/agent-router
40
+ # stay in sync with your team
41
+ botdocs sync
47
42
 
48
- # tell them how it went after you built from it
49
- botdocs endorse @alice/agent-router --rating positive \
50
- --comment "Built a working POC in 40 minutes."
43
+ # scaffold and publish your own skill
44
+ botdocs init my-skill
45
+ botdocs validate my-skill/
46
+ botdocs publish my-skill/
51
47
  ```
52
48
 
53
49
  ## Commands
54
50
 
55
51
  | Command | Purpose |
56
52
  |---|---|
57
- | `init [name]` | Scaffold a new BotDoc directory (`--canonical` for a multi-ecosystem skill). |
53
+ | `init [name]` | Scaffold a new skill directory (`--canonical` for a multi-ecosystem skill). |
58
54
  | `compile <path>` | Generate per-ecosystem skill drafts from a canonical source (BYOK). |
59
55
  | `edit <ref>` | LLM-assisted revision of a published skill ecosystem file (BYOK). |
60
56
  | `validate <source>` | Pre-publish structural check on a directory or file. |
61
- | `clone <user/slug>` | Download every file in a BotDoc to a local directory. |
62
57
  | `search <query>` | Search the public registry. |
63
58
  | `publish <source>` | Publish from a file, directory, or zip archive. |
64
- | `diff <user/slug>` | Preview remote changes before pulling. |
65
- | `pull <user/slug>` | Update a previously-cloned BotDoc. |
66
59
  | `install <ref>` | Install a skill or bundle (auto-detects destinations). |
67
60
  | `sync [ref]` | Check installed skills/bundles for updates and apply. |
68
61
  | `uninstall <ref>` | Remove an installed skill or bundle. |
69
62
  | `list` | Show installed skills and bundles. |
70
63
  | `ingest <path>` | Walk a directory, detect existing skills, upload as drafts. |
71
- | `endorse <user/slug>` | Rate a BotDoc after you've built from it (requires a prior clone). |
64
+ | `team list` / `show` / `create` / `add` / `remove` / `push` / `unpush` | Manage teams: shared skill libraries for your org. |
65
+ | `undo` | Restore the most recent backup run (reversible). |
66
+ | `backups list` / `restore` / `diff` / `clear` | Browse, restore, diff, and prune backup runs. |
72
67
  | `check-updates` | Check installed refs for available updates (1h cached). |
73
68
  | `install-instructions [target]` | Write/refresh `AGENTS.md` (or install a shell hook with `--shell-hook`). |
74
- | `login` | Authenticate via the GitHub device-code flow (`--sync-library` enables `/library`). |
69
+ | `login` | Authenticate by opening your browser; pass `--token bd_xxx` for headless/CI (`--sync-library` enables `/library`). |
75
70
  | `whoami` | Show the currently authenticated user. |
76
71
 
77
72
  Every command accepts `--json` for machine-readable output.
78
73
 
79
74
  Run `botdocs <command> --help` for full flags on any command.
80
75
 
76
+ ## Look & feel
77
+
78
+ `botdocs login` and `botdocs sync` are interactive and live by default — a
79
+ cyan-to-violet gradient brand mark, an animated polling spinner, and
80
+ per-file status rows that update in place. The CLI auto-detects when it's
81
+ running in a real terminal vs. a piped/CI environment and renders the
82
+ right thing without any flags. If you prefer plain output even on a TTY
83
+ (screen-reader friendly, or just calmer), pass `--no-ink` to either
84
+ command:
85
+
86
+ ```bash
87
+ botdocs login --no-ink
88
+ botdocs sync --no-ink
89
+ ```
90
+
91
+ `botdocs sync --json` keeps emitting the same machine-readable JSON it
92
+ always has — it never touches the Ink renderer.
93
+
81
94
  ## Configuration
82
95
 
83
96
  | Variable | Default | Purpose |
@@ -85,7 +98,11 @@ Run `botdocs <command> --help` for full flags on any command.
85
98
  | `BOTDOCS_API_URL` | `https://botdocs.ai` | Override the registry API endpoint (useful for local development). |
86
99
 
87
100
  Auth is stored at `~/.botdocs/auth.json` after `botdocs login`. Delete it
88
- to log out.
101
+ to log out. The default `botdocs login` opens your browser at
102
+ `/cli-auth`, where you sign in with whichever provider you prefer
103
+ (GitHub, Google, or email-OTP) and confirm the terminal session. For
104
+ non-interactive environments, mint a token at `/settings/tokens` and
105
+ run `botdocs login --token bd_xxx` instead.
89
106
 
90
107
  ## Teaching agents to use this CLI
91
108
 
@@ -150,7 +167,13 @@ botdocs init my-skill --canonical # scaffolds claude-code source
150
167
  # edit claude-code/commands/my-skill.md
151
168
 
152
169
  botdocs compile my-skill/ # generates claude/SKILL.md,
153
- # cursor/rules/my-skill.mdc, etc.
170
+ # cursor/rules/my-skill.mdc,
171
+ # codex/my-skill.md,
172
+ # copilot/instructions/my-skill.instructions.md,
173
+ # windsurf/rules/my-skill.md,
174
+ # gemini/instructions/my-skill.md,
175
+ # antigravity/skills/my-skill.md,
176
+ # opencode/instructions/my-skill.md, etc.
154
177
 
155
178
  botdocs publish my-skill/ # auto-compiles if stale; --no-compile to skip
156
179
  ```
@@ -168,6 +191,27 @@ botdocs edit @you/my-skill --ecosystem cursor
168
191
  Haiku) when set, otherwise fall back to `BOTDOCS_OPENAI_KEY`
169
192
  (GPT-4o mini). Use `--key-env <NAME>` to point at a different env var.
170
193
 
194
+ ## Teams
195
+
196
+ A team is a curated library of skills shared across its members. Skills
197
+ stay user-owned; teams *pin* skills to indicate "these are the ones we
198
+ use." Members install/sync the pinned set automatically.
199
+
200
+ ```bash
201
+ botdocs team create teamco --name "Team Co" # admin-only side: create
202
+ botdocs team add teamco @bob --role write # add a member
203
+ botdocs team push teamco @alice/eng-review-skill # pin a skill (WRITE+)
204
+ botdocs team push teamco @alice/eng --version 3 # pin to a specific version
205
+
206
+ botdocs team list # what teams am I in?
207
+ botdocs team show teamco # members + pinned skills
208
+ botdocs sync # pulls personal + team pins
209
+ ```
210
+
211
+ `botdocs sync` walks the team's pinned skills and installs/updates them
212
+ locally, marking the lockfile entry with the team they came from. Pinning
213
+ is curation, not access control — skills are public in v1.
214
+
171
215
  ## Skills + bundles
172
216
 
173
217
  Skills are bundles of files that ship to specific destinations on disk
@@ -190,22 +234,87 @@ botdocs sync
190
234
  `botdocs list` shows what you have installed; `botdocs uninstall <ref>`
191
235
  removes it.
192
236
 
237
+ ### Safety: backups before overwrite
238
+
239
+ `install` and `sync` will never silently clobber a hand-written file at
240
+ a colliding destination. Before any overwrite of a file the lockfile
241
+ doesn't claim as "ours and unchanged," the original is copied to a
242
+ timestamped backup directory:
243
+
244
+ - Project-scoped files → `<cwd>/.botdocs-backup/<ISO-ts>/<relative-path>`
245
+ - Global-scoped files (e.g. `~/.claude/skills/...`) → `~/.botdocs/backup/<ISO-ts>/<flattened-path>`
246
+
247
+ A single CLI invocation reuses the same `<ts>` folder, so all backups
248
+ from one run live together. A one-line warning is printed to stdout
249
+ for each backup taken. If the existing file matches a fingerprint we
250
+ recorded — i.e. we wrote it ourselves and the user hasn't edited it
251
+ since — no backup is taken (there's nothing to save). Backup failures
252
+ (e.g. permission errors) print a warning but don't block the install.
253
+
254
+ Pass `--no-backup` on `install` or `sync` to opt out — useful in CI
255
+ where backups are noise.
256
+
257
+ #### Undo and the `backups` surface
258
+
259
+ Backups are reversible. If you realize an `install` or `sync` clobbered
260
+ something you wanted to keep:
261
+
262
+ ```bash
263
+ botdocs undo # restore the most recent backup run
264
+ botdocs undo --dry-run # preview without writing
265
+ botdocs undo --yes # skip the confirm prompt (scripts)
266
+ ```
267
+
268
+ Before writing the restored content, the CURRENT state at each path is
269
+ itself backed up under a new timestamp — so `botdocs undo` is reversible.
270
+ Running it twice in a row swaps the state back. Pass `--no-backup` to
271
+ skip the pre-backup (loses reversibility).
272
+
273
+ For more granular control, the `backups` group browses, partial-restores,
274
+ diffs, and prunes:
275
+
276
+ ```bash
277
+ botdocs backups list # every run, newest first
278
+ botdocs backups list <ts> # files in a specific run
279
+ botdocs backups restore <ts> # restore the full run
280
+ botdocs backups restore <ts> --files a.mdc,b.mdc # restore a subset
281
+ botdocs backups diff <ts> <relpath> # diff backup vs current
282
+ botdocs backups clear # delete all runs (confirm)
283
+ botdocs backups clear --older-than 30d # delete runs older than N days
284
+ botdocs backups clear --dry-run # preview without deleting
285
+ ```
286
+
287
+ Each backup run records a `manifest.json` sidecar mapping original→backup,
288
+ so restoration is unambiguous even when global-scope filenames flatten
289
+ ambiguously (e.g. paths containing `_`).
290
+
193
291
  Authors who want to share their existing collection of skills run
194
292
  `botdocs ingest <path>` — the CLI walks the directory, detects each
195
- skill, and uploads them as drafts in your BotDocs account for review
196
- before publishing.
197
-
198
- ## Endorsing
293
+ skill across all 10 supported ecosystems (claude, claude-code, cursor,
294
+ chatgpt, codex, copilot, windsurf, gemini, antigravity, opencode), and
295
+ uploads them as drafts in your BotDocs account for review before
296
+ publishing.
199
297
 
200
- Endorsements are reserved for builders who actually used the spec — the
201
- server will reject an endorsement if it can't see a prior clone from the
202
- same account. If you hit that error the CLI will point you at:
298
+ By default `ingest` expects the canonical BotDocs layout (e.g.
299
+ `claude-code/commands/<slug>.md`, `cursor/rules/<slug>.mdc`). If your
300
+ files live in real on-disk locations instead, point `--from-tool=<ecosystem>`
301
+ at them and every matching file becomes a draft for that ecosystem:
203
302
 
204
303
  ```bash
205
- botdocs clone @user/slug
304
+ # Ingest all your Claude Code commands directly:
305
+ botdocs ingest --from-tool=claude-code ~/.claude/commands/
306
+
307
+ # Ingest a project's Cursor rules:
308
+ botdocs ingest --from-tool=cursor .cursor/rules/
309
+
310
+ # Ingest GitHub Copilot instructions (the .instructions.md extension is
311
+ # stripped from the slug automatically):
312
+ botdocs ingest --from-tool=copilot .github/instructions/
206
313
  ```
207
314
 
208
- …build something on top, then come back and run `endorse`.
315
+ The upload always uses the canonical BotDocs filename, so when someone
316
+ else `botdocs install`s the resulting skill it lands in the right
317
+ on-disk location.
209
318
 
210
319
  ## Development
211
320
 
@@ -0,0 +1,4 @@
1
+ import { Command } from 'commander';
2
+ import { type BackupRunSummary } from '../lib/backup.js';
3
+ export declare function registerBackupCommands(program: Command): void;
4
+ export type { BackupRunSummary };
@@ -0,0 +1,291 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import * as p from '@clack/prompts';
4
+ import { clearBackups, listBackupFiles, listBackupRuns, restoreBackup, } from '../lib/backup.js';
5
+ import { hasChanges, renderDiff } from '../lib/diff.js';
6
+ /** Show every backup run, newest first. */
7
+ async function backupsList(timestamp, options) {
8
+ const projectRoot = process.cwd();
9
+ if (timestamp) {
10
+ // List files in a specific run.
11
+ const entries = listBackupFiles(timestamp, projectRoot);
12
+ if (options.json) {
13
+ console.log(JSON.stringify({ runTimestamp: timestamp, files: entries }));
14
+ return;
15
+ }
16
+ if (entries.length === 0) {
17
+ console.log(`\n No backup run found with timestamp: ${timestamp}\n`);
18
+ return;
19
+ }
20
+ console.log(`\n Backup run: ${timestamp}`);
21
+ console.log(` Files: ${entries.length}\n`);
22
+ for (const e of entries) {
23
+ const rel = path.relative(projectRoot, e.originalPath) || e.originalPath;
24
+ console.log(` [${e.scope}] ${rel}`);
25
+ }
26
+ console.log('');
27
+ return;
28
+ }
29
+ const runs = listBackupRuns(projectRoot);
30
+ if (options.json) {
31
+ console.log(JSON.stringify({ runs }));
32
+ return;
33
+ }
34
+ if (runs.length === 0) {
35
+ console.log('\n No backup runs.\n');
36
+ return;
37
+ }
38
+ console.log('');
39
+ for (const r of runs) {
40
+ console.log(` ${r.runTimestamp} (${r.scope}, ${r.fileCount} file${r.fileCount === 1 ? '' : 's'})`);
41
+ }
42
+ console.log('');
43
+ }
44
+ /** Restore a specific backup run, optionally a subset via --files. */
45
+ async function backupsRestore(timestamp, options) {
46
+ const projectRoot = process.cwd();
47
+ const entries = listBackupFiles(timestamp, projectRoot);
48
+ if (entries.length === 0) {
49
+ if (options.json) {
50
+ console.log(JSON.stringify({ restored: [], failed: [], preBackedUp: [], message: 'No such run.' }));
51
+ }
52
+ else {
53
+ console.log(`\n No backup run found with timestamp: ${timestamp}\n`);
54
+ }
55
+ return;
56
+ }
57
+ const filters = options.files
58
+ ? options.files.split(',').map((f) => f.trim()).filter((f) => f.length > 0)
59
+ : undefined;
60
+ const targetCount = filters
61
+ ? entries.filter((e) => filters.some((f) => e.originalPath.endsWith(f))).length
62
+ : entries.length;
63
+ if (!options.json) {
64
+ const verb = options.dryRun ? 'Would restore' : 'Restore';
65
+ console.log(`\n ${verb} ${targetCount} file(s) from ${timestamp}`);
66
+ if (!options.noBackup && !options.dryRun) {
67
+ console.log(' (current state will be backed up first — reversible)');
68
+ }
69
+ console.log('');
70
+ }
71
+ if (!options.dryRun && !options.yes && !options.json) {
72
+ const confirmed = await p.confirm({
73
+ message: `Restore ${targetCount} file(s) from ${timestamp}?`,
74
+ initialValue: false,
75
+ });
76
+ if (p.isCancel(confirmed) || !confirmed) {
77
+ console.log(' Cancelled.\n');
78
+ return;
79
+ }
80
+ }
81
+ const result = restoreBackup(timestamp, projectRoot, {
82
+ files: filters,
83
+ dryRun: options.dryRun,
84
+ noBackup: options.noBackup,
85
+ });
86
+ if (options.json) {
87
+ console.log(JSON.stringify({
88
+ runTimestamp: timestamp,
89
+ dryRun: options.dryRun ?? false,
90
+ restored: result.restored.map((e) => e.originalPath),
91
+ failed: result.failed.map((f) => ({ originalPath: f.entry.originalPath, error: f.error })),
92
+ preBackedUp: result.preBackedUp,
93
+ }));
94
+ return;
95
+ }
96
+ reportRestoreResult(result, projectRoot, options.dryRun ?? false);
97
+ }
98
+ function reportRestoreResult(result, projectRoot, dryRun) {
99
+ const verb = dryRun ? 'Would restore' : 'Restored';
100
+ console.log(` ✓ ${verb} ${result.restored.length} file(s)`);
101
+ for (const e of result.restored) {
102
+ const rel = path.relative(projectRoot, e.originalPath) || e.originalPath;
103
+ console.log(` ${rel}`);
104
+ }
105
+ if (result.failed.length > 0) {
106
+ console.log(`\n ⚠ ${result.failed.length} file(s) failed:`);
107
+ for (const f of result.failed) {
108
+ const rel = path.relative(projectRoot, f.entry.originalPath) || f.entry.originalPath;
109
+ console.log(` ${rel} (${f.error})`);
110
+ }
111
+ }
112
+ console.log('');
113
+ }
114
+ /** Heuristic: read the first 8 KB of a buffer and treat the presence of a NUL
115
+ * byte as "binary." Good enough for telling backup text files apart from
116
+ * compiled binaries. */
117
+ function isBinary(buf) {
118
+ const limit = Math.min(buf.length, 8192);
119
+ for (let i = 0; i < limit; i++) {
120
+ if (buf[i] === 0)
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ /** Show a unified diff between the backup and the current on-disk file. */
126
+ async function backupsDiff(timestamp, relpath, options) {
127
+ const projectRoot = process.cwd();
128
+ const entries = listBackupFiles(timestamp, projectRoot);
129
+ const entry = entries.find((e) => e.originalPath.endsWith(relpath));
130
+ if (!entry) {
131
+ if (options.json) {
132
+ console.log(JSON.stringify({ error: 'no matching file in backup run' }));
133
+ }
134
+ else {
135
+ console.log(`\n No file matching "${relpath}" in run ${timestamp}\n`);
136
+ }
137
+ return;
138
+ }
139
+ if (!fs.existsSync(entry.backupPath)) {
140
+ if (options.json) {
141
+ console.log(JSON.stringify({ error: 'backup file missing on disk', backupPath: entry.backupPath }));
142
+ }
143
+ else {
144
+ console.log(`\n Backup file missing on disk: ${entry.backupPath}\n`);
145
+ }
146
+ return;
147
+ }
148
+ const backupBuf = fs.readFileSync(entry.backupPath);
149
+ const currentBuf = fs.existsSync(entry.originalPath)
150
+ ? fs.readFileSync(entry.originalPath)
151
+ : Buffer.alloc(0);
152
+ if (isBinary(backupBuf) || isBinary(currentBuf)) {
153
+ if (options.json) {
154
+ console.log(JSON.stringify({
155
+ originalPath: entry.originalPath,
156
+ backupPath: entry.backupPath,
157
+ binary: true,
158
+ differs: !backupBuf.equals(currentBuf),
159
+ }));
160
+ }
161
+ else {
162
+ console.log(`\n ${entry.originalPath}: differs (binary)\n`);
163
+ }
164
+ return;
165
+ }
166
+ const backupStr = backupBuf.toString('utf-8');
167
+ const currentStr = currentBuf.toString('utf-8');
168
+ if (options.json) {
169
+ console.log(JSON.stringify({
170
+ originalPath: entry.originalPath,
171
+ backupPath: entry.backupPath,
172
+ binary: false,
173
+ differs: hasChanges(currentStr, backupStr),
174
+ }));
175
+ return;
176
+ }
177
+ // Diff direction: show current → backup (i.e. what restoring would do).
178
+ console.log(`\n ${entry.originalPath}\n`);
179
+ console.log(renderDiff(currentStr, backupStr));
180
+ }
181
+ /** Parse `--older-than 30d` → 30. Returns undefined when the flag isn't set. */
182
+ function parseOlderThan(raw) {
183
+ if (!raw)
184
+ return undefined;
185
+ const m = raw.match(/^(\d+)\s*d?$/);
186
+ if (!m) {
187
+ throw new Error(`Invalid --older-than value: ${raw} (expected e.g. "30d" or "30")`);
188
+ }
189
+ return parseInt(m[1], 10);
190
+ }
191
+ async function backupsClear(options) {
192
+ const projectRoot = process.cwd();
193
+ let olderThanDays;
194
+ try {
195
+ olderThanDays = parseOlderThan(options.olderThan);
196
+ }
197
+ catch (err) {
198
+ if (options.json) {
199
+ console.log(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
200
+ }
201
+ else {
202
+ console.error(`\n ✗ ${err instanceof Error ? err.message : String(err)}\n`);
203
+ }
204
+ process.exit(1);
205
+ }
206
+ // First do a dry-run to see what's affected.
207
+ const preview = clearBackups(projectRoot, { olderThanDays, dryRun: true });
208
+ if (preview.cleared.length === 0) {
209
+ if (options.json) {
210
+ console.log(JSON.stringify({ cleared: [], kept: preview.kept }));
211
+ }
212
+ else {
213
+ console.log('\n Nothing to clear.\n');
214
+ }
215
+ return;
216
+ }
217
+ if (!options.json) {
218
+ const verb = options.dryRun ? 'Would clear' : 'Clear';
219
+ console.log(`\n ${verb} ${preview.cleared.length} backup run(s):`);
220
+ for (const r of preview.cleared) {
221
+ console.log(` ${r.runTimestamp} (${r.scope}, ${r.fileCount} file${r.fileCount === 1 ? '' : 's'})`);
222
+ }
223
+ console.log('');
224
+ }
225
+ if (options.dryRun) {
226
+ if (options.json) {
227
+ console.log(JSON.stringify({ dryRun: true, cleared: preview.cleared, kept: preview.kept }));
228
+ }
229
+ return;
230
+ }
231
+ // Double-confirm for destructive clears unless --yes. The "double" here is
232
+ // showing the list first AND requiring a y/N — printing the list makes the
233
+ // single prompt informed enough that a second prompt would be noise.
234
+ if (!options.yes && !options.json) {
235
+ const confirmed = await p.confirm({
236
+ message: `Permanently delete ${preview.cleared.length} backup run(s)?`,
237
+ initialValue: false,
238
+ });
239
+ if (p.isCancel(confirmed) || !confirmed) {
240
+ console.log(' Cancelled.\n');
241
+ return;
242
+ }
243
+ }
244
+ const result = clearBackups(projectRoot, { olderThanDays });
245
+ if (options.json) {
246
+ console.log(JSON.stringify({ cleared: result.cleared, kept: result.kept }));
247
+ return;
248
+ }
249
+ console.log(` ✓ Cleared ${result.cleared.length} backup run(s).\n`);
250
+ }
251
+ export function registerBackupCommands(program) {
252
+ const backups = program
253
+ .command('backups')
254
+ .description('Browse, restore, diff, and clear backup runs from install/sync overwrites');
255
+ backups
256
+ .command('list [timestamp]')
257
+ .description('List backup runs newest first; with a timestamp, list files in that run')
258
+ .action(async (timestamp) => {
259
+ await backupsList(timestamp, { json: program.opts().json });
260
+ });
261
+ backups
262
+ .command('restore <timestamp>')
263
+ .description('Restore a backup run (or a subset with --files)')
264
+ .option('--files <list>', 'Comma-separated relpath suffixes to restore (default: all files in the run)')
265
+ .option('--dry-run', 'Show what would be restored without writing')
266
+ .option('--yes', 'Skip the confirmation prompt')
267
+ .option('--no-backup', 'Skip backing up the current state before restoring (advanced)')
268
+ .action(async (timestamp, opts) => {
269
+ const { backup, ...rest } = opts;
270
+ await backupsRestore(timestamp, {
271
+ ...rest,
272
+ noBackup: backup === false,
273
+ json: program.opts().json,
274
+ });
275
+ });
276
+ backups
277
+ .command('diff <timestamp> <relpath>')
278
+ .description('Show a diff between the backup and the current file')
279
+ .action(async (timestamp, relpath) => {
280
+ await backupsDiff(timestamp, relpath, { json: program.opts().json });
281
+ });
282
+ backups
283
+ .command('clear')
284
+ .description('Delete backup runs (all, or filtered by age)')
285
+ .option('--older-than <duration>', 'Only clear runs older than this (e.g. "30d")')
286
+ .option('--dry-run', 'Show what would be cleared without deleting')
287
+ .option('--yes', 'Skip the confirmation prompt')
288
+ .action(async (opts) => {
289
+ await backupsClear({ ...opts, json: program.opts().json });
290
+ });
291
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { input, select } from '@inquirer/prompts';
4
+ import * as p from '@clack/prompts';
5
5
  import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
6
6
  import { complete, detectProvider, LlmError } from '../lib/llm.js';
7
7
  import { renderDiff } from '../lib/diff.js';
@@ -63,7 +63,15 @@ export async function edit(rawRef, options) {
63
63
  }
64
64
  console.log(` ✓ Pulled ${target.filename}`);
65
65
  const currentContent = await fetchRawContent(target.rawUrl);
66
- const userRequest = await input({ message: 'What change would you like to make?' });
66
+ const userRequestRaw = await p.text({
67
+ message: 'What change would you like to make?',
68
+ placeholder: 'e.g. "Add a section about testing"',
69
+ });
70
+ if (p.isCancel(userRequestRaw)) {
71
+ p.cancel('Edit cancelled.');
72
+ process.exit(0);
73
+ }
74
+ const userRequest = userRequestRaw;
67
75
  if (!userRequest.trim()) {
68
76
  console.error('\n ✗ No request provided. Aborting.\n');
69
77
  process.exit(1);
@@ -79,15 +87,15 @@ export async function edit(rawRef, options) {
79
87
  }
80
88
  revised = resp.text;
81
89
  console.log(renderDiff(currentContent, revised));
82
- const choice = await select({
90
+ const choice = await p.select({
83
91
  message: 'Apply revision?',
84
- choices: [
85
- { name: 'accept (push as draft to BotDocs)', value: 'accept' },
86
- { name: 'regenerate', value: 'regenerate' },
87
- { name: 'cancel', value: 'cancel' },
92
+ options: [
93
+ { value: 'accept', label: 'Accept (push as draft to BotDocs)' },
94
+ { value: 'regenerate', label: 'Regenerate' },
95
+ { value: 'cancel', label: 'Cancel' },
88
96
  ],
89
97
  });
90
- if (choice === 'cancel') {
98
+ if (p.isCancel(choice) || choice === 'cancel') {
91
99
  console.log('\n Cancelled. No changes pushed.\n');
92
100
  return;
93
101
  }
@@ -2,6 +2,8 @@ interface IngestOptions {
2
2
  bundle?: string;
3
3
  dryRun?: boolean;
4
4
  json?: boolean;
5
+ fromTool?: string;
5
6
  }
7
+ export declare const SUPPORTED_TOOLS: readonly string[];
6
8
  export declare function ingest(rootPath: string, options: IngestOptions): Promise<void>;
7
9
  export {};