mdrules 0.1.1 → 0.2.3
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 +211 -55
- package/dist/cli.js +1001 -220
- package/dist/web/assets/{abnfDiagram-WEYOIA3C-DKgk2hIQ.js → abnfDiagram-WEYOIA3C-DMRcmCIC.js} +1 -1
- package/dist/web/assets/{arc-CCAU-51h.js → arc-DZzWcSCk.js} +1 -1
- package/dist/web/assets/{architecture-WOLXFQ4H-BcNYkHmW.js → architecture-WOLXFQ4H-DMFg8Ew3.js} +1 -1
- package/dist/web/assets/{architectureDiagram-ZHVYKT53-DgRlfGj2.js → architectureDiagram-ZHVYKT53-DWkP0LWv.js} +1 -1
- package/dist/web/assets/{blockDiagram-RJAE7QA3-DoBplOhb.js → blockDiagram-RJAE7QA3-DrKNefYo.js} +1 -1
- package/dist/web/assets/{c4Diagram-XDPPPE54-CGKzgX6O.js → c4Diagram-XDPPPE54-wzxeN4C2.js} +1 -1
- package/dist/web/assets/channel-De1Bo4WJ.js +1 -0
- package/dist/web/assets/{chunk-2E7WHVMD-DhQx5a0n.js → chunk-2E7WHVMD-D_HCdfuS.js} +1 -1
- package/dist/web/assets/{chunk-4EMNKR2T-vHF11V5m.js → chunk-4EMNKR2T-dRBMJ4_-.js} +1 -1
- package/dist/web/assets/{chunk-55XCU76K-DgZ0btzr.js → chunk-55XCU76K-BfDf2f5L.js} +1 -1
- package/dist/web/assets/{chunk-7S6XSTFO-DoKaEIP0.js → chunk-7S6XSTFO-DQBRPMDG.js} +1 -1
- package/dist/web/assets/{chunk-AQDTSKZB-DktDdZ_o.js → chunk-AQDTSKZB-ClWiKzeu.js} +1 -1
- package/dist/web/assets/{chunk-DSNLSHJZ-C_3sVqJn.js → chunk-DSNLSHJZ-Ct9Lfuzm.js} +1 -1
- package/dist/web/assets/{chunk-HAGFOTED-DhCQIE99.js → chunk-HAGFOTED-Cno1MSCP.js} +1 -1
- package/dist/web/assets/{chunk-HNE4Q36P-D_sbl6UM.js → chunk-HNE4Q36P-CDmp78x5.js} +1 -1
- package/dist/web/assets/{chunk-NQOKRVIF-UuKYBp6F.js → chunk-NQOKRVIF-CqWTXOUR.js} +1 -1
- package/dist/web/assets/{chunk-UHW7PFBP-BkreF8rs.js → chunk-UHW7PFBP-psZpZtfm.js} +1 -1
- package/dist/web/assets/{chunk-VQ5W62QI-Cb7PeOKH.js → chunk-VQ5W62QI-CCDvsLND.js} +1 -1
- package/dist/web/assets/{chunk-WQQL4CDK-GsIKInJ-.js → chunk-WQQL4CDK-Cu2Aopzy.js} +1 -1
- package/dist/web/assets/{chunk-XXDRQBXY-Bbz14gys.js → chunk-XXDRQBXY-BWwvAa43.js} +1 -1
- package/dist/web/assets/{chunk-YZO67HFG--IdJYNXK.js → chunk-YZO67HFG-QHKY9fqM.js} +1 -1
- package/dist/web/assets/{chunk-ZAUX2LTX-DmenwPIM.js → chunk-ZAUX2LTX-DOdmfo9d.js} +1 -1
- package/dist/web/assets/{chunk-ZCHMQSOR-BZ2XP8BW.js → chunk-ZCHMQSOR-BNE_AYuB.js} +1 -1
- package/dist/web/assets/{classDiagram-v2-4MYWR3UA-B_FJCXgY.js → classDiagram-v2-4MYWR3UA-QVfxHH62.js} +1 -1
- package/dist/web/assets/{cose-bilkent-JH36ORCC-DMBdq3mq.js → cose-bilkent-JH36ORCC-1dupt6EA.js} +1 -1
- package/dist/web/assets/{cynefin-EF2NZ3EQ-BthcZtsD.js → cynefin-EF2NZ3EQ-CAB7KRcw.js} +1 -1
- package/dist/web/assets/{cynefinDiagram-WF24UDRP-Bva6JySb.js → cynefinDiagram-WF24UDRP-D2M2_xSW.js} +1 -1
- package/dist/web/assets/{dagre-NK4GIZVX-BE6DKQYs.js → dagre-NK4GIZVX-CY4Eoama.js} +1 -1
- package/dist/web/assets/{diagram-CBK3MWTC-DuDzgplS.js → diagram-CBK3MWTC-Ct0zNHAV.js} +1 -1
- package/dist/web/assets/{diagram-I5XIIV2D-DU3jaZXW.js → diagram-I5XIIV2D-gmcSomD3.js} +1 -1
- package/dist/web/assets/{diagram-ODIL2UCW-DYI3Rj6j.js → diagram-ODIL2UCW-Z01ngjlG.js} +1 -1
- package/dist/web/assets/{diagram-PFBEJUXE-FD1CYRbT.js → diagram-PFBEJUXE-By7lIQit.js} +1 -1
- package/dist/web/assets/{diagram-RJA3I4VV-BsbAupu0.js → diagram-RJA3I4VV-BbTc0lUo.js} +1 -1
- package/dist/web/assets/{diagram-TYUNFSNR-BMB0kMWv.js → diagram-TYUNFSNR-BZNmF7dF.js} +1 -1
- package/dist/web/assets/{dist-CIDoXcx0.js → dist-B4Zd6qux.js} +1 -1
- package/dist/web/assets/{dist-D6VkQ_7h.js → dist-BLO1tZtl.js} +1 -1
- package/dist/web/assets/{dist-ABZ0bfMR.js → dist-Bag2LZ55.js} +1 -1
- package/dist/web/assets/{dist-CugnsBBM.js → dist-CW4Hp5az.js} +1 -1
- package/dist/web/assets/{dist-BFFH-h4L.js → dist-DFZHgUoB.js} +1 -1
- package/dist/web/assets/{dist-1HFNA2Sd.js → dist-DHO-aIOo.js} +1 -1
- package/dist/web/assets/{dist-Be0WQzsz.js → dist-DY18ueDO.js} +1 -1
- package/dist/web/assets/{dist-B5RhPiDb.js → dist-FrDFGy8k.js} +1 -1
- package/dist/web/assets/{dist-EDnzgrlE.js → dist-luL4D3CY.js} +1 -1
- package/dist/web/assets/{dist-BoeMvgh-.js → dist-oMwphMn-.js} +1 -1
- package/dist/web/assets/{dist-CFaMxIWT.js → dist-roXUU37l.js} +1 -1
- package/dist/web/assets/{ebnfDiagram-V4P3TTPT-C5zEPRhM.js → ebnfDiagram-V4P3TTPT-JoyE0F88.js} +1 -1
- package/dist/web/assets/{elk-QONTBCTQ-BR5YBYTe.js → elk-QONTBCTQ-BNYZwxj2.js} +1 -1
- package/dist/web/assets/{erDiagram-KYQIGQAA-B-5pcNVz.js → erDiagram-KYQIGQAA-BQCuW6RJ.js} +1 -1
- package/dist/web/assets/{eventmodeling-K75KTNOO-DkMnS-Cz.js → eventmodeling-K75KTNOO-DcSjO4xn.js} +1 -1
- package/dist/web/assets/flowDiagram-IUGLLWBU-C2mAXQNT.js +1 -0
- package/dist/web/assets/{ganttDiagram-R7GPNTJY-B21dBS-O.js → ganttDiagram-R7GPNTJY-BofMym7I.js} +1 -1
- package/dist/web/assets/{gitGraph-VSP46ZUC-DalD3PH5.js → gitGraph-VSP46ZUC-D5CJVdju.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PDYL7XHY-CWi1FEeW.js → gitGraphDiagram-PDYL7XHY-BSH5YBss.js} +1 -1
- package/dist/web/assets/index-BUr29cVy.js +63 -0
- package/dist/web/assets/index-B_El5oZL.css +1 -0
- package/dist/web/assets/{info-OHQRW6UA-CByswqH6.js → info-OHQRW6UA-LJbVE13c.js} +1 -1
- package/dist/web/assets/{infoDiagram-MVDRKAX5-DizZ8XVo.js → infoDiagram-MVDRKAX5-CcDzYgLq.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-HPEHV2GE-DQoKMoDQ.js → ishikawaDiagram-HPEHV2GE-BTAZWgme.js} +1 -1
- package/dist/web/assets/{journeyDiagram-BOSHW7O7-DKj7UdLQ.js → journeyDiagram-BOSHW7O7-D_lt8C1z.js} +1 -1
- package/dist/web/assets/{kanban-definition-FNSA4HC5-bTblh8-S.js → kanban-definition-FNSA4HC5-BnOn_93I.js} +1 -1
- package/dist/web/assets/{line-DWkG8aZQ.js → line-D86czQCw.js} +1 -1
- package/dist/web/assets/{linear-aVPxw88X.js → linear-DX5Fyu8p.js} +1 -1
- package/dist/web/assets/{mermaid-parser.core-C-J7_HBm.js → mermaid-parser.core-BActkHhD.js} +3 -3
- package/dist/web/assets/{mermaid.core-DpHqjC1s.js → mermaid.core-Ce5oFJHI.js} +4 -4
- package/dist/web/assets/{mindmap-definition-6OY57UG3-DnifqP1p.js → mindmap-definition-6OY57UG3-D7CwuLeJ.js} +1 -1
- package/dist/web/assets/{packet-JDAUHWVQ-_ADKLP5T.js → packet-JDAUHWVQ-DBB-zZek.js} +1 -1
- package/dist/web/assets/{pegDiagram-ECIUJNBX-_SU4-PwS.js → pegDiagram-ECIUJNBX-CK1750zt.js} +1 -1
- package/dist/web/assets/{pie-XZMESJXO-BfbJN8hk.js → pie-XZMESJXO-DOtQfX6V.js} +1 -1
- package/dist/web/assets/{pieDiagram-GNMPY74T-DQQKmEIp.js → pieDiagram-GNMPY74T-Cu5hilM3.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-D57K4MC3-BLtF0lyY.js → quadrantDiagram-D57K4MC3-C6llE2gk.js} +1 -1
- package/dist/web/assets/{radar-ABXABTNO-D7Tlesuz.js → radar-ABXABTNO-BqYteODl.js} +1 -1
- package/dist/web/assets/{railroad-I3PHUGI6-CA4jSH3I.js → railroad-I3PHUGI6-QkKJVgJ_.js} +1 -1
- package/dist/web/assets/{railroad-abnf-LNEFI6M7-CumrL10E.js → railroad-abnf-LNEFI6M7-BOVXdtiq.js} +1 -1
- package/dist/web/assets/{railroad-ebnf-DWYD2UWJ-2GeHQm3G.js → railroad-ebnf-DWYD2UWJ-Dciqx8FR.js} +1 -1
- package/dist/web/assets/{railroad-peg-JR7BVNR7-CGbW9a0w.js → railroad-peg-JR7BVNR7-BJRps0kv.js} +1 -1
- package/dist/web/assets/{railroadDiagram-Y72NRBL7-BQKP5iZM.js → railroadDiagram-Y72NRBL7-BEp_79kB.js} +1 -1
- package/dist/web/assets/{requirementDiagram-EQYSW5RA-Cn_K53gc.js → requirementDiagram-EQYSW5RA-Bi1usS1S.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-SY24JHO5-BouDevNW.js → sankeyDiagram-SY24JHO5-cBLrx4TR.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-BLEHNHQU-C0rguio1.js → sequenceDiagram-BLEHNHQU-CwY8WSOO.js} +1 -1
- package/dist/web/assets/{src-BJB85M8z.js → src-BxxO9_wA.js} +1 -1
- package/dist/web/assets/{stateDiagram-v2-A6IESKWZ-BzgsnDJf.js → stateDiagram-v2-A6IESKWZ-CZAkkIQQ.js} +1 -1
- package/dist/web/assets/{swimlanes-B3ENRKFQ-BAnQnO43.js → swimlanes-B3ENRKFQ-B06LEs1H.js} +1 -1
- package/dist/web/assets/swimlanesDiagram-AYQRV4AK-Cx4IBQfH.js +8 -0
- package/dist/web/assets/{timeline-definition-JXYKGLGB-i_V2SDud.js → timeline-definition-JXYKGLGB-pBL_HcoZ.js} +1 -1
- package/dist/web/assets/{treeView-D4JQ5SDB-blg1ho_V.js → treeView-D4JQ5SDB-B3NYhcq3.js} +1 -1
- package/dist/web/assets/{treemap-SAJKECNS-GWe0sLdw.js → treemap-SAJKECNS-jUBClquT.js} +1 -1
- package/dist/web/assets/{usecaseDiagram-WPOLBNNX-C2epnXsY.js → usecaseDiagram-WPOLBNNX-BEy-FZyr.js} +1 -1
- package/dist/web/assets/{vennDiagram-Q3Y3IDCW-CYguUs2o.js → vennDiagram-Q3Y3IDCW-CU_ZQagp.js} +1 -1
- package/dist/web/assets/{wardley-7MLQ67FV-CA1BMgJ-.js → wardley-7MLQ67FV-DLfevnf3.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-IVAIDKWA-COJi2fLq.js → wardleyDiagram-IVAIDKWA-BnuVbP4c.js} +1 -1
- package/dist/web/assets/{xychartDiagram-4URNHDU4-BxweoZn5.js → xychartDiagram-4URNHDU4-pikAwF42.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/templates/main.md +1 -11
- package/templates/method/area/docs/ARCHITECTURE.md +14 -0
- package/templates/method/area/docs/TASKS.md +18 -0
- package/templates/method/area/docs/tasks/.gitkeep +0 -0
- package/templates/method/area-main.md +9 -0
- package/templates/method/root/docs/README.md +69 -0
- package/templates/method/root/docs/TASKS.md +18 -0
- package/templates/method/root/docs/architecture/README.md +10 -0
- package/templates/method/root/docs/architecture/stack.md +28 -0
- package/templates/method/root/docs/phases/01-foundations.md +23 -0
- package/templates/method/root/docs/tasks/_TEMPLATE.md +28 -0
- package/templates/method/root/docs/tasks/{{prefix}}-001-set-up-the-project.md +30 -0
- package/templates/method/root/docs/vision.md +22 -0
- package/templates/method/skills/mdrules-close-session/SKILL.md +20 -0
- package/templates/method/skills/mdrules-new-task/SKILL.md +22 -0
- package/templates/proposals/SKILL.md +114 -0
- package/templates/sdd/openspec/openspec/README.md +89 -0
- package/templates/sdd/openspec/openspec/changes/archive/.gitkeep +0 -0
- package/templates/sdd/openspec/openspec/config.yaml +12 -0
- package/templates/sdd/openspec/openspec/specs/.gitkeep +0 -0
- package/templates/sdd/speckit/.specify/memory/constitution.md +30 -0
- package/templates/sdd/speckit/specs/README.md +82 -0
- package/templates/skill/SKILL.md +9 -10
- package/dist/web/assets/channel-BWmWik05.js +0 -1
- package/dist/web/assets/flowDiagram-IUGLLWBU-Dq34jEaq.js +0 -1
- package/dist/web/assets/index-DBrCTV1V.js +0 -58
- package/dist/web/assets/index-DxpaDO0r.css +0 -1
- package/dist/web/assets/swimlanesDiagram-AYQRV4AK-BLBJ1_iu.js +0 -8
- package/templates/docs/architecture.md +0 -21
- package/templates/docs/decisions.md +0 -11
- package/templates/docs/phases/README.md +0 -25
- package/templates/docs/rules.md +0 -11
- package/templates/docs/tasks.md +0 -15
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,8 @@ var LANGS = ["en", "es", "fr", "pt", "de", "it"];
|
|
|
9
9
|
var LANG_NAMES = { en: "English", es: "Espa\xF1ol", fr: "Fran\xE7ais", pt: "Portugu\xEAs", de: "Deutsch", it: "Italiano" };
|
|
10
10
|
var DEFAULT_SOURCE = "en";
|
|
11
11
|
var isLang = (v) => LANGS.includes(v);
|
|
12
|
+
var isLangCode = (v) => typeof v === "string" && /^[a-z]{2,3}(-[a-z0-9]{2,8})?$/i.test(v);
|
|
13
|
+
var langName = (code, config) => isLang(code) ? LANG_NAMES[code] : config?.langNames?.[code] ?? code;
|
|
12
14
|
var MAIN_DOCS = ["CLAUDE.md", "AGENTS.md"];
|
|
13
15
|
var PKG_ROOT = (() => {
|
|
14
16
|
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -24,7 +26,7 @@ function readConfig(root2) {
|
|
|
24
26
|
try {
|
|
25
27
|
const raw = JSON.parse(fs.readFileSync(configFile(root2), "utf8"));
|
|
26
28
|
const langs = Array.isArray(raw.langs) ? raw.langs : raw.lang ? [raw.lang] : [];
|
|
27
|
-
return { mainDoc: raw.mainDoc ?? null, langs: langs.filter(
|
|
29
|
+
return { mainDoc: raw.mainDoc ?? null, langs: langs.filter(isLangCode), langNames: raw.langNames, workflow: raw.workflow, proposals: raw.proposals, initializedAt: raw.initializedAt };
|
|
28
30
|
} catch {
|
|
29
31
|
return null;
|
|
30
32
|
}
|
|
@@ -38,8 +40,8 @@ function safeResolve(root2, rel) {
|
|
|
38
40
|
if (abs !== root2 && !abs.startsWith(root2 + path.sep)) throw new Error(`Path outside project: ${rel}`);
|
|
39
41
|
return abs;
|
|
40
42
|
}
|
|
41
|
-
var toPosix = (
|
|
42
|
-
var DEFAULT_SETTINGS = { font: "inter", size: 15, lineHeight: 1.65, padding: 28, width: "full", wrap: true };
|
|
43
|
+
var toPosix = (p3) => p3.split(path.sep).join("/");
|
|
44
|
+
var DEFAULT_SETTINGS = { font: "inter", size: 15, lineHeight: 1.65, padding: 28, width: "full", wrap: true, uiLang: "en" };
|
|
43
45
|
var settingsFile = () => path.join(GLOBAL_DIR, "settings.json");
|
|
44
46
|
function readSettings() {
|
|
45
47
|
try {
|
|
@@ -55,10 +57,40 @@ function writeSettings(patch) {
|
|
|
55
57
|
return next;
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
// src/core/defaults.ts
|
|
61
|
+
import fs2 from "fs";
|
|
62
|
+
import path2 from "path";
|
|
63
|
+
var BASE_DEFAULTS = {
|
|
64
|
+
mode: "ask",
|
|
65
|
+
mainDoc: "CLAUDE.md",
|
|
66
|
+
workflow: "none",
|
|
67
|
+
agents: ["claude"],
|
|
68
|
+
proposals: "structured",
|
|
69
|
+
sourceLang: "en",
|
|
70
|
+
rules: [],
|
|
71
|
+
rulesInSubprojects: false,
|
|
72
|
+
hideInVsCode: true
|
|
73
|
+
};
|
|
74
|
+
var defaultsFile = () => path2.join(GLOBAL_DIR, "defaults.json");
|
|
75
|
+
function readDefaults() {
|
|
76
|
+
try {
|
|
77
|
+
const saved = { ...BASE_DEFAULTS, ...JSON.parse(fs2.readFileSync(defaultsFile(), "utf8")) };
|
|
78
|
+
if (!isLang(saved.sourceLang)) saved.sourceLang = BASE_DEFAULTS.sourceLang;
|
|
79
|
+
if (!MAIN_DOCS.includes(saved.mainDoc)) saved.mainDoc = BASE_DEFAULTS.mainDoc;
|
|
80
|
+
return saved;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function writeDefaults(defaults2) {
|
|
86
|
+
fs2.mkdirSync(GLOBAL_DIR, { recursive: true });
|
|
87
|
+
fs2.writeFileSync(defaultsFile(), JSON.stringify(defaults2, null, 2) + "\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
// src/core/i18n.ts
|
|
59
91
|
import crypto from "crypto";
|
|
60
|
-
import
|
|
61
|
-
import
|
|
92
|
+
import fs5 from "fs";
|
|
93
|
+
import path5 from "path";
|
|
62
94
|
|
|
63
95
|
// src/core/langdetect.ts
|
|
64
96
|
var STOPWORDS = {
|
|
@@ -84,56 +116,57 @@ function detectLang(md) {
|
|
|
84
116
|
}
|
|
85
117
|
|
|
86
118
|
// src/core/rules.ts
|
|
87
|
-
import
|
|
88
|
-
import
|
|
119
|
+
import fs3 from "fs";
|
|
120
|
+
import path3 from "path";
|
|
89
121
|
import YAML from "yaml";
|
|
90
|
-
var rulesDir = () =>
|
|
122
|
+
var rulesDir = () => path3.join(GLOBAL_DIR, "rules");
|
|
91
123
|
var START = "<!-- mdrules:rules:start -->";
|
|
92
124
|
var END = "<!-- mdrules:rules:end -->";
|
|
93
125
|
var slugify = (s) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
94
126
|
function seedRules() {
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
const src =
|
|
98
|
-
for (const f of
|
|
127
|
+
if (fs3.existsSync(rulesDir())) return;
|
|
128
|
+
fs3.mkdirSync(rulesDir(), { recursive: true });
|
|
129
|
+
const src = path3.join(TEMPLATES_DIR, "rules");
|
|
130
|
+
for (const f of fs3.readdirSync(src)) fs3.copyFileSync(path3.join(src, f), path3.join(rulesDir(), f));
|
|
99
131
|
}
|
|
100
|
-
function parseRule(id,
|
|
101
|
-
const m =
|
|
102
|
-
const meta = m ? YAML.parse(m[1]) ?? {} : {};
|
|
103
|
-
return { id, title: meta.title ?? id, body: (m ? m[2] :
|
|
132
|
+
function parseRule(id, text3) {
|
|
133
|
+
const m = text3.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
134
|
+
const meta = m ? YAML.parse(m[1], { logLevel: "error" }) ?? {} : {};
|
|
135
|
+
return { id, title: meta.title ?? id, body: (m ? m[2] : text3).trim() };
|
|
104
136
|
}
|
|
105
137
|
function listRules() {
|
|
106
138
|
seedRules();
|
|
107
|
-
return
|
|
139
|
+
return fs3.readdirSync(rulesDir()).filter((f) => f.endsWith(".md")).map((f) => parseRule(f.slice(0, -3), fs3.readFileSync(path3.join(rulesDir(), f), "utf8"))).sort((a, b) => a.title.localeCompare(b.title));
|
|
108
140
|
}
|
|
109
141
|
function saveRule(rule) {
|
|
110
142
|
seedRules();
|
|
111
143
|
const id = rule.id || slugify(rule.title) || `rule-${Date.now()}`;
|
|
112
|
-
|
|
144
|
+
if (!/^[\w-]+$/.test(id)) throw new Error(`Invalid rule id: ${id}`);
|
|
145
|
+
const text3 = `---
|
|
113
146
|
${YAML.stringify({ title: rule.title }).trim()}
|
|
114
147
|
---
|
|
115
148
|
${rule.body.trim()}
|
|
116
149
|
`;
|
|
117
|
-
|
|
150
|
+
fs3.writeFileSync(path3.join(rulesDir(), `${id}.md`), text3);
|
|
118
151
|
return { id, title: rule.title, body: rule.body.trim() };
|
|
119
152
|
}
|
|
120
153
|
function deleteRule(id) {
|
|
121
|
-
|
|
154
|
+
fs3.rmSync(path3.join(rulesDir(), `${path3.basename(id)}.md`), { force: true });
|
|
122
155
|
}
|
|
123
156
|
function appliedRuleIds(docText) {
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
126
|
-
return [...
|
|
157
|
+
const section2 = extractSection(docText);
|
|
158
|
+
if (!section2) return [];
|
|
159
|
+
return [...section2.matchAll(/<!-- rule:([\w-]+) -->/g)].map((m) => m[1]);
|
|
127
160
|
}
|
|
128
|
-
function extractSection(
|
|
129
|
-
const s =
|
|
130
|
-
const e =
|
|
131
|
-
return s >= 0 && e > s ?
|
|
161
|
+
function extractSection(text3) {
|
|
162
|
+
const s = text3.indexOf(START);
|
|
163
|
+
const e = text3.indexOf(END);
|
|
164
|
+
return s >= 0 && e > s ? text3.slice(s, e + END.length) : null;
|
|
132
165
|
}
|
|
133
166
|
function writtenRules(docText) {
|
|
134
|
-
const
|
|
167
|
+
const section2 = extractSection(docText) ?? "";
|
|
135
168
|
const out = {};
|
|
136
|
-
for (const m of
|
|
169
|
+
for (const m of section2.matchAll(/<!-- rule:([\w-]+) -->\n([\s\S]*?)(?=\n+<!-- (?:rule:|mdrules:rules:end))/g)) out[m[1]] = { id: m[1], title: m[1], body: m[2].trim() };
|
|
137
170
|
return out;
|
|
138
171
|
}
|
|
139
172
|
function sourceLangOf(docText) {
|
|
@@ -141,10 +174,10 @@ function sourceLangOf(docText) {
|
|
|
141
174
|
return m && isLang(m[1]) ? m[1] : DEFAULT_SOURCE;
|
|
142
175
|
}
|
|
143
176
|
function projectSourceLang(root2, mainDoc) {
|
|
144
|
-
const doc = mainDoc ?? MAIN_DOCS.find((d) =>
|
|
177
|
+
const doc = mainDoc ?? MAIN_DOCS.find((d) => fs3.existsSync(path3.join(root2, d)));
|
|
145
178
|
if (!doc) return DEFAULT_SOURCE;
|
|
146
179
|
try {
|
|
147
|
-
return sourceLangOf(
|
|
180
|
+
return sourceLangOf(fs3.readFileSync(path3.join(root2, doc), "utf8"));
|
|
148
181
|
} catch {
|
|
149
182
|
return DEFAULT_SOURCE;
|
|
150
183
|
}
|
|
@@ -165,36 +198,36 @@ ${r.body}`).join("\n\n");
|
|
|
165
198
|
].join("\n");
|
|
166
199
|
}
|
|
167
200
|
function applyRules(docText, rules, sourceLang = sourceLangOf(docText)) {
|
|
168
|
-
const
|
|
201
|
+
const section2 = renderSection(rules, sourceLang);
|
|
169
202
|
const existing = extractSection(docText);
|
|
170
|
-
if (existing) return docText.replace(existing,
|
|
171
|
-
return docText.replace(/\s*$/, "") + "\n\n" +
|
|
203
|
+
if (existing) return docText.replace(existing, () => section2);
|
|
204
|
+
return docText.replace(/\s*$/, "") + "\n\n" + section2 + "\n";
|
|
172
205
|
}
|
|
173
206
|
function applyRulesToFile(file, ids, sourceLang) {
|
|
174
|
-
const
|
|
207
|
+
const text3 = fs3.existsSync(file) ? fs3.readFileSync(file, "utf8") : "";
|
|
175
208
|
const all = listRules();
|
|
176
|
-
const written = writtenRules(
|
|
177
|
-
const rules = (ids ?? appliedRuleIds(
|
|
178
|
-
|
|
209
|
+
const written = writtenRules(text3);
|
|
210
|
+
const rules = (ids ?? appliedRuleIds(text3)).map((id) => all.find((r) => r.id === id) ?? written[id]).filter((r) => !!r);
|
|
211
|
+
fs3.writeFileSync(file, applyRules(text3, rules, sourceLang ?? sourceLangOf(text3)));
|
|
179
212
|
}
|
|
180
213
|
|
|
181
214
|
// src/core/scan.ts
|
|
182
|
-
import
|
|
183
|
-
import
|
|
215
|
+
import fs4 from "fs";
|
|
216
|
+
import path4 from "path";
|
|
184
217
|
import ignore from "ignore";
|
|
185
218
|
var ALWAYS_SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", ".mdrules", "dist", "build", ".next", ".turbo", "coverage", ".venv"]);
|
|
186
219
|
var IGNORE_FILE = ".mdrulesignore";
|
|
187
220
|
function loadIgnore(root2, file) {
|
|
188
221
|
const ig = ignore();
|
|
189
222
|
try {
|
|
190
|
-
ig.add(
|
|
223
|
+
ig.add(fs4.readFileSync(path4.join(root2, file), "utf8"));
|
|
191
224
|
} catch {
|
|
192
225
|
}
|
|
193
226
|
return ig;
|
|
194
227
|
}
|
|
195
228
|
function readIgnoreList(root2) {
|
|
196
229
|
try {
|
|
197
|
-
return
|
|
230
|
+
return fs4.readFileSync(path4.join(root2, IGNORE_FILE), "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
|
198
231
|
} catch {
|
|
199
232
|
return [];
|
|
200
233
|
}
|
|
@@ -203,7 +236,7 @@ function setIgnored(root2, rel, kind, ignored) {
|
|
|
203
236
|
const entry = kind === "dir" ? `${rel}/` : rel;
|
|
204
237
|
const lines = readIgnoreList(root2).filter((l) => l !== entry && l !== rel);
|
|
205
238
|
if (ignored) lines.push(entry);
|
|
206
|
-
|
|
239
|
+
fs4.writeFileSync(path4.join(root2, IGNORE_FILE), lines.length ? lines.join("\n") + "\n" : "");
|
|
207
240
|
}
|
|
208
241
|
function watchSkips(root2) {
|
|
209
242
|
const gitIgnore = loadIgnore(root2, ".gitignore");
|
|
@@ -220,20 +253,20 @@ function scanTree(root2) {
|
|
|
220
253
|
function walk(dirAbs, parentIgnored) {
|
|
221
254
|
let entries;
|
|
222
255
|
try {
|
|
223
|
-
entries =
|
|
256
|
+
entries = fs4.readdirSync(dirAbs, { withFileTypes: true });
|
|
224
257
|
} catch {
|
|
225
258
|
return [];
|
|
226
259
|
}
|
|
227
260
|
const nodes = [];
|
|
228
261
|
for (const e of entries) {
|
|
229
262
|
if (ALWAYS_SKIP.has(e.name)) continue;
|
|
230
|
-
const abs =
|
|
231
|
-
const rel = toPosix(
|
|
263
|
+
const abs = path4.join(dirAbs, e.name);
|
|
264
|
+
const rel = toPosix(path4.relative(root2, abs));
|
|
232
265
|
if (e.isDirectory()) {
|
|
233
266
|
if (gitIgnore.ignores(rel + "/")) continue;
|
|
234
267
|
const ignored = parentIgnored || mdIgnore.ignores(rel + "/");
|
|
235
268
|
const children = walk(abs, ignored);
|
|
236
|
-
if (!children.length) continue;
|
|
269
|
+
if (!children.length && !isEmptyDir(abs)) continue;
|
|
237
270
|
const project = MAIN_DOCS.some((d) => children.some((c) => c.kind === "file" && c.name === d));
|
|
238
271
|
nodes.push({ name: e.name, path: rel, kind: "dir", project: project || void 0, ignored: ignored || void 0, children });
|
|
239
272
|
} else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
|
|
@@ -246,9 +279,16 @@ function scanTree(root2) {
|
|
|
246
279
|
}
|
|
247
280
|
return walk(root2, false);
|
|
248
281
|
}
|
|
282
|
+
function isEmptyDir(abs) {
|
|
283
|
+
try {
|
|
284
|
+
return fs4.readdirSync(abs).length === 0;
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
var collator = new Intl.Collator(void 0, { numeric: true, sensitivity: "base" });
|
|
249
290
|
function compareNodes(a, b) {
|
|
250
|
-
|
|
251
|
-
return rank(a) - rank(b) || a.name.localeCompare(b.name);
|
|
291
|
+
return (a.kind === "dir" ? 0 : 1) - (b.kind === "dir" ? 0 : 1) || collator.compare(a.name, b.name);
|
|
252
292
|
}
|
|
253
293
|
function flattenFiles(nodes, includeIgnored = false) {
|
|
254
294
|
const out = [];
|
|
@@ -270,19 +310,19 @@ function findSubprojects(nodes) {
|
|
|
270
310
|
}
|
|
271
311
|
|
|
272
312
|
// src/core/i18n.ts
|
|
273
|
-
var stateFile = (root2) =>
|
|
274
|
-
var translationPath = (root2, lang, rel) =>
|
|
275
|
-
var hash = (
|
|
313
|
+
var stateFile = (root2) => path5.join(stateDir(root2), "state.json");
|
|
314
|
+
var translationPath = (root2, lang, rel) => path5.join(stateDir(root2), "i18n", lang, rel);
|
|
315
|
+
var hash = (text3) => crypto.createHash("sha1").update(text3).digest("hex").slice(0, 12);
|
|
276
316
|
var readText = (abs) => {
|
|
277
317
|
try {
|
|
278
|
-
return
|
|
318
|
+
return fs5.readFileSync(abs, "utf8");
|
|
279
319
|
} catch {
|
|
280
320
|
return null;
|
|
281
321
|
}
|
|
282
322
|
};
|
|
283
323
|
function readState(root2) {
|
|
284
324
|
try {
|
|
285
|
-
const raw = JSON.parse(
|
|
325
|
+
const raw = JSON.parse(fs5.readFileSync(stateFile(root2), "utf8"));
|
|
286
326
|
if (!raw.translations) return { translations: raw, keepLanguage: {} };
|
|
287
327
|
return { translations: raw.translations ?? {}, keepLanguage: raw.keepLanguage ?? {} };
|
|
288
328
|
} catch {
|
|
@@ -290,11 +330,11 @@ function readState(root2) {
|
|
|
290
330
|
}
|
|
291
331
|
}
|
|
292
332
|
function writeState(root2, state) {
|
|
293
|
-
|
|
294
|
-
|
|
333
|
+
fs5.mkdirSync(stateDir(root2), { recursive: true });
|
|
334
|
+
fs5.writeFileSync(stateFile(root2), JSON.stringify(state, null, 2) + "\n");
|
|
295
335
|
}
|
|
296
336
|
function fileStatus(root2, lang, rel) {
|
|
297
|
-
const source = readText(
|
|
337
|
+
const source = readText(path5.join(root2, rel));
|
|
298
338
|
const translation = readText(translationPath(root2, lang, rel));
|
|
299
339
|
if (translation === null || source === null) return "missing";
|
|
300
340
|
const recorded = readState(root2).translations[lang]?.[rel];
|
|
@@ -311,15 +351,15 @@ function allStatuses(root2, lang) {
|
|
|
311
351
|
path: f.path,
|
|
312
352
|
lang,
|
|
313
353
|
status: fileStatus(root2, lang, f.path),
|
|
314
|
-
translation:
|
|
354
|
+
translation: path5.relative(root2, translationPath(root2, lang, f.path))
|
|
315
355
|
}));
|
|
316
356
|
}
|
|
317
357
|
function foreignFiles(root2, sourceLang) {
|
|
318
358
|
const { keepLanguage: keepLanguage2 } = readState(root2);
|
|
319
359
|
const out = [];
|
|
320
360
|
for (const f of flattenFiles(scanTree(root2))) {
|
|
321
|
-
const
|
|
322
|
-
const detected =
|
|
361
|
+
const text3 = readText(path5.join(root2, f.path));
|
|
362
|
+
const detected = text3 ? detectLang(text3) : null;
|
|
323
363
|
if (detected && detected !== sourceLang && keepLanguage2[f.path] !== detected) out.push({ path: f.path, detected });
|
|
324
364
|
}
|
|
325
365
|
return out;
|
|
@@ -331,23 +371,25 @@ function syncReport(root2) {
|
|
|
331
371
|
return {
|
|
332
372
|
sourceLang,
|
|
333
373
|
langs,
|
|
374
|
+
/** Language names by code: translate into these (codes added by hand have no built-in name). */
|
|
375
|
+
names: Object.fromEntries([sourceLang, ...langs].map((l) => [l, langName(l, config)])),
|
|
334
376
|
foreign: foreignFiles(root2, sourceLang),
|
|
335
377
|
files: langs.flatMap((l) => allStatuses(root2, l))
|
|
336
378
|
};
|
|
337
379
|
}
|
|
338
380
|
function markSynced(root2, lang, rel) {
|
|
339
|
-
const source = readText(
|
|
381
|
+
const source = readText(path5.join(root2, rel));
|
|
340
382
|
const translation = readText(translationPath(root2, lang, rel));
|
|
341
383
|
if (source === null) throw new Error(`Source not found: ${rel}`);
|
|
342
|
-
if (translation === null) throw new Error(`Translation not found: ${
|
|
384
|
+
if (translation === null) throw new Error(`Translation not found: ${path5.relative(root2, translationPath(root2, lang, rel))}`);
|
|
343
385
|
const state = readState(root2);
|
|
344
386
|
state.translations[lang] ??= {};
|
|
345
387
|
state.translations[lang][rel] = { source: hash(source), translation: hash(translation) };
|
|
346
388
|
writeState(root2, state);
|
|
347
389
|
}
|
|
348
390
|
function keepLanguage(root2, rel) {
|
|
349
|
-
const
|
|
350
|
-
const detected =
|
|
391
|
+
const text3 = readText(path5.join(root2, rel));
|
|
392
|
+
const detected = text3 ? detectLang(text3) : null;
|
|
351
393
|
if (!detected) throw new Error(`Could not detect the language of ${rel}`);
|
|
352
394
|
const state = readState(root2);
|
|
353
395
|
state.keepLanguage[rel] = detected;
|
|
@@ -356,41 +398,47 @@ function keepLanguage(root2, rel) {
|
|
|
356
398
|
}
|
|
357
399
|
|
|
358
400
|
// src/server/index.ts
|
|
359
|
-
import
|
|
401
|
+
import fs11 from "fs";
|
|
360
402
|
import http from "http";
|
|
361
|
-
import
|
|
403
|
+
import path11 from "path";
|
|
362
404
|
import { watch } from "chokidar";
|
|
363
405
|
import open from "open";
|
|
364
406
|
import pc from "picocolors";
|
|
365
407
|
import { WebSocketServer } from "ws";
|
|
366
408
|
|
|
367
409
|
// src/core/dashboard.ts
|
|
368
|
-
import
|
|
369
|
-
import
|
|
410
|
+
import fs6 from "fs";
|
|
411
|
+
import path6 from "path";
|
|
370
412
|
import YAML2 from "yaml";
|
|
371
|
-
function parseFrontmatter(
|
|
372
|
-
const m =
|
|
413
|
+
function parseFrontmatter(text3) {
|
|
414
|
+
const m = text3.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
373
415
|
if (!m) return {};
|
|
374
416
|
try {
|
|
375
|
-
return YAML2.parse(m[1]) ?? {};
|
|
417
|
+
return YAML2.parse(m[1], { logLevel: "error" }) ?? {};
|
|
376
418
|
} catch {
|
|
377
419
|
return {};
|
|
378
420
|
}
|
|
379
421
|
}
|
|
422
|
+
var asText = (v) => v instanceof Date ? v.toISOString().slice(0, 10) : v == null || typeof v === "object" ? null : String(v);
|
|
380
423
|
function summarize(root2) {
|
|
381
|
-
return flattenFiles(scanTree(root2)).
|
|
382
|
-
const
|
|
383
|
-
const meta = parseFrontmatter(
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
const
|
|
424
|
+
return flattenFiles(scanTree(root2)).flatMap((f) => {
|
|
425
|
+
const text3 = fs6.readFileSync(path6.join(root2, f.path), "utf8");
|
|
426
|
+
const meta = parseFrontmatter(text3);
|
|
427
|
+
if (meta.type === "task" && f.name.startsWith("_")) return [];
|
|
428
|
+
const id = asText(meta.id);
|
|
429
|
+
const total = text3.match(/^\s*[-*+] \[[ xX]\]/gm)?.length ?? 0;
|
|
430
|
+
const done = text3.match(/^\s*[-*+] \[[xX]\]/gm)?.length ?? 0;
|
|
431
|
+
const heading2 = text3.match(/^#\s+(.+)$/m)?.[1];
|
|
432
|
+
const title = asText(meta.title) ?? (heading2 && id ? heading2.replace(new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*[\xB7:-]\\s*`), "") : heading2);
|
|
387
433
|
return {
|
|
388
434
|
path: f.path,
|
|
389
|
-
title:
|
|
390
|
-
type: meta.type
|
|
391
|
-
status: meta.status
|
|
435
|
+
title: title ?? f.name.replace(/\.md$/i, ""),
|
|
436
|
+
type: asText(meta.type),
|
|
437
|
+
status: asText(meta.status),
|
|
392
438
|
phase: typeof meta.phase === "number" ? meta.phase : null,
|
|
393
|
-
updated:
|
|
439
|
+
updated: asText(meta.updated),
|
|
440
|
+
id,
|
|
441
|
+
area: asText(meta.area),
|
|
394
442
|
tasks: { done, total }
|
|
395
443
|
};
|
|
396
444
|
});
|
|
@@ -432,6 +480,414 @@ function gitDiff(root2, rel) {
|
|
|
432
480
|
return git(root2, hasHead ? ["diff", "HEAD", "--", rel] : ["diff", "--cached", "--", rel]) ?? "";
|
|
433
481
|
}
|
|
434
482
|
|
|
483
|
+
// src/core/proposals.ts
|
|
484
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
485
|
+
import fs9 from "fs";
|
|
486
|
+
import path9 from "path";
|
|
487
|
+
|
|
488
|
+
// src/core/scaffold.ts
|
|
489
|
+
import fs7 from "fs";
|
|
490
|
+
import path7 from "path";
|
|
491
|
+
var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
492
|
+
function fill(text3, vars) {
|
|
493
|
+
return text3.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? "");
|
|
494
|
+
}
|
|
495
|
+
function copyTree(src, dest, vars, created, root2) {
|
|
496
|
+
for (const e of fs7.readdirSync(src, { withFileTypes: true })) {
|
|
497
|
+
const from = path7.join(src, e.name);
|
|
498
|
+
const to = path7.join(dest, fill(e.name, vars));
|
|
499
|
+
if (e.isDirectory()) {
|
|
500
|
+
fs7.mkdirSync(to, { recursive: true });
|
|
501
|
+
copyTree(from, to, vars, created, root2);
|
|
502
|
+
} else if (!fs7.existsSync(to)) {
|
|
503
|
+
fs7.writeFileSync(to, fill(fs7.readFileSync(from, "utf8"), vars));
|
|
504
|
+
created.push(toPosix(path7.relative(root2, to)));
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function createMainDoc(root2, name) {
|
|
509
|
+
const file = path7.join(root2, name);
|
|
510
|
+
if (fs7.existsSync(file)) return;
|
|
511
|
+
const text3 = fs7.readFileSync(path7.join(TEMPLATES_DIR, "main.md"), "utf8");
|
|
512
|
+
fs7.writeFileSync(file, fill(text3, { name: path7.basename(root2), date: today() }));
|
|
513
|
+
}
|
|
514
|
+
function installSkill(root2) {
|
|
515
|
+
const dir = path7.join(root2, ".claude", "skills", "mdrules");
|
|
516
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
517
|
+
fs7.copyFileSync(path7.join(TEMPLATES_DIR, "skill", "SKILL.md"), path7.join(dir, "SKILL.md"));
|
|
518
|
+
}
|
|
519
|
+
function ensureGitignore(root2) {
|
|
520
|
+
const file = path7.join(root2, ".gitignore");
|
|
521
|
+
const text3 = fs7.existsSync(file) ? fs7.readFileSync(file, "utf8") : "";
|
|
522
|
+
if (text3.split("\n").some((l) => [".mdrules", ".mdrules/", "/.mdrules", "/.mdrules/"].includes(l.trim()))) return;
|
|
523
|
+
fs7.writeFileSync(file, text3 + (text3 && !text3.endsWith("\n") ? "\n" : "") + ".mdrules/\n");
|
|
524
|
+
}
|
|
525
|
+
function hideInVsCode(root2) {
|
|
526
|
+
const file = path7.join(root2, ".vscode", "settings.json");
|
|
527
|
+
let settings = {};
|
|
528
|
+
if (fs7.existsSync(file)) {
|
|
529
|
+
try {
|
|
530
|
+
settings = JSON.parse(fs7.readFileSync(file, "utf8"));
|
|
531
|
+
} catch {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
settings["files.exclude"] = { ...settings["files.exclude"] ?? {}, "**/.mdrules": true };
|
|
536
|
+
fs7.mkdirSync(path7.dirname(file), { recursive: true });
|
|
537
|
+
fs7.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// src/core/sdd.ts
|
|
542
|
+
import { spawnSync } from "child_process";
|
|
543
|
+
import fs8 from "fs";
|
|
544
|
+
import path8 from "path";
|
|
545
|
+
var SDD_AGENTS = {
|
|
546
|
+
claude: { label: "Claude Code", openspec: "claude", speckit: "claude" },
|
|
547
|
+
codex: { label: "Codex", openspec: "codex", speckit: "codex" },
|
|
548
|
+
copilot: { label: "GitHub Copilot", openspec: "github-copilot", speckit: "copilot" },
|
|
549
|
+
gemini: { label: "Gemini CLI", openspec: "gemini", speckit: "gemini" },
|
|
550
|
+
cursor: { label: "Cursor", openspec: "cursor", speckit: "cursor-agent" }
|
|
551
|
+
};
|
|
552
|
+
var OPENSPEC_PKG = "@fission-ai/openspec@latest";
|
|
553
|
+
var SPECKIT_SRC = "git+https://github.com/github/spec-kit.git";
|
|
554
|
+
function detectWorkflow(root2) {
|
|
555
|
+
if (fs8.existsSync(path8.join(root2, "openspec"))) return "openspec";
|
|
556
|
+
if (fs8.existsSync(path8.join(root2, ".specify"))) return "speckit";
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
var has = (cmd) => spawnSync(cmd, ["--version"], { stdio: "ignore", shell: process.platform === "win32" }).status === 0;
|
|
560
|
+
var hasUv = () => has("uvx");
|
|
561
|
+
function installCommand(workflow, agents) {
|
|
562
|
+
if (workflow === "openspec") {
|
|
563
|
+
const tools = agents.map((a) => SDD_AGENTS[a].openspec).join(",") || "none";
|
|
564
|
+
const runner = has("pnpm") ? ["pnpm", "dlx"] : has("npx") ? ["npx", "--yes"] : null;
|
|
565
|
+
return runner && [...runner, OPENSPEC_PKG, "init", "--tools", tools, "--no-animation", "--no-copilot-cloud"];
|
|
566
|
+
}
|
|
567
|
+
if (!hasUv()) return null;
|
|
568
|
+
return ["uvx", "--from", SPECKIT_SRC, "specify", "init", "--here", "--force", "--non-interactive", "--ignore-agent-tools", "--integration", SDD_AGENTS[agents[0] ?? "claude"].speckit];
|
|
569
|
+
}
|
|
570
|
+
function installWorkflow(root2, workflow, agents, official) {
|
|
571
|
+
const before = new Set(flattenFiles(scanTree(root2), true).map((f) => f.path));
|
|
572
|
+
const result = { official: false, created: [], ignored: [] };
|
|
573
|
+
const cmd = official ? installCommand(workflow, agents) : null;
|
|
574
|
+
if (cmd) {
|
|
575
|
+
const run = spawnSync(cmd[0], cmd.slice(1), { cwd: root2, encoding: "utf8", timeout: 3e5, shell: process.platform === "win32", env: { ...process.env, NO_COLOR: "1" } });
|
|
576
|
+
if (run.status === 0) result.official = true;
|
|
577
|
+
else result.error = (run.error?.message ?? `${run.stderr ?? ""}
|
|
578
|
+
${run.stdout ?? ""}`).trim().split("\n").slice(-6).join("\n");
|
|
579
|
+
}
|
|
580
|
+
copyTree(path8.join(TEMPLATES_DIR, "sdd", workflow), root2, { date: today2() }, result.created, root2);
|
|
581
|
+
const dirs2 = /* @__PURE__ */ new Set();
|
|
582
|
+
for (const f of flattenFiles(scanTree(root2), true)) {
|
|
583
|
+
if (before.has(f.path) || !f.path.startsWith(".") || f.path.startsWith(".specify/memory/")) continue;
|
|
584
|
+
dirs2.add(path8.posix.dirname(f.path));
|
|
585
|
+
}
|
|
586
|
+
for (const dir of dirs2) setIgnored(root2, dir, "dir", true);
|
|
587
|
+
result.ignored = [...dirs2].sort();
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
var today2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
591
|
+
var WORKFLOW_TEXT = {
|
|
592
|
+
openspec: [
|
|
593
|
+
"## Workflow",
|
|
594
|
+
"",
|
|
595
|
+
"This project uses [OpenSpec](https://github.com/Fission-AI/OpenSpec) (spec-driven development; guide: `openspec/README.md`):",
|
|
596
|
+
"",
|
|
597
|
+
"- `openspec/specs/<domain>/spec.md` describes how the system behaves today.",
|
|
598
|
+
"- Every change starts in `openspec/changes/<change-id>/` with `proposal.md`, `tasks.md`, an optional `design.md` and delta specs, before any code.",
|
|
599
|
+
"- When it is done, archive it so its delta specs merge into `openspec/specs/`.",
|
|
600
|
+
"- Use the `/opsx:*` commands (or the `openspec` CLI) when available."
|
|
601
|
+
].join("\n"),
|
|
602
|
+
speckit: [
|
|
603
|
+
"## Workflow",
|
|
604
|
+
"",
|
|
605
|
+
"This project uses [GitHub Spec Kit](https://github.com/github/spec-kit) (spec-driven development; guide: `specs/README.md`):",
|
|
606
|
+
"",
|
|
607
|
+
"- Principles every change must respect: `.specify/memory/constitution.md`.",
|
|
608
|
+
"- One folder per feature in `specs/NNN-feature-name/`: `spec.md` (what and why), then `plan.md` (how), then `tasks.md`.",
|
|
609
|
+
"- Use the `/speckit-*` commands when available."
|
|
610
|
+
].join("\n")
|
|
611
|
+
};
|
|
612
|
+
function applyWorkflowToFile(file, workflow, text3) {
|
|
613
|
+
writeManagedBlock(file, workflow === "none" ? null : text3 ?? (workflow === "mdrules" ? null : WORKFLOW_TEXT[workflow]));
|
|
614
|
+
}
|
|
615
|
+
function writeManagedBlock(file, body, top = false, name = "workflow") {
|
|
616
|
+
const START2 = `<!-- mdrules:${name}:start -->`;
|
|
617
|
+
const END2 = `<!-- mdrules:${name}:end -->`;
|
|
618
|
+
const text3 = fs8.readFileSync(file, "utf8");
|
|
619
|
+
const s = text3.indexOf(START2);
|
|
620
|
+
const e = text3.indexOf(END2);
|
|
621
|
+
const existing = s >= 0 && e > s ? text3.slice(s, e + END2.length) : null;
|
|
622
|
+
if (body === null) {
|
|
623
|
+
if (existing) fs8.writeFileSync(file, text3.replace(new RegExp(`\\n*${escapeRe(existing)}\\n?`), "\n"));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const section2 = `${START2}
|
|
627
|
+
${body}
|
|
628
|
+
${END2}`;
|
|
629
|
+
if (existing) return fs8.writeFileSync(file, text3.replace(existing, () => section2));
|
|
630
|
+
const title = top ? /^# .*\n/m.exec(text3) : null;
|
|
631
|
+
if (title) return fs8.writeFileSync(file, text3.slice(0, title.index + title[0].length) + "\n" + section2 + "\n" + text3.slice(title.index + title[0].length));
|
|
632
|
+
fs8.writeFileSync(file, text3.replace(/\s*$/, "") + "\n\n" + section2 + "\n");
|
|
633
|
+
}
|
|
634
|
+
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
635
|
+
var dirs = (abs) => {
|
|
636
|
+
try {
|
|
637
|
+
return fs8.readdirSync(abs, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name).sort();
|
|
638
|
+
} catch {
|
|
639
|
+
return [];
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
var read = (abs) => {
|
|
643
|
+
try {
|
|
644
|
+
return fs8.readFileSync(abs, "utf8");
|
|
645
|
+
} catch {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
var progress = (text3) => ({ done: text3?.match(/^\s*[-*+] \[[xX]\]/gm)?.length ?? 0, total: text3?.match(/^\s*[-*+] \[[ xX]\]/gm)?.length ?? 0 });
|
|
650
|
+
var heading = (text3) => text3?.match(/^#\s+(.+)$/m)?.[1].trim() ?? null;
|
|
651
|
+
var countSpecs = (abs) => {
|
|
652
|
+
try {
|
|
653
|
+
return fs8.readdirSync(abs, { recursive: true }).filter((f) => String(f).endsWith("spec.md")).length;
|
|
654
|
+
} catch {
|
|
655
|
+
return 0;
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
function summarizeSdd(root2) {
|
|
659
|
+
const out = {};
|
|
660
|
+
const os3 = path8.join(root2, "openspec");
|
|
661
|
+
if (fs8.existsSync(os3)) {
|
|
662
|
+
const rel = (...p3) => toPosix(path8.join("openspec", ...p3));
|
|
663
|
+
out.openspec = {
|
|
664
|
+
changes: dirs(path8.join(os3, "changes")).filter((id) => id !== "archive").map((id) => {
|
|
665
|
+
const dir = path8.join(os3, "changes", id);
|
|
666
|
+
const proposal = read(path8.join(dir, "proposal.md"));
|
|
667
|
+
const tasks = read(path8.join(dir, "tasks.md"));
|
|
668
|
+
return {
|
|
669
|
+
id,
|
|
670
|
+
// "Proposal: Add login" → "Add login"
|
|
671
|
+
title: heading(proposal)?.replace(/^(change\s+)?proposal:\s*/i, "") ?? id,
|
|
672
|
+
path: rel("changes", id, proposal !== null ? "proposal.md" : "tasks.md"),
|
|
673
|
+
artifacts: { proposal: proposal !== null, design: fs8.existsSync(path8.join(dir, "design.md")), tasks: tasks !== null, specs: countSpecs(path8.join(dir, "specs")) },
|
|
674
|
+
tasks: progress(tasks)
|
|
675
|
+
};
|
|
676
|
+
}),
|
|
677
|
+
specs: dirs(path8.join(os3, "specs")).filter((id) => fs8.existsSync(path8.join(os3, "specs", id, "spec.md"))).map((id) => ({ id, path: rel("specs", id, "spec.md") })),
|
|
678
|
+
archived: dirs(path8.join(os3, "changes", "archive")).reverse().slice(0, 12).map((id) => {
|
|
679
|
+
const file = ["proposal.md", "tasks.md"].find((f) => fs8.existsSync(path8.join(os3, "changes", "archive", id, f)));
|
|
680
|
+
return { id, path: file ? rel("changes", "archive", id, file) : "" };
|
|
681
|
+
})
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
if (fs8.existsSync(path8.join(root2, ".specify"))) {
|
|
685
|
+
const constitution = read(path8.join(root2, ".specify", "memory", "constitution.md"));
|
|
686
|
+
out.speckit = {
|
|
687
|
+
// The template is full of [PLACEHOLDERS] until /speckit-constitution fills it.
|
|
688
|
+
constitution: constitution === null ? null : { path: ".specify/memory/constitution.md", filled: !/\[[A-Z_0-9]+\]/.test(constitution) },
|
|
689
|
+
features: dirs(path8.join(root2, "specs")).map((id) => {
|
|
690
|
+
const dir = path8.join(root2, "specs", id);
|
|
691
|
+
const spec = read(path8.join(dir, "spec.md"));
|
|
692
|
+
const tasks = read(path8.join(dir, "tasks.md"));
|
|
693
|
+
return {
|
|
694
|
+
id,
|
|
695
|
+
// "Feature Specification: Login" → "Login"
|
|
696
|
+
title: heading(spec)?.replace(/^feature specification:\s*/i, "") ?? id,
|
|
697
|
+
path: toPosix(path8.join("specs", id, spec !== null ? "spec.md" : "plan.md")),
|
|
698
|
+
artifacts: { spec: spec !== null, plan: fs8.existsSync(path8.join(dir, "plan.md")), tasks: tasks !== null },
|
|
699
|
+
tasks: progress(tasks)
|
|
700
|
+
};
|
|
701
|
+
})
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
return out;
|
|
705
|
+
}
|
|
706
|
+
function sddPrefixes(summary) {
|
|
707
|
+
return [...summary.openspec ? ["openspec/"] : [], ...summary.speckit ? ["specs/", ".specify/"] : []];
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/core/proposals.ts
|
|
711
|
+
var SKILL = path9.join(".claude", "skills", "mdrules-proposals");
|
|
712
|
+
function proposalsState(root2) {
|
|
713
|
+
return { active: fs9.existsSync(path9.join(root2, SKILL, "SKILL.md")), layout: readConfig(root2)?.proposals ?? "general" };
|
|
714
|
+
}
|
|
715
|
+
var WHERE = {
|
|
716
|
+
general: "`docs/proposals/NNN-short-name.md` (NNN: the next number in that folder)",
|
|
717
|
+
structured: "`docs/proposals/<topic>/NNN-short-name.md`, one folder per topic (e.g. `sidebar`, `auth`); in a monorepo, a proposal for a single area goes in `<area>/docs/proposals/<topic>/`. Reuse an existing topic folder; ask the user before creating a new one"
|
|
718
|
+
};
|
|
719
|
+
function section(layout) {
|
|
720
|
+
return [
|
|
721
|
+
"## Proposals",
|
|
722
|
+
"",
|
|
723
|
+
"When an answer would be long, has options or a design to validate, or the user asks for one, write a",
|
|
724
|
+
"temporary proposal the user answers in mdrules instead of a chat answer (skill: `mdrules-proposals`).",
|
|
725
|
+
"",
|
|
726
|
+
`- Where: ${WHERE[layout]}.`,
|
|
727
|
+
"- The user ticks options and comments (`<!-- comment by \u2026 -->`). When they say it is answered, reply to the",
|
|
728
|
+
" comments, update every doc that depends on the decision (tasks, phases, architecture\u2026), then delete the proposal."
|
|
729
|
+
].join("\n");
|
|
730
|
+
}
|
|
731
|
+
function mainDocs(root2, mainDoc) {
|
|
732
|
+
const doc = mainDoc ?? MAIN_DOCS.find((d) => fs9.existsSync(path9.join(root2, d)));
|
|
733
|
+
return doc ? [path9.join(root2, doc)] : [];
|
|
734
|
+
}
|
|
735
|
+
function activateProposals(root2, layout, mainDoc) {
|
|
736
|
+
const dir = path9.join(root2, SKILL);
|
|
737
|
+
fs9.mkdirSync(dir, { recursive: true });
|
|
738
|
+
fs9.writeFileSync(path9.join(dir, "SKILL.md"), fill(fs9.readFileSync(path9.join(TEMPLATES_DIR, "proposals", "SKILL.md"), "utf8"), { where: WHERE[layout] }));
|
|
739
|
+
fs9.mkdirSync(path9.join(root2, "docs", "proposals"), { recursive: true });
|
|
740
|
+
for (const file of mainDocs(root2, mainDoc)) writeManagedBlock(file, section(layout), false, "proposals");
|
|
741
|
+
writeConfig(root2, { ...readConfig(root2) ?? { mainDoc, langs: [] }, proposals: layout });
|
|
742
|
+
}
|
|
743
|
+
function deactivateProposals(root2, mainDoc) {
|
|
744
|
+
fs9.rmSync(path9.join(root2, SKILL), { recursive: true, force: true });
|
|
745
|
+
for (const file of mainDocs(root2, mainDoc)) writeManagedBlock(file, null, false, "proposals");
|
|
746
|
+
}
|
|
747
|
+
function authorName(root2) {
|
|
748
|
+
try {
|
|
749
|
+
return execFileSync2("git", ["config", "user.name"], { cwd: root2, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
750
|
+
} catch {
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/core/skills.ts
|
|
756
|
+
import fs10 from "fs";
|
|
757
|
+
import os2 from "os";
|
|
758
|
+
import path10 from "path";
|
|
759
|
+
import YAML3 from "yaml";
|
|
760
|
+
var SOURCE_DIRS = {
|
|
761
|
+
claude: [".claude", "skills"],
|
|
762
|
+
// Shared folder read by Codex, Copilot and Gemini CLI, and where `npx skills` installs.
|
|
763
|
+
agents: [".agents", "skills"],
|
|
764
|
+
codex: [".codex", "skills"],
|
|
765
|
+
gemini: [".gemini", "skills"],
|
|
766
|
+
copilot: [".copilot", "skills"],
|
|
767
|
+
cursor: [".cursor", "skills"]
|
|
768
|
+
};
|
|
769
|
+
var PROJECT_TARGETS = { claude: ".claude/skills", agents: ".agents/skills" };
|
|
770
|
+
var sourceDir = (source) => path10.join(os2.homedir(), ...SOURCE_DIRS[source]);
|
|
771
|
+
var display = (p3) => p3.startsWith(os2.homedir() + path10.sep) ? "~" + p3.slice(os2.homedir().length) : p3;
|
|
772
|
+
function skillSources() {
|
|
773
|
+
return Object.keys(SOURCE_DIRS).map((id) => ({ id, path: display(sourceDir(id)), exists: fs10.existsSync(sourceDir(id)) }));
|
|
774
|
+
}
|
|
775
|
+
var tagsFile = () => path10.join(GLOBAL_DIR, "skills.json");
|
|
776
|
+
function readTags() {
|
|
777
|
+
try {
|
|
778
|
+
return JSON.parse(fs10.readFileSync(tagsFile(), "utf8")).tags ?? {};
|
|
779
|
+
} catch {
|
|
780
|
+
return {};
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function setSkillTags(name, tags) {
|
|
784
|
+
const all = readTags();
|
|
785
|
+
const clean = [...new Set(tags.map((t) => t.trim().toLowerCase()).filter(Boolean))];
|
|
786
|
+
if (clean.length) all[name] = clean;
|
|
787
|
+
else delete all[name];
|
|
788
|
+
fs10.mkdirSync(GLOBAL_DIR, { recursive: true });
|
|
789
|
+
fs10.writeFileSync(tagsFile(), JSON.stringify({ tags: all }, null, 2) + "\n");
|
|
790
|
+
return clean;
|
|
791
|
+
}
|
|
792
|
+
function findSkills(dir, source, managed, depth = 0) {
|
|
793
|
+
let entries;
|
|
794
|
+
try {
|
|
795
|
+
entries = fs10.readdirSync(dir, { withFileTypes: true });
|
|
796
|
+
} catch {
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
return entries.flatMap((e) => {
|
|
800
|
+
const abs = path10.join(dir, e.name);
|
|
801
|
+
let real;
|
|
802
|
+
try {
|
|
803
|
+
if (!fs10.statSync(abs).isDirectory()) return [];
|
|
804
|
+
real = fs10.realpathSync(abs);
|
|
805
|
+
} catch {
|
|
806
|
+
return [];
|
|
807
|
+
}
|
|
808
|
+
if (fs10.existsSync(path10.join(abs, "SKILL.md"))) return [{ dir: abs, real, source, managed }];
|
|
809
|
+
return depth < 2 ? findSkills(abs, source, managed || e.name === "synced" || e.name.startsWith("."), depth + 1) : [];
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
function frontmatter(text3) {
|
|
813
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text3);
|
|
814
|
+
if (!m) return null;
|
|
815
|
+
try {
|
|
816
|
+
const data = YAML3.parse(m[1], { logLevel: "error" });
|
|
817
|
+
return data && typeof data === "object" ? data : null;
|
|
818
|
+
} catch {
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
function listFiles(dir) {
|
|
823
|
+
try {
|
|
824
|
+
return fs10.readdirSync(dir, { recursive: true, withFileTypes: true }).map((e) => ({ e, rel: path10.relative(dir, path10.join(e.parentPath ?? e.path ?? dir, e.name)).split(path10.sep).join("/") })).filter(({ e, rel }) => e.isFile() && rel !== "SKILL.md").map(({ rel }) => rel).sort().slice(0, 100);
|
|
825
|
+
} catch {
|
|
826
|
+
return [];
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
function listSkills(root2) {
|
|
830
|
+
const byReal = /* @__PURE__ */ new Map();
|
|
831
|
+
for (const source of Object.keys(SOURCE_DIRS)) {
|
|
832
|
+
for (const f of findSkills(sourceDir(source), source, false)) byReal.set(f.real, [...byReal.get(f.real) ?? [], f]);
|
|
833
|
+
}
|
|
834
|
+
const userTags = readTags();
|
|
835
|
+
const skills = [];
|
|
836
|
+
for (const [real, found] of byReal) {
|
|
837
|
+
const text3 = fs10.readFileSync(path10.join(real, "SKILL.md"), "utf8");
|
|
838
|
+
const fm = frontmatter(text3);
|
|
839
|
+
const folder = path10.basename(found[0].dir);
|
|
840
|
+
const name = typeof fm?.name === "string" ? fm.name : folder;
|
|
841
|
+
const description = typeof fm?.description === "string" ? fm.description.trim() : "";
|
|
842
|
+
const issues = [];
|
|
843
|
+
if (!fm) issues.push("frontmatter");
|
|
844
|
+
else {
|
|
845
|
+
if (typeof fm.name !== "string" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(fm.name) || fm.name.length > 64) issues.push("name");
|
|
846
|
+
else if (fm.name !== folder) issues.push("name-dir");
|
|
847
|
+
if (!description) issues.push("description");
|
|
848
|
+
else if (description.length > 1024) issues.push("description-long");
|
|
849
|
+
}
|
|
850
|
+
const declared = fm?.metadata?.tags;
|
|
851
|
+
const metaTags = Array.isArray(declared) ? declared.map(String) : typeof declared === "string" ? declared.split(",") : [];
|
|
852
|
+
skills.push({
|
|
853
|
+
key: real,
|
|
854
|
+
name,
|
|
855
|
+
folder,
|
|
856
|
+
description,
|
|
857
|
+
locations: found.map((f) => ({ source: f.source, path: display(f.dir) })),
|
|
858
|
+
managed: found.every((f) => f.managed),
|
|
859
|
+
files: listFiles(real),
|
|
860
|
+
tags: [.../* @__PURE__ */ new Set([...userTags[name] ?? [], ...metaTags.map((t) => t.trim().toLowerCase()).filter(Boolean)])],
|
|
861
|
+
issues,
|
|
862
|
+
installed: Object.keys(PROJECT_TARGETS).filter((t) => fs10.existsSync(path10.join(root2, PROJECT_TARGETS[t], folder, "SKILL.md")))
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
866
|
+
}
|
|
867
|
+
function getSkill(root2, key) {
|
|
868
|
+
const skill = typeof key === "string" ? listSkills(root2).find((s) => s.key === key) : void 0;
|
|
869
|
+
if (!skill) throw Object.assign(new Error("Skill not found"), { code: "ENOENT" });
|
|
870
|
+
return skill;
|
|
871
|
+
}
|
|
872
|
+
function projectTargets(root2) {
|
|
873
|
+
const has2 = (p3) => fs10.existsSync(path10.join(root2, p3));
|
|
874
|
+
const out = [];
|
|
875
|
+
if (has2(".claude") || has2("CLAUDE.md")) out.push("claude");
|
|
876
|
+
if (has2(".agents") || has2("AGENTS.md") || has2(".codex") || has2(".gemini") || has2(".github/skills")) out.push("agents");
|
|
877
|
+
return out.length ? out : ["claude"];
|
|
878
|
+
}
|
|
879
|
+
function installSkill2(root2, skill, targets, overwrite) {
|
|
880
|
+
const dests = targets.map((t) => path10.join(root2, PROJECT_TARGETS[t], skill.folder));
|
|
881
|
+
const existing = dests.filter((d) => fs10.existsSync(d));
|
|
882
|
+
if (existing.length && !overwrite) return { conflict: existing.map((d) => path10.relative(root2, d).split(path10.sep).join("/")) };
|
|
883
|
+
for (const dest of dests) {
|
|
884
|
+
fs10.rmSync(dest, { recursive: true, force: true });
|
|
885
|
+
fs10.mkdirSync(path10.dirname(dest), { recursive: true });
|
|
886
|
+
fs10.cpSync(skill.key, dest, { recursive: true, dereference: true });
|
|
887
|
+
}
|
|
888
|
+
return { ok: true };
|
|
889
|
+
}
|
|
890
|
+
|
|
435
891
|
// src/server/index.ts
|
|
436
892
|
var IMAGE_MIME = {
|
|
437
893
|
".png": "image/png",
|
|
@@ -479,7 +935,7 @@ function isLocalRequest(req) {
|
|
|
479
935
|
async function startServer({ root: root2, port, open: openBrowser }) {
|
|
480
936
|
const config = () => readConfig(root2) ?? { mainDoc: null, langs: [] };
|
|
481
937
|
const sourceLang = () => projectSourceLang(root2, readConfig(root2)?.mainDoc);
|
|
482
|
-
const viewLang = (value) =>
|
|
938
|
+
const viewLang = (value) => isLangCode(value) && value !== sourceLang() && config().langs.includes(value) ? value : null;
|
|
483
939
|
const mdPath = (rel) => {
|
|
484
940
|
if (typeof rel !== "string" || !rel.toLowerCase().endsWith(".md")) throw new HttpError(400, "Expected a .md path");
|
|
485
941
|
safeResolve(root2, rel);
|
|
@@ -489,24 +945,38 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
489
945
|
if (typeof rel !== "string" || !rel.trim()) throw new HttpError(400, "Expected a path");
|
|
490
946
|
const abs = safeResolve(root2, rel);
|
|
491
947
|
if (abs === root2) throw new HttpError(400, "Refusing to delete the project root");
|
|
492
|
-
if (!
|
|
948
|
+
if (!fs11.existsSync(abs)) throw new HttpError(404, "Not found");
|
|
493
949
|
return abs;
|
|
494
950
|
};
|
|
495
951
|
const routes = {
|
|
496
952
|
"GET /api/info": () => {
|
|
497
953
|
const c = config();
|
|
498
|
-
return {
|
|
954
|
+
return {
|
|
955
|
+
name: path11.basename(root2),
|
|
956
|
+
mainDoc: c.mainDoc ?? null,
|
|
957
|
+
sourceLang: sourceLang(),
|
|
958
|
+
langs: c.langs.filter((l) => l !== sourceLang()),
|
|
959
|
+
langNames: Object.fromEntries(c.langs.map((l) => [l, langName(l, c)])),
|
|
960
|
+
git: isGitRepo(root2),
|
|
961
|
+
proposals: proposalsState(root2),
|
|
962
|
+
author: authorName(root2)
|
|
963
|
+
};
|
|
499
964
|
},
|
|
500
965
|
"PUT /api/config": (_, body) => {
|
|
501
966
|
const c = config();
|
|
502
|
-
if (Array.isArray(body.langs)) c.langs = body.langs.filter(
|
|
967
|
+
if (Array.isArray(body.langs)) c.langs = body.langs.filter(isLangCode);
|
|
503
968
|
if (isLang(body.sourceLang) && body.sourceLang !== sourceLang()) {
|
|
504
969
|
if (!c.mainDoc) throw new HttpError(400, "No main document configured; run `mdrules init`");
|
|
505
|
-
applyRulesToFile(
|
|
970
|
+
applyRulesToFile(path11.join(root2, c.mainDoc), void 0, body.sourceLang);
|
|
506
971
|
}
|
|
507
972
|
writeConfig(root2, c);
|
|
508
973
|
return { ...c, langs: c.langs.filter((l) => l !== sourceLang()) };
|
|
509
974
|
},
|
|
975
|
+
"PUT /api/proposals": (_, body) => {
|
|
976
|
+
if (body.active) activateProposals(root2, body.layout === "structured" ? "structured" : "general", config().mainDoc);
|
|
977
|
+
else deactivateProposals(root2, config().mainDoc);
|
|
978
|
+
return proposalsState(root2);
|
|
979
|
+
},
|
|
510
980
|
"GET /api/settings": () => readSettings(),
|
|
511
981
|
"PUT /api/settings": (_, body) => writeSettings(body),
|
|
512
982
|
"GET /api/tree": (url2) => {
|
|
@@ -519,10 +989,10 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
519
989
|
"GET /api/file": (url2) => {
|
|
520
990
|
const rel = mdPath(url2.searchParams.get("path"));
|
|
521
991
|
const l = viewLang(url2.searchParams.get("lang"));
|
|
522
|
-
const source =
|
|
992
|
+
const source = fs11.readFileSync(safeResolve(root2, rel), "utf8");
|
|
523
993
|
if (l) {
|
|
524
994
|
const tPath = translationPath(root2, l, rel);
|
|
525
|
-
if (
|
|
995
|
+
if (fs11.existsSync(tPath)) return { path: rel, content: fs11.readFileSync(tPath, "utf8"), target: "translation", status: fileStatus(root2, l, rel) };
|
|
526
996
|
return { path: rel, content: source, target: "source", status: "missing" };
|
|
527
997
|
}
|
|
528
998
|
return { path: rel, content: source, target: "source", status: null };
|
|
@@ -531,31 +1001,40 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
531
1001
|
const rel = mdPath(body.path);
|
|
532
1002
|
const l = viewLang(body.lang);
|
|
533
1003
|
const file = body.target === "translation" && l ? translationPath(root2, l, rel) : safeResolve(root2, rel);
|
|
534
|
-
|
|
535
|
-
|
|
1004
|
+
fs11.mkdirSync(path11.dirname(file), { recursive: true });
|
|
1005
|
+
fs11.writeFileSync(file, String(body.content ?? ""));
|
|
536
1006
|
return { ok: true, status: l ? fileStatus(root2, l, rel) : null };
|
|
537
1007
|
},
|
|
538
1008
|
"POST /api/file": (_, body) => {
|
|
539
1009
|
const rel = mdPath(body.path);
|
|
540
1010
|
const file = safeResolve(root2, rel);
|
|
541
|
-
if (
|
|
542
|
-
|
|
543
|
-
const title =
|
|
544
|
-
|
|
1011
|
+
if (fs11.existsSync(file)) throw new HttpError(409, "File already exists");
|
|
1012
|
+
fs11.mkdirSync(path11.dirname(file), { recursive: true });
|
|
1013
|
+
const title = path11.basename(rel, ".md").replace(/^\d+[-_]/, "").replace(/[-_]/g, " ");
|
|
1014
|
+
fs11.writeFileSync(file, `# ${title.charAt(0).toUpperCase() + title.slice(1)}
|
|
545
1015
|
|
|
546
1016
|
`);
|
|
547
1017
|
return { ok: true, path: rel };
|
|
548
1018
|
},
|
|
1019
|
+
// Only a mkdir: nothing else is touched, so editors that have the project open are unaffected.
|
|
1020
|
+
"POST /api/dir": (_, body) => {
|
|
1021
|
+
if (typeof body.path !== "string" || !body.path.trim()) throw new HttpError(400, "Expected a path");
|
|
1022
|
+
const dir = safeResolve(root2, body.path);
|
|
1023
|
+
if (dir === root2) throw new HttpError(400, "Expected a path");
|
|
1024
|
+
if (fs11.existsSync(dir)) throw new HttpError(409, "A file or folder with that name already exists");
|
|
1025
|
+
fs11.mkdirSync(dir, { recursive: true });
|
|
1026
|
+
return { ok: true, path: toPosix(path11.relative(root2, dir)) };
|
|
1027
|
+
},
|
|
549
1028
|
"GET /api/delete-preview": (url2) => {
|
|
550
1029
|
const abs = deletable(url2.searchParams.get("path"));
|
|
551
|
-
if (!
|
|
552
|
-
const files =
|
|
1030
|
+
if (!fs11.statSync(abs).isDirectory()) return { kind: "file", files: 1, md: 1 };
|
|
1031
|
+
const files = fs11.readdirSync(abs, { recursive: true, withFileTypes: true }).filter((e) => e.isFile());
|
|
553
1032
|
return { kind: "dir", files: files.length, md: files.filter((e) => e.name.toLowerCase().endsWith(".md")).length };
|
|
554
1033
|
},
|
|
555
1034
|
"DELETE /api/path": (url2) => {
|
|
556
|
-
const rel = toPosix(
|
|
557
|
-
|
|
558
|
-
for (const l of config().langs)
|
|
1035
|
+
const rel = toPosix(path11.relative(root2, deletable(url2.searchParams.get("path"))));
|
|
1036
|
+
fs11.rmSync(safeResolve(root2, rel), { recursive: true });
|
|
1037
|
+
for (const l of config().langs) fs11.rmSync(translationPath(root2, l, rel), { recursive: true, force: true });
|
|
559
1038
|
return { ok: true };
|
|
560
1039
|
},
|
|
561
1040
|
"POST /api/ignore": (_, body) => {
|
|
@@ -564,21 +1043,48 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
564
1043
|
return { ok: true };
|
|
565
1044
|
},
|
|
566
1045
|
"GET /api/diff": (url2) => ({ diff: gitDiff(root2, mdPath(url2.searchParams.get("path"))) }),
|
|
567
|
-
"GET /api/dashboard": () =>
|
|
1046
|
+
"GET /api/dashboard": () => {
|
|
1047
|
+
const sdd = summarizeSdd(root2);
|
|
1048
|
+
const shown = sddPrefixes(sdd);
|
|
1049
|
+
return { docs: summarize(root2).filter((d) => !shown.some((p3) => d.path.startsWith(p3))), sdd };
|
|
1050
|
+
},
|
|
568
1051
|
"GET /api/rules": () => {
|
|
569
1052
|
const mainDoc = readConfig(root2)?.mainDoc;
|
|
570
|
-
const
|
|
571
|
-
return { rules: listRules(), applied: appliedRuleIds(
|
|
1053
|
+
const text3 = mainDoc && fs11.existsSync(path11.join(root2, mainDoc)) ? fs11.readFileSync(path11.join(root2, mainDoc), "utf8") : "";
|
|
1054
|
+
return { rules: listRules(), applied: appliedRuleIds(text3), mainDoc: mainDoc ?? null };
|
|
572
1055
|
},
|
|
573
1056
|
"PUT /api/rules": (_, body) => saveRule(body),
|
|
574
1057
|
"DELETE /api/rules": (url2) => {
|
|
575
1058
|
deleteRule(url2.searchParams.get("id") ?? "");
|
|
576
1059
|
return { ok: true };
|
|
577
1060
|
},
|
|
1061
|
+
// Global skills: reads and writes are limited to SKILL.md of discovered skill folders.
|
|
1062
|
+
"GET /api/skills": () => ({ skills: listSkills(root2), sources: skillSources(), targets: projectTargets(root2) }),
|
|
1063
|
+
"PUT /api/skills/tags": (_, body) => {
|
|
1064
|
+
if (typeof body.name !== "string" || !Array.isArray(body.tags)) throw new HttpError(400, "Expected name and tags");
|
|
1065
|
+
return { tags: setSkillTags(body.name, body.tags.map(String)) };
|
|
1066
|
+
},
|
|
1067
|
+
"GET /api/skills/file": (url2) => {
|
|
1068
|
+
const skill = getSkill(root2, url2.searchParams.get("key"));
|
|
1069
|
+
return { path: skill.key, content: fs11.readFileSync(path11.join(skill.key, "SKILL.md"), "utf8"), target: "source", status: null };
|
|
1070
|
+
},
|
|
1071
|
+
"PUT /api/skills/file": (_, body) => {
|
|
1072
|
+
const skill = getSkill(root2, body.key);
|
|
1073
|
+
if (skill.managed) throw new HttpError(403, "This skill is managed by the app that installed it");
|
|
1074
|
+
fs11.writeFileSync(path11.join(skill.key, "SKILL.md"), String(body.content ?? ""));
|
|
1075
|
+
return { ok: true, status: null };
|
|
1076
|
+
},
|
|
1077
|
+
"POST /api/skills/install": (_, body) => {
|
|
1078
|
+
const targets = (Array.isArray(body.targets) ? body.targets : []).filter((t) => typeof t === "string" && t in PROJECT_TARGETS);
|
|
1079
|
+
if (!targets.length) throw new HttpError(400, "Expected at least one target");
|
|
1080
|
+
const result = installSkill2(root2, getSkill(root2, body.key), targets, !!body.overwrite);
|
|
1081
|
+
if (result.conflict) throw new HttpError(409, result.conflict.join(", "));
|
|
1082
|
+
return result;
|
|
1083
|
+
},
|
|
578
1084
|
"POST /api/rules/apply": (_, body) => {
|
|
579
1085
|
const mainDoc = readConfig(root2)?.mainDoc;
|
|
580
1086
|
if (!mainDoc) throw new HttpError(400, "No main document configured; run `mdrules init`");
|
|
581
|
-
applyRulesToFile(
|
|
1087
|
+
applyRulesToFile(path11.join(root2, mainDoc), body.ids ?? []);
|
|
582
1088
|
return { ok: true };
|
|
583
1089
|
}
|
|
584
1090
|
};
|
|
@@ -610,13 +1116,13 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
610
1116
|
res.writeHead(404).end();
|
|
611
1117
|
return;
|
|
612
1118
|
}
|
|
613
|
-
const type = IMAGE_MIME[
|
|
614
|
-
if (!type || !
|
|
1119
|
+
const type = IMAGE_MIME[path11.extname(file).toLowerCase()];
|
|
1120
|
+
if (!type || !fs11.existsSync(file) || !fs11.statSync(file).isFile()) {
|
|
615
1121
|
res.writeHead(404).end();
|
|
616
1122
|
return;
|
|
617
1123
|
}
|
|
618
|
-
res.writeHead(200, { "content-type": type, "cache-control": "no-cache" });
|
|
619
|
-
|
|
1124
|
+
res.writeHead(200, { "content-type": type, "cache-control": "no-cache", "content-security-policy": "sandbox", "x-content-type-options": "nosniff" });
|
|
1125
|
+
fs11.createReadStream(file).pipe(res);
|
|
620
1126
|
}
|
|
621
1127
|
const actualPort = await listen(server, port);
|
|
622
1128
|
const wss = new WebSocketServer({ server, path: "/ws", verifyClient: ({ req }) => isLocalRequest(req) });
|
|
@@ -629,17 +1135,17 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
629
1135
|
ignoreInitial: true,
|
|
630
1136
|
// Unreadable folders (e.g. caches owned by another user) are skipped instead of failing.
|
|
631
1137
|
ignorePermissionErrors: true,
|
|
632
|
-
ignored: (
|
|
633
|
-
const rel = toPosix(
|
|
1138
|
+
ignored: (p3, stats) => {
|
|
1139
|
+
const rel = toPosix(path11.relative(root2, p3));
|
|
634
1140
|
if (rel === ".mdrules") return false;
|
|
635
1141
|
if (rel.startsWith(".mdrules/")) return !rel.startsWith(".mdrules/i18n") && rel !== ".mdrules/state.json" && rel !== ".mdrules/config.json";
|
|
636
1142
|
return skips(rel, stats ? stats.isDirectory() : void 0);
|
|
637
1143
|
}
|
|
638
1144
|
}).on("error", (err) => console.warn(pc.yellow(` file watcher: ${err.message}`))).on("all", (event, file) => {
|
|
639
|
-
const abs =
|
|
1145
|
+
const abs = path11.resolve(file);
|
|
640
1146
|
if (!abs.endsWith(".md") && !abs.endsWith(".json") && !abs.endsWith(".mdrulesignore") && event !== "addDir" && event !== "unlinkDir") return;
|
|
641
|
-
let rel = toPosix(
|
|
642
|
-
const i18n = toPosix(
|
|
1147
|
+
let rel = toPosix(path11.relative(root2, abs));
|
|
1148
|
+
const i18n = toPosix(path11.relative(root2, path11.join(stateDir(root2), "i18n")));
|
|
643
1149
|
if (rel.startsWith(i18n + "/")) rel = rel.split("/").slice(3).join("/");
|
|
644
1150
|
broadcast({ type: "fs", event, path: rel });
|
|
645
1151
|
});
|
|
@@ -647,7 +1153,7 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
647
1153
|
console.log(`
|
|
648
1154
|
${pc.bold("mdrules")} ${pc.dim("\u2192")} ${pc.cyan(url)} ${pc.dim(root2)}
|
|
649
1155
|
`);
|
|
650
|
-
if (!
|
|
1156
|
+
if (!fs11.existsSync(WEB_DIR)) console.log(pc.yellow(" Web UI not built: run `pnpm build`, or `pnpm dev` and open http://localhost:4320\n"));
|
|
651
1157
|
else if (openBrowser) open(url).catch(() => {
|
|
652
1158
|
});
|
|
653
1159
|
}
|
|
@@ -661,79 +1167,142 @@ function listen(server, port) {
|
|
|
661
1167
|
});
|
|
662
1168
|
}
|
|
663
1169
|
function serveStatic(pathname, res) {
|
|
664
|
-
let file =
|
|
665
|
-
if (!file.startsWith(WEB_DIR) || !
|
|
666
|
-
if (!
|
|
1170
|
+
let file = path11.join(WEB_DIR, path11.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
|
|
1171
|
+
if (!file.startsWith(WEB_DIR) || !fs11.existsSync(file) || fs11.statSync(file).isDirectory()) file = path11.join(WEB_DIR, "index.html");
|
|
1172
|
+
if (!fs11.existsSync(file)) {
|
|
667
1173
|
res.writeHead(404).end("Web UI not built. Run `pnpm build`.");
|
|
668
1174
|
return;
|
|
669
1175
|
}
|
|
670
|
-
res.writeHead(200, { "content-type": MIME[
|
|
671
|
-
|
|
1176
|
+
res.writeHead(200, { "content-type": MIME[path11.extname(file)] ?? "application/octet-stream" });
|
|
1177
|
+
fs11.createReadStream(file).pipe(res);
|
|
672
1178
|
}
|
|
673
1179
|
|
|
674
1180
|
// src/cli/init.ts
|
|
675
|
-
import
|
|
676
|
-
import
|
|
1181
|
+
import fs13 from "fs";
|
|
1182
|
+
import path13 from "path";
|
|
677
1183
|
import * as p from "@clack/prompts";
|
|
678
1184
|
import pc2 from "picocolors";
|
|
679
1185
|
|
|
680
|
-
// src/core/
|
|
681
|
-
import
|
|
682
|
-
import
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
if (e.isDirectory()) {
|
|
692
|
-
fs7.mkdirSync(to, { recursive: true });
|
|
693
|
-
copyTree(from, to, vars, created, root2);
|
|
694
|
-
} else if (!fs7.existsSync(to)) {
|
|
695
|
-
fs7.writeFileSync(to, fill(fs7.readFileSync(from, "utf8"), vars));
|
|
696
|
-
created.push(toPosix(path7.relative(root2, to)));
|
|
697
|
-
}
|
|
1186
|
+
// src/core/method.ts
|
|
1187
|
+
import fs12 from "fs";
|
|
1188
|
+
import path12 from "path";
|
|
1189
|
+
import YAML4 from "yaml";
|
|
1190
|
+
var SKIP = /* @__PURE__ */ new Set(["docs", "node_modules", "dist", "build", "coverage", "tmp"]);
|
|
1191
|
+
function detectAreas(root2) {
|
|
1192
|
+
const globs = [];
|
|
1193
|
+
try {
|
|
1194
|
+
const ws = YAML4.parse(fs12.readFileSync(path12.join(root2, "pnpm-workspace.yaml"), "utf8"), { logLevel: "error" });
|
|
1195
|
+
if (Array.isArray(ws?.packages)) globs.push(...ws.packages);
|
|
1196
|
+
} catch {
|
|
698
1197
|
}
|
|
1198
|
+
try {
|
|
1199
|
+
const ws = JSON.parse(fs12.readFileSync(path12.join(root2, "package.json"), "utf8")).workspaces;
|
|
1200
|
+
globs.push(...Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : []);
|
|
1201
|
+
} catch {
|
|
1202
|
+
}
|
|
1203
|
+
const tops = [...globs.map((g) => String(g).replace(/^\.\//, "").split("/")[0]), ...findSubprojects(scanTree(root2)).map((p3) => p3.split("/")[0])];
|
|
1204
|
+
return [...new Set(tops)].filter((d) => d && !d.includes("*") && !d.startsWith(".") && !d.startsWith("!") && !SKIP.has(d) && fs12.statSync(path12.join(root2, d), { throwIfNoEntry: false })?.isDirectory()).sort();
|
|
699
1205
|
}
|
|
700
|
-
|
|
1206
|
+
var KNOWN_PREFIXES = {
|
|
1207
|
+
apps: "APP",
|
|
1208
|
+
app: "APP",
|
|
1209
|
+
services: "SVC",
|
|
1210
|
+
service: "SVC",
|
|
1211
|
+
packages: "PKG",
|
|
1212
|
+
libs: "LIB",
|
|
1213
|
+
lib: "LIB",
|
|
1214
|
+
infra: "INF",
|
|
1215
|
+
infrastructure: "INF",
|
|
1216
|
+
proto: "PRO",
|
|
1217
|
+
backend: "BE",
|
|
1218
|
+
frontend: "FE",
|
|
1219
|
+
web: "WEB",
|
|
1220
|
+
api: "API",
|
|
1221
|
+
mobile: "MOB",
|
|
1222
|
+
tools: "TLS",
|
|
1223
|
+
scripts: "SCR"
|
|
1224
|
+
};
|
|
1225
|
+
function suggestPrefix(dir, used) {
|
|
1226
|
+
const base = KNOWN_PREFIXES[dir.toLowerCase()] ?? (dir.replace(/[^a-z0-9]/gi, "").slice(0, 3).toUpperCase() || "AREA");
|
|
1227
|
+
let prefix = base;
|
|
1228
|
+
for (let i = 2; used.has(prefix) || prefix === "CORE"; i++) prefix = `${base}${i}`;
|
|
1229
|
+
used.add(prefix);
|
|
1230
|
+
return prefix;
|
|
1231
|
+
}
|
|
1232
|
+
var rootPrefix = (areas) => areas.length ? "CORE" : "T";
|
|
1233
|
+
function scaffoldMethod(root2, mainDoc, areas) {
|
|
701
1234
|
const created = [];
|
|
702
|
-
|
|
703
|
-
copyTree(
|
|
1235
|
+
const name = path12.basename(root2);
|
|
1236
|
+
copyTree(path12.join(TEMPLATES_DIR, "method", "root"), root2, { date: today(), name, prefix: rootPrefix(areas), area: areas.length ? "core" : name }, created, root2);
|
|
1237
|
+
for (const a of areas) {
|
|
1238
|
+
const vars = { date: today(), name, prefix: a.prefix, area: a.dir };
|
|
1239
|
+
const dir = path12.join(root2, a.dir);
|
|
1240
|
+
copyTree(path12.join(TEMPLATES_DIR, "method", "area"), dir, vars, created, root2);
|
|
1241
|
+
let doc = MAIN_DOCS.map((d) => path12.join(dir, d)).find((f) => fs12.existsSync(f));
|
|
1242
|
+
if (!doc) {
|
|
1243
|
+
doc = path12.join(dir, mainDoc);
|
|
1244
|
+
fs12.writeFileSync(doc, fill(fs12.readFileSync(path12.join(TEMPLATES_DIR, "method", "area-main.md"), "utf8"), vars));
|
|
1245
|
+
created.push(path12.posix.join(a.dir, mainDoc));
|
|
1246
|
+
}
|
|
1247
|
+
writeManagedBlock(doc, areaBlock(a), true);
|
|
1248
|
+
}
|
|
704
1249
|
return created;
|
|
705
1250
|
}
|
|
706
|
-
function
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
function installSkill(root2) {
|
|
713
|
-
const dir = path7.join(root2, ".claude", "skills", "mdrules");
|
|
714
|
-
fs7.mkdirSync(dir, { recursive: true });
|
|
715
|
-
fs7.copyFileSync(path7.join(TEMPLATES_DIR, "skill", "SKILL.md"), path7.join(dir, "SKILL.md"));
|
|
1251
|
+
function areaBlock(a) {
|
|
1252
|
+
return [
|
|
1253
|
+
`- Tasks: prefix \`${a.prefix}\` \xB7 index in \`docs/TASKS.md\` \xB7 one file per task in \`docs/tasks/\``,
|
|
1254
|
+
"- Area architecture: `docs/ARCHITECTURE.md` \xB7 cross-cutting: `../docs/architecture/`"
|
|
1255
|
+
].join("\n");
|
|
716
1256
|
}
|
|
717
|
-
function
|
|
718
|
-
const
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
1257
|
+
function methodSection(areas) {
|
|
1258
|
+
const taskDirs = ["docs/tasks", ...areas.map((a) => `${a.dir}/docs/tasks`)].join(" ");
|
|
1259
|
+
const map = areas.length ? [
|
|
1260
|
+
"",
|
|
1261
|
+
"### Map",
|
|
1262
|
+
"",
|
|
1263
|
+
"| Folder | Docs | Task prefix |",
|
|
1264
|
+
"|---|---|---|",
|
|
1265
|
+
...areas.map((a) => `| \`${a.dir}/\` | \`${a.dir}/docs/\` (architecture, tasks) | ${a.prefix} |`),
|
|
1266
|
+
`| \`docs/\` | vision, cross-cutting architecture, phases and cross-area tasks | ${rootPrefix(areas)} |`
|
|
1267
|
+
] : [];
|
|
1268
|
+
return [
|
|
1269
|
+
"## Workflow (mdrules method)",
|
|
1270
|
+
"",
|
|
1271
|
+
"How we work: `docs/README.md`. Vision: `docs/vision.md` \xB7 architecture: `docs/architecture/` \xB7 phases: `docs/phases/` \xB7 tasks: `docs/TASKS.md`.",
|
|
1272
|
+
...map,
|
|
1273
|
+
"",
|
|
1274
|
+
"### Session start",
|
|
1275
|
+
"",
|
|
1276
|
+
`1. Find tasks in progress: \`grep -rl "^status: in-progress" --include=*.md ${taskDirs}\`.`,
|
|
1277
|
+
'2. Open the one with the most recent `updated`, read its "Next step" and tell the user in one line where we left off.',
|
|
1278
|
+
"3. If there are none, ask the user what to work on.",
|
|
1279
|
+
"",
|
|
1280
|
+
"### During the session",
|
|
1281
|
+
"",
|
|
1282
|
+
`- Read only what the task needs: its file, ${areas.length ? "the `CLAUDE.md`/`AGENTS.md` and `docs/` of its area, " : ""}and the one file in \`docs/architecture/\` that applies.`,
|
|
1283
|
+
'- Tick acceptance criteria as they are met and keep "Next step" current.',
|
|
1284
|
+
"- Before replacing a decision, propose it with the reasons; then update it in its one place.",
|
|
1285
|
+
"",
|
|
1286
|
+
"### Tasks",
|
|
1287
|
+
"",
|
|
1288
|
+
"- The user defines tasks: propose them, but create them only when asked (`/mdrules-new-task`).",
|
|
1289
|
+
`- One task = one file in ${areas.length ? "`<area>/docs/tasks/`; cross-area tasks in `docs/tasks/`" : "`docs/tasks/`"}. \`TASKS.md\` is only an index; each phase lists its tasks.`,
|
|
1290
|
+
"- At the end of a session: `/mdrules-close-session`.",
|
|
1291
|
+
"",
|
|
1292
|
+
"### Documentation without redundancy",
|
|
1293
|
+
"",
|
|
1294
|
+
"- Each fact lives in one place; everything else links to it.",
|
|
1295
|
+
areas.length ? "- A decision affecting one area goes in `<area>/docs/ARCHITECTURE.md`; several areas, in `docs/architecture/`." : "- Decisions go in `docs/architecture/`, one file per topic.",
|
|
1296
|
+
"- Main docs hold orientation, rules and commands only: never decisions or task lists."
|
|
1297
|
+
].join("\n");
|
|
722
1298
|
}
|
|
723
|
-
function
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
} catch {
|
|
730
|
-
return false;
|
|
731
|
-
}
|
|
1299
|
+
function installMethodSkills(root2) {
|
|
1300
|
+
const src = path12.join(TEMPLATES_DIR, "method", "skills");
|
|
1301
|
+
for (const name of fs12.readdirSync(src)) {
|
|
1302
|
+
const dest = path12.join(root2, ".claude", "skills", name);
|
|
1303
|
+
fs12.mkdirSync(dest, { recursive: true });
|
|
1304
|
+
fs12.copyFileSync(path12.join(src, name, "SKILL.md"), path12.join(dest, "SKILL.md"));
|
|
732
1305
|
}
|
|
733
|
-
settings["files.exclude"] = { ...settings["files.exclude"] ?? {}, "**/.mdrules": true };
|
|
734
|
-
fs7.mkdirSync(path7.dirname(file), { recursive: true });
|
|
735
|
-
fs7.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
736
|
-
return true;
|
|
737
1306
|
}
|
|
738
1307
|
|
|
739
1308
|
// src/cli/init.ts
|
|
@@ -744,13 +1313,19 @@ function check(value) {
|
|
|
744
1313
|
}
|
|
745
1314
|
return value;
|
|
746
1315
|
}
|
|
747
|
-
async function chooseMainDoc(root2) {
|
|
748
|
-
const existing = MAIN_DOCS.filter((
|
|
1316
|
+
async function chooseMainDoc(root2, d) {
|
|
1317
|
+
const existing = MAIN_DOCS.filter((d2) => fs13.existsSync(path13.join(root2, d2)));
|
|
1318
|
+
if (d) {
|
|
1319
|
+
if (existing.length) return existing.includes(d.mainDoc) ? d.mainDoc : existing[0];
|
|
1320
|
+
createMainDoc(root2, d.mainDoc);
|
|
1321
|
+
p.log.success(`Created ${d.mainDoc}`);
|
|
1322
|
+
return d.mainDoc;
|
|
1323
|
+
}
|
|
749
1324
|
if (existing.length === 2) {
|
|
750
1325
|
return check(
|
|
751
1326
|
await p.select({
|
|
752
1327
|
message: "Both CLAUDE.md and AGENTS.md exist. Which one is the main document?",
|
|
753
|
-
options: existing.map((
|
|
1328
|
+
options: existing.map((d2) => ({ value: d2, label: d2 }))
|
|
754
1329
|
})
|
|
755
1330
|
);
|
|
756
1331
|
}
|
|
@@ -760,7 +1335,7 @@ async function chooseMainDoc(root2) {
|
|
|
760
1335
|
message: `Found ${pc2.cyan(existing[0])}. Use it as the main document?`,
|
|
761
1336
|
options: [
|
|
762
1337
|
{ value: existing[0], label: `Yes, use ${existing[0]}` },
|
|
763
|
-
...MAIN_DOCS.filter((
|
|
1338
|
+
...MAIN_DOCS.filter((d2) => d2 !== existing[0]).map((d2) => ({ value: d2, label: `No, create ${d2}` }))
|
|
764
1339
|
]
|
|
765
1340
|
})
|
|
766
1341
|
);
|
|
@@ -770,52 +1345,132 @@ async function chooseMainDoc(root2) {
|
|
|
770
1345
|
const choice = check(
|
|
771
1346
|
await p.select({
|
|
772
1347
|
message: "No CLAUDE.md or AGENTS.md found. Which one should I create?",
|
|
773
|
-
options: MAIN_DOCS.map((
|
|
1348
|
+
options: MAIN_DOCS.map((d2) => ({ value: d2, label: d2, hint: d2 === "CLAUDE.md" ? "Claude Code" : "other agents" }))
|
|
774
1349
|
})
|
|
775
1350
|
);
|
|
776
1351
|
createMainDoc(root2, choice);
|
|
777
1352
|
p.log.success(`Created ${choice}`);
|
|
778
1353
|
return choice;
|
|
779
1354
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1355
|
+
var WORKFLOW_OPTIONS = [
|
|
1356
|
+
{ value: "none", label: "None" },
|
|
1357
|
+
{ value: "mdrules", label: "mdrules method", hint: "vision, architecture, phases and one file per task; per area in monorepos" },
|
|
1358
|
+
{ value: "openspec", label: "OpenSpec", hint: "specs + proposed changes, good for existing projects" },
|
|
1359
|
+
{ value: "speckit", label: "GitHub Spec Kit", hint: "constitution \u2192 spec \u2192 plan \u2192 tasks, per feature" }
|
|
1360
|
+
];
|
|
1361
|
+
var PROPOSALS_QUESTION = "The AI can write proposals you answer by ticking options and commenting. How should they be organized?";
|
|
1362
|
+
var PROPOSAL_OPTIONS = [
|
|
1363
|
+
{ value: "structured", label: "By topic and area", hint: "docs/proposals/auth/\u2026, services/docs/proposals/\u2026" },
|
|
1364
|
+
{ value: "general", label: "One folder", hint: "docs/proposals/" }
|
|
1365
|
+
];
|
|
1366
|
+
var SOURCE_LANG_QUESTION = "Language you work in (every .md file is written in it)";
|
|
1367
|
+
var SOURCE_LANG_MARK = /<!-- mdrules:source-lang:\w+ -->/;
|
|
1368
|
+
var LANG_OPTIONS = LANGS.map((l) => ({ value: l, label: LANG_NAMES[l], hint: l === "en" ? "default" : void 0 }));
|
|
1369
|
+
var HIDE_QUESTION = "Hide the .mdrules/ folder in VS Code?";
|
|
1370
|
+
var ruleOptions = (rules) => rules.map((r) => ({ value: r.id, label: r.title, hint: r.body.split("\n")[0].replace(/^- /, "") }));
|
|
1371
|
+
var agentOptions = () => Object.keys(SDD_AGENTS).map((a) => ({ value: a, label: SDD_AGENTS[a].label }));
|
|
1372
|
+
var WORKFLOW_LABEL = { none: "None", mdrules: "mdrules", openspec: "OpenSpec", speckit: "GitHub Spec Kit" };
|
|
1373
|
+
var WORKFLOW_GUIDE = { none: "", mdrules: "docs/README.md", openspec: "openspec/README.md", speckit: "specs/README.md" };
|
|
1374
|
+
async function chooseAreas(root2, d) {
|
|
1375
|
+
const candidates = detectAreas(root2);
|
|
1376
|
+
if (!candidates.length) return [];
|
|
1377
|
+
if (d) {
|
|
1378
|
+
const used2 = /* @__PURE__ */ new Set();
|
|
1379
|
+
return candidates.map((dir) => ({ dir, prefix: suggestPrefix(dir, used2) }));
|
|
1380
|
+
}
|
|
1381
|
+
const dirs2 = check(
|
|
798
1382
|
await p.multiselect({
|
|
799
|
-
message: "
|
|
800
|
-
options:
|
|
801
|
-
initialValues:
|
|
1383
|
+
message: "Monorepo detected. Which folders are areas with their own docs and tasks? (none: one docs/ for everything)",
|
|
1384
|
+
options: candidates.map((d2) => ({ value: d2, label: `${d2}/` })),
|
|
1385
|
+
initialValues: candidates,
|
|
802
1386
|
required: false
|
|
803
1387
|
})
|
|
804
1388
|
);
|
|
1389
|
+
const used = /* @__PURE__ */ new Set();
|
|
1390
|
+
const areas = dirs2.map((dir) => ({ dir, prefix: suggestPrefix(dir, used) }));
|
|
1391
|
+
if (!areas.length) return areas;
|
|
1392
|
+
const ok = check(await p.confirm({ message: `Task prefixes: ${areas.map((a) => `${a.dir} \u2192 ${a.prefix}`).join(", ")}, cross-area \u2192 CORE. Use them?`, initialValue: true }));
|
|
1393
|
+
if (ok) return areas;
|
|
1394
|
+
for (const a of areas) {
|
|
1395
|
+
a.prefix = check(
|
|
1396
|
+
await p.text({
|
|
1397
|
+
message: `Task prefix for ${a.dir}/`,
|
|
1398
|
+
initialValue: a.prefix,
|
|
1399
|
+
validate: (v) => /^[A-Z][A-Z0-9]{0,5}$/.test(v ?? "") && v !== "CORE" && !areas.some((o) => o !== a && o.prefix === v) ? void 0 : "Uppercase letters and digits, unique, not CORE"
|
|
1400
|
+
})
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
return areas;
|
|
1404
|
+
}
|
|
1405
|
+
async function chooseWorkflow(root2, mainDoc, previous, d) {
|
|
1406
|
+
const detected = detectWorkflow(root2);
|
|
1407
|
+
if (detected) {
|
|
1408
|
+
p.log.info(`${WORKFLOW_LABEL[detected]} found: mdrules shows it in the dashboard.`);
|
|
1409
|
+
return { workflow: detected };
|
|
1410
|
+
}
|
|
1411
|
+
for (; ; ) {
|
|
1412
|
+
const choice = d ? d.workflow : check(await p.select({ message: "Create a base structure for specs and planning?", options: WORKFLOW_OPTIONS, initialValue: previous ?? "none" }));
|
|
1413
|
+
if (choice === "none") return { workflow: choice };
|
|
1414
|
+
if (choice === "mdrules") {
|
|
1415
|
+
const areas = await chooseAreas(root2, d);
|
|
1416
|
+
const created = scaffoldMethod(root2, mainDoc, areas);
|
|
1417
|
+
installMethodSkills(root2);
|
|
1418
|
+
const big = areas.length > 0 || flattenFiles(scanTree(root2)).length > 40;
|
|
1419
|
+
const layout = !big ? "general" : d ? d.proposals : check(await p.select({ message: PROPOSALS_QUESTION, options: PROPOSAL_OPTIONS }));
|
|
1420
|
+
activateProposals(root2, layout, mainDoc);
|
|
1421
|
+
if (created.length) p.log.success(`Created ${created.length} files: ${created.slice(0, 8).join(", ")}${created.length > 8 ? "\u2026" : ""}`);
|
|
1422
|
+
p.log.info(`How it works: ${pc2.cyan("docs/README.md")}. Skills: /mdrules-new-task, /mdrules-close-session.`);
|
|
1423
|
+
return { workflow: choice, section: methodSection(areas) };
|
|
1424
|
+
}
|
|
1425
|
+
let official = true;
|
|
1426
|
+
if (choice === "speckit" && !hasUv() && d) {
|
|
1427
|
+
p.log.warn("Spec Kit needs uv (Python) to install its AI commands, and uv was not found: creating the structure only.");
|
|
1428
|
+
official = false;
|
|
1429
|
+
} else if (choice === "speckit" && !hasUv()) {
|
|
1430
|
+
const next = check(
|
|
1431
|
+
await p.select({
|
|
1432
|
+
message: "Spec Kit needs uv (Python) to install its AI commands, and uv was not found.",
|
|
1433
|
+
options: [
|
|
1434
|
+
{ value: "back", label: "Choose another option" },
|
|
1435
|
+
{ value: "skeleton", label: "Create the structure only", hint: "the guide explains how to install Spec Kit later" }
|
|
1436
|
+
]
|
|
1437
|
+
})
|
|
1438
|
+
);
|
|
1439
|
+
if (next === "back") continue;
|
|
1440
|
+
official = false;
|
|
1441
|
+
}
|
|
1442
|
+
const initial = mainDoc === "AGENTS.md" ? "codex" : "claude";
|
|
1443
|
+
const agents = !official ? [initial] : d ? choice === "speckit" ? [d.agents[0] ?? initial] : d.agents : choice === "openspec" ? check(await p.multiselect({ message: "Install the OpenSpec commands for which agents?", options: agentOptions(), initialValues: [initial], required: false })) : [check(await p.select({ message: "Install the Spec Kit commands for which agent?", options: agentOptions(), initialValue: initial }))];
|
|
1444
|
+
const spin = p.spinner();
|
|
1445
|
+
spin.start(official ? `Installing ${WORKFLOW_LABEL[choice]}\u2026` : "Creating the structure\u2026");
|
|
1446
|
+
const res = installWorkflow(root2, choice, agents, official);
|
|
1447
|
+
spin.stop(res.official ? `${WORKFLOW_LABEL[choice]} installed` : "Structure created");
|
|
1448
|
+
if (official && !res.official) p.log.warn(`Couldn't run the ${WORKFLOW_LABEL[choice]} installer:
|
|
1449
|
+
${pc2.dim(res.error ?? "")}
|
|
1450
|
+
Created the structure only.`);
|
|
1451
|
+
if (!res.official) p.log.info(`To add the AI commands later, see ${pc2.cyan(WORKFLOW_GUIDE[choice])}.`);
|
|
1452
|
+
if (res.ignored.length) p.log.info(`Agent commands hidden in mdrules (${res.ignored.length} folders); the eye button in the sidebar shows them.`);
|
|
1453
|
+
return { workflow: choice };
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
async function runInit(root2, d = null) {
|
|
1457
|
+
p.intro(pc2.bgCyan(pc2.black(" mdrules ")));
|
|
1458
|
+
if (d) p.log.info(`Using your defaults (${pc2.cyan(defaultsFile())}). To choose differently for this project: ${pc2.cyan("mdrules init --custom")}`);
|
|
1459
|
+
const previous = readConfig(root2);
|
|
1460
|
+
const mainDoc = await chooseMainDoc(root2, d);
|
|
1461
|
+
const mainFile = path13.join(root2, mainDoc);
|
|
1462
|
+
const { workflow, section: section2 } = await chooseWorkflow(root2, mainDoc, previous?.workflow, d);
|
|
1463
|
+
applyWorkflowToFile(mainFile, workflow, section2);
|
|
1464
|
+
const mainText = fs13.readFileSync(mainFile, "utf8");
|
|
1465
|
+
const sourceLang = d ? SOURCE_LANG_MARK.test(mainText) ? sourceLangOf(mainText) : d.sourceLang : check(await p.select({ message: SOURCE_LANG_QUESTION, options: LANG_OPTIONS, initialValue: sourceLangOf(mainText) }));
|
|
1466
|
+
const langs = (previous?.langs ?? []).filter((l) => l !== sourceLang);
|
|
805
1467
|
const rules = listRules();
|
|
806
1468
|
const applied = appliedRuleIds(mainText);
|
|
807
|
-
const selected = check(
|
|
808
|
-
await p.multiselect({
|
|
809
|
-
message: "Which working rules apply to this project?",
|
|
810
|
-
options: rules.map((r) => ({ value: r.id, label: r.title, hint: r.body.split("\n")[0].replace(/^- /, "") })),
|
|
811
|
-
initialValues: applied,
|
|
812
|
-
required: false
|
|
813
|
-
})
|
|
814
|
-
);
|
|
1469
|
+
const selected = d ? [.../* @__PURE__ */ new Set([...applied, ...d.rules.filter((id) => rules.some((r) => r.id === id))])] : check(await p.multiselect({ message: "Which working rules apply to this project?", options: ruleOptions(rules), initialValues: applied, required: false }));
|
|
815
1470
|
applyRulesToFile(mainFile, selected, sourceLang);
|
|
816
1471
|
const subprojects = findSubprojects(scanTree(root2));
|
|
817
1472
|
if (subprojects.length && selected.length) {
|
|
818
|
-
const targets = check(
|
|
1473
|
+
const targets = d ? d.rulesInSubprojects ? subprojects : [] : check(
|
|
819
1474
|
await p.multiselect({
|
|
820
1475
|
message: "Monorepo detected. Apply the same rules to these projects too?",
|
|
821
1476
|
options: subprojects.map((s) => ({ value: s, label: s })),
|
|
@@ -823,34 +1478,150 @@ async function runInit(root2) {
|
|
|
823
1478
|
})
|
|
824
1479
|
);
|
|
825
1480
|
for (const dir of targets) {
|
|
826
|
-
const doc = MAIN_DOCS.find((
|
|
827
|
-
applyRulesToFile(
|
|
1481
|
+
const doc = MAIN_DOCS.find((d2) => fs13.existsSync(path13.join(root2, dir, d2)));
|
|
1482
|
+
applyRulesToFile(path13.join(root2, dir, doc), selected, sourceLang);
|
|
828
1483
|
}
|
|
829
1484
|
}
|
|
830
1485
|
ensureGitignore(root2);
|
|
831
1486
|
if (!previous) {
|
|
832
|
-
const hide = check(await p.confirm({ message:
|
|
1487
|
+
const hide = d ? d.hideInVsCode : check(await p.confirm({ message: HIDE_QUESTION, initialValue: true }));
|
|
833
1488
|
if (hide && !hideInVsCode(root2)) p.log.warn('.vscode/settings.json has comments; add "**/.mdrules": true to files.exclude by hand.');
|
|
834
1489
|
}
|
|
835
1490
|
installSkill(root2);
|
|
836
|
-
const config = { mainDoc, langs, initializedAt: previous?.initializedAt ?? (/* @__PURE__ */ new Date()).toISOString() };
|
|
1491
|
+
const config = { mainDoc, langs, langNames: previous?.langNames, workflow, proposals: readConfig(root2)?.proposals, initializedAt: previous?.initializedAt ?? (/* @__PURE__ */ new Date()).toISOString() };
|
|
837
1492
|
writeConfig(root2, config);
|
|
838
|
-
p.outro(`Ready. Skill installed in ${pc2.cyan(".claude/skills/mdrules")}`);
|
|
1493
|
+
p.outro(`Ready. Skill installed in ${pc2.cyan(".claude/skills/mdrules")}. To review the docs in another language: ${pc2.cyan("mdrules translate")}`);
|
|
839
1494
|
return config;
|
|
840
1495
|
}
|
|
1496
|
+
async function runSetup(quick = false) {
|
|
1497
|
+
const previous = readDefaults() ?? BASE_DEFAULTS;
|
|
1498
|
+
if (quick) {
|
|
1499
|
+
const d2 = { ...previous, mode: "ask" };
|
|
1500
|
+
writeDefaults(d2);
|
|
1501
|
+
return d2;
|
|
1502
|
+
}
|
|
1503
|
+
p.intro(pc2.bgCyan(pc2.black(" mdrules setup ")));
|
|
1504
|
+
const mode = check(
|
|
1505
|
+
await p.select({
|
|
1506
|
+
message: "How should mdrules set up your projects?",
|
|
1507
|
+
options: [
|
|
1508
|
+
{ value: "defaults", label: "With my answers", hint: "asked now, once; every project uses them" },
|
|
1509
|
+
{ value: "ask", label: "Ask in each project" }
|
|
1510
|
+
],
|
|
1511
|
+
initialValue: previous.mode === "ask" && !readDefaults() ? "defaults" : previous.mode
|
|
1512
|
+
})
|
|
1513
|
+
);
|
|
1514
|
+
if (mode === "ask") {
|
|
1515
|
+
const d2 = { ...previous, mode };
|
|
1516
|
+
writeDefaults(d2);
|
|
1517
|
+
p.outro(`Saved. Each project will ask its own questions. Change it with ${pc2.cyan("mdrules setup")}`);
|
|
1518
|
+
return d2;
|
|
1519
|
+
}
|
|
1520
|
+
const mainDoc = check(
|
|
1521
|
+
await p.select({
|
|
1522
|
+
message: "Main document to create when a project has none",
|
|
1523
|
+
options: MAIN_DOCS.map((d2) => ({ value: d2, label: d2, hint: d2 === "CLAUDE.md" ? "Claude Code" : "other agents" })),
|
|
1524
|
+
initialValue: previous.mainDoc
|
|
1525
|
+
})
|
|
1526
|
+
);
|
|
1527
|
+
const workflow = check(await p.select({ message: "Base structure for specs and planning", options: WORKFLOW_OPTIONS, initialValue: previous.workflow }));
|
|
1528
|
+
let agents = previous.agents;
|
|
1529
|
+
let proposals = previous.proposals;
|
|
1530
|
+
if (workflow === "openspec")
|
|
1531
|
+
agents = check(await p.multiselect({ message: "Install the OpenSpec commands for which agents?", options: agentOptions(), initialValues: previous.agents, required: false }));
|
|
1532
|
+
if (workflow === "speckit") agents = [check(await p.select({ message: "Install the Spec Kit commands for which agent?", options: agentOptions(), initialValue: previous.agents[0] }))];
|
|
1533
|
+
if (workflow === "mdrules") proposals = check(await p.select({ message: `${PROPOSALS_QUESTION} (big projects and monorepos)`, options: PROPOSAL_OPTIONS, initialValue: previous.proposals }));
|
|
1534
|
+
const sourceLang = check(await p.select({ message: `${SOURCE_LANG_QUESTION}; projects that already declare one keep it`, options: LANG_OPTIONS, initialValue: previous.sourceLang }));
|
|
1535
|
+
const rules = listRules();
|
|
1536
|
+
const selected = check(await p.multiselect({ message: "Working rules to apply", options: ruleOptions(rules), initialValues: previous.rules.filter((id) => rules.some((r) => r.id === id)), required: false }));
|
|
1537
|
+
const rulesInSubprojects = selected.length ? check(await p.confirm({ message: "In monorepos, apply the rules to the subprojects too?", initialValue: previous.rulesInSubprojects })) : false;
|
|
1538
|
+
const hideInVsCode2 = check(await p.confirm({ message: HIDE_QUESTION, initialValue: previous.hideInVsCode }));
|
|
1539
|
+
const d = { mode, mainDoc, workflow, agents, proposals, sourceLang, rules: selected, rulesInSubprojects, hideInVsCode: hideInVsCode2 };
|
|
1540
|
+
writeDefaults(d);
|
|
1541
|
+
p.outro(`Saved in ${pc2.cyan(defaultsFile())}. Change them with ${pc2.cyan("mdrules setup")}; for a single project: ${pc2.cyan("mdrules init --custom")}`);
|
|
1542
|
+
return d;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// src/cli/translate.ts
|
|
1546
|
+
import * as p2 from "@clack/prompts";
|
|
1547
|
+
import pc3 from "picocolors";
|
|
1548
|
+
function check2(value) {
|
|
1549
|
+
if (p2.isCancel(value)) {
|
|
1550
|
+
p2.cancel("Cancelled.");
|
|
1551
|
+
process.exit(0);
|
|
1552
|
+
}
|
|
1553
|
+
return value;
|
|
1554
|
+
}
|
|
1555
|
+
var OTHER = "__other__";
|
|
1556
|
+
async function runTranslate(root2) {
|
|
1557
|
+
const config = readConfig(root2);
|
|
1558
|
+
if (!config) {
|
|
1559
|
+
console.log(`Run ${pc3.cyan("mdrules init")} first.`);
|
|
1560
|
+
process.exitCode = 1;
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
p2.intro(pc3.bgCyan(pc3.black(" mdrules translate ")));
|
|
1564
|
+
const source = projectSourceLang(root2, config.mainDoc);
|
|
1565
|
+
const custom = config.langs.filter((l) => !isLang(l));
|
|
1566
|
+
const picked = check2(
|
|
1567
|
+
await p2.multiselect({
|
|
1568
|
+
message: `Languages to review the docs in (the files stay in ${langName(source)})`,
|
|
1569
|
+
options: [
|
|
1570
|
+
...LANGS.filter((l) => l !== source).map((l) => ({ value: l, label: LANG_NAMES[l] })),
|
|
1571
|
+
...custom.map((l) => ({ value: l, label: `${langName(l, config)} (${l})`, hint: "interface in English" })),
|
|
1572
|
+
{ value: OTHER, label: "Another language\u2026", hint: "type it; the interface stays in English" }
|
|
1573
|
+
],
|
|
1574
|
+
initialValues: config.langs.filter((l) => l !== source),
|
|
1575
|
+
required: false
|
|
1576
|
+
})
|
|
1577
|
+
);
|
|
1578
|
+
const langs = picked.filter((l) => l !== OTHER);
|
|
1579
|
+
const names = { ...config.langNames ?? {} };
|
|
1580
|
+
if (picked.includes(OTHER)) {
|
|
1581
|
+
for (; ; ) {
|
|
1582
|
+
const name = check2(await p2.text({ message: "Language name (as the AI should understand it)", placeholder: "Japanese", validate: (v) => v?.trim() ? void 0 : "Type a language" })).trim();
|
|
1583
|
+
const code = check2(
|
|
1584
|
+
await p2.text({
|
|
1585
|
+
message: `Short code for ${name} (folder name of its translations)`,
|
|
1586
|
+
initialValue: name.slice(0, 2).toLowerCase(),
|
|
1587
|
+
validate: (v) => !isLangCode(v) ? "Two or three letters, e.g. ja or zh-tw" : v === source || langs.includes(v) ? "Already in the list" : void 0
|
|
1588
|
+
})
|
|
1589
|
+
).toLowerCase();
|
|
1590
|
+
langs.push(code);
|
|
1591
|
+
names[code] = name;
|
|
1592
|
+
if (!check2(await p2.confirm({ message: "Add another language?", initialValue: false }))) break;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
writeConfig(root2, { ...config, langs, langNames: names });
|
|
1596
|
+
if (!langs.length) {
|
|
1597
|
+
p2.outro("No translation languages: mdrules shows the docs in their source language only.");
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
p2.outro(
|
|
1601
|
+
[
|
|
1602
|
+
`Translations: ${langs.map((l) => langName(l, { ...config, langNames: names })).join(", ")}.`,
|
|
1603
|
+
`Ask your agent for ${pc3.cyan("/mdrules sync")} to translate the docs; then pick the language at the top of mdrules.`,
|
|
1604
|
+
...langs.some((l) => !isLang(l)) ? ["Menus stay in English for languages typed by hand."] : []
|
|
1605
|
+
].join("\n")
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
841
1608
|
|
|
842
1609
|
// src/cli/index.ts
|
|
843
1610
|
var HELP = `mdrules \u2014 visual editor for your project's Markdown
|
|
844
1611
|
|
|
845
1612
|
Usage
|
|
846
1613
|
mdrules init if needed, then open the editor
|
|
847
|
-
mdrules init (re)run the setup: main doc,
|
|
1614
|
+
mdrules init (re)run the project setup: main doc, specs structure, rules, languages
|
|
1615
|
+
mdrules setup choose how projects are set up: with your saved answers, or asking each time
|
|
848
1616
|
mdrules serve open the editor without running init
|
|
1617
|
+
mdrules translate choose languages to review the docs in (or: mdrules --translate)
|
|
849
1618
|
mdrules sync --status [--json] translations + files not in the source language
|
|
850
1619
|
mdrules sync --mark [--lang xx] <path...>|--all record translations as in sync
|
|
851
1620
|
mdrules sync --keep <path...> keep files in their current language
|
|
852
1621
|
|
|
853
1622
|
Options
|
|
1623
|
+
-D first run: skip saving defaults; every project asks its own questions
|
|
1624
|
+
--custom init: ask every question for this project, ignoring your defaults
|
|
854
1625
|
--port <n> server port (default 4319)
|
|
855
1626
|
--no-open do not open the browser
|
|
856
1627
|
`;
|
|
@@ -865,12 +1636,18 @@ var root = process.env.MDRULES_ROOT ?? process.cwd();
|
|
|
865
1636
|
async function main() {
|
|
866
1637
|
if (flag("--help") || flag("-h")) return console.log(HELP);
|
|
867
1638
|
if (command === "sync") return sync();
|
|
1639
|
+
if (command === "translate" || flag("--translate")) return runTranslate(root);
|
|
1640
|
+
if (command === "setup") {
|
|
1641
|
+
await runSetup(flag("-D"));
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
868
1644
|
if (command === "init") {
|
|
869
|
-
await runInit(root);
|
|
1645
|
+
await runInit(root, flag("--custom") ? null : await defaults());
|
|
870
1646
|
return;
|
|
871
1647
|
}
|
|
872
1648
|
if (command === "start" && !readConfig(root)) {
|
|
873
|
-
if (process.stdin.isTTY) await runInit(root);
|
|
1649
|
+
if (process.stdin.isTTY) await runInit(root, flag("--custom") ? null : await defaults());
|
|
1650
|
+
else if (readDefaults()?.mode === "defaults") await runInit(root, readDefaults());
|
|
874
1651
|
}
|
|
875
1652
|
if (command === "start" || command === "serve") {
|
|
876
1653
|
await startServer({ root, port: Number(option("--port") ?? 4319), open: !flag("--no-open") });
|
|
@@ -879,30 +1656,34 @@ async function main() {
|
|
|
879
1656
|
console.log(HELP);
|
|
880
1657
|
process.exitCode = 1;
|
|
881
1658
|
}
|
|
1659
|
+
async function defaults() {
|
|
1660
|
+
const d = readDefaults() ?? (process.stdin.isTTY ? await runSetup(flag("-D")) : null);
|
|
1661
|
+
return d?.mode === "defaults" ? d : null;
|
|
1662
|
+
}
|
|
882
1663
|
function sync() {
|
|
883
1664
|
const report = syncReport(root);
|
|
884
1665
|
const paths = (after) => args.slice(args.indexOf(after) + 1).filter((a, i, all) => !a.startsWith("--") && all[i - 1] !== "--lang");
|
|
885
1666
|
if (flag("--keep")) {
|
|
886
|
-
for (const
|
|
1667
|
+
for (const p3 of paths("--keep")) {
|
|
887
1668
|
try {
|
|
888
|
-
console.log(`kept ${
|
|
1669
|
+
console.log(`kept ${p3} (${keepLanguage(root, p3)})`);
|
|
889
1670
|
} catch (err) {
|
|
890
|
-
console.error(`skipped ${
|
|
1671
|
+
console.error(`skipped ${p3}: ${err.message}`);
|
|
891
1672
|
}
|
|
892
1673
|
}
|
|
893
1674
|
return;
|
|
894
1675
|
}
|
|
895
1676
|
if (flag("--mark")) {
|
|
896
1677
|
const only = option("--lang");
|
|
897
|
-
const langs = only &&
|
|
1678
|
+
const langs = only && report.langs.includes(only) ? [only] : report.langs;
|
|
898
1679
|
const files = flag("--all") ? [...new Set(report.files.map((f) => f.path))] : paths("--mark");
|
|
899
|
-
for (const
|
|
1680
|
+
for (const p3 of files) {
|
|
900
1681
|
for (const l of langs) {
|
|
901
1682
|
try {
|
|
902
|
-
markSynced(root, l,
|
|
903
|
-
console.log(`synced ${l} ${
|
|
1683
|
+
markSynced(root, l, p3);
|
|
1684
|
+
console.log(`synced ${l} ${p3}`);
|
|
904
1685
|
} catch (err) {
|
|
905
|
-
if (!flag("--all")) console.error(`skipped ${l} ${
|
|
1686
|
+
if (!flag("--all")) console.error(`skipped ${l} ${p3}: ${err.message}`);
|
|
906
1687
|
}
|
|
907
1688
|
}
|
|
908
1689
|
}
|