@botdocs/cli 0.2.0 → 0.3.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 +119 -2
  2. package/dist/commands/check-updates.d.ts +6 -0
  3. package/dist/commands/check-updates.js +77 -0
  4. package/dist/commands/check-updates.test.d.ts +1 -0
  5. package/dist/commands/check-updates.test.js +128 -0
  6. package/dist/commands/compile.d.ts +9 -0
  7. package/dist/commands/compile.js +93 -0
  8. package/dist/commands/compile.test.d.ts +1 -0
  9. package/dist/commands/compile.test.js +110 -0
  10. package/dist/commands/edit.d.ts +7 -0
  11. package/dist/commands/edit.js +105 -0
  12. package/dist/commands/edit.test.d.ts +1 -0
  13. package/dist/commands/edit.test.js +102 -0
  14. package/dist/commands/ingest.d.ts +7 -0
  15. package/dist/commands/ingest.js +101 -0
  16. package/dist/commands/ingest.test.d.ts +1 -0
  17. package/dist/commands/ingest.test.js +109 -0
  18. package/dist/commands/init.d.ts +1 -0
  19. package/dist/commands/init.js +34 -1
  20. package/dist/commands/install-instructions.d.ts +8 -0
  21. package/dist/commands/install-instructions.js +88 -0
  22. package/dist/commands/install.d.ts +8 -0
  23. package/dist/commands/install.js +143 -0
  24. package/dist/commands/install.test.d.ts +1 -0
  25. package/dist/commands/install.test.js +253 -0
  26. package/dist/commands/list.d.ts +6 -0
  27. package/dist/commands/list.js +35 -0
  28. package/dist/commands/list.test.d.ts +1 -0
  29. package/dist/commands/list.test.js +51 -0
  30. package/dist/commands/login.d.ts +5 -1
  31. package/dist/commands/login.js +8 -1
  32. package/dist/commands/publish.d.ts +1 -0
  33. package/dist/commands/publish.js +37 -0
  34. package/dist/commands/publish.test.d.ts +1 -0
  35. package/dist/commands/publish.test.js +76 -0
  36. package/dist/commands/sync.d.ts +7 -0
  37. package/dist/commands/sync.js +161 -0
  38. package/dist/commands/sync.test.d.ts +1 -0
  39. package/dist/commands/sync.test.js +263 -0
  40. package/dist/commands/uninstall.d.ts +5 -0
  41. package/dist/commands/uninstall.js +31 -0
  42. package/dist/commands/uninstall.test.d.ts +1 -0
  43. package/dist/commands/uninstall.test.js +67 -0
  44. package/dist/commands/validate.js +20 -5
  45. package/dist/index.js +86 -2
  46. package/dist/lib/auto-detect.d.ts +13 -0
  47. package/dist/lib/auto-detect.js +34 -0
  48. package/dist/lib/auto-detect.test.d.ts +1 -0
  49. package/dist/lib/auto-detect.test.js +58 -0
  50. package/dist/lib/canonical.d.ts +5 -0
  51. package/dist/lib/canonical.js +68 -0
  52. package/dist/lib/canonical.test.d.ts +1 -0
  53. package/dist/lib/canonical.test.js +48 -0
  54. package/dist/lib/config.d.ts +1 -0
  55. package/dist/lib/diff.d.ts +2 -0
  56. package/dist/lib/diff.js +36 -0
  57. package/dist/lib/diff.test.d.ts +1 -0
  58. package/dist/lib/diff.test.js +28 -0
  59. package/dist/lib/library-sync.d.ts +8 -0
  60. package/dist/lib/library-sync.js +30 -0
  61. package/dist/lib/library-sync.test.d.ts +1 -0
  62. package/dist/lib/library-sync.test.js +63 -0
  63. package/dist/lib/llm.d.ts +26 -0
  64. package/dist/lib/llm.js +61 -0
  65. package/dist/lib/llm.test.d.ts +1 -0
  66. package/dist/lib/llm.test.js +72 -0
  67. package/dist/lib/lockfile.d.ts +30 -0
  68. package/dist/lib/lockfile.js +70 -0
  69. package/dist/lib/lockfile.test.d.ts +1 -0
  70. package/dist/lib/lockfile.test.js +99 -0
  71. package/dist/lib/manifest.d.ts +18 -0
  72. package/dist/lib/manifest.js +77 -0
  73. package/dist/lib/manifest.test.d.ts +1 -0
  74. package/dist/lib/manifest.test.js +72 -0
  75. package/dist/lib/prompts.d.ts +5 -0
  76. package/dist/lib/prompts.js +26 -0
  77. package/dist/lib/shell-hook.d.ts +12 -0
  78. package/dist/lib/shell-hook.js +80 -0
  79. package/dist/lib/shell-hook.test.d.ts +1 -0
  80. package/dist/lib/shell-hook.test.js +68 -0
  81. package/dist/test-utils.d.ts +43 -0
  82. package/dist/test-utils.js +101 -0
  83. package/package.json +8 -2
  84. package/templates/agents.md +126 -0
  85. package/templates/ecosystem-prompts/compile-chatgpt.md +9 -0
  86. package/templates/ecosystem-prompts/compile-claude-code.md +11 -0
  87. package/templates/ecosystem-prompts/compile-claude.md +20 -0
  88. package/templates/ecosystem-prompts/compile-codex.md +8 -0
  89. package/templates/ecosystem-prompts/compile-cursor.md +11 -0
  90. package/templates/ecosystem-prompts/edit.md +12 -0
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { input, select } from '@inquirer/prompts';
5
+ import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
6
+ import { complete, detectProvider, LlmError } from '../lib/llm.js';
7
+ import { renderDiff } from '../lib/diff.js';
8
+ const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates', 'ecosystem-prompts');
9
+ function loadEditPrompt() {
10
+ return fs.readFileSync(path.join(TEMPLATES_DIR, 'edit.md'), 'utf-8');
11
+ }
12
+ function parseRef(raw) {
13
+ const cleaned = raw.startsWith('@') ? raw.slice(1) : raw;
14
+ const parts = cleaned.split('/');
15
+ if (parts.length !== 2 || !parts[0] || !parts[1])
16
+ throw new Error(`Invalid ref: ${raw}`);
17
+ return { username: parts[0], slug: parts[1] };
18
+ }
19
+ function fileForEcosystem(files, eco) {
20
+ if (eco === 'claude')
21
+ return files.find((f) => f.filename === 'claude/SKILL.md');
22
+ if (eco === 'claude-code')
23
+ return files.find((f) => f.filename.startsWith('claude-code/commands/'));
24
+ if (eco === 'cursor')
25
+ return files.find((f) => f.filename.startsWith('cursor/rules/'));
26
+ if (eco === 'chatgpt')
27
+ return files.find((f) => f.filename.startsWith('chatgpt/'));
28
+ if (eco === 'codex')
29
+ return files.find((f) => f.filename.startsWith('codex/'));
30
+ return undefined;
31
+ }
32
+ export async function edit(rawRef, options) {
33
+ const ref = parseRef(rawRef);
34
+ try {
35
+ detectProvider({ keyEnv: options.keyEnv });
36
+ }
37
+ catch (err) {
38
+ if (err instanceof LlmError) {
39
+ console.error(`\n ✗ ${err.message}\n`);
40
+ process.exit(1);
41
+ }
42
+ throw err;
43
+ }
44
+ let manifest;
45
+ try {
46
+ manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest`);
47
+ }
48
+ catch (err) {
49
+ if (err instanceof ApiError && err.status === 404) {
50
+ console.error(`\n ✗ Skill not found: @${ref.username}/${ref.slug}\n`);
51
+ process.exit(1);
52
+ }
53
+ throw err;
54
+ }
55
+ if (manifest.type !== 'SKILL') {
56
+ console.error('\n ✗ Edit only works on skills (not bundles).\n');
57
+ process.exit(1);
58
+ }
59
+ const target = fileForEcosystem(manifest.files, options.ecosystem);
60
+ if (!target) {
61
+ console.error(`\n ✗ No file found for ecosystem "${options.ecosystem}".\n`);
62
+ process.exit(1);
63
+ }
64
+ console.log(` ✓ Pulled ${target.filename}`);
65
+ const currentContent = await fetchRawContent(target.rawUrl);
66
+ const userRequest = await input({ message: 'What change would you like to make?' });
67
+ if (!userRequest.trim()) {
68
+ console.error('\n ✗ No request provided. Aborting.\n');
69
+ process.exit(1);
70
+ }
71
+ const system = loadEditPrompt();
72
+ const prompt = `CURRENT FILE:\n\n${currentContent}\n\nUSER REQUEST:\n${userRequest}`;
73
+ let revised;
74
+ while (true) {
75
+ const resp = await complete({ system, prompt, keyEnv: options.keyEnv });
76
+ if (resp.text.startsWith('EDIT_IMPOSSIBLE')) {
77
+ console.error(`\n ✗ ${resp.text}\n`);
78
+ process.exit(1);
79
+ }
80
+ revised = resp.text;
81
+ console.log(renderDiff(currentContent, revised));
82
+ const choice = await select({
83
+ message: 'Apply revision?',
84
+ choices: [
85
+ { name: 'accept (push as draft to BotDocs)', value: 'accept' },
86
+ { name: 'regenerate', value: 'regenerate' },
87
+ { name: 'cancel', value: 'cancel' },
88
+ ],
89
+ });
90
+ if (choice === 'cancel') {
91
+ console.log('\n Cancelled. No changes pushed.\n');
92
+ return;
93
+ }
94
+ if (choice === 'accept')
95
+ break;
96
+ // else regenerate — loop
97
+ }
98
+ await apiFetch(`/api/skills/${ref.username}/${ref.slug}/draft`, {
99
+ method: 'POST',
100
+ auth: true,
101
+ body: { ecosystem: options.ecosystem, content: revised },
102
+ });
103
+ console.log(`\n ✓ Pushed draft for ${rawRef} (${options.ecosystem}).`);
104
+ console.log(` Visit https://botdocs.ai/@${ref.username}/${ref.slug} to publish.\n`);
105
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { edit } from './edit.js';
3
+ import { captureConsole, mockFetch } from '../test-utils.js';
4
+ import { saveAuth } from '../lib/config.js';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ vi.mock('../lib/llm.js', () => ({
9
+ complete: vi.fn(),
10
+ detectProvider: vi.fn(() => ({ provider: 'anthropic', keyEnv: 'BOTDOCS_ANTHROPIC_KEY' })),
11
+ LlmError: class extends Error {
12
+ },
13
+ }));
14
+ vi.mock('@inquirer/prompts', () => ({
15
+ input: vi.fn(),
16
+ select: vi.fn(),
17
+ }));
18
+ import * as llm from '../lib/llm.js';
19
+ import * as inquirer from '@inquirer/prompts';
20
+ describe('edit', () => {
21
+ let captured;
22
+ let restoreFetch = () => { };
23
+ const origHome = os.homedir;
24
+ let homeTmp;
25
+ beforeEach(() => {
26
+ homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'edit-'));
27
+ os.homedir = () => homeTmp;
28
+ captured = captureConsole();
29
+ process.env.BOTDOCS_API_URL = 'http://test.local';
30
+ process.env.BOTDOCS_ANTHROPIC_KEY = 'test';
31
+ saveAuth({ githubToken: 't', username: 'u', displayName: 'U' });
32
+ });
33
+ afterEach(() => {
34
+ captured.restore();
35
+ restoreFetch();
36
+ fs.rmSync(homeTmp, { recursive: true, force: true });
37
+ os.homedir = origHome;
38
+ vi.restoreAllMocks();
39
+ delete process.env.BOTDOCS_ANTHROPIC_KEY;
40
+ });
41
+ it('round-trips: pulls, prompts user, calls LLM, pushes draft on accept', async () => {
42
+ const fm = mockFetch([
43
+ {
44
+ url: '/api/skills/alice/code-review/manifest',
45
+ response: {
46
+ body: {
47
+ ref: { username: 'alice', slug: 'code-review' },
48
+ type: 'SKILL',
49
+ version: '1.0.0',
50
+ sourceEcosystem: 'claude-code',
51
+ files: [
52
+ { filename: 'cursor/rules/code-review.mdc', rawUrl: 'http://test.local/raw/cursor' },
53
+ { filename: 'claude-code/commands/code-review.md', rawUrl: 'http://test.local/raw/source' },
54
+ ],
55
+ },
56
+ },
57
+ },
58
+ { url: '/raw/cursor', response: { body: '# Original cursor rule\n', contentType: 'text/plain' } },
59
+ { method: 'POST', url: '/api/skills/alice/code-review/draft', response: { body: { ok: true } } },
60
+ ]);
61
+ restoreFetch = fm.restore;
62
+ vi.mocked(inquirer.input).mockResolvedValue('Add a section about tests');
63
+ vi.mocked(inquirer.select).mockResolvedValue('accept');
64
+ vi.mocked(llm.complete).mockResolvedValue({
65
+ text: '# Original cursor rule\n\n## Tests\n',
66
+ usage: { inputTokens: 100, outputTokens: 50 },
67
+ provider: 'anthropic',
68
+ });
69
+ await edit('@alice/code-review', { ecosystem: 'cursor' });
70
+ const draftCall = fm.calls.find((c) => c.url.endsWith('/draft'));
71
+ expect(draftCall).toBeDefined();
72
+ expect(draftCall.body.ecosystem).toBe('cursor');
73
+ });
74
+ it('cancels without pushing when user picks cancel', async () => {
75
+ const fm = mockFetch([
76
+ {
77
+ url: '/api/skills/alice/x/manifest',
78
+ response: {
79
+ body: {
80
+ ref: { username: 'alice', slug: 'x' },
81
+ type: 'SKILL',
82
+ version: '1.0.0',
83
+ sourceEcosystem: 'claude',
84
+ files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/c' }],
85
+ },
86
+ },
87
+ },
88
+ { url: '/raw/c', response: { body: 'orig', contentType: 'text/plain' } },
89
+ ]);
90
+ restoreFetch = fm.restore;
91
+ vi.mocked(inquirer.input).mockResolvedValue('change something');
92
+ vi.mocked(inquirer.select).mockResolvedValue('cancel');
93
+ vi.mocked(llm.complete).mockResolvedValue({
94
+ text: 'new',
95
+ usage: { inputTokens: 10, outputTokens: 5 },
96
+ provider: 'anthropic',
97
+ });
98
+ await edit('@alice/x', { ecosystem: 'claude' });
99
+ const draftCall = fm.calls.find((c) => c.url.endsWith('/draft'));
100
+ expect(draftCall).toBeUndefined();
101
+ });
102
+ });
@@ -0,0 +1,7 @@
1
+ interface IngestOptions {
2
+ bundle?: string;
3
+ dryRun?: boolean;
4
+ json?: boolean;
5
+ }
6
+ export declare function ingest(rootPath: string, options: IngestOptions): Promise<void>;
7
+ export {};
@@ -0,0 +1,101 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { apiFetch } from '../lib/api.js';
4
+ const IGNORED_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.turbo']);
5
+ function walkFiles(root) {
6
+ const out = [];
7
+ function walk(dir) {
8
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
9
+ if (entry.isDirectory()) {
10
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.'))
11
+ continue;
12
+ walk(path.join(dir, entry.name));
13
+ }
14
+ else {
15
+ out.push(path.join(dir, entry.name));
16
+ }
17
+ }
18
+ }
19
+ walk(root);
20
+ return out;
21
+ }
22
+ function detectFile(absPath, root, content) {
23
+ 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' };
35
+ }
36
+ return null;
37
+ }
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)$/, '');
45
+ }
46
+ function titleFromContent(content, slug) {
47
+ const m = content.match(/^#\s+(.+)$/m);
48
+ return m?.[1]?.trim() ?? slug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
49
+ }
50
+ export async function ingest(rootPath, options) {
51
+ const root = path.resolve(rootPath);
52
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
53
+ console.error(`\n ✗ Not a directory: ${rootPath}\n`);
54
+ process.exit(1);
55
+ }
56
+ const detected = [];
57
+ for (const filePath of walkFiles(root)) {
58
+ const content = fs.readFileSync(filePath, 'utf-8');
59
+ const d = detectFile(filePath, root, content);
60
+ if (d)
61
+ detected.push(d);
62
+ }
63
+ if (detected.length === 0) {
64
+ console.log('\n No skills detected. Looked for: claude/, claude-code/commands/, cursor/rules/, chatgpt/.\n');
65
+ return;
66
+ }
67
+ // Group into logical skills by slug
68
+ const grouped = new Map();
69
+ 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),
75
+ description: '',
76
+ sourceEcosystem: f.ecosystem,
77
+ files: [],
78
+ });
79
+ }
80
+ grouped.get(slug).files.push({ filename: f.filename, content: f.content });
81
+ }
82
+ const skills = [...grouped.values()];
83
+ console.log(`\n ✓ Found ${skills.length} skill(s):`);
84
+ for (const s of skills) {
85
+ console.log(` • ${s.slug} (${s.files.length} file(s))`);
86
+ }
87
+ if (options.dryRun) {
88
+ console.log('\n --dry-run: not uploading.\n');
89
+ return;
90
+ }
91
+ const result = await apiFetch('/api/cli/ingest', {
92
+ method: 'POST',
93
+ auth: true,
94
+ body: { skills, bundle: options.bundle ? { name: options.bundle } : undefined },
95
+ });
96
+ if (options.json) {
97
+ console.log(JSON.stringify(result));
98
+ return;
99
+ }
100
+ console.log(`\n ✓ Drafts created. Review at:\n https://botdocs.ai${result.reviewUrl}\n`);
101
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ingest } from './ingest.js';
5
+ import { captureConsole, mockFetch, withTempDir } from '../test-utils.js';
6
+ vi.mock('../lib/config.js', () => ({
7
+ loadAuth: () => ({ githubToken: 'test-token', username: 'alice', displayName: 'Alice' }),
8
+ saveAuth: vi.fn(),
9
+ clearAuth: vi.fn(),
10
+ }));
11
+ describe('ingest', () => {
12
+ let tmp;
13
+ let captured;
14
+ let restoreFetch = () => { };
15
+ beforeEach(() => {
16
+ tmp = withTempDir();
17
+ process.env.BOTDOCS_API_URL = 'http://test.local';
18
+ captured = captureConsole();
19
+ });
20
+ afterEach(() => {
21
+ restoreFetch();
22
+ captured.restore();
23
+ tmp.cleanup();
24
+ vi.restoreAllMocks();
25
+ });
26
+ it('detects skills from claude/cursor/claude-code subtrees and POSTs them', async () => {
27
+ const root = path.join(tmp.dir, 'skills');
28
+ fs.mkdirSync(path.join(root, 'claude', 'code-review'), { recursive: true });
29
+ fs.mkdirSync(path.join(root, 'cursor', 'rules'), { recursive: true });
30
+ fs.writeFileSync(path.join(root, 'claude', 'code-review', 'SKILL.md'), '# Code Review\n\nA spec.');
31
+ fs.writeFileSync(path.join(root, 'cursor', 'rules', 'pr-summary.mdc'), '# PR Summary\n\nA rule.');
32
+ const fm = mockFetch([
33
+ {
34
+ method: 'POST',
35
+ url: '/api/cli/ingest',
36
+ response: { body: { draftId: 'd1', reviewUrl: '/ingest/d1' } },
37
+ },
38
+ ]);
39
+ restoreFetch = fm.restore;
40
+ await ingest(root, {});
41
+ expect(fm.calls).toHaveLength(1);
42
+ const body = fm.calls[0].body;
43
+ const slugs = body.skills.map((s) => s.slug).sort();
44
+ expect(slugs).toEqual(['code-review', 'pr-summary']);
45
+ expect(captured.stdout.join('\n')).toMatch(/d1/);
46
+ });
47
+ it('respects --bundle to wrap skills into a bundle draft', async () => {
48
+ const root = path.join(tmp.dir, 'skills');
49
+ fs.mkdirSync(path.join(root, 'claude', 'code-review'), { recursive: true });
50
+ fs.writeFileSync(path.join(root, 'claude', 'code-review', 'SKILL.md'), '# CR');
51
+ const fm = mockFetch([
52
+ { method: 'POST', url: '/api/cli/ingest', response: { body: { draftId: 'd2', reviewUrl: '/ingest/d2' } } },
53
+ ]);
54
+ restoreFetch = fm.restore;
55
+ await ingest(root, { bundle: 'Eng Skills' });
56
+ const body = fm.calls[0].body;
57
+ expect(body.bundle?.name).toBe('Eng Skills');
58
+ });
59
+ it('--dry-run prints findings without uploading', async () => {
60
+ const root = path.join(tmp.dir, 'skills');
61
+ fs.mkdirSync(path.join(root, 'claude', 'code-review'), { recursive: true });
62
+ fs.writeFileSync(path.join(root, 'claude', 'code-review', 'SKILL.md'), '# CR');
63
+ const fm = mockFetch([]);
64
+ restoreFetch = fm.restore;
65
+ await ingest(root, { dryRun: true });
66
+ expect(fm.calls).toHaveLength(0);
67
+ expect(captured.stdout.join('\n')).toMatch(/code-review/);
68
+ });
69
+ it('skips node_modules, .git, dist, and other well-known ignored directories', async () => {
70
+ const root = path.join(tmp.dir, 'project');
71
+ // A real skill we want detected
72
+ fs.mkdirSync(path.join(root, 'claude', 'real'), { recursive: true });
73
+ fs.writeFileSync(path.join(root, 'claude', 'real', 'SKILL.md'), '# Real');
74
+ // node_modules + dist + .git with planted skill files that MUST be ignored
75
+ fs.mkdirSync(path.join(root, 'node_modules', 'claude', 'fake'), { recursive: true });
76
+ fs.writeFileSync(path.join(root, 'node_modules', 'claude', 'fake', 'SKILL.md'), '# Fake');
77
+ fs.mkdirSync(path.join(root, '.git', 'claude', 'fake'), { recursive: true });
78
+ fs.writeFileSync(path.join(root, '.git', 'claude', 'fake', 'SKILL.md'), '# Fake');
79
+ fs.mkdirSync(path.join(root, 'dist', 'claude', 'fake'), { recursive: true });
80
+ fs.writeFileSync(path.join(root, 'dist', 'claude', 'fake', 'SKILL.md'), '# Fake');
81
+ const fm = mockFetch([
82
+ { method: 'POST', url: '/api/cli/ingest', response: { body: { draftId: 'd', reviewUrl: '/ingest/d' } } },
83
+ ]);
84
+ restoreFetch = fm.restore;
85
+ await ingest(root, {});
86
+ const body = fm.calls[0].body;
87
+ expect(body.skills.map((s) => s.slug)).toEqual(['real']);
88
+ });
89
+ it('groups multi-ecosystem files under the same slug into one logical skill', async () => {
90
+ // A skill named "code-review" has both a Claude SKILL.md and a Cursor rule.
91
+ // Without grouping these, the test ingest would POST 2 skills with the same slug
92
+ // and one would silently win in the DB.
93
+ const root = path.join(tmp.dir, 'skills');
94
+ fs.mkdirSync(path.join(root, 'claude', 'code-review'), { recursive: true });
95
+ fs.mkdirSync(path.join(root, 'cursor', 'rules'), { recursive: true });
96
+ fs.writeFileSync(path.join(root, 'claude', 'code-review', 'SKILL.md'), '# Code Review\n\nClaude version.');
97
+ fs.writeFileSync(path.join(root, 'cursor', 'rules', 'code-review.mdc'), '# Code Review\n\nCursor version.');
98
+ const fm = mockFetch([
99
+ { method: 'POST', url: '/api/cli/ingest', response: { body: { draftId: 'd', reviewUrl: '/ingest/d' } } },
100
+ ]);
101
+ restoreFetch = fm.restore;
102
+ await ingest(root, {});
103
+ const body = fm.calls[0].body;
104
+ // The two files should collapse into ONE skill named code-review with both files
105
+ const codeReview = body.skills.find((s) => s.slug === 'code-review');
106
+ expect(codeReview).toBeDefined();
107
+ expect(codeReview.files).toHaveLength(2);
108
+ });
109
+ });
@@ -2,6 +2,7 @@ interface InitOptions {
2
2
  title?: string;
3
3
  category?: string;
4
4
  json?: boolean;
5
+ canonical?: boolean;
5
6
  }
