@botdocs/cli 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +145 -36
  2. package/dist/commands/backups.d.ts +4 -0
  3. package/dist/commands/backups.js +291 -0
  4. package/dist/commands/edit.js +16 -8
  5. package/dist/commands/ingest.d.ts +2 -0
  6. package/dist/commands/ingest.js +162 -28
  7. package/dist/commands/install.d.ts +4 -0
  8. package/dist/commands/install.js +40 -3
  9. package/dist/commands/login.d.ts +7 -0
  10. package/dist/commands/login.js +240 -75
  11. package/dist/commands/sync.d.ts +16 -0
  12. package/dist/commands/sync.js +337 -25
  13. package/dist/commands/team.d.ts +2 -0
  14. package/dist/commands/team.js +251 -0
  15. package/dist/commands/undo.d.ts +19 -0
  16. package/dist/commands/undo.js +88 -0
  17. package/dist/commands/views/conflict-prompt.d.ts +24 -0
  18. package/dist/commands/views/conflict-prompt.js +19 -0
  19. package/dist/commands/views/login-app.d.ts +30 -0
  20. package/dist/commands/views/login-app.js +57 -0
  21. package/dist/commands/views/sync-app.d.ts +27 -0
  22. package/dist/commands/views/sync-app.js +147 -0
  23. package/dist/commands/views/sync-state.d.ts +84 -0
  24. package/dist/commands/views/sync-state.js +93 -0
  25. package/dist/commands/views/theme.d.ts +16 -0
  26. package/dist/commands/views/theme.js +16 -0
  27. package/dist/commands/whoami.js +13 -13
  28. package/dist/index.js +46 -39
  29. package/dist/lib/api.d.ts +2 -3
  30. package/dist/lib/api.js +14 -7
  31. package/dist/lib/auto-detect.js +46 -0
  32. package/dist/lib/backup.d.ts +121 -0
  33. package/dist/lib/backup.js +387 -0
  34. package/dist/lib/canonical.d.ts +1 -1
  35. package/dist/lib/canonical.js +43 -1
  36. package/dist/lib/config.d.ts +8 -1
  37. package/dist/lib/config.js +18 -9
  38. package/dist/lib/lockfile.d.ts +9 -0
  39. package/dist/lib/prompts.d.ts +10 -0
  40. package/dist/lib/prompts.js +36 -12
  41. package/package.json +27 -7
  42. package/templates/agents.md +60 -47
  43. package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
  44. package/templates/ecosystem-prompts/compile-copilot.md +14 -0
  45. package/templates/ecosystem-prompts/compile-gemini.md +14 -0
  46. package/templates/ecosystem-prompts/compile-opencode.md +13 -0
  47. package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
  48. package/dist/commands/check-updates.test.d.ts +0 -1
  49. package/dist/commands/check-updates.test.js +0 -128
  50. package/dist/commands/clone.d.ts +0 -3
  51. package/dist/commands/clone.js +0 -70
  52. package/dist/commands/compile.test.d.ts +0 -1
  53. package/dist/commands/compile.test.js +0 -110
  54. package/dist/commands/diff.d.ts +0 -3
  55. package/dist/commands/diff.js +0 -65
  56. package/dist/commands/edit.test.d.ts +0 -1
  57. package/dist/commands/edit.test.js +0 -102
  58. package/dist/commands/endorse.d.ts +0 -7
  59. package/dist/commands/endorse.js +0 -70
  60. package/dist/commands/ingest.test.d.ts +0 -1
  61. package/dist/commands/ingest.test.js +0 -109
  62. package/dist/commands/install.test.d.ts +0 -1
  63. package/dist/commands/install.test.js +0 -253
  64. package/dist/commands/list.test.d.ts +0 -1
  65. package/dist/commands/list.test.js +0 -51
  66. package/dist/commands/publish.test.d.ts +0 -1
  67. package/dist/commands/publish.test.js +0 -138
  68. package/dist/commands/pull.d.ts +0 -3
  69. package/dist/commands/pull.js +0 -78
  70. package/dist/commands/sync.test.d.ts +0 -1
  71. package/dist/commands/sync.test.js +0 -263
  72. package/dist/commands/uninstall.test.d.ts +0 -1
  73. package/dist/commands/uninstall.test.js +0 -67
  74. package/dist/lib/auto-detect.test.d.ts +0 -1
  75. package/dist/lib/auto-detect.test.js +0 -58
  76. package/dist/lib/canonical.test.d.ts +0 -1
  77. package/dist/lib/canonical.test.js +0 -48
  78. package/dist/lib/diff.test.d.ts +0 -1
  79. package/dist/lib/diff.test.js +0 -28
  80. package/dist/lib/library-sync.test.d.ts +0 -1
  81. package/dist/lib/library-sync.test.js +0 -63
  82. package/dist/lib/llm.test.d.ts +0 -1
  83. package/dist/lib/llm.test.js +0 -72
  84. package/dist/lib/lockfile.test.d.ts +0 -1
  85. package/dist/lib/lockfile.test.js +0 -99
  86. package/dist/lib/manifest.test.d.ts +0 -1
  87. package/dist/lib/manifest.test.js +0 -72
  88. package/dist/lib/shell-hook.test.d.ts +0 -1
  89. package/dist/lib/shell-hook.test.js +0 -68
  90. package/dist/test-utils.d.ts +0 -43
  91. package/dist/test-utils.js +0 -101
