@botdocs/cli 0.2.0 → 0.3.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.
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 +8 -1
  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
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ const anthropicCreate = vi.fn();
3
+ vi.mock('@anthropic-ai/sdk', () => ({
4
+ default: class {
5
+ messages = { create: anthropicCreate };
6
+ },
7
+ }));
8
+ const openaiCreate = vi.fn();
9
+ vi.mock('openai', () => ({
10
+ default: class {
11
+ chat = { completions: { create: openaiCreate } };
12
+ },
13
+ }));
14
+ import { detectProvider, complete, LlmError } from './llm.js';
15
+ describe('detectProvider', () => {
16
+ const env = { ...process.env };
17
+ beforeEach(() => {
18
+ delete process.env.BOTDOCS_ANTHROPIC_KEY;
19
+ delete process.env.BOTDOCS_OPENAI_KEY;
20
+ });
21
+ afterEach(() => {
22
+ process.env = { ...env };
23
+ });
24
+ it('prefers Anthropic when both keys are set', () => {
25
+ process.env.BOTDOCS_ANTHROPIC_KEY = 'a';
26
+ process.env.BOTDOCS_OPENAI_KEY = 'o';
27
+ expect(detectProvider().provider).toBe('anthropic');
28
+ });
29
+ it('falls back to OpenAI when only OpenAI key is set', () => {
30
+ process.env.BOTDOCS_OPENAI_KEY = 'o';
31
+ expect(detectProvider().provider).toBe('openai');
32
+ });
33
+ it('throws LlmError when no key is set', () => {
34
+ expect(() => detectProvider()).toThrow(LlmError);
35
+ });
36
+ it('honors --key-env override', () => {
37
+ process.env.BOTDOCS_ANTHROPIC_KEY = 'a';
38
+ process.env.BOTDOCS_OPENAI_KEY = 'o';
39
+ expect(detectProvider({ keyEnv: 'BOTDOCS_OPENAI_KEY' }).provider).toBe('openai');
40
+ });
41
+ });
42
+ describe('complete', () => {
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ process.env.BOTDOCS_ANTHROPIC_KEY = 'a';
46
+ delete process.env.BOTDOCS_OPENAI_KEY;
47
+ });
48
+ it('calls Anthropic with the system + user prompt and returns text', async () => {
49
+ anthropicCreate.mockResolvedValue({
50
+ content: [{ type: 'text', text: 'compiled output' }],
51
+ usage: { input_tokens: 100, output_tokens: 50 },
52
+ });
53
+ const result = await complete({ system: 'sys', prompt: 'usr' });
54
+ expect(result.text).toBe('compiled output');
55
+ expect(result.usage).toEqual({ inputTokens: 100, outputTokens: 50 });
56
+ expect(anthropicCreate).toHaveBeenCalledWith(expect.objectContaining({
57
+ system: 'sys',
58
+ messages: [{ role: 'user', content: 'usr' }],
59
+ }));
60
+ });
61
+ it('calls OpenAI when configured', async () => {
62
+ delete process.env.BOTDOCS_ANTHROPIC_KEY;
63
+ process.env.BOTDOCS_OPENAI_KEY = 'o';
64
+ openaiCreate.mockResolvedValue({
65
+ choices: [{ message: { content: 'openai output' } }],
66
+ usage: { prompt_tokens: 80, completion_tokens: 40 },
67
+ });
68
+ const result = await complete({ system: 'sys', prompt: 'usr' });
69
+ expect(result.text).toBe('openai output');
70
+ expect(result.usage).toEqual({ inputTokens: 80, outputTokens: 40 });
71
+ });
72
+ });
@@ -0,0 +1,30 @@
1
+ export interface InstalledFile {
2
+ src: string;
3
+ dest: string;
4
+ fingerprint: string;
5
+ }
6
+ export interface InstalledRef {
7
+ ref: string;
8
+ type: 'SKILL' | 'BUNDLE';
9
+ version: string;
10
+ installedAt: string;
11
+ files: InstalledFile[];
12
+ /** For bundles: the refs they expanded into. */
13
+ skills?: string[];
14
+ }
15
+ export interface Lockfile {
16
+ version: 1;
17
+ installs: InstalledRef[];
18
+ }
19
+ export declare function loadLockfile(): Lockfile;
20
+ export declare function saveLockfile(lf: Lockfile): void;
21
+ export declare function upsertInstall(entry: InstalledRef): void;
22
+ export declare function removeInstall(ref: string): void;
23
+ /** SHA-256 fingerprint with CRLF normalized to LF so cross-platform
24
+ * checkouts don't false-positive as conflicts. Files that look like text
25
+ * (no NUL bytes) get the normalized hash; otherwise we hash raw bytes. */
26
+ export declare function fingerprintFile(filePath: string): string;
27
+ /** Same fingerprint algorithm as `fingerprintFile`, but operating on an
28
+ * in-memory string. Use this when comparing downloaded content to existing
29
+ * file fingerprints WITHOUT writing to disk first. */
30
+ export declare function fingerprintContent(content: string): string;
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ function lockfilePath() {
6
+ return path.join(os.homedir(), '.botdocs', 'installed.json');
7
+ }
8
+ function configDir() {
9
+ return path.join(os.homedir(), '.botdocs');
10
+ }
11
+ export function loadLockfile() {
12
+ const p = lockfilePath();
13
+ if (!fs.existsSync(p))
14
+ return { version: 1, installs: [] };
15
+ try {
16
+ const data = JSON.parse(fs.readFileSync(p, 'utf-8'));
17
+ if (!data.version || !Array.isArray(data.installs)) {
18
+ return { version: 1, installs: [] };
19
+ }
20
+ return data;
21
+ }
22
+ catch {
23
+ return { version: 1, installs: [] };
24
+ }
25
+ }
26
+ export function saveLockfile(lf) {
27
+ const dir = configDir();
28
+ if (!fs.existsSync(dir))
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ const target = lockfilePath();
31
+ const tmp = `${target}.tmp`;
32
+ fs.writeFileSync(tmp, JSON.stringify(lf, null, 2), 'utf-8');
33
+ fs.renameSync(tmp, target);
34
+ }
35
+ export function upsertInstall(entry) {
36
+ const lf = loadLockfile();
37
+ const idx = lf.installs.findIndex((i) => i.ref === entry.ref);
38
+ if (idx >= 0)
39
+ lf.installs[idx] = entry;
40
+ else
41
+ lf.installs.push(entry);
42
+ saveLockfile(lf);
43
+ }
44
+ export function removeInstall(ref) {
45
+ const lf = loadLockfile();
46
+ lf.installs = lf.installs.filter((i) => i.ref !== ref);
47
+ saveLockfile(lf);
48
+ }
49
+ /** SHA-256 fingerprint with CRLF normalized to LF so cross-platform
50
+ * checkouts don't false-positive as conflicts. Files that look like text
51
+ * (no NUL bytes) get the normalized hash; otherwise we hash raw bytes. */
52
+ export function fingerprintFile(filePath) {
53
+ const buf = fs.readFileSync(filePath);
54
+ const hasher = createHash('sha256');
55
+ // Canonical "looks binary" check: presence of a NUL byte.
56
+ const looksBinary = buf.includes(0);
57
+ if (looksBinary) {
58
+ hasher.update(buf);
59
+ }
60
+ else {
61
+ hasher.update(buf.toString('utf-8').replace(/\r\n/g, '\n'));
62
+ }
63
+ return hasher.digest('hex');
64
+ }
65
+ /** Same fingerprint algorithm as `fingerprintFile`, but operating on an
66
+ * in-memory string. Use this when comparing downloaded content to existing
67
+ * file fingerprints WITHOUT writing to disk first. */
68
+ export function fingerprintContent(content) {
69
+ return createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex');
70
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,99 @@
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 { loadLockfile, saveLockfile, upsertInstall, removeInstall, fingerprintFile, fingerprintContent, } from './lockfile.js';
6
+ describe('lockfile', () => {
7
+ const origHome = os.homedir;
8
+ let tmp;
9
+ beforeEach(() => {
10
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lockfile-test-'));
11
+ os.homedir = () => tmp;
12
+ });
13
+ afterEach(() => {
14
+ os.homedir = origHome;
15
+ fs.rmSync(tmp, { recursive: true, force: true });
16
+ });
17
+ it('loadLockfile returns empty when file does not exist', () => {
18
+ const lf = loadLockfile();
19
+ expect(lf).toEqual({ version: 1, installs: [] });
20
+ });
21
+ it('saveLockfile writes a valid JSON file', () => {
22
+ const lf = { version: 1, installs: [{ ref: '@a/b', type: 'SKILL', version: '1.0.0', installedAt: '2026-04-29T00:00:00Z', files: [] }] };
23
+ saveLockfile(lf);
24
+ const read = loadLockfile();
25
+ expect(read.installs).toHaveLength(1);
26
+ expect(read.installs[0].ref).toBe('@a/b');
27
+ });
28
+ it('upsertInstall replaces an existing entry with the same ref', () => {
29
+ saveLockfile({
30
+ version: 1,
31
+ installs: [{ ref: '@a/b', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] }],
32
+ });
33
+ upsertInstall({
34
+ ref: '@a/b',
35
+ type: 'SKILL',
36
+ version: '2.0.0',
37
+ installedAt: 't2',
38
+ files: [],
39
+ });
40
+ const lf = loadLockfile();
41
+ expect(lf.installs).toHaveLength(1);
42
+ expect(lf.installs[0].version).toBe('2.0.0');
43
+ });
44
+ it('removeInstall removes the entry by ref', () => {
45
+ saveLockfile({
46
+ version: 1,
47
+ installs: [
48
+ { ref: '@a/b', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] },
49
+ { ref: '@a/c', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] },
50
+ ],
51
+ });
52
+ removeInstall('@a/b');
53
+ const lf = loadLockfile();
54
+ expect(lf.installs).toHaveLength(1);
55
+ expect(lf.installs[0].ref).toBe('@a/c');
56
+ });
57
+ it('fingerprintFile normalizes CRLF to LF before hashing', () => {
58
+ const lf = path.join(tmp, 'lf.txt');
59
+ const crlf = path.join(tmp, 'crlf.txt');
60
+ fs.writeFileSync(lf, 'one\ntwo\nthree\n');
61
+ fs.writeFileSync(crlf, 'one\r\ntwo\r\nthree\r\n');
62
+ expect(fingerprintFile(lf)).toBe(fingerprintFile(crlf));
63
+ });
64
+ it('fingerprintFile hashes binary files (NUL bytes) as raw bytes', () => {
65
+ const binPath = path.join(tmp, 'bin.dat');
66
+ // A buffer with a NUL byte — looks binary
67
+ fs.writeFileSync(binPath, Buffer.from([0x00, 0x01, 0xff, 0xfe, 0x80]));
68
+ // Same buffer twice should give the same fingerprint
69
+ expect(fingerprintFile(binPath)).toBe(fingerprintFile(binPath));
70
+ });
71
+ it('fingerprintFile treats files with U+FFFD as text (no false binary classification)', () => {
72
+ const txtPath = path.join(tmp, 'with-replacement.md');
73
+ // Text content that legitimately contains U+FFFD — should still be text-hashed
74
+ fs.writeFileSync(txtPath, '# Header\n\nReplacement: �\n');
75
+ const fp1 = fingerprintFile(txtPath);
76
+ // Same content with CRLF — should fingerprint identically (text path normalized)
77
+ const txtCrlfPath = path.join(tmp, 'with-replacement-crlf.md');
78
+ fs.writeFileSync(txtCrlfPath, '# Header\r\n\r\nReplacement: �\r\n');
79
+ expect(fingerprintFile(txtCrlfPath)).toBe(fp1);
80
+ });
81
+ it('fingerprintContent agrees with fingerprintFile for the same text content', () => {
82
+ const filePath = path.join(tmp, 'agree.txt');
83
+ const content = 'hello\nworld\n';
84
+ fs.writeFileSync(filePath, content);
85
+ expect(fingerprintContent(content)).toBe(fingerprintFile(filePath));
86
+ });
87
+ it('fingerprintContent normalizes CRLF the same way as fingerprintFile', () => {
88
+ const filePath = path.join(tmp, 'crlf.txt');
89
+ fs.writeFileSync(filePath, 'a\r\nb\r\n');
90
+ expect(fingerprintContent('a\nb\n')).toBe(fingerprintFile(filePath));
91
+ });
92
+ it('loadLockfile returns empty when JSON is corrupt (does not throw)', () => {
93
+ const lf = path.join(tmp, '.botdocs', 'installed.json');
94
+ fs.mkdirSync(path.dirname(lf), { recursive: true });
95
+ fs.writeFileSync(lf, '{ this is not valid JSON');
96
+ const result = loadLockfile();
97
+ expect(result).toEqual({ version: 1, installs: [] });
98
+ });
99
+ });
@@ -0,0 +1,18 @@
1
+ export type BotDocType = 'SPEC' | 'SKILL' | 'BUNDLE';
2
+ export declare class ManifestError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface SkillRef {
6
+ username: string;
7
+ slug: string;
8
+ }
9
+ export interface ParsedManifest {
10
+ type: BotDocType;
11
+ version: string;
12
+ title: string;
13
+ description: string;
14
+ sourceEcosystem?: string;
15
+ ecosystems?: string[];
16
+ skills: SkillRef[];
17
+ }
18
+ export declare function parseManifest(input: unknown): ParsedManifest;
@@ -0,0 +1,77 @@
1
+ export class ManifestError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = 'ManifestError';
5
+ }
6
+ }
7
+ const SEMVER = /^\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/;
8
+ function parseSkillRef(raw) {
9
+ const cleaned = raw.startsWith('@') ? raw.slice(1) : raw;
10
+ const parts = cleaned.split('/');
11
+ if (parts.length !== 2) {
12
+ throw new ManifestError(`Invalid skill ref: "${raw}" (expected username/slug)`);
13
+ }
14
+ const [username, slug] = parts;
15
+ if (!username || !slug) {
16
+ throw new ManifestError(`Invalid skill ref: "${raw}" (expected username/slug)`);
17
+ }
18
+ return { username, slug };
19
+ }
20
+ export function parseManifest(input) {
21
+ if (!input || typeof input !== 'object') {
22
+ throw new ManifestError('Manifest must be a JSON object');
23
+ }
24
+ const data = input;
25
+ if (data.type !== undefined && typeof data.type !== 'string') {
26
+ throw new ManifestError(`Invalid type: must be a string, got ${typeof data.type}`);
27
+ }
28
+ const rawType = data.type ?? 'spec';
29
+ const upper = rawType.toUpperCase();
30
+ if (upper !== 'SPEC' && upper !== 'SKILL' && upper !== 'BUNDLE') {
31
+ throw new ManifestError(`Invalid type: "${rawType}" (expected spec, skill, or bundle)`);
32
+ }
33
+ const type = upper;
34
+ if (data.version !== undefined && typeof data.version !== 'string') {
35
+ throw new ManifestError(`Invalid version: must be a string, got ${typeof data.version}`);
36
+ }
37
+ const version = data.version ?? '1.0.0';
38
+ if (type !== 'SPEC' && !SEMVER.test(version)) {
39
+ throw new ManifestError(`Version must be semver (e.g. 1.0.0), got: "${version}"`);
40
+ }
41
+ if (typeof data.title !== 'string' || typeof data.description !== 'string' || !data.title || !data.description) {
42
+ throw new ManifestError('title and description are required');
43
+ }
44
+ const title = data.title;
45
+ const description = data.description;
46
+ let skills = [];
47
+ if (type === 'BUNDLE') {
48
+ if (!Array.isArray(data.skills)) {
49
+ throw new ManifestError('Bundle manifest must include a `skills` array');
50
+ }
51
+ skills = data.skills.map((entry) => {
52
+ if (typeof entry !== 'string') {
53
+ throw new ManifestError(`Bundle skills entries must be strings, got: ${JSON.stringify(entry)}`);
54
+ }
55
+ return parseSkillRef(entry);
56
+ });
57
+ }
58
+ let sourceEcosystem;
59
+ if (type === 'SKILL' && data.sourceEcosystem !== undefined) {
60
+ if (typeof data.sourceEcosystem !== 'string') {
61
+ throw new ManifestError(`Invalid sourceEcosystem: must be a string, got ${typeof data.sourceEcosystem}`);
62
+ }
63
+ sourceEcosystem = data.sourceEcosystem;
64
+ }
65
+ let ecosystems;
66
+ if (data.ecosystems !== undefined) {
67
+ if (!Array.isArray(data.ecosystems)) {
68
+ throw new ManifestError('ecosystems must be an array of strings');
69
+ }
70
+ ecosystems = data.ecosystems.map((e, i) => {
71
+ if (typeof e !== 'string')
72
+ throw new ManifestError(`ecosystems[${i}] must be a string`);
73
+ return e;
74
+ });
75
+ }
76
+ return { type, version, title, description, sourceEcosystem, ecosystems, skills };
77
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseManifest, ManifestError } from './manifest.js';
3
+ describe('parseManifest', () => {
4
+ it('parses a SKILL manifest', () => {
5
+ const m = parseManifest({
6
+ type: 'skill',
7
+ version: '1.0.0',
8
+ title: 'Code Review',
9
+ description: 'Reviews PRs',
10
+ sourceEcosystem: 'claude-code',
11
+ });
12
+ expect(m.type).toBe('SKILL');
13
+ expect(m.version).toBe('1.0.0');
14
+ expect(m.sourceEcosystem).toBe('claude-code');
15
+ });
16
+ it('parses a BUNDLE manifest with refs', () => {
17
+ const m = parseManifest({
18
+ type: 'bundle',
19
+ version: '1.0.0',
20
+ title: 'Eng Skills',
21
+ description: 'All engineering skills',
22
+ skills: ['@alice/code-review', '@alice/pr-summary'],
23
+ });
24
+ expect(m.type).toBe('BUNDLE');
25
+ expect(m.skills).toEqual([
26
+ { username: 'alice', slug: 'code-review' },
27
+ { username: 'alice', slug: 'pr-summary' },
28
+ ]);
29
+ });
30
+ it('defaults type to SPEC when missing', () => {
31
+ const m = parseManifest({ title: 't', description: 'd' });
32
+ expect(m.type).toBe('SPEC');
33
+ });
34
+ it('throws on non-semver version', () => {
35
+ expect(() => parseManifest({ type: 'skill', version: 'latest', title: 't', description: 'd' })).toThrow(ManifestError);
36
+ });
37
+ it('throws when bundle skills entry is malformed', () => {
38
+ expect(() => parseManifest({ type: 'bundle', version: '1.0.0', title: 't', description: 'd', skills: ['not-a-ref'] })).toThrow(ManifestError);
39
+ });
40
+ it('accepts skills with leading @', () => {
41
+ const m = parseManifest({
42
+ type: 'bundle',
43
+ version: '1.0.0',
44
+ title: 't',
45
+ description: 'd',
46
+ skills: ['@alice/x', 'bob/y'],
47
+ });
48
+ expect(m.skills).toHaveLength(2);
49
+ });
50
+ it('throws on non-object input', () => {
51
+ expect(() => parseManifest(null)).toThrow(ManifestError);
52
+ expect(() => parseManifest('string')).toThrow(ManifestError);
53
+ expect(() => parseManifest(42)).toThrow(ManifestError);
54
+ });
55
+ it('throws on non-string type field', () => {
56
+ expect(() => parseManifest({ type: 42, title: 't', description: 'd' })).toThrow(ManifestError);
57
+ });
58
+ it('throws when bundle is missing skills array', () => {
59
+ expect(() => parseManifest({ type: 'bundle', version: '1.0.0', title: 't', description: 'd' })).toThrow(ManifestError);
60
+ });
61
+ it('parses ecosystems[] field on a SKILL manifest', () => {
62
+ const m = parseManifest({
63
+ type: 'skill',
64
+ version: '1.0.0',
65
+ title: 'X',
66
+ description: 'Y',
67
+ sourceEcosystem: 'claude-code',
68
+ ecosystems: ['claude', 'claude-code', 'cursor'],
69
+ });
70
+ expect(m.ecosystems).toEqual(['claude', 'claude-code', 'cursor']);
71
+ });
72
+ });
@@ -0,0 +1,5 @@
1
+ export type CleanUpdateChoice = 'apply' | 'skip' | 'diff';
2
+ export declare function promptCleanUpdate(label: string): Promise<CleanUpdateChoice>;
3
+ export type ConflictChoice = 'skip' | 'overwrite';
4
+ export declare function promptConflict(label: string): Promise<ConflictChoice>;
5
+ export declare function confirmOverwrite(label: string): Promise<boolean>;
@@ -0,0 +1,26 @@
1
+ import { confirm, select } from '@inquirer/prompts';
2
+ export async function promptCleanUpdate(label) {
3
+ return select({
4
+ message: `Apply update for ${label}?`,
5
+ choices: [
6
+ { name: 'apply', value: 'apply' },
7
+ { name: 'skip', value: 'skip' },
8
+ { name: 'diff (show before deciding)', value: 'diff' },
9
+ ],
10
+ });
11
+ }
12
+ export async function promptConflict(label) {
13
+ return select({
14
+ message: `${label}: your local copy differs from upstream`,
15
+ choices: [
16
+ { name: 'skip this update (keep your local)', value: 'skip' },
17
+ { name: 'overwrite local with upstream', value: 'overwrite' },
18
+ ],
19
+ });
20
+ }
21
+ export async function confirmOverwrite(label) {
22
+ return confirm({
23
+ message: `${label}: this will REPLACE your local edits with the upstream version. Are you sure?`,
24
+ default: false,
25
+ });
26
+ }
@@ -0,0 +1,12 @@
1
+ export declare const BEGIN = "# BEGIN @botdocs/cli";
2
+ export declare const END = "# END @botdocs/cli";
3
+ export type Shell = 'zsh' | 'bash' | 'fish';
4
+ export declare function detectShell(env?: NodeJS.ProcessEnv): Shell | null;
5
+ export declare function installShellHook(shell: Shell): {
6
+ action: 'created' | 'updated';
7
+ path: string;
8
+ };
9
+ export declare function uninstallShellHook(shell: Shell): {
10
+ removed: boolean;
11
+ path: string;
12
+ };
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ export const BEGIN = '# BEGIN @botdocs/cli';
5
+ export const END = '# END @botdocs/cli';
6
+ export function detectShell(env = process.env) {
7
+ // Prefer the login shell from $SHELL — that's what the user actually uses.
8
+ // ZSH_VERSION / BASH only fire when invoked FROM that shell, so they're
9
+ // less reliable when users run this command via bash -c or similar.
10
+ const shell = env.SHELL ?? '';
11
+ if (shell.endsWith('zsh'))
12
+ return 'zsh';
13
+ if (shell.endsWith('bash'))
14
+ return 'bash';
15
+ if (shell.endsWith('fish'))
16
+ return 'fish';
17
+ if (env.ZSH_VERSION)
18
+ return 'zsh';
19
+ if (env.BASH || env.BASH_VERSION)
20
+ return 'bash';
21
+ return null;
22
+ }
23
+ function rcPath(shell) {
24
+ const home = os.homedir();
25
+ if (shell === 'zsh')
26
+ return path.join(home, '.zshrc');
27
+ if (shell === 'bash')
28
+ return path.join(home, '.bashrc');
29
+ return path.join(home, '.config', 'fish', 'config.fish');
30
+ }
31
+ function snippet(shell) {
32
+ if (shell === 'fish') {
33
+ return [
34
+ BEGIN,
35
+ 'if type -q botdocs',
36
+ ' botdocs check-updates --quiet 2>/dev/null',
37
+ 'end',
38
+ END,
39
+ ].join('\n');
40
+ }
41
+ return [
42
+ BEGIN,
43
+ 'if command -v botdocs >/dev/null 2>&1; then',
44
+ ' botdocs check-updates --quiet 2>/dev/null',
45
+ 'fi',
46
+ END,
47
+ ].join('\n');
48
+ }
49
+ function escapeRegExp(s) {
50
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
51
+ }
52
+ export function installShellHook(shell) {
53
+ const filePath = rcPath(shell);
54
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
55
+ const block = snippet(shell);
56
+ if (!fs.existsSync(filePath)) {
57
+ fs.writeFileSync(filePath, `${block}\n`, 'utf-8');
58
+ return { action: 'created', path: filePath };
59
+ }
60
+ const existing = fs.readFileSync(filePath, 'utf-8');
61
+ const re = new RegExp(`${escapeRegExp(BEGIN)}[\\s\\S]*?${escapeRegExp(END)}\\n?`);
62
+ if (re.test(existing)) {
63
+ fs.writeFileSync(filePath, existing.replace(re, `${block}\n`), 'utf-8');
64
+ return { action: 'updated', path: filePath };
65
+ }
66
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
67
+ fs.writeFileSync(filePath, existing + sep + block + '\n', 'utf-8');
68
+ return { action: 'updated', path: filePath };
69
+ }
70
+ export function uninstallShellHook(shell) {
71
+ const filePath = rcPath(shell);
72
+ if (!fs.existsSync(filePath))
73
+ return { removed: false, path: filePath };
74
+ const existing = fs.readFileSync(filePath, 'utf-8');
75
+ const re = new RegExp(`${escapeRegExp(BEGIN)}[\\s\\S]*?${escapeRegExp(END)}\\n?`);
76
+ if (!re.test(existing))
77
+ return { removed: false, path: filePath };
78
+ fs.writeFileSync(filePath, existing.replace(re, ''), 'utf-8');
79
+ return { removed: true, path: filePath };
80
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
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 { detectShell, installShellHook, uninstallShellHook, BEGIN, END } from './shell-hook.js';
6
+ describe('shell-hook', () => {
7
+ const origHome = os.homedir;
8
+ let homeTmp;
9
+ beforeEach(() => {
10
+ homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sh-'));
11
+ os.homedir = () => homeTmp;
12
+ });
13
+ afterEach(() => {
14
+ fs.rmSync(homeTmp, { recursive: true, force: true });
15
+ os.homedir = origHome;
16
+ });
17
+ it('detectShell returns zsh for ZSH_VERSION env', () => {
18
+ expect(detectShell({ ZSH_VERSION: '5.9' })).toBe('zsh');
19
+ });
20
+ it('detectShell returns bash from BASH env', () => {
21
+ expect(detectShell({ BASH: '/bin/bash' })).toBe('bash');
22
+ });
23
+ it('detectShell falls back to SHELL env', () => {
24
+ expect(detectShell({ SHELL: '/usr/bin/fish' })).toBe('fish');
25
+ expect(detectShell({ SHELL: '/bin/zsh' })).toBe('zsh');
26
+ });
27
+ it('detectShell returns null for unknown shells', () => {
28
+ expect(detectShell({ SHELL: '/bin/csh' })).toBeNull();
29
+ });
30
+ it('detectShell prefers $SHELL over BASH env var (handles bash -c from zsh)', () => {
31
+ // User's login shell is zsh; they ran the command via `bash -c botdocs ...`
32
+ // which exports BASH=/bin/bash. We want zsh, not bash.
33
+ expect(detectShell({ SHELL: '/bin/zsh', BASH: '/bin/bash' })).toBe('zsh');
34
+ });
35
+ it('installShellHook creates a delimited block in .zshrc', () => {
36
+ const result = installShellHook('zsh');
37
+ const rc = fs.readFileSync(path.join(homeTmp, '.zshrc'), 'utf-8');
38
+ expect(rc).toContain(BEGIN);
39
+ expect(rc).toContain(END);
40
+ expect(rc).toContain('botdocs check-updates --quiet');
41
+ expect(result.action).toBe('created');
42
+ });
43
+ it('installShellHook is idempotent on re-run', () => {
44
+ installShellHook('zsh');
45
+ const r2 = installShellHook('zsh');
46
+ const rc = fs.readFileSync(path.join(homeTmp, '.zshrc'), 'utf-8');
47
+ expect(rc.match(new RegExp(BEGIN.replace(/[/\\$.*+?()[\]{}|]/g, '\\$&'), 'g'))).toHaveLength(1);
48
+ expect(r2.action).toBe('updated');
49
+ });
50
+ it('installShellHook preserves user content outside the markers', () => {
51
+ fs.writeFileSync(path.join(homeTmp, '.bashrc'), 'export EDITOR=vim\n', 'utf-8');
52
+ installShellHook('bash');
53
+ const rc = fs.readFileSync(path.join(homeTmp, '.bashrc'), 'utf-8');
54
+ expect(rc).toContain('export EDITOR=vim');
55
+ expect(rc).toContain(BEGIN);
56
+ });
57
+ it('uninstallShellHook removes the block', () => {
58
+ installShellHook('zsh');
59
+ uninstallShellHook('zsh');
60
+ const rc = fs.readFileSync(path.join(homeTmp, '.zshrc'), 'utf-8');
61
+ expect(rc).not.toContain(BEGIN);
62
+ });
63
+ it('writes the fish-syntax variant for fish', () => {
64
+ installShellHook('fish');
65
+ const rc = fs.readFileSync(path.join(homeTmp, '.config', 'fish', 'config.fish'), 'utf-8');
66
+ expect(rc).toContain('botdocs check-updates --quiet');
67
+ });
68
+ });