reffy-cli 0.5.1 → 0.6.1
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 +5 -7
- package/dist/cli.js +161 -2
- package/dist/diagram.d.ts +21 -0
- package/dist/diagram.js +110 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -8,10 +8,8 @@ Reffy is intended as an ideation layer for spec-driven development (SDD) in stra
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
Recommended usage in any repo:
|
|
12
|
-
|
|
13
11
|
```bash
|
|
14
|
-
npm install -g reffy-cli
|
|
12
|
+
npm install -g reffy-cli@latest
|
|
15
13
|
```
|
|
16
14
|
|
|
17
15
|
## Quickstart (CLI-only)
|
|
@@ -21,10 +19,6 @@ Inside your project:
|
|
|
21
19
|
```bash
|
|
22
20
|
reffy init
|
|
23
21
|
reffy bootstrap
|
|
24
|
-
reffy doctor
|
|
25
|
-
reffy reindex
|
|
26
|
-
reffy validate
|
|
27
|
-
reffy summarize
|
|
28
22
|
```
|
|
29
23
|
|
|
30
24
|
Command summary:
|
|
@@ -35,6 +29,7 @@ Command summary:
|
|
|
35
29
|
- `reffy reindex`: reconciles `.references/manifest.json` with `.references/artifacts` by adding missing files and removing stale entries.
|
|
36
30
|
- `reffy validate`: validates `.references/manifest.json` against manifest v1 contract.
|
|
37
31
|
- `reffy summarize`: generates a read-only handoff summary from indexed artifacts.
|
|
32
|
+
- `reffy diagram render`: renders Mermaid diagrams as SVG or ASCII, including spec-aware generation from OpenSpec `spec.md`.
|
|
38
33
|
|
|
39
34
|
Output modes:
|
|
40
35
|
|
|
@@ -51,6 +46,9 @@ reffy doctor --output text
|
|
|
51
46
|
reffy doctor --output json
|
|
52
47
|
reffy summarize --output text
|
|
53
48
|
reffy summarize --output json
|
|
49
|
+
reffy diagram render --stdin --format svg < diagram.mmd
|
|
50
|
+
reffy diagram render --input openspec/specs/auth/spec.md --format ascii
|
|
51
|
+
reffy diagram render --input openspec/specs/auth/spec.md --format svg --output .references/artifacts/auth-spec.svg
|
|
54
52
|
```
|
|
55
53
|
|
|
56
54
|
## Using Reffy With SDD Frameworks (OpenSpec Example)
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { renderDiagram } from "./diagram.js";
|
|
4
5
|
import { runDoctor } from "./doctor.js";
|
|
5
6
|
import { ReferencesStore } from "./storage.js";
|
|
6
7
|
import { summarizeArtifacts } from "./summarize.js";
|
|
@@ -200,8 +201,128 @@ function usage() {
|
|
|
200
201
|
" reindex Scan .references/artifacts and add missing files to manifest.",
|
|
201
202
|
" validate Validate .references/manifest.json against manifest v1 contract.",
|
|
202
203
|
" summarize Generate a read-only summary of indexed Reffy artifacts.",
|
|
204
|
+
" diagram Render Mermaid diagrams (supports SVG and ASCII).",
|
|
203
205
|
].join("\n");
|
|
204
206
|
}
|
|
207
|
+
function diagramUsage() {
|
|
208
|
+
return [
|
|
209
|
+
"Usage: reffy diagram render [--repo PATH] [--input PATH|--stdin] [--format svg|ascii] [--output PATH]",
|
|
210
|
+
"",
|
|
211
|
+
"Options:",
|
|
212
|
+
" --input PATH Read Mermaid (or OpenSpec spec.md) from file",
|
|
213
|
+
" --stdin Read Mermaid text from stdin",
|
|
214
|
+
" --format VALUE Output format: svg (default) or ascii",
|
|
215
|
+
" --output PATH Write rendered result to file instead of stdout",
|
|
216
|
+
" --theme NAME Apply built-in SVG theme (beautiful-mermaid)",
|
|
217
|
+
" --bg HEX SVG color override for background",
|
|
218
|
+
" --fg HEX SVG color override for foreground",
|
|
219
|
+
" --line HEX SVG color override for connector lines",
|
|
220
|
+
" --accent HEX SVG color override for accents/arrowheads",
|
|
221
|
+
" --muted HEX SVG color override for secondary text",
|
|
222
|
+
" --surface HEX SVG color override for node surfaces",
|
|
223
|
+
" --border HEX SVG color override for node borders",
|
|
224
|
+
" --font NAME SVG font family override",
|
|
225
|
+
].join("\n");
|
|
226
|
+
}
|
|
227
|
+
function parseDiagramArgs(argv) {
|
|
228
|
+
const repoRoot = parseRepoArg(argv);
|
|
229
|
+
const args = {
|
|
230
|
+
repoRoot,
|
|
231
|
+
stdin: false,
|
|
232
|
+
format: "svg",
|
|
233
|
+
};
|
|
234
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
235
|
+
const arg = argv[i];
|
|
236
|
+
if (arg === "--repo") {
|
|
237
|
+
i += 1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (arg.startsWith("--repo="))
|
|
241
|
+
continue;
|
|
242
|
+
if (arg === "--stdin") {
|
|
243
|
+
args.stdin = true;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (arg === "--input") {
|
|
247
|
+
const value = argv[i + 1];
|
|
248
|
+
if (!value)
|
|
249
|
+
throw new Error("--input requires a path");
|
|
250
|
+
args.inputPath = value;
|
|
251
|
+
i += 1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (arg.startsWith("--input=")) {
|
|
255
|
+
const value = arg.split("=", 2)[1];
|
|
256
|
+
if (!value)
|
|
257
|
+
throw new Error("--input requires a path");
|
|
258
|
+
args.inputPath = value;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (arg === "--output") {
|
|
262
|
+
const value = argv[i + 1];
|
|
263
|
+
if (!value)
|
|
264
|
+
throw new Error("--output requires a path");
|
|
265
|
+
args.outputPath = value;
|
|
266
|
+
i += 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (arg.startsWith("--output=")) {
|
|
270
|
+
const value = arg.split("=", 2)[1];
|
|
271
|
+
if (!value)
|
|
272
|
+
throw new Error("--output requires a path");
|
|
273
|
+
args.outputPath = value;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (arg === "--format") {
|
|
277
|
+
const value = argv[i + 1];
|
|
278
|
+
if (!value)
|
|
279
|
+
throw new Error("--format requires a value: svg|ascii");
|
|
280
|
+
if (value !== "svg" && value !== "ascii")
|
|
281
|
+
throw new Error(`Unsupported format: ${value}. Valid formats: svg, ascii`);
|
|
282
|
+
args.format = value;
|
|
283
|
+
i += 1;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (arg.startsWith("--format=")) {
|
|
287
|
+
const value = arg.split("=", 2)[1];
|
|
288
|
+
if (value !== "svg" && value !== "ascii")
|
|
289
|
+
throw new Error(`Unsupported format: ${value}. Valid formats: svg, ascii`);
|
|
290
|
+
args.format = value;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const keyMap = {
|
|
294
|
+
"--theme": "theme",
|
|
295
|
+
"--bg": "bg",
|
|
296
|
+
"--fg": "fg",
|
|
297
|
+
"--line": "line",
|
|
298
|
+
"--accent": "accent",
|
|
299
|
+
"--muted": "muted",
|
|
300
|
+
"--surface": "surface",
|
|
301
|
+
"--border": "border",
|
|
302
|
+
"--font": "font",
|
|
303
|
+
};
|
|
304
|
+
const directKey = keyMap[arg];
|
|
305
|
+
if (directKey) {
|
|
306
|
+
const value = argv[i + 1];
|
|
307
|
+
if (!value)
|
|
308
|
+
throw new Error(`${arg} requires a value`);
|
|
309
|
+
args[directKey] = value;
|
|
310
|
+
i += 1;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const inline = Object.entries(keyMap).find(([flag]) => arg.startsWith(`${flag}=`));
|
|
314
|
+
if (inline) {
|
|
315
|
+
const [flag, key] = inline;
|
|
316
|
+
const value = arg.slice(flag.length + 1);
|
|
317
|
+
if (!value)
|
|
318
|
+
throw new Error(`${flag} requires a value`);
|
|
319
|
+
args[key] = value;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
throw new Error(`Unknown diagram option: ${arg}`);
|
|
323
|
+
}
|
|
324
|
+
return args;
|
|
325
|
+
}
|
|
205
326
|
function printSection(title, values) {
|
|
206
327
|
console.log(`${title}:`);
|
|
207
328
|
if (values.length === 0) {
|
|
@@ -214,13 +335,51 @@ function printSection(title, values) {
|
|
|
214
335
|
}
|
|
215
336
|
async function main() {
|
|
216
337
|
const [, , command, ...rest] = process.argv;
|
|
217
|
-
const output = parseOutputMode(rest);
|
|
218
|
-
printBanner(output);
|
|
219
338
|
if (!command) {
|
|
220
339
|
console.error(usage());
|
|
221
340
|
return 1;
|
|
222
341
|
}
|
|
342
|
+
if (command === "diagram") {
|
|
343
|
+
const [subcommand, ...diagramArgs] = rest;
|
|
344
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
345
|
+
console.error(diagramUsage());
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
if (subcommand !== "render") {
|
|
349
|
+
console.error(`Unknown diagram subcommand: ${subcommand}`);
|
|
350
|
+
console.error(diagramUsage());
|
|
351
|
+
return 1;
|
|
352
|
+
}
|
|
353
|
+
const parsed = parseDiagramArgs(diagramArgs);
|
|
354
|
+
const rendered = await renderDiagram({
|
|
355
|
+
repoRoot: parsed.repoRoot,
|
|
356
|
+
inputPath: parsed.inputPath,
|
|
357
|
+
stdin: parsed.stdin,
|
|
358
|
+
format: parsed.format,
|
|
359
|
+
outputPath: parsed.outputPath,
|
|
360
|
+
theme: parsed.theme,
|
|
361
|
+
bg: parsed.bg,
|
|
362
|
+
fg: parsed.fg,
|
|
363
|
+
line: parsed.line,
|
|
364
|
+
accent: parsed.accent,
|
|
365
|
+
muted: parsed.muted,
|
|
366
|
+
surface: parsed.surface,
|
|
367
|
+
border: parsed.border,
|
|
368
|
+
font: parsed.font,
|
|
369
|
+
});
|
|
370
|
+
if (parsed.outputPath) {
|
|
371
|
+
console.log(`Wrote ${parsed.format} diagram to ${path.isAbsolute(parsed.outputPath) ? parsed.outputPath : path.join(parsed.repoRoot, parsed.outputPath)}`);
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
process.stdout.write(rendered.content);
|
|
375
|
+
if (!rendered.content.endsWith("\n")) {
|
|
376
|
+
process.stdout.write("\n");
|
|
377
|
+
}
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
const output = parseOutputMode(rest);
|
|
223
381
|
if (command === "init") {
|
|
382
|
+
printBanner(output);
|
|
224
383
|
const repoRoot = parseRepoArg(rest);
|
|
225
384
|
const agents = await initAgents(repoRoot);
|
|
226
385
|
const payload = { status: "ok", command: "init", ...agents };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type DiagramFormat = "svg" | "ascii";
|
|
2
|
+
export interface DiagramRenderRequest {
|
|
3
|
+
repoRoot: string;
|
|
4
|
+
inputPath?: string;
|
|
5
|
+
stdin?: boolean;
|
|
6
|
+
format: DiagramFormat;
|
|
7
|
+
outputPath?: string;
|
|
8
|
+
theme?: string;
|
|
9
|
+
bg?: string;
|
|
10
|
+
fg?: string;
|
|
11
|
+
line?: string;
|
|
12
|
+
accent?: string;
|
|
13
|
+
muted?: string;
|
|
14
|
+
surface?: string;
|
|
15
|
+
border?: string;
|
|
16
|
+
font?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function renderDiagram(request: DiagramRenderRequest): Promise<{
|
|
19
|
+
content: string;
|
|
20
|
+
source_kind: "mermaid" | "spec";
|
|
21
|
+
}>;
|
package/dist/diagram.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { THEMES, renderMermaid, renderMermaidAscii } from "beautiful-mermaid";
|
|
4
|
+
function isLikelyOpenSpecDocument(inputPath, content) {
|
|
5
|
+
if (inputPath && path.basename(inputPath).toLowerCase() === "spec.md")
|
|
6
|
+
return true;
|
|
7
|
+
return /^### Requirement:\s+/m.test(content) && /^#### Scenario:\s+/m.test(content);
|
|
8
|
+
}
|
|
9
|
+
function toNodeId(prefix, index) {
|
|
10
|
+
return `${prefix}${String(index + 1)}`;
|
|
11
|
+
}
|
|
12
|
+
function sanitizeLabel(value) {
|
|
13
|
+
return value.replace(/"/g, "'").replace(/\s+/g, " ").trim();
|
|
14
|
+
}
|
|
15
|
+
function convertSpecToMermaid(specText) {
|
|
16
|
+
const requirementRegex = /^### Requirement:\s*(.+)$/gm;
|
|
17
|
+
const scenarioRegex = /^#### Scenario:\s*(.+)$/gm;
|
|
18
|
+
const requirements = Array.from(specText.matchAll(requirementRegex)).map((match) => ({
|
|
19
|
+
title: sanitizeLabel(match[1] ?? ""),
|
|
20
|
+
index: match.index ?? 0,
|
|
21
|
+
}));
|
|
22
|
+
if (requirements.length === 0) {
|
|
23
|
+
throw new Error("Unable to derive diagram from spec.md: no requirements found.");
|
|
24
|
+
}
|
|
25
|
+
const scenarios = Array.from(specText.matchAll(scenarioRegex)).map((match) => ({
|
|
26
|
+
title: sanitizeLabel(match[1] ?? ""),
|
|
27
|
+
index: match.index ?? 0,
|
|
28
|
+
}));
|
|
29
|
+
const lines = ["graph TD"];
|
|
30
|
+
let scenarioCursor = 0;
|
|
31
|
+
for (let i = 0; i < requirements.length; i += 1) {
|
|
32
|
+
const requirement = requirements[i];
|
|
33
|
+
const nextRequirementIndex = i + 1 < requirements.length ? requirements[i + 1]?.index ?? specText.length : specText.length;
|
|
34
|
+
const requirementId = toNodeId("R", i);
|
|
35
|
+
lines.push(` ${requirementId}["Requirement: ${requirement.title}"]`);
|
|
36
|
+
while (scenarioCursor < scenarios.length) {
|
|
37
|
+
const scenario = scenarios[scenarioCursor];
|
|
38
|
+
if (scenario.index < requirement.index || scenario.index >= nextRequirementIndex) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
const scenarioId = toNodeId("S", scenarioCursor);
|
|
42
|
+
lines.push(` ${scenarioId}["Scenario: ${scenario.title}"]`);
|
|
43
|
+
lines.push(` ${requirementId} --> ${scenarioId}`);
|
|
44
|
+
scenarioCursor += 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
async function readStdinFully() {
|
|
50
|
+
const chunks = [];
|
|
51
|
+
for await (const chunk of process.stdin) {
|
|
52
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
53
|
+
}
|
|
54
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
55
|
+
}
|
|
56
|
+
async function loadSource(request) {
|
|
57
|
+
if (request.stdin && request.inputPath) {
|
|
58
|
+
throw new Error("Provide either --stdin or --input, not both.");
|
|
59
|
+
}
|
|
60
|
+
if (!request.stdin && !request.inputPath) {
|
|
61
|
+
throw new Error("Diagram input required: pass --stdin or --input <path>.");
|
|
62
|
+
}
|
|
63
|
+
const raw = request.stdin
|
|
64
|
+
? await readStdinFully()
|
|
65
|
+
: await fs.readFile(path.isAbsolute(request.inputPath ?? "") ? request.inputPath : path.join(request.repoRoot, request.inputPath), "utf8");
|
|
66
|
+
const trimmed = raw.trim();
|
|
67
|
+
if (trimmed.length === 0) {
|
|
68
|
+
throw new Error("Diagram input is empty.");
|
|
69
|
+
}
|
|
70
|
+
if (isLikelyOpenSpecDocument(request.inputPath, raw)) {
|
|
71
|
+
return { sourceText: convertSpecToMermaid(raw), sourceKind: "spec" };
|
|
72
|
+
}
|
|
73
|
+
return { sourceText: raw, sourceKind: "mermaid" };
|
|
74
|
+
}
|
|
75
|
+
function resolveSvgTheme(request) {
|
|
76
|
+
const options = {};
|
|
77
|
+
if (request.theme) {
|
|
78
|
+
const theme = THEMES[request.theme];
|
|
79
|
+
if (!theme) {
|
|
80
|
+
const validThemes = Object.keys(THEMES).sort().join(", ");
|
|
81
|
+
throw new Error(`Unsupported theme: ${request.theme}. Valid themes: ${validThemes}`);
|
|
82
|
+
}
|
|
83
|
+
Object.assign(options, theme);
|
|
84
|
+
}
|
|
85
|
+
const overrides = ["bg", "fg", "line", "accent", "muted", "surface", "border", "font"];
|
|
86
|
+
for (const key of overrides) {
|
|
87
|
+
const value = request[key];
|
|
88
|
+
if (typeof value === "string" && value.length > 0) {
|
|
89
|
+
options[key] = value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return options;
|
|
93
|
+
}
|
|
94
|
+
export async function renderDiagram(request) {
|
|
95
|
+
const { sourceText, sourceKind } = await loadSource(request);
|
|
96
|
+
let content = "";
|
|
97
|
+
if (request.format === "ascii") {
|
|
98
|
+
content = renderMermaidAscii(sourceText);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const svgOptions = resolveSvgTheme(request);
|
|
102
|
+
content = await renderMermaid(sourceText, svgOptions);
|
|
103
|
+
}
|
|
104
|
+
if (request.outputPath) {
|
|
105
|
+
const outPath = path.isAbsolute(request.outputPath) ? request.outputPath : path.join(request.repoRoot, request.outputPath);
|
|
106
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
107
|
+
await fs.writeFile(outPath, content, "utf8");
|
|
108
|
+
}
|
|
109
|
+
return { content, source_kind: sourceKind };
|
|
110
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reffy-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "CLI-first, framework-agnostic references workflow for any repo (TypeScript)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"node": ">=20"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
+
"beautiful-mermaid": "^0.1.3",
|
|
28
29
|
"mime-types": "^2.1.35",
|
|
29
30
|
"uuid": "^11.1.0"
|
|
30
31
|
},
|