@vibedx/vibekit 0.10.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 +1 -1
- 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/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
|
@@ -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;
|
|
@@ -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
|
+
});
|