@vibedx/vibekit 0.10.0 → 0.11.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 CHANGED
@@ -54,6 +54,22 @@ The skill teaches agents the ticket-driven workflow — they'll create focused t
54
54
 
55
55
  **Coordinating multiple agents?** See **[docs/agent-workflow.md](./docs/agent-workflow.md)** for a framework-agnostic pattern for running multi-agent teams on a shared repo — assignees, polling loops, escalation, and loop prevention.
56
56
 
57
+ ### 🧩 Claude Code Plugin
58
+
59
+ Prefer Claude Code? Install the vibekit plugin to get the full ticket-driven workflow, agents, and hooks built in:
60
+
61
+ ```
62
+ /plugin install vibekit
63
+ ```
64
+
65
+ The plugin bundles:
66
+
67
+ - **Skills** — `vibekit-workflow` (full create → start → implement → close flow) and `ticket-writer` (well-structured tickets with acceptance criteria)
68
+ - **Agents** — `ticket-worker` (reads a ticket, implements it, closes it) and `reviewer` (checks completed work against acceptance criteria)
69
+ - **Hooks** — `SessionStart` detects your `.vibe/` directory and surfaces open ticket counts
70
+
71
+ See **[vibekit-plugin/](./vibekit-plugin/)** for details. The CLI is optional but recommended (`npm install -g @vibedx/vibekit`).
72
+
57
73
  ## 🤔 Why VibeKit?
58
74
 
59
75
  - **🎯 Vibe code with manageable smaller tasks** - Break down complex features into focused tickets
@@ -139,6 +155,27 @@ vibe plan to-ticket .vibe/plans/my-feature.md --auto
139
155
  vibe plan to-ticket .vibe/plans/my-feature.md --dry-run
140
156
  ```
141
157
 
158
+ ### 📚 Docs
159
+ ```bash
160
+ # Create a doc from a template (types: guide, design-doc, code-doc, faq)
161
+ vibe docs add "Getting Started"
162
+ vibe docs add "Docs Architecture" --type design-doc
163
+
164
+ # Also open it in $EDITOR after creating
165
+ vibe docs add "API Reference" --type code-doc --edit
166
+
167
+ # List docs, with optional filters
168
+ vibe docs list
169
+ vibe docs list --type design-doc --status draft --tag api
170
+
171
+ # Print a doc to stdout
172
+ vibe docs show DOC-001
173
+
174
+ # AI refinement pass — expands sections and fills placeholders
175
+ vibe docs refine DOC-001
176
+ ```
177
+ Docs live in `.vibe/docs/` with their own `DOC-NNN` sequence (independent from tickets). Templates are scaffolded into `.vibe/.templates/docs/` on `vibe init`.
178
+
142
179
  ### 🔀 Pull Requests
143
180
  ```bash
144
181
  # Open a GitHub PR from the current ticket branch (title/body from the ticket)
package/assets/config.yml CHANGED
@@ -21,6 +21,16 @@ tickets:
21
21
  - high
22
22
  - critical
23
23
 
24
+ docs:
25
+ path: ".vibe/docs"
26
+ id_format: "DOC-{number}"
27
+ default_template: ".vibe/.templates/docs/default.md"
28
+ default_type: guide
29
+ status_options:
30
+ - draft
31
+ - review
32
+ - published
33
+
24
34
  team:
25
35
  path: .vibe/team.yml
26
36
 