6
7
  export declare function init(dirName: string | undefined, options: InitOptions): Promise<void>;
7
8
  export {};
@@ -1,5 +1,6 @@
1
1
  import { writeFileSync, mkdirSync, existsSync } from 'fs';
2
- import { join } from 'path';
2
+ import { dirname, join } from 'path';
3
+ import { relative } from 'node:path';
3
4
  export async function init(dirName, options) {
4
5
  const name = dirName || 'my-botdoc';
5
6
  const dir = join(process.cwd(), name);
@@ -14,6 +15,38 @@ export async function init(dirName, options) {
14
15
  }
15
16
  mkdirSync(dir, { recursive: true });
16
17
  const title = options.title || name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
18
+ if (options.canonical) {
19
+ const sourceFile = join(dir, 'claude-code', 'commands', `${name}.md`);
20
+ mkdirSync(dirname(sourceFile), { recursive: true });
21
+ writeFileSync(sourceFile, `# ${title}\n\n## Overview\n\nDescribe the skill.\n`, 'utf-8');
22
+ const manifest = {
23
+ type: 'skill',
24
+ version: '1.0.0',
25
+ title,
26
+ description: '',
27
+ sourceEcosystem: 'claude-code',
28
+ ecosystems: ['claude', 'claude-code', 'cursor', 'chatgpt'],
29
+ license: 'MIT',
30
+ };
31
+ writeFileSync(join(dir, 'botdocs.json'), JSON.stringify(manifest, null, 2), 'utf-8');
32
+ if (options.json) {
33
+ console.log(JSON.stringify({
34
+ success: true,
35
+ directory: name,
36
+ files: [relative(dir, sourceFile), 'botdocs.json'],
37
+ canonical: true,
38
+ }));
39
+ }
40
+ else {
41
+ console.log(`\n Created ${name}/`);
42
+ console.log(` claude-code/commands/${name}.md — your canonical source`);
43
+ console.log(` botdocs.json — declares ecosystems to generate`);
44
+ console.log(`\n Edit the source file, then:`);
45
+ console.log(` botdocs compile ${name}/`);
46
+ console.log(` botdocs publish ${name}/\n`);
47
+ }
48
+ return;
49
+ }
17
50
  const indexContent = `# ${title}
18
51
 
19
52
  ## Overview
@@ -0,0 +1,8 @@
1
+ interface InstallOptions {
2
+ print?: boolean;
3
+ json?: boolean;
4
+ shellHook?: boolean;
5
+ removeShellHook?: boolean;
6
+ }
7
+ export declare function installInstructions(target: string | undefined, options: InstallOptions): Promise<void>;
8
+ export {};
@@ -0,0 +1,88 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { detectShell, installShellHook, uninstallShellHook } from '../lib/shell-hook.js';
5
+ /** Markers around the managed block so re-running the command updates the
6
+ * section in place instead of duplicating it. Anything outside the markers
7
+ * (the user's own content) is left untouched. */
8
+ const BEGIN = '<!-- BEGIN @botdocs/cli -->';
9
+ const END = '<!-- END @botdocs/cli -->';
10
+ const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
11
+ function escapeRegExp(s) {
12
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
14
+ function buildBlock() {
15
+ const template = fs.readFileSync(path.join(TEMPLATES_DIR, 'agents.md'), 'utf-8');
16
+ return `${BEGIN}\n${template.trim()}\n${END}\n`;
17
+ }
18
+ export async function installInstructions(target, options) {
19
+ if (options.removeShellHook) {
20
+ const shell = detectShell();
21
+ if (!shell) {
22
+ console.error('\n ✗ Could not detect shell (set $SHELL).\n');
23
+ process.exit(1);
24
+ }
25
+ const result = uninstallShellHook(shell);
26
+ if (options.json) {
27
+ console.log(JSON.stringify({ removed: result.removed, path: result.path }));
28
+ }
29
+ else {
30
+ console.log(`\n ${result.removed ? '✓ Removed' : 'No hook found in'} ${result.path}\n`);
31
+ }
32
+ return;
33
+ }
34
+ if (options.shellHook) {
35
+ const shell = detectShell();
36
+ if (!shell) {
37
+ console.error('\n ✗ Could not detect shell (set $SHELL or use a supported shell: zsh, bash, fish).\n');
38
+ process.exit(1);
39
+ }
40
+ const result = installShellHook(shell);
41
+ if (options.json) {
42
+ console.log(JSON.stringify({ action: result.action, path: result.path, shell }));
43
+ }
44
+ else {
45
+ console.log(`\n ✓ ${result.action === 'created' ? 'Created' : 'Updated'} shell hook at ${result.path}`);
46
+ console.log(` Open a new terminal to start seeing update notices.\n`);
47
+ }
48
+ return;
49
+ }
50
+ const targetDir = path.resolve(target ?? '.');
51
+ const file = path.join(targetDir, 'AGENTS.md');
52
+ const block = buildBlock();
53
+ if (options.print) {
54
+ process.stdout.write(block);
55
+ return;
56
+ }
57
+ if (!fs.existsSync(targetDir)) {
58
+ console.error(`\n ✗ Target directory does not exist: ${targetDir}\n`);
59
+ process.exit(1);
60
+ }
61
+ let action;
62
+ if (fs.existsSync(file)) {
63
+ const existing = fs.readFileSync(file, 'utf-8');
64
+ if (existing.includes(BEGIN) && existing.includes(END)) {
65
+ const re = new RegExp(`${escapeRegExp(BEGIN)}[\\s\\S]*?${escapeRegExp(END)}\\n?`);
66
+ const next = existing.replace(re, block);
67
+ fs.writeFileSync(file, next, 'utf-8');
68
+ action = 'updated';
69
+ }
70
+ else {
71
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
72
+ fs.writeFileSync(file, existing + sep + block, 'utf-8');
73
+ action = 'appended';
74
+ }
75
+ }
76
+ else {
77
+ fs.writeFileSync(file, block, 'utf-8');
78
+ action = 'created';
79
+ }
80
+ if (options.json) {
81
+ console.log(JSON.stringify({ action, path: file }));
82
+ return;
83
+ }
84
+ const verb = { created: 'Created', updated: 'Updated', appended: 'Appended to' }[action];
85
+ console.log(`\n ✓ ${verb} ${path.relative(process.cwd(), file)}`);
86
+ console.log(` Any agent that reads AGENTS.md (Claude Code, Cursor, Codex, Copilot) will\n` +
87
+ ` now know how to use the botdocs CLI.\n`);
88
+ }
@@ -0,0 +1,8 @@
1
+ interface InstallOptions {
2
+ project?: string;
3
+ flat?: boolean;
4
+ clean?: boolean;
5
+ json?: boolean;
6
+ }
7
+ export declare function install(rawRef: string, options: InstallOptions): Promise<void>;
8
+ export {};