@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.
Files changed (90) hide show
  1. package/README.md +119 -2
  2. package/dist/commands/check-updates.d.ts +6 -0
  3. package/dist/commands/check-updates.js +77 -0
  4. package/dist/commands/check-updates.test.d.ts +1 -0
  5. package/dist/commands/check-updates.test.js +128 -0
  6. package/dist/commands/compile.d.ts +9 -0
  7. package/dist/commands/compile.js +93 -0
  8. package/dist/commands/compile.test.d.ts +1 -0
  9. package/dist/commands/compile.test.js +110 -0
  10. package/dist/commands/edit.d.ts +7 -0
  11. package/dist/commands/edit.js +105 -0
  12. package/dist/commands/edit.test.d.ts +1 -0
  13. package/dist/commands/edit.test.js +102 -0
  14. package/dist/commands/ingest.d.ts +7 -0
  15. package/dist/commands/ingest.js +101 -0
  16. package/dist/commands/ingest.test.d.ts +1 -0
  17. package/dist/commands/ingest.test.js +109 -0
  18. package/dist/commands/init.d.ts +1 -0
  19. package/dist/commands/init.js +34 -1
  20. package/dist/commands/install-instructions.d.ts +8 -0
  21. package/dist/commands/install-instructions.js +88 -0
  22. package/dist/commands/install.d.ts +8 -0
  23. package/dist/commands/install.js +143 -0
  24. package/dist/commands/install.test.d.ts +1 -0
  25. package/dist/commands/install.test.js +253 -0
  26. package/dist/commands/list.d.ts +6 -0
  27. package/dist/commands/list.js +35 -0
  28. package/dist/commands/list.test.d.ts +1 -0
  29. package/dist/commands/list.test.js +51 -0
  30. package/dist/commands/login.d.ts +5 -1
  31. package/dist/commands/login.js +10 -7
  32. package/dist/commands/publish.d.ts +1 -0
  33. package/dist/commands/publish.js +37 -0
  34. package/dist/commands/publish.test.d.ts +1 -0
  35. package/dist/commands/publish.test.js +76 -0
  36. package/dist/commands/sync.d.ts +7 -0
  37. package/dist/commands/sync.js +161 -0
  38. package/dist/commands/sync.test.d.ts +1 -0
  39. package/dist/commands/sync.test.js +263 -0
  40. package/dist/commands/uninstall.d.ts +5 -0
  41. package/dist/commands/uninstall.js +31 -0
  42. package/dist/commands/uninstall.test.d.ts +1 -0
  43. package/dist/commands/uninstall.test.js +67 -0
  44. package/dist/commands/validate.js +20 -5
  45. package/dist/index.js +86 -2
  46. package/dist/lib/auto-detect.d.ts +13 -0
  47. package/dist/lib/auto-detect.js +34 -0
  48. package/dist/lib/auto-detect.test.d.ts +1 -0
  49. package/dist/lib/auto-detect.test.js +58 -0
  50. package/dist/lib/canonical.d.ts +5 -0
  51. package/dist/lib/canonical.js +68 -0
  52. package/dist/lib/canonical.test.d.ts +1 -0
  53. package/dist/lib/canonical.test.js +48 -0
  54. package/dist/lib/config.d.ts +1 -0
  55. package/dist/lib/diff.d.ts +2 -0
  56. package/dist/lib/diff.js +36 -0
  57. package/dist/lib/diff.test.d.ts +1 -0
  58. package/dist/lib/diff.test.js +28 -0
  59. package/dist/lib/library-sync.d.ts +8 -0
  60. package/dist/lib/library-sync.js +30 -0
  61. package/dist/lib/library-sync.test.d.ts +1 -0
  62. package/dist/lib/library-sync.test.js +63 -0
  63. package/dist/lib/llm.d.ts +26 -0
  64. package/dist/lib/llm.js +61 -0
  65. package/dist/lib/llm.test.d.ts +1 -0
  66. package/dist/lib/llm.test.js +72 -0
  67. package/dist/lib/lockfile.d.ts +30 -0
  68. package/dist/lib/lockfile.js +70 -0
  69. package/dist/lib/lockfile.test.d.ts +1 -0
  70. package/dist/lib/lockfile.test.js +99 -0
  71. package/dist/lib/manifest.d.ts +18 -0
  72. package/dist/lib/manifest.js +77 -0
  73. package/dist/lib/manifest.test.d.ts +1 -0
  74. package/dist/lib/manifest.test.js +72 -0
  75. package/dist/lib/prompts.d.ts +5 -0
  76. package/dist/lib/prompts.js +26 -0
  77. package/dist/lib/shell-hook.d.ts +12 -0
  78. package/dist/lib/shell-hook.js +80 -0
  79. package/dist/lib/shell-hook.test.d.ts +1 -0
  80. package/dist/lib/shell-hook.test.js +68 -0
  81. package/dist/test-utils.d.ts +43 -0
  82. package/dist/test-utils.js +101 -0
  83. package/package.json +8 -2
  84. package/templates/agents.md +126 -0
  85. package/templates/ecosystem-prompts/compile-chatgpt.md +9 -0
  86. package/templates/ecosystem-prompts/compile-claude-code.md +11 -0
  87. package/templates/ecosystem-prompts/compile-claude.md +20 -0
  88. package/templates/ecosystem-prompts/compile-codex.md +8 -0
  89. package/templates/ecosystem-prompts/compile-cursor.md +11 -0
  90. package/templates/ecosystem-prompts/edit.md +12 -0