@@ -0,0 +1,39 @@
1
+ ---
2
+ id: {id}
3
+ title: {title}
4
+ slug: {slug}
5
+ type: code-doc
6
+ status: draft
7
+ tags: []
8
+ author: ""
9
+ created_at: {date}
10
+ updated_at: {date}
11
+ ---
12
+
13
+ ## Summary
14
+
15
+ <!-- What does this module/component/service do, in one or two sentences? -->
16
+
17
+ ## Architecture
18
+
19
+ <!-- How it's structured: key files, modules, and how they fit together. -->
20
+
21
+ ## Public API
22
+
23
+ <!-- Functions, classes, or endpoints exposed. Include signatures and `code` examples. -->
24
+
25
+ ## Usage
26
+
27
+ <!-- How to use it, with examples. -->
28
+
29
+ ## Internals
30
+
31
+ <!-- Notable implementation details, invariants, and gotchas. -->
32
+
33
+ ## Testing
34
+
35
+ <!-- How this code is tested and how to run those tests. -->
36
+
37
+ ## AI Prompt
38
+
39
+ <!-- Instructions for `vibe docs refine` to document undocumented parts of the code. -->
@@ -0,0 +1,35 @@
1
+ ---
2
+ id: {id}
3
+ title: {title}
4
+ slug: {slug}
5
+ type: guide
6
+ status: draft
7
+ tags: []
8
+ author: ""
9
+ created_at: {date}
10
+ updated_at: {date}
11
+ ---
12
+
13
+ ## Overview
14
+
15
+ <!-- What is this guide about? Who is it for and what will they be able to do after reading it? -->
16
+
17
+ ## Prerequisites
18
+
19
+ <!-- Anything the reader needs to know or have set up first. -->
20
+
21
+ ## Steps
22
+
23
+ <!-- Walk through the process step by step. Use code blocks for commands and file paths. -->
24
+
25
+ ## Examples
26
+
27
+ <!-- Concrete examples that show the guide in action. -->
28
+
29
+ ## References
30
+
31
+ <!-- Links to related docs, tickets, or external resources. -->
32
+
33
+ ## AI Prompt
34
+
35
+ <!-- Instructions for `vibe docs refine` to expand or improve this guide. -->
@@ -0,0 +1,43 @@
1
+ ---
2
+ id: {id}
3
+ title: {title}
4
+ slug: {slug}
5
+ type: design-doc
6
+ status: draft
7
+ tags: []
8
+ author: ""
9
+ created_at: {date}
10
+ updated_at: {date}
11
+ ---
12
+
13
+ ## Context & Problem
14
+
15
+ <!-- What problem are we solving? Why now? What's the current state? -->
16
+
17
+ ## Goals
18
+
19
+ <!-- What must this design achieve? -->
20
+
21
+ ## Non-Goals
22
+
23
+ <!-- Explicitly out of scope. -->
24
+
25
+ ## Proposed Design
26
+
27
+ <!-- The core of the doc: architecture, data flow, key decisions. -->
28
+
29
+ ## Alternatives Considered
30
+
31
+ <!-- Other approaches and why they were not chosen. -->
32
+
33
+ ## Risks & Trade-offs
34
+
35
+ <!-- What could go wrong, and what are we trading away? -->
36
+
37
+ ## Rollout Plan
38
+
39
+ <!-- How this ships: phases, migrations, feature flags. -->
40
+
41
+ ## AI Prompt
42
+
43
+ <!-- Instructions for `vibe docs refine` to pressure-test or expand this design. -->
@@ -0,0 +1,33 @@
1
+ ---
2
+ id: {id}
3
+ title: {title}
4
+ slug: {slug}
5
+ type: faq
6
+ status: draft
7
+ tags: []
8
+ author: ""
9
+ created_at: {date}
10
+ updated_at: {date}
11
+ ---
12
+
13
+ ## Overview
14
+
15
+ <!-- What topic does this FAQ cover? -->
16
+
17
+ ## Questions
18
+
19
+ ### Q: <question here>
20
+
21
+ <!-- Answer. -->
22
+
23
+ ### Q: <question here>
24
+
25
+ <!-- Answer. -->
26
+
27
+ ## Still stuck?
28
+
29
+ <!-- Where to go for more help: docs, channels, contacts. -->
30
+
31
+ ## AI Prompt
32
+
33
+ <!-- Instructions for `vibe docs refine` to add or improve answers. -->
package/index.js CHANGED
@@ -24,7 +24,7 @@ const __dirname = dirname(__filename);
24
24
  const AVAILABLE_COMMANDS = [
25
25
  'init', 'new', 'close', 'list', 'get-started',
26
26
  'start', 'link', 'unlink', 'refine', 'lint', 'review', 'team', 'skills',
27
- 'status', 'stats', 'plan', 'pr', 'swarm'
27
+ 'status', 'stats', 'plan', 'pr', 'swarm', 'docs'
28
28
  ];
