@botdocs/cli 0.2.0 → 0.3.1
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 +119 -2
- package/dist/commands/check-updates.d.ts +6 -0
- package/dist/commands/check-updates.js +77 -0
- package/dist/commands/check-updates.test.d.ts +1 -0
- package/dist/commands/check-updates.test.js +128 -0
- package/dist/commands/compile.d.ts +9 -0
- package/dist/commands/compile.js +93 -0
- package/dist/commands/compile.test.d.ts +1 -0
- package/dist/commands/compile.test.js +110 -0
- package/dist/commands/edit.d.ts +7 -0
- package/dist/commands/edit.js +105 -0
- package/dist/commands/edit.test.d.ts +1 -0
- package/dist/commands/edit.test.js +102 -0
- package/dist/commands/ingest.d.ts +7 -0
- package/dist/commands/ingest.js +101 -0
- package/dist/commands/ingest.test.d.ts +1 -0
- package/dist/commands/ingest.test.js +109 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +34 -1
- package/dist/commands/install-instructions.d.ts +8 -0
- package/dist/commands/install-instructions.js +88 -0
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.js +143 -0
- package/dist/commands/install.test.d.ts +1 -0
- package/dist/commands/install.test.js +253 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.js +35 -0
- package/dist/commands/list.test.d.ts +1 -0
- package/dist/commands/list.test.js +51 -0
- package/dist/commands/login.d.ts +5 -1
- package/dist/commands/login.js +10 -7
- package/dist/commands/publish.d.ts +1 -0
- package/dist/commands/publish.js +37 -0
- package/dist/commands/publish.test.d.ts +1 -0
- package/dist/commands/publish.test.js +76 -0
- package/dist/commands/sync.d.ts +7 -0
- package/dist/commands/sync.js +161 -0
- package/dist/commands/sync.test.d.ts +1 -0
- package/dist/commands/sync.test.js +263 -0
- package/dist/commands/uninstall.d.ts +5 -0
- package/dist/commands/uninstall.js +31 -0
- package/dist/commands/uninstall.test.d.ts +1 -0
- package/dist/commands/uninstall.test.js +67 -0
- package/dist/commands/validate.js +20 -5
- package/dist/index.js +86 -2
- package/dist/lib/auto-detect.d.ts +13 -0
- package/dist/lib/auto-detect.js +34 -0
- package/dist/lib/auto-detect.test.d.ts +1 -0
- package/dist/lib/auto-detect.test.js +58 -0
- package/dist/lib/canonical.d.ts +5 -0
- package/dist/lib/canonical.js +68 -0
- package/dist/lib/canonical.test.d.ts +1 -0
- package/dist/lib/canonical.test.js +48 -0
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/diff.d.ts +2 -0
- package/dist/lib/diff.js +36 -0
- package/dist/lib/diff.test.d.ts +1 -0
- package/dist/lib/diff.test.js +28 -0
- package/dist/lib/library-sync.d.ts +8 -0
- package/dist/lib/library-sync.js +30 -0
- package/dist/lib/library-sync.test.d.ts +1 -0
- package/dist/lib/library-sync.test.js +63 -0
- package/dist/lib/llm.d.ts +26 -0
- package/dist/lib/llm.js +61 -0
- package/dist/lib/llm.test.d.ts +1 -0
- package/dist/lib/llm.test.js +72 -0
- package/dist/lib/lockfile.d.ts +30 -0
- package/dist/lib/lockfile.js +70 -0
- package/dist/lib/lockfile.test.d.ts +1 -0
- package/dist/lib/lockfile.test.js +99 -0
- package/dist/lib/manifest.d.ts +18 -0
- package/dist/lib/manifest.js +77 -0
- package/dist/lib/manifest.test.d.ts +1 -0
- package/dist/lib/manifest.test.js +72 -0
- package/dist/lib/prompts.d.ts +5 -0
- package/dist/lib/prompts.js +26 -0
- package/dist/lib/shell-hook.d.ts +12 -0
- package/dist/lib/shell-hook.js +80 -0
- package/dist/lib/shell-hook.test.d.ts +1 -0
- package/dist/lib/shell-hook.test.js +68 -0
- package/dist/test-utils.d.ts +43 -0
- package/dist/test-utils.js +101 -0
- package/package.json +8 -2
- package/templates/agents.md +126 -0
- package/templates/ecosystem-prompts/compile-chatgpt.md +9 -0
- package/templates/ecosystem-prompts/compile-claude-code.md +11 -0
- package/templates/ecosystem-prompts/compile-claude.md +20 -0
- package/templates/ecosystem-prompts/compile-codex.md +8 -0
- package/templates/ecosystem-prompts/compile-cursor.md +11 -0
- package/templates/ecosystem-prompts/edit.md +12 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
|
|
5
|
+
import { detectDestination } from '../lib/auto-detect.js';
|
|
6
|
+
import { fingerprintContent, fingerprintFile, loadLockfile, upsertInstall, } from '../lib/lockfile.js';
|
|
7
|
+
import { syncLibrary } from '../lib/library-sync.js';
|
|
8
|
+
function parseRef(raw) {
|
|
9
|
+
const cleaned = raw.startsWith('@') ? raw.slice(1) : raw;
|
|
10
|
+
const parts = cleaned.split('/');
|
|
11
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
12
|
+
throw new Error(`Invalid ref: ${raw} (expected @user/slug)`);
|
|
13
|
+
}
|
|
14
|
+
return { username: parts[0], slug: parts[1] };
|
|
15
|
+
}
|
|
16
|
+
function buildContext(scope, slug, options) {
|
|
17
|
+
return {
|
|
18
|
+
scope,
|
|
19
|
+
slug,
|
|
20
|
+
homeDir: os.homedir(),
|
|
21
|
+
projectDir: path.resolve(options.project ?? process.cwd()),
|
|
22
|
+
flatScope: options.flat ?? false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function ensureDir(filePath) {
|
|
26
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
async function downloadAndWrite(file, dest, options) {
|
|
29
|
+
const content = await fetchRawContent(file.rawUrl);
|
|
30
|
+
if (fs.existsSync(dest) && !options.clean) {
|
|
31
|
+
const existingFp = fingerprintFile(dest);
|
|
32
|
+
const tmpFp = fingerprintContent(content);
|
|
33
|
+
if (existingFp === tmpFp) {
|
|
34
|
+
// Already present at same fingerprint — additive no-op.
|
|
35
|
+
return { src: file.filename, dest, fingerprint: existingFp };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
ensureDir(dest);
|
|
39
|
+
fs.writeFileSync(dest, content, 'utf-8');
|
|
40
|
+
return { src: file.filename, dest, fingerprint: fingerprintFile(dest) };
|
|
41
|
+
}
|
|
42
|
+
async function installSkill(ref, manifest, options, scope) {
|
|
43
|
+
const ctx = buildContext(scope, ref.slug, options);
|
|
44
|
+
const filesInstalled = [];
|
|
45
|
+
for (const file of manifest.files) {
|
|
46
|
+
const detection = detectDestination(file.filename, ctx);
|
|
47
|
+
if (detection.kind === 'skip')
|
|
48
|
+
continue;
|
|
49
|
+
if (detection.kind === 'manual') {
|
|
50
|
+
// Print for user paste (ChatGPT case). Suppressed under --json so output stays parseable.
|
|
51
|
+
if (!options.json) {
|
|
52
|
+
const content = await fetchRawContent(file.rawUrl);
|
|
53
|
+
console.log(`\n Manual paste required for ${file.filename}:\n${content}\n`);
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const installed = await downloadAndWrite(file, detection.dest, options);
|
|
58
|
+
if (installed)
|
|
59
|
+
filesInstalled.push(installed);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
ref: `@${ref.username}/${ref.slug}`,
|
|
63
|
+
type: 'SKILL',
|
|
64
|
+
version: manifest.version,
|
|
65
|
+
installedAt: new Date().toISOString(),
|
|
66
|
+
files: filesInstalled,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export async function install(rawRef, options) {
|
|
70
|
+
const ref = parseRef(rawRef);
|
|
71
|
+
const refStr = `@${ref.username}/${ref.slug}`;
|
|
72
|
+
if (options.clean) {
|
|
73
|
+
const lf = loadLockfile();
|
|
74
|
+
const prior = lf.installs.find((i) => i.ref === refStr);
|
|
75
|
+
if (prior) {
|
|
76
|
+
for (const f of prior.files) {
|
|
77
|
+
if (fs.existsSync(f.dest))
|
|
78
|
+
fs.unlinkSync(f.dest);
|
|
79
|
+
}
|
|
80
|
+
// For bundles, also clean each contained skill's files
|
|
81
|
+
if (prior.type === 'BUNDLE' && prior.skills) {
|
|
82
|
+
for (const skillRef of prior.skills) {
|
|
83
|
+
const skillEntry = lf.installs.find((i) => i.ref === skillRef);
|
|
84
|
+
if (skillEntry) {
|
|
85
|
+
for (const f of skillEntry.files) {
|
|
86
|
+
if (fs.existsSync(f.dest))
|
|
87
|
+
fs.unlinkSync(f.dest);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let manifest;
|
|
95
|
+
try {
|
|
96
|
+
manifest = await apiFetch(`/api/skills/${ref.username}/${ref.slug}/manifest`);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
100
|
+
console.error(`\n ✗ Skill or bundle not found: ${refStr}\n`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
const installedSummaries = [];
|
|
106
|
+
if (manifest.type === 'SKILL') {
|
|
107
|
+
const entry = await installSkill(ref, manifest, options, ref.username);
|
|
108
|
+
upsertInstall(entry);
|
|
109
|
+
installedSummaries.push(entry);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const skills = [];
|
|
113
|
+
for (const skill of manifest.skills) {
|
|
114
|
+
const entry = await installSkill(skill.ref, skill, options, ref.username);
|
|
115
|
+
upsertInstall(entry);
|
|
116
|
+
installedSummaries.push(entry);
|
|
117
|
+
skills.push(`@${skill.ref.username}/${skill.ref.slug}`);
|
|
118
|
+
}
|
|
119
|
+
const bundleEntry = {
|
|
120
|
+
ref: refStr,
|
|
121
|
+
type: 'BUNDLE',
|
|
122
|
+
version: manifest.version,
|
|
123
|
+
installedAt: new Date().toISOString(),
|
|
124
|
+
files: [],
|
|
125
|
+
skills,
|
|
126
|
+
};
|
|
127
|
+
upsertInstall(bundleEntry);
|
|
128
|
+
installedSummaries.push(bundleEntry);
|
|
129
|
+
}
|
|
130
|
+
if (options.json) {
|
|
131
|
+
console.log(JSON.stringify({ ref: refStr, installed: installedSummaries }));
|
|
132
|
+
await syncLibrary();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
console.log(`\n ✓ Installed ${refStr}`);
|
|
136
|
+
for (const entry of installedSummaries) {
|
|
137
|
+
if (entry.type === 'BUNDLE')
|
|
138
|
+
continue;
|
|
139
|
+
console.log(` ${entry.ref}: ${entry.files.length} file(s)`);
|
|
140
|
+
}
|
|
141
|
+
console.log('');
|
|
142
|
+
await syncLibrary();
|
|
143
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { install } from './install.js';
|
|
6
|
+
import { captureConsole, mockFetch, withTempDir } from '../test-utils.js';
|
|
7
|
+
import { loadLockfile } from '../lib/lockfile.js';
|
|
8
|
+
vi.mock('../lib/library-sync.js', () => ({
|
|
9
|
+
syncLibrary: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
import * as librarySync from '../lib/library-sync.js';
|
|
12
|
+
describe('install', () => {
|
|
13
|
+
let tmp;
|
|
14
|
+
let captured;
|
|
15
|
+
let restoreFetch = () => { };
|
|
16
|
+
const origHome = os.homedir;
|
|
17
|
+
let homeTmp;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
tmp = withTempDir();
|
|
20
|
+
homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'install-home-'));
|
|
21
|
+
os.homedir = () => homeTmp;
|
|
22
|
+
process.env.BOTDOCS_API_URL = 'http://test.local';
|
|
23
|
+
captured = captureConsole();
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
restoreFetch();
|
|
27
|
+
captured.restore();
|
|
28
|
+
tmp.cleanup();
|
|
29
|
+
fs.rmSync(homeTmp, { recursive: true, force: true });
|
|
30
|
+
os.homedir = origHome;
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
it('installs a SKILL bundle to the right destinations', async () => {
|
|
34
|
+
const fm = mockFetch([
|
|
35
|
+
{
|
|
36
|
+
url: '/api/skills/alice/code-review/manifest',
|
|
37
|
+
response: {
|
|
38
|
+
body: {
|
|
39
|
+
ref: { username: 'alice', slug: 'code-review' },
|
|
40
|
+
type: 'SKILL',
|
|
41
|
+
version: '1.0.0',
|
|
42
|
+
sourceEcosystem: 'claude-code',
|
|
43
|
+
files: [
|
|
44
|
+
{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/skill' },
|
|
45
|
+
{ filename: 'cursor/rules/code-review.mdc', rawUrl: 'http://test.local/raw/cursor' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{ url: '/raw/skill', response: { body: '# Claude SKILL\n\nbody', contentType: 'text/plain' } },
|
|
51
|
+
{ url: '/raw/cursor', response: { body: '# Cursor rule', contentType: 'text/plain' } },
|
|
52
|
+
]);
|
|
53
|
+
restoreFetch = fm.restore;
|
|
54
|
+
await install('@alice/code-review', { project: tmp.dir });
|
|
55
|
+
const claudePath = path.join(homeTmp, '.claude', 'skills', 'alice', 'code-review', 'SKILL.md');
|
|
56
|
+
const cursorPath = path.join(tmp.dir, '.cursor', 'rules', 'code-review.mdc');
|
|
57
|
+
expect(fs.existsSync(claudePath)).toBe(true);
|
|
58
|
+
expect(fs.existsSync(cursorPath)).toBe(true);
|
|
59
|
+
const lf = loadLockfile();
|
|
60
|
+
expect(lf.installs).toHaveLength(1);
|
|
61
|
+
expect(lf.installs[0].ref).toBe('@alice/code-review');
|
|
62
|
+
expect(lf.installs[0].files).toHaveLength(2);
|
|
63
|
+
});
|
|
64
|
+
it('installs a BUNDLE by recursively installing each skill', async () => {
|
|
65
|
+
const fm = mockFetch([
|
|
66
|
+
{
|
|
67
|
+
url: '/api/skills/teamco/eng-skills/manifest',
|
|
68
|
+
response: {
|
|
69
|
+
body: {
|
|
70
|
+
ref: { username: 'teamco', slug: 'eng-skills' },
|
|
71
|
+
type: 'BUNDLE',
|
|
72
|
+
version: '1.0.0',
|
|
73
|
+
skills: [
|
|
74
|
+
{
|
|
75
|
+
ref: { username: 'teamco', slug: 'code-review' },
|
|
76
|
+
version: '1.0.0',
|
|
77
|
+
sourceEcosystem: 'claude-code',
|
|
78
|
+
files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/cr' }],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
ref: { username: 'teamco', slug: 'pr-summary' },
|
|
82
|
+
version: '1.0.0',
|
|
83
|
+
sourceEcosystem: 'claude-code',
|
|
84
|
+
files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/ps' }],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{ url: '/raw/cr', response: { body: '# CR', contentType: 'text/plain' } },
|
|
91
|
+
{ url: '/raw/ps', response: { body: '# PS', contentType: 'text/plain' } },
|
|
92
|
+
]);
|
|
93
|
+
restoreFetch = fm.restore;
|
|
94
|
+
await install('@teamco/eng-skills', { project: tmp.dir });
|
|
95
|
+
expect(fs.existsSync(path.join(homeTmp, '.claude', 'skills', 'teamco', 'code-review', 'SKILL.md'))).toBe(true);
|
|
96
|
+
expect(fs.existsSync(path.join(homeTmp, '.claude', 'skills', 'teamco', 'pr-summary', 'SKILL.md'))).toBe(true);
|
|
97
|
+
const lf = loadLockfile();
|
|
98
|
+
expect(lf.installs.map((i) => i.ref).sort()).toEqual([
|
|
99
|
+
'@teamco/code-review',
|
|
100
|
+
'@teamco/eng-skills',
|
|
101
|
+
'@teamco/pr-summary',
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
it('emits JSON on --json', async () => {
|
|
105
|
+
const fm = mockFetch([
|
|
106
|
+
{
|
|
107
|
+
url: '/api/skills/alice/x/manifest',
|
|
108
|
+
response: {
|
|
109
|
+
body: {
|
|
110
|
+
ref: { username: 'alice', slug: 'x' },
|
|
111
|
+
type: 'SKILL',
|
|
112
|
+
version: '1.0.0',
|
|
113
|
+
sourceEcosystem: null,
|
|
114
|
+
files: [],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
restoreFetch = fm.restore;
|
|
120
|
+
await install('@alice/x', { project: tmp.dir, json: true });
|
|
121
|
+
const out = JSON.parse(captured.stdout[0]);
|
|
122
|
+
expect(out.ref).toBe('@alice/x');
|
|
123
|
+
expect(out.installed).toBeDefined();
|
|
124
|
+
});
|
|
125
|
+
it('exits 1 on bad ref', async () => {
|
|
126
|
+
await expect(install('not-a-ref', {})).rejects.toThrow();
|
|
127
|
+
});
|
|
128
|
+
it('--clean removes files from the prior install before re-installing', async () => {
|
|
129
|
+
const dest = path.join(homeTmp, '.claude', 'skills', 'alice', 'x', 'OLD.md');
|
|
130
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
131
|
+
fs.writeFileSync(dest, 'old content');
|
|
132
|
+
const { saveLockfile } = await import('../lib/lockfile.js');
|
|
133
|
+
saveLockfile({
|
|
134
|
+
version: 1,
|
|
135
|
+
installs: [
|
|
136
|
+
{
|
|
137
|
+
ref: '@alice/x',
|
|
138
|
+
type: 'SKILL',
|
|
139
|
+
version: '1.0.0',
|
|
140
|
+
installedAt: 't',
|
|
141
|
+
files: [{ src: 'claude/OLD.md', dest, fingerprint: 'old-fp' }],
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
const fm = mockFetch([
|
|
146
|
+
{
|
|
147
|
+
url: '/api/skills/alice/x/manifest',
|
|
148
|
+
response: {
|
|
149
|
+
body: {
|
|
150
|
+
ref: { username: 'alice', slug: 'x' },
|
|
151
|
+
type: 'SKILL',
|
|
152
|
+
version: '2.0.0',
|
|
153
|
+
sourceEcosystem: null,
|
|
154
|
+
files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/new' }],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{ url: '/raw/new', response: { body: 'new content', contentType: 'text/plain' } },
|
|
159
|
+
]);
|
|
160
|
+
restoreFetch = fm.restore;
|
|
161
|
+
await install('@alice/x', { project: tmp.dir, clean: true });
|
|
162
|
+
// Old file should be gone, new one should be present
|
|
163
|
+
expect(fs.existsSync(dest)).toBe(false);
|
|
164
|
+
expect(fs.existsSync(path.join(homeTmp, '.claude', 'skills', 'alice', 'x', 'SKILL.md'))).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
it('--clean cascades file removal across a bundle\'s child skills', async () => {
|
|
167
|
+
// Pre-populate a bundle and child skill in the lockfile + on disk
|
|
168
|
+
const childDest = path.join(homeTmp, '.claude', 'skills', 'teamco', 'old-skill', 'SKILL.md');
|
|
169
|
+
fs.mkdirSync(path.dirname(childDest), { recursive: true });
|
|
170
|
+
fs.writeFileSync(childDest, 'old child content', 'utf-8');
|
|
171
|
+
const { saveLockfile } = await import('../lib/lockfile.js');
|
|
172
|
+
saveLockfile({
|
|
173
|
+
version: 1,
|
|
174
|
+
installs: [
|
|
175
|
+
{ ref: '@teamco/eng', type: 'BUNDLE', version: '1.0.0', installedAt: 't', files: [], skills: ['@teamco/old-skill'] },
|
|
176
|
+
{ ref: '@teamco/old-skill', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [{ src: 'claude/SKILL.md', dest: childDest, fingerprint: 'old' }] },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
// New manifest replaces old-skill with new-skill
|
|
180
|
+
const fm = mockFetch([
|
|
181
|
+
{
|
|
182
|
+
url: '/api/skills/teamco/eng/manifest',
|
|
183
|
+
response: {
|
|
184
|
+
body: {
|
|
185
|
+
ref: { username: 'teamco', slug: 'eng' },
|
|
186
|
+
type: 'BUNDLE',
|
|
187
|
+
version: '1.1.0',
|
|
188
|
+
skills: [
|
|
189
|
+
{
|
|
190
|
+
ref: { username: 'teamco', slug: 'new-skill' },
|
|
191
|
+
version: '1.0.0',
|
|
192
|
+
sourceEcosystem: 'claude',
|
|
193
|
+
files: [{ filename: 'claude/SKILL.md', rawUrl: 'http://test.local/raw/new' }],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{ url: '/raw/new', response: { body: 'new content', contentType: 'text/plain' } },
|
|
200
|
+
]);
|
|
201
|
+
restoreFetch = fm.restore;
|
|
202
|
+
await install('@teamco/eng', { project: tmp.dir, clean: true });
|
|
203
|
+
// Old child file should be deleted by the cascade
|
|
204
|
+
expect(fs.existsSync(childDest)).toBe(false);
|
|
205
|
+
// New skill installed
|
|
206
|
+
expect(fs.existsSync(path.join(homeTmp, '.claude', 'skills', 'teamco', 'new-skill', 'SKILL.md'))).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
it('--json suppresses the manual-paste console.log for chatgpt files', async () => {
|
|
209
|
+
const fm = mockFetch([
|
|
210
|
+
{
|
|
211
|
+
url: '/api/skills/alice/x/manifest',
|
|
212
|
+
response: {
|
|
213
|
+
body: {
|
|
214
|
+
ref: { username: 'alice', slug: 'x' },
|
|
215
|
+
type: 'SKILL',
|
|
216
|
+
version: '1.0.0',
|
|
217
|
+
sourceEcosystem: null,
|
|
218
|
+
files: [{ filename: 'chatgpt/instructions.md', rawUrl: 'http://test.local/raw/cg' }],
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
// Note: under --json, the manual-paste branch should NOT call fetchRawContent at all
|
|
223
|
+
// (it's wrapped in `if (!options.json)`), so no /raw/cg mock is needed.
|
|
224
|
+
]);
|
|
225
|
+
restoreFetch = fm.restore;
|
|
226
|
+
await install('@alice/x', { project: tmp.dir, json: true });
|
|
227
|
+
// stdout has exactly one entry — the JSON output
|
|
228
|
+
expect(captured.stdout).toHaveLength(1);
|
|
229
|
+
const out = JSON.parse(captured.stdout[0]);
|
|
230
|
+
expect(out.ref).toBe('@alice/x');
|
|
231
|
+
// Verify no manual-paste content leaked into stdout
|
|
232
|
+
expect(captured.stdout.join('\n')).not.toMatch(/Manual paste required/);
|
|
233
|
+
});
|
|
234
|
+
it('invokes syncLibrary after a successful install', async () => {
|
|
235
|
+
const fm = mockFetch([
|
|
236
|
+
{
|
|
237
|
+
url: '/api/skills/alice/x/manifest',
|
|
238
|
+
response: {
|
|
239
|
+
body: {
|
|
240
|
+
ref: { username: 'alice', slug: 'x' },
|
|
241
|
+
type: 'SKILL',
|
|
242
|
+
version: '1.0.0',
|
|
243
|
+
sourceEcosystem: null,
|
|
244
|
+
files: [],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
]);
|
|
249
|
+
restoreFetch = fm.restore;
|
|
250
|
+
await install('@alice/x', { project: tmp.dir });
|
|
251
|
+
expect(librarySync.syncLibrary).toHaveBeenCalledTimes(1);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { loadLockfile } from '../lib/lockfile.js';
|
|
2
|
+
export async function list(options) {
|
|
3
|
+
const lf = loadLockfile();
|
|
4
|
+
if (options.json) {
|
|
5
|
+
console.log(JSON.stringify({ installs: lf.installs }));
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
if (lf.installs.length === 0) {
|
|
9
|
+
console.log('\n nothing installed yet — try `botdocs install @user/slug`\n');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const bundles = lf.installs.filter((i) => i.type === 'BUNDLE');
|
|
13
|
+
const skills = lf.installs.filter((i) => i.type === 'SKILL');
|
|
14
|
+
const skillsByRef = new Map(skills.map((s) => [s.ref, s]));
|
|
15
|
+
const claimed = new Set();
|
|
16
|
+
console.log('');
|
|
17
|
+
for (const bundle of bundles) {
|
|
18
|
+
console.log(` ${bundle.ref} (bundle, v${bundle.version})`);
|
|
19
|
+
for (const ref of bundle.skills ?? []) {
|
|
20
|
+
claimed.add(ref);
|
|
21
|
+
const s = skillsByRef.get(ref);
|
|
22
|
+
if (s)
|
|
23
|
+
console.log(` • ${s.ref} (v${s.version}, ${s.files.length} file(s))`);
|
|
24
|
+
}
|
|
25
|
+
console.log('');
|
|
26
|
+
}
|
|
27
|
+
const standalone = skills.filter((s) => !claimed.has(s.ref));
|
|
28
|
+
if (standalone.length > 0) {
|
|
29
|
+
console.log(' Standalone skills:');
|
|
30
|
+
for (const s of standalone) {
|
|
31
|
+
console.log(` • ${s.ref} (v${s.version}, ${s.files.length} file(s))`);
|
|
32
|
+
}
|
|
33
|
+
console.log('');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { list } from './list.js';
|
|
6
|
+
import { captureConsole } from '../test-utils.js';
|
|
7
|
+
import { saveLockfile } from '../lib/lockfile.js';
|
|
8
|
+
describe('list', () => {
|
|
9
|
+
let captured;
|
|
10
|
+
const origHome = os.homedir;
|
|
11
|
+
let homeTmp;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'list-home-'));
|
|
14
|
+
os.homedir = () => homeTmp;
|
|
15
|
+
captured = captureConsole();
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
captured.restore();
|
|
19
|
+
fs.rmSync(homeTmp, { recursive: true, force: true });
|
|
20
|
+
os.homedir = origHome;
|
|
21
|
+
});
|
|
22
|
+
it('says "nothing installed" when lockfile is empty', async () => {
|
|
23
|
+
await list({});
|
|
24
|
+
expect(captured.stdout.join('\n')).toMatch(/nothing installed/i);
|
|
25
|
+
});
|
|
26
|
+
it('groups skills by their parent bundle', async () => {
|
|
27
|
+
saveLockfile({
|
|
28
|
+
version: 1,
|
|
29
|
+
installs: [
|
|
30
|
+
{ ref: '@a/eng', type: 'BUNDLE', version: '1.0.0', installedAt: 't', files: [], skills: ['@a/cr', '@a/ps'] },
|
|
31
|
+
{ ref: '@a/cr', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] },
|
|
32
|
+
{ ref: '@a/ps', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] },
|
|
33
|
+
{ ref: '@a/standalone', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
await list({});
|
|
37
|
+
const out = captured.stdout.join('\n');
|
|
38
|
+
expect(out).toContain('@a/eng');
|
|
39
|
+
expect(out).toContain('@a/cr');
|
|
40
|
+
expect(out).toContain('@a/standalone');
|
|
41
|
+
});
|
|
42
|
+
it('emits JSON on --json', async () => {
|
|
43
|
+
saveLockfile({
|
|
44
|
+
version: 1,
|
|
45
|
+
installs: [{ ref: '@a/x', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] }],
|
|
46
|
+
});
|
|
47
|
+
await list({ json: true });
|
|
48
|
+
const out = JSON.parse(captured.stdout[0]);
|
|
49
|
+
expect(out.installs).toHaveLength(1);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/dist/commands/login.d.ts
CHANGED
package/dist/commands/login.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { saveAuth } from '../lib/config.js';
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
}
|
|
2
|
+
const DEFAULT_GITHUB_CLIENT_ID = 'Ov23lizUYDKJOhumsyee';
|
|
3
|
+
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || process.env.GITHUB_ID || DEFAULT_GITHUB_CLIENT_ID;
|
|
4
|
+
export async function login(options) {
|
|
9
5
|
// Step 1: Request device code
|
|
10
6
|
const deviceResponse = await fetch('https://github.com/login/device/code', {
|
|
11
7
|
method: 'POST',
|
|
@@ -64,8 +60,15 @@ export async function login() {
|
|
|
64
60
|
githubToken: tokenData.access_token,
|
|
65
61
|
username: user.login,
|
|
66
62
|
displayName: user.name || user.login,
|
|
63
|
+
syncLibrary: options?.syncLibrary === true,
|
|
67
64
|
});
|
|
68
65
|
console.log(`\nAuthenticated as ${user.login}`);
|
|
66
|
+
if (options?.syncLibrary) {
|
|
67
|
+
console.log(' Library sync enabled. Your installed-refs list will appear at https://botdocs.ai/library after install/sync/uninstall.');
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
console.log(' Library sync is OFF. Re-run `botdocs login --sync-library` to enable the personalized Library page.');
|
|
71
|
+
}
|
|
69
72
|
return;
|
|
70
73
|
}
|
|
71
74
|
if (tokenData.error === 'expired_token') {
|
package/dist/commands/publish.js
CHANGED
|
@@ -2,6 +2,9 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import AdmZip from 'adm-zip';
|
|
4
4
|
import { apiFetch } from '../lib/api.js';
|
|
5
|
+
import { parseManifest } from '../lib/manifest.js';
|
|
6
|
+
import { compile } from './compile.js';
|
|
7
|
+
import { ecosystemDestination } from '../lib/canonical.js';
|
|
5
8
|
const VALID_CATEGORIES = [
|
|
6
9
|
'KNOWLEDGE_MANAGEMENT',
|
|
7
10
|
'DEV_WORKFLOW',
|
|
@@ -21,6 +24,7 @@ export async function publish(source, options) {
|
|
|
21
24
|
let files;
|
|
22
25
|
const stat = fs.statSync(resolved);
|
|
23
26
|
if (stat.isDirectory()) {
|
|
27
|
+
await maybeAutoCompile(resolved, options);
|
|
24
28
|
files = collectFromDirectory(resolved);
|
|
25
29
|
}
|
|
26
30
|
else if (resolved.endsWith('.zip')) {
|
|
@@ -81,6 +85,39 @@ export async function publish(source, options) {
|
|
|
81
85
|
console.log(`\nPublished: ${result.url}`);
|
|
82
86
|
}
|
|
83
87
|
}
|
|
88
|
+
async function maybeAutoCompile(source, options) {
|
|
89
|
+
if (options.noCompile)
|
|
90
|
+
return;
|
|
91
|
+
const manifestPath = path.join(source, 'botdocs.json');
|
|
92
|
+
if (!fs.existsSync(manifestPath))
|
|
93
|
+
return;
|
|
94
|
+
let manifest;
|
|
95
|
+
try {
|
|
96
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
97
|
+
manifest = parseManifest(raw);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (manifest.type !== 'SKILL' || !manifest.ecosystems || !manifest.sourceEcosystem)
|
|
103
|
+
return;
|
|
104
|
+
const sourceEco = manifest.sourceEcosystem;
|
|
105
|
+
const slug = path.basename(source);
|
|
106
|
+
const sourceFile = path.join(source, ecosystemDestination(sourceEco, slug));
|
|
107
|
+
if (!fs.existsSync(sourceFile))
|
|
108
|
+
return;
|
|
109
|
+
const sourceMtime = fs.statSync(sourceFile).mtimeMs;
|
|
110
|
+
const stale = manifest.ecosystems
|
|
111
|
+
.filter((e) => e !== sourceEco)
|
|
112
|
+
.some((e) => {
|
|
113
|
+
const dest = path.join(source, ecosystemDestination(e, slug));
|
|
114
|
+
return !fs.existsSync(dest) || fs.statSync(dest).mtimeMs < sourceMtime;
|
|
115
|
+
});
|
|
116
|
+
if (stale) {
|
|
117
|
+
console.log(' Auto-compiling stale ecosystem files…');
|
|
118
|
+
await compile(source, {});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
84
121
|
function collectFromFile(filePath) {
|
|
85
122
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
86
123
|
const filename = path.basename(filePath);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|