@vibedx/vibekit 0.9.0 → 0.11.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 +37 -0
- package/assets/config.yml +10 -0
- package/assets/docs/code-doc.md +39 -0
- package/assets/docs/default.md +35 -0
- package/assets/docs/design-doc.md +43 -0
- package/assets/docs/faq.md +33 -0
- package/index.js +1 -1
- package/package.json +3 -2
- package/src/commands/docs/add.js +162 -0
- package/src/commands/docs/index.js +47 -0
- package/src/commands/docs/index.test.js +130 -0
- package/src/commands/docs/list.js +137 -0
- package/src/commands/docs/refine.js +138 -0
- package/src/commands/docs/show.js +28 -0
- package/src/commands/init/index.js +13 -1
- package/src/utils/doc.js +137 -0
- package/src/utils/doc.test.js +112 -0
- package/vibekit-plugin/.claude-plugin/plugin.json +20 -0
- package/vibekit-plugin/README.md +57 -0
- package/vibekit-plugin/agents/reviewer.md +47 -0
- package/vibekit-plugin/agents/ticket-worker.md +47 -0
- package/vibekit-plugin/hooks/detect-vibe.sh +15 -0
- package/vibekit-plugin/hooks/hooks.json +17 -0
- package/vibekit-plugin/settings.json +9 -0
- package/vibekit-plugin/skills/ticket-writer/SKILL.md +80 -0
- package/vibekit-plugin/skills/vibekit-workflow/SKILL.md +319 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { getDocsDir } from '../../utils/doc.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse `vibe docs list` filter flags.
|
|
8
|
+
* @param {string[]} args
|
|
9
|
+
* @returns {{type: string|null, status: string|null, tag: string|null}}
|
|
10
|
+
*/
|
|
11
|
+
function parseFilters(args) {
|
|
12
|
+
let type = null;
|
|
13
|
+
let status = null;
|
|
14
|
+
let tag = null;
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < args.length; i++) {
|
|
17
|
+
const arg = args[i];
|
|
18
|
+
if (arg === '--type' && i + 1 < args.length) {
|
|
19
|
+
type = args[++i];
|
|
20
|
+
} else if (arg === '--status' && i + 1 < args.length) {
|
|
21
|
+
status = args[++i];
|
|
22
|
+
} else if (arg === '--tag' && i + 1 < args.length) {
|
|
23
|
+
tag = args[++i];
|
|
24
|
+
} else if (arg.startsWith('--type=')) {
|
|
25
|
+
type = arg.split('=')[1];
|
|
26
|
+
} else if (arg.startsWith('--status=')) {
|
|
27
|
+
status = arg.split('=')[1];
|
|
28
|
+
} else if (arg.startsWith('--tag=')) {
|
|
29
|
+
tag = arg.split('=')[1];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { type, status, tag };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeTags(raw) {
|
|
37
|
+
if (Array.isArray(raw)) return raw.map(String);
|
|
38
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
39
|
+
return raw.replace(/[\[\]]/g, '').split(',').map(t => t.trim()).filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `vibe docs list` — render a filtered table of docs.
|
|
46
|
+
* @param {string[]} args
|
|
47
|
+
*/
|
|
48
|
+
function listDocs(args) {
|
|
49
|
+
const { type, status, tag } = parseFilters(args);
|
|
50
|
+
const docsDir = getDocsDir();
|
|
51
|
+
|
|
52
|
+
if (!fs.existsSync(docsDir)) {
|
|
53
|
+
console.log('No docs found. Create one with: vibe docs add "title"');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const files = fs.readdirSync(docsDir).filter(f => f.endsWith('.md'));
|
|
58
|
+
|
|
59
|
+
if (files.length === 0) {
|
|
60
|
+
console.log('No docs found. Create one with: vibe docs add "title"');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let docs = [];
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
try {
|
|
67
|
+
const content = fs.readFileSync(path.join(docsDir, file), 'utf-8');
|
|
68
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
69
|
+
if (match) {
|
|
70
|
+
const fm = yaml.load(match[1]) || {};
|
|
71
|
+
docs.push({
|
|
72
|
+
id: fm.id || 'Unknown',
|
|
73
|
+
title: fm.title || 'Untitled',
|
|
74
|
+
type: fm.type || 'guide',
|
|
75
|
+
status: fm.status || 'draft',
|
|
76
|
+
tags: normalizeTags(fm.tags),
|
|
77
|
+
file
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
console.warn(`⚠️ Could not parse doc: ${file}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (type) docs = docs.filter(d => d.type === type);
|
|
86
|
+
if (status) docs = docs.filter(d => d.status === status);
|
|
87
|
+
if (tag) docs = docs.filter(d => d.tags.includes(tag));
|
|
88
|
+
|
|
89
|
+
docs.sort((a, b) => {
|
|
90
|
+
const na = parseInt(a.id.replace(/\D/g, ''), 10) || 0;
|
|
91
|
+
const nb = parseInt(b.id.replace(/\D/g, ''), 10) || 0;
|
|
92
|
+
return na - nb;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const filters = [
|
|
96
|
+
type ? `type: ${type}` : '',
|
|
97
|
+
status ? `status: ${status}` : '',
|
|
98
|
+
tag ? `tag: ${tag}` : ''
|
|
99
|
+
].filter(Boolean).join(', ');
|
|
100
|
+
|
|
101
|
+
if (docs.length === 0) {
|
|
102
|
+
console.log(filters ? `No docs found (${filters}).` : 'No docs found.');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log('\n📚 VibeKit Docs 📚\n');
|
|
107
|
+
|
|
108
|
+
const idWidth = 10;
|
|
109
|
+
const typeWidth = 12;
|
|
110
|
+
const statusWidth = 11;
|
|
111
|
+
const titleWidth = 40;
|
|
112
|
+
|
|
113
|
+
console.log(
|
|
114
|
+
`${'ID'.padEnd(idWidth)}| ${'TYPE'.padEnd(typeWidth)}| ${'STATUS'.padEnd(statusWidth)}| TITLE`
|
|
115
|
+
);
|
|
116
|
+
console.log(`${'-'.repeat(idWidth)}+${'-'.repeat(typeWidth + 1)}+${'-'.repeat(statusWidth + 1)}+${'-'.repeat(titleWidth)}`);
|
|
117
|
+
|
|
118
|
+
for (const doc of docs) {
|
|
119
|
+
let statusColor = '\x1b[0m';
|
|
120
|
+
if (doc.status === 'published') statusColor = '\x1b[32m';
|
|
121
|
+
else if (doc.status === 'review') statusColor = '\x1b[36m';
|
|
122
|
+
else if (doc.status === 'draft') statusColor = '\x1b[33m';
|
|
123
|
+
|
|
124
|
+
const title = doc.title.length > titleWidth - 3
|
|
125
|
+
? doc.title.substring(0, titleWidth - 3) + '...'
|
|
126
|
+
: doc.title;
|
|
127
|
+
|
|
128
|
+
console.log(
|
|
129
|
+
`${doc.id.padEnd(idWidth)}| ${doc.type.padEnd(typeWidth)}| ${statusColor}${doc.status.padEnd(statusWidth)}\x1b[0m| ${title}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
console.log(`${'-'.repeat(idWidth)}+${'-'.repeat(typeWidth + 1)}+${'-'.repeat(statusWidth + 1)}+${'-'.repeat(titleWidth)}`);
|
|
134
|
+
console.log(`Found ${docs.length} doc(s)${filters ? ` (${filters})` : ''}.\n`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default listDocs;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import { getConfig } from '../../utils/index.js';
|
|
4
|
+
import { resolveDocId, parseDoc } from '../../utils/doc.js';
|
|
5
|
+
import { logger, spinner } from '../../utils/cli.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Send a prompt to Claude via the native CLI and return the text result.
|
|
9
|
+
* @param {string} prompt
|
|
10
|
+
* @returns {Promise<string>}
|
|
11
|
+
*/
|
|
12
|
+
function executeClaude(prompt) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const env = { ...process.env };
|
|
15
|
+
delete env.ANTHROPIC_API_KEY;
|
|
16
|
+
|
|
17
|
+
const child = spawn('claude', ['--print', '--output-format', 'json'], {
|
|
18
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
19
|
+
env
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let stdout = '';
|
|
23
|
+
let stderr = '';
|
|
24
|
+
child.stdout.on('data', (c) => { stdout += c; });
|
|
25
|
+
child.stderr.on('data', (c) => { stderr += c; });
|
|
26
|
+
child.on('error', (err) => reject(new Error(`Failed to spawn Claude: ${err.message}`)));
|
|
27
|
+
child.on('close', (code) => {
|
|
28
|
+
if (!stdout.trim()) {
|
|
29
|
+
reject(new Error(stderr.trim() || `Claude exited with code ${code}`));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const envelope = JSON.parse(stdout.trim());
|
|
34
|
+
if (envelope.is_error) {
|
|
35
|
+
reject(new Error(envelope.result || 'Claude reported an error'));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const text = (envelope.result ?? '').trim();
|
|
39
|
+
if (!text) {
|
|
40
|
+
reject(new Error('Claude returned an empty result'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
resolve(text);
|
|
44
|
+
} catch {
|
|
45
|
+
reject(new Error('Claude returned non-JSON output'));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
child.stdin.write(prompt, 'utf8');
|
|
50
|
+
child.stdin.end();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Strip a leading markdown code fence if Claude wrapped the body.
|
|
56
|
+
* @param {string} text
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function stripCodeFence(text) {
|
|
60
|
+
const match = text.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/);
|
|
61
|
+
return match ? match[1] : text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildPrompt(metadata, body) {
|
|
65
|
+
const title = metadata.title || 'Untitled';
|
|
66
|
+
const type = metadata.type || 'guide';
|
|
67
|
+
return `You are a senior technical writer improving a project document.
|
|
68
|
+
|
|
69
|
+
Document title: ${title}
|
|
70
|
+
Document type: ${type}
|
|
71
|
+
|
|
72
|
+
Current body (markdown, everything after the YAML frontmatter):
|
|
73
|
+
---
|
|
74
|
+
${body}
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
INSTRUCTIONS:
|
|
78
|
+
1. Improve and expand the document body. Fill in empty sections and replace HTML comment placeholders (<!-- ... -->) with real, useful content inferred from the title, type, and surrounding context.
|
|
79
|
+
2. Keep the existing section headings (## ...). You may add sub-sections where helpful.
|
|
80
|
+
3. Use developer-friendly formatting: \`code\`, code blocks, file paths, and lists.
|
|
81
|
+
4. Respond with ONLY the improved markdown body — no frontmatter, no explanation, no surrounding code fences.`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `vibe docs refine <id>` — AI pass that improves and writes back the doc body.
|
|
86
|
+
* @param {string[]} args
|
|
87
|
+
*/
|
|
88
|
+
async function refineDoc(args) {
|
|
89
|
+
try {
|
|
90
|
+
const input = args[0];
|
|
91
|
+
if (!input) {
|
|
92
|
+
throw new Error('Please provide a doc ID. Usage: vibe docs refine <id>');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const config = getConfig();
|
|
96
|
+
if (!config.ai?.enabled) {
|
|
97
|
+
throw new Error('AI is not enabled. Run "vibe link" first.');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const docInfo = resolveDocId(input);
|
|
101
|
+
if (!docInfo) {
|
|
102
|
+
throw new Error(`Doc ${input} not found. Use "vibe docs list" to see available docs.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const doc = parseDoc(docInfo.path);
|
|
106
|
+
logger.info(`Refining ${docInfo.id} — ${doc.metadata.title || 'Untitled'}`);
|
|
107
|
+
|
|
108
|
+
const body = doc.contentLines.join('\n').trim();
|
|
109
|
+
const prompt = buildPrompt(doc.metadata, body);
|
|
110
|
+
|
|
111
|
+
const loading = spinner('🤖 Refining document...');
|
|
112
|
+
let result;
|
|
113
|
+
try {
|
|
114
|
+
result = await executeClaude(prompt);
|
|
115
|
+
loading.succeed('Refinement complete!');
|
|
116
|
+
} catch (error) {
|
|
117
|
+
loading.fail('Refinement failed');
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const newBody = stripCodeFence(result).trim();
|
|
122
|
+
|
|
123
|
+
const timestamp = new Date().toISOString().split('T')[0];
|
|
124
|
+
const yamlLines = doc.yamlLines.map(line =>
|
|
125
|
+
line.startsWith('updated_at:') ? `updated_at: ${timestamp}` : line
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const reconstructed = ['---', ...yamlLines, '---', '', newBody, ''].join('\n');
|
|
129
|
+
fs.writeFileSync(docInfo.path, reconstructed, 'utf-8');
|
|
130
|
+
|
|
131
|
+
logger.success(`${docInfo.id} refined and updated.`);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logger.error(error.message);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default refineDoc;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { resolveDocId } from '../../utils/doc.js';
|
|
3
|
+
import { logger } from '../../utils/cli.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `vibe docs show <id>` — print a doc to stdout.
|
|
7
|
+
* @param {string[]} args
|
|
8
|
+
*/
|
|
9
|
+
function showDoc(args) {
|
|
10
|
+
try {
|
|
11
|
+
const input = args[0];
|
|
12
|
+
if (!input) {
|
|
13
|
+
throw new Error('Please provide a doc ID. Usage: vibe docs show <id>');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const docInfo = resolveDocId(input);
|
|
17
|
+
if (!docInfo) {
|
|
18
|
+
throw new Error(`Doc ${input} not found. Use "vibe docs list" to see available docs.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log(fs.readFileSync(docInfo.path, 'utf-8'));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
logger.error(error.message);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default showDoc;
|
|
@@ -106,13 +106,25 @@ async function initCommand(args) {
|
|
|
106
106
|
fs.mkdirSync(targetFolder, { recursive: true });
|
|
107
107
|
fs.mkdirSync(path.join(targetFolder, 'tickets'), { recursive: true });
|
|
108
108
|
fs.mkdirSync(path.join(targetFolder, 'plans'), { recursive: true });
|
|
109
|
+
fs.mkdirSync(path.join(targetFolder, 'docs'), { recursive: true });
|
|
109
110
|
fs.mkdirSync(path.join(targetFolder, '.templates'), { recursive: true });
|
|
111
|
+
fs.mkdirSync(path.join(targetFolder, '.templates', 'docs'), { recursive: true });
|
|
110
112
|
|
|
111
113
|
fs.copyFileSync(configSrc, path.join(targetFolder, 'config.yml'));
|
|
112
114
|
fs.copyFileSync(templateSrc, path.join(targetFolder, '.templates', 'default.md'));
|
|
113
115
|
fs.copyFileSync(teamSrc, path.join(targetFolder, 'team.yml'));
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
const docsTemplatesSrc = path.join(__dirname, '../../../assets', 'docs');
|
|
118
|
+
if (fs.existsSync(docsTemplatesSrc)) {
|
|
119
|
+
for (const file of fs.readdirSync(docsTemplatesSrc)) {
|
|
120
|
+
fs.copyFileSync(
|
|
121
|
+
path.join(docsTemplatesSrc, file),
|
|
122
|
+
path.join(targetFolder, '.templates', 'docs', file)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`✅ '${targetFolder}' initialized with config, tickets/, plans/, docs/, and .templates/`);
|
|
116
128
|
} else {
|
|
117
129
|
console.log(`⚠️ Folder '${targetFolder}' already exists. Skipping .vibe creation.`);
|
|
118
130
|
}
|
package/src/utils/doc.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { getProjectRoot } from './index.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DOCS_PATH = '.vibe/docs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the docs directory from config or default.
|
|
10
|
+
* @returns {string} Absolute path to the docs directory
|
|
11
|
+
*/
|
|
12
|
+
export function getDocsDir() {
|
|
13
|
+
const root = getProjectRoot();
|
|
14
|
+
const configPath = path.join(root, '.vibe', 'config.yml');
|
|
15
|
+
let docsPath = DEFAULT_DOCS_PATH;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(configPath)) {
|
|
19
|
+
const config = yaml.load(fs.readFileSync(configPath, 'utf-8')) || {};
|
|
20
|
+
if (config.docs?.path && typeof config.docs.path === 'string') {
|
|
21
|
+
docsPath = config.docs.path;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error(`❌ Error reading config: ${error.message}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return path.resolve(root, docsPath);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generate the next doc ID (DOC-NNN), independent from ticket IDs.
|
|
33
|
+
* @returns {string} Next doc ID, e.g. "DOC-002"
|
|
34
|
+
*/
|
|
35
|
+
export function getNextDocId() {
|
|
36
|
+
const docsDir = getDocsDir();
|
|
37
|
+
|
|
38
|
+
if (!fs.existsSync(docsDir)) {
|
|
39
|
+
return 'DOC-001';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const numbers = fs.readdirSync(docsDir)
|
|
44
|
+
.filter(f => f.endsWith('.md'))
|
|
45
|
+
.map(f => f.match(/^DOC-(\d+)/))
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.map(match => parseInt(match[1], 10))
|
|
48
|
+
.filter(num => !isNaN(num) && num > 0);
|
|
49
|
+
|
|
50
|
+
const next = Math.max(0, ...numbers) + 1;
|
|
51
|
+
return `DOC-${String(next).padStart(3, '0')}`;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error(`❌ Error scanning docs directory: ${error.message}`);
|
|
54
|
+
return 'DOC-001';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a doc identifier (1, 001, DOC-1, DOC-001) to its file.
|
|
60
|
+
* @param {string|number} input - The doc identifier
|
|
61
|
+
* @returns {Object|null} { id, file, path } or null if not found
|
|
62
|
+
* @throws {Error} If the input format is invalid
|
|
63
|
+
*/
|
|
64
|
+
export function resolveDocId(input) {
|
|
65
|
+
if (!input && input !== 0) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const docsDir = getDocsDir();
|
|
70
|
+
|
|
71
|
+
if (!fs.existsSync(docsDir)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const files = fs.readdirSync(docsDir).filter(f => f.endsWith('.md'));
|
|
76
|
+
|
|
77
|
+
let clean = input.toString().trim().toUpperCase();
|
|
78
|
+
if (clean.startsWith('DOC-')) {
|
|
79
|
+
clean = clean.replace('DOC-', '');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!/^\d+$/.test(clean)) {
|
|
83
|
+
throw new Error(`Invalid doc ID format: ${input}. Expected numeric ID or DOC-XXX format.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const fullId = `DOC-${clean.padStart(3, '0')}`;
|
|
87
|
+
const matchingFile = files.find(file => file.startsWith(fullId));
|
|
88
|
+
|
|
89
|
+
if (matchingFile) {
|
|
90
|
+
return {
|
|
91
|
+
id: fullId,
|
|
92
|
+
file: matchingFile,
|
|
93
|
+
path: path.join(docsDir, matchingFile)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse a doc markdown file, splitting frontmatter and content.
|
|
102
|
+
* @param {string} filePath - Path to the doc file
|
|
103
|
+
* @returns {Object} { metadata, yamlLines, contentLines, fullContent, filePath }
|
|
104
|
+
* @throws {Error} If the file is missing or malformed
|
|
105
|
+
*/
|
|
106
|
+
export function parseDoc(filePath) {
|
|
107
|
+
if (typeof filePath !== 'string') {
|
|
108
|
+
throw new Error('File path must be a string');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!fs.existsSync(filePath)) {
|
|
112
|
+
throw new Error(`Doc file not found: ${filePath}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
116
|
+
|
|
117
|
+
if (!content.trim()) {
|
|
118
|
+
throw new Error('Doc file is empty');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const lines = content.split('\n');
|
|
122
|
+
|
|
123
|
+
if (lines[0] !== '---') {
|
|
124
|
+
throw new Error('Invalid doc format: missing opening frontmatter delimiter (---)');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const yamlEndIndex = lines.findIndex((line, index) => index > 0 && line === '---');
|
|
128
|
+
if (yamlEndIndex === -1) {
|
|
129
|
+
throw new Error('Invalid doc format: missing closing frontmatter delimiter (---)');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const yamlLines = lines.slice(1, yamlEndIndex);
|
|
133
|
+
const contentLines = lines.slice(yamlEndIndex + 1);
|
|
134
|
+
const metadata = yaml.load(yamlLines.join('\n')) || {};
|
|
135
|
+
|
|
136
|
+
return { metadata, yamlLines, contentLines, fullContent: content, filePath };
|
|
137
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
createTempDir,
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
mockConsole,
|
|
8
|
+
mockProcessCwd,
|
|
9
|
+
createMockVibeProject
|
|
10
|
+
} from './test-helpers.js';
|
|
11
|
+
import { getNextDocId, resolveDocId, parseDoc, getDocsDir } from './doc.js';
|
|
12
|
+
|
|
13
|
+
function writeDoc(docsDir, id, slug, extra = '') {
|
|
14
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
15
|
+
const content = `---
|
|
16
|
+
id: ${id}
|
|
17
|
+
title: ${slug}
|
|
18
|
+
type: guide
|
|
19
|
+
status: draft
|
|
20
|
+
tags: []
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Overview
|
|
24
|
+
${extra}
|
|
25
|
+
`;
|
|
26
|
+
fs.writeFileSync(path.join(docsDir, `${id}-${slug}.md`), content, 'utf-8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('doc utils', () => {
|
|
30
|
+
let tempDir;
|
|
31
|
+
let consoleMock;
|
|
32
|
+
let restoreCwd;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
tempDir = createTempDir('doc-util-test');
|
|
36
|
+
consoleMock = mockConsole();
|
|
37
|
+
restoreCwd = mockProcessCwd(tempDir);
|
|
38
|
+
createMockVibeProject(tempDir);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
consoleMock.restore();
|
|
43
|
+
restoreCwd();
|
|
44
|
+
cleanupTempDir(tempDir);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('getDocsDir', () => {
|
|
48
|
+
it('defaults to .vibe/docs', () => {
|
|
49
|
+
expect(getDocsDir()).toBe(path.join(tempDir, '.vibe', 'docs'));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('getNextDocId', () => {
|
|
54
|
+
it('returns DOC-001 when no docs exist', () => {
|
|
55
|
+
expect(getNextDocId()).toBe('DOC-001');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('increments based on existing docs', () => {
|
|
59
|
+
const docsDir = path.join(tempDir, '.vibe', 'docs');
|
|
60
|
+
writeDoc(docsDir, 'DOC-001', 'first');
|
|
61
|
+
writeDoc(docsDir, 'DOC-002', 'second');
|
|
62
|
+
expect(getNextDocId()).toBe('DOC-003');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('is independent from ticket IDs', () => {
|
|
66
|
+
const ticketsDir = path.join(tempDir, '.vibe', 'tickets');
|
|
67
|
+
fs.writeFileSync(path.join(ticketsDir, 'TKT-042-x.md'), '---\nid: TKT-042\n---\n');
|
|
68
|
+
expect(getNextDocId()).toBe('DOC-001');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('resolveDocId', () => {
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
writeDoc(path.join(tempDir, '.vibe', 'docs'), 'DOC-001', 'first');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('resolves a bare number', () => {
|
|
78
|
+
expect(resolveDocId('1').id).toBe('DOC-001');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('resolves a full DOC- id', () => {
|
|
82
|
+
expect(resolveDocId('DOC-001').id).toBe('DOC-001');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns null for a missing doc', () => {
|
|
86
|
+
expect(resolveDocId('999')).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('throws on invalid format', () => {
|
|
90
|
+
expect(() => resolveDocId('abc')).toThrow(/Invalid doc ID/);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('parseDoc', () => {
|
|
95
|
+
it('splits frontmatter and content', () => {
|
|
96
|
+
const docsDir = path.join(tempDir, '.vibe', 'docs');
|
|
97
|
+
writeDoc(docsDir, 'DOC-001', 'first', 'Body text');
|
|
98
|
+
const parsed = parseDoc(path.join(docsDir, 'DOC-001-first.md'));
|
|
99
|
+
expect(parsed.metadata.id).toBe('DOC-001');
|
|
100
|
+
expect(parsed.metadata.type).toBe('guide');
|
|
101
|
+
expect(parsed.contentLines.join('\n')).toContain('## Overview');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('throws on missing frontmatter', () => {
|
|
105
|
+
const docsDir = path.join(tempDir, '.vibe', 'docs');
|
|
106
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
107
|
+
const p = path.join(docsDir, 'DOC-009-bad.md');
|
|
108
|
+
fs.writeFileSync(p, 'no frontmatter here', 'utf-8');
|
|
109
|
+
expect(() => parseDoc(p)).toThrow(/frontmatter/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vibekit",
|
|
3
|
+
"displayName": "VibeKit — Ticket-Driven Development",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Structured ticket workflows, agent orchestration, and worktree isolation for AI-assisted development",
|
|
6
|
+
"author": "vibedx",
|
|
7
|
+
"repository": "https://github.com/vibedx/vibekit",
|
|
8
|
+
"homepage": "https://github.com/vibedx/vibekit",
|
|
9
|
+
"keywords": ["tickets", "workflow", "agents", "worktrees"],
|
|
10
|
+
"skills": [
|
|
11
|
+
"skills/vibekit-workflow",
|
|
12
|
+
"skills/ticket-writer"
|
|
13
|
+
],
|
|
14
|
+
"agents": [
|
|
15
|
+
"agents/ticket-worker.md",
|
|
16
|
+
"agents/reviewer.md"
|
|
17
|
+
],
|
|
18
|
+
"hooks": "hooks/hooks.json",
|
|
19
|
+
"settings": "settings.json"
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# VibeKit — Claude Code Plugin
|
|
2
|
+
|
|
3
|
+
Ticket-driven development workflows for Claude Code. Install once; every project with a `.vibe/` directory gets structured ticket workflows, agent orchestration, and worktree isolation.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
/plugin install vibekit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or from the community marketplace browser in Claude Code.
|
|
12
|
+
|
|
13
|
+
## What's included
|
|
14
|
+
|
|
15
|
+
**Skills**
|
|
16
|
+
- `vibekit-workflow` — teaches Claude the full ticket-driven workflow (create → start → implement → close)
|
|
17
|
+
- `ticket-writer` — helps write well-structured tickets with actionable acceptance criteria
|
|
18
|
+
|
|
19
|
+
**Agents**
|
|
20
|
+
- `ticket-worker` — autonomous agent that reads a ticket, implements it, and closes it
|
|
21
|
+
- `reviewer` — reviews a completed ticket against its acceptance criteria
|
|
22
|
+
|
|
23
|
+
**Hooks**
|
|
24
|
+
- `SessionStart` — detects `.vibe/` directory and surfaces open ticket counts on session start
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
The VibeKit CLI is optional but recommended for full functionality:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g @vibedx/vibekit
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Without the CLI, skills and agents still work — you just won't get the `vibe` commands for branch management and ticket status updates.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# In any project
|
|
40
|
+
vibe init # set up .vibe/ directory
|
|
41
|
+
vibe new "Add user auth" --priority high -n # create a ticket
|
|
42
|
+
vibe start TKT-001 # start working (creates branch)
|
|
43
|
+
# ... implement the work ...
|
|
44
|
+
vibe close TKT-001 # mark done
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or let an agent do it:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
vibe start TKT-001 --agent # spawns Claude agent to work on the ticket autonomously
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Links
|
|
54
|
+
|
|
55
|
+
- [VibeKit repo](https://github.com/vibedx/vibekit)
|
|
56
|
+
- [npm package](https://www.npmjs.com/package/@vibedx/vibekit)
|
|
57
|
+
- [Claude Code plugin docs](https://code.claude.com/docs/en/discover-plugins)
|