29
29
 
30
30
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibedx/vibekit",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "A powerful CLI tool for managing development tickets and project workflows",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,162 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname } from 'path';
6
+ import { getProjectRoot, getConfig, createSlug } from '../../utils/index.js';
7
+ import { getDocsDir, getNextDocId } from '../../utils/doc.js';
8
+ import { logger } from '../../utils/cli.js';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ const DEFAULT_TYPE = 'guide';
14
+
15
+ // Doc type -> template filename (bundled and project-local share names, guide -> default)
16
+ const TYPE_TEMPLATES = {
17
+ guide: 'default.md',
18
+ 'design-doc': 'design-doc.md',
19
+ 'code-doc': 'code-doc.md',
20
+ faq: 'faq.md'
21
+ };
22
+
23
+ /**
24
+ * Parse `vibe docs add` arguments.
25
+ * @param {string[]} args
26
+ * @returns {{title: string, type: string, edit: boolean}}
27
+ */
28
+ function parseArguments(args) {
29
+ const titleParts = [];
30
+ let type = null;
31
+ let edit = false;
32
+
33
+ for (let i = 0; i < args.length; i++) {
34
+ const arg = args[i];
35
+ if (arg === '--type' && i + 1 < args.length) {
36
+ type = args[i + 1];
37
+ i++;
38
+ } else if (arg === '--edit' || arg === '-e') {
39
+ edit = true;
40
+ } else if (!arg.startsWith('--')) {
41
+ titleParts.push(arg);
42
+ }
43
+ }
44
+
45
+ const title = titleParts.join(' ').trim();
46
+ if (!title) {
47
+ throw new Error('Please provide a title. Usage: vibe docs add "title" [--type <type>]');
48
+ }
49
+
50
+ return { title, type, edit };
51
+ }
52
+
53
+ /**
54
+ * Resolve the type, falling back to the default if unrecognised.
55
+ * @param {string|null} type
56
+ * @param {Object} config
57
+ * @returns {string}
58
+ */
59
+ function resolveType(type, config) {
60
+ const defaultType = config.docs?.default_type || DEFAULT_TYPE;
61
+ if (!type) {
62
+ return defaultType;
63
+ }
64
+ if (!TYPE_TEMPLATES[type]) {
65
+ logger.warning(`Unknown doc type '${type}'. Falling back to '${defaultType}'.`);
66
+ return defaultType;
67
+ }
68
+ return type;
69
+ }
70
+
71
+ /**
72
+ * Load the template for a type: project-local first, then bundled asset.
73
+ * @param {string} type
74
+ * @param {string} root
75
+ * @returns {string}
76
+ */
77
+ function loadTemplate(type, root) {
78
+ const templateFile = TYPE_TEMPLATES[type] || TYPE_TEMPLATES[DEFAULT_TYPE];
79
+ const projectTemplate = path.join(root, '.vibe', '.templates', 'docs', templateFile);
80
+ const bundledTemplate = path.join(__dirname, '../../../assets', 'docs', templateFile);
81
+
82
+ if (fs.existsSync(projectTemplate)) {
83
+ return fs.readFileSync(projectTemplate, 'utf-8');
84
+ }
85
+ if (fs.existsSync(bundledTemplate)) {
86
+ return fs.readFileSync(bundledTemplate, 'utf-8');
87
+ }
88
+ throw new Error(`No template found for type '${type}'. Run "vibe init" to scaffold doc templates.`);
89
+ }
90
+
91
+ /**
92
+ * Fill template placeholders.
93
+ * @param {string} template
94
+ * @param {{id: string, title: string, slug: string, type: string, timestamp: string}} data
95
+ * @returns {string}
96
+ */
97
+ function fillTemplate(template, { id, title, slug, type, timestamp }) {
98
+ return template
99
+ .replace(/{id}/g, id)
100
+ .replace(/{title}/g, title)
101
+ .replace(/{slug}/g, slug)
102
+ .replace(/{date}/g, timestamp)
103
+ .replace(/^type: .*$/m, `type: ${type}`);
104
+ }
105
+
106
+ /**
107
+ * Open a file in $EDITOR.
108
+ * @param {string} filePath
109
+ */
110
+ function openInEditor(filePath) {
111
+ const editor = process.env.EDITOR || process.env.VISUAL;
112
+ if (!editor) {
113
+ logger.warning('No $EDITOR set — skipping auto-open.');
114
+ return;
115
+ }
116
+ const child = spawn(editor, [filePath], { stdio: 'inherit' });
117
+ child.on('error', (err) => {
118
+ logger.warning(`Could not open editor: ${err.message}`);
119
+ });
120
+ }
121
+
122
+ /**
123
+ * `vibe docs add` — create a new doc from a template.
124
+ * @param {string[]} args
125
+ */
126
+ async function addDoc(args) {
127
+ try {
128
+ const { title, type: rawType, edit } = parseArguments(args);
129
+ const root = getProjectRoot();
130
+ const config = getConfig();
131
+
132
+ const type = resolveType(rawType, config);
133
+ const template = loadTemplate(type, root);
134
+
135
+ const id = getNextDocId();
136
+ const slug = createSlug(title, config);
137
+ const filename = `${id}-${slug}.md`;
138
+ const timestamp = new Date().toISOString().split('T')[0];
139
+
140
+ const docsDir = getDocsDir();
141
+ if (!fs.existsSync(docsDir)) {
142
+ fs.mkdirSync(docsDir, { recursive: true });
143
+ }
144
+
145
+ const outputPath = path.join(docsDir, filename);
146
+ const content = fillTemplate(template, { id, title, slug, type, timestamp });
147
+
148
+ fs.writeFileSync(outputPath, content, 'utf-8');
149
+
150
+ const relativePath = path.relative(process.cwd(), outputPath);
151
+ logger.success(`Created ${id} (${type}): ${relativePath}`);
152
+
153
+ if (edit) {
154
+ openInEditor(outputPath);
155
+ }
156
+ } catch (error) {
157
+ logger.error(error.message);
158
+ process.exit(1);
159
+ }
160
+ }
161
+
162
+ export default addDoc;
@@ -0,0 +1,47 @@
1
+ import { logger } from '../../utils/cli.js';
2
+ import addDoc from './add.js';
3
+ import listDocs from './list.js';
4
+ import refineDoc from './refine.js';
5
+ import showDoc from './show.js';
6
+
7
+ const SUBCOMMANDS = {
8
+ add: addDoc,
9
+ list: listDocs,
10
+ refine: refineDoc,
11
+ show: showDoc
12
+ };
13
+
14
+ function showHelp() {
15
+ console.log(`\n📚 vibe docs — project documentation\n`);
16
+ console.log('Usage: vibe docs <subcommand> [options]\n');
17
+ console.log('Subcommands:');
18
+ console.log(' add "title" [--type guide|design-doc|code-doc|faq] [--edit] Create a new doc');
19
+ console.log(' list [--type <type>] [--status <status>] [--tag <tag>] List docs');
20
+ console.log(' show <id> Print a doc to stdout');
21
+ console.log(' refine <id> AI refinement pass\n');
22
+ }
23
+
24
+ /**
25
+ * `vibe docs` entry point — routes to subcommands.
26
+ * @param {string[]} args - Command arguments
27
+ */
28
+ async function docsCommand(args = []) {
29
+ const [subcommand, ...rest] = args;
30
+
31
+ if (!subcommand || subcommand === '--help' || subcommand === '-h') {
32
+ showHelp();
33
+ return;
34
+ }
35
+
36
+ const handler = SUBCOMMANDS[subcommand];
37
+
38
+ if (!handler) {
39
+ logger.error(`Unknown docs subcommand: ${subcommand}`);
40
+ showHelp();
41
+ process.exit(1);
42
+ }
43
+
44
+ await handler(rest);
45
+ }
46
+
47
+ export default docsCommand;
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import yaml from 'js-yaml';
5
+ import {
6
+ createTempDir,
7
+ cleanupTempDir,
8
+ mockConsole,
9
+ mockProcessCwd,
10
+ mockProcessExit,
11
+ createMockVibeProject
12
+ } from '../../utils/test-helpers.js';
13
+ import addDoc from './add.js';
14
+ import listDocs from './list.js';
15
+ import showDoc from './show.js';
16
+
17
+ function readFrontmatter(filePath) {
18
+ const content = fs.readFileSync(filePath, 'utf-8');
19
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
20
+ return yaml.load(match[1]);
21
+ }
22
+
23
+ describe('docs command', () => {
24
+ let tempDir;
25
+ let consoleMock;
26
+ let restoreCwd;
27
+ let exitMock;
28
+
29
+ beforeEach(() => {
30
+ tempDir = createTempDir('docs-test');
31
+ consoleMock = mockConsole();
32
+ restoreCwd = mockProcessCwd(tempDir);
33
+ exitMock = mockProcessExit();
34
+ createMockVibeProject(tempDir);
35
+ });
36
+
37
+ afterEach(() => {
38
+ consoleMock.restore();
39
+ restoreCwd();
40
+ exitMock.restore();
41
+ cleanupTempDir(tempDir);
42
+ });
43
+
44
+ describe('add', () => {
45
+ it('creates a DOC-001 file with correct frontmatter', async () => {
46
+ await addDoc(['My First Guide']);
47
+
48
+ const docsDir = path.join(tempDir, '.vibe', 'docs');
49
+ const files = fs.readdirSync(docsDir);
50
+ expect(files).toHaveLength(1);
51
+ expect(files[0]).toMatch(/^DOC-001-my-first-guide\.md$/);
52
+
53
+ const fm = readFrontmatter(path.join(docsDir, files[0]));
54
+ expect(fm.id).toBe('DOC-001');
55
+ expect(fm.title).toBe('My First Guide');
56
+ expect(fm.type).toBe('guide');
57
+ expect(fm.status).toBe('draft');
58
+ });
59
+
60
+ it('assigns sequential IDs', async () => {
61
+ await addDoc(['First']);
62
+ await addDoc(['Second']);
63
+
64
+ const docsDir = path.join(tempDir, '.vibe', 'docs');
65
+ const ids = fs.readdirSync(docsDir).map(f => readFrontmatter(path.join(docsDir, f)).id).sort();
66
+ expect(ids).toEqual(['DOC-001', 'DOC-002']);
67
+ });
68
+
69
+ it('selects the template for --type', async () => {
70
+ await addDoc(['A Design', '--type', 'design-doc']);
71
+
72
+ const docsDir = path.join(tempDir, '.vibe', 'docs');
73
+ const file = fs.readdirSync(docsDir)[0];
74
+ const content = fs.readFileSync(path.join(docsDir, file), 'utf-8');
75
+ expect(readFrontmatter(path.join(docsDir, file)).type).toBe('design-doc');
76
+ expect(content).toContain('## Proposed Design');
77
+ });
78
+
79
+ it('falls back to guide for an unknown type', async () => {
80
+ await addDoc(['Weird', '--type', 'bogus']);
81
+
82
+ const docsDir = path.join(tempDir, '.vibe', 'docs');
83
+ const file = fs.readdirSync(docsDir)[0];
84
+ expect(readFrontmatter(path.join(docsDir, file)).type).toBe('guide');
85
+ });
86
+
87
+ it('exits with an error when no title is given', async () => {
88
+ await expect(addDoc([])).rejects.toThrow(/process.exit/);
89
+ expect(exitMock.exitCalls).toContain(1);
90
+ });
91
+ });
92
+
93
+ describe('list', () => {
94
+ beforeEach(async () => {
95
+ await addDoc(['Alpha Guide']);
96
+ await addDoc(['Beta Design', '--type', 'design-doc']);
97
+ });
98
+
99
+ it('lists all docs', () => {
100
+ listDocs([]);
101
+ const output = consoleMock.logs.log.join('\n');
102
+ expect(output).toContain('DOC-001');
103
+ expect(output).toContain('DOC-002');
104
+ expect(output).toContain('Found 2 doc(s)');
105
+ });
106
+
107
+ it('filters by type', () => {
108
+ listDocs(['--type', 'design-doc']);
109
+ const output = consoleMock.logs.log.join('\n');
110
+ expect(output).toContain('Beta Design');
111
+ expect(output).toContain('Found 1 doc(s) (type: design-doc)');
112
+ expect(output).not.toContain('Alpha Guide');
113
+ });
114
+ });
115
+
116
+ describe('show', () => {
117
+ it('prints a doc by id', async () => {
118
+ await addDoc(['Show Me']);
119
+ showDoc(['1']);
120
+ const output = consoleMock.logs.log.join('\n');
121
+ expect(output).toContain('id: DOC-001');
122
+ expect(output).toContain('title: Show Me');
123
+ });
124
+
125
+ it('errors on a missing doc', () => {
126
+ expect(() => showDoc(['999'])).toThrow(/process.exit/);
127
+ expect(exitMock.exitCalls).toContain(1);
128
+ });
129
+ });
130
+ });
@@ -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;
@@ -41,12 +41,12 @@ function createSampleTicket(title, description, priority = "medium", status = "o
41
41
  content = content.replace(/^status: .*$/m, `status: ${status}`);
42
42
 
43
43
  // Add description
44
- content = content.replace(/## Description\\s*\\n\\s*\\n/m, `## Description\\n\\n${description}\\n\\n`);
45
-
44
+ content = content.replace(/## Description\s*\n\s*\n/m, `## Description\n\n${description}\n\n`);
45
+
46
46
  // Add AI prompt for the feature request example
47
47
  if (title.includes("AI Prompt")) {
48
- content = content.replace(/## AI Prompt\\s*\\n\\s*\\n/m,
49
- `## AI Prompt\\n\\nGenerate ideas for implementing this feature in a Node.js CLI application. Consider user experience, error handling, and performance.\\n\\n`);
48
+ content = content.replace(/## AI Prompt\s*\n\s*\n/m,
49
+ `## AI Prompt\n\nGenerate ideas for implementing this feature in a Node.js CLI application. Consider user experience, error handling, and performance.\n\n`);
50
50
  }
51
51
 
52
52
  const outputPath = path.join(ticketDir, filename);
@@ -122,6 +122,10 @@ describe('get-started command', () => {
122
122
  expect(content).toContain('status: open');
123
123
  expect(content).toContain('## Description');
124
124
  expect(content).toContain('Simple Task Example');
125
+ // The description text must be injected under the Description heading (regression: TKT-028)
126
+ expect(content).toContain('This is a simple task ticket example showing the basic structure.');
127
+ // Injected content must be real newlines, never literal backslash sequences
128
+ expect(content).not.toMatch(/\\n|\\s/);
125
129
 
126
130
  // Find the bug report example
127
131
  const bugReportFile = files.find(f => f.includes('bug-report-example'));
@@ -222,8 +226,12 @@ describe('get-started command', () => {
222
226
 
223
227
  const content = fs.readFileSync(path.join(ticketsDir, aiPromptFile), 'utf-8');
224
228
  expect(content).toContain('## AI Prompt');
225
- expect(content).toContain('## AI Prompt');
226
229
  expect(content).toContain('Feature Request with AI Prompt');
230
+ // The AI prompt body must be injected under the heading (regression: TKT-028)
231
+ expect(content).toContain('Generate ideas for implementing this feature in a Node.js CLI application.');
232
+ // And the description for this ticket too
233
+ expect(content).toContain('This example shows how to use the AI prompt section');
234
+ expect(content).not.toMatch(/\\n|\\s/);
227
235
  });
228
236
 
229
237
  it('should create tickets with proper slug formatting', () => {
@@ -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
- console.log(`✅ '${targetFolder}' initialized with config, tickets/, plans/, and .templates/default.md`);
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
  }
@@ -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
+ });