@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 +118 -0
- package/dist/commands/clone.d.ts +3 -0
- package/dist/commands/clone.js +70 -0
- package/dist/commands/diff.d.ts +3 -0
- package/dist/commands/diff.js +65 -0
- package/dist/commands/endorse.d.ts +7 -0
- package/dist/commands/endorse.js +70 -0
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.js +57 -0
- package/dist/commands/login.d.ts +1 -0
- package/dist/commands/login.js +84 -0
- package/dist/commands/publish.d.ts +10 -0
- package/dist/commands/publish.js +172 -0
- package/dist/commands/pull.d.ts +3 -0
- package/dist/commands/pull.js +78 -0
- package/dist/commands/search.d.ts +3 -0
- package/dist/commands/search.js +58 -0
- package/dist/commands/validate.d.ts +3 -0
- package/dist/commands/validate.js +79 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.js +26 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +88 -0
- package/dist/lib/api.d.ts +17 -0
- package/dist/lib/api.js +66 -0
- package/dist/lib/config.d.ts +9 -0
- package/dist/lib/config.js +30 -0
- package/package.json +60 -0
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,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,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,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,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,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,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,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,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,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
|
+
}
|
package/dist/index.d.ts
ADDED
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 {};
|
package/dist/lib/api.js
ADDED
|
@@ -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
|
+
}
|