@botdocs/cli 0.3.2 → 0.4.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 (89) hide show
  1. package/README.md +123 -37
  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/install.d.ts +4 -0
  6. package/dist/commands/install.js +21 -3
  7. package/dist/commands/login.d.ts +7 -0
  8. package/dist/commands/login.js +240 -75
  9. package/dist/commands/sync.d.ts +16 -0
  10. package/dist/commands/sync.js +337 -25
  11. package/dist/commands/team.d.ts +2 -0
  12. package/dist/commands/team.js +251 -0
  13. package/dist/commands/undo.d.ts +19 -0
  14. package/dist/commands/undo.js +88 -0
  15. package/dist/commands/views/conflict-prompt.d.ts +24 -0
  16. package/dist/commands/views/conflict-prompt.js +19 -0
  17. package/dist/commands/views/login-app.d.ts +30 -0
  18. package/dist/commands/views/login-app.js +57 -0
  19. package/dist/commands/views/sync-app.d.ts +27 -0
  20. package/dist/commands/views/sync-app.js +147 -0
  21. package/dist/commands/views/sync-state.d.ts +84 -0
  22. package/dist/commands/views/sync-state.js +93 -0
  23. package/dist/commands/views/theme.d.ts +16 -0
  24. package/dist/commands/views/theme.js +16 -0
  25. package/dist/commands/whoami.js +13 -13
  26. package/dist/index.js +44 -38
  27. package/dist/lib/api.d.ts +2 -3
  28. package/dist/lib/api.js +14 -7
  29. package/dist/lib/auto-detect.js +46 -0
  30. package/dist/lib/backup.d.ts +121 -0
  31. package/dist/lib/backup.js +387 -0
  32. package/dist/lib/canonical.d.ts +1 -1
  33. package/dist/lib/canonical.js +43 -1
  34. package/dist/lib/config.d.ts +8 -1
  35. package/dist/lib/config.js +18 -9
  36. package/dist/lib/lockfile.d.ts +9 -0
  37. package/dist/lib/prompts.d.ts +10 -0
  38. package/dist/lib/prompts.js +36 -12
  39. package/package.json +27 -7
  40. package/templates/agents.md +60 -47
  41. package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
  42. package/templates/ecosystem-prompts/compile-copilot.md +14 -0
  43. package/templates/ecosystem-prompts/compile-gemini.md +14 -0
  44. package/templates/ecosystem-prompts/compile-opencode.md +13 -0
  45. package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
  46. package/dist/commands/check-updates.test.d.ts +0 -1
  47. package/dist/commands/check-updates.test.js +0 -128
  48. package/dist/commands/clone.d.ts +0 -3
  49. package/dist/commands/clone.js +0 -70
  50. package/dist/commands/compile.test.d.ts +0 -1
  51. package/dist/commands/compile.test.js +0 -110
  52. package/dist/commands/diff.d.ts +0 -3
  53. package/dist/commands/diff.js +0 -65
  54. package/dist/commands/edit.test.d.ts +0 -1
  55. package/dist/commands/edit.test.js +0 -102
  56. package/dist/commands/endorse.d.ts +0 -7
  57. package/dist/commands/endorse.js +0 -70
  58. package/dist/commands/ingest.test.d.ts +0 -1
  59. package/dist/commands/ingest.test.js +0 -109
  60. package/dist/commands/install.test.d.ts +0 -1
  61. package/dist/commands/install.test.js +0 -253
  62. package/dist/commands/list.test.d.ts +0 -1
  63. package/dist/commands/list.test.js +0 -51
  64. package/dist/commands/publish.test.d.ts +0 -1
  65. package/dist/commands/publish.test.js +0 -138
  66. package/dist/commands/pull.d.ts +0 -3
  67. package/dist/commands/pull.js +0 -78
  68. package/dist/commands/sync.test.d.ts +0 -1
  69. package/dist/commands/sync.test.js +0 -263
  70. package/dist/commands/uninstall.test.d.ts +0 -1
  71. package/dist/commands/uninstall.test.js +0 -67
  72. package/dist/lib/auto-detect.test.d.ts +0 -1
  73. package/dist/lib/auto-detect.test.js +0 -58
  74. package/dist/lib/canonical.test.d.ts +0 -1
  75. package/dist/lib/canonical.test.js +0 -48
  76. package/dist/lib/diff.test.d.ts +0 -1
  77. package/dist/lib/diff.test.js +0 -28
  78. package/dist/lib/library-sync.test.d.ts +0 -1
  79. package/dist/lib/library-sync.test.js +0 -63
  80. package/dist/lib/llm.test.d.ts +0 -1
  81. package/dist/lib/llm.test.js +0 -72
  82. package/dist/lib/lockfile.test.d.ts +0 -1
  83. package/dist/lib/lockfile.test.js +0 -99
  84. package/dist/lib/manifest.test.d.ts +0 -1
  85. package/dist/lib/manifest.test.js +0 -72
  86. package/dist/lib/shell-hook.test.d.ts +0 -1
  87. package/dist/lib/shell-hook.test.js +0 -68
  88. package/dist/test-utils.d.ts +0 -43
  89. 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,64 @@ botdocs sync
