@botdocs/cli 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -37
- package/dist/commands/backups.d.ts +4 -0
- package/dist/commands/backups.js +291 -0
- package/dist/commands/edit.js +16 -8
- package/dist/commands/install.d.ts +4 -0
- package/dist/commands/install.js +21 -3
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.js +240 -75
- package/dist/commands/sync.d.ts +16 -0
- package/dist/commands/sync.js +337 -25
- package/dist/commands/team.d.ts +2 -0
- package/dist/commands/team.js +251 -0
- package/dist/commands/undo.d.ts +19 -0
- package/dist/commands/undo.js +88 -0
- package/dist/commands/views/conflict-prompt.d.ts +24 -0
- package/dist/commands/views/conflict-prompt.js +19 -0
- package/dist/commands/views/login-app.d.ts +30 -0
- package/dist/commands/views/login-app.js +57 -0
- package/dist/commands/views/sync-app.d.ts +27 -0
- package/dist/commands/views/sync-app.js +147 -0
- package/dist/commands/views/sync-state.d.ts +84 -0
- package/dist/commands/views/sync-state.js +93 -0
- package/dist/commands/views/theme.d.ts +16 -0
- package/dist/commands/views/theme.js +16 -0
- package/dist/commands/whoami.js +13 -13
- package/dist/index.js +44 -38
- package/dist/lib/api.d.ts +2 -3
- package/dist/lib/api.js +14 -7
- package/dist/lib/auto-detect.js +46 -0
- package/dist/lib/backup.d.ts +121 -0
- package/dist/lib/backup.js +387 -0
- package/dist/lib/canonical.d.ts +1 -1
- package/dist/lib/canonical.js +43 -1
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.js +18 -9
- package/dist/lib/lockfile.d.ts +9 -0
- package/dist/lib/prompts.d.ts +10 -0
- package/dist/lib/prompts.js +36 -12
- package/package.json +27 -7
- package/templates/agents.md +60 -47
- package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
- package/templates/ecosystem-prompts/compile-copilot.md +14 -0
- package/templates/ecosystem-prompts/compile-gemini.md +14 -0
- package/templates/ecosystem-prompts/compile-opencode.md +13 -0
- package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
- package/dist/commands/check-updates.test.d.ts +0 -1
- package/dist/commands/check-updates.test.js +0 -128
- package/dist/commands/clone.d.ts +0 -3
- package/dist/commands/clone.js +0 -70
- package/dist/commands/compile.test.d.ts +0 -1
- package/dist/commands/compile.test.js +0 -110
- package/dist/commands/diff.d.ts +0 -3
- package/dist/commands/diff.js +0 -65
- package/dist/commands/edit.test.d.ts +0 -1
- package/dist/commands/edit.test.js +0 -102
- package/dist/commands/endorse.d.ts +0 -7
- package/dist/commands/endorse.js +0 -70
- package/dist/commands/ingest.test.d.ts +0 -1
- package/dist/commands/ingest.test.js +0 -109
- package/dist/commands/install.test.d.ts +0 -1
- package/dist/commands/install.test.js +0 -253
- package/dist/commands/list.test.d.ts +0 -1
- package/dist/commands/list.test.js +0 -51
- package/dist/commands/publish.test.d.ts +0 -1
- package/dist/commands/publish.test.js +0 -138
- package/dist/commands/pull.d.ts +0 -3
- package/dist/commands/pull.js +0 -78
- package/dist/commands/sync.test.d.ts +0 -1
- package/dist/commands/sync.test.js +0 -263
- package/dist/commands/uninstall.test.d.ts +0 -1
- package/dist/commands/uninstall.test.js +0 -67
- package/dist/lib/auto-detect.test.d.ts +0 -1
- package/dist/lib/auto-detect.test.js +0 -58
- package/dist/lib/canonical.test.d.ts +0 -1
- package/dist/lib/canonical.test.js +0 -48
- package/dist/lib/diff.test.d.ts +0 -1
- package/dist/lib/diff.test.js +0 -28
- package/dist/lib/library-sync.test.d.ts +0 -1
- package/dist/lib/library-sync.test.js +0 -63
- package/dist/lib/llm.test.d.ts +0 -1
- package/dist/lib/llm.test.js +0 -72
- package/dist/lib/lockfile.test.d.ts +0 -1
- package/dist/lib/lockfile.test.js +0 -99
- package/dist/lib/manifest.test.d.ts +0 -1
- package/dist/lib/manifest.test.js +0 -72
- package/dist/lib/shell-hook.test.d.ts +0 -1
- package/dist/lib/shell-hook.test.js +0 -68
- package/dist/test-utils.d.ts +0 -43
- package/dist/test-utils.js +0 -101
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { compile } from './compile.js';
|
|
5
|
-
import { captureConsole, withTempDir } from '../test-utils.js';
|
|
6
|
-
vi.mock('../lib/llm.js', () => ({
|
|
7
|
-
complete: vi.fn(),
|
|
8
|
-
detectProvider: vi.fn(() => ({ provider: 'anthropic', keyEnv: 'BOTDOCS_ANTHROPIC_KEY' })),
|
|
9
|
-
LlmError: class extends Error {
|
|
10
|
-
},
|
|
11
|
-
}));
|
|
12
|
-
import * as llm from '../lib/llm.js';
|
|
13
|
-
describe('compile', () => {
|
|
14
|
-
let tmp;
|
|
15
|
-
let captured;
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
tmp = withTempDir();
|
|
18
|
-
captured = captureConsole();
|
|
19
|
-
process.env.BOTDOCS_ANTHROPIC_KEY = 'test-key';
|
|
20
|
-
});
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
captured.restore();
|
|
23
|
-
tmp.cleanup();
|
|
24
|
-
vi.restoreAllMocks();
|
|
25
|
-
delete process.env.BOTDOCS_ANTHROPIC_KEY;
|
|
26
|
-
});
|
|
27
|
-
function setupSkill() {
|
|
28
|
-
const root = path.join(tmp.dir, 'my-skill');
|
|
29
|
-
fs.mkdirSync(path.join(root, 'claude-code', 'commands'), { recursive: true });
|
|
30
|
-
fs.writeFileSync(path.join(root, 'botdocs.json'), JSON.stringify({
|
|
31
|
-
type: 'skill',
|
|
32
|
-
version: '1.0.0',
|
|
33
|
-
title: 'Code Review',
|
|
34
|
-
description: 'Reviews PRs',
|
|
35
|
-
sourceEcosystem: 'claude-code',
|
|
36
|
-
ecosystems: ['claude', 'claude-code', 'cursor'],
|
|
37
|
-
}));
|
|
38
|
-
fs.writeFileSync(path.join(root, 'claude-code', 'commands', 'my-skill.md'), '# Code Review\n\nReview the PR.');
|
|
39
|
-
return root;
|
|
40
|
-
}
|
|
41
|
-
it('generates per-ecosystem files (excluding the source ecosystem)', async () => {
|
|
42
|
-
vi.mocked(llm.complete).mockResolvedValue({
|
|
43
|
-
text: 'GENERATED OUTPUT',
|
|
44
|
-
usage: { inputTokens: 100, outputTokens: 50 },
|
|
45
|
-
provider: 'anthropic',
|
|
46
|
-
});
|
|
47
|
-
const root = setupSkill();
|
|
48
|
-
await compile(root, {});
|
|
49
|
-
expect(fs.existsSync(path.join(root, 'claude', 'SKILL.md'))).toBe(true);
|
|
50
|
-
expect(fs.existsSync(path.join(root, 'cursor', 'rules', 'my-skill.mdc'))).toBe(true);
|
|
51
|
-
// source-ecosystem file is untouched
|
|
52
|
-
expect(fs.readFileSync(path.join(root, 'claude-code', 'commands', 'my-skill.md'), 'utf-8'))
|
|
53
|
-
.toBe('# Code Review\n\nReview the PR.');
|
|
54
|
-
expect(llm.complete).toHaveBeenCalledTimes(2); // claude + cursor
|
|
55
|
-
});
|
|
56
|
-
it('--ecosystems flag subsets the generated set', async () => {
|
|
57
|
-
vi.mocked(llm.complete).mockResolvedValue({
|
|
58
|
-
text: 'OUT',
|
|
59
|
-
usage: { inputTokens: 10, outputTokens: 5 },
|
|
60
|
-
provider: 'anthropic',
|
|
61
|
-
});
|
|
62
|
-
const root = setupSkill();
|
|
63
|
-
await compile(root, { ecosystems: 'cursor' });
|
|
64
|
-
expect(fs.existsSync(path.join(root, 'cursor', 'rules', 'my-skill.mdc'))).toBe(true);
|
|
65
|
-
expect(fs.existsSync(path.join(root, 'claude', 'SKILL.md'))).toBe(false);
|
|
66
|
-
expect(llm.complete).toHaveBeenCalledTimes(1);
|
|
67
|
-
});
|
|
68
|
-
it('errors when no API key is set', async () => {
|
|
69
|
-
delete process.env.BOTDOCS_ANTHROPIC_KEY;
|
|
70
|
-
vi.mocked(llm.detectProvider).mockImplementation(() => {
|
|
71
|
-
throw new llm.LlmError('No LLM key');
|
|
72
|
-
});
|
|
73
|
-
const root = setupSkill();
|
|
74
|
-
await expect(compile(root, {})).rejects.toThrow();
|
|
75
|
-
});
|
|
76
|
-
it('exits 1 when sourceEcosystem in botdocs.json is unsupported', async () => {
|
|
77
|
-
vi.mocked(llm.complete).mockResolvedValue({
|
|
78
|
-
text: 'X',
|
|
79
|
-
usage: { inputTokens: 1, outputTokens: 1 },
|
|
80
|
-
provider: 'anthropic',
|
|
81
|
-
});
|
|
82
|
-
const root = path.join(tmp.dir, 'bad-source');
|
|
83
|
-
fs.mkdirSync(root, { recursive: true });
|
|
84
|
-
fs.writeFileSync(path.join(root, 'botdocs.json'), JSON.stringify({
|
|
85
|
-
type: 'skill',
|
|
86
|
-
version: '1.0.0',
|
|
87
|
-
title: 'X',
|
|
88
|
-
description: 'Y',
|
|
89
|
-
sourceEcosystem: 'claude-codee', // typo
|
|
90
|
-
ecosystems: ['claude'],
|
|
91
|
-
}));
|
|
92
|
-
await expect(compile(root, {})).rejects.toThrow();
|
|
93
|
-
expect(captured.stderr.join('\n')).toMatch(/Unsupported ecosystem.*claude-codee/);
|
|
94
|
-
});
|
|
95
|
-
it('exits 1 when --regenerate target is unsupported', async () => {
|
|
96
|
-
const root = path.join(tmp.dir, 'good');
|
|
97
|
-
fs.mkdirSync(path.join(root, 'claude-code', 'commands'), { recursive: true });
|
|
98
|
-
fs.writeFileSync(path.join(root, 'botdocs.json'), JSON.stringify({
|
|
99
|
-
type: 'skill',
|
|
100
|
-
version: '1.0.0',
|
|
101
|
-
title: 'X',
|
|
102
|
-
description: 'Y',
|
|
103
|
-
sourceEcosystem: 'claude-code',
|
|
104
|
-
ecosystems: ['claude', 'claude-code'],
|
|
105
|
-
}));
|
|
106
|
-
fs.writeFileSync(path.join(root, 'claude-code', 'commands', 'good.md'), '# Body');
|
|
107
|
-
await expect(compile(root, { regenerate: 'cursor-but-typo' })).rejects.toThrow();
|
|
108
|
-
expect(captured.stderr.join('\n')).toMatch(/Unsupported ecosystem.*cursor-but-typo/);
|
|
109
|
-
});
|
|
110
|
-
});
|
package/dist/commands/diff.d.ts
DELETED
package/dist/commands/diff.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
import { apiFetch, fetchRawContent } from '../lib/api.js';
|
|
4
|
-
export async function diff(ref, options) {
|
|
5
|
-
const parts = ref.replace(/^@/, '').split('/');
|
|
6
|
-
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
7
|
-
console.error('Usage: botdocs diff <username/slug>');
|
|
8
|
-
process.exit(1);
|
|
9
|
-
}
|
|
10
|
-
const [username, slug] = parts;
|
|
11
|
-
const localDir = join(process.cwd(), slug);
|
|
12
|
-
if (!existsSync(localDir)) {
|
|
13
|
-
console.error(`No local directory found: ${slug}/`);
|
|
14
|
-
console.error(`Run 'botdocs clone ${ref}' first.`);
|
|
15
|
-
process.exit(1);
|
|
16
|
-
}
|
|
17
|
-
const manifest = await apiFetch(`/@${username}/${slug}/manifest`);
|
|
18
|
-
const changes = [];
|
|
19
|
-
const localFiles = new Set();
|
|
20
|
-
const metaPath = join(localDir, '.botdocs.json');
|
|
21
|
-
if (existsSync(metaPath)) {
|
|
22
|
-
const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
|
23
|
-
if (meta.files) {
|
|
24
|
-
for (const f of meta.files) {
|
|
25
|
-
localFiles.add(f);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
for (const remoteFile of manifest.files) {
|
|
30
|
-
const localPath = join(localDir, remoteFile.filename);
|
|
31
|
-
if (!existsSync(localPath)) {
|
|
32
|
-
changes.push({ filename: remoteFile.filename, status: 'added' });
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
const localContent = readFileSync(localPath, 'utf-8');
|
|
36
|
-
const remoteContent = await fetchRawContent(remoteFile.rawUrl);
|
|
37
|
-
if (localContent !== remoteContent) {
|
|
38
|
-
changes.push({ filename: remoteFile.filename, status: 'modified' });
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
changes.push({ filename: remoteFile.filename, status: 'unchanged' });
|
|
42
|
-
}
|
|
43
|
-
localFiles.delete(remoteFile.filename);
|
|
44
|
-
}
|
|
45
|
-
for (const filename of localFiles) {
|
|
46
|
-
if (filename === '.botdocs.json')
|
|
47
|
-
continue;
|
|
48
|
-
changes.push({ filename, status: 'removed' });
|
|
49
|
-
}
|
|
50
|
-
if (options.json) {
|
|
51
|
-
console.log(JSON.stringify({ ref, changes }));
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
const modified = changes.filter((c) => c.status !== 'unchanged');
|
|
55
|
-
if (modified.length === 0) {
|
|
56
|
-
console.log('\n Up to date — no remote changes.\n');
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
console.log('');
|
|
60
|
-
for (const change of modified) {
|
|
61
|
-
const icon = change.status === 'added' ? '+' : change.status === 'removed' ? '-' : '~';
|
|
62
|
-
console.log(` ${icon} ${change.filename}`);
|
|
63
|
-
}
|
|
64
|
-
console.log(`\n ${modified.length} file(s) changed. Run 'botdocs pull ${ref}' to update.\n`);
|
|
65
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
});
|
package/dist/commands/endorse.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { ApiError, apiFetch } from '../lib/api.js';
|
|
2
|
-
const VALID_RATINGS = ['positive', 'neutral', 'negative'];
|
|
3
|
-
function parseRef(ref) {
|
|
4
|
-
const parts = ref.replace(/^@/, '').split('/');
|
|
5
|
-
if (parts.length !== 2 || !parts[0] || !parts[1])
|
|
6
|
-
return null;
|
|
7
|
-
return { username: parts[0], slug: parts[1] };
|
|
8
|
-
}
|
|
9
|
-
/** Treat any 403 whose message mentions "clone" as the server's
|
|
10
|
-
* clone-required guard. The web detail page surfaces the same hint inline;
|
|
11
|
-
* here we point users at the CLI command that fixes it. */
|
|
12
|
-
function isCloneRequired(err) {
|
|
13
|
-
return err.status === 403 && /clone/i.test(err.message);
|
|
14
|
-
}
|
|
15
|
-
export async function endorse(ref, options) {
|
|
16
|
-
const parsed = parseRef(ref);
|
|
17
|
-
if (!parsed) {
|
|
18
|
-
console.error('Usage: botdocs endorse <username/slug> --rating <positive|neutral|negative>');
|
|
19
|
-
process.exit(1);
|
|
20
|
-
}
|
|
21
|
-
const { username, slug } = parsed;
|
|
22
|
-
const rating = options.rating?.toLowerCase();
|
|
23
|
-
if (!rating || !VALID_RATINGS.includes(rating)) {
|
|
24
|
-
console.error('Rating must be: positive, neutral, or negative');
|
|
25
|
-
process.exit(1);
|
|
26
|
-
}
|
|
27
|
-
let result;
|
|
28
|
-
try {
|
|
29
|
-
result = await apiFetch(`/api/endorsements/${username}/${slug}`, {
|
|
30
|
-
method: 'POST',
|
|
31
|
-
body: {
|
|
32
|
-
rating: rating.toUpperCase(),
|
|
33
|
-
comment: options.comment,
|
|
34
|
-
source: 'CLI',
|
|
35
|
-
},
|
|
36
|
-
auth: true,
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
catch (err) {
|
|
40
|
-
if (err instanceof ApiError) {
|
|
41
|
-
if (isCloneRequired(err)) {
|
|
42
|
-
console.error(`\n ✗ ${err.message}\n` +
|
|
43
|
-
`\n Endorsements are reserved for builders who actually used the spec. Run:\n` +
|
|
44
|
-
` botdocs clone @${username}/${slug}\n` +
|
|
45
|
-
` …build something on top, then come back and endorse it.\n`);
|
|
46
|
-
process.exit(1);
|
|
47
|
-
}
|
|
48
|
-
if (err.status === 401) {
|
|
49
|
-
console.error('\n ✗ Not authenticated. Run `botdocs login` first.\n');
|
|
50
|
-
process.exit(1);
|
|
51
|
-
}
|
|
52
|
-
if (err.status === 404) {
|
|
53
|
-
console.error(`\n ✗ BotDoc not found: @${username}/${slug}\n`);
|
|
54
|
-
process.exit(1);
|
|
55
|
-
}
|
|
56
|
-
console.error(`\n ✗ ${err.message}\n`);
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
59
|
-
throw err;
|
|
60
|
-
}
|
|
61
|
-
if (options.json) {
|
|
62
|
-
console.log(JSON.stringify(result));
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
console.log(`\n ✓ Endorsed @${username}/${slug} as ${rating}.`);
|
|
66
|
-
if (options.comment) {
|
|
67
|
-
console.log(` Comment: "${options.comment}"`);
|
|
68
|
-
}
|
|
69
|
-
console.log('');
|
|
70
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,109 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|