airgen-cli 0.1.5 → 0.1.7

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.
@@ -1,4 +1,205 @@
1
+ import { writeFileSync } from "node:fs";
1
2
  import { output, printTable, isJsonMode } from "../output.js";
3
+ // ── Mermaid rendering helpers ─────────────────────────────────
4
+ function sanitizeId(id) {
5
+ return id.replace(/[^a-zA-Z0-9_-]/g, "_");
6
+ }
7
+ function mermaidNodeShape(block) {
8
+ const id = sanitizeId(block.id);
9
+ const label = block.name.replace(/"/g, "'");
10
+ const stereo = block.stereotype?.replace(/[«»<>]/g, "") ?? block.kind ?? "block";
11
+ const display = `"«${stereo}»\\n${label}"`;
12
+ switch (block.kind) {
13
+ case "system": return `${id}[${display}]`;
14
+ case "subsystem": return `${id}[${display}]`;
15
+ case "actor": return `${id}([${display}])`;
16
+ case "external": return `${id}>${display}]`;
17
+ case "interface": return `${id}{{${display}}}`;
18
+ default: return `${id}[${display}]`; // component
19
+ }
20
+ }
21
+ function mermaidArrow(kind) {
22
+ switch (kind) {
23
+ case "flow": return "==>";
24
+ case "dependency": return "-.->";
25
+ case "composition": return "--*";
26
+ default: return "-->"; // association
27
+ }
28
+ }
29
+ function mermaidStyle(block) {
30
+ const id = sanitizeId(block.id);
31
+ switch (block.kind) {
32
+ case "system": return `style ${id} fill:#ebf8ff,stroke:#1a365d,color:#1a365d`;
33
+ case "subsystem": return `style ${id} fill:#f0f5ff,stroke:#2c5282,color:#2c5282`;
34
+ case "actor": return `style ${id} fill:#f0fff4,stroke:#276749,color:#276749`;
35
+ case "external": return `style ${id} fill:#fffbeb,stroke:#92400e,color:#92400e`;
36
+ case "interface": return `style ${id} fill:#faf5ff,stroke:#6b21a8,color:#6b21a8`;
37
+ default: return null;
38
+ }
39
+ }
40
+ // ── Terminal (layered) rendering ─────────────────────────────
41
+ const KIND_ICONS = {
42
+ system: "■", subsystem: "□", component: "◦",
43
+ actor: "☺", external: "◇", interface: "◈",
44
+ };
45
+ function kindArrow(kind) {
46
+ switch (kind) {
47
+ case "flow": return "══▶";
48
+ case "dependency": return "--▷";
49
+ case "composition": return "◆──";
50
+ default: return "──▶";
51
+ }
52
+ }
53
+ function renderTerminal(blocks, connectors) {
54
+ const blockMap = new Map(blocks.map(b => [b.id, b]));
55
+ // Build adjacency
56
+ const outEdges = new Map();
57
+ const inEdges = new Map();
58
+ const inDegree = new Map();
59
+ for (const b of blocks)
60
+ inDegree.set(b.id, 0);
61
+ for (const c of connectors) {
62
+ const out = outEdges.get(c.source) ?? [];
63
+ out.push({ target: c.target, label: c.label ?? "", kind: c.kind });
64
+ outEdges.set(c.source, out);
65
+ const inn = inEdges.get(c.target) ?? [];
66
+ inn.push({ source: c.source, label: c.label ?? "", kind: c.kind });
67
+ inEdges.set(c.target, inn);
68
+ inDegree.set(c.target, (inDegree.get(c.target) ?? 0) + 1);
69
+ }
70
+ // BFS layering (Kahn's algorithm, cycles go to last layer)
71
+ const layer = new Map();
72
+ const queue = [];
73
+ for (const b of blocks) {
74
+ if ((inDegree.get(b.id) ?? 0) === 0) {
75
+ queue.push(b.id);
76
+ layer.set(b.id, 0);
77
+ }
78
+ }
79
+ let maxLayer = 0;
80
+ while (queue.length > 0) {
81
+ const id = queue.shift();
82
+ const curLayer = layer.get(id);
83
+ for (const e of outEdges.get(id) ?? []) {
84
+ if (layer.has(e.target))
85
+ continue;
86
+ const newDeg = (inDegree.get(e.target) ?? 1) - 1;
87
+ inDegree.set(e.target, newDeg);
88
+ if (newDeg <= 0) {
89
+ const nextLayer = curLayer + 1;
90
+ layer.set(e.target, nextLayer);
91
+ maxLayer = Math.max(maxLayer, nextLayer);
92
+ queue.push(e.target);
93
+ }
94
+ }
95
+ }
96
+ // Assign remaining (cycle members) to maxLayer + 1
97
+ for (const b of blocks) {
98
+ if (!layer.has(b.id)) {
99
+ layer.set(b.id, maxLayer + 1);
100
+ maxLayer = maxLayer + 1;
101
+ }
102
+ }
103
+ // Group blocks by layer
104
+ const layers = [];
105
+ for (let i = 0; i <= maxLayer; i++)
106
+ layers.push([]);
107
+ for (const b of blocks) {
108
+ layers[layer.get(b.id)].push(b);
109
+ }
110
+ // Compute column widths for the block name column
111
+ const nameColWidth = Math.max(...blocks.map(b => b.name.length + 4), 20);
112
+ const icon = (b) => KIND_ICONS[b.kind ?? ""] ?? "□";
113
+ const lines = [];
114
+ const PAD = " ";
115
+ for (let li = 0; li < layers.length; li++) {
116
+ const layerBlocks = layers[li];
117
+ if (layerBlocks.length === 0)
118
+ continue;
119
+ // Layer header
120
+ const layerLabel = li === 0 ? "Sources" : li === layers.length - 1 ? "Outputs" : `Layer ${li}`;
121
+ lines.push(`${PAD}┄┄ ${layerLabel} ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄`);
122
+ lines.push("");
123
+ for (const b of layerBlocks) {
124
+ const blockIcon = icon(b);
125
+ const stereo = b.stereotype?.replace(/[«»<>]/g, "") ?? b.kind ?? "block";
126
+ const nameStr = `${blockIcon} ${b.name}`;
127
+ const paddedName = nameStr + " ".repeat(Math.max(0, nameColWidth - nameStr.length));
128
+ // Incoming connections for this block
129
+ const incoming = inEdges.get(b.id) ?? [];
130
+ // Outgoing connections
131
+ const outgoing = outEdges.get(b.id) ?? [];
132
+ // Build the box line
133
+ const boxTop = `${PAD} ┌${"─".repeat(nameColWidth + 2)}┐`;
134
+ const boxSte = `${PAD} │ «${stereo}»${" ".repeat(Math.max(0, nameColWidth - stereo.length - 2))} │`;
135
+ const boxNam = `${PAD} │ ${paddedName} │`;
136
+ const boxBot = `${PAD} └${"─".repeat(nameColWidth + 2)}┘`;
137
+ lines.push(boxTop);
138
+ lines.push(boxSte);
139
+ lines.push(boxNam);
140
+ lines.push(boxBot);
141
+ // Show incoming connections (who feeds this block)
142
+ if (incoming.length > 0) {
143
+ for (const e of incoming) {
144
+ const srcBlock = blockMap.get(e.source);
145
+ const srcName = srcBlock?.name ?? "?";
146
+ const arrow = kindArrow(e.kind);
147
+ const label = e.label ? ` (${e.label})` : "";
148
+ lines.push(`${PAD} ◀── ${srcName}${label}`);
149
+ }
150
+ }
151
+ // Show outgoing connections (where this block sends data)
152
+ if (outgoing.length > 0) {
153
+ for (const e of outgoing) {
154
+ const tgtBlock = blockMap.get(e.target);
155
+ const tgtName = tgtBlock?.name ?? "?";
156
+ const arrow = kindArrow(e.kind);
157
+ const label = e.label ? ` ${e.label} ` : " ";
158
+ lines.push(`${PAD} ${arrow}${label}──▶ ${tgtName}`);
159
+ }
160
+ }
161
+ lines.push("");
162
+ }
163
+ // Visual separator between layers
164
+ if (li < layers.length - 1) {
165
+ lines.push(`${PAD} │`);
166
+ lines.push(`${PAD} ▼`);
167
+ lines.push("");
168
+ }
169
+ }
170
+ // Legend
171
+ lines.push(`${PAD}┄┄ Legend ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄`);
172
+ lines.push(`${PAD} ☺ actor ■ system □ subsystem ◦ component ◇ external ◈ interface`);
173
+ lines.push(`${PAD} ──▶ association ══▶ flow ◆── composition --▷ dependency`);
174
+ return lines.join("\n");
175
+ }
176
+ function renderMermaid(blocks, connectors, direction) {
177
+ const lines = [`flowchart ${direction}`];
178
+ // Nodes
179
+ for (const b of blocks) {
180
+ lines.push(` ${mermaidNodeShape(b)}`);
181
+ }
182
+ // Edges
183
+ for (const c of connectors) {
184
+ const src = sanitizeId(c.source);
185
+ const tgt = sanitizeId(c.target);
186
+ const arrow = mermaidArrow(c.kind);
187
+ if (c.label) {
188
+ lines.push(` ${src} ${arrow}|"${c.label.replace(/"/g, "'")}"| ${tgt}`);
189
+ }
190
+ else {
191
+ lines.push(` ${src} ${arrow} ${tgt}`);
192
+ }
193
+ }
194
+ // Styles
195
+ const styles = blocks.map(mermaidStyle).filter(Boolean);
196
+ if (styles.length > 0) {
197
+ lines.push("");
198
+ for (const s of styles)
199
+ lines.push(` ${s}`);
200
+ }
201
+ return lines.join("\n");
202
+ }
2
203
  export function registerDiagramCommands(program, client) {
3
204
  const cmd = program.command("diagrams").alias("diag").description("Architecture diagrams");
4
205
  cmd
@@ -35,6 +236,60 @@ export function registerDiagramCommands(program, client) {
35
236
  ]);
36
237
  output({ blocks, connectors });
37
238
  });