190
234
  `botdocs list` shows what you have installed; `botdocs uninstall <ref>`
191
235
  removes it.
192
236
 
193
- Authors who want to share their existing collection of skills run
194
- `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.
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>`
197
246
 
198
- ## Endorsing
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.
199
253
 
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:
254
+ Pass `--no-backup` on `install` or `sync` to opt outuseful 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:
203
261
 
204
262
  ```bash
205
- botdocs clone @user/slug
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)
206
266
  ```
207
267
 
208
- …build something on top, then come back and run `endorse`.
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
+
291
+ Authors who want to share their existing collection of skills run
292
+ `botdocs ingest <path>` — the CLI walks the directory, detects each
293
+ skill, and uploads them as drafts in your BotDocs account for review
294
+ before publishing.
209
295
 
210
296
  ## Development
211
297
 
@@ -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
  }
@@ -3,6 +3,10 @@ interface InstallOptions {
3
3
  flat?: boolean;
4
4
  clean?: boolean;
5
5
  json?: boolean;
6
+ /** When true, skip backups before overwriting existing files. Intended for
7
+ * CI where backups are noise; default behavior backs up untracked or
8
+ * locally-edited files to `.botdocs-backup/<ts>/` before the overwrite. */
9
+ noBackup?: boolean;
6
10
  }
7
11
  export declare function install(rawRef: string, options: InstallOptions): Promise<void>;
8
12
  export {};
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
5
5
  import { detectDestination } from '../lib/auto-detect.js';
6
6
  import { fingerprintContent, fingerprintFile, loadLockfile, upsertInstall, } from '../lib/lockfile.js';
7
+ import { backupFile, isLockfileOwnedAndUnchanged } from '../lib/backup.js';
7
8
  import { syncLibrary } from '../lib/library-sync.js';
8
9
  function parseRef(raw) {
9
10
  const cleaned = raw.startsWith('@') ? raw.slice(1) : raw;
@@ -25,16 +26,33 @@ function buildContext(scope, slug, options) {
25
26
  function ensureDir(filePath) {
26
27
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
27
28
  }
28
- async function downloadAndWrite(file, dest, options) {
29
+ async function downloadAndWrite(file, dest, options, projectDir) {
29
30
  const content = await fetchRawContent(file.rawUrl);
30
31
  if (fs.existsSync(dest) && !options.clean) {
31
32
  const existingFp = fingerprintFile(dest);
32
33
  const tmpFp = fingerprintContent(content);
33
34
  if (existingFp === tmpFp) {
34
- // Already present at same fingerprint — additive no-op.
35
+ // Already present at same fingerprint — additive no-op. No backup
36
+ // needed: we're about to write the same bytes anyway.
35
37
  return { src: file.filename, dest, fingerprint: existingFp };
36
38
  }
37
39
  }
40
+ // About to overwrite. If the existing file isn't something we own and
41
+ // haven't touched, take a backup first so a hand-written rule at a
42
+ // colliding path isn't silently lost.
43
+ if (fs.existsSync(dest) && !options.noBackup && !isLockfileOwnedAndUnchanged(dest)) {
44
+ const result = backupFile(dest, projectDir);
45
+ if (!options.json) {
46
+ if (result.ok) {
47
+ const relSrc = path.relative(process.cwd(), dest);
48
+ const relDest = path.relative(process.cwd(), result.dest);
49
+ console.log(` ⚠ Backed up existing file: ${relSrc} → ${relDest}`);
50
+ }
51
+ else {
52
+ console.log(` ⚠ Could not back up ${dest}: ${result.error} — proceeding with overwrite.`);
53
+ }
54
+ }
55
+ }
38
56
  ensureDir(dest);
39
57
  fs.writeFileSync(dest, content, 'utf-8');
40
58
  return { src: file.filename, dest, fingerprint: fingerprintFile(dest) };
@@ -54,7 +72,7 @@ async function installSkill(ref, manifest, options, scope) {
54
72
  }
55
73
  continue;
56
74
  }
57
- const installed = await downloadAndWrite(file, detection.dest, options);
75
+ const installed = await downloadAndWrite(file, detection.dest, options, ctx.projectDir);
58
76
  if (installed)
59
77
  filesInstalled.push(installed);
60
78
  }
@@ -1,5 +1,12 @@
1
1
  interface LoginOptions {
2
2
  syncLibrary?: boolean;
3
+ /** Skip the browser flow and store this token directly. Used for CI/headless
4
+ * environments where the user has already minted a token at /settings/tokens. */
5
+ token?: string;
6
+ /** Force the plain-text rendering path even on a real TTY. Useful for users
7
+ * who prefer screen-reader-friendly output, or anyone disturbed by live
8
+ * redraws. The non-TTY path is taken automatically when stdout is piped. */
9
+ noInk?: boolean;
3
10
  }
4
11
  export declare function login(options?: LoginOptions): Promise<void>;
5
12
  export {};