@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.
Files changed (90) 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/publish.js +53 -16
  10. package/dist/commands/sync.d.ts +16 -0
  11. package/dist/commands/sync.js +337 -25
  12. package/dist/commands/team.d.ts +2 -0
  13. package/dist/commands/team.js +251 -0
  14. package/dist/commands/undo.d.ts +19 -0
  15. package/dist/commands/undo.js +88 -0
  16. package/dist/commands/views/conflict-prompt.d.ts +24 -0
  17. package/dist/commands/views/conflict-prompt.js +19 -0
  18. package/dist/commands/views/login-app.d.ts +30 -0
  19. package/dist/commands/views/login-app.js +57 -0
  20. package/dist/commands/views/sync-app.d.ts +27 -0
  21. package/dist/commands/views/sync-app.js +147 -0
  22. package/dist/commands/views/sync-state.d.ts +84 -0
  23. package/dist/commands/views/sync-state.js +93 -0
  24. package/dist/commands/views/theme.d.ts +16 -0
  25. package/dist/commands/views/theme.js +16 -0
  26. package/dist/commands/whoami.js +13 -13
  27. package/dist/index.js +44 -38
  28. package/dist/lib/api.d.ts +2 -3
  29. package/dist/lib/api.js +14 -7
  30. package/dist/lib/auto-detect.js +46 -0
  31. package/dist/lib/backup.d.ts +121 -0
  32. package/dist/lib/backup.js +387 -0
  33. package/dist/lib/canonical.d.ts +1 -1
  34. package/dist/lib/canonical.js +43 -1
  35. package/dist/lib/config.d.ts +8 -1
  36. package/dist/lib/config.js +18 -9
  37. package/dist/lib/lockfile.d.ts +9 -0
  38. package/dist/lib/prompts.d.ts +10 -0
  39. package/dist/lib/prompts.js +36 -12
  40. package/package.json +27 -7
  41. package/templates/agents.md +60 -47
  42. package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
  43. package/templates/ecosystem-prompts/compile-copilot.md +14 -0
  44. package/templates/ecosystem-prompts/compile-gemini.md +14 -0
  45. package/templates/ecosystem-prompts/compile-opencode.md +13 -0
  46. package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
  47. package/dist/commands/check-updates.test.d.ts +0 -1
  48. package/dist/commands/check-updates.test.js +0 -128
  49. package/dist/commands/clone.d.ts +0 -3
  50. package/dist/commands/clone.js +0 -70
  51. package/dist/commands/compile.test.d.ts +0 -1
  52. package/dist/commands/compile.test.js +0 -110
  53. package/dist/commands/diff.d.ts +0 -3
  54. package/dist/commands/diff.js +0 -65
  55. package/dist/commands/edit.test.d.ts +0 -1
  56. package/dist/commands/edit.test.js +0 -102
  57. package/dist/commands/endorse.d.ts +0 -7
  58. package/dist/commands/endorse.js +0 -70
  59. package/dist/commands/ingest.test.d.ts +0 -1
  60. package/dist/commands/ingest.test.js +0 -109
  61. package/dist/commands/install.test.d.ts +0 -1
  62. package/dist/commands/install.test.js +0 -253
  63. package/dist/commands/list.test.d.ts +0 -1
  64. package/dist/commands/list.test.js +0 -51
  65. package/dist/commands/publish.test.d.ts +0 -1
  66. package/dist/commands/publish.test.js +0 -76
  67. package/dist/commands/pull.d.ts +0 -3
  68. package/dist/commands/pull.js +0 -78
  69. package/dist/commands/sync.test.d.ts +0 -1
  70. package/dist/commands/sync.test.js +0 -263
  71. package/dist/commands/uninstall.test.d.ts +0 -1
  72. package/dist/commands/uninstall.test.js +0 -67
  73. package/dist/lib/auto-detect.test.d.ts +0 -1
  74. package/dist/lib/auto-detect.test.js +0 -58
  75. package/dist/lib/canonical.test.d.ts +0 -1
  76. package/dist/lib/canonical.test.js +0 -48
  77. package/dist/lib/diff.test.d.ts +0 -1
  78. package/dist/lib/diff.test.js +0 -28
  79. package/dist/lib/library-sync.test.d.ts +0 -1
  80. package/dist/lib/library-sync.test.js +0 -63
  81. package/dist/lib/llm.test.d.ts +0 -1
  82. package/dist/lib/llm.test.js +0 -72
  83. package/dist/lib/lockfile.test.d.ts +0 -1
  84. package/dist/lib/lockfile.test.js +0 -99
  85. package/dist/lib/manifest.test.d.ts +0 -1
  86. package/dist/lib/manifest.test.js +0 -72
  87. package/dist/lib/shell-hook.test.d.ts +0 -1
  88. package/dist/lib/shell-hook.test.js +0 -68
  89. package/dist/test-utils.d.ts +0 -43
  90. package/dist/test-utils.js +0 -101