package/dist/index.js CHANGED
@@ -10,6 +10,15 @@ import { init } from './commands/init.js';
10
10
  import { validate } from './commands/validate.js';
11
11
  import { diff } from './commands/diff.js';
12
12
  import { endorse } from './commands/endorse.js';
13
+ import { installInstructions } from './commands/install-instructions.js';
14
+ import { install } from './commands/install.js';
15
+ import { list } from './commands/list.js';
16
+ import { uninstall } from './commands/uninstall.js';
17
+ import { sync } from './commands/sync.js';
18
+ import { ingest } from './commands/ingest.js';
19
+ import { checkUpdates } from './commands/check-updates.js';
20
+ import { compile } from './commands/compile.js';
21
+ import { edit } from './commands/edit.js';
13
22
  const program = new Command();
14
23
  program
15
24
  .name('botdocs')
@@ -21,6 +30,7 @@ program
21
30
  .description('Scaffold a new BotDoc directory with index.md template')
22
31
  .option('--title <title>', 'BotDoc title')
23
32
  .option('--category <category>', 'Category')
33
+ .option('--canonical', 'Scaffold a multi-ecosystem skill (claude-code source + ecosystems list)')
24
34
  .action(async (name, opts) => {
25
35
  await init(name, { ...opts, json: program.opts().json });
26
36
  });
@@ -50,6 +60,7 @@ program
50
60
  .option('--category <category>', 'Category (knowledge_management, dev_workflow, automation, agent_config, project_scaffold, other)')
51
61
  .option('--tags <tags>', 'Comma-separated tags')
52
62
  .option('--license <license>', 'License (MIT, CC_BY_4_0, CC_BY_SA_4_0, CC0, ALL_RIGHTS_RESERVED)')
63
+ .option('--no-compile', 'Skip auto-compile (publish whatever files are on disk as-is)')
53
64
  .action(async (source, options) => {
54
65
  await publish(source, { ...options, json: program.opts().json });
55
66
  });
@@ -76,8 +87,9 @@ program
76
87
  program
77
88
  .command('login')
78
89
  .description('Authenticate via GitHub device code flow')