239
+ cmd
240
+ .command("render")
241
+ .description("Render a diagram in the terminal or as Mermaid syntax")
242
+ .argument("<tenant>", "Tenant slug")
243
+ .argument("<project>", "Project slug")
244
+ .argument("<id>", "Diagram ID")
245
+ .option("--format <fmt>", "Output format: text, mermaid", "text")
246
+ .option("--direction <dir>", "Layout direction for mermaid: TB, LR, BT, RL", "TB")
247
+ .option("-o, --output <file>", "Write to file instead of stdout")
248
+ .option("--wrap", "Wrap mermaid in markdown fenced code block")
249
+ .action(async (tenant, project, id, opts) => {
250
+ // Fetch diagram metadata + blocks + connectors in parallel
251
+ const [diagramData, blocksData, connectorsData] = await Promise.all([
252
+ client.get(`/architecture/diagrams/${tenant}/${project}`),
253
+ client.get(`/architecture/blocks/${tenant}/${project}/${id}`),
254
+ client.get(`/architecture/connectors/${tenant}/${project}/${id}`),
255
+ ]);
256
+ const diagram = (diagramData.diagrams ?? []).find(d => d.id === id);
257
+ const blocks = blocksData.blocks ?? [];
258
+ const connectors = connectorsData.connectors ?? [];
259
+ if (blocks.length === 0) {
260
+ console.log("Empty diagram — no blocks to render.");
261
+ return;
262
+ }
263
+ let rendered;
264
+ if (opts.format === "mermaid") {
265
+ rendered = renderMermaid(blocks, connectors, opts.direction);
266
+ if (isJsonMode()) {
267
+ output({ mermaid: rendered, blocks: blocks.length, connectors: connectors.length });
268
+ return;
269
+ }
270
+ if (opts.wrap)
271
+ rendered = "```mermaid\n" + rendered + "\n```";
272
+ }
273
+ else {
274
+ // Terminal text format
275
+ const header = diagram?.name ?? id;
276
+ const headerLine = ` ${header}`;
277
+ const underline = ` ${"═".repeat(header.length)}`;
278
+ const stats = ` ${blocks.length} blocks, ${connectors.length} connectors`;
279
+ rendered = [headerLine, underline, stats, "", renderTerminal(blocks, connectors)].join("\n");
280
+ if (isJsonMode()) {
281
+ output({ text: rendered, blocks: blocks.length, connectors: connectors.length });
282
+ return;
283
+ }
284
+ }
285
+ if (opts.output) {
286
+ writeFileSync(opts.output, rendered + "\n", "utf-8");
287
+ console.log(`Diagram written to ${opts.output}`);
288
+ }
289
+ else {
290
+ console.log(rendered);
291
+ }
292
+ });
38
293
  cmd
