@deveonc/spec-to-code 1.0.0-beta.2

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 ADDED
@@ -0,0 +1,123 @@
1
+ # πŸš€ spec-to-code
2
+
3
+ ✨ A lightweight npm package that bootstraps a **Spec-to-Code workflow**,
4
+ from any project folder, whether you're starting a new app or adding features/tests,
5
+ using **LLM agents πŸ€–** and **rules πŸ“œ**.
6
+
7
+ Inspired by Mattia D'Argenio’s *Spec-to-Code* methodology and the **bmad-method**
8
+ approach to agent-based development.
9
+
10
+ ---
11
+
12
+ ## 🧠 Why spec-to-code?
13
+
14
+ Building software with LLMs works **only if it’s structured**.
15
+
16
+ This project helps you move from a vague idea πŸ’‘ to production-ready code πŸ—οΈ by
17
+ organizing LLM interactions into clear, reproducible steps:
18
+
19
+ ➑️ **Specification β†’ Planning β†’ Execution β†’ Review**
20
+
21
+ No prompt spaghetti.
22
+ No magic.
23
+ Just explicit workflows and clearly defined responsibilities.
24
+
25
+ ---
26
+
27
+ ## ⚑ Quick start
28
+
29
+ In a new project, run once:
30
+
31
+ ```
32
+ npx @deveonc/spec-to-code
33
+ ```
34
+
35
+ Then, in your agent tool:
36
+
37
+ ```
38
+ /Architect
39
+ ```
40
+
41
+
42
+ 🧭 The **Architect agent** will automatically guide you through the specification
43
+ process, one question at a time.
44
+
45
+ ---
46
+
47
+ ## πŸ“¦ What gets installed
48
+
49
+ ```
50
+ .ai/
51
+ β”œβ”€β”€ agents/ πŸ€– Architect, Planning, Develop, Reviewer agents
52
+ └── rules/ πŸ“œ Coding & architectural rules
53
+ ```
54
+
55
+ Everything is local, versioned, and auditable.
56
+
57
+ If a `.opencode/` folder exists, the installer generates OpenCode
58
+ commands + agents from `.ai/agents` into `.opencode/commands` and
59
+ `.opencode/agents`.
60
+
61
+ If a `.claude/` folder exists, the installer generates Agent Skills
62
+ from `.ai/agents` into `.claude/skills` (compatible with Claude Code).
63
+
64
+ Installer state is stored in `.ai/config.json` (version, date,
65
+ selected integrations, selected rules).
66
+
67
+ You can regenerate commands, agents, and skills manually:
68
+ ```
69
+ node bin/scripts/opencode/generate-commands.js --agents .ai/agents --out .opencode/commands
70
+ node bin/scripts/opencode/generate-opencode-agents.js --agents .ai/agents --out .opencode/agents
71
+ node bin/scripts/claude/generate-skills.js --agents .ai/agents --out .claude/skills
72
+ ```
73
+
74
+ Script layout (by tool):
75
+ - `bin/scripts/opencode/` - OpenCode commands + agents
76
+ - `bin/scripts/claude/` - Claude Code skills (Agent Skills format)
77
+ - `bin/scripts/rules/` - Rules selection and updates
78
+ - `bin/lib/` - Shared utilities
79
+
80
+ During installation, a checklist is shown for OpenCode/Claude Code
81
+ integration. Existing folders are pre-selected, and you can enable
82
+ or disable each integration before the files are copied. After that,
83
+ you choose which rules to install. Finish by running `/Architect`.
84
+
85
+ On rerun:
86
+ - If the version changed, `.ai` is refreshed (config preserved).
87
+ - If the version is the same, no files are copied unless you change
88
+ integrations or rules.
89
+
90
+ You can also choose which rules are installed. `default.rules.md` is always
91
+ included. To reconfigure later:
92
+ ```
93
+ node bin/scripts/rules/configure-rules.js --out .ai/rules
94
+ ```
95
+
96
+ ---
97
+
98
+ ## 🎯 Designed for
99
+
100
+ - πŸ§‘β€πŸ’» OpenCode users
101
+ - πŸ€– Agent-based LLM workflows
102
+ - πŸ—οΈ Developers who want structure, not vibes
103
+ - πŸ•°οΈ Long-lived, maintainable projects
104
+ - 🧠 Compatibility with Claude Code and OpenCode
105
+
106
+ Works for:
107
+ - creating a brand new application from scratch
108
+ - extending an existing codebase with new features or tests
109
+
110
+ When you request new features after the initial spec, route them through
111
+ Planning to add tasks to `docs/todo.md` before any implementation.
112
+
113
+ ---
114
+
115
+ ## 🧩 Philosophy
116
+
117
+ LLMs are powerful β€” but only when constrained.
118
+
119
+ **spec-to-code** treats prompts as **contracts**,
120
+ agents as **roles**,
121
+ and development as a **workflow**, not a conversation.
122
+
123
+ Happy building πŸš€
@@ -0,0 +1,57 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const toTitle = (value) => value.charAt(0).toUpperCase() + value.slice(1);
5
+
6
+ const parseFrontmatter = (lines) => {
7
+ if (lines.length === 0 || lines[0].trim() !== "---") {
8
+ return { metadata: {}, body: lines.join("\n") };
9
+ }
10
+
11
+ const endIndex = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
12
+ if (endIndex === -1) {
13
+ return { metadata: {}, body: lines.join("\n") };
14
+ }
15
+
16
+ const metadataLines = lines.slice(1, endIndex);
17
+ const body = lines.slice(endIndex + 1).join("\n").replace(/^\n+/u, "");
18
+ const metadata = {};
19
+
20
+ for (const line of metadataLines) {
21
+ const separatorIndex = line.indexOf(":");
22
+ if (separatorIndex === -1) continue;
23
+ const key = line.slice(0, separatorIndex).trim();
24
+ const value = line.slice(separatorIndex + 1).trim();
25
+ if (key) {
26
+ metadata[key] = value;
27
+ }
28
+ }
29
+
30
+ return { metadata, body };
31
+ };
32
+
33
+ const readAgentMetadata = (agentPath) => {
34
+ const content = fs.readFileSync(agentPath, "utf8");
35
+ const { metadata, body } = parseFrontmatter(content.split(/\r?\n/u));
36
+ const baseName = path.basename(agentPath, ".agent.md");
37
+
38
+ return {
39
+ name: metadata.name ?? baseName,
40
+ description: metadata.description ?? `Agent instructions for ${baseName}.`,
41
+ command: metadata.command ?? toTitle(baseName),
42
+ body,
43
+ };
44
+ };
45
+
46
+ const readAllAgentMetadata = (agentsDir) => {
47
+ if (!fs.existsSync(agentsDir)) {
48
+ return [];
49
+ }
50
+
51
+ return fs
52
+ .readdirSync(agentsDir, { withFileTypes: true })
53
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".agent.md"))
54
+ .map((entry) => readAgentMetadata(path.join(agentsDir, entry.name)));
55
+ };
56
+
57
+ export { readAgentMetadata, readAllAgentMetadata, toTitle };
@@ -0,0 +1,81 @@
1
+ import readline from "readline";
2
+
3
+ const askQuestion = (prompt) => new Promise((resolve) => {
4
+ const rl = readline.createInterface({
5
+ input: process.stdin,
6
+ output: process.stdout,
7
+ });
8
+
9
+ rl.question(prompt, (answer) => {
10
+ rl.close();
11
+ resolve(answer);
12
+ });
13
+ });
14
+
15
+ const renderChecklist = (title, items) => {
16
+ if (title) {
17
+ console.log(title);
18
+ }
19
+ items.forEach((item, index) => {
20
+ const marker = item.checked ? "[x]" : "[ ]";
21
+ console.log(` ${marker} ${index + 1}. ${item.label}`);
22
+ });
23
+ };
24
+
25
+ const parseToggleInput = (input) => new Set(
26
+ input
27
+ .split(/[\s,]+/u)
28
+ .map((value) => Number.parseInt(value.trim(), 10))
29
+ .filter((value) => Number.isInteger(value) && value > 0),
30
+ );
31
+
32
+ const promptChecklist = async ({ title, prompt, items }) => {
33
+ renderChecklist(title, items);
34
+
35
+ const answer = await askQuestion(prompt);
36
+ if (!answer.trim()) {
37
+ return items.map((item) => item.checked);
38
+ }
39
+
40
+ const toggles = parseToggleInput(answer);
41
+
42
+ return items.map((item, index) => {
43
+ if (toggles.has(index + 1)) {
44
+ return !item.checked;
45
+ }
46
+ return item.checked;
47
+ });
48
+ };
49
+
50
+ const ensureSelection = async ({
51
+ title,
52
+ prompt,
53
+ items,
54
+ selections,
55
+ fallbackSelections,
56
+ }) => {
57
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
58
+ if (selections.some(Boolean)) {
59
+ return selections;
60
+ }
61
+ if (fallbackSelections) {
62
+ console.warn("⚠️ No selection; using defaults.");
63
+ return fallbackSelections;
64
+ }
65
+ throw new Error("No selection provided.");
66
+ }
67
+
68
+ let current = selections;
69
+ while (!current.some(Boolean)) {
70
+ console.warn("⚠️ Select at least one option to continue.");
71
+ const nextItems = items.map((item, index) => ({
72
+ ...item,
73
+ checked: current[index],
74
+ }));
75
+ current = await promptChecklist({ title, prompt, items: nextItems });
76
+ }
77
+
78
+ return current;
79
+ };
80
+
81
+ export { ensureSelection, promptChecklist };
@@ -0,0 +1,26 @@
1
+ import fs from "fs";
2
+
3
+ const readInstallConfig = (configPath) => {
4
+ if (!fs.existsSync(configPath)) {
5
+ return null;
6
+ }
7
+
8
+ const raw = fs.readFileSync(configPath, "utf8");
9
+ if (!raw.trim()) {
10
+ return null;
11
+ }
12
+
13
+ try {
14
+ return JSON.parse(raw);
15
+ } catch (error) {
16
+ console.warn("⚠️ Skipped: .ai/config.json (invalid JSON)");
17
+ return null;
18
+ }
19
+ };
20
+
21
+ const writeInstallConfig = (configPath, config) => {
22
+ const content = `${JSON.stringify(config, null, 4)}\n`;
23
+ fs.writeFileSync(configPath, content, "utf8");
24
+ };
25
+
26
+ export { readInstallConfig, writeInstallConfig };
@@ -0,0 +1,42 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ const listRuleFiles = (sourceDir) => {
5
+ if (!fs.existsSync(sourceDir)) {
6
+ return [];
7
+ }
8
+
9
+ return fs
10
+ .readdirSync(sourceDir, { withFileTypes: true })
11
+ .filter((entry) => entry.isFile())
12
+ .map((entry) => entry.name)
13
+ .sort((left, right) => left.localeCompare(right));
14
+ };
15
+
16
+ const applyRules = ({ sourceDir, targetDir, selected }) => {
17
+ fs.mkdirSync(targetDir, { recursive: true });
18
+
19
+ const available = listRuleFiles(sourceDir);
20
+ const selectedSet = new Set(selected);
21
+ const results = { copied: [], removed: [] };
22
+
23
+ for (const ruleName of available) {
24
+ const srcFile = path.join(sourceDir, ruleName);
25
+ const dstFile = path.join(targetDir, ruleName);
26
+
27
+ if (selectedSet.has(ruleName)) {
28
+ fs.copyFileSync(srcFile, dstFile);
29
+ results.copied.push(ruleName);
30
+ continue;
31
+ }
32
+
33
+ if (fs.existsSync(dstFile)) {
34
+ fs.unlinkSync(dstFile);
35
+ results.removed.push(ruleName);
36
+ }
37
+ }
38
+
39
+ return results;
40
+ };
41
+
42
+ export { applyRules, listRuleFiles };
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { pathToFileURL } from "url";
5
+
6
+ import { readAgentMetadata, toTitle } from "../../lib/agent-metadata.js";
7
+
8
+ const buildSkillContent = (name, description, agentContent) => {
9
+ const title = toTitle(name);
10
+ const trimmed = agentContent.trimEnd();
11
+
12
+ return [
13
+ "---",
14
+ `name: ${name}`,
15
+ `description: ${description}`,
16
+ "---",
17
+ "",
18
+ `# ${title}`,
19
+ "",
20
+ trimmed,
21
+ "",
22
+ ].join("\n");
23
+ };
24
+
25
+ const parseArgs = (args) => {
26
+ const options = {};
27
+ for (let index = 0; index < args.length; index += 1) {
28
+ const value = args[index];
29
+ if (value === "--agents" && args[index + 1]) {
30
+ options.agentsDir = args[index + 1];
31
+ index += 1;
32
+ continue;
33
+ }
34
+ if (value === "--out" && args[index + 1]) {
35
+ options.skillsDir = args[index + 1];
36
+ index += 1;
37
+ }
38
+ }
39
+ return options;
40
+ };
41
+
42
+ const resolvePath = (value) => path.resolve(process.cwd(), value);
43
+
44
+ const generateSkills = ({ agentsDir, skillsDir }) => {
45
+ if (!fs.existsSync(agentsDir)) {
46
+ throw new Error(`Missing agents directory: ${agentsDir}`);
47
+ }
48
+
49
+ fs.mkdirSync(skillsDir, { recursive: true });
50
+
51
+ const results = { created: [], skipped: [] };
52
+ const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
53
+ for (const entry of entries) {
54
+ if (!entry.isFile() || !entry.name.endsWith(".agent.md")) continue;
55
+
56
+ const agentPath = path.join(agentsDir, entry.name);
57
+ const metadata = readAgentMetadata(agentPath);
58
+ const skillPath = path.join(skillsDir, metadata.name, "SKILL.md");
59
+
60
+ if (fs.existsSync(skillPath)) {
61
+ results.skipped.push(metadata.name);
62
+ continue;
63
+ }
64
+
65
+ const skillContent = buildSkillContent(
66
+ metadata.name,
67
+ metadata.description,
68
+ metadata.body,
69
+ );
70
+
71
+ fs.mkdirSync(path.dirname(skillPath), { recursive: true });
72
+ fs.writeFileSync(skillPath, skillContent, "utf8");
73
+ results.created.push(metadata.name);
74
+ }
75
+
76
+ return results;
77
+ };
78
+
79
+ const run = () => {
80
+ const { agentsDir, skillsDir } = parseArgs(process.argv.slice(2));
81
+ const resolvedAgentsDir = resolvePath(
82
+ agentsDir ?? path.join(process.cwd(), ".ai", "agents"),
83
+ );
84
+ const resolvedSkillsDir = resolvePath(
85
+ skillsDir ?? path.join(process.cwd(), ".claude", "skills"),
86
+ );
87
+
88
+ const { created, skipped } = generateSkills({
89
+ agentsDir: resolvedAgentsDir,
90
+ skillsDir: resolvedSkillsDir,
91
+ });
92
+
93
+ console.log(`βœ… Skills generated in ${resolvedSkillsDir}`);
94
+ if (created.length > 0) {
95
+ console.log(`βœ… Created: ${created.join(", ")}`);
96
+ }
97
+ if (skipped.length > 0) {
98
+ console.log(`⚠️ Skipped: ${skipped.join(", ")}`);
99
+ }
100
+ };
101
+
102
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
103
+ try {
104
+ run();
105
+ } catch (error) {
106
+ console.error(error);
107
+ process.exitCode = 1;
108
+ }
109
+ }
110
+
111
+ export { generateSkills };
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { pathToFileURL } from "url";
5
+
6
+ import { readAgentMetadata } from "../../lib/agent-metadata.js";
7
+
8
+ const parseArgs = (args) => {
9
+ const options = {};
10
+ for (let index = 0; index < args.length; index += 1) {
11
+ const value = args[index];
12
+ if (value === "--agents" && args[index + 1]) {
13
+ options.agentsDir = args[index + 1];
14
+ index += 1;
15
+ continue;
16
+ }
17
+ if (value === "--out" && args[index + 1]) {
18
+ options.commandsDir = args[index + 1];
19
+ index += 1;
20
+ }
21
+ }
22
+ return options;
23
+ };
24
+
25
+ const resolvePath = (value) => path.resolve(process.cwd(), value);
26
+
27
+ const buildCommandContent = (description, agentFileName) => [
28
+ "---",
29
+ `description: ${description}`,
30
+ "---",
31
+ `@.ai/agents/${agentFileName}`,
32
+ "",
33
+ ].join("\n");
34
+
35
+ const generateCommands = ({ agentsDir, commandsDir }) => {
36
+ if (!fs.existsSync(agentsDir)) {
37
+ throw new Error(`Missing agents directory: ${agentsDir}`);
38
+ }
39
+
40
+ fs.mkdirSync(commandsDir, { recursive: true });
41
+
42
+ const results = { created: [], skipped: [] };
43
+ const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
44
+ for (const entry of entries) {
45
+ if (!entry.isFile() || !entry.name.endsWith(".agent.md")) continue;
46
+
47
+ const agentPath = path.join(agentsDir, entry.name);
48
+ const metadata = readAgentMetadata(agentPath);
49
+ const commandFileName = `${metadata.command}.md`;
50
+ const commandPath = path.join(commandsDir, commandFileName);
51
+
52
+ if (fs.existsSync(commandPath)) {
53
+ results.skipped.push(metadata.command);
54
+ continue;
55
+ }
56
+
57
+ const commandContent = buildCommandContent(metadata.description, entry.name);
58
+ fs.writeFileSync(commandPath, commandContent, "utf8");
59
+ results.created.push(metadata.command);
60
+ }
61
+
62
+ return results;
63
+ };
64
+
65
+ const run = () => {
66
+ const { agentsDir, commandsDir } = parseArgs(process.argv.slice(2));
67
+ const resolvedAgentsDir = resolvePath(
68
+ agentsDir ?? path.join(process.cwd(), ".ai", "agents"),
69
+ );
70
+ const resolvedCommandsDir = resolvePath(
71
+ commandsDir ?? path.join(process.cwd(), ".opencode", "commands"),
72
+ );
73
+
74
+ const { created, skipped } = generateCommands({
75
+ agentsDir: resolvedAgentsDir,
76
+ commandsDir: resolvedCommandsDir,
77
+ });
78
+
79
+ console.log(`βœ… Commands generated in ${resolvedCommandsDir}`);
80
+ if (created.length > 0) {
81
+ console.log(`βœ… Created: ${created.join(", ")}`);
82
+ }
83
+ if (skipped.length > 0) {
84
+ console.log(`⚠️ Skipped: ${skipped.join(", ")}`);
85
+ }
86
+ };
87
+
88
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
89
+ try {
90
+ run();
91
+ } catch (error) {
92
+ console.error(error);
93
+ process.exitCode = 1;
94
+ }
95
+ }
96
+
97
+ export { generateCommands };
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { pathToFileURL } from "url";
5
+
6
+ import { readAgentMetadata } from "../../lib/agent-metadata.js";
7
+
8
+ const parseArgs = (args) => {
9
+ const options = {};
10
+ for (let index = 0; index < args.length; index += 1) {
11
+ const value = args[index];
12
+ if (value === "--agents" && args[index + 1]) {
13
+ options.agentsDir = args[index + 1];
14
+ index += 1;
15
+ continue;
16
+ }
17
+ if (value === "--out" && args[index + 1]) {
18
+ options.outDir = args[index + 1];
19
+ index += 1;
20
+ }
21
+ }
22
+ return options;
23
+ };
24
+
25
+ const resolvePath = (value) => path.resolve(process.cwd(), value);
26
+
27
+ const buildAgentContent = (description, body) => [
28
+ "---",
29
+ `description: ${description}`,
30
+ "---",
31
+ body.trimEnd(),
32
+ "",
33
+ ].join("\n");
34
+
35
+ const generateOpencodeAgents = ({ agentsDir, outDir }) => {
36
+ if (!fs.existsSync(agentsDir)) {
37
+ throw new Error(`Missing agents directory: ${agentsDir}`);
38
+ }
39
+
40
+ fs.mkdirSync(outDir, { recursive: true });
41
+
42
+ const results = { created: [], skipped: [] };
43
+ const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
44
+ for (const entry of entries) {
45
+ if (!entry.isFile() || !entry.name.endsWith(".agent.md")) continue;
46
+
47
+ const agentPath = path.join(agentsDir, entry.name);
48
+ const metadata = readAgentMetadata(agentPath);
49
+ const outPath = path.join(outDir, `${metadata.name}.md`);
50
+
51
+ if (fs.existsSync(outPath)) {
52
+ results.skipped.push(metadata.name);
53
+ continue;
54
+ }
55
+
56
+ const content = buildAgentContent(metadata.description, metadata.body);
57
+ fs.writeFileSync(outPath, content, "utf8");
58
+ results.created.push(metadata.name);
59
+ }
60
+
61
+ return results;
62
+ };
63
+
64
+ const run = () => {
65
+ const { agentsDir, outDir } = parseArgs(process.argv.slice(2));
66
+ const resolvedAgentsDir = resolvePath(
67
+ agentsDir ?? path.join(process.cwd(), ".ai", "agents"),
68
+ );
69
+ const resolvedOutDir = resolvePath(
70
+ outDir ?? path.join(process.cwd(), ".opencode", "agents"),
71
+ );
72
+
73
+ const { created, skipped } = generateOpencodeAgents({
74
+ agentsDir: resolvedAgentsDir,
75
+ outDir: resolvedOutDir,
76
+ });
77
+
78
+ console.log(`βœ… OpenCode agents generated in ${resolvedOutDir}`);
79
+ if (created.length > 0) {
80
+ console.log(`βœ… Created: ${created.join(", ")}`);
81
+ }
82
+ if (skipped.length > 0) {
83
+ console.log(`⚠️ Skipped: ${skipped.join(", ")}`);
84
+ }
85
+ };
86
+
87
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
88
+ try {
89
+ run();
90
+ } catch (error) {
91
+ console.error(error);
92
+ process.exitCode = 1;
93
+ }
94
+ }
95
+
96
+ export { generateOpencodeAgents };