@@ -1,6 +1,94 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { apiFetch } from '../lib/api.js';
4
+ /**
5
+ * Single source of truth for ecosystem detection. New ecosystems should be
6
+ * added here — both auto-detect mode and future `--from-tool` mode consult
7
+ * this table.
8
+ */
9
+ const DETECTORS = {
10
+ claude: {
11
+ pathPrefix: 'claude/',
12
+ extensions: ['/SKILL.md'],
13
+ nested: true,
14
+ slugFor: (abs, root) => {
15
+ const rel = path.relative(root, abs).split(path.sep).join('/');
16
+ // Match `<anything>/<slug>/SKILL.md` — slug is the parent dir of SKILL.md.
17
+ if (!rel.endsWith('/SKILL.md'))
18
+ return null;
19
+ const parts = rel.split('/');
20
+ if (parts.length < 2)
21
+ return null;
22
+ return parts[parts.length - 2] ?? null;
23
+ },
24
+ canonicalFilename: (slug) => `claude/${slug}/SKILL.md`,
25
+ },
26
+ 'claude-code': {
27
+ pathPrefix: 'claude-code/commands/',
28
+ extensions: ['.md'],
29
+ nested: false,
30
+ slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
31
+ canonicalFilename: (slug) => `claude-code/commands/${slug}.md`,
32
+ },
33
+ cursor: {
34
+ pathPrefix: 'cursor/rules/',
35
+ extensions: ['.mdc'],
36
+ nested: false,
37
+ slugFor: (abs) => path.basename(abs).replace(/\.mdc$/, '') || null,
38
+ canonicalFilename: (slug) => `cursor/rules/${slug}.mdc`,
39
+ },
40
+ chatgpt: {
41
+ pathPrefix: 'chatgpt/',
42
+ extensions: ['.md'],
43
+ nested: false,
44
+ slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
45
+ canonicalFilename: (slug) => `chatgpt/${slug}.md`,
46
+ },
47
+ codex: {
48
+ pathPrefix: 'codex/',
49
+ extensions: ['.md'],
50
+ nested: false,
51
+ slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
52
+ canonicalFilename: (slug) => `codex/${slug}.md`,
53
+ },
54
+ copilot: {
55
+ pathPrefix: 'copilot/instructions/',
56
+ // IMPORTANT: full multi-dot extension — strip BEFORE the .md to get clean slug.
57
+ extensions: ['.instructions.md'],
58
+ nested: false,
59
+ slugFor: (abs) => path.basename(abs).replace(/\.instructions\.md$/, '') || null,
60
+ canonicalFilename: (slug) => `copilot/instructions/${slug}.instructions.md`,
61
+ },
62
+ windsurf: {
63
+ pathPrefix: 'windsurf/rules/',
64
+ extensions: ['.md'],
65
+ nested: false,
66
+ slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
67
+ canonicalFilename: (slug) => `windsurf/rules/${slug}.md`,
68
+ },
69
+ gemini: {
70
+ pathPrefix: 'gemini/instructions/',
71
+ extensions: ['.md'],
72
+ nested: false,
73
+ slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
74
+ canonicalFilename: (slug) => `gemini/instructions/${slug}.md`,
75
+ },
76
+ antigravity: {
77
+ pathPrefix: 'antigravity/skills/',
78
+ extensions: ['.md'],
79
+ nested: false,
80
+ slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
81
+ canonicalFilename: (slug) => `antigravity/skills/${slug}.md`,
82
+ },
83
+ opencode: {
84
+ pathPrefix: 'opencode/instructions/',
85
+ extensions: ['.md'],
86
+ nested: false,
87
+ slugFor: (abs) => path.basename(abs).replace(/\.md$/, '') || null,
88
+ canonicalFilename: (slug) => `opencode/instructions/${slug}.md`,
89
+ },
90
+ };
91
+ export const SUPPORTED_TOOLS = Object.keys(DETECTORS);
4
92
  const IGNORED_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.turbo']);
