doctrina-cli 0.1.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 +105 -0
- package/package.json +38 -0
- package/src/commands/analyze.js +126 -0
- package/src/commands/change.js +292 -0
- package/src/commands/clarify.js +146 -0
- package/src/commands/decision.js +183 -0
- package/src/commands/hooks.js +59 -0
- package/src/commands/init.js +158 -0
- package/src/commands/skill.js +130 -0
- package/src/commands/spec.js +80 -0
- package/src/commands/templates.js +162 -0
- package/src/commands/validate.js +239 -0
- package/src/index.js +102 -0
- package/src/lib/args.js +92 -0
- package/src/lib/colors.js +31 -0
- package/src/lib/dates.js +21 -0
- package/src/lib/fs-ops.js +75 -0
- package/src/lib/index-json.js +109 -0
- package/src/lib/prompt.js +19 -0
- package/src/lib/suggest.js +44 -0
- package/src/lib/templates.js +78 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { isFile, read } from "../lib/fs-ops.js";
|
|
4
|
+
import { c } from "../lib/colors.js";
|
|
5
|
+
|
|
6
|
+
const RULES = [
|
|
7
|
+
{
|
|
8
|
+
// "may" is intentionally NOT in this list: EARS Optional grammar
|
|
9
|
+
// uses "the system may ..." legitimately, and a clarify pass that
|
|
10
|
+
// flags every EARS Optional is unusable on Doctrina-style specs.
|
|
11
|
+
// The remaining weasel terms are unambiguous.
|
|
12
|
+
name: "weasel",
|
|
13
|
+
re: /\b(might|could|should\s+probably|perhaps|maybe|approximately|roughly)\b/gi,
|
|
14
|
+
hint: "state explicitly with a definite verb",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "vague",
|
|
18
|
+
re: /\b(many|few|some|several)\b(?!\s+\d)/gi,
|
|
19
|
+
hint: "quantify (use a number or a precise scope)",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "placeholder",
|
|
23
|
+
re: /\b(TBD|TODO|FIXME|XXX|\?\?\?)\b/g,
|
|
24
|
+
hint: "resolve before applying",
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export async function run(positional, _flags) {
|
|
29
|
+
const target = positional[0];
|
|
30
|
+
if (!target) {
|
|
31
|
+
console.error(c.red("error:") + " clarify requires a path to a Markdown file");
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
const projectRoot = process.cwd();
|
|
35
|
+
const fullPath = path.resolve(projectRoot, target);
|
|
36
|
+
if (!isFile(fullPath)) {
|
|
37
|
+
console.error(c.red("error:") + ` file not found: ${target}`);
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`clarify ${path.relative(projectRoot, fullPath)}`);
|
|
42
|
+
console.log("");
|
|
43
|
+
|
|
44
|
+
const text = read(fullPath);
|
|
45
|
+
const skippedRanges = collectSkippedRanges(text);
|
|
46
|
+
const smells = [];
|
|
47
|
+
|
|
48
|
+
// Per-line rules: regex pass with skip-range filter.
|
|
49
|
+
const lines = text.split("\n");
|
|
50
|
+
let cursor = 0;
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
const lineStart = cursor;
|
|
54
|
+
const lineEnd = cursor + line.length;
|
|
55
|
+
for (const rule of RULES) {
|
|
56
|
+
rule.re.lastIndex = 0;
|
|
57
|
+
let m;
|
|
58
|
+
while ((m = rule.re.exec(line))) {
|
|
59
|
+
const absoluteStart = lineStart + m.index;
|
|
60
|
+
if (isInSkippedRange(absoluteStart, skippedRanges)) continue;
|
|
61
|
+
smells.push({
|
|
62
|
+
line: i + 1,
|
|
63
|
+
rule: rule.name,
|
|
64
|
+
token: m[0],
|
|
65
|
+
hint: rule.hint,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
cursor = lineEnd + 1; // +1 for the newline
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Section rule: empty Acceptance criteria.
|
|
73
|
+
if (hasEmptyAcceptanceCriteria(text)) {
|
|
74
|
+
smells.push({
|
|
75
|
+
line: findAcceptanceLine(text),
|
|
76
|
+
rule: "empty-section",
|
|
77
|
+
token: "Acceptance criteria",
|
|
78
|
+
hint: "fill in observable, verifiable signals",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
smells.sort((a, b) => a.line - b.line);
|
|
83
|
+
|
|
84
|
+
for (const s of smells) {
|
|
85
|
+
console.log(`${c.yellow("⚠")} line ${s.line}: ${s.rule} "${s.token}" — ${s.hint}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log("");
|
|
89
|
+
if (smells.length === 0) {
|
|
90
|
+
console.log(c.green("ok") + " no smells found");
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
const word = smells.length === 1 ? "smell" : "smells";
|
|
94
|
+
console.log(c.red("fail") + ` ${smells.length} ${word}`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Build ranges to skip: fenced code blocks, HTML comments, inline backticks.
|
|
99
|
+
function collectSkippedRanges(text) {
|
|
100
|
+
const ranges = [];
|
|
101
|
+
// Fenced code blocks (``` ... ```)
|
|
102
|
+
for (const m of text.matchAll(/```[\s\S]*?```/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
103
|
+
// HTML comments (<!-- ... -->)
|
|
104
|
+
for (const m of text.matchAll(/<!--[\s\S]*?-->/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
105
|
+
// Inline backtick spans (`...`)
|
|
106
|
+
for (const m of text.matchAll(/`[^`\n]+`/g)) ranges.push([m.index, m.index + m[0].length]);
|
|
107
|
+
return ranges;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isInSkippedRange(offset, ranges) {
|
|
111
|
+
for (const [a, b] of ranges) if (offset >= a && offset < b) return true;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function hasEmptyAcceptanceCriteria(text) {
|
|
116
|
+
const headingRe = /^##\s+Acceptance criteria\s*$/m;
|
|
117
|
+
const m = headingRe.exec(text);
|
|
118
|
+
if (!m) return false;
|
|
119
|
+
const after = text.slice(m.index + m[0].length);
|
|
120
|
+
// Find the body between this heading and the next heading of equal level.
|
|
121
|
+
const nextHeadingMatch = after.match(/^##\s+/m);
|
|
122
|
+
const body = nextHeadingMatch ? after.slice(0, nextHeadingMatch.index) : after;
|
|
123
|
+
// Strip HTML comments before checking emptiness.
|
|
124
|
+
const stripped = body.replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
125
|
+
return stripped.length === 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findAcceptanceLine(text) {
|
|
129
|
+
const lines = text.split("\n");
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
if (/^##\s+Acceptance criteria\s*$/.test(lines[i])) return i + 1;
|
|
132
|
+
}
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const help = `
|
|
137
|
+
Usage: doctrina clarify <path>
|
|
138
|
+
|
|
139
|
+
Read a Markdown file and report weasel words, vague quantifiers,
|
|
140
|
+
placeholders, and empty Acceptance criteria sections. Exits 0 when
|
|
141
|
+
no smells are found, 1 otherwise. Matches inside fenced code
|
|
142
|
+
blocks, HTML comments, and inline backtick spans are skipped.
|
|
143
|
+
|
|
144
|
+
Intended use: run before \`doctrina change apply\` or before
|
|
145
|
+
opening a PR. The command never modifies files.
|
|
146
|
+
`;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists, read, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { walk } from "../lib/fs-ops.js";
|
|
5
|
+
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
|
+
import * as idx from "../lib/index-json.js";
|
|
7
|
+
import { today, slugify, padNumber } from "../lib/dates.js";
|
|
8
|
+
import { c } from "../lib/colors.js";
|
|
9
|
+
import { suggest } from "../lib/suggest.js";
|
|
10
|
+
|
|
11
|
+
const SUBCOMMANDS = ["new", "supersede"];
|
|
12
|
+
|
|
13
|
+
export async function run(positional, _flags) {
|
|
14
|
+
const sub = positional[0];
|
|
15
|
+
switch (sub) {
|
|
16
|
+
case "new":
|
|
17
|
+
return decisionNew(positional.slice(1));
|
|
18
|
+
case "supersede":
|
|
19
|
+
return decisionSupersede(positional.slice(1));
|
|
20
|
+
default:
|
|
21
|
+
console.error(c.red("error:") + ` unknown decision subcommand "${sub ?? ""}"`);
|
|
22
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
23
|
+
console.error(c.gray("hint: ") + (guess
|
|
24
|
+
? `did you mean \`doctrina decision ${guess}\`?`
|
|
25
|
+
: `available: ${SUBCOMMANDS.join(", ")}`));
|
|
26
|
+
return 2;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function decisionNew(args) {
|
|
31
|
+
const title = args.join(" ").trim();
|
|
32
|
+
if (!title) {
|
|
33
|
+
console.error(c.red("error:") + " decision new requires \"<title>\"");
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
const projectRoot = process.cwd();
|
|
37
|
+
ensureDoctrinaProject(projectRoot);
|
|
38
|
+
|
|
39
|
+
const next = nextDecisionNumber(projectRoot);
|
|
40
|
+
const date = today();
|
|
41
|
+
const slug = slugify(title);
|
|
42
|
+
const filename = `${next}-${slug}.md`;
|
|
43
|
+
const targetPath = path.join(projectRoot, ".doctrina", "decisions", filename);
|
|
44
|
+
if (exists(targetPath)) {
|
|
45
|
+
console.error(c.red("error:") + ` ${path.relative(projectRoot, targetPath)} already exists`);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const templatesDir = locateTemplatesDir();
|
|
50
|
+
const tpl = read(path.join(templatesDir, "decision.md.template"));
|
|
51
|
+
const body = substitute(tpl, {
|
|
52
|
+
DECISION_NUMBER: next,
|
|
53
|
+
DECISION_TITLE: title,
|
|
54
|
+
DECISION_SLUG: slug,
|
|
55
|
+
DATE: date,
|
|
56
|
+
});
|
|
57
|
+
write(targetPath, body, { force: false });
|
|
58
|
+
console.log(c.green("created") + ` ${path.relative(projectRoot, targetPath)}`);
|
|
59
|
+
|
|
60
|
+
const index = idx.load(projectRoot);
|
|
61
|
+
idx.addDecision(index, {
|
|
62
|
+
id: next,
|
|
63
|
+
path: `.doctrina/decisions/${filename}`,
|
|
64
|
+
title,
|
|
65
|
+
status: "proposed",
|
|
66
|
+
date,
|
|
67
|
+
});
|
|
68
|
+
idx.touch(index, date);
|
|
69
|
+
idx.save(projectRoot, index);
|
|
70
|
+
console.log(c.green("indexed") + ` decision ${next}`);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function decisionSupersede(args) {
|
|
75
|
+
const target = args[0];
|
|
76
|
+
if (!target) {
|
|
77
|
+
console.error(c.red("error:") + " decision supersede requires the target ADR number (e.g. 0003)");
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
const projectRoot = process.cwd();
|
|
81
|
+
ensureDoctrinaProject(projectRoot);
|
|
82
|
+
|
|
83
|
+
const padded = padNumber(parseInt(target, 10));
|
|
84
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
85
|
+
const files = walk(adrDir);
|
|
86
|
+
const oldFile = files.find((f) => path.basename(f).startsWith(`${padded}-`));
|
|
87
|
+
if (!oldFile) {
|
|
88
|
+
console.error(c.red("error:") + ` no ADR found with number ${padded} in ${path.relative(projectRoot, adrDir)}`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
const oldText = read(oldFile);
|
|
92
|
+
const oldStatusMatch = oldText.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
|
|
93
|
+
if (!oldStatusMatch) {
|
|
94
|
+
console.error(c.red("error:") + ` ADR at ${path.relative(projectRoot, oldFile)} has no Status: header`);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const oldStatus = oldStatusMatch[1].trim();
|
|
98
|
+
if (oldStatus.toLowerCase().startsWith("superseded")) {
|
|
99
|
+
console.error(c.red("error:") + ` ADR ${padded} is already superseded`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Title for new ADR is read from argv after the target number, or prompted.
|
|
104
|
+
const title = args.slice(1).join(" ").trim();
|
|
105
|
+
if (!title) {
|
|
106
|
+
console.error(c.red("error:") + " supply the new ADR title as the second argument");
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Create the new ADR using the same path the `new` flow uses.
|
|
111
|
+
const next = nextDecisionNumber(projectRoot);
|
|
112
|
+
const date = today();
|
|
113
|
+
const slug = slugify(title);
|
|
114
|
+
const filename = `${next}-${slug}.md`;
|
|
115
|
+
const newPath = path.join(adrDir, filename);
|
|
116
|
+
const templatesDir = locateTemplatesDir();
|
|
117
|
+
const tpl = read(path.join(templatesDir, "decision.md.template"));
|
|
118
|
+
let body = substitute(tpl, {
|
|
119
|
+
DECISION_NUMBER: next,
|
|
120
|
+
DECISION_TITLE: title,
|
|
121
|
+
DECISION_SLUG: slug,
|
|
122
|
+
DATE: date,
|
|
123
|
+
});
|
|
124
|
+
// Inject Supersedes link
|
|
125
|
+
body = body.replace(/(\*\*Supersedes:\*\*)\s+—/, `$1 ${padded}`);
|
|
126
|
+
write(newPath, body, { force: false });
|
|
127
|
+
console.log(c.green("created") + ` ${path.relative(projectRoot, newPath)}`);
|
|
128
|
+
|
|
129
|
+
// Mutate ONLY the Status: and Superseded by: headers of the old ADR
|
|
130
|
+
const updated = oldText
|
|
131
|
+
.replace(/^(-\s+\*\*Status:\*\*)\s+.+$/m, `$1 superseded by ${next}`)
|
|
132
|
+
.replace(/^(-\s+\*\*Superseded by:\*\*)\s+.+$/m, `$1 ${next}`);
|
|
133
|
+
write(oldFile, updated, { force: true });
|
|
134
|
+
console.log(c.green("updated") + ` ${path.relative(projectRoot, oldFile)} status -> superseded by ${next}`);
|
|
135
|
+
|
|
136
|
+
// Update index
|
|
137
|
+
const index = idx.load(projectRoot);
|
|
138
|
+
idx.addDecision(index, {
|
|
139
|
+
id: next,
|
|
140
|
+
path: `.doctrina/decisions/${filename}`,
|
|
141
|
+
title,
|
|
142
|
+
status: "proposed",
|
|
143
|
+
date,
|
|
144
|
+
supersedes: padded,
|
|
145
|
+
});
|
|
146
|
+
idx.updateDecision(index, padded, () => ({
|
|
147
|
+
status: `superseded by ${next}`,
|
|
148
|
+
superseded_by: next,
|
|
149
|
+
}));
|
|
150
|
+
idx.touch(index, date);
|
|
151
|
+
idx.save(projectRoot, index);
|
|
152
|
+
console.log(c.green("indexed") + ` supersession recorded`);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function nextDecisionNumber(projectRoot) {
|
|
157
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
158
|
+
const files = walk(adrDir);
|
|
159
|
+
let max = 0;
|
|
160
|
+
for (const f of files) {
|
|
161
|
+
const m = path.basename(f).match(/^(\d{4})-/);
|
|
162
|
+
if (m) {
|
|
163
|
+
const n = parseInt(m[1], 10);
|
|
164
|
+
if (n > max) max = n;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return padNumber(max + 1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function ensureDoctrinaProject(projectRoot) {
|
|
171
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
172
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const help = `
|
|
177
|
+
Usage: doctrina decision <subcommand> [args]
|
|
178
|
+
|
|
179
|
+
Subcommands:
|
|
180
|
+
new "<title>" Create the next sequentially numbered ADR
|
|
181
|
+
supersede <number> "<new title>" Create a new ADR that supersedes the target
|
|
182
|
+
and rewrite the target's Status: header
|
|
183
|
+
`;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { chmodSync } from "node:fs";
|
|
4
|
+
import { exists, mkdirp, read, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { locateTemplatesDir } from "../lib/templates.js";
|
|
6
|
+
import { flagBool } from "../lib/args.js";
|
|
7
|
+
import { c } from "../lib/colors.js";
|
|
8
|
+
import { suggest } from "../lib/suggest.js";
|
|
9
|
+
|
|
10
|
+
const SUBCOMMANDS = ["install"];
|
|
11
|
+
|
|
12
|
+
export async function run(positional, flags) {
|
|
13
|
+
const sub = positional[0];
|
|
14
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
15
|
+
console.error(c.red("error:") + ` unknown hooks subcommand "${sub ?? ""}"`);
|
|
16
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
17
|
+
console.error(c.gray("hint: ") + (guess
|
|
18
|
+
? `did you mean \`doctrina hooks ${guess}\`?`
|
|
19
|
+
: `available: ${SUBCOMMANDS.join(", ")}`));
|
|
20
|
+
return 2;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const force = flagBool(flags, "force", false);
|
|
24
|
+
const projectRoot = process.cwd();
|
|
25
|
+
|
|
26
|
+
if (!exists(path.join(projectRoot, ".git"))) {
|
|
27
|
+
console.error(c.red("error:") + " not a git repository (no .git/ in cwd)");
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const hooksDir = path.join(projectRoot, ".git", "hooks");
|
|
32
|
+
mkdirp(hooksDir);
|
|
33
|
+
|
|
34
|
+
const target = path.join(hooksDir, "pre-commit");
|
|
35
|
+
if (exists(target) && !force) {
|
|
36
|
+
console.error(c.red("error:") + ` ${path.relative(projectRoot, target)} already exists (pass --force to overwrite)`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const templatesDir = locateTemplatesDir();
|
|
41
|
+
const tplPath = path.join(templatesDir, "hooks", "pre-commit.sample");
|
|
42
|
+
const body = read(tplPath);
|
|
43
|
+
write(target, body, { force: true });
|
|
44
|
+
chmodSync(target, 0o755);
|
|
45
|
+
console.log(c.green("installed") + ` ${path.relative(projectRoot, target)}`);
|
|
46
|
+
console.log(`The hook runs ${c.cyan("doctrina validate")} before each commit.`);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const help = `
|
|
51
|
+
Usage: doctrina hooks install [--force]
|
|
52
|
+
|
|
53
|
+
Install the Doctrina pre-commit hook into .git/hooks/pre-commit.
|
|
54
|
+
The hook runs \`doctrina validate\` and blocks the commit when
|
|
55
|
+
validation reports errors.
|
|
56
|
+
|
|
57
|
+
Options:
|
|
58
|
+
--force Overwrite an existing pre-commit hook
|
|
59
|
+
`;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists, isDir, isFile, read, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
|
|
5
|
+
import { today } from "../lib/dates.js";
|
|
6
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
7
|
+
import { c } from "../lib/colors.js";
|
|
8
|
+
import { ask } from "../lib/prompt.js";
|
|
9
|
+
|
|
10
|
+
const SUPPORTED_AGENTS = [
|
|
11
|
+
"claude",
|
|
12
|
+
"codex",
|
|
13
|
+
"cursor",
|
|
14
|
+
"copilot",
|
|
15
|
+
"gemini",
|
|
16
|
+
"aider",
|
|
17
|
+
"windsurf",
|
|
18
|
+
"continue",
|
|
19
|
+
"amp",
|
|
20
|
+
"devin",
|
|
21
|
+
"factory",
|
|
22
|
+
"jules",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export async function run(_positional, flags) {
|
|
26
|
+
const force = flagBool(flags, "force", false);
|
|
27
|
+
const nonInteractive = flagBool(flags, "non-interactive", false);
|
|
28
|
+
const projectRoot = process.cwd();
|
|
29
|
+
const projectName = flagString(flags, "project-name") ?? path.basename(projectRoot);
|
|
30
|
+
const date = flagString(flags, "date") ?? today();
|
|
31
|
+
|
|
32
|
+
let description = flagString(flags, "project-description") ?? "";
|
|
33
|
+
if (!description && !nonInteractive) {
|
|
34
|
+
description = await ask("One-sentence project description:");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const agentSelector = flagString(flags, "agent");
|
|
38
|
+
const fromPath = flagString(flags, "from");
|
|
39
|
+
const agentsMdPath = path.join(projectRoot, "AGENTS.md");
|
|
40
|
+
const doctrinaDir = path.join(projectRoot, ".doctrina");
|
|
41
|
+
|
|
42
|
+
if (!force && (exists(agentsMdPath) || exists(doctrinaDir))) {
|
|
43
|
+
console.error(c.red("error:") + " AGENTS.md or .doctrina/ already exists at this path");
|
|
44
|
+
console.error(c.gray("hint: ") + "pass --force to overwrite");
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve --from conventions source if supplied.
|
|
49
|
+
let fromAgentsContent = null;
|
|
50
|
+
let fromProductContent = null;
|
|
51
|
+
if (fromPath) {
|
|
52
|
+
const fromAbs = path.resolve(projectRoot, fromPath);
|
|
53
|
+
if (!isDir(fromAbs)) {
|
|
54
|
+
console.error(c.red("error:") + ` --from path is not a directory: ${fromPath}`);
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
const fromAgents = path.join(fromAbs, "AGENTS.md");
|
|
58
|
+
const fromProduct = path.join(fromAbs, ".doctrina", "product.md");
|
|
59
|
+
if (isFile(fromAgents)) fromAgentsContent = read(fromAgents);
|
|
60
|
+
if (isFile(fromProduct)) fromProductContent = read(fromProduct);
|
|
61
|
+
if (!fromAgentsContent && !fromProductContent) {
|
|
62
|
+
console.error(c.yellow("warn:") + ` no AGENTS.md or .doctrina/product.md found at ${fromPath}, proceeding with defaults`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const templatesDir = locateTemplatesDir();
|
|
67
|
+
const tokens = {
|
|
68
|
+
PROJECT_NAME: projectName,
|
|
69
|
+
PROJECT_DESCRIPTION: description,
|
|
70
|
+
DATE: date,
|
|
71
|
+
AGENTS_MD_PATH: "AGENTS.md",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Root AGENTS.md — with optional --from base content prepended.
|
|
75
|
+
const agentsTemplatePath = path.join(templatesDir, "AGENTS.md.template");
|
|
76
|
+
const agentsTemplateBody = read(agentsTemplatePath);
|
|
77
|
+
const projectAgents = substitute(agentsTemplateBody, tokens);
|
|
78
|
+
const finalAgents = fromAgentsContent
|
|
79
|
+
? fromAgentsContent.replace(/\n+$/, "") + "\n\n---\n\n## Project-specific\n\n" + projectAgents
|
|
80
|
+
: projectAgents;
|
|
81
|
+
write(agentsMdPath, finalAgents, { force });
|
|
82
|
+
console.log(c.green("created") + ` ${path.relative(projectRoot, agentsMdPath)}` + (fromAgentsContent ? " (with --from base)" : ""));
|
|
83
|
+
const finalLines = finalAgents.split("\n").length;
|
|
84
|
+
if (finalLines > 150) {
|
|
85
|
+
console.log(c.yellow("warn:") + ` AGENTS.md is ${finalLines} lines (>150 soft cap); consider trimming the base`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// .doctrina/ skeleton
|
|
89
|
+
const skeleton = loadTemplateTree(templatesDir, "doctrina");
|
|
90
|
+
for (const entry of skeleton) {
|
|
91
|
+
// Inject --from product.md vision if present
|
|
92
|
+
if (fromProductContent && entry.relativePath === "product.md.template") {
|
|
93
|
+
const visionMatch = fromProductContent.match(/##\s+Vision\b[\s\S]*?(?=\n##\s|$)/);
|
|
94
|
+
const visionBlock = visionMatch ? visionMatch[0].trim() : fromProductContent.trim();
|
|
95
|
+
const projectBody = substitute(entry.body, tokens);
|
|
96
|
+
const merged = `# ${projectName} — Product\n\n## Conventions base (from ${fromPath})\n\n${visionBlock}\n\n---\n\n${projectBody.replace(/^#[^\n]*\n+/, "")}`;
|
|
97
|
+
const dest = path.join(doctrinaDir, "product.md");
|
|
98
|
+
write(dest, merged, { force });
|
|
99
|
+
console.log(c.green("created") + ` ${path.relative(projectRoot, dest)} (with --from base)`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const written = materialiseEntry(entry, doctrinaDir, tokens, { force });
|
|
103
|
+
console.log(c.green("created") + ` ${path.relative(projectRoot, written)}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Adapters
|
|
107
|
+
if (agentSelector) {
|
|
108
|
+
const agents = agentSelector === "all" ? SUPPORTED_AGENTS : [agentSelector];
|
|
109
|
+
for (const agent of agents) {
|
|
110
|
+
if (!SUPPORTED_AGENTS.includes(agent)) {
|
|
111
|
+
console.error(c.yellow("warn:") + ` unknown agent "${agent}", skipping`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
installAdapter(templatesDir, projectRoot, agent, tokens, { force });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log("");
|
|
119
|
+
console.log(c.bold("Doctrina initialised."));
|
|
120
|
+
console.log(`Next: edit ${c.cyan("AGENTS.md")} and ${c.cyan(".doctrina/product.md")} for your project.`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function installAdapter(templatesDir, projectRoot, agent, tokens, opts) {
|
|
125
|
+
const tree = loadTemplateTree(templatesDir, path.join("adapters", agent));
|
|
126
|
+
for (const entry of tree) {
|
|
127
|
+
if (entry.relativePath === "README.md") continue;
|
|
128
|
+
const adapterTokens = {
|
|
129
|
+
...tokens,
|
|
130
|
+
AGENTS_MD_PATH: adapterAgentsMdRelativePath(entry.relativePath),
|
|
131
|
+
};
|
|
132
|
+
const written = materialiseEntry(entry, projectRoot, adapterTokens, opts);
|
|
133
|
+
console.log(c.green(`adapter[${agent}]`) + ` ${path.relative(projectRoot, written)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function adapterAgentsMdRelativePath(relativePath) {
|
|
138
|
+
const cleaned = relativePath.replace(/\.template$/, "");
|
|
139
|
+
const depth = cleaned.split(path.sep).length - 1;
|
|
140
|
+
return depth === 0 ? "AGENTS.md" : "../".repeat(depth) + "AGENTS.md";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const help = `
|
|
144
|
+
Usage: doctrina init [options]
|
|
145
|
+
|
|
146
|
+
Scaffold AGENTS.md and the .doctrina/ skeleton in the current directory.
|
|
147
|
+
|
|
148
|
+
Options:
|
|
149
|
+
--project-name <name> Override the project name (default: basename of cwd)
|
|
150
|
+
--project-description <text> One-sentence description
|
|
151
|
+
--agent <name> Install adapter for one agent
|
|
152
|
+
(claude|codex|cursor|copilot|gemini|aider|windsurf|continue|amp|devin|factory|jules|all)
|
|
153
|
+
--from <path> Local conventions directory; if it contains AGENTS.md and/or
|
|
154
|
+
.doctrina/product.md, the content is folded into the new project
|
|
155
|
+
--date <YYYY-MM-DD> Override the system date
|
|
156
|
+
--force Overwrite existing files
|
|
157
|
+
--non-interactive Fail instead of prompting for missing values
|
|
158
|
+
`;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists, isDir, mkdirp, read, walk, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
5
|
+
import * as idx from "../lib/index-json.js";
|
|
6
|
+
import { today } from "../lib/dates.js";
|
|
7
|
+
import { flagBool } from "../lib/args.js";
|
|
8
|
+
import { c } from "../lib/colors.js";
|
|
9
|
+
import { suggest } from "../lib/suggest.js";
|
|
10
|
+
|
|
11
|
+
const SUBCOMMANDS = ["new", "list"];
|
|
12
|
+
|
|
13
|
+
export async function run(positional, flags) {
|
|
14
|
+
const sub = positional[0];
|
|
15
|
+
if (!SUBCOMMANDS.includes(sub)) {
|
|
16
|
+
console.error(c.red("error:") + ` unknown skill subcommand "${sub ?? ""}"`);
|
|
17
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
18
|
+
console.error(c.gray("hint: ") + (guess
|
|
19
|
+
? `did you mean \`doctrina skill ${guess}\`?`
|
|
20
|
+
: `available: ${SUBCOMMANDS.join(", ")}`));
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
23
|
+
if (sub === "new") return skillNew(positional.slice(1), flags);
|
|
24
|
+
return skillList();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function skillNew(args, flags) {
|
|
28
|
+
const name = args[0];
|
|
29
|
+
if (!name || !/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
30
|
+
console.error(c.red("error:") + " skill name must be lowercase letters, digits, or hyphens (e.g. \"db-migration\")");
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
const force = flagBool(flags, "force", false);
|
|
34
|
+
const projectRoot = process.cwd();
|
|
35
|
+
ensureDoctrinaProject(projectRoot);
|
|
36
|
+
|
|
37
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
38
|
+
mkdirp(skillsDir);
|
|
39
|
+
const targetPath = path.join(skillsDir, `${name}.md`);
|
|
40
|
+
if (exists(targetPath) && !force) {
|
|
41
|
+
console.error(c.red("error:") + ` ${path.relative(projectRoot, targetPath)} already exists (pass --force to overwrite)`);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const templatesDir = locateTemplatesDir();
|
|
46
|
+
const tplPath = path.join(templatesDir, "skill.md.template");
|
|
47
|
+
const body = substitute(read(tplPath), { SKILL_NAME: name });
|
|
48
|
+
write(targetPath, body, { force });
|
|
49
|
+
console.log(c.green("created") + ` ${path.relative(projectRoot, targetPath)}`);
|
|
50
|
+
|
|
51
|
+
const date = today();
|
|
52
|
+
const index = idx.load(projectRoot);
|
|
53
|
+
idx.addSkill(index, {
|
|
54
|
+
id: name,
|
|
55
|
+
path: `.doctrina/skills/${name}.md`,
|
|
56
|
+
description: "<edit me — one-sentence summary>",
|
|
57
|
+
last_updated: date,
|
|
58
|
+
});
|
|
59
|
+
idx.touch(index, date);
|
|
60
|
+
idx.save(projectRoot, index);
|
|
61
|
+
console.log(c.green("indexed") + ` skill "${name}"`);
|
|
62
|
+
console.log("");
|
|
63
|
+
console.log(c.gray("hint: ") + "fill the description and when frontmatter fields, then update index.json description by hand");
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function skillList() {
|
|
68
|
+
const projectRoot = process.cwd();
|
|
69
|
+
ensureDoctrinaProject(projectRoot);
|
|
70
|
+
|
|
71
|
+
const skillsDir = path.join(projectRoot, ".doctrina", "skills");
|
|
72
|
+
if (!isDir(skillsDir)) {
|
|
73
|
+
console.log(c.gray("no skills directory; create one with `doctrina skill new <name>`"));
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const files = walk(skillsDir).filter((f) => f.endsWith(".md"));
|
|
78
|
+
if (files.length === 0) {
|
|
79
|
+
console.log(c.gray("no skills found in .doctrina/skills/"));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(c.bold("Skills:"));
|
|
84
|
+
console.log("");
|
|
85
|
+
for (const f of files) {
|
|
86
|
+
const text = read(f);
|
|
87
|
+
const name = parseFrontmatter(text, "name") ?? path.basename(f, ".md");
|
|
88
|
+
const desc = parseFrontmatter(text, "description") ?? c.gray("<missing description>");
|
|
89
|
+
console.log(` ${c.cyan(name.padEnd(24))} ${desc}`);
|
|
90
|
+
}
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(c.gray(`${files.length} skill${files.length === 1 ? "" : "s"}`));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseFrontmatter(text, key) {
|
|
97
|
+
// Match frontmatter blocks bounded by `---` lines at start of file.
|
|
98
|
+
const fmMatch = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
|
|
99
|
+
if (!fmMatch) return null;
|
|
100
|
+
const block = fmMatch[1];
|
|
101
|
+
const lineRe = new RegExp(`^${key}\\s*:\\s*(.+)$`, "m");
|
|
102
|
+
const m = block.match(lineRe);
|
|
103
|
+
return m ? m[1].trim() : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ensureDoctrinaProject(projectRoot) {
|
|
107
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
108
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const help = `
|
|
113
|
+
Usage: doctrina skill <subcommand>
|
|
114
|
+
|
|
115
|
+
Manage on-demand procedural memory — specialised "how to do X"
|
|
116
|
+
knowledge loaded only when relevant.
|
|
117
|
+
|
|
118
|
+
Subcommands:
|
|
119
|
+
new <name> Scaffold a new skill from the template and index
|
|
120
|
+
it. Name must match [a-z][a-z0-9-]*.
|
|
121
|
+
list Print one line per skill with name + description.
|
|
122
|
+
Read-only.
|
|
123
|
+
|
|
124
|
+
Skills are written by humans, not generated. See docs/en/skills.md
|
|
125
|
+
for the design rationale and the distinction from specs / AGENTS.md /
|
|
126
|
+
the rejected memory/ folder.
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
// Re-export the frontmatter parser so validate.js can reuse it.
|
|
130
|
+
export { parseFrontmatter };
|