79
- .action(async () => {
80
- await login();
90
+ .option('--sync-library', 'Enable personalized Library page (uploads sanitized lockfile after install/sync/uninstall)')
91
+ .action(async (opts) => {
92
+ await login(opts);
81
93
  });
82
94
  program
83
95
  .command('whoami')
@@ -85,4 +97,76 @@ program
85
97
  .action(async () => {
86
98
  await whoami({ json: program.opts().json });
87
99
  });
100
+ program
101
+ .command('install-instructions [target]')
102
+ .description('Write AGENTS.md (default) or install a shell hook for update notifications')
103
+ .option('--print', 'Print the instruction block to stdout instead of writing a file')
104
+ .option('--shell-hook', 'Install a shell hook to print update notices on new terminals')
105
+ .option('--remove-shell-hook', 'Remove a previously-installed shell hook')
106
+ .action(async (target, opts) => {
107
+ await installInstructions(target, { ...opts, json: program.opts().json });
108
+ });
109
+ program
110
+ .command('install <ref>')
111
+ .description('Install a skill or bundle locally (skills go to ~/.claude/skills/, project files to .cursor/rules/, etc.)')
112
+ .option('--project <dir>', 'Override the project root used for project-local files')
113
+ .option('--flat', 'Skip the {scope} subdirectory in install paths (collision-prone, not recommended)')
114
+ .option('--clean', 'Wipe-and-reinstall instead of additive')
115
+ .action(async (ref, opts) => {
116
+ await install(ref, { ...opts, json: program.opts().json });
117
+ });
118
+ program
119
+ .command('list')
120
+ .description('List installed skills and bundles')
121
+ .option('--outdated', 'Show only refs with available updates')
122
+ .action(async (opts) => {
123
+ await list({ ...opts, json: program.opts().json });
124
+ });
125
+ program
126
+ .command('uninstall <ref>')
127
+ .description('Remove an installed skill or bundle (deletes installed files locally)')
128
+ .action(async (ref, opts) => {
129
+ await uninstall(ref, { ...opts, json: program.opts().json });
130
+ });
131
+ program
132
+ .command('sync [ref]')
133
+ .description('Check installed skills/bundles for updates and apply')
134
+ .option('--yes', 'Auto-accept clean updates; skip files with local edits')
135
+ .option('--dry-run', 'Show what would change without applying')
136
+ .action(async (ref, opts) => {
137
+ await sync(ref, { ...opts, json: program.opts().json });
138
+ });
139
+ program
140
+ .command('check-updates')
141
+ .description('Check installed skills/bundles for available updates (1-hour cached)')
142
+ .option('--quiet', 'Print a one-liner only when there are updates (used by the shell hook)')
143
+ .action(async (opts) => {
144
+ await checkUpdates({ ...opts, json: program.opts().json });
145
+ });
146
+ program
147
+ .command('ingest <path>')
148
+ .description('Walk a directory of existing skill files and create drafts in your BotDocs account for review')
149
+ .option('--bundle <name>', 'Group all detected skills into a single bundle draft')
150
+ .option('--dry-run', 'Show what would be detected without uploading')
151
+ .action(async (sourcePath, opts) => {
152
+ await ingest(sourcePath, { ...opts, json: program.opts().json });
153
+ });
154
+ program
155
+ .command('compile <path>')
156
+ .description('Generate per-ecosystem drafts from a canonical source (BYOK LLM, local-only)')
157
+ .option('--source <path>', 'Override auto-detected source ecosystem path')
158
+ .option('--ecosystems <list>', 'Comma-separated list of ecosystems to generate (e.g. claude,cursor)')
159
+ .option('--regenerate <ecosystem>', 'Re-generate just one ecosystem')
160
+ .option('--key-env <name>', 'Env var name to use for the API key (e.g. BOTDOCS_OPENAI_KEY)')
161
+ .action(async (skillPath, opts) => {
162
+ await compile(skillPath, { ...opts, json: program.opts().json });
163
+ });
164
+ program
165
+ .command('edit <ref>')
166
+ .description('LLM-assisted revision of a published skill ecosystem file (BYOK; pushes a draft)')
167
+ .requiredOption('--ecosystem <name>', 'Which ecosystem file to edit (claude, claude-code, cursor, chatgpt, codex)')
168
+ .option('--key-env <name>', 'Override the env var used for the API key')
169
+ .action(async (ref, opts) => {
170
+ await edit(ref, { ...opts, json: program.opts().json });
171
+ });
88
172
  program.parse();
@@ -0,0 +1,13 @@
1
+ export interface DetectContext {
2
+ scope: string;
3
+ slug: string;
4
+ homeDir: string;
5
+ projectDir: string;
6
+ flatScope: boolean;
7
+ }
8
+ export type DetectKind = 'global' | 'project' | 'manual' | 'skip';
9
+ export interface Detection {
10
+ kind: DetectKind;
11
+ dest: string;
12
+ }
13
+ export declare function detectDestination(srcRelative: string, ctx: DetectContext): Detection;
@@ -0,0 +1,34 @@
1
+ import path from 'node:path';
2
+ const NORMALIZE = (p) => p.replace(/\\/g, '/');
3
+ export function detectDestination(srcRelative, ctx) {
4
+ const src = NORMALIZE(srcRelative);
5
+ if (src.startsWith('claude/')) {
6
+ const remainder = src.slice('claude/'.length);
7
+ // claude/SKILL.md → keep the leaf filename
8
+ // claude/<inner>/SKILL.md → strip the inner-name segment, keep the leaf
9
+ const finalName = remainder.includes('/')
10
+ ? remainder.replace(/^[^/]+\//, '')
11
+ : remainder;
12
+ const skillPath = ctx.flatScope ? ctx.slug : path.join(ctx.scope, ctx.slug);
13
+ return {
14
+ kind: 'global',
15
+ dest: path.join(ctx.homeDir, '.claude', 'skills', skillPath, finalName),
16
+ };
17
+ }
18
+ if (src.startsWith('claude-code/commands/')) {
19
+ return {
20
+ kind: 'project',
21
+ dest: path.join(ctx.projectDir, '.claude', 'commands', path.basename(src)),
22
+ };
23
+ }
24
+ if (src.startsWith('cursor/rules/')) {
25
+ return {
26
+ kind: 'project',
27
+ dest: path.join(ctx.projectDir, '.cursor', 'rules', path.basename(src)),
28
+ };
29
+ }
30
+ if (src.startsWith('chatgpt/')) {
31
+ return { kind: 'manual', dest: src };
32
+ }
33
+ return { kind: 'skip', dest: src };
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { detectDestination } from './auto-detect.js';
3
+ const ctx = {
4
+ scope: 'teamco',
5
+ slug: 'code-review',
6
+ homeDir: '/home/u',
7
+ projectDir: '/work/proj',
8
+ flatScope: false,
9
+ };
10
+ describe('detectDestination', () => {
11
+ it('routes claude/SKILL.md to ~/.claude/skills/{scope}/{slug}/SKILL.md', () => {
12
+ expect(detectDestination('claude/SKILL.md', ctx)).toEqual({
13
+ kind: 'global',
14
+ dest: '/home/u/.claude/skills/teamco/code-review/SKILL.md',
15
+ });
16
+ });
17
+ it('routes nested claude/<name>/SKILL.md keeping the inner filename', () => {
18
+ expect(detectDestination('claude/code-review/SKILL.md', ctx)).toEqual({
19
+ kind: 'global',
20
+ dest: '/home/u/.claude/skills/teamco/code-review/SKILL.md',
21
+ });
22
+ });
23
+ it('routes cursor rules to project local', () => {
24
+ expect(detectDestination('cursor/rules/style-guide.mdc', ctx)).toEqual({
25
+ kind: 'project',
26
+ dest: '/work/proj/.cursor/rules/style-guide.mdc',
27
+ });
28
+ });
29
+ it('routes claude-code commands to project local', () => {
30
+ expect(detectDestination('claude-code/commands/code-review.md', ctx)).toEqual({
31
+ kind: 'project',
32
+ dest: '/work/proj/.claude/commands/code-review.md',
33
+ });
34
+ });
35
+ it('routes chatgpt files to "manual" with a path hint', () => {
36
+ expect(detectDestination('chatgpt/code-review.md', ctx)).toEqual({
37
+ kind: 'manual',
38
+ dest: 'chatgpt/code-review.md',
39
+ });
40
+ });
41
+ it('honors flatScope by skipping the scope subdir', () => {
42
+ const flat = { ...ctx, flatScope: true };
43
+ expect(detectDestination('claude/SKILL.md', flat).dest).toBe('/home/u/.claude/skills/code-review/SKILL.md');
44
+ });
45
+ it('returns "skip" for unknown ecosystem files', () => {
46
+ expect(detectDestination('docs/notes.md', ctx).kind).toBe('skip');
47
+ });
48
+ it('handles claude/<inner>/<deeper>/SKILL.md without losing the leaf filename', () => {
49
+ // Documents the current behavior: the regex strips only the first inner
50
+ // segment. If we ever support arbitrary nesting, this test must update.
51
+ const result = detectDestination('claude/alpha/beta/SKILL.md', ctx);
52
+ expect(result.kind).toBe('global');
53
+ // Confirms that the leaf "SKILL.md" survives the inner-segment strip; we
54
+ // don't lose it. The current implementation produces "beta/SKILL.md" as
55
+ // the leaf — pin that behavior so we know what we have.
56
+ expect(result.dest.endsWith('SKILL.md')).toBe(true);
57
+ });
58
+ });
@@ -0,0 +1,5 @@
1
+ export type Ecosystem = 'claude' | 'claude-code' | 'cursor' | 'chatgpt' | 'codex';
2
+ export declare const SUPPORTED_ECOSYSTEMS: Ecosystem[];
3
+ export declare function autoDetectSourceEcosystem(skillRoot: string): Ecosystem | null;
4
+ export declare function ecosystemDestination(eco: Ecosystem, slug: string): string;
5
+ export declare function readSourceContent(skillRoot: string, sourceEcosystem: Ecosystem, slug: string): string;
@@ -0,0 +1,68 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export const SUPPORTED_ECOSYSTEMS = ['claude', 'claude-code', 'cursor', 'chatgpt', 'codex'];
4
+ const ECOSYSTEM_FILE_GLOB = {
5
+ claude: ['claude/SKILL.md'],
6
+ 'claude-code': ['claude-code/commands'],
7
+ cursor: ['cursor/rules'],
8
+ chatgpt: ['chatgpt'],
9
+ codex: ['codex'],
10
+ };
11
+ function readSize(filePath) {
12
+ if (!fs.existsSync(filePath))
13
+ return 0;
14
+ const stat = fs.statSync(filePath);
15
+ if (stat.isFile())
16
+ return stat.size;
17
+ if (stat.isDirectory()) {
18
+ let total = 0;
19
+ for (const entry of fs.readdirSync(filePath, { withFileTypes: true })) {
20
+ if (entry.isFile())
21
+ total += fs.statSync(path.join(filePath, entry.name)).size;
22
+ }
23
+ return total;
24
+ }
25
+ return 0;
26
+ }
27
+ export function autoDetectSourceEcosystem(skillRoot) {
28
+ const sizes = SUPPORTED_ECOSYSTEMS.map((eco) => {
29
+ const totalSize = ECOSYSTEM_FILE_GLOB[eco].reduce((sum, rel) => sum + readSize(path.join(skillRoot, rel)), 0);
30
+ return { eco, size: totalSize };
31
+ }).filter((entry) => entry.size > 0);
32
+ if (sizes.length === 0)
33
+ return null;
34
+ sizes.sort((a, b) => {
35
+ if (b.size !== a.size)
36
+ return b.size - a.size;
37
+ return a.eco.localeCompare(b.eco);
38
+ });
39
+ return sizes[0].eco;
40
+ }
41
+ export function ecosystemDestination(eco, slug) {
42
+ switch (eco) {
43
+ case 'claude':
44
+ return 'claude/SKILL.md';
45
+ case 'claude-code':
46
+ return `claude-code/commands/${slug}.md`;
47
+ case 'cursor':
48
+ return `cursor/rules/${slug}.mdc`;
49
+ case 'chatgpt':
50
+ return `chatgpt/${slug}.md`;
51
+ case 'codex':
52
+ return `codex/${slug}.md`;
53
+ }
54
+ }
55
+ export function readSourceContent(skillRoot, sourceEcosystem, slug) {
56
+ const dest = ecosystemDestination(sourceEcosystem, slug);
57
+ const candidate = path.join(skillRoot, dest);
58
+ if (fs.existsSync(candidate))
59
+ return fs.readFileSync(candidate, 'utf-8');
60
+ // Fallback for ecosystems that store as a directory (claude-code/commands)
61
+ const dir = path.join(skillRoot, ECOSYSTEM_FILE_GLOB[sourceEcosystem][0]);
62
+ if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) {
63
+ const entries = fs.readdirSync(dir).filter((f) => f.endsWith('.md') || f.endsWith('.mdc'));
64
+ if (entries[0])
65
+ return fs.readFileSync(path.join(dir, entries[0]), 'utf-8');
66
+ }
67
+ throw new Error(`No source content found for ecosystem "${sourceEcosystem}" in ${skillRoot}`);
68
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
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 { autoDetectSourceEcosystem, ecosystemDestination } from './canonical.js';
6
+ describe('autoDetectSourceEcosystem', () => {
7
+ let tmp;
8
+ beforeEach(() => {
9
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'canon-'));
10
+ });
11
+ afterEach(() => {
12
+ fs.rmSync(tmp, { recursive: true, force: true });
13
+ });
14
+ function write(rel, body) {
15
+ const full = path.join(tmp, rel);
16
+ fs.mkdirSync(path.dirname(full), { recursive: true });
17
+ fs.writeFileSync(full, body);
18
+ }
19
+ it('picks the ecosystem with the longest content', () => {
20
+ write('claude/SKILL.md', 'short');
21
+ write('claude-code/commands/x.md', 'a much longer body that beats Claude in size');
22
+ write('cursor/rules/x.mdc', 'tiny');
23
+ expect(autoDetectSourceEcosystem(tmp)).toBe('claude-code');
24
+ });
25
+ it('tie-breaks alphabetically when sizes are equal', () => {
26
+ write('claude/SKILL.md', 'samesize');
27
+ write('cursor/rules/x.mdc', 'samesize');
28
+ expect(autoDetectSourceEcosystem(tmp)).toBe('claude');
29
+ });
30
+ it('returns null when no ecosystem files are present', () => {
31
+ write('README.md', 'just docs');
32
+ expect(autoDetectSourceEcosystem(tmp)).toBeNull();
33
+ });
34
+ });
35
+ describe('ecosystemDestination', () => {
36
+ it('returns the conventional file path for each ecosystem', () => {
37
+ const cases = [
38
+ ['claude', 'claude/SKILL.md'],
39
+ ['claude-code', 'claude-code/commands/code-review.md'],
40
+ ['cursor', 'cursor/rules/code-review.mdc'],
41
+ ['chatgpt', 'chatgpt/code-review.md'],
42
+ ['codex', 'codex/code-review.md'],
43
+ ];
44
+ for (const [eco, expected] of cases) {
45
+ expect(ecosystemDestination(eco, 'code-review')).toBe(expected);
46
+ }
47
+ });
48
+ });
@@ -2,6 +2,7 @@ interface AuthConfig {
2
2
  githubToken: string;
3
3
  username: string;
4
4
  displayName: string;
5
+ syncLibrary?: boolean;
5
6
  }
6
7
  export declare function saveAuth(config: AuthConfig): void;
7
8
  export declare function loadAuth(): AuthConfig | null;
@@ -0,0 +1,2 @@
1
+ export declare function hasChanges(a: string, b: string): boolean;
2
+ export declare function renderDiff(before: string, after: string): string;
@@ -0,0 +1,36 @@
1
+ import { diffLines } from 'diff';
2
+ const DIM = '\x1b[2m';
3
+ const RED = '\x1b[31m';
4
+ const GREEN = '\x1b[32m';
5
+ const RESET = '\x1b[0m';
6
+ function normalize(s) {
7
+ return s.replace(/\r\n/g, '\n');
8
+ }
9
+ export function hasChanges(a, b) {
10
+ return normalize(a) !== normalize(b);
11
+ }
12
+ export function renderDiff(before, after) {
13
+ if (!hasChanges(before, after))
14
+ return `${DIM}no changes${RESET}`;
15
+ const parts = diffLines(normalize(before), normalize(after));
16
+ let out = '';
17
+ for (const p of parts) {
18
+ const lines = p.value.split('\n');
19
+ if (lines[lines.length - 1] === '')
20
+ lines.pop();
21
+ if (p.added) {
22
+ for (const l of lines)
23
+ out += `${GREEN}+${l}${RESET}\n`;
24
+ }
25
+ else if (p.removed) {
26
+ for (const l of lines)
27
+ out += `${RED}-${l}${RESET}\n`;
28
+ }
29
+ else {
30
+ const trimmed = lines.length > 4 ? [...lines.slice(0, 2), ' ...', ...lines.slice(-2)] : lines;
31
+ for (const l of trimmed)
32
+ out += `${DIM} ${l}${RESET}\n`;
33
+ }
34
+ }
35
+ return out;
36
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderDiff, hasChanges } from './diff.js';
3
+ describe('renderDiff', () => {
4
+ it('marks added lines with + and removed lines with -', () => {
5
+ const out = renderDiff('one\ntwo\nthree\n', 'one\ntwo-modified\nthree\n');
6
+ expect(out).toContain('-two');
7
+ expect(out).toContain('+two-modified');
8
+ });
9
+ it('returns "no changes" when inputs are identical', () => {
10
+ expect(renderDiff('a\nb\n', 'a\nb\n')).toMatch(/no changes/i);
11
+ });
12
+ it('truncates context blocks longer than 4 lines with an ellipsis', () => {
13
+ const before = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nMODIFY\nl10\nl11\nl12\nl13\n';
14
+ const after = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nCHANGED\nl10\nl11\nl12\nl13\n';
15
+ const out = renderDiff(before, after);
16
+ // The unchanged context blocks before and after the modification should
17
+ // include the literal "..." truncation marker.
18
+ expect(out).toContain('...');
19
+ });
20
+ });
21
+ describe('hasChanges', () => {
22
+ it('returns false when normalized contents match', () => {
23
+ expect(hasChanges('a\r\nb\r\n', 'a\nb\n')).toBe(false);
24
+ });
25
+ it('returns true when contents differ', () => {
26
+ expect(hasChanges('a\nb\n', 'a\nc\n')).toBe(true);
27
+ });
28
+ });
@@ -0,0 +1,8 @@
1
+ /** Posts a sanitized snapshot of the lockfile to BotDocs if the user has
2
+ * opted-in via `botdocs login --sync-library`. Sanitization strips:
3
+ * - file contents (lockfile never contained them, but the route also rejects them)
4
+ * - install destinations (private to the user's machine)
5
+ * - fingerprints (not useful server-side; minimize what we send)
6
+ * Only refs + versions + types remain. Failures are silent so install
7
+ * and sync flows never break on telemetry hiccups. */
8
+ export declare function syncLibrary(): Promise<void>;
@@ -0,0 +1,30 @@
1
+ import { apiFetch } from './api.js';
2
+ import { loadLockfile } from './lockfile.js';
3
+ import { loadAuth } from './config.js';
4
+ /** Posts a sanitized snapshot of the lockfile to BotDocs if the user has
5
+ * opted-in via `botdocs login --sync-library`. Sanitization strips:
6
+ * - file contents (lockfile never contained them, but the route also rejects them)
7
+ * - install destinations (private to the user's machine)
8
+ * - fingerprints (not useful server-side; minimize what we send)
9
+ * Only refs + versions + types remain. Failures are silent so install
10
+ * and sync flows never break on telemetry hiccups. */
11
+ export async function syncLibrary() {
12
+ const auth = loadAuth();
13
+ if (!auth?.syncLibrary)
14
+ return;
15
+ const lf = loadLockfile();
16
+ const sanitized = {
17
+ version: 1,
18
+ installs: lf.installs.map((i) => ({ ref: i.ref, type: i.type, version: i.version })),
19
+ };
20
+ try {
21
+ await apiFetch('/api/library/lockfile-sync', {
22
+ method: 'POST',
23
+ auth: true,
24
+ body: sanitized,
25
+ });
26
+ }
27
+ catch {
28
+ // Silent — never break user flows
29
+ }
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
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 { mockFetch } from '../test-utils.js';
6
+ import { saveLockfile } from './lockfile.js';
7
+ import { saveAuth } from './config.js';
8
+ import { syncLibrary } from './library-sync.js';
9
+ describe('syncLibrary', () => {
10
+ let restoreFetch = () => { };
11
+ const origHome = os.homedir;
12
+ let homeTmp;
13
+ beforeEach(() => {
14
+ homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'libsync-'));
15
+ os.homedir = () => homeTmp;
16
+ process.env.BOTDOCS_API_URL = 'http://test.local';
17
+ });
18
+ afterEach(() => {
19
+ restoreFetch();
20
+ fs.rmSync(homeTmp, { recursive: true, force: true });
21
+ os.homedir = origHome;
22
+ vi.restoreAllMocks();
23
+ });
24
+ it('is a no-op when sync_library is not enabled', async () => {
25
+ saveAuth({ githubToken: 't', username: 'u', displayName: 'U' });
26
+ const fm = mockFetch([]);
27
+ restoreFetch = fm.restore;
28
+ await syncLibrary();
29
+ expect(fm.calls).toHaveLength(0);
30
+ });
31
+ it('POSTs sanitized lockfile (refs + versions only) when enabled', async () => {
32
+ saveAuth({ githubToken: 't', username: 'u', displayName: 'U', syncLibrary: true });
33
+ saveLockfile({
34
+ version: 1,
35
+ installs: [{
36
+ ref: '@a/b',
37
+ type: 'SKILL',
38
+ version: '1.0.0',
39
+ installedAt: 't',
40
+ files: [{ src: 's', dest: '/some/path', fingerprint: 'fp' }],
41
+ }],
42
+ });
43
+ const fm = mockFetch([
44
+ { method: 'POST', url: '/api/library/lockfile-sync', response: { body: { ok: true } } },
45
+ ]);
46
+ restoreFetch = fm.restore;
47
+ await syncLibrary();
48
+ expect(fm.calls).toHaveLength(1);
49
+ const body = fm.calls[0].body;
50
+ expect(body.installs[0]).toEqual({ ref: '@a/b', type: 'SKILL', version: '1.0.0' });
51
+ // Sanitization: no files / fingerprints / dest
52
+ expect(body.installs[0].files).toBeUndefined();
53
+ });
54
+ it('swallows errors so install/sync flows never break on telemetry failures', async () => {
55
+ saveAuth({ githubToken: 't', username: 'u', displayName: 'U', syncLibrary: true });
56
+ saveLockfile({ version: 1, installs: [] });
57
+ const fm = mockFetch([
58
+ { method: 'POST', url: '/api/library/lockfile-sync', response: { status: 500, body: { error: 'boom' } } },
59
+ ]);
60
+ restoreFetch = fm.restore;
61
+ await expect(syncLibrary()).resolves.toBeUndefined();
62
+ });
63
+ });
@@ -0,0 +1,26 @@
1
+ export declare class LlmError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export type Provider = 'anthropic' | 'openai';
5
+ export interface DetectOptions {
6
+ keyEnv?: string;
7
+ }
8
+ export interface ProviderInfo {
9
+ provider: Provider;
10
+ keyEnv: string;
11
+ }
12
+ export declare function detectProvider(options?: DetectOptions): ProviderInfo;
13
+ export interface CompleteRequest {
14
+ system: string;
15
+ prompt: string;
16
+ keyEnv?: string;
17
+ }
18
+ export interface CompleteResponse {
19
+ text: string;
20
+ usage: {
21
+ inputTokens: number;
22
+ outputTokens: number;
23
+ };
24
+ provider: Provider;
25
+ }
26
+ export declare function complete(req: CompleteRequest): Promise<CompleteResponse>;
@@ -0,0 +1,61 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import OpenAI from 'openai';
3
+ export class LlmError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'LlmError';
7
+ }
8
+ }
9
+ export function detectProvider(options = {}) {
10
+ if (options.keyEnv) {
11
+ const value = process.env[options.keyEnv];
12
+ if (!value)
13
+ throw new LlmError(`Env var ${options.keyEnv} is not set`);
14
+ if (options.keyEnv.includes('OPENAI'))
15
+ return { provider: 'openai', keyEnv: options.keyEnv };
16
+ return { provider: 'anthropic', keyEnv: options.keyEnv };
17
+ }
18
+ if (process.env.BOTDOCS_ANTHROPIC_KEY)
19
+ return { provider: 'anthropic', keyEnv: 'BOTDOCS_ANTHROPIC_KEY' };
20
+ if (process.env.BOTDOCS_OPENAI_KEY)
21
+ return { provider: 'openai', keyEnv: 'BOTDOCS_OPENAI_KEY' };
22
+ throw new LlmError('No LLM key found. Set BOTDOCS_ANTHROPIC_KEY (recommended) or BOTDOCS_OPENAI_KEY in your env.');
23
+ }
24
+ export async function complete(req) {
25
+ const info = detectProvider({ keyEnv: req.keyEnv });
26
+ if (info.provider === 'anthropic') {
27
+ const client = new Anthropic({ apiKey: process.env[info.keyEnv] });
28
+ const resp = await client.messages.create({
29
+ model: 'claude-haiku-4-5',
30
+ max_tokens: 4096,
31
+ system: req.system,
32
+ messages: [{ role: 'user', content: req.prompt }],
33
+ });
34
+ const blocks = resp.content;
35
+ const text = blocks
36
+ .filter((b) => b.type === 'text')
37
+ .map((b) => b.text ?? '')
38
+ .join('');
39
+ return {
40
+ text,
41
+ usage: { inputTokens: resp.usage.input_tokens, outputTokens: resp.usage.output_tokens },
42
+ provider: 'anthropic',
43
+ };
44
+ }
45
+ const client = new OpenAI({ apiKey: process.env[info.keyEnv] });
46
+ const resp = await client.chat.completions.create({
47
+ model: 'gpt-4o-mini',
48
+ messages: [
49
+ { role: 'system', content: req.system },
50
+ { role: 'user', content: req.prompt },
51
+ ],
52
+ });
53
+ return {
54
+ text: resp.choices[0]?.message?.content ?? '',
55
+ usage: {
56
+ inputTokens: resp.usage?.prompt_tokens ?? 0,
57
+ outputTokens: resp.usage?.completion_tokens ?? 0,
58
+ },
59
+ provider: 'openai',
60
+ };
61
+ }
@@ -0,0 +1 @@
1
+ export {};