39
294
  .command("create")
40
295
  .description("Create a new diagram")
@@ -21,6 +21,33 @@ async function fetchAllRequirements(client, tenant, project) {
21
21
  }
22
22
  return all.filter((r) => !r.deleted && !r.deletedAt);
23
23
  }
24
+ /** Fetch a single requirement by its resolved ID (ref or full colon ID). */
25
+ async function fetchRequirement(client, tenant, project, resolvedId) {
26
+ // Try direct GET by ref/ID first (works for both ref and full colon ID)
27
+ try {
28
+ const data = await client.get(`/requirements/${tenant}/${project}/${resolvedId}`);
29
+ if (data.record)
30
+ return data.record;
31
+ }
32
+ catch {
33
+ // Not found via direct endpoint — fall through to paginated search
34
+ }
35
+ // Paginated search as fallback
36
+ for (let page = 1; page <= MAX_PAGES; page++) {
37
+ const data = await client.get(`/requirements/${tenant}/${project}`, {
38
+ page: String(page),
39
+ limit: String(PAGE_SIZE),
40
+ });
41
+ const items = data.data ?? [];
42
+ const found = items.find(r => r.id === resolvedId);
43
+ if (found)
44
+ return found;
45
+ const totalPages = data.meta?.totalPages ?? 1;
46
+ if (page >= totalPages)
47
+ break;
48
+ }
49
+ return null;
50
+ }
24
51
  function getImplStatus(tags) {
25
52
  if (!tags)
26
53
  return null;
@@ -74,9 +101,7 @@ export function registerImplementationCommands(program, client) {
74
101
  process.exit(1);
75
102
  }
76
103
  const resolvedId = await resolveRequirementId(client, tenant, project, requirement);
77
- // Fetch current requirement to get existing tags + attributes
78
- const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "100" });
79
- const req = (reqData.data ?? []).find(r => r.id === resolvedId);
104
+ const req = await fetchRequirement(client, tenant, project, resolvedId);
80
105
  if (!req) {
81
106
  console.error(`Requirement ${requirement} not found.`);
82
107
  process.exit(1);
@@ -226,9 +251,7 @@ export function registerImplementationCommands(program, client) {
226
251
  }
227
252
  try {
228
253
  const resolvedId = await resolveRequirementId(client, tenant, project, item.ref);
229
- // Fetch requirement for current tags/attributes
230
- const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "100" });
231
- const req = (reqData.data ?? []).find(r => r.id === resolvedId);
254
+ const req = await fetchRequirement(client, tenant, project, resolvedId);
232
255
  if (!req) {
233
256
  errors.push(`${item.ref}: not found`);
234
257
  failed++;
@@ -279,9 +302,7 @@ export function registerImplementationCommands(program, client) {
279
302
  .option("--line <n>", "Line number (for file type)")
280
303
  .action(async (tenant, project, requirement, opts) => {
281
304
  const resolvedId = await resolveRequirementId(client, tenant, project, requirement);
282
- // Fetch current requirement
283
- const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "100" });
284
- const req = (reqData.data ?? []).find(r => r.id === resolvedId);
305
+ const req = await fetchRequirement(client, tenant, project, resolvedId);
285
306
  if (!req) {
286
307
  console.error(`Requirement ${requirement} not found.`);
287
308
  process.exit(1);
@@ -319,8 +340,7 @@ export function registerImplementationCommands(program, client) {
319
340
  .requiredOption("--path <path>", "Artifact path")
320
341
  .action(async (tenant, project, requirement, opts) => {
321
342
  const resolvedId = await resolveRequirementId(client, tenant, project, requirement);
322
- const reqData = await client.get(`/requirements/${tenant}/${project}`, { page: "1", limit: "100" });
323
- const req = (reqData.data ?? []).find(r => r.id === resolvedId);
343
+ const req = await fetchRequirement(client, tenant, project, resolvedId);
324
344
  if (!req) {
325
345
  console.error(`Requirement ${requirement} not found.`);
326
346
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgen-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "AIRGen CLI — requirements engineering from the command line",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",