reffy-cli 0.4.0 → 0.6.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 CHANGED
@@ -1,17 +1,17 @@
1
1
  # reffy
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/reffy-cli.svg)](https://www.npmjs.com/package/reffy-cli)
4
+ [![MIT License](https://img.shields.io/github/license/RoskiDeluge/reffy-ts.svg)](LICENSE)
5
+ [![CI](https://github.com/RoskiDeluge/reffy-ts/actions/workflows/ci.yml/badge.svg)](https://github.com/RoskiDeluge/reffy-ts/actions/workflows/ci.yml)
6
+
3
7
  Reffy is intended as an ideation layer for spec-driven development (SDD) in straightforward, version controlled and agent-friendly markdown files.
4
8
 
5
9
  ## Install
6
10
 
7
- Recommended usage in any repo:
8
-
9
11
  ```bash
10
12
  npm install -g reffy-cli
11
13
  ```
12
14
 
13
- The install runs this package's `prepare` step, which builds `dist/` automatically.
14
-
15
15
  ## Quickstart (CLI-only)
16
16
 
17
17
  Inside your project:
@@ -19,10 +19,6 @@ Inside your project:
19
19
  ```bash
20
20
  reffy init
21
21
  reffy bootstrap
22
- reffy doctor
23
- reffy reindex
24
- reffy validate
25
- reffy summarize
26
22
  ```
27
23
 
28
24
  Command summary:
@@ -33,6 +29,7 @@ Command summary:
33
29
  - `reffy reindex`: reconciles `.references/manifest.json` with `.references/artifacts` by adding missing files and removing stale entries.
34
30
  - `reffy validate`: validates `.references/manifest.json` against manifest v1 contract.
35
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`.
36
33
 
37
34
  Output modes:
38
35
 
@@ -49,6 +46,9 @@ reffy doctor --output text
49
46
  reffy doctor --output json
50
47
  reffy summarize --output text
51
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
52
52
  ```
53
53
 
54
54
  ## Using Reffy With SDD Frameworks (OpenSpec Example)
@@ -85,3 +85,5 @@ npm run build
85
85
  npm run check
86
86
  npm test
87
87
  ```
88
+
89
+ `npm install` runs this package's `prepare` step, which builds `dist/` automatically.
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,12 +335,50 @@ 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);
381
+ printBanner(output);
223
382
  if (command === "init") {
224
383
  const repoRoot = parseRepoArg(rest);
225
384
  const agents = await initAgents(repoRoot);
@@ -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
+ }>;
@@ -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.4.0",
3
+ "version": "0.6.0",
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
  },