@botdocs/cli 0.2.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 ADDED
@@ -0,0 +1,118 @@
1
+ # botdocs
2
+
3
+ The official CLI for [BotDocs](https://botdocs.ai) — clone, search,
4
+ publish, and endorse concept specifications.
5
+
6
+ A BotDoc is a structured spec an AI agent can build from. This CLI is the
7
+ fastest way to pull one onto your machine, ship one of your own, or report
8
+ back after you've built something on top.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ # one-off
14
+ npx @botdocs/cli <command>
15
+
16
+ # global (preferred — gives you the `botdocs` command)
17
+ npm i -g @botdocs/cli
18
+ # or
19
+ pnpm add -g @botdocs/cli
20
+ ```
21
+
22
+ After a global install the binary is just `botdocs`:
23
+
24
+ ```bash
25
+ botdocs --help
26
+ ```
27
+
28
+ Requires Node.js 20 or newer.
29
+
30
+ ## Quick start
31
+
32
+ ```bash
33
+ # scaffold a new spec in ./my-spec/
34
+ botdocs init my-spec
35
+
36
+ # validate before publishing
37
+ botdocs validate my-spec/
38
+
39
+ # log in (GitHub device code, one time)
40
+ botdocs login
41
+
42
+ # publish from a directory
43
+ botdocs publish my-spec/
44
+
45
+ # clone someone else's spec
46
+ botdocs clone @alice/agent-router
47
+
48
+ # tell them how it went after you built from it
49
+ botdocs endorse @alice/agent-router --rating positive \
50
+ --comment "Built a working POC in 40 minutes."
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ | Command | Purpose |
56
+ |---|---|
57
+ | `init [name]` | Scaffold a new BotDoc directory with an `index.md` template. |
58
+ | `validate <source>` | Pre-publish structural check on a directory or file. |
59
+ | `clone <user/slug>` | Download every file in a BotDoc to a local directory. |
60
+ | `search <query>` | Search the public registry. |
61
+ | `publish <source>` | Publish from a file, directory, or zip archive. |
62
+ | `diff <user/slug>` | Preview remote changes before pulling. |
63
+ | `pull <user/slug>` | Update a previously-cloned BotDoc. |
64
+ | `endorse <user/slug>` | Rate a BotDoc after you've built from it (requires a prior clone). |
65
+ | `login` | Authenticate via the GitHub device-code flow. |
66
+ | `whoami` | Show the currently authenticated user. |
67
+
68
+ Every command accepts `--json` for machine-readable output.
69
+
70
+ Run `botdocs <command> --help` for full flags on any command.
71
+
72
+ ## Configuration
73
+
74
+ | Variable | Default | Purpose |
75
+ |---|---|---|
76
+ | `BOTDOCS_API_URL` | `https://botdocs.ai` | Override the registry API endpoint (useful for local development). |
77
+
78
+ Auth is stored at `~/.botdocs/auth.json` after `botdocs login`. Delete it
79
+ to log out.
80
+
81
+ ## Endorsing
82
+
83
+ Endorsements are reserved for builders who actually used the spec — the
84
+ server will reject an endorsement if it can't see a prior clone from the
85
+ same account. If you hit that error the CLI will point you at:
86
+
87
+ ```bash
88
+ botdocs clone @user/slug
89
+ ```
90
+
91
+ …build something on top, then come back and run `endorse`.
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ pnpm install
97
+ pnpm --filter @botdocs/cli build # tsc -> dist/
98
+ pnpm --filter @botdocs/cli test # vitest
99
+ pnpm --filter @botdocs/cli typecheck
100
+ ```
101
+
102
+ ## Releasing
103
+
104
+ Versioning and publishing run on
105
+ [changesets](https://github.com/changesets/changesets):
106
+
107
+ 1. After making changes, run `pnpm changeset` from the repo root. Pick
108
+ `@botdocs/cli`, choose patch/minor/major, write a one-line summary.
109
+ 2. Commit the generated `.changeset/<slug>.md` along with your code.
110
+ 3. When your PR merges to `master`, a "Version Packages" PR is opened
111
+ automatically with the bumped version and CHANGELOG entries.
112
+ 4. Reviewing and merging that PR triggers `npm publish` from CI.
113
+
114
+ No manual tagging or version bumps required.
115
+
116
+ ## License
117
+
118
+ MIT © BotDocs contributors
@@ -0,0 +1,3 @@
1
+ export declare function clone(ref: string, options?: {
2
+ json?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { apiFetch, fetchRawContent } from '../lib/api.js';
4
+ export async function clone(ref, options = {}) {
5
+ const parsed = parseRef(ref);
6
+ if (!parsed) {
7
+ console.error('Invalid reference. Use format: username/slug');
8
+ process.exit(1);
9
+ }
10
+ const { username, slug } = parsed;
11
+ // Fetch manifest
12
+ console.log(`Fetching ${username}/${slug}...`);
13
+ let manifest;
14
+ try {
15
+ manifest = await apiFetch(`/@${username}/${slug}/manifest`);
16
+ }
17
+ catch {
18
+ console.error(`BotDoc not found: ${username}/${slug}`);
19
+ process.exit(1);
20
+ }
21
+ // Create output directory
22
+ const outDir = path.resolve(slug);
23
+ if (fs.existsSync(outDir)) {
24
+ console.error(`Directory already exists: ${slug}/`);
25
+ console.error('Use `botdocs pull` to update existing clones.');
26
+ process.exit(1);
27
+ }
28
+ fs.mkdirSync(outDir, { recursive: true });
29
+ // Download all files
30
+ for (const file of manifest.files) {
31
+ console.log(` ${file.filename}`);
32
+ const content = await fetchRawContent(file.rawUrl);
33
+ fs.writeFileSync(path.join(outDir, file.filename), content, 'utf-8');
34
+ }
35
+ // Save metadata for pull
36
+ const metadata = {
37
+ username,
38
+ slug,
39
+ clonedAt: new Date().toISOString(),
40
+ files: manifest.files.map((f) => f.filename),
41
+ };
42
+ fs.writeFileSync(path.join(outDir, '.botdocs.json'), JSON.stringify(metadata, null, 2), 'utf-8');
43
+ // Record clone on server
44
+ try {
45
+ await apiFetch('/api/cli/clone', {
46
+ method: 'POST',
47
+ body: { username, slug },
48
+ });
49
+ }
50
+ catch {
51
+ // Non-fatal — clone succeeded locally even if recording fails
52
+ }
53
+ if (options.json) {
54
+ console.log(JSON.stringify({ success: true, directory: slug, files: manifest.files.map(f => f.filename) }));
55
+ }
56
+ else {
57
+ console.log(`\nCloned ${manifest.files.length} file(s) to ./${slug}/`);
58
+ console.log('');
59
+ console.log(` After building from this BotDoc, share your experience:`);
60
+ console.log(` botdocs endorse ${ref} --rating positive`);
61
+ }
62
+ }
63
+ function parseRef(ref) {
64
+ // Accept: username/slug or @username/slug
65
+ const cleaned = ref.startsWith('@') ? ref.slice(1) : ref;
66
+ const parts = cleaned.split('/');
67
+ if (parts.length !== 2 || !parts[0] || !parts[1])
68
+ return null;
69
+ return { username: parts[0], slug: parts[1] };
70
+ }
@@ -0,0 +1,3 @@
1
+ export declare function diff(ref: string, options: {
2
+ json?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,65 @@
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
+ }
@@ -0,0 +1,7 @@
1
+ interface EndorseOptions {
2
+ rating: string;
3
+ comment?: string;
4
+ json?: boolean;
5
+ }
6
+ export declare function endorse(ref: string, options: EndorseOptions): Promise<void>;
7
+ export {};
@@ -0,0 +1,70 @@
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
+ }
@@ -0,0 +1,7 @@
1
+ interface InitOptions {
2
+ title?: string;
3
+ category?: string;
4
+ json?: boolean;
5
+ }
6
+ export declare function init(dirName: string | undefined, options: InitOptions): Promise<void>;
7
+ export {};
@@ -0,0 +1,57 @@
1
+ import { writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ export async function init(dirName, options) {
4
+ const name = dirName || 'my-botdoc';
5
+ const dir = join(process.cwd(), name);
6
+ if (existsSync(dir)) {
7
+ if (options.json) {
8
+ console.log(JSON.stringify({ error: `Directory ${name} already exists` }));
9
+ }
10
+ else {
11
+ console.error(`Error: Directory ${name} already exists.`);
12
+ }
13
+ process.exit(1);
14
+ }
15
+ mkdirSync(dir, { recursive: true });
16
+ const title = options.title || name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
17
+ const indexContent = `# ${title}
18
+
19
+ ## Overview
20
+
21
+ Describe what this concept specification is about. What problem does it solve? What should an agent build from it?
22
+
23
+ ## Architecture
24
+
25
+ Describe the high-level architecture. What are the main components? How do they interact?
26
+
27
+ ## Features
28
+
29
+ List the key features this system should have.
30
+
31
+ ## Data Model
32
+
33
+ Describe the entities and their relationships.
34
+
35
+ ## Success Criteria
36
+
37
+ How do you know when an implementation of this spec is correct?
38
+ `;
39
+ writeFileSync(join(dir, 'index.md'), indexContent, 'utf-8');
40
+ const botdocsJson = {
41
+ title,
42
+ description: '',
43
+ category: options.category?.toUpperCase() || 'OTHER',
44
+ tags: [],
45
+ license: 'MIT',
46
+ };
47
+ writeFileSync(join(dir, 'botdocs.json'), JSON.stringify(botdocsJson, null, 2), 'utf-8');
48
+ if (options.json) {
49
+ console.log(JSON.stringify({ success: true, directory: name, files: ['index.md', 'botdocs.json'] }));
50
+ }
51
+ else {
52
+ console.log(`\n Created ${name}/`);
53
+ console.log(` index.md — your spec starts here`);
54
+ console.log(` botdocs.json — metadata for publishing`);
55
+ console.log(`\n Next: edit index.md, then run botdocs publish ${name}/\n`);
56
+ }
57
+ }
@@ -0,0 +1 @@
1
+ export declare function login(): Promise<void>;
@@ -0,0 +1,84 @@
1
+ import { saveAuth } from '../lib/config.js';
2
+ const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || process.env.GITHUB_ID || '';
3
+ export async function login() {
4
+ if (!GITHUB_CLIENT_ID) {
5
+ console.error('Error: GitHub Client ID not configured.\n' +
6
+ 'Set GITHUB_CLIENT_ID or GITHUB_ID environment variable.');
7
+ process.exit(1);
8
+ }
9
+ // Step 1: Request device code
10
+ const deviceResponse = await fetch('https://github.com/login/device/code', {
11
+ method: 'POST',
12
+ headers: {
13
+ Accept: 'application/json',
14
+ 'Content-Type': 'application/json',
15
+ },
16
+ body: JSON.stringify({
17
+ client_id: GITHUB_CLIENT_ID,
18
+ scope: 'read:user',
19
+ }),
20
+ });
21
+ if (!deviceResponse.ok) {
22
+ console.error('Failed to initiate device code flow.');
23
+ process.exit(1);
24
+ }
25
+ const deviceData = (await deviceResponse.json());
26
+ // Step 2: Display user code
27
+ console.log('\nTo authenticate, visit:');
28
+ console.log(`\n ${deviceData.verification_uri}\n`);
29
+ console.log(`Enter code: ${deviceData.user_code}\n`);
30
+ console.log('Waiting for authorization...');
31
+ // Step 3: Poll for token
32
+ const interval = (deviceData.interval || 5) * 1000;
33
+ const expiresAt = Date.now() + deviceData.expires_in * 1000;
34
+ while (Date.now() < expiresAt) {
35
+ await new Promise((resolve) => setTimeout(resolve, interval));
36
+ const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
37
+ method: 'POST',
38
+ headers: {
39
+ Accept: 'application/json',
40
+ 'Content-Type': 'application/json',
41
+ },
42
+ body: JSON.stringify({
43
+ client_id: GITHUB_CLIENT_ID,
44
+ device_code: deviceData.device_code,
45
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
46
+ }),
47
+ });
48
+ const tokenData = (await tokenResponse.json());
49
+ if (tokenData.access_token) {
50
+ // Step 4: Get user info
51
+ const userResponse = await fetch('https://api.github.com/user', {
52
+ headers: {
53
+ Authorization: `Bearer ${tokenData.access_token}`,
54
+ Accept: 'application/vnd.github+json',
55
+ },
56
+ });
57
+ if (!userResponse.ok) {
58
+ console.error('Failed to get user info from GitHub.');
59
+ process.exit(1);
60
+ }
61
+ const user = (await userResponse.json());
62
+ // Step 5: Save credentials
63
+ saveAuth({
64
+ githubToken: tokenData.access_token,
65
+ username: user.login,
66
+ displayName: user.name || user.login,
67
+ });
68
+ console.log(`\nAuthenticated as ${user.login}`);
69
+ return;
70
+ }
71
+ if (tokenData.error === 'expired_token') {
72
+ console.error('\nDevice code expired. Please try again.');
73
+ process.exit(1);
74
+ }
75
+ if (tokenData.error &&
76
+ tokenData.error !== 'authorization_pending' &&
77
+ tokenData.error !== 'slow_down') {
78
+ console.error(`\nAuthentication error: ${tokenData.error_description || tokenData.error}`);
79
+ process.exit(1);
80
+ }
81
+ }
82
+ console.error('\nAuthentication timed out. Please try again.');
83
+ process.exit(1);
84
+ }
@@ -0,0 +1,10 @@
1
+ interface PublishOptions {
2
+ title?: string;
3
+ description?: string;
4
+ category?: string;
5
+ tags?: string;
6
+ license?: string;
7
+ json?: boolean;
8
+ }
9
+ export declare function publish(source: string, options: PublishOptions): Promise<void>;
10
+ export {};
@@ -0,0 +1,172 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import AdmZip from 'adm-zip';
4
+ import { apiFetch } from '../lib/api.js';
5
+ const VALID_CATEGORIES = [
6
+ 'KNOWLEDGE_MANAGEMENT',
7
+ 'DEV_WORKFLOW',
8
+ 'AUTOMATION',
9
+ 'AGENT_CONFIG',
10
+ 'PROJECT_SCAFFOLD',
11
+ 'OTHER',
12
+ ];
13
+ const VALID_LICENSES = ['MIT', 'CC_BY_4_0', 'CC_BY_SA_4_0', 'CC0', 'ALL_RIGHTS_RESERVED'];
14
+ export async function publish(source, options) {
15
+ const resolved = path.resolve(source);
16
+ if (!fs.existsSync(resolved)) {
17
+ console.error(`Source not found: ${source}`);
18
+ process.exit(1);
19
+ }
20
+ // Determine source type and collect files
21
+ let files;
22
+ const stat = fs.statSync(resolved);
23
+ if (stat.isDirectory()) {
24
+ files = collectFromDirectory(resolved);
25
+ }
26
+ else if (resolved.endsWith('.zip')) {
27
+ files = collectFromZip(resolved);
28
+ }
29
+ else {
30
+ // Single markdown file
31
+ files = collectFromFile(resolved);
32
+ }
33
+ if (files.length === 0) {
34
+ console.error('No markdown files found.');
35
+ process.exit(1);
36
+ }
37
+ // Ensure index.md exists
38
+ const hasIndex = files.some((f) => f.filename === 'index.md');
39
+ if (!hasIndex) {
40
+ // For single file, rename to index.md
41
+ if (files.length === 1) {
42
+ files[0].filename = 'index.md';
43
+ }
44
+ else {
45
+ console.error('Multi-file BotDocs must include an index.md file.');
46
+ process.exit(1);
47
+ }
48
+ }
49
+ // Resolve metadata from flags
50
+ const title = options.title || deriveTitle(source, files);
51
+ const description = options.description || '';
52
+ const category = resolveCategory(options.category);
53
+ const tags = options.tags
54
+ ? options.tags
55
+ .split(',')
56
+ .map((t) => t.trim())
57
+ .filter(Boolean)
58
+ : [];
59
+ const license = resolveLicense(options.license);
60
+ if (!description) {
61
+ console.error('Description is required. Use --description "..."');
62
+ process.exit(1);
63
+ }
64
+ console.log(`Publishing "${title}" (${files.length} file(s))...`);
65
+ const result = await apiFetch('/api/botdocs', {
66
+ method: 'POST',
67
+ auth: true,
68
+ body: {
69
+ title,
70
+ description,
71
+ category,
72
+ tags,
73
+ license,
74
+ files,
75
+ },
76
+ });
77
+ if (options.json) {
78
+ console.log(JSON.stringify(result));
79
+ }
80
+ else {
81
+ console.log(`\nPublished: ${result.url}`);
82
+ }
83
+ }
84
+ function collectFromFile(filePath) {
85
+ const content = fs.readFileSync(filePath, 'utf-8');
86
+ const filename = path.basename(filePath);
87
+ return [{ filename, content, sortOrder: 0 }];
88
+ }
89
+ function collectFromDirectory(dirPath) {
90
+ const entries = fs.readdirSync(dirPath);
91
+ const files = [];
92
+ for (let i = 0; i < entries.length; i++) {
93
+ const entry = entries[i];
94
+ if (entry.startsWith('.'))
95
+ continue;
96
+ const fullPath = path.join(dirPath, entry);
97
+ const stat = fs.statSync(fullPath);
98
+ if (stat.isFile() && (entry.endsWith('.md') || entry.endsWith('.markdown'))) {
99
+ files.push({
100
+ filename: entry,
101
+ content: fs.readFileSync(fullPath, 'utf-8'),
102
+ sortOrder: entry === 'index.md' ? 0 : i + 1,
103
+ });
104
+ }
105
+ }
106
+ // Ensure index.md is first
107
+ files.sort((a, b) => a.sortOrder - b.sortOrder);
108
+ return files;
109
+ }
110
+ function collectFromZip(zipPath) {
111
+ const zip = new AdmZip(zipPath);
112
+ const entries = zip.getEntries();
113
+ const files = [];
114
+ for (let i = 0; i < entries.length; i++) {
115
+ const entry = entries[i];
116
+ if (entry.isDirectory)
117
+ continue;
118
+ // Get the filename (strip any directory prefix)
119
+ const filename = path.basename(entry.entryName);
120
+ if (!filename.endsWith('.md') && !filename.endsWith('.markdown'))
121
+ continue;
122
+ if (filename.startsWith('.'))
123
+ continue;
124
+ files.push({
125
+ filename,
126
+ content: entry.getData().toString('utf-8'),
127
+ sortOrder: filename === 'index.md' ? 0 : i + 1,
128
+ });
129
+ }
130
+ files.sort((a, b) => a.sortOrder - b.sortOrder);
131
+ return files;
132
+ }
133
+ function deriveTitle(source, files) {
134
+ // Try to extract title from first heading in index.md
135
+ const indexFile = files.find((f) => f.filename === 'index.md');
136
+ if (indexFile) {
137
+ const match = indexFile.content.match(/^#\s+(.+)$/m);
138
+ if (match?.[1])
139
+ return match[1].trim();
140
+ }
141
+ // Fall back to source name
142
+ const basename = path.basename(source, path.extname(source));
143
+ return basename.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
144
+ }
145
+ function resolveCategory(input) {
146
+ if (!input)
147
+ return 'OTHER';
148
+ const upper = input.toUpperCase().replace(/[\s-]/g, '_');
149
+ // Try exact match
150
+ if (VALID_CATEGORIES.includes(upper))
151
+ return upper;
152
+ // Try fuzzy match
153
+ const found = VALID_CATEGORIES.find((c) => c.includes(upper) || upper.includes(c));
154
+ return found ?? 'OTHER';
155
+ }
156
+ function resolveLicense(input) {
157
+ if (!input)
158
+ return 'MIT';
159
+ const upper = input.toUpperCase().replace(/[\s-]/g, '_');
160
+ if (VALID_LICENSES.includes(upper))
161
+ return upper;
162
+ // Common aliases
163
+ const aliases = {
164
+ CC_BY: 'CC_BY_4_0',
165
+ 'CC-BY': 'CC_BY_4_0',
166
+ CC_BY_SA: 'CC_BY_SA_4_0',
167
+ 'CC-BY-SA': 'CC_BY_SA_4_0',
168
+ CC0: 'CC0',
169
+ ALL_RIGHTS: 'ALL_RIGHTS_RESERVED',
170
+ };
171
+ return aliases[upper] ?? 'MIT';
172
+ }
@@ -0,0 +1,3 @@
1
+ export declare function pull(ref: string, options?: {
2
+ json?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { apiFetch, fetchRawContent } from '../lib/api.js';
4
+ export async function pull(ref, options = {}) {
5
+ const parsed = parseRef(ref);
6
+ if (!parsed) {
7
+ console.error('Invalid reference. Use format: username/slug');
8
+ process.exit(1);
9
+ }
10
+ const { username, slug } = parsed;
11
+ const outDir = path.resolve(slug);
12
+ // Check for existing clone metadata
13
+ const metaPath = path.join(outDir, '.botdocs.json');
14
+ if (!fs.existsSync(metaPath)) {
15
+ console.error(`No clone found at ./${slug}/`);
16
+ console.error('Use `botdocs clone` first.');
17
+ process.exit(1);
18
+ }
19
+ console.log(`Updating ${username}/${slug}...`);
20
+ // Fetch latest manifest
21
+ let manifest;
22
+ try {
23
+ manifest = await apiFetch(`/@${username}/${slug}/manifest`);
24
+ }
25
+ catch {
26
+ console.error(`BotDoc not found: ${username}/${slug}`);
27
+ process.exit(1);
28
+ }
29
+ // Download all files (overwrite)
30
+ let updated = 0;
31
+ for (const file of manifest.files) {
32
+ const filePath = path.join(outDir, file.filename);
33
+ const content = await fetchRawContent(file.rawUrl);
34
+ const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
35
+ if (existing !== content) {
36
+ fs.writeFileSync(filePath, content, 'utf-8');
37
+ console.log(` Updated: ${file.filename}`);
38
+ updated++;
39
+ }
40
+ }
41
+ // Remove files that no longer exist in the BotDoc
42
+ const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
43
+ const remoteFiles = new Set(manifest.files.map((f) => f.filename));
44
+ for (const oldFile of metadata.files) {
45
+ if (!remoteFiles.has(oldFile)) {
46
+ const oldPath = path.join(outDir, oldFile);
47
+ if (fs.existsSync(oldPath)) {
48
+ fs.unlinkSync(oldPath);
49
+ console.log(` Removed: ${oldFile}`);
50
+ updated++;
51
+ }
52
+ }
53
+ }
54
+ // Update metadata
55
+ const newMetadata = {
56
+ username,
57
+ slug,
58
+ clonedAt: metadata.clonedAt,
59
+ files: manifest.files.map((f) => f.filename),
60
+ };
61
+ fs.writeFileSync(metaPath, JSON.stringify(newMetadata, null, 2), 'utf-8');
62
+ if (options.json) {
63
+ console.log(JSON.stringify({ success: true, updated, files: manifest.files.map(f => f.filename) }));
64
+ }
65
+ else if (updated === 0) {
66
+ console.log('Already up to date.');
67
+ }
68
+ else {
69
+ console.log(`\nUpdated ${updated} file(s).`);
70
+ }
71
+ }
72
+ function parseRef(ref) {
73
+ const cleaned = ref.startsWith('@') ? ref.slice(1) : ref;
74
+ const parts = cleaned.split('/');
75
+ if (parts.length !== 2 || !parts[0] || !parts[1])
76
+ return null;
77
+ return { username: parts[0], slug: parts[1] };
78
+ }
@@ -0,0 +1,3 @@
1
+ export declare function search(query: string, options?: {
2
+ json?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,58 @@
1
+ import { apiFetch } from '../lib/api.js';
2
+ export async function search(query, options = {}) {
3
+ if (!query.trim()) {
4
+ console.error('Please provide a search query.');
5
+ process.exit(1);
6
+ }
7
+ const encoded = encodeURIComponent(query);
8
+ const data = await apiFetch(`/api/search?q=${encoded}`);
9
+ if (options.json) {
10
+ console.log(JSON.stringify(data, null, 2));
11
+ return;
12
+ }
13
+ if (data.results.length === 0) {
14
+ console.log(`No results for "${query}".`);
15
+ return;
16
+ }
17
+ console.log(`${data.total} result(s) for "${query}":\n`);
18
+ // Print formatted table
19
+ const maxTitle = Math.min(40, Math.max(...data.results.map((r) => r.title.length)));
20
+ const maxAuthor = Math.max(...data.results.map((r) => r.author.length));
21
+ const header = [
22
+ 'Title'.padEnd(maxTitle),
23
+ 'Author'.padEnd(maxAuthor),
24
+ 'Stars'.padStart(5),
25
+ 'Clones'.padStart(6),
26
+ 'Category',
27
+ ].join(' ');
28
+ const separator = '-'.repeat(header.length);
29
+ console.log(header);
30
+ console.log(separator);
31
+ for (const result of data.results) {
32
+ const title = result.title.length > maxTitle
33
+ ? result.title.slice(0, maxTitle - 1) + '…'
34
+ : result.title.padEnd(maxTitle);
35
+ const row = [
36
+ title,
37
+ result.author.padEnd(maxAuthor),
38
+ String(result.starCount).padStart(5),
39
+ String(result.cloneCount).padStart(6),
40
+ formatCategory(result.category),
41
+ ].join(' ');
42
+ console.log(row);
43
+ }
44
+ if (data.totalPages > 1) {
45
+ console.log(`\nPage ${data.page}/${data.totalPages}`);
46
+ }
47
+ }
48
+ function formatCategory(category) {
49
+ const labels = {
50
+ KNOWLEDGE_MANAGEMENT: 'Knowledge',
51
+ DEV_WORKFLOW: 'Dev Workflow',
52
+ AUTOMATION: 'Automation',
53
+ AGENT_CONFIG: 'Agent Config',
54
+ PROJECT_SCAFFOLD: 'Scaffold',
55
+ OTHER: 'Other',
56
+ };
57
+ return labels[category] ?? category;
58
+ }
@@ -0,0 +1,3 @@
1
+ export declare function validate(source: string, options: {
2
+ json?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,79 @@
1
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
2
+ import { join, extname } from 'path';
3
+ export async function validate(source, options) {
4
+ const errors = [];
5
+ const stat = statSync(source, { throwIfNoEntry: false });
6
+ if (!stat) {
7
+ errors.push({ file: source, message: 'Path does not exist', severity: 'error' });
8
+ return outputResults(errors, options.json);
9
+ }
10
+ let files;
11
+ let baseDir;
12
+ if (stat.isDirectory()) {
13
+ baseDir = source;
14
+ files = readdirSync(source).filter((f) => (extname(f) === '.md' || extname(f) === '.markdown') && statSync(join(source, f)).isFile());
15
+ if (files.length === 0) {
16
+ errors.push({ file: source, message: 'No markdown files found', severity: 'error' });
17
+ }
18
+ if (!files.includes('index.md')) {
19
+ errors.push({ file: 'index.md', message: 'Missing index.md (required entry point)', severity: 'error' });
20
+ }
21
+ if (existsSync(join(source, 'botdocs.json'))) {
22
+ try {
23
+ const meta = JSON.parse(readFileSync(join(source, 'botdocs.json'), 'utf-8'));
24
+ if (!meta.title)
25
+ errors.push({ file: 'botdocs.json', message: 'Missing title', severity: 'error' });
26
+ if (!meta.description)
27
+ errors.push({ file: 'botdocs.json', message: 'Missing description', severity: 'warning' });
28
+ }
29
+ catch {
30
+ errors.push({ file: 'botdocs.json', message: 'Invalid JSON', severity: 'error' });
31
+ }
32
+ }
33
+ else {
34
+ errors.push({ file: 'botdocs.json', message: 'Missing botdocs.json metadata file', severity: 'warning' });
35
+ }
36
+ }
37
+ else {
38
+ baseDir = '.';
39
+ files = [source];
40
+ }
41
+ for (const file of files) {
42
+ const filePath = stat.isDirectory() ? join(baseDir, file) : file;
43
+ const content = readFileSync(filePath, 'utf-8');
44
+ if (content.trim().length === 0) {
45
+ errors.push({ file, message: 'File is empty', severity: 'error' });
46
+ continue;
47
+ }
48
+ if (!content.match(/^#\s+/m)) {
49
+ errors.push({ file, message: 'No markdown heading found', severity: 'warning' });
50
+ }
51
+ if (content.length < 100) {
52
+ errors.push({ file, message: 'Content is very short (< 100 chars)', severity: 'warning' });
53
+ }
54
+ }
55
+ outputResults(errors, options.json);
56
+ }
57
+ function outputResults(errors, json) {
58
+ const errorCount = errors.filter((e) => e.severity === 'error').length;
59
+ const warnCount = errors.filter((e) => e.severity === 'warning').length;
60
+ const valid = errorCount === 0;
61
+ if (json) {
62
+ console.log(JSON.stringify({ valid, errors, errorCount, warningCount: warnCount }));
63
+ if (!valid)
64
+ process.exit(1);
65
+ return;
66
+ }
67
+ if (errors.length === 0) {
68
+ console.log('\n Valid! Ready to publish.\n');
69
+ return;
70
+ }
71
+ console.log('');
72
+ for (const err of errors) {
73
+ const icon = err.severity === 'error' ? 'x' : '!';
74
+ console.log(` ${icon} ${err.file}: ${err.message}`);
75
+ }
76
+ console.log(`\n ${errorCount} error(s), ${warnCount} warning(s)\n`);
77
+ if (!valid)
78
+ process.exit(1);
79
+ }
@@ -0,0 +1,3 @@
1
+ export declare function whoami(options?: {
2
+ json?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { loadAuth } from '../lib/config.js';
2
+ export async function whoami(options = {}) {
3
+ const auth = loadAuth();
4
+ if (!auth) {
5
+ console.log('Not logged in. Run `botdocs login` to authenticate.');
6
+ process.exit(1);
7
+ }
8
+ // Verify token is still valid
9
+ const response = await fetch('https://api.github.com/user', {
10
+ headers: {
11
+ Authorization: `Bearer ${auth.githubToken}`,
12
+ Accept: 'application/vnd.github+json',
13
+ },
14
+ });
15
+ if (!response.ok) {
16
+ console.log('Session expired. Run `botdocs login` to re-authenticate.');
17
+ process.exit(1);
18
+ }
19
+ const user = (await response.json());
20
+ if (options.json) {
21
+ console.log(JSON.stringify({ login: user.login, name: user.name }));
22
+ }
23
+ else {
24
+ console.log(`Logged in as ${user.login}${user.name ? ` (${user.name})` : ''}`);
25
+ }
26
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { clone } from './commands/clone.js';
4
+ import { search } from './commands/search.js';
5
+ import { publish } from './commands/publish.js';
6
+ import { pull } from './commands/pull.js';
7
+ import { login } from './commands/login.js';
8
+ import { whoami } from './commands/whoami.js';
9
+ import { init } from './commands/init.js';
10
+ import { validate } from './commands/validate.js';
11
+ import { diff } from './commands/diff.js';
12
+ import { endorse } from './commands/endorse.js';
13
+ const program = new Command();
14
+ program
15
+ .name('botdocs')
16
+ .description('CLI for BotDocs — clone, search, and publish concept specifications')
17
+ .version('0.1.0')
18
+ .option('--json', 'Output results as JSON');
19
+ program
20
+ .command('init [name]')
21
+ .description('Scaffold a new BotDoc directory with index.md template')
22
+ .option('--title <title>', 'BotDoc title')
23
+ .option('--category <category>', 'Category')
24
+ .action(async (name, opts) => {
25
+ await init(name, { ...opts, json: program.opts().json });
26
+ });
27
+ program
28
+ .command('validate <source>')
29
+ .description('Validate a BotDoc directory or file before publishing')
30
+ .action(async (source) => {
31
+ await validate(source, { json: program.opts().json });
32
+ });
33
+ program
34
+ .command('clone <username/slug>')
35
+ .description('Download all BotDoc files to a local directory')
36
+ .action(async (ref) => {
37
+ await clone(ref, { json: program.opts().json });
38
+ });
39
+ program
40
+ .command('search <query>')
41
+ .description('Search for BotDocs')
42
+ .action(async (query) => {
43
+ await search(query, { json: program.opts().json });
44
+ });
45
+ program
46
+ .command('publish <source>')
47
+ .description('Publish a BotDoc from a file, directory, or zip archive')
48
+ .option('--title <title>', 'BotDoc title')
49
+ .option('--description <description>', 'BotDoc description')
50
+ .option('--category <category>', 'Category (knowledge_management, dev_workflow, automation, agent_config, project_scaffold, other)')
51
+ .option('--tags <tags>', 'Comma-separated tags')
52
+ .option('--license <license>', 'License (MIT, CC_BY_4_0, CC_BY_SA_4_0, CC0, ALL_RIGHTS_RESERVED)')
53
+ .action(async (source, options) => {
54
+ await publish(source, { ...options, json: program.opts().json });
55
+ });
56
+ program
57
+ .command('diff <username/slug>')
58
+ .description('Preview remote changes before pulling')
59
+ .action(async (ref) => {
60
+ await diff(ref, { json: program.opts().json });
61
+ });
62
+ program
63
+ .command('pull <username/slug>')
64
+ .description('Update previously cloned BotDoc files with the latest version')
65
+ .action(async (ref) => {
66
+ await pull(ref, { json: program.opts().json });
67
+ });
68
+ program
69
+ .command('endorse <username/slug>')
70
+ .description('Endorse a BotDoc after building from it')
71
+ .requiredOption('--rating <rating>', 'Rating: positive, neutral, or negative')
72
+ .option('--comment <comment>', 'Optional feedback about the build experience')
73
+ .action(async (ref, opts) => {
74
+ await endorse(ref, { ...opts, json: program.opts().json });
75
+ });
76
+ program
77
+ .command('login')
78
+ .description('Authenticate via GitHub device code flow')
79
+ .action(async () => {
80
+ await login();
81
+ });
82
+ program
83
+ .command('whoami')
84
+ .description('Display the current authenticated user')
85
+ .action(async () => {
86
+ await whoami({ json: program.opts().json });
87
+ });
88
+ program.parse();
@@ -0,0 +1,17 @@
1
+ export declare function getApiUrl(): string;
2
+ /** Thrown by apiFetch when the server returns a non-2xx response. Carries the
3
+ * status code and the server-provided message so callers can branch on it
4
+ * (e.g. the `endorse` command surfaces a friendly hint on the 403 returned by
5
+ * the clone-required guard). */
6
+ export declare class ApiError extends Error {
7
+ readonly status: number;
8
+ constructor(status: number, message: string);
9
+ }
10
+ interface FetchOptions {
11
+ method?: string;
12
+ body?: unknown;
13
+ auth?: boolean;
14
+ }
15
+ export declare function apiFetch<T>(path: string, options?: FetchOptions): Promise<T>;
16
+ export declare function fetchRawContent(rawUrl: string): Promise<string>;
17
+ export {};
@@ -0,0 +1,66 @@
1
+ import { loadAuth } from './config.js';
2
+ const DEFAULT_API_URL = 'https://botdocs.ai';
3
+ export function getApiUrl() {
4
+ return process.env.BOTDOCS_API_URL || DEFAULT_API_URL;
5
+ }
6
+ /** Thrown by apiFetch when the server returns a non-2xx response. Carries the
7
+ * status code and the server-provided message so callers can branch on it
8
+ * (e.g. the `endorse` command surfaces a friendly hint on the 403 returned by
9
+ * the clone-required guard). */
10
+ export class ApiError extends Error {
11
+ status;
12
+ constructor(status, message) {
13
+ super(message);
14
+ this.name = 'ApiError';
15
+ this.status = status;
16
+ }
17
+ }
18
+ export async function apiFetch(path, options = {}) {
19
+ const { method = 'GET', body, auth = false } = options;
20
+ const baseUrl = getApiUrl();
21
+ const url = `${baseUrl}${path}`;
22
+ const headers = {
23
+ Accept: 'application/json',
24
+ };
25
+ if (body) {
26
+ headers['Content-Type'] = 'application/json';
27
+ }
28
+ if (auth) {
29
+ const config = loadAuth();
30
+ if (!config?.githubToken) {
31
+ throw new Error('Not authenticated. Run `botdocs login` first.');
32
+ }
33
+ headers['Authorization'] = `Bearer ${config.githubToken}`;
34
+ }
35
+ const response = await fetch(url, {
36
+ method,
37
+ headers,
38
+ body: body ? JSON.stringify(body) : undefined,
39
+ });
40
+ if (!response.ok) {
41
+ const text = await response.text();
42
+ let message;
43
+ try {
44
+ const json = JSON.parse(text);
45
+ message = json.error || text;
46
+ }
47
+ catch {
48
+ message = text;
49
+ }
50
+ throw new ApiError(response.status, message);
51
+ }
52
+ const contentType = response.headers.get('content-type') || '';
53
+ if (contentType.includes('application/json')) {
54
+ return (await response.json());
55
+ }
56
+ return (await response.text());
57
+ }
58
+ export async function fetchRawContent(rawUrl) {
59
+ const baseUrl = getApiUrl();
60
+ const url = rawUrl.startsWith('http') ? rawUrl : `${baseUrl}${rawUrl}`;
61
+ const response = await fetch(url);
62
+ if (!response.ok) {
63
+ throw new ApiError(response.status, `Failed to fetch ${rawUrl}`);
64
+ }
65
+ return await response.text();
66
+ }
@@ -0,0 +1,9 @@
1
+ interface AuthConfig {
2
+ githubToken: string;
3
+ username: string;
4
+ displayName: string;
5
+ }
6
+ export declare function saveAuth(config: AuthConfig): void;
7
+ export declare function loadAuth(): AuthConfig | null;
8
+ export declare function clearAuth(): void;
9
+ export {};
@@ -0,0 +1,30 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
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');
6
+ function ensureConfigDir() {
7
+ if (!fs.existsSync(CONFIG_DIR)) {
8
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
9
+ }
10
+ }
11
+ export function saveAuth(config) {
12
+ ensureConfigDir();
13
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(config, null, 2), 'utf-8');
14
+ }
15
+ export function loadAuth() {
16
+ if (!fs.existsSync(AUTH_FILE))
17
+ return null;
18
+ try {
19
+ const data = fs.readFileSync(AUTH_FILE, 'utf-8');
20
+ return JSON.parse(data);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ export function clearAuth() {
27
+ if (fs.existsSync(AUTH_FILE)) {
28
+ fs.unlinkSync(AUTH_FILE);
29
+ }
30
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@botdocs/cli",
3
+ "version": "0.2.0",
4
+ "description": "CLI for BotDocs — clone, search, publish, and endorse concept specifications.",
5
+ "keywords": [
6
+ "botdocs",
7
+ "ai",
8
+ "agents",
9
+ "specs",
10
+ "specification",
11
+ "cli",
12
+ "llm"
13
+ ],
14
+ "homepage": "https://botdocs.ai",
15
+ "bugs": {
16
+ "url": "https://github.com/trev91/Botdocs/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/trev91/Botdocs.git",
21
+ "directory": "packages/cli"
22
+ },
23
+ "license": "MIT",
24
+ "author": "BotDocs contributors",
25
+ "bin": {
26
+ "botdocs": "./dist/index.js"
27
+ },
28
+ "devDependencies": {
29
+ "@eslint/js": "^10.0.1",
30
+ "@types/adm-zip": "^0.5.8",
31
+ "@types/node": "^22.14.0",
32
+ "eslint": "^10.2.0",
33
+ "typescript": "^5.8.0",
34
+ "typescript-eslint": "^8.58.0",
35
+ "vitest": "^3.1.0"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "README.md"
40
+ ],
41
+ "engines": {
42
+ "node": ">=20"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "type": "module",
48
+ "dependencies": {
49
+ "adm-zip": "^0.5.17",
50
+ "commander": "^14.0.3"
51
+ },
52
+ "scripts": {
53
+ "dev": "tsc -p tsconfig.build.json --watch",
54
+ "build": "tsc -p tsconfig.build.json",
55
+ "lint": "eslint src/",
56
+ "typecheck": "tsc --noEmit",
57
+ "test": "vitest run",
58
+ "test:watch": "vitest"
59
+ }
60
+ }