mspec 0.0.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 +94 -0
- package/dist/commands/implement.js +47 -0
- package/dist/commands/implement.test.js +92 -0
- package/dist/commands/init.js +57 -0
- package/dist/commands/init.test.js +79 -0
- package/dist/commands/plan.js +52 -0
- package/dist/commands/plan.test.js +78 -0
- package/dist/index.js +26 -0
- package/dist/templates/index.js +74 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# mspec
|
|
2
|
+
|
|
3
|
+
> **A minimalist Spec-Driven Development (SDD) toolkit for solo developers and AI agents.**
|
|
4
|
+
|
|
5
|
+
`mspec` is a lightweight alternative to heavy SDD frameworks. It removes the "enterprise theater" (branch-per-feature, complex state files, and heavy daemon processes) and focuses strictly on **intent** (the Spec) and **execution** (the Tasks) using simple Markdown files.
|
|
6
|
+
|
|
7
|
+
It is designed to work seamlessly alongside your favorite AI coding agents: Claude Code, Gemini CLI, Cursor, OpenCode, or Zed.
|
|
8
|
+
|
|
9
|
+
## Philosophy
|
|
10
|
+
- **Token Efficient:** Uses a single `SPEC.md` for context instead of massive chat histories.
|
|
11
|
+
- **Visual-First:** Encourages Mermaid.js diagrams over long, confusing paragraphs.
|
|
12
|
+
- **Data Dictionaries:** Uses simple Markdown tables for data modeling instead of strict, unreadable JSON schemas.
|
|
13
|
+
- **Agent Handoff:** You design the spec; the AI breaks down the tasks; the AI implements the tasks sequentially.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
You can run `mspec` directly via `npx` (recommended) or install it globally.
|
|
20
|
+
|
|
21
|
+
### Running via npx (No install required)
|
|
22
|
+
```bash
|
|
23
|
+
npx mspec <command>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Global Installation
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g mspec
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
*(Note: If you are cloning this repository locally for development, run `npm install`, then `npm run build`, and finally `npm link` to make the `mspec` command available globally on your machine).*
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## How to Use
|
|
36
|
+
|
|
37
|
+
The workflow follows a simple three-step loop: **Initialize -> Plan -> Implement**.
|
|
38
|
+
|
|
39
|
+
### Step 1: Initialize the Project
|
|
40
|
+
Run this command in the root of your project:
|
|
41
|
+
```bash
|
|
42
|
+
npx mspec init
|
|
43
|
+
```
|
|
44
|
+
- It will prompt you for your preferred AI agent (Claude, Gemini, Cursor, etc.).
|
|
45
|
+
- It will create the `.mspec/specs/` and `.mspec/tasks/` directories.
|
|
46
|
+
- It will automatically inject a custom command/instruction file into your project (e.g., `.gemini/commands/mspec.toml` or `.cursor/rules/mspec.mdc`) so your AI agent understands the framework.
|
|
47
|
+
|
|
48
|
+
### Step 2: The Inquiry (Creating a Spec)
|
|
49
|
+
Talk to your AI agent and ask it to draft a specification using the `mspec` standard.
|
|
50
|
+
|
|
51
|
+
**Example Prompt to your AI:**
|
|
52
|
+
> "Let's create a spec for a new authentication feature. Save it to `.mspec/specs/001-auth.md`. Include a markdown description, a mermaid sequence diagram for login, and a markdown data dictionary for the user object."
|
|
53
|
+
|
|
54
|
+
### Step 3: Scaffold the Plan
|
|
55
|
+
Once you are happy with the `001-auth.md` spec, run:
|
|
56
|
+
```bash
|
|
57
|
+
npx mspec plan 001-auth
|
|
58
|
+
```
|
|
59
|
+
- This validates that the spec exists.
|
|
60
|
+
- It generates a boilerplate `.mspec/tasks/001-auth.tasks.md` file containing strict instructions for the AI.
|
|
61
|
+
- **Next Action:** Tell your AI agent: *"Please read the spec and fill out the tasks file for 001-auth."* The AI will break the spec down into granular checkboxes.
|
|
62
|
+
|
|
63
|
+
### Step 4: Implement and Execute
|
|
64
|
+
Once the checklist is generated, it's time to hand the wheel over to the AI.
|
|
65
|
+
```bash
|
|
66
|
+
npx mspec implement 001-auth
|
|
67
|
+
```
|
|
68
|
+
- This command analyzes the task list.
|
|
69
|
+
- It outputs a highly structured prompt to your terminal.
|
|
70
|
+
- **Next Action:** Copy the outputted prompt and paste it to your AI agent.
|
|
71
|
+
- *What the AI does:* It will read the `.tasks.md` file, pick the first unchecked `- [ ]` task, implement the code, run your tests, change the checkbox to `- [x]`, and then **stop** to wait for your review.
|
|
72
|
+
|
|
73
|
+
#### Batch Execution
|
|
74
|
+
If you trust the AI and want it to burn through the whole checklist without stopping for your approval between tasks, use the `--batch` flag:
|
|
75
|
+
```bash
|
|
76
|
+
npx mspec implement 001-auth --batch
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Directory Structure
|
|
82
|
+
A project using `mspec` will look like this:
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
your-project/
|
|
86
|
+
├── .mspec/
|
|
87
|
+
│ ├── mspec.json # Auto-generated config
|
|
88
|
+
│ ├── specs/
|
|
89
|
+
│ │ └── 001-auth.md # The "Intent" (Markdown/Mermaid)
|
|
90
|
+
│ └── tasks/
|
|
91
|
+
│ └── 001-auth.tasks.md # The "Execution" (Checklists)
|
|
92
|
+
├── src/ # Your actual code
|
|
93
|
+
└── package.json
|
|
94
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.implementCommand = implementCommand;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
async function implementCommand(specName, options) {
|
|
11
|
+
if (typeof specName !== 'string' || specName.includes('..') || path_1.default.isAbsolute(specName)) {
|
|
12
|
+
console.error(chalk_1.default.red(`Error: Invalid spec name. Path traversal is not allowed.`));
|
|
13
|
+
process.exit(1);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const mspecDir = path_1.default.join(process.cwd(), '.mspec');
|
|
17
|
+
const tasksPath = path_1.default.join(mspecDir, 'tasks', `${specName}.tasks.md`);
|
|
18
|
+
// 1. Check if the tasks file exists
|
|
19
|
+
if (!fs_1.default.existsSync(tasksPath)) {
|
|
20
|
+
console.error(chalk_1.default.red(`Error: Tasks file not found at ${tasksPath}`));
|
|
21
|
+
console.log(chalk_1.default.yellow(`Run 'mspec plan ${specName}' and have your AI fill it out first.`));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// 2. Read the file and check for remaining tasks
|
|
26
|
+
const tasksContent = fs_1.default.readFileSync(tasksPath, 'utf-8');
|
|
27
|
+
if (!tasksContent.includes('- [ ]')) {
|
|
28
|
+
console.log(chalk_1.default.yellow(`Warning: No incomplete tasks found in ${specName}.tasks.md.`));
|
|
29
|
+
console.log(chalk_1.default.green(`It looks like this spec is already fully implemented!`));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// 3. Generate the execution prompt
|
|
33
|
+
const isBatch = options.batch === true;
|
|
34
|
+
const executionPrompt = `> **mspec execution directive:**
|
|
35
|
+
> Please read \`.mspec/tasks/${specName}.tasks.md\`.
|
|
36
|
+
> 1. Find the first incomplete task marked with \`- [ ]\`.
|
|
37
|
+
> 2. Implement the requirements for that specific task.
|
|
38
|
+
> 3. Verify your implementation (run tests/build).
|
|
39
|
+
> 4. If successful, change the task to \`- [x]\` in the file.
|
|
40
|
+
> 5. ${isBatch
|
|
41
|
+
? 'Continue to the next task until all tasks in the current phase are complete.'
|
|
42
|
+
: 'Stop and wait for my approval before moving to the next task.'}`;
|
|
43
|
+
// 4. Output the prompt
|
|
44
|
+
console.log(chalk_1.default.magenta('\n=== Copy the text below and paste it to your AI agent ===\n'));
|
|
45
|
+
console.log(executionPrompt);
|
|
46
|
+
console.log(chalk_1.default.magenta('\n==========================================================\n'));
|
|
47
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const implement_1 = require("./implement");
|
|
10
|
+
describe('implementCommand', () => {
|
|
11
|
+
let originalCwd;
|
|
12
|
+
let tmpDir;
|
|
13
|
+
let mockExit;
|
|
14
|
+
let mockConsoleError;
|
|
15
|
+
let mockConsoleLog;
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
originalCwd = process.cwd;
|
|
18
|
+
});
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'mspec-test-implement-'));
|
|
21
|
+
process.cwd = () => tmpDir;
|
|
22
|
+
mockExit = jest.spyOn(process, 'exit').mockImplementation((() => { }));
|
|
23
|
+
mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => { });
|
|
24
|
+
mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => { });
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
process.cwd = originalCwd;
|
|
28
|
+
fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
|
|
29
|
+
jest.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
it('should reject path traversal in spec name', async () => {
|
|
32
|
+
await (0, implement_1.implementCommand)('../outside-spec', {});
|
|
33
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
34
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid spec name'));
|
|
35
|
+
});
|
|
36
|
+
it('should reject absolute paths in spec name', async () => {
|
|
37
|
+
await (0, implement_1.implementCommand)('/etc/passwd', {});
|
|
38
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
39
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid spec name'));
|
|
40
|
+
});
|
|
41
|
+
it('should exit with error if tasks file does not exist', async () => {
|
|
42
|
+
await (0, implement_1.implementCommand)('missing-spec', {});
|
|
43
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
44
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Error: Tasks file not found'));
|
|
45
|
+
});
|
|
46
|
+
it('should warn if no incomplete tasks are found', async () => {
|
|
47
|
+
const tasksDir = path_1.default.join(tmpDir, '.mspec', 'tasks');
|
|
48
|
+
fs_1.default.mkdirSync(tasksDir, { recursive: true });
|
|
49
|
+
fs_1.default.writeFileSync(path_1.default.join(tasksDir, 'done-spec.tasks.md'), '- [x] task 1\n- [x] task 2');
|
|
50
|
+
await (0, implement_1.implementCommand)('done-spec', {});
|
|
51
|
+
expect(mockExit).not.toHaveBeenCalled();
|
|
52
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Warning: No incomplete tasks found'));
|
|
53
|
+
});
|
|
54
|
+
it('should handle empty task files gracefully', async () => {
|
|
55
|
+
const tasksDir = path_1.default.join(tmpDir, '.mspec', 'tasks');
|
|
56
|
+
fs_1.default.mkdirSync(tasksDir, { recursive: true });
|
|
57
|
+
fs_1.default.writeFileSync(path_1.default.join(tasksDir, 'empty.tasks.md'), '');
|
|
58
|
+
await (0, implement_1.implementCommand)('empty', {});
|
|
59
|
+
expect(mockExit).not.toHaveBeenCalled();
|
|
60
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Warning: No incomplete tasks found'));
|
|
61
|
+
});
|
|
62
|
+
it('should generate one-by-one execution prompt by default', async () => {
|
|
63
|
+
const tasksDir = path_1.default.join(tmpDir, '.mspec', 'tasks');
|
|
64
|
+
fs_1.default.mkdirSync(tasksDir, { recursive: true });
|
|
65
|
+
fs_1.default.writeFileSync(path_1.default.join(tasksDir, 'active-spec.tasks.md'), '- [x] task 1\n- [ ] task 2');
|
|
66
|
+
await (0, implement_1.implementCommand)('active-spec', {});
|
|
67
|
+
expect(mockExit).not.toHaveBeenCalled();
|
|
68
|
+
const promptOutput = mockConsoleLog.mock.calls[1][0];
|
|
69
|
+
expect(promptOutput).toContain('mspec execution directive:');
|
|
70
|
+
expect(promptOutput).toContain('active-spec.tasks.md');
|
|
71
|
+
expect(promptOutput).toContain('Stop and wait for my approval before moving to the next task');
|
|
72
|
+
});
|
|
73
|
+
it('should generate batch execution prompt when --batch is true', async () => {
|
|
74
|
+
const tasksDir = path_1.default.join(tmpDir, '.mspec', 'tasks');
|
|
75
|
+
fs_1.default.mkdirSync(tasksDir, { recursive: true });
|
|
76
|
+
fs_1.default.writeFileSync(path_1.default.join(tasksDir, 'batch-spec.tasks.md'), '- [ ] task 1\n- [ ] task 2');
|
|
77
|
+
await (0, implement_1.implementCommand)('batch-spec', { batch: true });
|
|
78
|
+
expect(mockExit).not.toHaveBeenCalled();
|
|
79
|
+
const promptOutput = mockConsoleLog.mock.calls[1][0];
|
|
80
|
+
expect(promptOutput).toContain('mspec execution directive:');
|
|
81
|
+
expect(promptOutput).toContain('Continue to the next task until all tasks');
|
|
82
|
+
});
|
|
83
|
+
it('should support nested spec names like feature/001-auth', async () => {
|
|
84
|
+
const tasksDir = path_1.default.join(tmpDir, '.mspec', 'tasks', 'feature');
|
|
85
|
+
fs_1.default.mkdirSync(tasksDir, { recursive: true });
|
|
86
|
+
fs_1.default.writeFileSync(path_1.default.join(tasksDir, '001-auth.tasks.md'), '- [ ] task 1');
|
|
87
|
+
await (0, implement_1.implementCommand)('feature/001-auth', {});
|
|
88
|
+
expect(mockExit).not.toHaveBeenCalled();
|
|
89
|
+
const promptOutput = mockConsoleLog.mock.calls[1][0];
|
|
90
|
+
expect(promptOutput).toContain('feature/001-auth.tasks.md');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.initCommand = initCommand;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
const templates_1 = require("../templates");
|
|
11
|
+
// Use require for enquirer to avoid commonjs/esm interop issues with its types
|
|
12
|
+
const { prompt } = require('enquirer');
|
|
13
|
+
async function initCommand() {
|
|
14
|
+
const mspecDir = path_1.default.join(process.cwd(), '.mspec');
|
|
15
|
+
if (fs_1.default.existsSync(mspecDir)) {
|
|
16
|
+
console.log(chalk_1.default.yellow('.mspec directory already exists. Initialization skipped.'));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let response;
|
|
20
|
+
try {
|
|
21
|
+
response = await prompt({
|
|
22
|
+
type: 'select',
|
|
23
|
+
name: 'agent',
|
|
24
|
+
message: 'Which AI agent are you using?',
|
|
25
|
+
choices: ['claude', 'gemini', 'cursor', 'opencode', 'zed', 'generic'],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.log(chalk_1.default.yellow('\nInitialization cancelled.'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const agent = response.agent;
|
|
33
|
+
// Create directories
|
|
34
|
+
fs_1.default.mkdirSync(path_1.default.join(mspecDir, 'specs'), { recursive: true });
|
|
35
|
+
fs_1.default.mkdirSync(path_1.default.join(mspecDir, 'tasks'), { recursive: true });
|
|
36
|
+
// Write mspec.json config
|
|
37
|
+
const mspecConfig = {
|
|
38
|
+
agent,
|
|
39
|
+
paths: {
|
|
40
|
+
specs: '.mspec/specs',
|
|
41
|
+
tasks: '.mspec/tasks'
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
fs_1.default.writeFileSync(path_1.default.join(mspecDir, 'mspec.json'), JSON.stringify(mspecConfig, null, 2));
|
|
45
|
+
// Write agent integration file
|
|
46
|
+
const template = (0, templates_1.getTemplate)(agent);
|
|
47
|
+
if (template) {
|
|
48
|
+
const targetDir = path_1.default.join(process.cwd(), template.dir);
|
|
49
|
+
fs_1.default.mkdirSync(targetDir, { recursive: true });
|
|
50
|
+
fs_1.default.writeFileSync(path_1.default.join(targetDir, template.file), template.content);
|
|
51
|
+
console.log(chalk_1.default.green(`Created integration file for ${agent} at ${path_1.default.join(template.dir, template.file)}`));
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
console.log(chalk_1.default.yellow(`No specific integration template found for ${agent}. Setup completed with generic settings.`));
|
|
55
|
+
}
|
|
56
|
+
console.log(chalk_1.default.green('mspec initialized successfully!'));
|
|
57
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const init_1 = require("./init");
|
|
10
|
+
const mockPrompt = jest.fn();
|
|
11
|
+
jest.mock('enquirer', () => ({
|
|
12
|
+
prompt: (...args) => mockPrompt(...args)
|
|
13
|
+
}));
|
|
14
|
+
describe('initCommand', () => {
|
|
15
|
+
let originalCwd;
|
|
16
|
+
let tmpDir;
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
originalCwd = process.cwd;
|
|
19
|
+
});
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'mspec-test-init-'));
|
|
22
|
+
process.cwd = () => tmpDir;
|
|
23
|
+
mockPrompt.mockReset();
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
process.cwd = originalCwd;
|
|
27
|
+
fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
|
|
28
|
+
jest.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
const providers = [
|
|
31
|
+
{ agent: 'claude', expectedFile: '.claude/commands/mspec.md' },
|
|
32
|
+
{ agent: 'gemini', expectedFile: '.gemini/commands/mspec.toml' },
|
|
33
|
+
{ agent: 'cursor', expectedFile: '.cursor/rules/mspec.mdc' },
|
|
34
|
+
{ agent: 'opencode', expectedFile: '.opencode/commands/mspec.md' },
|
|
35
|
+
{ agent: 'zed', expectedFile: '.mspec/INSTRUCTIONS.md' },
|
|
36
|
+
{ agent: 'generic', expectedFile: '.mspec/INSTRUCTIONS.md' }
|
|
37
|
+
];
|
|
38
|
+
providers.forEach(({ agent, expectedFile }) => {
|
|
39
|
+
it(`should initialize the .mspec environment for ${agent}`, async () => {
|
|
40
|
+
mockPrompt.mockResolvedValueOnce({ agent });
|
|
41
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
42
|
+
await (0, init_1.initCommand)();
|
|
43
|
+
expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.mspec'))).toBe(true);
|
|
44
|
+
expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.mspec/specs'))).toBe(true);
|
|
45
|
+
expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.mspec/tasks'))).toBe(true);
|
|
46
|
+
const configPath = path_1.default.join(tmpDir, '.mspec/mspec.json');
|
|
47
|
+
expect(fs_1.default.existsSync(configPath)).toBe(true);
|
|
48
|
+
const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
|
|
49
|
+
expect(config.agent).toBe(agent);
|
|
50
|
+
const integrationPath = path_1.default.join(tmpDir, expectedFile);
|
|
51
|
+
expect(fs_1.default.existsSync(integrationPath)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
it('should skip initialization if .mspec directory already exists', async () => {
|
|
55
|
+
const mspecDir = path_1.default.join(tmpDir, '.mspec');
|
|
56
|
+
fs_1.default.mkdirSync(mspecDir);
|
|
57
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
58
|
+
await (0, init_1.initCommand)();
|
|
59
|
+
expect(fs_1.default.existsSync(path_1.default.join(mspecDir, 'specs'))).toBe(false);
|
|
60
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Initialization skipped.'));
|
|
61
|
+
});
|
|
62
|
+
it('should handle prompt cancellation gracefully', async () => {
|
|
63
|
+
mockPrompt.mockRejectedValueOnce(new Error('User cancelled'));
|
|
64
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
65
|
+
await (0, init_1.initCommand)();
|
|
66
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Initialization cancelled.'));
|
|
67
|
+
expect(fs_1.default.existsSync(path_1.default.join(tmpDir, '.mspec'))).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
it('should handle missing template gracefully', async () => {
|
|
70
|
+
mockPrompt.mockResolvedValueOnce({ agent: 'unknown-agent' });
|
|
71
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
72
|
+
await (0, init_1.initCommand)();
|
|
73
|
+
const configPath = path_1.default.join(tmpDir, '.mspec/mspec.json');
|
|
74
|
+
expect(fs_1.default.existsSync(configPath)).toBe(true);
|
|
75
|
+
const config = JSON.parse(fs_1.default.readFileSync(configPath, 'utf-8'));
|
|
76
|
+
expect(config.agent).toBe('unknown-agent');
|
|
77
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No specific integration template found'));
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.planCommand = planCommand;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
async function planCommand(specName) {
|
|
11
|
+
if (typeof specName !== 'string' || specName.includes('..') || path_1.default.isAbsolute(specName)) {
|
|
12
|
+
console.error(chalk_1.default.red(`Error: Invalid spec name. Path traversal is not allowed.`));
|
|
13
|
+
process.exit(1);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const mspecDir = path_1.default.join(process.cwd(), '.mspec');
|
|
17
|
+
const specPath = path_1.default.join(mspecDir, 'specs', `${specName}.md`);
|
|
18
|
+
const tasksPath = path_1.default.join(mspecDir, 'tasks', `${specName}.tasks.md`);
|
|
19
|
+
// 1. Check if the spec file exists
|
|
20
|
+
if (!fs_1.default.existsSync(specPath)) {
|
|
21
|
+
console.error(chalk_1.default.red(`Error: Spec file not found at ${specPath}`));
|
|
22
|
+
console.log(chalk_1.default.yellow(`Make sure you have created the spec first using your AI agent or manually.`));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// 2. Check if the tasks file already exists
|
|
27
|
+
if (fs_1.default.existsSync(tasksPath)) {
|
|
28
|
+
console.log(chalk_1.default.yellow(`Warning: Tasks file already exists at ${tasksPath}`));
|
|
29
|
+
console.log(chalk_1.default.gray(`Skipping generation to prevent overwriting your existing plan.`));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// 3. Generate the boilerplate markdown
|
|
33
|
+
const boilerplate = `# Implementation Tasks: ${specName}
|
|
34
|
+
|
|
35
|
+
> **AI INSTRUCTION:** Read \`.mspec/specs/${specName}.md\`. Break down the requirements into granular, sequential implementation tasks below. Use checkboxes (\`- [ ]\`). Group by phases.
|
|
36
|
+
|
|
37
|
+
## Phase 1: Setup & Scaffolding
|
|
38
|
+
- [ ] ...
|
|
39
|
+
|
|
40
|
+
## Phase 2: Core Logic
|
|
41
|
+
- [ ] ...
|
|
42
|
+
|
|
43
|
+
## Phase 3: Validation
|
|
44
|
+
- [ ] ...
|
|
45
|
+
`;
|
|
46
|
+
// Ensure tasks directory exists in case it was somehow deleted or it's a nested spec (e.g. 'auth/login')
|
|
47
|
+
fs_1.default.mkdirSync(path_1.default.dirname(tasksPath), { recursive: true });
|
|
48
|
+
// 4. Write the file
|
|
49
|
+
fs_1.default.writeFileSync(tasksPath, boilerplate, 'utf-8');
|
|
50
|
+
console.log(chalk_1.default.green(`Success: Scaffolded tasks file at ${tasksPath}`));
|
|
51
|
+
console.log(chalk_1.default.blue(`Next Step: Ask your AI agent to "fill out the tasks for ${specName}"`));
|
|
52
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const plan_1 = require("./plan");
|
|
10
|
+
describe('planCommand', () => {
|
|
11
|
+
let originalCwd;
|
|
12
|
+
let tmpDir;
|
|
13
|
+
let mockExit;
|
|
14
|
+
let mockConsoleError;
|
|
15
|
+
let mockConsoleLog;
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
originalCwd = process.cwd;
|
|
18
|
+
});
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'mspec-test-plan-'));
|
|
21
|
+
process.cwd = () => tmpDir;
|
|
22
|
+
mockExit = jest.spyOn(process, 'exit').mockImplementation((() => { }));
|
|
23
|
+
mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => { });
|
|
24
|
+
mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => { });
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
process.cwd = originalCwd;
|
|
28
|
+
fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
|
|
29
|
+
jest.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
it('should reject path traversal in spec name', async () => {
|
|
32
|
+
await (0, plan_1.planCommand)('../outside-spec');
|
|
33
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
34
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid spec name'));
|
|
35
|
+
});
|
|
36
|
+
it('should reject absolute paths in spec name', async () => {
|
|
37
|
+
await (0, plan_1.planCommand)('/etc/passwd');
|
|
38
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
39
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid spec name'));
|
|
40
|
+
});
|
|
41
|
+
it('should exit with error if spec file does not exist', async () => {
|
|
42
|
+
await (0, plan_1.planCommand)('missing-spec');
|
|
43
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
44
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Error: Spec file not found'));
|
|
45
|
+
});
|
|
46
|
+
it('should skip generation if tasks file already exists', async () => {
|
|
47
|
+
const specsDir = path_1.default.join(tmpDir, '.mspec', 'specs');
|
|
48
|
+
const tasksDir = path_1.default.join(tmpDir, '.mspec', 'tasks');
|
|
49
|
+
fs_1.default.mkdirSync(specsDir, { recursive: true });
|
|
50
|
+
fs_1.default.mkdirSync(tasksDir, { recursive: true });
|
|
51
|
+
fs_1.default.writeFileSync(path_1.default.join(specsDir, 'test-spec.md'), '# Spec');
|
|
52
|
+
fs_1.default.writeFileSync(path_1.default.join(tasksDir, 'test-spec.tasks.md'), '# Existing Tasks');
|
|
53
|
+
await (0, plan_1.planCommand)('test-spec');
|
|
54
|
+
expect(mockExit).not.toHaveBeenCalled();
|
|
55
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Warning: Tasks file already exists'));
|
|
56
|
+
const content = fs_1.default.readFileSync(path_1.default.join(tasksDir, 'test-spec.tasks.md'), 'utf-8');
|
|
57
|
+
expect(content).toBe('# Existing Tasks');
|
|
58
|
+
});
|
|
59
|
+
it('should scaffold boilerplate if spec exists and tasks do not', async () => {
|
|
60
|
+
const specsDir = path_1.default.join(tmpDir, '.mspec', 'specs');
|
|
61
|
+
fs_1.default.mkdirSync(specsDir, { recursive: true });
|
|
62
|
+
fs_1.default.writeFileSync(path_1.default.join(specsDir, 'test-spec.md'), '# Spec');
|
|
63
|
+
await (0, plan_1.planCommand)('test-spec');
|
|
64
|
+
expect(mockExit).not.toHaveBeenCalled();
|
|
65
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Success: Scaffolded tasks file'));
|
|
66
|
+
const tasksPath = path_1.default.join(tmpDir, '.mspec', 'tasks', 'test-spec.tasks.md');
|
|
67
|
+
expect(fs_1.default.existsSync(tasksPath)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
it('should support nested spec names like feature/001-auth', async () => {
|
|
70
|
+
const specsDir = path_1.default.join(tmpDir, '.mspec', 'specs', 'feature');
|
|
71
|
+
fs_1.default.mkdirSync(specsDir, { recursive: true });
|
|
72
|
+
fs_1.default.writeFileSync(path_1.default.join(specsDir, '001-auth.md'), '# Spec');
|
|
73
|
+
await (0, plan_1.planCommand)('feature/001-auth');
|
|
74
|
+
expect(mockExit).not.toHaveBeenCalled();
|
|
75
|
+
const tasksPath = path_1.default.join(tmpDir, '.mspec', 'tasks', 'feature', '001-auth.tasks.md');
|
|
76
|
+
expect(fs_1.default.existsSync(tasksPath)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const init_1 = require("./commands/init");
|
|
6
|
+
const plan_1 = require("./commands/plan");
|
|
7
|
+
const implement_1 = require("./commands/implement");
|
|
8
|
+
const program = new commander_1.Command();
|
|
9
|
+
program
|
|
10
|
+
.name('mspec')
|
|
11
|
+
.description('Minimalist Spec-Driven Development CLI')
|
|
12
|
+
.version('1.0.0');
|
|
13
|
+
program
|
|
14
|
+
.command('init')
|
|
15
|
+
.description('Initialize mspec in the current directory')
|
|
16
|
+
.action(init_1.initCommand);
|
|
17
|
+
program
|
|
18
|
+
.command('plan <spec-name>')
|
|
19
|
+
.description('Scaffold a tasks file for a given spec')
|
|
20
|
+
.action(plan_1.planCommand);
|
|
21
|
+
program
|
|
22
|
+
.command('implement <spec-name>')
|
|
23
|
+
.description('Generate execution instructions for the AI agent to implement a spec')
|
|
24
|
+
.option('-b, --batch', 'Instruct the AI to complete all tasks without stopping for approval', false)
|
|
25
|
+
.action(implement_1.implementCommand);
|
|
26
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.templates = void 0;
|
|
4
|
+
exports.getTemplate = getTemplate;
|
|
5
|
+
exports.templates = {
|
|
6
|
+
claude: {
|
|
7
|
+
dir: '.claude/commands',
|
|
8
|
+
file: 'mspec.md',
|
|
9
|
+
content: `---
|
|
10
|
+
description: "Commands for Spec-Driven Development using mspec"
|
|
11
|
+
---
|
|
12
|
+
You are an AI assistant using the mspec framework.
|
|
13
|
+
When asked to /mspec:spec, /mspec:plan, or /mspec:apply, follow the minimalist spec-driven development guidelines.
|
|
14
|
+
Specs are in .mspec/specs. Tasks are in .mspec/tasks.
|
|
15
|
+
`
|
|
16
|
+
},
|
|
17
|
+
gemini: {
|
|
18
|
+
dir: '.gemini/commands',
|
|
19
|
+
file: 'mspec.toml',
|
|
20
|
+
content: `description = "Commands for Spec-Driven Development using mspec"
|
|
21
|
+
prompt = """
|
|
22
|
+
You are an AI assistant using the mspec framework.
|
|
23
|
+
When asked to /mspec:spec, /mspec:plan, or /mspec:apply, follow the minimalist spec-driven development guidelines.
|
|
24
|
+
Specs are in .mspec/specs. Tasks are in .mspec/tasks.
|
|
25
|
+
"""
|
|
26
|
+
`
|
|
27
|
+
},
|
|
28
|
+
cursor: {
|
|
29
|
+
dir: '.cursor/rules',
|
|
30
|
+
file: 'mspec.mdc',
|
|
31
|
+
content: `---
|
|
32
|
+
description: Commands for Spec-Driven Development using mspec
|
|
33
|
+
globs: *
|
|
34
|
+
---
|
|
35
|
+
You are an AI assistant using the mspec framework.
|
|
36
|
+
When asked to /mspec:spec, /mspec:plan, or /mspec:apply, follow the minimalist spec-driven development guidelines.
|
|
37
|
+
Specs are in .mspec/specs. Tasks are in .mspec/tasks.
|
|
38
|
+
`
|
|
39
|
+
},
|
|
40
|
+
opencode: {
|
|
41
|
+
dir: '.opencode/commands',
|
|
42
|
+
file: 'mspec.md',
|
|
43
|
+
content: `---
|
|
44
|
+
description: "Commands for Spec-Driven Development using mspec"
|
|
45
|
+
---
|
|
46
|
+
You are an AI assistant using the mspec framework.
|
|
47
|
+
When asked to /mspec:spec, /mspec:plan, or /mspec:apply, follow the minimalist spec-driven development guidelines.
|
|
48
|
+
Specs are in .mspec/specs. Tasks are in .mspec/tasks.
|
|
49
|
+
`
|
|
50
|
+
},
|
|
51
|
+
zed: {
|
|
52
|
+
dir: '.mspec',
|
|
53
|
+
file: 'INSTRUCTIONS.md',
|
|
54
|
+
content: `# mspec Instructions
|
|
55
|
+
|
|
56
|
+
You are an AI assistant using the mspec framework.
|
|
57
|
+
When asked to /mspec:spec, /mspec:plan, or /mspec:apply, follow the minimalist spec-driven development guidelines.
|
|
58
|
+
Specs are in .mspec/specs. Tasks are in .mspec/tasks.
|
|
59
|
+
`
|
|
60
|
+
},
|
|
61
|
+
generic: {
|
|
62
|
+
dir: '.mspec',
|
|
63
|
+
file: 'INSTRUCTIONS.md',
|
|
64
|
+
content: `# mspec Instructions
|
|
65
|
+
|
|
66
|
+
You are an AI assistant using the mspec framework.
|
|
67
|
+
When asked to /mspec:spec, /mspec:plan, or /mspec:apply, follow the minimalist spec-driven development guidelines.
|
|
68
|
+
Specs are in .mspec/specs. Tasks are in .mspec/tasks.
|
|
69
|
+
`
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function getTemplate(agent) {
|
|
73
|
+
return exports.templates[agent];
|
|
74
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mspec",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A minimalist Spec-Driven Development (SDD) toolkit for solo developers and AI agents",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "jest",
|
|
8
|
+
"build": "tsc"
|
|
9
|
+
},
|
|
10
|
+
"keywords": "mspec,spec,sdd,ai,claude,gemini,cursor,zed,opencode",
|
|
11
|
+
"author": "rzkmak",
|
|
12
|
+
"license": "ISC",
|
|
13
|
+
"type": "commonjs",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@types/node": "^25.3.5",
|
|
16
|
+
"chalk": "^4.1.2",
|
|
17
|
+
"commander": "^14.0.3",
|
|
18
|
+
"enquirer": "^2.4.1",
|
|
19
|
+
"ts-node": "^10.9.2"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/jest": "^30.0.0",
|
|
23
|
+
"jest": "^30.2.0",
|
|
24
|
+
"ts-jest": "^29.4.6",
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"mspec": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"repository": "https://github.com/rzkmak/mspec",
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
]
|
|
34
|
+
}
|