5
93
  function walkFiles(root) {
6
94
  const out = [];
@@ -19,65 +107,111 @@ function walkFiles(root) {
19
107
  walk(root);
20
108
  return out;
21
109
  }
22
- function detectFile(absPath, root, content) {
110
+ /** Does the filename end with any of the detector's declared extensions? */
111
+ function matchesExtension(absPath, detector) {
112
+ return detector.extensions.some((ext) => absPath.endsWith(ext));
113
+ }
114
+ /**
115
+ * Auto-detect mode: iterate every detector and pick the first one whose
116
+ * path-prefix + extension match. Returns null if nothing matches.
117
+ */
118
+ function detectAuto(absPath, root, content) {
23
119
  const rel = path.relative(root, absPath).split(path.sep).join('/');
24
- if (rel.startsWith('claude/') && rel.endsWith('/SKILL.md')) {
25
- return { filename: rel, content, ecosystem: 'claude' };
26
- }
27
- if (rel.startsWith('claude-code/commands/') && rel.endsWith('.md')) {
28
- return { filename: rel, content, ecosystem: 'claude-code' };
29
- }
30
- if (rel.startsWith('cursor/rules/') && rel.endsWith('.mdc')) {
31
- return { filename: rel, content, ecosystem: 'cursor' };
32
- }
33
- if (rel.startsWith('chatgpt/') && rel.endsWith('.md')) {
34
- return { filename: rel, content, ecosystem: 'chatgpt' };
120
+ for (const [ecosystem, detector] of Object.entries(DETECTORS)) {
121
+ if (!rel.startsWith(detector.pathPrefix))
122
+ continue;
123
+ if (!matchesExtension(absPath, detector))
124
+ continue;
125
+ const slug = detector.slugFor(absPath, root);
126
+ if (!slug)
127
+ continue;
128
+ return {
129
+ filename: detector.canonicalFilename(slug),
130
+ content,
131
+ ecosystem,
132
+ slug,
133
+ };
35
134
  }
36
135
  return null;
37
136
  }
38
- /** Returns the slug for a detected file by stripping ecosystem path prefix and extension. */
39
- function slugFor(file) {
40
- if (file.ecosystem === 'claude') {
41
- const dirs = file.filename.split('/');
42
- return dirs[1] ?? 'untitled';
43
- }
44
- return path.basename(file.filename).replace(/\.(md|mdc)$/, '');
137
+ /**
138
+ * `--from-tool` mode: every file matching the chosen ecosystem's extension
139
+ * becomes a draft skill for that ecosystem, regardless of path prefix.
140
+ * Slug + canonical filename come from the detector, so the upload uses the
141
+ * canonical BotDocs layout — that's what lets downstream `botdocs install`
142
+ * route the file back to the right on-disk destination.
143
+ */
144
+ function detectFromTool(absPath, root, content, ecosystem) {
145
+ const detector = DETECTORS[ecosystem];
146
+ if (!detector)
147
+ return null;
148
+ if (!matchesExtension(absPath, detector))
149
+ return null;
150
+ const slug = detector.slugFor(absPath, root);
151
+ if (!slug)
152
+ return null;
153
+ return {
154
+ filename: detector.canonicalFilename(slug),
155
+ content,
156
+ ecosystem,
157
+ slug,
158
+ };
45
159
  }
46
160
  function titleFromContent(content, slug) {
47
161
  const m = content.match(/^#\s+(.+)$/m);
48
162
  return m?.[1]?.trim() ?? slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
49
163
  }
164
+ /** Human-readable list of all canonical path prefixes for the empty-result message. */
165
+ function ecosystemPrefixSummary() {
166
+ return Object.values(DETECTORS)
167
+ .map((d) => d.pathPrefix)
168
+ .join(', ');
169
+ }
50
170
  export async function ingest(rootPath, options) {
51
171
  const root = path.resolve(rootPath);
52
172
  if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
53
173
  console.error(`\n ✗ Not a directory: ${rootPath}\n`);
54
174
  process.exit(1);
175
+ return;
176
+ }
177
+ if (options.fromTool && !SUPPORTED_TOOLS.includes(options.fromTool)) {
178
+ console.error(`\n ✗ Unknown --from-tool "${options.fromTool}". Supported: ${SUPPORTED_TOOLS.join(', ')}\n`);
179
+ process.exit(1);
180
+ return;
55
181
  }
56
182
  const detected = [];
57
183
  for (const filePath of walkFiles(root)) {
58
184
  const content = fs.readFileSync(filePath, 'utf-8');
59
- const d = detectFile(filePath, root, content);
185
+ const d = options.fromTool
186
+ ? detectFromTool(filePath, root, content, options.fromTool)
187
+ : detectAuto(filePath, root, content);
60
188
  if (d)
61
189
  detected.push(d);
62
190
  }
63
191
  if (detected.length === 0) {
64
- console.log('\n No skills detected. Looked for: claude/, claude-code/commands/, cursor/rules/, chatgpt/.\n');
192
+ if (options.fromTool) {
193
+ const detector = DETECTORS[options.fromTool];
194
+ console.log(`\n ⚠ No files matched ecosystem "${options.fromTool}" (expected ${detector.extensions.join(' or ')} files) in ${root}.\n`);
195
+ }
196
+ else {
197
+ console.log(`\n No skills detected. Looked for: ${ecosystemPrefixSummary()}.\n`);
198
+ }
65
199
  return;
66
200
  }
67
- // Group into logical skills by slug
201
+ // Group into logical skills by slug, merging files that share a slug
202
+ // across ecosystems (e.g. a `code-review` Claude SKILL.md + a Cursor rule).
68
203
  const grouped = new Map();
69
204
  for (const f of detected) {
70
- const slug = slugFor(f);
71
- if (!grouped.has(slug)) {
72
- grouped.set(slug, {
73
- slug,
74
- title: titleFromContent(f.content, slug),
205
+ if (!grouped.has(f.slug)) {
206
+ grouped.set(f.slug, {
207
+ slug: f.slug,
208
+ title: titleFromContent(f.content, f.slug),
75
209
  description: '',
76
210
  sourceEcosystem: f.ecosystem,
77
211
  files: [],
78
212
  });
79
213
  }
80
- grouped.get(slug).files.push({ filename: f.filename, content: f.content });
214
+ grouped.get(f.slug).files.push({ filename: f.filename, content: f.content });
81
215
  }
82
216
  const skills = [...grouped.values()];
83
217
  console.log(`\n ✓ Found ${skills.length} skill(s):`);
@@ -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) };
@@ -42,6 +60,7 @@ async function downloadAndWrite(file, dest, options) {
42
60
  async function installSkill(ref, manifest, options, scope) {
43
61
  const ctx = buildContext(scope, ref.slug, options);
44
62
  const filesInstalled = [];
63
+ let manualPromptsShown = 0;
45
64
  for (const file of manifest.files) {
46
65
  const detection = detectDestination(file.filename, ctx);
47
66
  if (detection.kind === 'skip')
@@ -52,12 +71,30 @@ async function installSkill(ref, manifest, options, scope) {
52
71
  const content = await fetchRawContent(file.rawUrl);
53
72
  console.log(`\n Manual paste required for ${file.filename}:\n${content}\n`);
54
73
  }
74
+ // A manual paste prompt counts as "we surfaced this file to the user",
75
+ // so don't trigger the no-installable-files warning below.
76
+ manualPromptsShown += 1;
55
77
  continue;
56
78
  }
57
- const installed = await downloadAndWrite(file, detection.dest, options);
79
+ const installed = await downloadAndWrite(file, detection.dest, options, ctx.projectDir);
58
80
  if (installed)
59
81
  filesInstalled.push(installed);
60
82
  }
83
+ // Surface clear feedback when the manifest produced nothing actionable.
84
+ // Suppressed under --json so machine-readable output stays clean.
85
+ if (!options.json) {
86
+ const refStr = `@${ref.username}/${ref.slug}`;
87
+ if (manifest.files.length === 0) {
88
+ console.log(`\n ⚠ ${refStr} has no files. Nothing to install.\n` +
89
+ ` This BotDoc may have been published before any content was added.\n`);
90
+ }
91
+ else if (filesInstalled.length === 0 && manualPromptsShown === 0) {
92
+ console.log(`\n ⚠ ${refStr} has files but none target a supported agent.\n` +
93
+ ` It may be a spec-only BotDoc without compiled per-agent files\n` +
94
+ ` (claude/SKILL.md, cursor/rules/*.mdc, etc.).\n` +
95
+ ` Ask the author to run \`botdocs compile\` and re-publish.\n`);
96
+ }
97
+ }
61
98
  return {
62
99
  ref: `@${ref.username}/${ref.slug}`,
63
100
  type: 'SKILL',
@@ -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 {};