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.
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists, read, 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"];
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 spec subcommand "${sub ?? ""}"`);
17
+ const guess = suggest(sub, SUBCOMMANDS);
18
+ console.error(c.gray("hint: ") + (guess
19
+ ? `did you mean \`doctrina spec ${guess}\`?`
20
+ : `available: ${SUBCOMMANDS.join(", ")}`));
21
+ return 2;
22
+ }
23
+
24
+ const capability = positional[1];
25
+ if (!capability || !/^[a-z][a-z0-9-]*$/.test(capability)) {
26
+ console.error(c.red("error:") + " capability must be lowercase letters, digits, or hyphens (e.g. \"core\", \"templates\").");
27
+ return 2;
28
+ }
29
+
30
+ const force = flagBool(flags, "force", false);
31
+ const bug = flagBool(flags, "bug", false);
32
+ const projectRoot = process.cwd();
33
+ ensureDoctrinaProject(projectRoot);
34
+
35
+ const targetDir = path.join(projectRoot, ".doctrina", "specs", capability);
36
+ const targetPath = path.join(targetDir, "spec.md");
37
+ if (exists(targetPath) && !force) {
38
+ console.error(c.red("error:") + ` ${path.relative(projectRoot, targetPath)} already exists (pass --force to overwrite)`);
39
+ return 1;
40
+ }
41
+
42
+ const templatesDir = locateTemplatesDir();
43
+ const tplName = bug ? "spec-bug.md.template" : "spec.md.template";
44
+ const tplPath = path.join(templatesDir, tplName);
45
+ const date = today();
46
+ const body = substitute(read(tplPath), { CAPABILITY: capability, DATE: date });
47
+ write(targetPath, body, { force });
48
+ console.log(c.green("created") + ` ${path.relative(projectRoot, targetPath)}${bug ? " (bug-spec shape)" : ""}`);
49
+
50
+ const index = idx.load(projectRoot);
51
+ idx.addSpec(index, {
52
+ id: capability,
53
+ path: `.doctrina/specs/${capability}/spec.md`,
54
+ status: "active",
55
+ version: "0.1.0",
56
+ last_updated: date,
57
+ });
58
+ idx.touch(index, date);
59
+ idx.save(projectRoot, index);
60
+ console.log(c.green("indexed") + ` spec "${capability}"`);
61
+ return 0;
62
+ }
63
+
64
+ function ensureDoctrinaProject(projectRoot) {
65
+ if (!exists(path.join(projectRoot, ".doctrina"))) {
66
+ throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
67
+ }
68
+ }
69
+
70
+ export const help = `
71
+ Usage: doctrina spec new <capability> [--bug] [--force]
72
+
73
+ Create a new capability spec under .doctrina/specs/<capability>/spec.md
74
+ and add an entry to .doctrina/index.json.
75
+
76
+ Options:
77
+ --bug Scaffold the bug-shape template (current / expected /
78
+ unchanged behaviour) instead of the EARS capability spec.
79
+ --force Overwrite an existing spec.
80
+ `;
@@ -0,0 +1,162 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists, isDir, isFile, lineCount, read, walk } from "../lib/fs-ops.js";
4
+ import { locateTemplatesDir } from "../lib/templates.js";
5
+ import { c } from "../lib/colors.js";
6
+ import { suggest } from "../lib/suggest.js";
7
+
8
+ const SUBCOMMANDS = ["list", "check"];
9
+
10
+ // Recommended sections per file kind. Adopters whose files lack these
11
+ // headings get a warning from `templates check`; they are recommendations,
12
+ // not hard requirements (validate handles the hard requirements).
13
+ const AGENTS_SECTIONS = [
14
+ "## Stack",
15
+ "## Commands",
16
+ "## Repository structure",
17
+ "## Conventions and boundaries",
18
+ "## How to read context",
19
+ ];
20
+ const PRODUCT_SECTIONS = [
21
+ "## Vision",
22
+ "## Problem",
23
+ "## Target users",
24
+ "## Scope",
25
+ "## Success criteria",
26
+ ];
27
+ const INDEX_FIELDS = ["$schema_version", "project", "artifacts"];
28
+
29
+ export async function run(positional, _flags) {
30
+ const sub = positional[0];
31
+ if (!SUBCOMMANDS.includes(sub)) {
32
+ console.error(c.red("error:") + ` unknown templates subcommand "${sub ?? ""}"`);
33
+ const guess = suggest(sub, SUBCOMMANDS);
34
+ console.error(c.gray("hint: ") + (guess
35
+ ? `did you mean \`doctrina templates ${guess}\`?`
36
+ : `available: ${SUBCOMMANDS.join(", ")}`));
37
+ return 2;
38
+ }
39
+ if (sub === "list") return listTemplates();
40
+ return checkTemplates();
41
+ }
42
+
43
+ function listTemplates() {
44
+ const dir = locateTemplatesDir();
45
+ const files = walk(dir).filter((f) => !f.endsWith(".gitkeep"));
46
+ if (files.length === 0) {
47
+ console.error(c.red("error:") + " no templates found at " + dir);
48
+ return 1;
49
+ }
50
+ console.log(c.bold(`Templates shipped by the installed Doctrina CLI:`));
51
+ console.log("");
52
+ let maxLen = 0;
53
+ const rows = [];
54
+ for (const f of files) {
55
+ const rel = path.relative(dir, f).split(path.sep).join("/");
56
+ const lines = lineCount(f);
57
+ rows.push({ rel, lines });
58
+ if (rel.length > maxLen) maxLen = rel.length;
59
+ }
60
+ for (const r of rows) {
61
+ console.log(` ${r.rel.padEnd(maxLen + 2)}${String(r.lines).padStart(4)} lines`);
62
+ }
63
+ console.log("");
64
+ console.log(c.gray(`${rows.length} templates · location: ${dir}`));
65
+ return 0;
66
+ }
67
+
68
+ function checkTemplates() {
69
+ const projectRoot = process.cwd();
70
+ if (!isDir(path.join(projectRoot, ".doctrina"))) {
71
+ console.error(c.red("error:") + " not a Doctrina project (no .doctrina/ in cwd)");
72
+ console.error(c.gray("hint: ") + "run `doctrina init` first");
73
+ return 1;
74
+ }
75
+
76
+ const findings = [];
77
+ const ok = [];
78
+
79
+ // AGENTS.md sections
80
+ const agentsPath = path.join(projectRoot, "AGENTS.md");
81
+ if (isFile(agentsPath)) {
82
+ const text = read(agentsPath);
83
+ for (const heading of AGENTS_SECTIONS) {
84
+ if (hasHeading(text, heading)) ok.push(`AGENTS.md: ${heading}`);
85
+ else findings.push(`AGENTS.md missing recommended section "${heading}"`);
86
+ }
87
+ } else {
88
+ findings.push("AGENTS.md missing at project root");
89
+ }
90
+
91
+ // product.md sections
92
+ const productPath = path.join(projectRoot, ".doctrina", "product.md");
93
+ if (isFile(productPath)) {
94
+ const text = read(productPath);
95
+ for (const heading of PRODUCT_SECTIONS) {
96
+ if (hasHeading(text, heading)) ok.push(`product.md: ${heading}`);
97
+ else findings.push(`.doctrina/product.md missing recommended section "${heading}"`);
98
+ }
99
+ } else {
100
+ findings.push(".doctrina/product.md missing");
101
+ }
102
+
103
+ // index.json schema fields
104
+ const indexPath = path.join(projectRoot, ".doctrina", "index.json");
105
+ if (isFile(indexPath)) {
106
+ try {
107
+ const idx = JSON.parse(read(indexPath));
108
+ for (const field of INDEX_FIELDS) {
109
+ if (idx[field] !== undefined) ok.push(`index.json: ${field}`);
110
+ else findings.push(`.doctrina/index.json missing field "${field}"`);
111
+ }
112
+ if (idx.$schema_version && idx.$schema_version !== "0.1.0") {
113
+ findings.push(`.doctrina/index.json $schema_version is "${idx.$schema_version}" (expected "0.1.0")`);
114
+ }
115
+ } catch (err) {
116
+ findings.push(`.doctrina/index.json failed to parse: ${err.message}`);
117
+ }
118
+ } else {
119
+ findings.push(".doctrina/index.json missing");
120
+ }
121
+
122
+ // Output
123
+ for (const o of ok) console.log(c.green("✓ ") + o);
124
+ for (const f of findings) console.log(c.yellow("✗ ") + f);
125
+ console.log("");
126
+
127
+ if (findings.length === 0) {
128
+ console.log(c.green("ok") + " all recommended sections present");
129
+ return 0;
130
+ }
131
+ console.log(c.red("fail") + ` ${findings.length} recommendation${findings.length === 1 ? "" : "s"}`);
132
+ console.log(c.gray("hint: ") + "this command is read-only; review and add the missing sections by hand");
133
+ return 1;
134
+ }
135
+
136
+ function hasHeading(text, heading) {
137
+ // Match an exact h2 heading at line start (case-insensitive for the body
138
+ // after "## ", so "## Vision" matches "## VISION" or "## vision" too).
139
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
140
+ return new RegExp(`^${escaped}\\b`, "im").test(text);
141
+ }
142
+
143
+ export const help = `
144
+ Usage: doctrina templates <subcommand>
145
+
146
+ Read-only inspection of the framework's templates and the current
147
+ project's adherence to recommended template shapes.
148
+
149
+ Subcommands:
150
+ list Enumerate the templates the installed CLI ships, with
151
+ their relative paths and line counts.
152
+ check Compare the current project's AGENTS.md, product.md,
153
+ and index.json against the recommended sections and
154
+ schema fields shipped in this CLI version. Reports any
155
+ recommended section that is missing. Read-only; never
156
+ modifies any files. Exits 1 when recommendations exist,
157
+ 0 when the project follows the current template shape.
158
+
159
+ Distinct from \`doctrina validate\`: validate answers "is this a
160
+ well-formed Doctrina tree?"; templates check answers "does this
161
+ tree still follow the shape the current CLI's templates recommend?"
162
+ `;
@@ -0,0 +1,239 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { exists, isDir, isFile, lineCount, read, walk } from "../lib/fs-ops.js";
4
+ import * as idx from "../lib/index-json.js";
5
+ import { c } from "../lib/colors.js";
6
+ import { parseFrontmatter } from "./skill.js";
7
+
8
+ export async function run(_positional, _flags) {
9
+ const projectRoot = process.cwd();
10
+ const errors = [];
11
+ const warnings = [];
12
+
13
+ // 1. AGENTS.md
14
+ const agentsMd = path.join(projectRoot, "AGENTS.md");
15
+ if (!isFile(agentsMd)) {
16
+ errors.push("AGENTS.md missing at project root");
17
+ } else {
18
+ const lines = lineCount(agentsMd);
19
+ if (lines > 200) errors.push(`AGENTS.md is ${lines} lines (>200, hard limit)`);
20
+ else if (lines > 150) warnings.push(`AGENTS.md is ${lines} lines (>150 soft limit)`);
21
+ }
22
+
23
+ // 2. product.md
24
+ const productMd = path.join(projectRoot, ".doctrina", "product.md");
25
+ if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
26
+
27
+ // 3. index.json
28
+ let index = null;
29
+ try {
30
+ index = idx.load(projectRoot);
31
+ } catch (err) {
32
+ errors.push(err.message);
33
+ }
34
+
35
+ if (index) {
36
+ if (index.$schema_version !== "0.1.0") {
37
+ warnings.push(`index.json $schema_version is "${index.$schema_version}" (expected "0.1.0")`);
38
+ }
39
+ if (!index.artifacts) {
40
+ errors.push("index.json missing artifacts object");
41
+ } else {
42
+ // 4. Every referenced artifact path must exist
43
+ const allArtifactPaths = [];
44
+ if (index.artifacts.product?.path) allArtifactPaths.push(index.artifacts.product.path);
45
+ for (const s of index.artifacts.specs ?? []) allArtifactPaths.push(s.path);
46
+ for (const d of index.artifacts.decisions ?? []) allArtifactPaths.push(d.path);
47
+ for (const ch of index.artifacts.changes ?? []) allArtifactPaths.push(ch.path);
48
+ for (const ch of index.artifacts.changes_archive ?? []) allArtifactPaths.push(ch.path);
49
+ for (const rel of allArtifactPaths) {
50
+ const full = path.join(projectRoot, rel);
51
+ if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
52
+ }
53
+ }
54
+ }
55
+
56
+ // 5. ADRs have parseable Status: header
57
+ const adrDir = path.join(projectRoot, ".doctrina", "decisions");
58
+ if (isDir(adrDir)) {
59
+ for (const f of walk(adrDir)) {
60
+ if (!f.endsWith(".md")) continue;
61
+ const text = read(f);
62
+ if (!/^-\s+\*\*Status:\*\*\s+\S+/m.test(text)) {
63
+ errors.push(`${path.relative(projectRoot, f)} missing or malformed Status: header`);
64
+ }
65
+ }
66
+ }
67
+
68
+ // 6. Adapter templates under 30 lines
69
+ const adaptersDir = path.join(projectRoot, ".doctrina", "templates", "adapters");
70
+ if (isDir(adaptersDir)) {
71
+ for (const f of walk(adaptersDir)) {
72
+ if (!f.endsWith(".template")) continue;
73
+ const lines = lineCount(f);
74
+ if (lines > 30) errors.push(`${path.relative(projectRoot, f)} is ${lines} lines (>30, adapter cap)`);
75
+ }
76
+ }
77
+
78
+ // 7. Every open change has a proposal
79
+ const changesDir = path.join(projectRoot, ".doctrina", "changes");
80
+ if (isDir(changesDir)) {
81
+ const { readdirSync } = await import("node:fs");
82
+ for (const entry of readdirSync(changesDir)) {
83
+ if (entry === "archive" || entry.startsWith(".")) continue;
84
+ const proposal = path.join(changesDir, entry, "proposal.md");
85
+ if (!isFile(proposal)) {
86
+ errors.push(`open change "${entry}" missing proposal.md`);
87
+ }
88
+ }
89
+ }
90
+
91
+ // 8. Specs over 400 lines warn (lost-in-the-middle).
92
+ const specsDir = path.join(projectRoot, ".doctrina", "specs");
93
+ if (isDir(specsDir)) {
94
+ const { readdirSync } = await import("node:fs");
95
+ for (const cap of readdirSync(specsDir)) {
96
+ const specPath = path.join(specsDir, cap, "spec.md");
97
+ if (isFile(specPath)) {
98
+ const lines = lineCount(specPath);
99
+ if (lines > 400) warnings.push(`${path.relative(projectRoot, specPath)} is ${lines} lines (>400 soft cap)`);
100
+ }
101
+ }
102
+ }
103
+
104
+ // 9. ADRs over 300 lines warn.
105
+ if (isDir(adrDir)) {
106
+ for (const f of walk(adrDir)) {
107
+ if (!f.endsWith(".md")) continue;
108
+ const lines = lineCount(f);
109
+ if (lines > 300) warnings.push(`${path.relative(projectRoot, f)} is ${lines} lines (>300 soft cap)`);
110
+ }
111
+ }
112
+
113
+ // 10b. Stale-reference detection — Markdown link targets inside specs
114
+ // and ADRs that no longer exist on disk. Backtick paths are NOT
115
+ // checked (descriptive prose, too noisy). Links are pointers.
116
+ for (const dir of [specsDir, adrDir]) {
117
+ if (!isDir(dir)) continue;
118
+ for (const f of walk(dir)) {
119
+ if (!f.endsWith(".md")) continue;
120
+ const text = read(f);
121
+ const fileDir = path.dirname(f);
122
+ for (const ref of extractPathReferences(text)) {
123
+ // Try relative to the file first, then to project root
124
+ const candidates = [
125
+ path.resolve(fileDir, ref),
126
+ path.resolve(projectRoot, ref),
127
+ ];
128
+ if (!candidates.some(exists)) {
129
+ warnings.push(`${path.relative(projectRoot, f)} references missing path \`${ref}\``);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // 10. Orphan detection — files on disk not referenced in index.json.
136
+ if (index?.artifacts) {
137
+ const indexedSpecIds = new Set((index.artifacts.specs ?? []).map((s) => s.id));
138
+ if (isDir(specsDir)) {
139
+ const { readdirSync } = await import("node:fs");
140
+ for (const cap of readdirSync(specsDir)) {
141
+ const specPath = path.join(specsDir, cap, "spec.md");
142
+ if (isFile(specPath) && !indexedSpecIds.has(cap)) {
143
+ warnings.push(`orphan spec ${path.relative(projectRoot, specPath)} (capability "${cap}" not in index.json)`);
144
+ }
145
+ }
146
+ }
147
+ const indexedAdrIds = new Set((index.artifacts.decisions ?? []).map((d) => d.id));
148
+ if (isDir(adrDir)) {
149
+ for (const f of walk(adrDir)) {
150
+ if (!f.endsWith(".md")) continue;
151
+ const m = path.basename(f).match(/^(\d{4})-/);
152
+ if (m && !indexedAdrIds.has(m[1])) {
153
+ warnings.push(`orphan ADR ${path.relative(projectRoot, f)} (id "${m[1]}" not in index.json)`);
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ // 12-14. Skills validation
160
+ const skillsDir = path.join(projectRoot, ".doctrina", "skills");
161
+ if (isDir(skillsDir)) {
162
+ for (const f of walk(skillsDir)) {
163
+ if (!f.endsWith(".md")) continue;
164
+ const text = read(f);
165
+ const rel = path.relative(projectRoot, f);
166
+ const baseName = path.basename(f, ".md");
167
+ const nameField = parseFrontmatter(text, "name");
168
+ const descField = parseFrontmatter(text, "description");
169
+ const whenField = parseFrontmatter(text, "when");
170
+ if (!nameField) warnings.push(`${rel} missing required frontmatter field "name"`);
171
+ if (!descField) warnings.push(`${rel} missing required frontmatter field "description"`);
172
+ if (!whenField) warnings.push(`${rel} missing required frontmatter field "when"`);
173
+ if (nameField && nameField !== baseName) {
174
+ warnings.push(`${rel} name "${nameField}" does not match filename slug "${baseName}"`);
175
+ }
176
+ const lines = lineCount(f);
177
+ if (lines > 200) warnings.push(`${rel} is ${lines} lines (>200 soft cap)`);
178
+ else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
179
+ }
180
+ }
181
+
182
+ // Output
183
+ for (const w of warnings) console.log(c.yellow("warn: ") + w);
184
+ for (const e of errors) console.log(c.red("error: ") + e);
185
+
186
+ console.log("");
187
+ if (errors.length === 0 && warnings.length === 0) {
188
+ console.log(c.green("ok") + " all validation checks passed");
189
+ } else {
190
+ const summary = `${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`;
191
+ console.log((errors.length === 0 ? c.green("ok") : c.red("fail")) + " " + summary);
192
+ }
193
+ return errors.length === 0 ? 0 : 1;
194
+ }
195
+
196
+ // Extract candidate file path references from Markdown text. Only
197
+ // Markdown link targets `[text](path)` are checked — backtick spans
198
+ // are descriptive prose and produce too many false positives.
199
+ function extractPathReferences(text) {
200
+ const out = new Set();
201
+ for (const m of text.matchAll(/\]\(([^)\s]+)\)/g)) {
202
+ const token = m[1].trim();
203
+ if (isLikelyPath(token)) out.add(stripFragment(token));
204
+ }
205
+ return [...out];
206
+ }
207
+
208
+ function stripFragment(s) {
209
+ // Remove an in-page anchor (#section) or query string from a path token.
210
+ return s.split(/[#?]/)[0];
211
+ }
212
+
213
+ function isLikelyPath(s) {
214
+ if (!s) return false;
215
+ if (/^(https?:|mailto:|ftp:|#|@)/i.test(s)) return false;
216
+ if (s.includes("{{") || s.includes("}}")) return false;
217
+ if (s.includes("*") || s.includes("?")) return false;
218
+ if (s.includes("<") || s.includes(">")) return false;
219
+ if (s.includes(" ") || s.includes("`")) return false;
220
+ // Strip in-page anchor or query string before further checks
221
+ const head = stripFragment(s);
222
+ if (!head) return false;
223
+ // Folder references (path ending with /) are skipped: not file targets
224
+ if (head.endsWith("/")) return false;
225
+ // Must look like a file path: contains a slash or has a file extension
226
+ const hasSlash = head.includes("/");
227
+ const hasExt = /\.[a-z0-9]{1,8}$/i.test(head);
228
+ if (!hasSlash && !hasExt) return false;
229
+ if (head.startsWith("-")) return false;
230
+ return true;
231
+ }
232
+
233
+ export const help = `
234
+ Usage: doctrina validate
235
+
236
+ Run schema, artifact-existence, and structural checks against the
237
+ .doctrina/ tree in the current working directory. Exits 0 if no errors
238
+ (warnings allowed), 1 otherwise.
239
+ `;
package/src/index.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { readFileSync } from "node:fs";
6
+ import { parseArgs } from "./lib/args.js";
7
+ import { c } from "./lib/colors.js";
8
+ import { suggest } from "./lib/suggest.js";
9
+
10
+ import * as init from "./commands/init.js";
11
+ import * as spec from "./commands/spec.js";
12
+ import * as change from "./commands/change.js";
13
+ import * as decision from "./commands/decision.js";
14
+ import * as validate from "./commands/validate.js";
15
+ import * as hooks from "./commands/hooks.js";
16
+ import * as analyze from "./commands/analyze.js";
17
+ import * as clarify from "./commands/clarify.js";
18
+ import * as templates from "./commands/templates.js";
19
+ import * as skill from "./commands/skill.js";
20
+
21
+ const COMMANDS = { init, spec, change, decision, validate, hooks, analyze, clarify, templates, skill };
22
+
23
+ const TOP_HELP = `
24
+ Usage: doctrina <command> [options]
25
+
26
+ Commands:
27
+ init Scaffold AGENTS.md and .doctrina/ in the current directory
28
+ spec new Create a new capability spec (--bug for bug-shape)
29
+ change new Open a change proposal
30
+ change apply Apply spec deltas (ADDED/REMOVED auto, MODIFIED manual)
31
+ change archive Archive an applied change
32
+ decision new Create the next sequentially numbered ADR
33
+ decision supersede Create a new ADR that supersedes an existing one
34
+ skill new Scaffold an on-demand procedural memory skill
35
+ skill list List skills with their descriptions
36
+ analyze Inspect a change folder before applying
37
+ clarify Smell-test a Markdown file for ambiguity
38
+ validate Run schema and structural checks
39
+ templates list List the templates shipped by the installed CLI
40
+ templates check Compare the project against the recommended template shape
41
+ hooks install Install the pre-commit hook
42
+
43
+ Global flags:
44
+ --help, -h Show this message (or per-command help if after a command)
45
+ --version, -v Print the version
46
+ `;
47
+
48
+ async function main(argv) {
49
+ const { positional, flags } = parseArgs(argv, {
50
+ boolean: ["help", "h", "version", "v", "force", "non-interactive"],
51
+ });
52
+
53
+ if (flags.get("version") || flags.get("v")) {
54
+ console.log(readVersion());
55
+ return 0;
56
+ }
57
+
58
+ if (positional.length === 0) {
59
+ process.stdout.write(TOP_HELP);
60
+ return 0;
61
+ }
62
+
63
+ const commandName = positional[0];
64
+ const command = COMMANDS[commandName];
65
+ if (!command) {
66
+ console.error(c.red("error:") + ` unknown command "${commandName}"`);
67
+ const guess = suggest(commandName, Object.keys(COMMANDS));
68
+ if (guess) {
69
+ console.error(c.gray("hint: ") + `did you mean \`doctrina ${guess}\`?`);
70
+ } else {
71
+ console.error(c.gray("hint: ") + "try `doctrina --help` for the command surface");
72
+ }
73
+ return 2;
74
+ }
75
+
76
+ if (flags.get("help") || flags.get("h")) {
77
+ process.stdout.write(command.help ?? `(no help for ${commandName})\n`);
78
+ return 0;
79
+ }
80
+
81
+ try {
82
+ return await command.run(positional.slice(1), flags);
83
+ } catch (err) {
84
+ console.error(c.red("error:") + ` ${err.message}`);
85
+ if (flags.get("debug") && err.stack) {
86
+ console.error(c.gray(err.stack));
87
+ }
88
+ return 1;
89
+ }
90
+ }
91
+
92
+ function readVersion() {
93
+ const here = path.dirname(fileURLToPath(import.meta.url));
94
+ const pkgPath = path.resolve(here, "..", "package.json");
95
+ try {
96
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.0.0";
97
+ } catch {
98
+ return "0.0.0";
99
+ }
100
+ }
101
+
102
+ main(process.argv.slice(2)).then((code) => process.exit(code ?? 0));
@@ -0,0 +1,92 @@
1
+ // Tiny argv parser. Returns { positional: string[], flags: Map<string, string|boolean> }.
2
+ // Supports:
3
+ // --flag -> flags.set("flag", true)
4
+ // --flag=value -> flags.set("flag", "value")
5
+ // --flag value -> flags.set("flag", "value") (next token consumed)
6
+ // -x -> flags.set("x", true)
7
+ // -x=value or -x value -> string value
8
+ // -- -> stop flag parsing; rest is positional
9
+ //
10
+ // Anything else becomes positional. Repeated flags overwrite the previous value.
11
+
12
+ export function parseArgs(argv, opts = {}) {
13
+ const booleanFlags = new Set(opts.boolean ?? []);
14
+ const positional = [];
15
+ const flags = new Map();
16
+ let i = 0;
17
+ let stopped = false;
18
+
19
+ while (i < argv.length) {
20
+ const token = argv[i];
21
+
22
+ if (stopped) {
23
+ positional.push(token);
24
+ i += 1;
25
+ continue;
26
+ }
27
+
28
+ if (token === "--") {
29
+ stopped = true;
30
+ i += 1;
31
+ continue;
32
+ }
33
+
34
+ if (token.startsWith("--")) {
35
+ const body = token.slice(2);
36
+ const eq = body.indexOf("=");
37
+ if (eq >= 0) {
38
+ flags.set(body.slice(0, eq), body.slice(eq + 1));
39
+ i += 1;
40
+ } else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
41
+ flags.set(body, true);
42
+ i += 1;
43
+ } else {
44
+ flags.set(body, argv[i + 1] ?? true);
45
+ i += 2;
46
+ }
47
+ continue;
48
+ }
49
+
50
+ if (token.startsWith("-") && token.length > 1) {
51
+ const body = token.slice(1);
52
+ const eq = body.indexOf("=");
53
+ if (eq >= 0) {
54
+ flags.set(body.slice(0, eq), body.slice(eq + 1));
55
+ i += 1;
56
+ } else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
57
+ flags.set(body, true);
58
+ i += 1;
59
+ } else {
60
+ flags.set(body, argv[i + 1] ?? true);
61
+ i += 2;
62
+ }
63
+ continue;
64
+ }
65
+
66
+ positional.push(token);
67
+ i += 1;
68
+ }
69
+
70
+ return { positional, flags };
71
+ }
72
+
73
+ function isNextTokenFlag(token) {
74
+ if (token === undefined) return true;
75
+ if (typeof token !== "string") return true;
76
+ return token.startsWith("-");
77
+ }
78
+
79
+ export function flagString(flags, name, fallback = undefined) {
80
+ const v = flags.get(name);
81
+ if (v === undefined || v === true) return fallback;
82
+ return String(v);
83
+ }
84
+
85
+ export function flagBool(flags, name, fallback = false) {
86
+ const v = flags.get(name);
87
+ if (v === undefined) return fallback;
88
+ if (v === true) return true;
89
+ if (v === false) return false;
90
+ const s = String(v).toLowerCase();
91
+ return s === "true" || s === "1" || s === "yes";
92
+ }