@botdocs/cli 0.4.0 → 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.
- package/README.md +25 -2
- package/dist/commands/ingest.d.ts +2 -0
- package/dist/commands/ingest.js +162 -28
- package/dist/commands/install.js +19 -0
- package/dist/index.js +2 -1
- package/package.json +1 -1
- package/templates/agents.md +1 -1
package/README.md
CHANGED
|
@@ -290,8 +290,31 @@ ambiguously (e.g. paths containing `_`).
|
|
|
290
290
|
|
|
291
291
|
Authors who want to share their existing collection of skills run
|
|
292
292
|
`botdocs ingest <path>` — the CLI walks the directory, detects each
|
|
293
|
-
skill
|
|
294
|
-
|
|
293
|
+
skill across all 10 supported ecosystems (claude, claude-code, cursor,
|
|
294
|
+
chatgpt, codex, copilot, windsurf, gemini, antigravity, opencode), and
|
|
295
|
+
uploads them as drafts in your BotDocs account for review before
|
|
296
|
+
publishing.
|
|
297
|
+
|
|
298
|
+
By default `ingest` expects the canonical BotDocs layout (e.g.
|
|
299
|
+
`claude-code/commands/<slug>.md`, `cursor/rules/<slug>.mdc`). If your
|
|
300
|
+
files live in real on-disk locations instead, point `--from-tool=<ecosystem>`
|
|
301
|
+
at them and every matching file becomes a draft for that ecosystem:
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
# Ingest all your Claude Code commands directly:
|
|
305
|
+
botdocs ingest --from-tool=claude-code ~/.claude/commands/
|
|
306
|
+
|
|
307
|
+
# Ingest a project's Cursor rules:
|
|
308
|
+
botdocs ingest --from-tool=cursor .cursor/rules/
|
|
309
|
+
|
|
310
|
+
# Ingest GitHub Copilot instructions (the .instructions.md extension is
|
|
311
|
+
# stripped from the slug automatically):
|
|
312
|
+
botdocs ingest --from-tool=copilot .github/instructions/
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
The upload always uses the canonical BotDocs filename, so when someone
|
|
316
|
+
else `botdocs install`s the resulting skill it lands in the right
|
|
317
|
+
on-disk location.
|
|
295
318
|
|
|
296
319
|
## Development
|
|
297
320
|
|
|
@@ -2,6 +2,8 @@ interface IngestOptions {
|
|
|
2
2
|
bundle?: string;
|
|
3
3
|
dryRun?: boolean;
|
|
4
4
|
json?: boolean;
|
|
5
|
+
fromTool?: string;
|
|
5
6
|
}
|
|
7
|
+
export declare const SUPPORTED_TOOLS: readonly string[];
|
|
6
8
|
export declare function ingest(rootPath: string, options: IngestOptions): Promise<void>;
|
|
7
9
|
export {};
|
package/dist/commands/ingest.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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):`);
|
package/dist/commands/install.js
CHANGED
|
@@ -60,6 +60,7 @@ async function downloadAndWrite(file, dest, options, projectDir) {
|
|
|
60
60
|
async function installSkill(ref, manifest, options, scope) {
|
|
61
61
|
const ctx = buildContext(scope, ref.slug, options);
|
|
62
62
|
const filesInstalled = [];
|
|
63
|
+
let manualPromptsShown = 0;
|
|
63
64
|
for (const file of manifest.files) {
|
|
64
65
|
const detection = detectDestination(file.filename, ctx);
|
|
65
66
|
if (detection.kind === 'skip')
|
|
@@ -70,12 +71,30 @@ async function installSkill(ref, manifest, options, scope) {
|
|
|
70
71
|
const content = await fetchRawContent(file.rawUrl);
|
|
71
72
|
console.log(`\n Manual paste required for ${file.filename}:\n${content}\n`);
|
|
72
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;
|
|
73
77
|
continue;
|
|
74
78
|
}
|
|
75
79
|
const installed = await downloadAndWrite(file, detection.dest, options, ctx.projectDir);
|
|
76
80
|
if (installed)
|
|
77
81
|
filesInstalled.push(installed);
|
|
78
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
|
+
}
|
|
79
98
|
return {
|
|
80
99
|
ref: `@${ref.username}/${ref.slug}`,
|
|
81
100
|
type: 'SKILL',
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { install } from './commands/install.js';
|
|
|
11
11
|
import { list } from './commands/list.js';
|
|
12
12
|
import { uninstall } from './commands/uninstall.js';
|
|
13
13
|
import { sync } from './commands/sync.js';
|
|
14
|
-
import { ingest } from './commands/ingest.js';
|
|
14
|
+
import { ingest, SUPPORTED_TOOLS as INGEST_SUPPORTED_TOOLS } from './commands/ingest.js';
|
|
15
15
|
import { checkUpdates } from './commands/check-updates.js';
|
|
16
16
|
import { compile } from './commands/compile.js';
|
|
17
17
|
import { edit } from './commands/edit.js';
|
|
@@ -141,6 +141,7 @@ program
|
|
|
141
141
|
.description('Walk a directory of existing skill files and create drafts in your BotDocs account for review')
|
|
142
142
|
.option('--bundle <name>', 'Group all detected skills into a single bundle draft')
|
|
143
143
|
.option('--dry-run', 'Show what would be detected without uploading')
|
|
144
|
+
.option('--from-tool <ecosystem>', `Treat every file in the path as belonging to a single ecosystem (one of: ${INGEST_SUPPORTED_TOOLS.join(', ')}). Useful when ingesting directly from ~/.claude/commands/, .cursor/rules/, etc.`)
|
|
144
145
|
.action(async (sourcePath, opts) => {
|
|
145
146
|
await ingest(sourcePath, { ...opts, json: program.opts().json });
|
|
146
147
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@botdocs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "CLI for BotDocs — author, publish, install, and sync agent skills across Claude, Claude Code, Cursor, Codex, ChatGPT, Windsurf, Copilot, Gemini, Antigravity, and OpenCode.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"botdocs",
|
package/templates/agents.md
CHANGED
|
@@ -129,7 +129,7 @@ the user and recommend they set one.
|
|
|
129
129
|
| `backups list / restore / diff / clear` | Browse and manage backup runs. |
|
|
130
130
|
| `compile <path>` | Generate per-ecosystem drafts from a canonical source (BYOK). |
|
|
131
131
|
| `edit <ref>` | LLM-assisted revision of a published skill ecosystem file (BYOK). |
|
|
132
|
-
| `ingest <path>` | Walk a directory, detect existing skills, upload as drafts. |
|
|
132
|
+
| `ingest <path>` | Walk a directory, detect existing skills across all 10 ecosystems, upload as drafts. Use `--from-tool=<ecosystem>` to ingest from real on-disk locations like `~/.claude/commands/` or `.cursor/rules/`. |
|
|
133
133
|
| `team list` | List teams you belong to. |
|
|
134
134
|
| `team show <slug>` | Members + pinned skills for a team. |
|
|
135
135
|
| `team push <slug> <ref>` | Pin a skill to a team (WRITE+). |
|