@@ -0,0 +1,121 @@
1
+ /** Only exported so tests can reset between cases. Do NOT call from production
2
+ * code — the whole point of the cache is that one CLI invocation = one folder. */
3
+ export declare function _resetRunTimestampForTests(): void;
4
+ export interface BackupResult {
5
+ /** Absolute path to where the backup landed (or would land, if !ok). */
6
+ dest: string;
7
+ /** Whether the backup succeeded. */
8
+ ok: boolean;
9
+ /** If !ok, the error message to surface to the user. */
10
+ error?: string;
11
+ }
12
+ /** A single file's record in a backup run's `manifest.json` sidecar.
13
+ *
14
+ * The manifest is the source of truth for what each backup file represents.
15
+ * Restoration MUST read from the manifest rather than path-decoding — the
16
+ * global-scope flattening (`/` → `_`) is lossy if filenames contain
17
+ * underscores. */
18
+ export interface BackupManifestEntry {
19
+ /** Original absolute path of the backed-up file. */
20
+ originalPath: string;
21
+ /** Absolute path to where the backup landed. */
22
+ backupPath: string;
23
+ /** "project" if the original was under projectRoot, else "global". */
24
+ scope: 'project' | 'global';
25
+ /** ISO timestamp of when this individual file was backed up. */
26
+ backedUpAt: string;
27
+ }
28
+ export interface BackupManifest {
29
+ /** ISO timestamp shared by all files in this run (matches the folder name). */
30
+ runTimestamp: string;
31
+ files: BackupManifestEntry[];
32
+ }
33
+ /** Compute the backup destination for an existing file without actually
34
+ * copying. Used by --dry-run to print "would back up" lines without touching
35
+ * disk. */
36
+ export declare function backupDestination(absolutePath: string, projectRoot: string, homeDir?: string): string;
37
+ /** Backup an existing file before it's about to be overwritten.
38
+ *
39
+ * - Project-scoped files (under `projectRoot`) → `<projectRoot>/.botdocs-backup/<ts>/<rel>`
40
+ * - Anything else (e.g. global skills under ~/.claude/skills) → `~/.botdocs/backup/<ts>/<absPath-with-/-as-_>`
41
+ *
42
+ * Also appends an entry to the run's `manifest.json` sidecar so `botdocs undo`
43
+ * and `botdocs backups restore` can recover unambiguously even when the
44
+ * flattened global filename is ambiguous.
45
+ *
46
+ * Returns `{ ok: false, error }` rather than throwing so callers can surface a
47
+ * warning and proceed with the overwrite. We never want a backup failure to
48
+ * block the underlying install/sync. */
49
+ export declare function backupFile(absolutePath: string, projectRoot: string, homeDir?: string): BackupResult;
50
+ /** Returns true if the given destination path appears in any install's
51
+ * `files[]` with a fingerprint matching the current on-disk content. That is,
52
+ * "this is a file we wrote and the user hasn't edited it since." Untracked
53
+ * files OR tracked-but-edited files both return false → backup required.
54
+ *
55
+ * The fingerprint check is what distinguishes "we wrote this and own it" from
56
+ * "we wrote this but the user has since edited it" — the latter still deserves
57
+ * a backup before we clobber the edits. */
58
+ export declare function isLockfileOwnedAndUnchanged(dest: string): boolean;
59
+ /** Summary of one backup run, suitable for `botdocs backups list` output. */
60
+ export interface BackupRunSummary {
61
+ /** ISO timestamp shared by every file in the run (matches the folder name). */
62
+ runTimestamp: string;
63
+ /** Where the manifest(s) for this run live: project, global, or both. */
64
+ scope: 'project' | 'global' | 'both';
65
+ /** Total files in the run, across both scopes if both exist. */
66
+ fileCount: number;
67
+ /** Primary run-root directory; the project one if both exist. */
68
+ rootPath: string;
69
+ }
70
+ /** List every backup run across both roots, newest first.
71
+ *
72
+ * Returns the union of timestamps in the project root and the global root.
73
+ * When a timestamp appears in both, it's reported once with `scope: 'both'`. */
74
+ export declare function listBackupRuns(projectRoot: string, homeDir?: string): BackupRunSummary[];
75
+ /** List every file in a specific backup run, across both manifests if present. */
76
+ export declare function listBackupFiles(runTimestamp: string, projectRoot: string, homeDir?: string): BackupManifestEntry[];
77
+ export interface RestoreOptions {
78
+ /** Optional subset filter — restore only entries whose `originalPath` ends
79
+ * with one of these relative paths. Useful for partial-restore from a
80
+ * larger run. */
81
+ files?: string[];
82
+ /** If true, don't write — just compute the plan. */
83
+ dryRun?: boolean;
84
+ /** If true, skip the "back up current state before restoring" step. The
85
+ * default is to back up first so `undo` is itself reversible. */
86
+ noBackup?: boolean;
87
+ }
88
+ export interface RestoreFailure {
89
+ entry: BackupManifestEntry;
90
+ error: string;
91
+ }
92
+ export interface RestoreResult {
93
+ /** Entries that were successfully restored (or would be, under --dry-run). */
94
+ restored: BackupManifestEntry[];
95
+ /** Entries we couldn't restore — backup file missing/unreadable etc.
96
+ * We skip these and continue with the rest of the run. */
97
+ failed: RestoreFailure[];
98
+ /** Original paths whose current state was backed up before the restore.
99
+ * Files that didn't exist at the original path are NOT included here —
100
+ * there was nothing to back up. */
101
+ preBackedUp: string[];
102
+ }
103
+ /** Restore every file recorded in a backup run's manifest to its original path.
104
+ *
105
+ * Before writing each restored file, the CURRENT contents at `originalPath` are
106
+ * themselves backed up under a NEW timestamp so `undo` is reversible — running
107
+ * `botdocs undo` twice in a row swaps the state back. Pass `noBackup: true` to
108
+ * skip the pre-backup (advanced). */
109
+ export declare function restoreBackup(runTimestamp: string, projectRoot: string, options?: RestoreOptions, homeDir?: string): RestoreResult;
110
+ export interface ClearOptions {
111
+ /** Drop runs whose timestamp is older than N days. If omitted, clear ALL runs. */
112
+ olderThanDays?: number;
113
+ /** If true, just compute the plan — don't delete anything. */
114
+ dryRun?: boolean;
115
+ }
116
+ export interface ClearResult {
117
+ cleared: BackupRunSummary[];
118
+ kept: BackupRunSummary[];
119
+ }
120
+ /** Clear backup runs, optionally filtered by age. */
121
+ export declare function clearBackups(projectRoot: string, options?: ClearOptions, homeDir?: string): ClearResult;
@@ -0,0 +1,387 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fingerprintFile, loadLockfile } from './lockfile.js';
5
+ let runTimestamp = null;
6
+ /** A single timestamp per CLI invocation, lazily generated on first backup.
7
+ * Cached at the module level so multiple backups in the same `botdocs install`
8
+ * or `botdocs sync` run all land under the same `<ts>` folder, making them
9
+ * easy to inspect and restore as a unit. */
10
+ function getRunTimestamp() {
11
+ if (!runTimestamp) {
12
+ runTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
13
+ }
14
+ return runTimestamp;
15
+ }
16
+ /** Only exported so tests can reset between cases. Do NOT call from production
17
+ * code — the whole point of the cache is that one CLI invocation = one folder. */
18
+ export function _resetRunTimestampForTests() {
19
+ runTimestamp = null;
20
+ }
21
+ /** Resolve symlinks for the parent directory so comparisons hold up against
22
+ * platform quirks (macOS prefixes `/var/...` with `/private`, etc.). Falls
23
+ * back to the original path if the parent doesn't exist yet. */
24
+ function realParentResolve(p) {
25
+ const abs = path.resolve(p);
26
+ const parent = path.dirname(abs);
27
+ try {
28
+ const realParent = fs.realpathSync(parent);
29
+ return path.join(realParent, path.basename(abs));
30
+ }
31
+ catch {
32
+ return abs;
33
+ }
34
+ }
35
+ /** Determine whether a path is under projectRoot (project-scoped) or not. */
36
+ function isProjectScoped(absolutePath, projectRoot) {
37
+ const normProject = realParentResolve(projectRoot);
38
+ const normAbs = realParentResolve(absolutePath);
39
+ const rel = path.relative(normProject, normAbs);
40
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
41
+ }
42
+ /** Root directory for a backup run's folder. Either:
43
+ * - Project: `<projectRoot>/.botdocs-backup/<ts>`
44
+ * - Global: `<homeDir>/.botdocs/backup/<ts>` */
45
+ function backupRunRoot(scope, ts, projectRoot, homeDir) {
46
+ if (scope === 'project') {
47
+ return path.join(realParentResolve(projectRoot), '.botdocs-backup', ts);
48
+ }
49
+ return path.join(homeDir, '.botdocs', 'backup', ts);
50
+ }
51
+ /** Compute the backup destination for an existing file without actually
52
+ * copying. Used by --dry-run to print "would back up" lines without touching
53
+ * disk. */
54
+ export function backupDestination(absolutePath, projectRoot, homeDir = os.homedir()) {
55
+ const ts = getRunTimestamp();
56
+ const normAbs = realParentResolve(absolutePath);
57
+ if (isProjectScoped(absolutePath, projectRoot)) {
58
+ const normProject = realParentResolve(projectRoot);
59
+ const rel = path.relative(normProject, normAbs);
60
+ return path.join(normProject, '.botdocs-backup', ts, rel);
61
+ }
62
+ // Global-scoped: flatten the absolute path into a single filename by
63
+ // replacing path separators with `_`. Predictable, but lossy if filenames
64
+ // already contain `_` — the manifest.json sidecar is the source of truth
65
+ // for restoration.
66
+ const flattened = normAbs.replace(/[/\\]/g, '_');
67
+ return path.join(homeDir, '.botdocs', 'backup', ts, flattened);
68
+ }
69
+ /** Read a manifest.json from disk, returning a fresh empty manifest if the
70
+ * file doesn't exist or can't be parsed. */
71
+ function readManifest(manifestPath, ts) {
72
+ if (!fs.existsSync(manifestPath)) {
73
+ return { runTimestamp: ts, files: [] };
74
+ }
75
+ try {
76
+ const raw = fs.readFileSync(manifestPath, 'utf-8');
77
+ const parsed = JSON.parse(raw);
78
+ if (typeof parsed === 'object' &&
79
+ parsed !== null &&
80
+ typeof parsed.runTimestamp === 'string' &&
81
+ Array.isArray(parsed.files)) {
82
+ return parsed;
83
+ }
84
+ }
85
+ catch {
86
+ // Fall through to a fresh manifest — better to lose one stale corrupt
87
+ // file than block all future backups in this folder.
88
+ }
89
+ return { runTimestamp: ts, files: [] };
90
+ }
91
+ /** Write the manifest atomically: write to `<path>.tmp` then rename. Protects
92
+ * against partial writes if the CLI is interrupted mid-backup. */
93
+ function writeManifestAtomic(manifestPath, manifest) {
94
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
95
+ const tmp = `${manifestPath}.tmp`;
96
+ fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2), 'utf-8');
97
+ fs.renameSync(tmp, manifestPath);
98
+ }
99
+ /** Append a single entry to the appropriate manifest.json for this run. */
100
+ function appendToManifest(entry, projectRoot, homeDir) {
101
+ const ts = getRunTimestamp();
102
+ const runRoot = backupRunRoot(entry.scope, ts, projectRoot, homeDir);
103
+ const manifestPath = path.join(runRoot, 'manifest.json');
104
+ const manifest = readManifest(manifestPath, ts);
105
+ manifest.files.push(entry);
106
+ writeManifestAtomic(manifestPath, manifest);
107
+ }
108
+ /** Backup an existing file before it's about to be overwritten.
109
+ *
110
+ * - Project-scoped files (under `projectRoot`) → `<projectRoot>/.botdocs-backup/<ts>/<rel>`
111
+ * - Anything else (e.g. global skills under ~/.claude/skills) → `~/.botdocs/backup/<ts>/<absPath-with-/-as-_>`
112
+ *
113
+ * Also appends an entry to the run's `manifest.json` sidecar so `botdocs undo`
114
+ * and `botdocs backups restore` can recover unambiguously even when the
115
+ * flattened global filename is ambiguous.
116
+ *
117
+ * Returns `{ ok: false, error }` rather than throwing so callers can surface a
118
+ * warning and proceed with the overwrite. We never want a backup failure to
119
+ * block the underlying install/sync. */
120
+ export function backupFile(absolutePath, projectRoot, homeDir = os.homedir()) {
121
+ const dest = backupDestination(absolutePath, projectRoot, homeDir);
122
+ // No-op if the source doesn't exist — nothing to back up. We return ok:true
123
+ // so callers don't print a spurious warning.
124
+ if (!fs.existsSync(absolutePath)) {
125
+ return { dest, ok: true };
126
+ }
127
+ try {
128
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
129
+ fs.copyFileSync(absolutePath, dest);
130
+ // Record the canonical original→backup mapping in the run's manifest so
131
+ // restoration doesn't have to round-trip through the lossy flattening.
132
+ const scope = isProjectScoped(absolutePath, projectRoot)
133
+ ? 'project'
134
+ : 'global';
135
+ appendToManifest({
136
+ originalPath: realParentResolve(absolutePath),
137
+ backupPath: dest,
138
+ scope,
139
+ backedUpAt: new Date().toISOString(),
140
+ }, projectRoot, homeDir);
141
+ return { dest, ok: true };
142
+ }
143
+ catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ return { dest, ok: false, error: message };
146
+ }
147
+ }
148
+ /** Returns true if the given destination path appears in any install's
149
+ * `files[]` with a fingerprint matching the current on-disk content. That is,
150
+ * "this is a file we wrote and the user hasn't edited it since." Untracked
151
+ * files OR tracked-but-edited files both return false → backup required.
152
+ *
153
+ * The fingerprint check is what distinguishes "we wrote this and own it" from
154
+ * "we wrote this but the user has since edited it" — the latter still deserves
155
+ * a backup before we clobber the edits. */
156
+ export function isLockfileOwnedAndUnchanged(dest) {
157
+ if (!fs.existsSync(dest))
158
+ return false;
159
+ const lf = loadLockfile();
160
+ let currentFp;
161
+ try {
162
+ currentFp = fingerprintFile(dest);
163
+ }
164
+ catch {
165
+ // If we can't read the file to fingerprint it, treat as unowned —
166
+ // caller will attempt a backup and that path will surface the error.
167
+ return false;
168
+ }
169
+ for (const install of lf.installs) {
170
+ for (const file of install.files) {
171
+ if (file.dest === dest && file.fingerprint === currentFp) {
172
+ return true;
173
+ }
174
+ }
175
+ }
176
+ return false;
177
+ }
178
+ /** Path to the project-scope backup root (the parent of all `<ts>` folders). */
179
+ function projectBackupRoot(projectRoot) {
180
+ return path.join(realParentResolve(projectRoot), '.botdocs-backup');
181
+ }
182
+ /** Path to the global-scope backup root. */
183
+ function globalBackupRoot(homeDir) {
184
+ return path.join(homeDir, '.botdocs', 'backup');
185
+ }
186
+ /** List every timestamp folder under a backup root, returning their basenames. */
187
+ function listTimestampsIn(root) {
188
+ if (!fs.existsSync(root))
189
+ return [];
190
+ let entries;
191
+ try {
192
+ entries = fs.readdirSync(root, { withFileTypes: true });
193
+ }
194
+ catch {
195
+ return [];
196
+ }
197
+ return entries
198
+ .filter((e) => e.isDirectory())
199
+ .map((e) => e.name);
200
+ }
201
+ /** Load the manifest at `<runRoot>/manifest.json` if it exists. */
202
+ function loadManifest(runRoot) {
203
+ const manifestPath = path.join(runRoot, 'manifest.json');
204
+ if (!fs.existsSync(manifestPath))
205
+ return null;
206
+ try {
207
+ const raw = fs.readFileSync(manifestPath, 'utf-8');
208
+ const parsed = JSON.parse(raw);
209
+ if (typeof parsed === 'object' &&
210
+ parsed !== null &&
211
+ typeof parsed.runTimestamp === 'string' &&
212
+ Array.isArray(parsed.files)) {
213
+ return parsed;
214
+ }
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ return null;
220
+ }
221
+ /** List every backup run across both roots, newest first.
222
+ *
223
+ * Returns the union of timestamps in the project root and the global root.
224
+ * When a timestamp appears in both, it's reported once with `scope: 'both'`. */
225
+ export function listBackupRuns(projectRoot, homeDir = os.homedir()) {
226
+ const projectTs = new Set(listTimestampsIn(projectBackupRoot(projectRoot)));
227
+ const globalTs = new Set(listTimestampsIn(globalBackupRoot(homeDir)));
228
+ const all = new Set([...projectTs, ...globalTs]);
229
+ const summaries = [];
230
+ for (const ts of all) {
231
+ const inProject = projectTs.has(ts);
232
+ const inGlobal = globalTs.has(ts);
233
+ let scope;
234
+ let rootPath;
235
+ let fileCount;
236
+ if (inProject && inGlobal) {
237
+ scope = 'both';
238
+ rootPath = path.join(projectBackupRoot(projectRoot), ts);
239
+ const pm = loadManifest(path.join(projectBackupRoot(projectRoot), ts));
240
+ const gm = loadManifest(path.join(globalBackupRoot(homeDir), ts));
241
+ fileCount = (pm?.files.length ?? 0) + (gm?.files.length ?? 0);
242
+ }
243
+ else if (inProject) {
244
+ scope = 'project';
245
+ rootPath = path.join(projectBackupRoot(projectRoot), ts);
246
+ fileCount = loadManifest(rootPath)?.files.length ?? 0;
247
+ }
248
+ else {
249
+ scope = 'global';
250
+ rootPath = path.join(globalBackupRoot(homeDir), ts);
251
+ fileCount = loadManifest(rootPath)?.files.length ?? 0;
252
+ }
253
+ summaries.push({ runTimestamp: ts, scope, fileCount, rootPath });
254
+ }
255
+ // ISO timestamp folder names sort lexicographically into chronological order.
256
+ summaries.sort((a, b) => (a.runTimestamp < b.runTimestamp ? 1 : -1));
257
+ return summaries;
258
+ }
259
+ /** List every file in a specific backup run, across both manifests if present. */
260
+ export function listBackupFiles(runTimestamp, projectRoot, homeDir = os.homedir()) {
261
+ const projectManifest = loadManifest(path.join(projectBackupRoot(projectRoot), runTimestamp));
262
+ const globalManifest = loadManifest(path.join(globalBackupRoot(homeDir), runTimestamp));
263
+ const entries = [];
264
+ if (projectManifest)
265
+ entries.push(...projectManifest.files);
266
+ if (globalManifest)
267
+ entries.push(...globalManifest.files);
268
+ return entries;
269
+ }
270
+ /** Match an entry against the user's `--files a,b,c` filter. We do a suffix
271
+ * match so the caller can pass `.cursor/rules/foo.mdc` without having to know
272
+ * the absolute path. Empty filter = match everything. */
273
+ function matchesFileFilter(entry, filters) {
274
+ if (!filters || filters.length === 0)
275
+ return true;
276
+ return filters.some((f) => entry.originalPath.endsWith(f));
277
+ }
278
+ /** Restore every file recorded in a backup run's manifest to its original path.
279
+ *
280
+ * Before writing each restored file, the CURRENT contents at `originalPath` are
281
+ * themselves backed up under a NEW timestamp so `undo` is reversible — running
282
+ * `botdocs undo` twice in a row swaps the state back. Pass `noBackup: true` to
283
+ * skip the pre-backup (advanced). */
284
+ export function restoreBackup(runTimestamp, projectRoot, options = {}, homeDir = os.homedir()) {
285
+ const all = listBackupFiles(runTimestamp, projectRoot, homeDir);
286
+ const entries = all.filter((e) => matchesFileFilter(e, options.files));
287
+ const restored = [];
288
+ const failed = [];
289
+ const preBackedUp = [];
290
+ for (const entry of entries) {
291
+ if (!fs.existsSync(entry.backupPath)) {
292
+ failed.push({ entry, error: `backup file missing: ${entry.backupPath}` });
293
+ continue;
294
+ }
295
+ let content;
296
+ try {
297
+ content = fs.readFileSync(entry.backupPath);
298
+ }
299
+ catch (err) {
300
+ const message = err instanceof Error ? err.message : String(err);
301
+ failed.push({ entry, error: `read failed: ${message}` });
302
+ continue;
303
+ }
304
+ if (options.dryRun) {
305
+ restored.push(entry);
306
+ continue;
307
+ }
308
+ // Pre-backup the current state at originalPath so this restore is itself
309
+ // reversible. Skip if --no-backup, or if the file doesn't exist (nothing
310
+ // to back up). The pre-backup writes to a new run-timestamp folder.
311
+ if (!options.noBackup && fs.existsSync(entry.originalPath)) {
312
+ const result = backupFile(entry.originalPath, projectRoot, homeDir);
313
+ if (result.ok) {
314
+ preBackedUp.push(entry.originalPath);
315
+ }
316
+ // If pre-backup fails, we proceed with the restore — the user asked
317
+ // for the restore, not the pre-backup, and we don't want a permissions
318
+ // hiccup on the pre-backup to block recovery.
319
+ }
320
+ try {
321
+ fs.mkdirSync(path.dirname(entry.originalPath), { recursive: true });
322
+ fs.writeFileSync(entry.originalPath, content);
323
+ restored.push(entry);
324
+ }
325
+ catch (err) {
326
+ const message = err instanceof Error ? err.message : String(err);
327
+ failed.push({ entry, error: `write failed: ${message}` });
328
+ }
329
+ }
330
+ return { restored, failed, preBackedUp };
331
+ }
332
+ /** Parse the folder name back into a real Date for `--older-than-days` math.
333
+ * The folder name is an ISO timestamp with `:` and `.` replaced by `-`, so
334
+ * undo: every `-` that comes after the first `T` gets turned back. */
335
+ function parseRunTimestamp(ts) {
336
+ // Format: 2026-05-13T12-34-56-789Z
337
+ const tIndex = ts.indexOf('T');
338
+ if (tIndex < 0)
339
+ return null;
340
+ const datePart = ts.slice(0, tIndex);
341
+ const timePart = ts.slice(tIndex + 1);
342
+ // timePart: 12-34-56-789Z → 12:34:56.789Z
343
+ const m = timePart.match(/^(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/);
344
+ if (!m)
345
+ return null;
346
+ const iso = `${datePart}T${m[1]}:${m[2]}:${m[3]}.${m[4]}Z`;
347
+ const d = new Date(iso);
348
+ if (Number.isNaN(d.getTime()))
349
+ return null;
350
+ return d;
351
+ }
352
+ /** Clear backup runs, optionally filtered by age. */
353
+ export function clearBackups(projectRoot, options = {}, homeDir = os.homedir()) {
354
+ const all = listBackupRuns(projectRoot, homeDir);
355
+ const cleared = [];
356
+ const kept = [];
357
+ const cutoff = options.olderThanDays !== undefined
358
+ ? Date.now() - options.olderThanDays * 86_400_000
359
+ : null;
360
+ for (const run of all) {
361
+ let shouldClear = cutoff === null;
362
+ if (cutoff !== null) {
363
+ const runDate = parseRunTimestamp(run.runTimestamp);
364
+ shouldClear = runDate !== null && runDate.getTime() < cutoff;
365
+ }
366
+ if (shouldClear) {
367
+ if (!options.dryRun) {
368
+ // Remove BOTH the project-side and global-side folders for this
369
+ // timestamp if they exist. listBackupRuns merges them into a single
370
+ // summary, but the on-disk folders are separate.
371
+ const projectDir = path.join(projectBackupRoot(projectRoot), run.runTimestamp);
372
+ const globalDir = path.join(globalBackupRoot(homeDir), run.runTimestamp);
373
+ if (fs.existsSync(projectDir)) {
374
+ fs.rmSync(projectDir, { recursive: true, force: true });
375
+ }
376
+ if (fs.existsSync(globalDir)) {
377
+ fs.rmSync(globalDir, { recursive: true, force: true });
378
+ }
379
+ }
380
+ cleared.push(run);
381
+ }
382
+ else {
383
+ kept.push(run);
384
+ }
385
+ }
386
+ return { cleared, kept };
387
+ }
@@ -1,4 +1,4 @@
1
- export type Ecosystem = 'claude' | 'claude-code' | 'cursor' | 'chatgpt' | 'codex';
1
+ export type Ecosystem = 'claude' | 'claude-code' | 'cursor' | 'chatgpt' | 'codex' | 'copilot' | 'antigravity' | 'gemini' | 'opencode' | 'windsurf';
2
2
  export declare const SUPPORTED_ECOSYSTEMS: Ecosystem[];
3
3
  export declare function autoDetectSourceEcosystem(skillRoot: string): Ecosystem | null;
4
4
  export declare function ecosystemDestination(eco: Ecosystem, slug: string): string;
@@ -1,12 +1,31 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- export const SUPPORTED_ECOSYSTEMS = ['claude', 'claude-code', 'cursor', 'chatgpt', 'codex'];
3
+ export const SUPPORTED_ECOSYSTEMS = [
4
+ 'claude',
5
+ 'claude-code',
6
+ 'cursor',
7
+ 'chatgpt',
8
+ 'codex',
9
+ 'copilot',
10
+ 'antigravity',
11
+ 'gemini',
12
+ 'opencode',
13
+ 'windsurf',
14
+ ];
15
+ // Source-of-truth for each path is documented in `ecosystemDestination`
16
+ // below; this map only governs which on-disk directory we look at when
17
+ // auto-detecting the source ecosystem of a skill.
4
18
  const ECOSYSTEM_FILE_GLOB = {
5
19
  claude: ['claude/SKILL.md'],
6
20
  'claude-code': ['claude-code/commands'],
7
21
  cursor: ['cursor/rules'],
8
22
  chatgpt: ['chatgpt'],
9
23
  codex: ['codex'],
24
+ copilot: ['copilot/instructions'],
25
+ antigravity: ['antigravity/skills'],
26
+ gemini: ['gemini/instructions'],
27
+ opencode: ['opencode/instructions'],
28
+ windsurf: ['windsurf/rules'],
10
29
  };
11
30
  function readSize(filePath) {
12
31
  if (!fs.existsSync(filePath))
@@ -50,6 +69,29 @@ export function ecosystemDestination(eco, slug) {
50
69
  return `chatgpt/${slug}.md`;
51
70
  case 'codex':
52
71
  return `codex/${slug}.md`;
72
+ case 'copilot':
73
+ // GitHub Copilot custom instructions:
74
+ // https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot
75
+ return `copilot/instructions/${slug}.instructions.md`;
76
+ case 'antigravity':
77
+ // Google Antigravity skills live under the gemini config tree at
78
+ // ~/.gemini/antigravity/skills/<slug>.md — see install destination in
79
+ // `auto-detect.ts`. The source mirror is `antigravity/skills/<slug>.md`.
80
+ return `antigravity/skills/${slug}.md`;
81
+ case 'gemini':
82
+ // Gemini CLI reads markdown instructions from ~/.gemini/instructions/
83
+ // (https://github.com/google-gemini/gemini-cli). Source path mirrors
84
+ // the install destination.
85
+ return `gemini/instructions/${slug}.md`;
86
+ case 'opencode':
87
+ // OpenCode (SST) reads markdown instructions from
88
+ // ~/.config/opencode/instructions/ (https://github.com/sst/opencode).
89
+ return `opencode/instructions/${slug}.md`;
90
+ case 'windsurf':
91
+ // Windsurf (Codeium) project rules:
92
+ // https://docs.codeium.com/windsurf/cascade#windsurfrules — files live
93
+ // under .codeium/windsurf-rules/ in each project.
94
+ return `windsurf/rules/${slug}.md`;
53
95
  }
54
96
  }
55
97
  export function readSourceContent(skillRoot, sourceEcosystem, slug) {
@@ -1,5 +1,12 @@
1
+ /** Persistent CLI credentials.
2
+ *
3
+ * `token` is an opaque api_tokens value (bd_<hex>) issued by the web app —
4
+ * either via /settings/tokens or via the browser-mediated /cli-auth flow.
5
+ * Earlier CLI versions stored a GitHub access token under `githubToken`; that
6
+ * field is gone and any stale auth.json file will be ignored (the server
7
+ * 401s and the user is told to run `botdocs login` again). */
1
8
  interface AuthConfig {
2
- githubToken: string;
9
+ token: string;
3
10
  username: string;
4
11
  displayName: string;
5
12
  syncLibrary?: boolean;
@@ -1,22 +1,30 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- const CONFIG_DIR = path.join(os.homedir(), '.botdocs');
5
- const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
4
+ // Resolved lazily so tests can swap os.homedir between cases without having
5
+ // to monkey-patch a captured constant.
6
+ function getConfigDir() {
7
+ return path.join(os.homedir(), '.botdocs');
8
+ }
9
+ function getAuthFile() {
10
+ return path.join(getConfigDir(), 'auth.json');
11
+ }
6
12
  function ensureConfigDir() {
7
- if (!fs.existsSync(CONFIG_DIR)) {
8
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
13
+ const dir = getConfigDir();
14
+ if (!fs.existsSync(dir)) {
15
+ fs.mkdirSync(dir, { recursive: true });
9
16
  }
10
17
  }
11
18
  export function saveAuth(config) {
12
19
  ensureConfigDir();
13
- fs.writeFileSync(AUTH_FILE, JSON.stringify(config, null, 2), 'utf-8');
20
+ fs.writeFileSync(getAuthFile(), JSON.stringify(config, null, 2), 'utf-8');
14
21
  }
15
22
  export function loadAuth() {
16
- if (!fs.existsSync(AUTH_FILE))
23
+ const file = getAuthFile();
24
+ if (!fs.existsSync(file))
17
25
  return null;
18
26
  try {
19
- const data = fs.readFileSync(AUTH_FILE, 'utf-8');
27
+ const data = fs.readFileSync(file, 'utf-8');
20
28
  return JSON.parse(data);
21
29
  }
22
30
  catch {
@@ -24,7 +32,8 @@ export function loadAuth() {
24
32
  }
25
33
  }
26
34
  export function clearAuth() {
27
- if (fs.existsSync(AUTH_FILE)) {
28
- fs.unlinkSync(AUTH_FILE);
35
+ const file = getAuthFile();
36
+ if (fs.existsSync(file)) {
37
+ fs.unlinkSync(file);
29
38
  }
30
39
  }
@@ -11,6 +11,15 @@ export interface InstalledRef {
11
11
  files: InstalledFile[];
12
12
  /** For bundles: the refs they expanded into. */
13
13
  skills?: string[];
14
+ /** Where this install came from. Defaults to a personal install when absent.
15
+ * Used by `botdocs sync` to identify team-pinned skills (which are managed
16
+ * by the team and may be pinned to a specific version). */
17
+ source?: {
18
+ type: 'personal';
19
+ } | {
20
+ type: 'team';
21
+ slug: string;
22
+ };
14
23
  }
15
24
  export interface Lockfile {
16
25
  version: 1;
@@ -1,5 +1,15 @@
1
1
  export type CleanUpdateChoice = 'apply' | 'skip' | 'diff';
2
+ /** Prompt for a clean-update decision (upstream has new content; local is
3
+ * unchanged since last install). The "diff" option lets the user inspect the
4
+ * change before deciding; callers should loop on "diff" themselves. */
2
5
  export declare function promptCleanUpdate(label: string): Promise<CleanUpdateChoice>;
3
6
  export type ConflictChoice = 'skip' | 'overwrite';
7
+ /** Prompt for a conflict resolution when local has diverged from the
8
+ * lockfile fingerprint AND upstream has new content. Distinct from
9
+ * `promptCleanUpdate` because the user's edits are at risk; the destructive
10
+ * option ("overwrite") is gated behind `confirmOverwrite` below. */
4
11
  export declare function promptConflict(label: string): Promise<ConflictChoice>;
12
+ /** Second confirmation for destructive overwrites — the user has already said
13
+ * "overwrite" via `promptConflict`, but we want a y/N before clobbering their
14
+ * edits. Defaults to false so a stray Enter doesn't blow away work. */
5
15
  export declare function confirmOverwrite(label: string): Promise<boolean>;