@botdocs/cli 0.3.1 → 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.
- package/README.md +123 -37
- package/dist/commands/backups.d.ts +4 -0
- package/dist/commands/backups.js +291 -0
- package/dist/commands/edit.js +16 -8
- package/dist/commands/install.d.ts +4 -0
- package/dist/commands/install.js +21 -3
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.js +240 -75
- package/dist/commands/publish.js +53 -16
- package/dist/commands/sync.d.ts +16 -0
- package/dist/commands/sync.js +337 -25
- package/dist/commands/team.d.ts +2 -0
- package/dist/commands/team.js +251 -0
- package/dist/commands/undo.d.ts +19 -0
- package/dist/commands/undo.js +88 -0
- package/dist/commands/views/conflict-prompt.d.ts +24 -0
- package/dist/commands/views/conflict-prompt.js +19 -0
- package/dist/commands/views/login-app.d.ts +30 -0
- package/dist/commands/views/login-app.js +57 -0
- package/dist/commands/views/sync-app.d.ts +27 -0
- package/dist/commands/views/sync-app.js +147 -0
- package/dist/commands/views/sync-state.d.ts +84 -0
- package/dist/commands/views/sync-state.js +93 -0
- package/dist/commands/views/theme.d.ts +16 -0
- package/dist/commands/views/theme.js +16 -0
- package/dist/commands/whoami.js +13 -13
- package/dist/index.js +44 -38
- package/dist/lib/api.d.ts +2 -3
- package/dist/lib/api.js +14 -7
- package/dist/lib/auto-detect.js +46 -0
- package/dist/lib/backup.d.ts +121 -0
- package/dist/lib/backup.js +387 -0
- package/dist/lib/canonical.d.ts +1 -1
- package/dist/lib/canonical.js +43 -1
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.js +18 -9
- package/dist/lib/lockfile.d.ts +9 -0
- package/dist/lib/prompts.d.ts +10 -0
- package/dist/lib/prompts.js +36 -12
- package/package.json +27 -7
- package/templates/agents.md +60 -47
- package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
- package/templates/ecosystem-prompts/compile-copilot.md +14 -0
- package/templates/ecosystem-prompts/compile-gemini.md +14 -0
- package/templates/ecosystem-prompts/compile-opencode.md +13 -0
- package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
- package/dist/commands/check-updates.test.d.ts +0 -1
- package/dist/commands/check-updates.test.js +0 -128
- package/dist/commands/clone.d.ts +0 -3
- package/dist/commands/clone.js +0 -70
- package/dist/commands/compile.test.d.ts +0 -1
- package/dist/commands/compile.test.js +0 -110
- package/dist/commands/diff.d.ts +0 -3
- package/dist/commands/diff.js +0 -65
- package/dist/commands/edit.test.d.ts +0 -1
- package/dist/commands/edit.test.js +0 -102
- package/dist/commands/endorse.d.ts +0 -7
- package/dist/commands/endorse.js +0 -70
- package/dist/commands/ingest.test.d.ts +0 -1
- package/dist/commands/ingest.test.js +0 -109
- package/dist/commands/install.test.d.ts +0 -1
- package/dist/commands/install.test.js +0 -253
- package/dist/commands/list.test.d.ts +0 -1
- package/dist/commands/list.test.js +0 -51
- package/dist/commands/publish.test.d.ts +0 -1
- package/dist/commands/publish.test.js +0 -76
- package/dist/commands/pull.d.ts +0 -3
- package/dist/commands/pull.js +0 -78
- package/dist/commands/sync.test.d.ts +0 -1
- package/dist/commands/sync.test.js +0 -263
- package/dist/commands/uninstall.test.d.ts +0 -1
- package/dist/commands/uninstall.test.js +0 -67
- package/dist/lib/auto-detect.test.d.ts +0 -1
- package/dist/lib/auto-detect.test.js +0 -58
- package/dist/lib/canonical.test.d.ts +0 -1
- package/dist/lib/canonical.test.js +0 -48
- package/dist/lib/diff.test.d.ts +0 -1
- package/dist/lib/diff.test.js +0 -28
- package/dist/lib/library-sync.test.d.ts +0 -1
- package/dist/lib/library-sync.test.js +0 -63
- package/dist/lib/llm.test.d.ts +0 -1
- package/dist/lib/llm.test.js +0 -72
- package/dist/lib/lockfile.test.d.ts +0 -1
- package/dist/lib/lockfile.test.js +0 -99
- package/dist/lib/manifest.test.d.ts +0 -1
- package/dist/lib/manifest.test.js +0 -72
- package/dist/lib/shell-hook.test.d.ts +0 -1
- package/dist/lib/shell-hook.test.js +0 -68
- package/dist/test-utils.d.ts +0 -43
- 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) —
|
|
4
|
-
|
|
3
|
+
The official CLI for [BotDocs](https://botdocs.ai) — author, publish,
|
|
4
|
+
install, and sync agent skills.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
43
|
-
botdocs
|
|
37
|
+
# install a team's shared skills
|
|
38
|
+
botdocs install @teamco/eng-skills
|
|
44
39
|
|
|
45
|
-
#
|
|
46
|
-
botdocs
|
|
40
|
+
# stay in sync with your team
|
|
41
|
+
botdocs sync
|
|
47
42
|
|
|
48
|
-
#
|
|
49
|
-
botdocs
|
|
50
|
-
|
|
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
|
|
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
|
-
| `
|
|
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
|
|
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,
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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:
|
|
203
261
|
|
|
204
262
|
```bash
|
|
205
|
-
botdocs
|
|
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
|
-
|
|
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,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
|
+
}
|
package/dist/commands/edit.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
85
|
-
{
|
|
86
|
-
{
|
|
87
|
-
{
|
|
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 {};
|
package/dist/commands/install.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -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 {};
|