mdrules 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +196 -55
- package/dist/cli.js +871 -165
- package/dist/web/assets/{abnfDiagram-WEYOIA3C-DKgk2hIQ.js → abnfDiagram-WEYOIA3C-COoXc57p.js} +1 -1
- package/dist/web/assets/{arc-CCAU-51h.js → arc-CQ-mpttu.js} +1 -1
- package/dist/web/assets/{architecture-WOLXFQ4H-BcNYkHmW.js → architecture-WOLXFQ4H-Cl3-F9oV.js} +1 -1
- package/dist/web/assets/{architectureDiagram-ZHVYKT53-DgRlfGj2.js → architectureDiagram-ZHVYKT53-C1LOUHfq.js} +1 -1
- package/dist/web/assets/{blockDiagram-RJAE7QA3-DoBplOhb.js → blockDiagram-RJAE7QA3-YeQnD2ds.js} +1 -1
- package/dist/web/assets/{c4Diagram-XDPPPE54-CGKzgX6O.js → c4Diagram-XDPPPE54-XLmSlndV.js} +1 -1
- package/dist/web/assets/channel-Bu489uW7.js +1 -0
- package/dist/web/assets/{chunk-2E7WHVMD-DhQx5a0n.js → chunk-2E7WHVMD-HGXqy_LQ.js} +1 -1
- package/dist/web/assets/{chunk-4EMNKR2T-vHF11V5m.js → chunk-4EMNKR2T-DsQggzQE.js} +1 -1
- package/dist/web/assets/{chunk-55XCU76K-DgZ0btzr.js → chunk-55XCU76K-DY1xg8cl.js} +1 -1
- package/dist/web/assets/{chunk-7S6XSTFO-DoKaEIP0.js → chunk-7S6XSTFO-BuyzABLx.js} +1 -1
- package/dist/web/assets/{chunk-AQDTSKZB-DktDdZ_o.js → chunk-AQDTSKZB-CsDWfENX.js} +1 -1
- package/dist/web/assets/{chunk-DSNLSHJZ-C_3sVqJn.js → chunk-DSNLSHJZ-CuiVxF9g.js} +1 -1
- package/dist/web/assets/{chunk-HAGFOTED-DhCQIE99.js → chunk-HAGFOTED-CjjQypU8.js} +1 -1
- package/dist/web/assets/{chunk-HNE4Q36P-D_sbl6UM.js → chunk-HNE4Q36P-B0-YOuZI.js} +1 -1
- package/dist/web/assets/{chunk-NQOKRVIF-UuKYBp6F.js → chunk-NQOKRVIF-DMWnWh6r.js} +1 -1
- package/dist/web/assets/{chunk-UHW7PFBP-BkreF8rs.js → chunk-UHW7PFBP-D3E4ik9Y.js} +1 -1
- package/dist/web/assets/{chunk-VQ5W62QI-Cb7PeOKH.js → chunk-VQ5W62QI-CiPAEjx6.js} +1 -1
- package/dist/web/assets/{chunk-WQQL4CDK-GsIKInJ-.js → chunk-WQQL4CDK-StBWijFa.js} +1 -1
- package/dist/web/assets/{chunk-XXDRQBXY-Bbz14gys.js → chunk-XXDRQBXY-CINBf_Ay.js} +1 -1
- package/dist/web/assets/{chunk-YZO67HFG--IdJYNXK.js → chunk-YZO67HFG-BSkchBSd.js} +1 -1
- package/dist/web/assets/{chunk-ZAUX2LTX-DmenwPIM.js → chunk-ZAUX2LTX-CYfcgJpt.js} +1 -1
- package/dist/web/assets/{chunk-ZCHMQSOR-BZ2XP8BW.js → chunk-ZCHMQSOR-CwWl64AI.js} +1 -1
- package/dist/web/assets/{classDiagram-v2-4MYWR3UA-B_FJCXgY.js → classDiagram-v2-4MYWR3UA-Ce6JKsB3.js} +1 -1
- package/dist/web/assets/{cose-bilkent-JH36ORCC-DMBdq3mq.js → cose-bilkent-JH36ORCC-COClxHi_.js} +1 -1
- package/dist/web/assets/{cynefin-EF2NZ3EQ-BthcZtsD.js → cynefin-EF2NZ3EQ-BGZI2c8V.js} +1 -1
- package/dist/web/assets/{cynefinDiagram-WF24UDRP-Bva6JySb.js → cynefinDiagram-WF24UDRP-B24KUNeS.js} +1 -1
- package/dist/web/assets/{dagre-NK4GIZVX-BE6DKQYs.js → dagre-NK4GIZVX-DjCzI5LG.js} +1 -1
- package/dist/web/assets/{diagram-CBK3MWTC-DuDzgplS.js → diagram-CBK3MWTC-pa5xxV7I.js} +1 -1
- package/dist/web/assets/{diagram-I5XIIV2D-DU3jaZXW.js → diagram-I5XIIV2D-BQtih66c.js} +1 -1
- package/dist/web/assets/{diagram-ODIL2UCW-DYI3Rj6j.js → diagram-ODIL2UCW-CtodvdqO.js} +1 -1
- package/dist/web/assets/{diagram-PFBEJUXE-FD1CYRbT.js → diagram-PFBEJUXE-B-CnrB9d.js} +1 -1
- package/dist/web/assets/{diagram-RJA3I4VV-BsbAupu0.js → diagram-RJA3I4VV-Di8-srfd.js} +1 -1
- package/dist/web/assets/{diagram-TYUNFSNR-BMB0kMWv.js → diagram-TYUNFSNR-DT-cfj0M.js} +1 -1
- package/dist/web/assets/{dist-B5RhPiDb.js → dist-5RSr3i1a.js} +1 -1
- package/dist/web/assets/{dist-CugnsBBM.js → dist-B4Z2sV8l.js} +1 -1
- package/dist/web/assets/{dist-EDnzgrlE.js → dist-B7yJAZLB.js} +1 -1
- package/dist/web/assets/{dist-ABZ0bfMR.js → dist-BxXtIjV1.js} +1 -1
- package/dist/web/assets/{dist-1HFNA2Sd.js → dist-CA-ReuvA.js} +1 -1
- package/dist/web/assets/{dist-CIDoXcx0.js → dist-CLMm60_p.js} +1 -1
- package/dist/web/assets/{dist-D6VkQ_7h.js → dist-CPFvkcDl.js} +1 -1
- package/dist/web/assets/{dist-BoeMvgh-.js → dist-CZxj92ue.js} +1 -1
- package/dist/web/assets/{dist-CFaMxIWT.js → dist-DVG9d1NU.js} +1 -1
- package/dist/web/assets/{dist-BFFH-h4L.js → dist-DevJcR0Y.js} +1 -1
- package/dist/web/assets/{dist-Be0WQzsz.js → dist-JJNpWgR6.js} +1 -1
- package/dist/web/assets/{ebnfDiagram-V4P3TTPT-C5zEPRhM.js → ebnfDiagram-V4P3TTPT-5btoYlHi.js} +1 -1
- package/dist/web/assets/{elk-QONTBCTQ-BR5YBYTe.js → elk-QONTBCTQ-BiXiP5mn.js} +1 -1
- package/dist/web/assets/{erDiagram-KYQIGQAA-B-5pcNVz.js → erDiagram-KYQIGQAA-Csvahmwh.js} +1 -1
- package/dist/web/assets/{eventmodeling-K75KTNOO-DkMnS-Cz.js → eventmodeling-K75KTNOO-CTDk7azy.js} +1 -1
- package/dist/web/assets/flowDiagram-IUGLLWBU-CTlRlF-F.js +1 -0
- package/dist/web/assets/{ganttDiagram-R7GPNTJY-B21dBS-O.js → ganttDiagram-R7GPNTJY-Kr_UYAyo.js} +1 -1
- package/dist/web/assets/{gitGraph-VSP46ZUC-DalD3PH5.js → gitGraph-VSP46ZUC-DWAozJzq.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PDYL7XHY-CWi1FEeW.js → gitGraphDiagram-PDYL7XHY-DyTW4WQG.js} +1 -1
- package/dist/web/assets/index-lmzHXuPr.css +1 -0
- package/dist/web/assets/index-qPUGRLvu.js +63 -0
- package/dist/web/assets/{info-OHQRW6UA-CByswqH6.js → info-OHQRW6UA-DiJj6mF5.js} +1 -1
- package/dist/web/assets/{infoDiagram-MVDRKAX5-DizZ8XVo.js → infoDiagram-MVDRKAX5-D02QrLp5.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-HPEHV2GE-DQoKMoDQ.js → ishikawaDiagram-HPEHV2GE-CXW4mRts.js} +1 -1
- package/dist/web/assets/{journeyDiagram-BOSHW7O7-DKj7UdLQ.js → journeyDiagram-BOSHW7O7-DwyTpaHZ.js} +1 -1
- package/dist/web/assets/{kanban-definition-FNSA4HC5-bTblh8-S.js → kanban-definition-FNSA4HC5-B8br7aDC.js} +1 -1
- package/dist/web/assets/{line-DWkG8aZQ.js → line-C7pCcPIA.js} +1 -1
- package/dist/web/assets/{linear-aVPxw88X.js → linear-Tw3CU3Fk.js} +1 -1
- package/dist/web/assets/{mermaid-parser.core-C-J7_HBm.js → mermaid-parser.core-DuxqJtRQ.js} +3 -3
- package/dist/web/assets/{mermaid.core-DpHqjC1s.js → mermaid.core-DsOnw7nE.js} +4 -4
- package/dist/web/assets/{mindmap-definition-6OY57UG3-DnifqP1p.js → mindmap-definition-6OY57UG3-Cx4RGd4t.js} +1 -1
- package/dist/web/assets/{packet-JDAUHWVQ-_ADKLP5T.js → packet-JDAUHWVQ-FgGACr61.js} +1 -1
- package/dist/web/assets/{pegDiagram-ECIUJNBX-_SU4-PwS.js → pegDiagram-ECIUJNBX-DbM0mpqP.js} +1 -1
- package/dist/web/assets/{pie-XZMESJXO-BfbJN8hk.js → pie-XZMESJXO-D3_sBOVu.js} +1 -1
- package/dist/web/assets/{pieDiagram-GNMPY74T-DQQKmEIp.js → pieDiagram-GNMPY74T-COgyAYuf.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-D57K4MC3-BLtF0lyY.js → quadrantDiagram-D57K4MC3-CHA07iGU.js} +1 -1
- package/dist/web/assets/{radar-ABXABTNO-D7Tlesuz.js → radar-ABXABTNO-DecHbZ8y.js} +1 -1
- package/dist/web/assets/{railroad-I3PHUGI6-CA4jSH3I.js → railroad-I3PHUGI6-CX9gFbv6.js} +1 -1
- package/dist/web/assets/{railroad-abnf-LNEFI6M7-CumrL10E.js → railroad-abnf-LNEFI6M7-7ekNszOQ.js} +1 -1
- package/dist/web/assets/{railroad-ebnf-DWYD2UWJ-2GeHQm3G.js → railroad-ebnf-DWYD2UWJ-DdczO4iK.js} +1 -1
- package/dist/web/assets/{railroad-peg-JR7BVNR7-CGbW9a0w.js → railroad-peg-JR7BVNR7-tTF8C5h6.js} +1 -1
- package/dist/web/assets/{railroadDiagram-Y72NRBL7-BQKP5iZM.js → railroadDiagram-Y72NRBL7-7GM0UzXp.js} +1 -1
- package/dist/web/assets/{requirementDiagram-EQYSW5RA-Cn_K53gc.js → requirementDiagram-EQYSW5RA-BSC7dW7G.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-SY24JHO5-BouDevNW.js → sankeyDiagram-SY24JHO5-zbVVy5A9.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-BLEHNHQU-C0rguio1.js → sequenceDiagram-BLEHNHQU-DEe5_sJr.js} +1 -1
- package/dist/web/assets/{src-BJB85M8z.js → src-BRIqFVDQ.js} +1 -1
- package/dist/web/assets/{stateDiagram-v2-A6IESKWZ-BzgsnDJf.js → stateDiagram-v2-A6IESKWZ-D9iIEu7F.js} +1 -1
- package/dist/web/assets/{swimlanes-B3ENRKFQ-BAnQnO43.js → swimlanes-B3ENRKFQ-kSlZwPrl.js} +1 -1
- package/dist/web/assets/swimlanesDiagram-AYQRV4AK-DUYiDwoF.js +8 -0
- package/dist/web/assets/{timeline-definition-JXYKGLGB-i_V2SDud.js → timeline-definition-JXYKGLGB-D3FVcL1M.js} +1 -1
- package/dist/web/assets/{treeView-D4JQ5SDB-blg1ho_V.js → treeView-D4JQ5SDB-Dg-vnn_2.js} +1 -1
- package/dist/web/assets/{treemap-SAJKECNS-GWe0sLdw.js → treemap-SAJKECNS-SUcVM8SE.js} +1 -1
- package/dist/web/assets/{usecaseDiagram-WPOLBNNX-C2epnXsY.js → usecaseDiagram-WPOLBNNX-pg19th6w.js} +1 -1
- package/dist/web/assets/{vennDiagram-Q3Y3IDCW-CYguUs2o.js → vennDiagram-Q3Y3IDCW-C0bGBnZZ.js} +1 -1
- package/dist/web/assets/{wardley-7MLQ67FV-CA1BMgJ-.js → wardley-7MLQ67FV-DRUta4Q-.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-IVAIDKWA-COJi2fLq.js → wardleyDiagram-IVAIDKWA-CafLMkAZ.js} +1 -1
- package/dist/web/assets/{xychartDiagram-4URNHDU4-BxweoZn5.js → xychartDiagram-4URNHDU4-CbBmnzba.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,7 +40,7 @@ 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 = (
|
|
43
|
+
var toPosix = (p3) => p3.split(path.sep).join("/");
|
|
42
44
|
var DEFAULT_SETTINGS = { font: "inter", size: 15, lineHeight: 1.65, padding: 28, width: "full", wrap: true };
|
|
43
45
|
var settingsFile = () => path.join(GLOBAL_DIR, "settings.json");
|
|
44
46
|
function readSettings() {
|
|
@@ -97,10 +99,10 @@ function seedRules() {
|
|
|
97
99
|
const src = path2.join(TEMPLATES_DIR, "rules");
|
|
98
100
|
for (const f of fs2.readdirSync(src)) fs2.copyFileSync(path2.join(src, f), path2.join(rulesDir(), f));
|
|
99
101
|
}
|
|
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] :
|
|
102
|
+
function parseRule(id, text3) {
|
|
103
|
+
const m = text3.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
104
|
+
const meta = m ? YAML.parse(m[1], { logLevel: "error" }) ?? {} : {};
|
|
105
|
+
return { id, title: meta.title ?? id, body: (m ? m[2] : text3).trim() };
|
|
104
106
|
}
|
|
105
107
|
function listRules() {
|
|
106
108
|
seedRules();
|
|
@@ -109,31 +111,31 @@ function listRules() {
|
|
|
109
111
|
function saveRule(rule) {
|
|
110
112
|
seedRules();
|
|
111
113
|
const id = rule.id || slugify(rule.title) || `rule-${Date.now()}`;
|
|
112
|
-
const
|
|
114
|
+
const text3 = `---
|
|
113
115
|
${YAML.stringify({ title: rule.title }).trim()}
|
|
114
116
|
---
|
|
115
117
|
${rule.body.trim()}
|
|
116
118
|
`;
|
|
117
|
-
fs2.writeFileSync(path2.join(rulesDir(), `${id}.md`),
|
|
119
|
+
fs2.writeFileSync(path2.join(rulesDir(), `${id}.md`), text3);
|
|
118
120
|
return { id, title: rule.title, body: rule.body.trim() };
|
|
119
121
|
}
|
|
120
122
|
function deleteRule(id) {
|
|
121
123
|
fs2.rmSync(path2.join(rulesDir(), `${path2.basename(id)}.md`), { force: true });
|
|
122
124
|
}
|
|
123
125
|
function appliedRuleIds(docText) {
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
126
|
-
return [...
|
|
126
|
+
const section2 = extractSection(docText);
|
|
127
|
+
if (!section2) return [];
|
|
128
|
+
return [...section2.matchAll(/<!-- rule:([\w-]+) -->/g)].map((m) => m[1]);
|
|
127
129
|
}
|
|
128
|
-
function extractSection(
|
|
129
|
-
const s =
|
|
130
|
-
const e =
|
|
131
|
-
return s >= 0 && e > s ?
|
|
130
|
+
function extractSection(text3) {
|
|
131
|
+
const s = text3.indexOf(START);
|
|
132
|
+
const e = text3.indexOf(END);
|
|
133
|
+
return s >= 0 && e > s ? text3.slice(s, e + END.length) : null;
|
|
132
134
|
}
|
|
133
135
|
function writtenRules(docText) {
|
|
134
|
-
const
|
|
136
|
+
const section2 = extractSection(docText) ?? "";
|
|
135
137
|
const out = {};
|
|
136
|
-
for (const m of
|
|
138
|
+
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
139
|
return out;
|
|
138
140
|
}
|
|
139
141
|
function sourceLangOf(docText) {
|
|
@@ -165,17 +167,17 @@ ${r.body}`).join("\n\n");
|
|
|
165
167
|
].join("\n");
|
|
166
168
|
}
|
|
167
169
|
function applyRules(docText, rules, sourceLang = sourceLangOf(docText)) {
|
|
168
|
-
const
|
|
170
|
+
const section2 = renderSection(rules, sourceLang);
|
|
169
171
|
const existing = extractSection(docText);
|
|
170
|
-
if (existing) return docText.replace(existing,
|
|
171
|
-
return docText.replace(/\s*$/, "") + "\n\n" +
|
|
172
|
+
if (existing) return docText.replace(existing, section2);
|
|
173
|
+
return docText.replace(/\s*$/, "") + "\n\n" + section2 + "\n";
|
|
172
174
|
}
|
|
173
175
|
function applyRulesToFile(file, ids, sourceLang) {
|
|
174
|
-
const
|
|
176
|
+
const text3 = fs2.existsSync(file) ? fs2.readFileSync(file, "utf8") : "";
|
|
175
177
|
const all = listRules();
|
|
176
|
-
const written = writtenRules(
|
|
177
|
-
const rules = (ids ?? appliedRuleIds(
|
|
178
|
-
fs2.writeFileSync(file, applyRules(
|
|
178
|
+
const written = writtenRules(text3);
|
|
179
|
+
const rules = (ids ?? appliedRuleIds(text3)).map((id) => all.find((r) => r.id === id) ?? written[id]).filter((r) => !!r);
|
|
180
|
+
fs2.writeFileSync(file, applyRules(text3, rules, sourceLang ?? sourceLangOf(text3)));
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
// src/core/scan.ts
|
|
@@ -205,6 +207,15 @@ function setIgnored(root2, rel, kind, ignored) {
|
|
|
205
207
|
if (ignored) lines.push(entry);
|
|
206
208
|
fs3.writeFileSync(path3.join(root2, IGNORE_FILE), lines.length ? lines.join("\n") + "\n" : "");
|
|
207
209
|
}
|
|
210
|
+
function watchSkips(root2) {
|
|
211
|
+
const gitIgnore = loadIgnore(root2, ".gitignore");
|
|
212
|
+
return (rel, isDir) => {
|
|
213
|
+
if (!rel || rel.startsWith("..")) return false;
|
|
214
|
+
if (rel.split("/").some((s) => ALWAYS_SKIP.has(s))) return true;
|
|
215
|
+
if (isDir !== false && gitIgnore.ignores(rel + "/")) return true;
|
|
216
|
+
return isDir !== true && gitIgnore.ignores(rel);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
208
219
|
function scanTree(root2) {
|
|
209
220
|
const gitIgnore = loadIgnore(root2, ".gitignore");
|
|
210
221
|
const mdIgnore = loadIgnore(root2, IGNORE_FILE);
|
|
@@ -224,7 +235,7 @@ function scanTree(root2) {
|
|
|
224
235
|
if (gitIgnore.ignores(rel + "/")) continue;
|
|
225
236
|
const ignored = parentIgnored || mdIgnore.ignores(rel + "/");
|
|
226
237
|
const children = walk(abs, ignored);
|
|
227
|
-
if (!children.length) continue;
|
|
238
|
+
if (!children.length && !isEmptyDir(abs)) continue;
|
|
228
239
|
const project = MAIN_DOCS.some((d) => children.some((c) => c.kind === "file" && c.name === d));
|
|
229
240
|
nodes.push({ name: e.name, path: rel, kind: "dir", project: project || void 0, ignored: ignored || void 0, children });
|
|
230
241
|
} else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
|
|
@@ -237,9 +248,16 @@ function scanTree(root2) {
|
|
|
237
248
|
}
|
|
238
249
|
return walk(root2, false);
|
|
239
250
|
}
|
|
251
|
+
function isEmptyDir(abs) {
|
|
252
|
+
try {
|
|
253
|
+
return fs3.readdirSync(abs).length === 0;
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
var collator = new Intl.Collator(void 0, { numeric: true, sensitivity: "base" });
|
|
240
259
|
function compareNodes(a, b) {
|
|
241
|
-
|
|
242
|
-
return rank(a) - rank(b) || a.name.localeCompare(b.name);
|
|
260
|
+
return (a.kind === "dir" ? 0 : 1) - (b.kind === "dir" ? 0 : 1) || collator.compare(a.name, b.name);
|
|
243
261
|
}
|
|
244
262
|
function flattenFiles(nodes, includeIgnored = false) {
|
|
245
263
|
const out = [];
|
|
@@ -263,7 +281,7 @@ function findSubprojects(nodes) {
|
|
|
263
281
|
// src/core/i18n.ts
|
|
264
282
|
var stateFile = (root2) => path4.join(stateDir(root2), "state.json");
|
|
265
283
|
var translationPath = (root2, lang, rel) => path4.join(stateDir(root2), "i18n", lang, rel);
|
|
266
|
-
var hash = (
|
|
284
|
+
var hash = (text3) => crypto.createHash("sha1").update(text3).digest("hex").slice(0, 12);
|
|
267
285
|
var readText = (abs) => {
|
|
268
286
|
try {
|
|
269
287
|
return fs4.readFileSync(abs, "utf8");
|
|
@@ -309,8 +327,8 @@ function foreignFiles(root2, sourceLang) {
|
|
|
309
327
|
const { keepLanguage: keepLanguage2 } = readState(root2);
|
|
310
328
|
const out = [];
|
|
311
329
|
for (const f of flattenFiles(scanTree(root2))) {
|
|
312
|
-
const
|
|
313
|
-
const detected =
|
|
330
|
+
const text3 = readText(path4.join(root2, f.path));
|
|
331
|
+
const detected = text3 ? detectLang(text3) : null;
|
|
314
332
|
if (detected && detected !== sourceLang && keepLanguage2[f.path] !== detected) out.push({ path: f.path, detected });
|
|
315
333
|
}
|
|
316
334
|
return out;
|
|
@@ -322,6 +340,8 @@ function syncReport(root2) {
|
|
|
322
340
|
return {
|
|
323
341
|
sourceLang,
|
|
324
342
|
langs,
|
|
343
|
+
/** Language names by code: translate into these (codes added by hand have no built-in name). */
|
|
344
|
+
names: Object.fromEntries([sourceLang, ...langs].map((l) => [l, langName(l, config)])),
|
|
325
345
|
foreign: foreignFiles(root2, sourceLang),
|
|
326
346
|
files: langs.flatMap((l) => allStatuses(root2, l))
|
|
327
347
|
};
|
|
@@ -337,8 +357,8 @@ function markSynced(root2, lang, rel) {
|
|
|
337
357
|
writeState(root2, state);
|
|
338
358
|
}
|
|
339
359
|
function keepLanguage(root2, rel) {
|
|
340
|
-
const
|
|
341
|
-
const detected =
|
|
360
|
+
const text3 = readText(path4.join(root2, rel));
|
|
361
|
+
const detected = text3 ? detectLang(text3) : null;
|
|
342
362
|
if (!detected) throw new Error(`Could not detect the language of ${rel}`);
|
|
343
363
|
const state = readState(root2);
|
|
344
364
|
state.keepLanguage[rel] = detected;
|
|
@@ -347,9 +367,9 @@ function keepLanguage(root2, rel) {
|
|
|
347
367
|
}
|
|
348
368
|
|
|
349
369
|
// src/server/index.ts
|
|
350
|
-
import
|
|
370
|
+
import fs10 from "fs";
|
|
351
371
|
import http from "http";
|
|
352
|
-
import
|
|
372
|
+
import path10 from "path";
|
|
353
373
|
import { watch } from "chokidar";
|
|
354
374
|
import open from "open";
|
|
355
375
|
import pc from "picocolors";
|
|
@@ -359,29 +379,35 @@ import { WebSocketServer } from "ws";
|
|
|
359
379
|
import fs5 from "fs";
|
|
360
380
|
import path5 from "path";
|
|
361
381
|
import YAML2 from "yaml";
|
|
362
|
-
function parseFrontmatter(
|
|
363
|
-
const m =
|
|
382
|
+
function parseFrontmatter(text3) {
|
|
383
|
+
const m = text3.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
364
384
|
if (!m) return {};
|
|
365
385
|
try {
|
|
366
|
-
return YAML2.parse(m[1]) ?? {};
|
|
386
|
+
return YAML2.parse(m[1], { logLevel: "error" }) ?? {};
|
|
367
387
|
} catch {
|
|
368
388
|
return {};
|
|
369
389
|
}
|
|
370
390
|
}
|
|
391
|
+
var asText = (v) => v instanceof Date ? v.toISOString().slice(0, 10) : v == null || typeof v === "object" ? null : String(v);
|
|
371
392
|
function summarize(root2) {
|
|
372
|
-
return flattenFiles(scanTree(root2)).
|
|
373
|
-
const
|
|
374
|
-
const meta = parseFrontmatter(
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
const
|
|
393
|
+
return flattenFiles(scanTree(root2)).flatMap((f) => {
|
|
394
|
+
const text3 = fs5.readFileSync(path5.join(root2, f.path), "utf8");
|
|
395
|
+
const meta = parseFrontmatter(text3);
|
|
396
|
+
if (meta.type === "task" && f.name.startsWith("_")) return [];
|
|
397
|
+
const id = asText(meta.id);
|
|
398
|
+
const total = text3.match(/^\s*[-*+] \[[ xX]\]/gm)?.length ?? 0;
|
|
399
|
+
const done = text3.match(/^\s*[-*+] \[[xX]\]/gm)?.length ?? 0;
|
|
400
|
+
const heading2 = text3.match(/^#\s+(.+)$/m)?.[1];
|
|
401
|
+
const title = asText(meta.title) ?? (heading2 && id ? heading2.replace(new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*[\xB7:-]\\s*`), "") : heading2);
|
|
378
402
|
return {
|
|
379
403
|
path: f.path,
|
|
380
|
-
title:
|
|
381
|
-
type: meta.type
|
|
382
|
-
status: meta.status
|
|
404
|
+
title: title ?? f.name.replace(/\.md$/i, ""),
|
|
405
|
+
type: asText(meta.type),
|
|
406
|
+
status: asText(meta.status),
|
|
383
407
|
phase: typeof meta.phase === "number" ? meta.phase : null,
|
|
384
|
-
updated:
|
|
408
|
+
updated: asText(meta.updated),
|
|
409
|
+
id,
|
|
410
|
+
area: asText(meta.area),
|
|
385
411
|
tasks: { done, total }
|
|
386
412
|
};
|
|
387
413
|
});
|
|
@@ -423,6 +449,414 @@ function gitDiff(root2, rel) {
|
|
|
423
449
|
return git(root2, hasHead ? ["diff", "HEAD", "--", rel] : ["diff", "--cached", "--", rel]) ?? "";
|
|
424
450
|
}
|
|
425
451
|
|
|
452
|
+
// src/core/proposals.ts
|
|
453
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
454
|
+
import fs8 from "fs";
|
|
455
|
+
import path8 from "path";
|
|
456
|
+
|
|
457
|
+
// src/core/scaffold.ts
|
|
458
|
+
import fs6 from "fs";
|
|
459
|
+
import path6 from "path";
|
|
460
|
+
var today = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
461
|
+
function fill(text3, vars) {
|
|
462
|
+
return text3.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? "");
|
|
463
|
+
}
|
|
464
|
+
function copyTree(src, dest, vars, created, root2) {
|
|
465
|
+
for (const e of fs6.readdirSync(src, { withFileTypes: true })) {
|
|
466
|
+
const from = path6.join(src, e.name);
|
|
467
|
+
const to = path6.join(dest, fill(e.name, vars));
|
|
468
|
+
if (e.isDirectory()) {
|
|
469
|
+
fs6.mkdirSync(to, { recursive: true });
|
|
470
|
+
copyTree(from, to, vars, created, root2);
|
|
471
|
+
} else if (!fs6.existsSync(to)) {
|
|
472
|
+
fs6.writeFileSync(to, fill(fs6.readFileSync(from, "utf8"), vars));
|
|
473
|
+
created.push(toPosix(path6.relative(root2, to)));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function createMainDoc(root2, name) {
|
|
478
|
+
const file = path6.join(root2, name);
|
|
479
|
+
if (fs6.existsSync(file)) return;
|
|
480
|
+
const text3 = fs6.readFileSync(path6.join(TEMPLATES_DIR, "main.md"), "utf8");
|
|
481
|
+
fs6.writeFileSync(file, fill(text3, { name: path6.basename(root2), date: today() }));
|
|
482
|
+
}
|
|
483
|
+
function installSkill(root2) {
|
|
484
|
+
const dir = path6.join(root2, ".claude", "skills", "mdrules");
|
|
485
|
+
fs6.mkdirSync(dir, { recursive: true });
|
|
486
|
+
fs6.copyFileSync(path6.join(TEMPLATES_DIR, "skill", "SKILL.md"), path6.join(dir, "SKILL.md"));
|
|
487
|
+
}
|
|
488
|
+
function ensureGitignore(root2) {
|
|
489
|
+
const file = path6.join(root2, ".gitignore");
|
|
490
|
+
const text3 = fs6.existsSync(file) ? fs6.readFileSync(file, "utf8") : "";
|
|
491
|
+
if (text3.split("\n").some((l) => [".mdrules", ".mdrules/", "/.mdrules", "/.mdrules/"].includes(l.trim()))) return;
|
|
492
|
+
fs6.writeFileSync(file, text3 + (text3 && !text3.endsWith("\n") ? "\n" : "") + ".mdrules/\n");
|
|
493
|
+
}
|
|
494
|
+
function hideInVsCode(root2) {
|
|
495
|
+
const file = path6.join(root2, ".vscode", "settings.json");
|
|
496
|
+
let settings = {};
|
|
497
|
+
if (fs6.existsSync(file)) {
|
|
498
|
+
try {
|
|
499
|
+
settings = JSON.parse(fs6.readFileSync(file, "utf8"));
|
|
500
|
+
} catch {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
settings["files.exclude"] = { ...settings["files.exclude"] ?? {}, "**/.mdrules": true };
|
|
505
|
+
fs6.mkdirSync(path6.dirname(file), { recursive: true });
|
|
506
|
+
fs6.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/core/sdd.ts
|
|
511
|
+
import { spawnSync } from "child_process";
|
|
512
|
+
import fs7 from "fs";
|
|
513
|
+
import path7 from "path";
|
|
514
|
+
var SDD_AGENTS = {
|
|
515
|
+
claude: { label: "Claude Code", openspec: "claude", speckit: "claude" },
|
|
516
|
+
codex: { label: "Codex", openspec: "codex", speckit: "codex" },
|
|
517
|
+
copilot: { label: "GitHub Copilot", openspec: "github-copilot", speckit: "copilot" },
|
|
518
|
+
gemini: { label: "Gemini CLI", openspec: "gemini", speckit: "gemini" },
|
|
519
|
+
cursor: { label: "Cursor", openspec: "cursor", speckit: "cursor-agent" }
|
|
520
|
+
};
|
|
521
|
+
var OPENSPEC_PKG = "@fission-ai/openspec@latest";
|
|
522
|
+
var SPECKIT_SRC = "git+https://github.com/github/spec-kit.git";
|
|
523
|
+
function detectWorkflow(root2) {
|
|
524
|
+
if (fs7.existsSync(path7.join(root2, "openspec"))) return "openspec";
|
|
525
|
+
if (fs7.existsSync(path7.join(root2, ".specify"))) return "speckit";
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
var has = (cmd) => spawnSync(cmd, ["--version"], { stdio: "ignore", shell: process.platform === "win32" }).status === 0;
|
|
529
|
+
var hasUv = () => has("uvx");
|
|
530
|
+
function installCommand(workflow, agents) {
|
|
531
|
+
if (workflow === "openspec") {
|
|
532
|
+
const tools = agents.map((a) => SDD_AGENTS[a].openspec).join(",") || "none";
|
|
533
|
+
const runner = has("pnpm") ? ["pnpm", "dlx"] : has("npx") ? ["npx", "--yes"] : null;
|
|
534
|
+
return runner && [...runner, OPENSPEC_PKG, "init", "--tools", tools, "--no-animation", "--no-copilot-cloud"];
|
|
535
|
+
}
|
|
536
|
+
if (!hasUv()) return null;
|
|
537
|
+
return ["uvx", "--from", SPECKIT_SRC, "specify", "init", "--here", "--force", "--non-interactive", "--ignore-agent-tools", "--integration", SDD_AGENTS[agents[0] ?? "claude"].speckit];
|
|
538
|
+
}
|
|
539
|
+
function installWorkflow(root2, workflow, agents, official) {
|
|
540
|
+
const before = new Set(flattenFiles(scanTree(root2), true).map((f) => f.path));
|
|
541
|
+
const result = { official: false, created: [], ignored: [] };
|
|
542
|
+
const cmd = official ? installCommand(workflow, agents) : null;
|
|
543
|
+
if (cmd) {
|
|
544
|
+
const run = spawnSync(cmd[0], cmd.slice(1), { cwd: root2, encoding: "utf8", timeout: 3e5, shell: process.platform === "win32", env: { ...process.env, NO_COLOR: "1" } });
|
|
545
|
+
if (run.status === 0) result.official = true;
|
|
546
|
+
else result.error = (run.error?.message ?? `${run.stderr ?? ""}
|
|
547
|
+
${run.stdout ?? ""}`).trim().split("\n").slice(-6).join("\n");
|
|
548
|
+
}
|
|
549
|
+
copyTree(path7.join(TEMPLATES_DIR, "sdd", workflow), root2, { date: today2() }, result.created, root2);
|
|
550
|
+
const dirs2 = /* @__PURE__ */ new Set();
|
|
551
|
+
for (const f of flattenFiles(scanTree(root2), true)) {
|
|
552
|
+
if (before.has(f.path) || !f.path.startsWith(".") || f.path.startsWith(".specify/memory/")) continue;
|
|
553
|
+
dirs2.add(path7.posix.dirname(f.path));
|
|
554
|
+
}
|
|
555
|
+
for (const dir of dirs2) setIgnored(root2, dir, "dir", true);
|
|
556
|
+
result.ignored = [...dirs2].sort();
|
|
557
|
+
return result;
|
|
558
|
+
}
|
|
559
|
+
var today2 = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
560
|
+
var WORKFLOW_TEXT = {
|
|
561
|
+
openspec: [
|
|
562
|
+
"## Workflow",
|
|
563
|
+
"",
|
|
564
|
+
"This project uses [OpenSpec](https://github.com/Fission-AI/OpenSpec) (spec-driven development; guide: `openspec/README.md`):",
|
|
565
|
+
"",
|
|
566
|
+
"- `openspec/specs/<domain>/spec.md` describes how the system behaves today.",
|
|
567
|
+
"- Every change starts in `openspec/changes/<change-id>/` with `proposal.md`, `tasks.md`, an optional `design.md` and delta specs, before any code.",
|
|
568
|
+
"- When it is done, archive it so its delta specs merge into `openspec/specs/`.",
|
|
569
|
+
"- Use the `/opsx:*` commands (or the `openspec` CLI) when available."
|
|
570
|
+
].join("\n"),
|
|
571
|
+
speckit: [
|
|
572
|
+
"## Workflow",
|
|
573
|
+
"",
|
|
574
|
+
"This project uses [GitHub Spec Kit](https://github.com/github/spec-kit) (spec-driven development; guide: `specs/README.md`):",
|
|
575
|
+
"",
|
|
576
|
+
"- Principles every change must respect: `.specify/memory/constitution.md`.",
|
|
577
|
+
"- One folder per feature in `specs/NNN-feature-name/`: `spec.md` (what and why), then `plan.md` (how), then `tasks.md`.",
|
|
578
|
+
"- Use the `/speckit-*` commands when available."
|
|
579
|
+
].join("\n")
|
|
580
|
+
};
|
|
581
|
+
function applyWorkflowToFile(file, workflow, text3) {
|
|
582
|
+
writeManagedBlock(file, workflow === "none" ? null : text3 ?? (workflow === "mdrules" ? null : WORKFLOW_TEXT[workflow]));
|
|
583
|
+
}
|
|
584
|
+
function writeManagedBlock(file, body, top = false, name = "workflow") {
|
|
585
|
+
const START2 = `<!-- mdrules:${name}:start -->`;
|
|
586
|
+
const END2 = `<!-- mdrules:${name}:end -->`;
|
|
587
|
+
const text3 = fs7.readFileSync(file, "utf8");
|
|
588
|
+
const s = text3.indexOf(START2);
|
|
589
|
+
const e = text3.indexOf(END2);
|
|
590
|
+
const existing = s >= 0 && e > s ? text3.slice(s, e + END2.length) : null;
|
|
591
|
+
if (body === null) {
|
|
592
|
+
if (existing) fs7.writeFileSync(file, text3.replace(new RegExp(`\\n*${escapeRe(existing)}\\n?`), "\n"));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const section2 = `${START2}
|
|
596
|
+
${body}
|
|
597
|
+
${END2}`;
|
|
598
|
+
if (existing) return fs7.writeFileSync(file, text3.replace(existing, section2));
|
|
599
|
+
const title = top ? /^# .*\n/m.exec(text3) : null;
|
|
600
|
+
if (title) return fs7.writeFileSync(file, text3.slice(0, title.index + title[0].length) + "\n" + section2 + "\n" + text3.slice(title.index + title[0].length));
|
|
601
|
+
fs7.writeFileSync(file, text3.replace(/\s*$/, "") + "\n\n" + section2 + "\n");
|
|
602
|
+
}
|
|
603
|
+
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
604
|
+
var dirs = (abs) => {
|
|
605
|
+
try {
|
|
606
|
+
return fs7.readdirSync(abs, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name).sort();
|
|
607
|
+
} catch {
|
|
608
|
+
return [];
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
var read = (abs) => {
|
|
612
|
+
try {
|
|
613
|
+
return fs7.readFileSync(abs, "utf8");
|
|
614
|
+
} catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
var progress = (text3) => ({ done: text3?.match(/^\s*[-*+] \[[xX]\]/gm)?.length ?? 0, total: text3?.match(/^\s*[-*+] \[[ xX]\]/gm)?.length ?? 0 });
|
|
619
|
+
var heading = (text3) => text3?.match(/^#\s+(.+)$/m)?.[1].trim() ?? null;
|
|
620
|
+
var countSpecs = (abs) => {
|
|
621
|
+
try {
|
|
622
|
+
return fs7.readdirSync(abs, { recursive: true }).filter((f) => String(f).endsWith("spec.md")).length;
|
|
623
|
+
} catch {
|
|
624
|
+
return 0;
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
function summarizeSdd(root2) {
|
|
628
|
+
const out = {};
|
|
629
|
+
const os3 = path7.join(root2, "openspec");
|
|
630
|
+
if (fs7.existsSync(os3)) {
|
|
631
|
+
const rel = (...p3) => toPosix(path7.join("openspec", ...p3));
|
|
632
|
+
out.openspec = {
|
|
633
|
+
changes: dirs(path7.join(os3, "changes")).filter((id) => id !== "archive").map((id) => {
|
|
634
|
+
const dir = path7.join(os3, "changes", id);
|
|
635
|
+
const proposal = read(path7.join(dir, "proposal.md"));
|
|
636
|
+
const tasks = read(path7.join(dir, "tasks.md"));
|
|
637
|
+
return {
|
|
638
|
+
id,
|
|
639
|
+
// "Proposal: Add login" → "Add login"
|
|
640
|
+
title: heading(proposal)?.replace(/^(change\s+)?proposal:\s*/i, "") ?? id,
|
|
641
|
+
path: rel("changes", id, proposal !== null ? "proposal.md" : "tasks.md"),
|
|
642
|
+
artifacts: { proposal: proposal !== null, design: fs7.existsSync(path7.join(dir, "design.md")), tasks: tasks !== null, specs: countSpecs(path7.join(dir, "specs")) },
|
|
643
|
+
tasks: progress(tasks)
|
|
644
|
+
};
|
|
645
|
+
}),
|
|
646
|
+
specs: dirs(path7.join(os3, "specs")).filter((id) => fs7.existsSync(path7.join(os3, "specs", id, "spec.md"))).map((id) => ({ id, path: rel("specs", id, "spec.md") })),
|
|
647
|
+
archived: dirs(path7.join(os3, "changes", "archive")).reverse().slice(0, 12).map((id) => {
|
|
648
|
+
const file = ["proposal.md", "tasks.md"].find((f) => fs7.existsSync(path7.join(os3, "changes", "archive", id, f)));
|
|
649
|
+
return { id, path: file ? rel("changes", "archive", id, file) : "" };
|
|
650
|
+
})
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
if (fs7.existsSync(path7.join(root2, ".specify"))) {
|
|
654
|
+
const constitution = read(path7.join(root2, ".specify", "memory", "constitution.md"));
|
|
655
|
+
out.speckit = {
|
|
656
|
+
// The template is full of [PLACEHOLDERS] until /speckit-constitution fills it.
|
|
657
|
+
constitution: constitution === null ? null : { path: ".specify/memory/constitution.md", filled: !/\[[A-Z_0-9]+\]/.test(constitution) },
|
|
658
|
+
features: dirs(path7.join(root2, "specs")).map((id) => {
|
|
659
|
+
const dir = path7.join(root2, "specs", id);
|
|
660
|
+
const spec = read(path7.join(dir, "spec.md"));
|
|
661
|
+
const tasks = read(path7.join(dir, "tasks.md"));
|
|
662
|
+
return {
|
|
663
|
+
id,
|
|
664
|
+
// "Feature Specification: Login" → "Login"
|
|
665
|
+
title: heading(spec)?.replace(/^feature specification:\s*/i, "") ?? id,
|
|
666
|
+
path: toPosix(path7.join("specs", id, spec !== null ? "spec.md" : "plan.md")),
|
|
667
|
+
artifacts: { spec: spec !== null, plan: fs7.existsSync(path7.join(dir, "plan.md")), tasks: tasks !== null },
|
|
668
|
+
tasks: progress(tasks)
|
|
669
|
+
};
|
|
670
|
+
})
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
return out;
|
|
674
|
+
}
|
|
675
|
+
function sddPrefixes(summary) {
|
|
676
|
+
return [...summary.openspec ? ["openspec/"] : [], ...summary.speckit ? ["specs/", ".specify/"] : []];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/core/proposals.ts
|
|
680
|
+
var SKILL = path8.join(".claude", "skills", "mdrules-proposals");
|
|
681
|
+
function proposalsState(root2) {
|
|
682
|
+
return { active: fs8.existsSync(path8.join(root2, SKILL, "SKILL.md")), layout: readConfig(root2)?.proposals ?? "general" };
|
|
683
|
+
}
|
|
684
|
+
var WHERE = {
|
|
685
|
+
general: "`docs/proposals/NNN-short-name.md` (NNN: the next number in that folder)",
|
|
686
|
+
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"
|
|
687
|
+
};
|
|
688
|
+
function section(layout) {
|
|
689
|
+
return [
|
|
690
|
+
"## Proposals",
|
|
691
|
+
"",
|
|
692
|
+
"When an answer would be long, has options or a design to validate, or the user asks for one, write a",
|
|
693
|
+
"temporary proposal the user answers in mdrules instead of a chat answer (skill: `mdrules-proposals`).",
|
|
694
|
+
"",
|
|
695
|
+
`- Where: ${WHERE[layout]}.`,
|
|
696
|
+
"- The user ticks options and comments (`<!-- comment by \u2026 -->`). When they say it is answered, reply to the",
|
|
697
|
+
" comments, update every doc that depends on the decision (tasks, phases, architecture\u2026), then delete the proposal."
|
|
698
|
+
].join("\n");
|
|
699
|
+
}
|
|
700
|
+
function mainDocs(root2, mainDoc) {
|
|
701
|
+
const doc = mainDoc ?? MAIN_DOCS.find((d) => fs8.existsSync(path8.join(root2, d)));
|
|
702
|
+
return doc ? [path8.join(root2, doc)] : [];
|
|
703
|
+
}
|
|
704
|
+
function activateProposals(root2, layout, mainDoc) {
|
|
705
|
+
const dir = path8.join(root2, SKILL);
|
|
706
|
+
fs8.mkdirSync(dir, { recursive: true });
|
|
707
|
+
fs8.writeFileSync(path8.join(dir, "SKILL.md"), fill(fs8.readFileSync(path8.join(TEMPLATES_DIR, "proposals", "SKILL.md"), "utf8"), { where: WHERE[layout] }));
|
|
708
|
+
fs8.mkdirSync(path8.join(root2, "docs", "proposals"), { recursive: true });
|
|
709
|
+
for (const file of mainDocs(root2, mainDoc)) writeManagedBlock(file, section(layout), false, "proposals");
|
|
710
|
+
writeConfig(root2, { ...readConfig(root2) ?? { mainDoc, langs: [] }, proposals: layout });
|
|
711
|
+
}
|
|
712
|
+
function deactivateProposals(root2, mainDoc) {
|
|
713
|
+
fs8.rmSync(path8.join(root2, SKILL), { recursive: true, force: true });
|
|
714
|
+
for (const file of mainDocs(root2, mainDoc)) writeManagedBlock(file, null, false, "proposals");
|
|
715
|
+
}
|
|
716
|
+
function authorName(root2) {
|
|
717
|
+
try {
|
|
718
|
+
return execFileSync2("git", ["config", "user.name"], { cwd: root2, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
719
|
+
} catch {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/core/skills.ts
|
|
725
|
+
import fs9 from "fs";
|
|
726
|
+
import os2 from "os";
|
|
727
|
+
import path9 from "path";
|
|
728
|
+
import YAML3 from "yaml";
|
|
729
|
+
var SOURCE_DIRS = {
|
|
730
|
+
claude: [".claude", "skills"],
|
|
731
|
+
// Shared folder read by Codex, Copilot and Gemini CLI, and where `npx skills` installs.
|
|
732
|
+
agents: [".agents", "skills"],
|
|
733
|
+
codex: [".codex", "skills"],
|
|
734
|
+
gemini: [".gemini", "skills"],
|
|
735
|
+
copilot: [".copilot", "skills"],
|
|
736
|
+
cursor: [".cursor", "skills"]
|
|
737
|
+
};
|
|
738
|
+
var PROJECT_TARGETS = { claude: ".claude/skills", agents: ".agents/skills" };
|
|
739
|
+
var sourceDir = (source) => path9.join(os2.homedir(), ...SOURCE_DIRS[source]);
|
|
740
|
+
var display = (p3) => p3.startsWith(os2.homedir() + path9.sep) ? "~" + p3.slice(os2.homedir().length) : p3;
|
|
741
|
+
function skillSources() {
|
|
742
|
+
return Object.keys(SOURCE_DIRS).map((id) => ({ id, path: display(sourceDir(id)), exists: fs9.existsSync(sourceDir(id)) }));
|
|
743
|
+
}
|
|
744
|
+
var tagsFile = () => path9.join(GLOBAL_DIR, "skills.json");
|
|
745
|
+
function readTags() {
|
|
746
|
+
try {
|
|
747
|
+
return JSON.parse(fs9.readFileSync(tagsFile(), "utf8")).tags ?? {};
|
|
748
|
+
} catch {
|
|
749
|
+
return {};
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function setSkillTags(name, tags) {
|
|
753
|
+
const all = readTags();
|
|
754
|
+
const clean = [...new Set(tags.map((t) => t.trim().toLowerCase()).filter(Boolean))];
|
|
755
|
+
if (clean.length) all[name] = clean;
|
|
756
|
+
else delete all[name];
|
|
757
|
+
fs9.mkdirSync(GLOBAL_DIR, { recursive: true });
|
|
758
|
+
fs9.writeFileSync(tagsFile(), JSON.stringify({ tags: all }, null, 2) + "\n");
|
|
759
|
+
return clean;
|
|
760
|
+
}
|
|
761
|
+
function findSkills(dir, source, managed, depth = 0) {
|
|
762
|
+
let entries;
|
|
763
|
+
try {
|
|
764
|
+
entries = fs9.readdirSync(dir, { withFileTypes: true });
|
|
765
|
+
} catch {
|
|
766
|
+
return [];
|
|
767
|
+
}
|
|
768
|
+
return entries.flatMap((e) => {
|
|
769
|
+
const abs = path9.join(dir, e.name);
|
|
770
|
+
let real;
|
|
771
|
+
try {
|
|
772
|
+
if (!fs9.statSync(abs).isDirectory()) return [];
|
|
773
|
+
real = fs9.realpathSync(abs);
|
|
774
|
+
} catch {
|
|
775
|
+
return [];
|
|
776
|
+
}
|
|
777
|
+
if (fs9.existsSync(path9.join(abs, "SKILL.md"))) return [{ dir: abs, real, source, managed }];
|
|
778
|
+
return depth < 2 ? findSkills(abs, source, managed || e.name === "synced" || e.name.startsWith("."), depth + 1) : [];
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
function frontmatter(text3) {
|
|
782
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text3);
|
|
783
|
+
if (!m) return null;
|
|
784
|
+
try {
|
|
785
|
+
const data = YAML3.parse(m[1], { logLevel: "error" });
|
|
786
|
+
return data && typeof data === "object" ? data : null;
|
|
787
|
+
} catch {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
function listFiles(dir) {
|
|
792
|
+
try {
|
|
793
|
+
return fs9.readdirSync(dir, { recursive: true, withFileTypes: true }).map((e) => ({ e, rel: path9.relative(dir, path9.join(e.parentPath ?? e.path ?? dir, e.name)).split(path9.sep).join("/") })).filter(({ e, rel }) => e.isFile() && rel !== "SKILL.md").map(({ rel }) => rel).sort().slice(0, 100);
|
|
794
|
+
} catch {
|
|
795
|
+
return [];
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function listSkills(root2) {
|
|
799
|
+
const byReal = /* @__PURE__ */ new Map();
|
|
800
|
+
for (const source of Object.keys(SOURCE_DIRS)) {
|
|
801
|
+
for (const f of findSkills(sourceDir(source), source, false)) byReal.set(f.real, [...byReal.get(f.real) ?? [], f]);
|
|
802
|
+
}
|
|
803
|
+
const userTags = readTags();
|
|
804
|
+
const skills = [];
|
|
805
|
+
for (const [real, found] of byReal) {
|
|
806
|
+
const text3 = fs9.readFileSync(path9.join(real, "SKILL.md"), "utf8");
|
|
807
|
+
const fm = frontmatter(text3);
|
|
808
|
+
const folder = path9.basename(found[0].dir);
|
|
809
|
+
const name = typeof fm?.name === "string" ? fm.name : folder;
|
|
810
|
+
const description = typeof fm?.description === "string" ? fm.description.trim() : "";
|
|
811
|
+
const issues = [];
|
|
812
|
+
if (!fm) issues.push("frontmatter");
|
|
813
|
+
else {
|
|
814
|
+
if (typeof fm.name !== "string" || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(fm.name) || fm.name.length > 64) issues.push("name");
|
|
815
|
+
else if (fm.name !== folder) issues.push("name-dir");
|
|
816
|
+
if (!description) issues.push("description");
|
|
817
|
+
else if (description.length > 1024) issues.push("description-long");
|
|
818
|
+
}
|
|
819
|
+
const declared = fm?.metadata?.tags;
|
|
820
|
+
const metaTags = Array.isArray(declared) ? declared.map(String) : typeof declared === "string" ? declared.split(",") : [];
|
|
821
|
+
skills.push({
|
|
822
|
+
key: real,
|
|
823
|
+
name,
|
|
824
|
+
folder,
|
|
825
|
+
description,
|
|
826
|
+
locations: found.map((f) => ({ source: f.source, path: display(f.dir) })),
|
|
827
|
+
managed: found.every((f) => f.managed),
|
|
828
|
+
files: listFiles(real),
|
|
829
|
+
tags: [.../* @__PURE__ */ new Set([...userTags[name] ?? [], ...metaTags.map((t) => t.trim().toLowerCase()).filter(Boolean)])],
|
|
830
|
+
issues,
|
|
831
|
+
installed: Object.keys(PROJECT_TARGETS).filter((t) => fs9.existsSync(path9.join(root2, PROJECT_TARGETS[t], folder, "SKILL.md")))
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
835
|
+
}
|
|
836
|
+
function getSkill(root2, key) {
|
|
837
|
+
const skill = typeof key === "string" ? listSkills(root2).find((s) => s.key === key) : void 0;
|
|
838
|
+
if (!skill) throw Object.assign(new Error("Skill not found"), { code: "ENOENT" });
|
|
839
|
+
return skill;
|
|
840
|
+
}
|
|
841
|
+
function projectTargets(root2) {
|
|
842
|
+
const has2 = (p3) => fs9.existsSync(path9.join(root2, p3));
|
|
843
|
+
const out = [];
|
|
844
|
+
if (has2(".claude") || has2("CLAUDE.md")) out.push("claude");
|
|
845
|
+
if (has2(".agents") || has2("AGENTS.md") || has2(".codex") || has2(".gemini") || has2(".github/skills")) out.push("agents");
|
|
846
|
+
return out.length ? out : ["claude"];
|
|
847
|
+
}
|
|
848
|
+
function installSkill2(root2, skill, targets, overwrite) {
|
|
849
|
+
const dests = targets.map((t) => path9.join(root2, PROJECT_TARGETS[t], skill.folder));
|
|
850
|
+
const existing = dests.filter((d) => fs9.existsSync(d));
|
|
851
|
+
if (existing.length && !overwrite) return { conflict: existing.map((d) => path9.relative(root2, d).split(path9.sep).join("/")) };
|
|
852
|
+
for (const dest of dests) {
|
|
853
|
+
fs9.rmSync(dest, { recursive: true, force: true });
|
|
854
|
+
fs9.mkdirSync(path9.dirname(dest), { recursive: true });
|
|
855
|
+
fs9.cpSync(skill.key, dest, { recursive: true, dereference: true });
|
|
856
|
+
}
|
|
857
|
+
return { ok: true };
|
|
858
|
+
}
|
|
859
|
+
|
|
426
860
|
// src/server/index.ts
|
|
427
861
|
var IMAGE_MIME = {
|
|
428
862
|
".png": "image/png",
|
|
@@ -470,7 +904,7 @@ function isLocalRequest(req) {
|
|
|
470
904
|
async function startServer({ root: root2, port, open: openBrowser }) {
|
|
471
905
|
const config = () => readConfig(root2) ?? { mainDoc: null, langs: [] };
|
|
472
906
|
const sourceLang = () => projectSourceLang(root2, readConfig(root2)?.mainDoc);
|
|
473
|
-
const viewLang = (value) =>
|
|
907
|
+
const viewLang = (value) => isLangCode(value) && value !== sourceLang() && config().langs.includes(value) ? value : null;
|
|
474
908
|
const mdPath = (rel) => {
|
|
475
909
|
if (typeof rel !== "string" || !rel.toLowerCase().endsWith(".md")) throw new HttpError(400, "Expected a .md path");
|
|
476
910
|
safeResolve(root2, rel);
|
|
@@ -480,24 +914,38 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
480
914
|
if (typeof rel !== "string" || !rel.trim()) throw new HttpError(400, "Expected a path");
|
|
481
915
|
const abs = safeResolve(root2, rel);
|
|
482
916
|
if (abs === root2) throw new HttpError(400, "Refusing to delete the project root");
|
|
483
|
-
if (!
|
|
917
|
+
if (!fs10.existsSync(abs)) throw new HttpError(404, "Not found");
|
|
484
918
|
return abs;
|
|
485
919
|
};
|
|
486
920
|
const routes = {
|
|
487
921
|
"GET /api/info": () => {
|
|
488
922
|
const c = config();
|
|
489
|
-
return {
|
|
923
|
+
return {
|
|
924
|
+
name: path10.basename(root2),
|
|
925
|
+
mainDoc: c.mainDoc ?? null,
|
|
926
|
+
sourceLang: sourceLang(),
|
|
927
|
+
langs: c.langs.filter((l) => l !== sourceLang()),
|
|
928
|
+
langNames: Object.fromEntries(c.langs.map((l) => [l, langName(l, c)])),
|
|
929
|
+
git: isGitRepo(root2),
|
|
930
|
+
proposals: proposalsState(root2),
|
|
931
|
+
author: authorName(root2)
|
|
932
|
+
};
|
|
490
933
|
},
|
|
491
934
|
"PUT /api/config": (_, body) => {
|
|
492
935
|
const c = config();
|
|
493
|
-
if (Array.isArray(body.langs)) c.langs = body.langs.filter(
|
|
936
|
+
if (Array.isArray(body.langs)) c.langs = body.langs.filter(isLangCode);
|
|
494
937
|
if (isLang(body.sourceLang) && body.sourceLang !== sourceLang()) {
|
|
495
938
|
if (!c.mainDoc) throw new HttpError(400, "No main document configured; run `mdrules init`");
|
|
496
|
-
applyRulesToFile(
|
|
939
|
+
applyRulesToFile(path10.join(root2, c.mainDoc), void 0, body.sourceLang);
|
|
497
940
|
}
|
|
498
941
|
writeConfig(root2, c);
|
|
499
942
|
return { ...c, langs: c.langs.filter((l) => l !== sourceLang()) };
|
|
500
943
|
},
|
|
944
|
+
"PUT /api/proposals": (_, body) => {
|
|
945
|
+
if (body.active) activateProposals(root2, body.layout === "structured" ? "structured" : "general", config().mainDoc);
|
|
946
|
+
else deactivateProposals(root2, config().mainDoc);
|
|
947
|
+
return proposalsState(root2);
|
|
948
|
+
},
|
|
501
949
|
"GET /api/settings": () => readSettings(),
|
|
502
950
|
"PUT /api/settings": (_, body) => writeSettings(body),
|
|
503
951
|
"GET /api/tree": (url2) => {
|
|
@@ -510,10 +958,10 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
510
958
|
"GET /api/file": (url2) => {
|
|
511
959
|
const rel = mdPath(url2.searchParams.get("path"));
|
|
512
960
|
const l = viewLang(url2.searchParams.get("lang"));
|
|
513
|
-
const source =
|
|
961
|
+
const source = fs10.readFileSync(safeResolve(root2, rel), "utf8");
|
|
514
962
|
if (l) {
|
|
515
963
|
const tPath = translationPath(root2, l, rel);
|
|
516
|
-
if (
|
|
964
|
+
if (fs10.existsSync(tPath)) return { path: rel, content: fs10.readFileSync(tPath, "utf8"), target: "translation", status: fileStatus(root2, l, rel) };
|
|
517
965
|
return { path: rel, content: source, target: "source", status: "missing" };
|
|
518
966
|
}
|
|
519
967
|
return { path: rel, content: source, target: "source", status: null };
|
|
@@ -522,31 +970,40 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
522
970
|
const rel = mdPath(body.path);
|
|
523
971
|
const l = viewLang(body.lang);
|
|
524
972
|
const file = body.target === "translation" && l ? translationPath(root2, l, rel) : safeResolve(root2, rel);
|
|
525
|
-
|
|
526
|
-
|
|
973
|
+
fs10.mkdirSync(path10.dirname(file), { recursive: true });
|
|
974
|
+
fs10.writeFileSync(file, String(body.content ?? ""));
|
|
527
975
|
return { ok: true, status: l ? fileStatus(root2, l, rel) : null };
|
|
528
976
|
},
|
|
529
977
|
"POST /api/file": (_, body) => {
|
|
530
978
|
const rel = mdPath(body.path);
|
|
531
979
|
const file = safeResolve(root2, rel);
|
|
532
|
-
if (
|
|
533
|
-
|
|
534
|
-
const title =
|
|
535
|
-
|
|
980
|
+
if (fs10.existsSync(file)) throw new HttpError(409, "File already exists");
|
|
981
|
+
fs10.mkdirSync(path10.dirname(file), { recursive: true });
|
|
982
|
+
const title = path10.basename(rel, ".md").replace(/^\d+[-_]/, "").replace(/[-_]/g, " ");
|
|
983
|
+
fs10.writeFileSync(file, `# ${title.charAt(0).toUpperCase() + title.slice(1)}
|
|
536
984
|
|
|
537
985
|
`);
|
|
538
986
|
return { ok: true, path: rel };
|
|
539
987
|
},
|
|
988
|
+
// Only a mkdir: nothing else is touched, so editors that have the project open are unaffected.
|
|
989
|
+
"POST /api/dir": (_, body) => {
|
|
990
|
+
if (typeof body.path !== "string" || !body.path.trim()) throw new HttpError(400, "Expected a path");
|
|
991
|
+
const dir = safeResolve(root2, body.path);
|
|
992
|
+
if (dir === root2) throw new HttpError(400, "Expected a path");
|
|
993
|
+
if (fs10.existsSync(dir)) throw new HttpError(409, "A file or folder with that name already exists");
|
|
994
|
+
fs10.mkdirSync(dir, { recursive: true });
|
|
995
|
+
return { ok: true, path: toPosix(path10.relative(root2, dir)) };
|
|
996
|
+
},
|
|
540
997
|
"GET /api/delete-preview": (url2) => {
|
|
541
998
|
const abs = deletable(url2.searchParams.get("path"));
|
|
542
|
-
if (!
|
|
543
|
-
const files =
|
|
999
|
+
if (!fs10.statSync(abs).isDirectory()) return { kind: "file", files: 1, md: 1 };
|
|
1000
|
+
const files = fs10.readdirSync(abs, { recursive: true, withFileTypes: true }).filter((e) => e.isFile());
|
|
544
1001
|
return { kind: "dir", files: files.length, md: files.filter((e) => e.name.toLowerCase().endsWith(".md")).length };
|
|
545
1002
|
},
|
|
546
1003
|
"DELETE /api/path": (url2) => {
|
|
547
|
-
const rel = toPosix(
|
|
548
|
-
|
|
549
|
-
for (const l of config().langs)
|
|
1004
|
+
const rel = toPosix(path10.relative(root2, deletable(url2.searchParams.get("path"))));
|
|
1005
|
+
fs10.rmSync(safeResolve(root2, rel), { recursive: true });
|
|
1006
|
+
for (const l of config().langs) fs10.rmSync(translationPath(root2, l, rel), { recursive: true, force: true });
|
|
550
1007
|
return { ok: true };
|
|
551
1008
|
},
|
|
552
1009
|
"POST /api/ignore": (_, body) => {
|
|
@@ -555,21 +1012,48 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
555
1012
|
return { ok: true };
|
|
556
1013
|
},
|
|
557
1014
|
"GET /api/diff": (url2) => ({ diff: gitDiff(root2, mdPath(url2.searchParams.get("path"))) }),
|
|
558
|
-
"GET /api/dashboard": () =>
|
|
1015
|
+
"GET /api/dashboard": () => {
|
|
1016
|
+
const sdd = summarizeSdd(root2);
|
|
1017
|
+
const shown = sddPrefixes(sdd);
|
|
1018
|
+
return { docs: summarize(root2).filter((d) => !shown.some((p3) => d.path.startsWith(p3))), sdd };
|
|
1019
|
+
},
|
|
559
1020
|
"GET /api/rules": () => {
|
|
560
1021
|
const mainDoc = readConfig(root2)?.mainDoc;
|
|
561
|
-
const
|
|
562
|
-
return { rules: listRules(), applied: appliedRuleIds(
|
|
1022
|
+
const text3 = mainDoc && fs10.existsSync(path10.join(root2, mainDoc)) ? fs10.readFileSync(path10.join(root2, mainDoc), "utf8") : "";
|
|
1023
|
+
return { rules: listRules(), applied: appliedRuleIds(text3), mainDoc: mainDoc ?? null };
|
|
563
1024
|
},
|
|
564
1025
|
"PUT /api/rules": (_, body) => saveRule(body),
|
|
565
1026
|
"DELETE /api/rules": (url2) => {
|
|
566
1027
|
deleteRule(url2.searchParams.get("id") ?? "");
|
|
567
1028
|
return { ok: true };
|
|
568
1029
|
},
|
|
1030
|
+
// Global skills: reads and writes are limited to SKILL.md of discovered skill folders.
|
|
1031
|
+
"GET /api/skills": () => ({ skills: listSkills(root2), sources: skillSources(), targets: projectTargets(root2) }),
|
|
1032
|
+
"PUT /api/skills/tags": (_, body) => {
|
|
1033
|
+
if (typeof body.name !== "string" || !Array.isArray(body.tags)) throw new HttpError(400, "Expected name and tags");
|
|
1034
|
+
return { tags: setSkillTags(body.name, body.tags.map(String)) };
|
|
1035
|
+
},
|
|
1036
|
+
"GET /api/skills/file": (url2) => {
|
|
1037
|
+
const skill = getSkill(root2, url2.searchParams.get("key"));
|
|
1038
|
+
return { path: skill.key, content: fs10.readFileSync(path10.join(skill.key, "SKILL.md"), "utf8"), target: "source", status: null };
|
|
1039
|
+
},
|
|
1040
|
+
"PUT /api/skills/file": (_, body) => {
|
|
1041
|
+
const skill = getSkill(root2, body.key);
|
|
1042
|
+
if (skill.managed) throw new HttpError(403, "This skill is managed by the app that installed it");
|
|
1043
|
+
fs10.writeFileSync(path10.join(skill.key, "SKILL.md"), String(body.content ?? ""));
|
|
1044
|
+
return { ok: true, status: null };
|
|
1045
|
+
},
|
|
1046
|
+
"POST /api/skills/install": (_, body) => {
|
|
1047
|
+
const targets = (Array.isArray(body.targets) ? body.targets : []).filter((t) => typeof t === "string" && t in PROJECT_TARGETS);
|
|
1048
|
+
if (!targets.length) throw new HttpError(400, "Expected at least one target");
|
|
1049
|
+
const result = installSkill2(root2, getSkill(root2, body.key), targets, !!body.overwrite);
|
|
1050
|
+
if (result.conflict) throw new HttpError(409, result.conflict.join(", "));
|
|
1051
|
+
return result;
|
|
1052
|
+
},
|
|
569
1053
|
"POST /api/rules/apply": (_, body) => {
|
|
570
1054
|
const mainDoc = readConfig(root2)?.mainDoc;
|
|
571
1055
|
if (!mainDoc) throw new HttpError(400, "No main document configured; run `mdrules init`");
|
|
572
|
-
applyRulesToFile(
|
|
1056
|
+
applyRulesToFile(path10.join(root2, mainDoc), body.ids ?? []);
|
|
573
1057
|
return { ok: true };
|
|
574
1058
|
}
|
|
575
1059
|
};
|
|
@@ -601,13 +1085,13 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
601
1085
|
res.writeHead(404).end();
|
|
602
1086
|
return;
|
|
603
1087
|
}
|
|
604
|
-
const type = IMAGE_MIME[
|
|
605
|
-
if (!type || !
|
|
1088
|
+
const type = IMAGE_MIME[path10.extname(file).toLowerCase()];
|
|
1089
|
+
if (!type || !fs10.existsSync(file) || !fs10.statSync(file).isFile()) {
|
|
606
1090
|
res.writeHead(404).end();
|
|
607
1091
|
return;
|
|
608
1092
|
}
|
|
609
1093
|
res.writeHead(200, { "content-type": type, "cache-control": "no-cache" });
|
|
610
|
-
|
|
1094
|
+
fs10.createReadStream(file).pipe(res);
|
|
611
1095
|
}
|
|
612
1096
|
const actualPort = await listen(server, port);
|
|
613
1097
|
const wss = new WebSocketServer({ server, path: "/ws", verifyClient: ({ req }) => isLocalRequest(req) });
|
|
@@ -615,17 +1099,22 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
615
1099
|
const data = JSON.stringify(msg);
|
|
616
1100
|
for (const client of wss.clients) client.send(data);
|
|
617
1101
|
};
|
|
1102
|
+
const skips = watchSkips(root2);
|
|
618
1103
|
watch(root2, {
|
|
619
1104
|
ignoreInitial: true,
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
1105
|
+
// Unreadable folders (e.g. caches owned by another user) are skipped instead of failing.
|
|
1106
|
+
ignorePermissionErrors: true,
|
|
1107
|
+
ignored: (p3, stats) => {
|
|
1108
|
+
const rel = toPosix(path10.relative(root2, p3));
|
|
1109
|
+
if (rel === ".mdrules") return false;
|
|
1110
|
+
if (rel.startsWith(".mdrules/")) return !rel.startsWith(".mdrules/i18n") && rel !== ".mdrules/state.json" && rel !== ".mdrules/config.json";
|
|
1111
|
+
return skips(rel, stats ? stats.isDirectory() : void 0);
|
|
623
1112
|
}
|
|
624
|
-
}).on("all", (event, file) => {
|
|
625
|
-
const abs =
|
|
1113
|
+
}).on("error", (err) => console.warn(pc.yellow(` file watcher: ${err.message}`))).on("all", (event, file) => {
|
|
1114
|
+
const abs = path10.resolve(file);
|
|
626
1115
|
if (!abs.endsWith(".md") && !abs.endsWith(".json") && !abs.endsWith(".mdrulesignore") && event !== "addDir" && event !== "unlinkDir") return;
|
|
627
|
-
let rel = toPosix(
|
|
628
|
-
const i18n = toPosix(
|
|
1116
|
+
let rel = toPosix(path10.relative(root2, abs));
|
|
1117
|
+
const i18n = toPosix(path10.relative(root2, path10.join(stateDir(root2), "i18n")));
|
|
629
1118
|
if (rel.startsWith(i18n + "/")) rel = rel.split("/").slice(3).join("/");
|
|
630
1119
|
broadcast({ type: "fs", event, path: rel });
|
|
631
1120
|
});
|
|
@@ -633,7 +1122,7 @@ async function startServer({ root: root2, port, open: openBrowser }) {
|
|
|
633
1122
|
console.log(`
|
|
634
1123
|
${pc.bold("mdrules")} ${pc.dim("\u2192")} ${pc.cyan(url)} ${pc.dim(root2)}
|
|
635
1124
|
`);
|
|
636
|
-
if (!
|
|
1125
|
+
if (!fs10.existsSync(WEB_DIR)) console.log(pc.yellow(" Web UI not built: run `pnpm build`, or `pnpm dev` and open http://localhost:4320\n"));
|
|
637
1126
|
else if (openBrowser) open(url).catch(() => {
|
|
638
1127
|
});
|
|
639
1128
|
}
|
|
@@ -647,79 +1136,142 @@ function listen(server, port) {
|
|
|
647
1136
|
});
|
|
648
1137
|
}
|
|
649
1138
|
function serveStatic(pathname, res) {
|
|
650
|
-
let file =
|
|
651
|
-
if (!file.startsWith(WEB_DIR) || !
|
|
652
|
-
if (!
|
|
1139
|
+
let file = path10.join(WEB_DIR, path10.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
|
|
1140
|
+
if (!file.startsWith(WEB_DIR) || !fs10.existsSync(file) || fs10.statSync(file).isDirectory()) file = path10.join(WEB_DIR, "index.html");
|
|
1141
|
+
if (!fs10.existsSync(file)) {
|
|
653
1142
|
res.writeHead(404).end("Web UI not built. Run `pnpm build`.");
|
|
654
1143
|
return;
|
|
655
1144
|
}
|
|
656
|
-
res.writeHead(200, { "content-type": MIME[
|
|
657
|
-
|
|
1145
|
+
res.writeHead(200, { "content-type": MIME[path10.extname(file)] ?? "application/octet-stream" });
|
|
1146
|
+
fs10.createReadStream(file).pipe(res);
|
|
658
1147
|
}
|
|
659
1148
|
|
|
660
1149
|
// src/cli/init.ts
|
|
661
|
-
import
|
|
662
|
-
import
|
|
1150
|
+
import fs12 from "fs";
|
|
1151
|
+
import path12 from "path";
|
|
663
1152
|
import * as p from "@clack/prompts";
|
|
664
1153
|
import pc2 from "picocolors";
|
|
665
1154
|
|
|
666
|
-
// src/core/
|
|
667
|
-
import
|
|
668
|
-
import
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
if (e.isDirectory()) {
|
|
678
|
-
fs7.mkdirSync(to, { recursive: true });
|
|
679
|
-
copyTree(from, to, vars, created, root2);
|
|
680
|
-
} else if (!fs7.existsSync(to)) {
|
|
681
|
-
fs7.writeFileSync(to, fill(fs7.readFileSync(from, "utf8"), vars));
|
|
682
|
-
created.push(toPosix(path7.relative(root2, to)));
|
|
683
|
-
}
|
|
1155
|
+
// src/core/method.ts
|
|
1156
|
+
import fs11 from "fs";
|
|
1157
|
+
import path11 from "path";
|
|
1158
|
+
import YAML4 from "yaml";
|
|
1159
|
+
var SKIP = /* @__PURE__ */ new Set(["docs", "node_modules", "dist", "build", "coverage", "tmp"]);
|
|
1160
|
+
function detectAreas(root2) {
|
|
1161
|
+
const globs = [];
|
|
1162
|
+
try {
|
|
1163
|
+
const ws = YAML4.parse(fs11.readFileSync(path11.join(root2, "pnpm-workspace.yaml"), "utf8"), { logLevel: "error" });
|
|
1164
|
+
if (Array.isArray(ws?.packages)) globs.push(...ws.packages);
|
|
1165
|
+
} catch {
|
|
684
1166
|
}
|
|
1167
|
+
try {
|
|
1168
|
+
const ws = JSON.parse(fs11.readFileSync(path11.join(root2, "package.json"), "utf8")).workspaces;
|
|
1169
|
+
globs.push(...Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : []);
|
|
1170
|
+
} catch {
|
|
1171
|
+
}
|
|
1172
|
+
const tops = [...globs.map((g) => String(g).replace(/^\.\//, "").split("/")[0]), ...findSubprojects(scanTree(root2)).map((p3) => p3.split("/")[0])];
|
|
1173
|
+
return [...new Set(tops)].filter((d) => d && !d.includes("*") && !d.startsWith(".") && !d.startsWith("!") && !SKIP.has(d) && fs11.statSync(path11.join(root2, d), { throwIfNoEntry: false })?.isDirectory()).sort();
|
|
1174
|
+
}
|
|
1175
|
+
var KNOWN_PREFIXES = {
|
|
1176
|
+
apps: "APP",
|
|
1177
|
+
app: "APP",
|
|
1178
|
+
services: "SVC",
|
|
1179
|
+
service: "SVC",
|
|
1180
|
+
packages: "PKG",
|
|
1181
|
+
libs: "LIB",
|
|
1182
|
+
lib: "LIB",
|
|
1183
|
+
infra: "INF",
|
|
1184
|
+
infrastructure: "INF",
|
|
1185
|
+
proto: "PRO",
|
|
1186
|
+
backend: "BE",
|
|
1187
|
+
frontend: "FE",
|
|
1188
|
+
web: "WEB",
|
|
1189
|
+
api: "API",
|
|
1190
|
+
mobile: "MOB",
|
|
1191
|
+
tools: "TLS",
|
|
1192
|
+
scripts: "SCR"
|
|
1193
|
+
};
|
|
1194
|
+
function suggestPrefix(dir, used) {
|
|
1195
|
+
const base = KNOWN_PREFIXES[dir.toLowerCase()] ?? (dir.replace(/[^a-z0-9]/gi, "").slice(0, 3).toUpperCase() || "AREA");
|
|
1196
|
+
let prefix = base;
|
|
1197
|
+
for (let i = 2; used.has(prefix) || prefix === "CORE"; i++) prefix = `${base}${i}`;
|
|
1198
|
+
used.add(prefix);
|
|
1199
|
+
return prefix;
|
|
685
1200
|
}
|
|
686
|
-
|
|
1201
|
+
var rootPrefix = (areas) => areas.length ? "CORE" : "T";
|
|
1202
|
+
function scaffoldMethod(root2, mainDoc, areas) {
|
|
687
1203
|
const created = [];
|
|
688
|
-
|
|
689
|
-
copyTree(
|
|
1204
|
+
const name = path11.basename(root2);
|
|
1205
|
+
copyTree(path11.join(TEMPLATES_DIR, "method", "root"), root2, { date: today(), name, prefix: rootPrefix(areas), area: areas.length ? "core" : name }, created, root2);
|
|
1206
|
+
for (const a of areas) {
|
|
1207
|
+
const vars = { date: today(), name, prefix: a.prefix, area: a.dir };
|
|
1208
|
+
const dir = path11.join(root2, a.dir);
|
|
1209
|
+
copyTree(path11.join(TEMPLATES_DIR, "method", "area"), dir, vars, created, root2);
|
|
1210
|
+
let doc = MAIN_DOCS.map((d) => path11.join(dir, d)).find((f) => fs11.existsSync(f));
|
|
1211
|
+
if (!doc) {
|
|
1212
|
+
doc = path11.join(dir, mainDoc);
|
|
1213
|
+
fs11.writeFileSync(doc, fill(fs11.readFileSync(path11.join(TEMPLATES_DIR, "method", "area-main.md"), "utf8"), vars));
|
|
1214
|
+
created.push(path11.posix.join(a.dir, mainDoc));
|
|
1215
|
+
}
|
|
1216
|
+
writeManagedBlock(doc, areaBlock(a), true);
|
|
1217
|
+
}
|
|
690
1218
|
return created;
|
|
691
1219
|
}
|
|
692
|
-
function
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
function installSkill(root2) {
|
|
699
|
-
const dir = path7.join(root2, ".claude", "skills", "mdrules");
|
|
700
|
-
fs7.mkdirSync(dir, { recursive: true });
|
|
701
|
-
fs7.copyFileSync(path7.join(TEMPLATES_DIR, "skill", "SKILL.md"), path7.join(dir, "SKILL.md"));
|
|
1220
|
+
function areaBlock(a) {
|
|
1221
|
+
return [
|
|
1222
|
+
`- Tasks: prefix \`${a.prefix}\` \xB7 index in \`docs/TASKS.md\` \xB7 one file per task in \`docs/tasks/\``,
|
|
1223
|
+
"- Area architecture: `docs/ARCHITECTURE.md` \xB7 cross-cutting: `../docs/architecture/`"
|
|
1224
|
+
].join("\n");
|
|
702
1225
|
}
|
|
703
|
-
function
|
|
704
|
-
const
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
1226
|
+
function methodSection(areas) {
|
|
1227
|
+
const taskDirs = ["docs/tasks", ...areas.map((a) => `${a.dir}/docs/tasks`)].join(" ");
|
|
1228
|
+
const map = areas.length ? [
|
|
1229
|
+
"",
|
|
1230
|
+
"### Map",
|
|
1231
|
+
"",
|
|
1232
|
+
"| Folder | Docs | Task prefix |",
|
|
1233
|
+
"|---|---|---|",
|
|
1234
|
+
...areas.map((a) => `| \`${a.dir}/\` | \`${a.dir}/docs/\` (architecture, tasks) | ${a.prefix} |`),
|
|
1235
|
+
`| \`docs/\` | vision, cross-cutting architecture, phases and cross-area tasks | ${rootPrefix(areas)} |`
|
|
1236
|
+
] : [];
|
|
1237
|
+
return [
|
|
1238
|
+
"## Workflow (mdrules method)",
|
|
1239
|
+
"",
|
|
1240
|
+
"How we work: `docs/README.md`. Vision: `docs/vision.md` \xB7 architecture: `docs/architecture/` \xB7 phases: `docs/phases/` \xB7 tasks: `docs/TASKS.md`.",
|
|
1241
|
+
...map,
|
|
1242
|
+
"",
|
|
1243
|
+
"### Session start",
|
|
1244
|
+
"",
|
|
1245
|
+
`1. Find tasks in progress: \`grep -rl "^status: in-progress" --include=*.md ${taskDirs}\`.`,
|
|
1246
|
+
'2. Open the one with the most recent `updated`, read its "Next step" and tell the user in one line where we left off.',
|
|
1247
|
+
"3. If there are none, ask the user what to work on.",
|
|
1248
|
+
"",
|
|
1249
|
+
"### During the session",
|
|
1250
|
+
"",
|
|
1251
|
+
`- 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.`,
|
|
1252
|
+
'- Tick acceptance criteria as they are met and keep "Next step" current.',
|
|
1253
|
+
"- Before replacing a decision, propose it with the reasons; then update it in its one place.",
|
|
1254
|
+
"",
|
|
1255
|
+
"### Tasks",
|
|
1256
|
+
"",
|
|
1257
|
+
"- The user defines tasks: propose them, but create them only when asked (`/mdrules-new-task`).",
|
|
1258
|
+
`- 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.`,
|
|
1259
|
+
"- At the end of a session: `/mdrules-close-session`.",
|
|
1260
|
+
"",
|
|
1261
|
+
"### Documentation without redundancy",
|
|
1262
|
+
"",
|
|
1263
|
+
"- Each fact lives in one place; everything else links to it.",
|
|
1264
|
+
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.",
|
|
1265
|
+
"- Main docs hold orientation, rules and commands only: never decisions or task lists."
|
|
1266
|
+
].join("\n");
|
|
708
1267
|
}
|
|
709
|
-
function
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
} catch {
|
|
716
|
-
return false;
|
|
717
|
-
}
|
|
1268
|
+
function installMethodSkills(root2) {
|
|
1269
|
+
const src = path11.join(TEMPLATES_DIR, "method", "skills");
|
|
1270
|
+
for (const name of fs11.readdirSync(src)) {
|
|
1271
|
+
const dest = path11.join(root2, ".claude", "skills", name);
|
|
1272
|
+
fs11.mkdirSync(dest, { recursive: true });
|
|
1273
|
+
fs11.copyFileSync(path11.join(src, name, "SKILL.md"), path11.join(dest, "SKILL.md"));
|
|
718
1274
|
}
|
|
719
|
-
settings["files.exclude"] = { ...settings["files.exclude"] ?? {}, "**/.mdrules": true };
|
|
720
|
-
fs7.mkdirSync(path7.dirname(file), { recursive: true });
|
|
721
|
-
fs7.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
722
|
-
return true;
|
|
723
1275
|
}
|
|
724
1276
|
|
|
725
1277
|
// src/cli/init.ts
|
|
@@ -731,7 +1283,7 @@ function check(value) {
|
|
|
731
1283
|
return value;
|
|
732
1284
|
}
|
|
733
1285
|
async function chooseMainDoc(root2) {
|
|
734
|
-
const existing = MAIN_DOCS.filter((d) =>
|
|
1286
|
+
const existing = MAIN_DOCS.filter((d) => fs12.existsSync(path12.join(root2, d)));
|
|
735
1287
|
if (existing.length === 2) {
|
|
736
1288
|
return check(
|
|
737
1289
|
await p.select({
|
|
@@ -763,31 +1315,119 @@ async function chooseMainDoc(root2) {
|
|
|
763
1315
|
p.log.success(`Created ${choice}`);
|
|
764
1316
|
return choice;
|
|
765
1317
|
}
|
|
1318
|
+
var WORKFLOW_LABEL = { none: "None", mdrules: "mdrules", openspec: "OpenSpec", speckit: "GitHub Spec Kit" };
|
|
1319
|
+
var WORKFLOW_GUIDE = { none: "", mdrules: "docs/README.md", openspec: "openspec/README.md", speckit: "specs/README.md" };
|
|
1320
|
+
async function chooseAreas(root2) {
|
|
1321
|
+
const candidates = detectAreas(root2);
|
|
1322
|
+
if (!candidates.length) return [];
|
|
1323
|
+
const dirs2 = check(
|
|
1324
|
+
await p.multiselect({
|
|
1325
|
+
message: "Monorepo detected. Which folders are areas with their own docs and tasks? (none: one docs/ for everything)",
|
|
1326
|
+
options: candidates.map((d) => ({ value: d, label: `${d}/` })),
|
|
1327
|
+
initialValues: candidates,
|
|
1328
|
+
required: false
|
|
1329
|
+
})
|
|
1330
|
+
);
|
|
1331
|
+
const used = /* @__PURE__ */ new Set();
|
|
1332
|
+
const areas = dirs2.map((dir) => ({ dir, prefix: suggestPrefix(dir, used) }));
|
|
1333
|
+
if (!areas.length) return areas;
|
|
1334
|
+
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 }));
|
|
1335
|
+
if (ok) return areas;
|
|
1336
|
+
for (const a of areas) {
|
|
1337
|
+
a.prefix = check(
|
|
1338
|
+
await p.text({
|
|
1339
|
+
message: `Task prefix for ${a.dir}/`,
|
|
1340
|
+
initialValue: a.prefix,
|
|
1341
|
+
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"
|
|
1342
|
+
})
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
return areas;
|
|
1346
|
+
}
|
|
1347
|
+
async function chooseWorkflow(root2, mainDoc, previous) {
|
|
1348
|
+
const detected = detectWorkflow(root2);
|
|
1349
|
+
if (detected) {
|
|
1350
|
+
p.log.info(`${WORKFLOW_LABEL[detected]} found: mdrules shows it in the dashboard.`);
|
|
1351
|
+
return { workflow: detected };
|
|
1352
|
+
}
|
|
1353
|
+
for (; ; ) {
|
|
1354
|
+
const choice = check(
|
|
1355
|
+
await p.select({
|
|
1356
|
+
message: "Create a base structure for specs and planning?",
|
|
1357
|
+
options: [
|
|
1358
|
+
{ value: "none", label: "None" },
|
|
1359
|
+
{ value: "mdrules", label: "mdrules method", hint: "vision, architecture, phases and one file per task; per area in monorepos" },
|
|
1360
|
+
{ value: "openspec", label: "OpenSpec", hint: "specs + proposed changes, good for existing projects" },
|
|
1361
|
+
{ value: "speckit", label: "GitHub Spec Kit", hint: "constitution \u2192 spec \u2192 plan \u2192 tasks, per feature" }
|
|
1362
|
+
],
|
|
1363
|
+
initialValue: previous ?? "none"
|
|
1364
|
+
})
|
|
1365
|
+
);
|
|
1366
|
+
if (choice === "none") return { workflow: choice };
|
|
1367
|
+
if (choice === "mdrules") {
|
|
1368
|
+
const areas = await chooseAreas(root2);
|
|
1369
|
+
const created = scaffoldMethod(root2, mainDoc, areas);
|
|
1370
|
+
installMethodSkills(root2);
|
|
1371
|
+
const big = areas.length > 0 || flattenFiles(scanTree(root2)).length > 40;
|
|
1372
|
+
const layout = big ? check(
|
|
1373
|
+
await p.select({
|
|
1374
|
+
message: "The AI can write proposals you answer by ticking options and commenting. How should they be organized?",
|
|
1375
|
+
options: [
|
|
1376
|
+
{ value: "structured", label: "By topic and area", hint: "docs/proposals/auth/\u2026, services/docs/proposals/\u2026" },
|
|
1377
|
+
{ value: "general", label: "One folder", hint: "docs/proposals/" }
|
|
1378
|
+
]
|
|
1379
|
+
})
|
|
1380
|
+
) : "general";
|
|
1381
|
+
activateProposals(root2, layout, mainDoc);
|
|
1382
|
+
if (created.length) p.log.success(`Created ${created.length} files: ${created.slice(0, 8).join(", ")}${created.length > 8 ? "\u2026" : ""}`);
|
|
1383
|
+
p.log.info(`How it works: ${pc2.cyan("docs/README.md")}. Skills: /mdrules-new-task, /mdrules-close-session.`);
|
|
1384
|
+
return { workflow: choice, section: methodSection(areas) };
|
|
1385
|
+
}
|
|
1386
|
+
let official = true;
|
|
1387
|
+
if (choice === "speckit" && !hasUv()) {
|
|
1388
|
+
const next = check(
|
|
1389
|
+
await p.select({
|
|
1390
|
+
message: "Spec Kit needs uv (Python) to install its AI commands, and uv was not found.",
|
|
1391
|
+
options: [
|
|
1392
|
+
{ value: "back", label: "Choose another option" },
|
|
1393
|
+
{ value: "skeleton", label: "Create the structure only", hint: "the guide explains how to install Spec Kit later" }
|
|
1394
|
+
]
|
|
1395
|
+
})
|
|
1396
|
+
);
|
|
1397
|
+
if (next === "back") continue;
|
|
1398
|
+
official = false;
|
|
1399
|
+
}
|
|
1400
|
+
const initial = mainDoc === "AGENTS.md" ? "codex" : "claude";
|
|
1401
|
+
const agentOptions = Object.keys(SDD_AGENTS).map((a) => ({ value: a, label: SDD_AGENTS[a].label }));
|
|
1402
|
+
const agents = !official ? [initial] : 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 }))];
|
|
1403
|
+
const spin = p.spinner();
|
|
1404
|
+
spin.start(official ? `Installing ${WORKFLOW_LABEL[choice]}\u2026` : "Creating the structure\u2026");
|
|
1405
|
+
const res = installWorkflow(root2, choice, agents, official);
|
|
1406
|
+
spin.stop(res.official ? `${WORKFLOW_LABEL[choice]} installed` : "Structure created");
|
|
1407
|
+
if (official && !res.official) p.log.warn(`Couldn't run the ${WORKFLOW_LABEL[choice]} installer:
|
|
1408
|
+
${pc2.dim(res.error ?? "")}
|
|
1409
|
+
Created the structure only.`);
|
|
1410
|
+
if (!res.official) p.log.info(`To add the AI commands later, see ${pc2.cyan(WORKFLOW_GUIDE[choice])}.`);
|
|
1411
|
+
if (res.ignored.length) p.log.info(`Agent commands hidden in mdrules (${res.ignored.length} folders); the eye button in the sidebar shows them.`);
|
|
1412
|
+
return { workflow: choice };
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
766
1415
|
async function runInit(root2) {
|
|
767
1416
|
p.intro(pc2.bgCyan(pc2.black(" mdrules ")));
|
|
768
1417
|
const previous = readConfig(root2);
|
|
769
1418
|
const mainDoc = await chooseMainDoc(root2);
|
|
770
|
-
const mainFile =
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
775
|
-
const mainText = fs8.readFileSync(mainFile, "utf8");
|
|
1419
|
+
const mainFile = path12.join(root2, mainDoc);
|
|
1420
|
+
const { workflow, section: section2 } = await chooseWorkflow(root2, mainDoc, previous?.workflow);
|
|
1421
|
+
applyWorkflowToFile(mainFile, workflow, section2);
|
|
1422
|
+
const mainText = fs12.readFileSync(mainFile, "utf8");
|
|
776
1423
|
const sourceLang = check(
|
|
777
1424
|
await p.select({
|
|
778
|
-
message: "
|
|
1425
|
+
message: "Language you work in (every .md file is written in it)",
|
|
779
1426
|
options: LANGS.map((l) => ({ value: l, label: LANG_NAMES[l], hint: l === "en" ? "default" : void 0 })),
|
|
780
1427
|
initialValue: sourceLangOf(mainText)
|
|
781
1428
|
})
|
|
782
1429
|
);
|
|
783
|
-
const langs =
|
|
784
|
-
await p.multiselect({
|
|
785
|
-
message: "Translation languages (view and edit docs in them; the source files never change language)",
|
|
786
|
-
options: LANGS.filter((l) => l !== sourceLang).map((l) => ({ value: l, label: LANG_NAMES[l] })),
|
|
787
|
-
initialValues: (previous?.langs ?? []).filter((l) => l !== sourceLang),
|
|
788
|
-
required: false
|
|
789
|
-
})
|
|
790
|
-
);
|
|
1430
|
+
const langs = (previous?.langs ?? []).filter((l) => l !== sourceLang);
|
|
791
1431
|
const rules = listRules();
|
|
792
1432
|
const applied = appliedRuleIds(mainText);
|
|
793
1433
|
const selected = check(
|
|
@@ -809,8 +1449,8 @@ async function runInit(root2) {
|
|
|
809
1449
|
})
|
|
810
1450
|
);
|
|
811
1451
|
for (const dir of targets) {
|
|
812
|
-
const doc = MAIN_DOCS.find((d) =>
|
|
813
|
-
applyRulesToFile(
|
|
1452
|
+
const doc = MAIN_DOCS.find((d) => fs12.existsSync(path12.join(root2, dir, d)));
|
|
1453
|
+
applyRulesToFile(path12.join(root2, dir, doc), selected, sourceLang);
|
|
814
1454
|
}
|
|
815
1455
|
}
|
|
816
1456
|
ensureGitignore(root2);
|
|
@@ -819,19 +1459,84 @@ async function runInit(root2) {
|
|
|
819
1459
|
if (hide && !hideInVsCode(root2)) p.log.warn('.vscode/settings.json has comments; add "**/.mdrules": true to files.exclude by hand.');
|
|
820
1460
|
}
|
|
821
1461
|
installSkill(root2);
|
|
822
|
-
const config = { mainDoc, langs, initializedAt: previous?.initializedAt ?? (/* @__PURE__ */ new Date()).toISOString() };
|
|
1462
|
+
const config = { mainDoc, langs, langNames: previous?.langNames, workflow, proposals: readConfig(root2)?.proposals, initializedAt: previous?.initializedAt ?? (/* @__PURE__ */ new Date()).toISOString() };
|
|
823
1463
|
writeConfig(root2, config);
|
|
824
|
-
p.outro(`Ready. Skill installed in ${pc2.cyan(".claude/skills/mdrules")}`);
|
|
1464
|
+
p.outro(`Ready. Skill installed in ${pc2.cyan(".claude/skills/mdrules")}. To review the docs in another language: ${pc2.cyan("mdrules translate")}`);
|
|
825
1465
|
return config;
|
|
826
1466
|
}
|
|
827
1467
|
|
|
1468
|
+
// src/cli/translate.ts
|
|
1469
|
+
import * as p2 from "@clack/prompts";
|
|
1470
|
+
import pc3 from "picocolors";
|
|
1471
|
+
function check2(value) {
|
|
1472
|
+
if (p2.isCancel(value)) {
|
|
1473
|
+
p2.cancel("Cancelled.");
|
|
1474
|
+
process.exit(0);
|
|
1475
|
+
}
|
|
1476
|
+
return value;
|
|
1477
|
+
}
|
|
1478
|
+
var OTHER = "__other__";
|
|
1479
|
+
async function runTranslate(root2) {
|
|
1480
|
+
const config = readConfig(root2);
|
|
1481
|
+
if (!config) {
|
|
1482
|
+
console.log(`Run ${pc3.cyan("mdrules init")} first.`);
|
|
1483
|
+
process.exitCode = 1;
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
p2.intro(pc3.bgCyan(pc3.black(" mdrules translate ")));
|
|
1487
|
+
const source = projectSourceLang(root2, config.mainDoc);
|
|
1488
|
+
const custom = config.langs.filter((l) => !isLang(l));
|
|
1489
|
+
const picked = check2(
|
|
1490
|
+
await p2.multiselect({
|
|
1491
|
+
message: `Languages to review the docs in (the files stay in ${langName(source)})`,
|
|
1492
|
+
options: [
|
|
1493
|
+
...LANGS.filter((l) => l !== source).map((l) => ({ value: l, label: LANG_NAMES[l] })),
|
|
1494
|
+
...custom.map((l) => ({ value: l, label: `${langName(l, config)} (${l})`, hint: "interface in English" })),
|
|
1495
|
+
{ value: OTHER, label: "Another language\u2026", hint: "type it; the interface stays in English" }
|
|
1496
|
+
],
|
|
1497
|
+
initialValues: config.langs.filter((l) => l !== source),
|
|
1498
|
+
required: false
|
|
1499
|
+
})
|
|
1500
|
+
);
|
|
1501
|
+
const langs = picked.filter((l) => l !== OTHER);
|
|
1502
|
+
const names = { ...config.langNames ?? {} };
|
|
1503
|
+
if (picked.includes(OTHER)) {
|
|
1504
|
+
for (; ; ) {
|
|
1505
|
+
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();
|
|
1506
|
+
const code = check2(
|
|
1507
|
+
await p2.text({
|
|
1508
|
+
message: `Short code for ${name} (folder name of its translations)`,
|
|
1509
|
+
initialValue: name.slice(0, 2).toLowerCase(),
|
|
1510
|
+
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
|
|
1511
|
+
})
|
|
1512
|
+
).toLowerCase();
|
|
1513
|
+
langs.push(code);
|
|
1514
|
+
names[code] = name;
|
|
1515
|
+
if (!check2(await p2.confirm({ message: "Add another language?", initialValue: false }))) break;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
writeConfig(root2, { ...config, langs, langNames: names });
|
|
1519
|
+
if (!langs.length) {
|
|
1520
|
+
p2.outro("No translation languages: mdrules shows the docs in their source language only.");
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
p2.outro(
|
|
1524
|
+
[
|
|
1525
|
+
`Translations: ${langs.map((l) => langName(l, { ...config, langNames: names })).join(", ")}.`,
|
|
1526
|
+
`Ask your agent for ${pc3.cyan("/mdrules sync")} to translate the docs; then pick the language at the top of mdrules.`,
|
|
1527
|
+
...langs.some((l) => !isLang(l)) ? ["Menus stay in English for languages typed by hand."] : []
|
|
1528
|
+
].join("\n")
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
828
1532
|
// src/cli/index.ts
|
|
829
1533
|
var HELP = `mdrules \u2014 visual editor for your project's Markdown
|
|
830
1534
|
|
|
831
1535
|
Usage
|
|
832
1536
|
mdrules init if needed, then open the editor
|
|
833
|
-
mdrules init (re)run the setup: main doc,
|
|
1537
|
+
mdrules init (re)run the setup: main doc, specs structure, rules, languages
|
|
834
1538
|
mdrules serve open the editor without running init
|
|
1539
|
+
mdrules translate choose languages to review the docs in (or: mdrules --translate)
|
|
835
1540
|
mdrules sync --status [--json] translations + files not in the source language
|
|
836
1541
|
mdrules sync --mark [--lang xx] <path...>|--all record translations as in sync
|
|
837
1542
|
mdrules sync --keep <path...> keep files in their current language
|
|
@@ -851,6 +1556,7 @@ var root = process.env.MDRULES_ROOT ?? process.cwd();
|
|
|
851
1556
|
async function main() {
|
|
852
1557
|
if (flag("--help") || flag("-h")) return console.log(HELP);
|
|
853
1558
|
if (command === "sync") return sync();
|
|
1559
|
+
if (command === "translate" || flag("--translate")) return runTranslate(root);
|
|
854
1560
|
if (command === "init") {
|
|
855
1561
|
await runInit(root);
|
|
856
1562
|
return;
|
|
@@ -869,26 +1575,26 @@ function sync() {
|
|
|
869
1575
|
const report = syncReport(root);
|
|
870
1576
|
const paths = (after) => args.slice(args.indexOf(after) + 1).filter((a, i, all) => !a.startsWith("--") && all[i - 1] !== "--lang");
|
|
871
1577
|
if (flag("--keep")) {
|
|
872
|
-
for (const
|
|
1578
|
+
for (const p3 of paths("--keep")) {
|
|
873
1579
|
try {
|
|
874
|
-
console.log(`kept ${
|
|
1580
|
+
console.log(`kept ${p3} (${keepLanguage(root, p3)})`);
|
|
875
1581
|
} catch (err) {
|
|
876
|
-
console.error(`skipped ${
|
|
1582
|
+
console.error(`skipped ${p3}: ${err.message}`);
|
|
877
1583
|
}
|
|
878
1584
|
}
|
|
879
1585
|
return;
|
|
880
1586
|
}
|
|
881
1587
|
if (flag("--mark")) {
|
|
882
1588
|
const only = option("--lang");
|
|
883
|
-
const langs = only &&
|
|
1589
|
+
const langs = only && report.langs.includes(only) ? [only] : report.langs;
|
|
884
1590
|
const files = flag("--all") ? [...new Set(report.files.map((f) => f.path))] : paths("--mark");
|
|
885
|
-
for (const
|
|
1591
|
+
for (const p3 of files) {
|
|
886
1592
|
for (const l of langs) {
|
|
887
1593
|
try {
|
|
888
|
-
markSynced(root, l,
|
|
889
|
-
console.log(`synced ${l} ${
|
|
1594
|
+
markSynced(root, l, p3);
|
|
1595
|
+
console.log(`synced ${l} ${p3}`);
|
|
890
1596
|
} catch (err) {
|
|
891
|
-
if (!flag("--all")) console.error(`skipped ${l} ${
|
|
1597
|
+
if (!flag("--all")) console.error(`skipped ${l} ${p3}: ${err.message}`);
|
|
892
1598
|
}
|
|
893
1599
|
}
|
|
894
1600
|
}
|