airgen-cli 0.17.2 → 0.18.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.
@@ -176,12 +176,15 @@ function renderTerminal(blocks, connectors) {
176
176
  function renderMermaid(blocks, connectors, direction) {
177
177
  resetMermaidIds();
178
178
  const lines = [`flowchart ${direction}`];
179
+ const blockIds = new Set(blocks.map(b => b.id));
179
180
  // Nodes
180
181
  for (const b of blocks) {
181
182
  lines.push(` ${mermaidNodeShape(b)}`);
182
183
  }
183
- // Edges
184
+ // Edges (skip orphan connectors whose source or target block is missing)
184
185
  for (const c of connectors) {
186
+ if (!blockIds.has(c.source) || !blockIds.has(c.target))
187
+ continue;
185
188
  const src = sanitizeId(c.source);
186
189
  const tgt = sanitizeId(c.target);
187
190
  const arrow = mermaidArrow(c.kind);
@@ -288,7 +291,10 @@ export function registerDiagramCommands(program, client) {
288
291
  if (opts.clean) {
289
292
  rendered = rendered
290
293
  .split("\n")
291
- .filter(l => !l.trim().startsWith("classDef ") && !l.trim().startsWith("class ") && !l.trim().startsWith("style "))
294
+ .filter(l => {
295
+ const t = l.trim();
296
+ return !t.startsWith("classDef ") && !t.startsWith("class ") && !t.startsWith("style ") && t !== "";
297
+ })
292
298
  .map(l => l
293
299
  .replace(/<<[^>]+>><br\/?>/g, "") // Strip <<stereotype>><br> or <<stereotype>><br/>
294
300
  .replace(/[a-z]+<br>/gi, (m) => m) // Keep stereo<br>label (valid Mermaid)
@@ -459,7 +465,7 @@ export function registerDiagramCommands(program, client) {
459
465
  });
460
466
  blocks
461
467
  .command("create")
462
- .description("Create a new block")
468
+ .description("Create a new block (use --upsert to return existing if name matches)")
463
469
  .argument("<tenant>", "Tenant slug")
464
470
  .argument("<project-key>", "Project key")
465
471
  .requiredOption("--diagram <id>", "Diagram ID")
@@ -471,7 +477,22 @@ export function registerDiagramCommands(program, client) {
471
477
  .option("--height <n>", "Height")
472
478
  .option("--stereotype <s>", "Stereotype")
473
479
  .option("--description <desc>", "Description")
480
+ .option("--upsert", "Return existing block if name matches on this diagram instead of creating duplicate")
474
481
  .action(async (tenant, projectKey, opts) => {
482
+ // Upsert: check for existing block with same name on this diagram
483
+ if (opts.upsert) {
484
+ const existing = await client.get(`/architecture/blocks/${tenant}/${projectKey}/${opts.diagram}`);
485
+ const match = (existing.blocks ?? []).find(b => b.name.toLowerCase() === opts.name.toLowerCase());
486
+ if (match) {
487
+ if (isJsonMode()) {
488
+ output({ block: match, deduplicated: true });
489
+ }
490
+ else {
491
+ console.log(`Block already exists: ${match.id} (${match.name})`);
492
+ }
493
+ return;
494
+ }
495
+ }
475
496
  const data = await client.post("/architecture/blocks", {
476
497
  tenant,
477
498
  projectKey,
@@ -493,6 +514,85 @@ export function registerDiagramCommands(program, client) {
493
514
  output(data);
494
515
  }
495
516
  });
517
+ blocks
518
+ .command("deduplicate")
519
+ .description("Find and remove duplicate-named blocks on a diagram")
520
+ .argument("<tenant>", "Tenant slug")
521
+ .argument("<project>", "Project slug")
522
+ .option("--diagram <id>", "Specific diagram ID (otherwise checks all)")
523
+ .option("--dry-run", "Show duplicates without deleting (default)")
524
+ .option("--apply", "Actually delete duplicates")
525
+ .action(async (tenant, project, opts) => {
526
+ const diagramIds = [];
527
+ if (opts.diagram) {
528
+ diagramIds.push(opts.diagram);
529
+ }
530
+ else {
531
+ const data = await client.get(`/architecture/diagrams/${tenant}/${project}`);
532
+ diagramIds.push(...(data.diagrams ?? []).map(d => d.id));
533
+ }
534
+ let totalRemoved = 0;
535
+ for (const diagId of diagramIds) {
536
+ const data = await client.get(`/architecture/blocks/${tenant}/${project}/${diagId}`);
537
+ const allBlocks = data.blocks ?? [];
538
+ const byName = new Map();
539
+ for (const b of allBlocks) {
540
+ const name = b.name.toLowerCase().trim();
541
+ const group = byName.get(name) ?? [];
542
+ group.push(b);
543
+ byName.set(name, group);
544
+ }
545
+ for (const [, group] of byName) {
546
+ if (group.length <= 1)
547
+ continue;
548
+ // Keep oldest (first created)
549
+ const sorted = group.sort((a, b) => (a.id < b.id ? -1 : 1));
550
+ const keep = sorted[0];
551
+ const dupes = sorted.slice(1);
552
+ console.log(` "${keep.name}" on ${diagId}: keeping ${keep.id}, ${dupes.length} duplicate(s)`);
553
+ if (opts.apply) {
554
+ for (const dup of dupes) {
555
+ await client.delete(`/architecture/blocks/${tenant}/${project}/${dup.id}`);
556
+ totalRemoved++;
557
+ }
558
+ }
559
+ else {
560
+ for (const dup of dupes) {
561
+ console.log(` [dry-run] would remove ${dup.id}`);
562
+ }
563
+ totalRemoved += dupes.length;
564
+ }
565
+ }
566
+ }
567
+ console.log(`${opts.apply ? "Removed" : "Would remove"} ${totalRemoved} duplicate block(s).`);
568
+ });
569
+ blocks
570
+ .command("find")
571
+ .description("Search blocks by name across project or diagram")
572
+ .argument("<tenant>", "Tenant slug")
573
+ .argument("<project>", "Project slug")
574
+ .requiredOption("--name <name>", "Block name to search (case-insensitive substring)")
575
+ .option("--diagram <id>", "Limit to specific diagram")
576
+ .action(async (tenant, project, opts) => {
577
+ if (opts.diagram) {
578
+ const data = await client.get(`/architecture/blocks/${tenant}/${project}/${opts.diagram}`);
579
+ const matches = (data.blocks ?? []).filter(b => b.name.toLowerCase().includes(opts.name.toLowerCase()));
580
+ if (isJsonMode()) {
581
+ output(matches);
582
+ return;
583
+ }
584
+ printTable(["ID", "Name", "Kind"], matches.map(b => [b.id, b.name, b.kind ?? ""]));
585
+ }
586
+ else {
587
+ const libData = await client.get(`/architecture/block-library/${tenant}/${project}`);
588
+ const matches = (libData.blocks ?? []).filter(b => b.name.toLowerCase().includes(opts.name.toLowerCase()));
589
+ if (isJsonMode()) {
590
+ output(matches);
591
+ return;
592
+ }
593
+ printTable(["ID", "Name", "Kind"], matches.map(b => [b.id, b.name, b.kind ?? ""]));
594
+ }
595
+ });
496
596
  blocks
497
597
  .command("delete")
498
598
  .description("Delete a block from the project")
@@ -505,6 +605,65 @@ export function registerDiagramCommands(program, client) {
505
605
  });
506
606
  // Connectors sub-group
507
607
  const connectors = cmd.command("connectors").alias("conn").description("Manage connectors");
608
+ connectors
609
+ .command("list")
610
+ .description("List connectors in a diagram")
611
+ .argument("<tenant>", "Tenant slug")
612
+ .argument("<project>", "Project slug")
613
+ .argument("<diagram-id>", "Diagram ID")
614
+ .action(async (tenant, project, diagramId) => {
615
+ const data = await client.get(`/architecture/connectors/${tenant}/${project}/${diagramId}`);
616
+ const list = data.connectors ?? [];
617
+ if (isJsonMode()) {
618
+ output(list);
619
+ }
620
+ else {
621
+ if (list.length === 0) {
622
+ console.log("No connectors in this diagram.");
623
+ return;
624
+ }
625
+ printTable(["ID", "Source", "Target", "Kind", "Label"], list.map(c => [c.id, c.source, c.target, c.kind ?? "", c.label ?? ""]));
626
+ }
627
+ });
628
+ connectors
629
+ .command("cleanup")
630
+ .description("Find and remove orphan connectors (source or target block missing)")
631
+ .argument("<tenant>", "Tenant slug")
632
+ .argument("<project>", "Project slug")
633
+ .option("--diagram <id>", "Specific diagram ID (otherwise checks all)")
634
+ .option("--apply", "Actually delete orphans (default is dry-run)")
635
+ .action(async (tenant, project, opts) => {
636
+ const diagramIds = [];
637
+ if (opts.diagram) {
638
+ diagramIds.push(opts.diagram);
639
+ }
640
+ else {
641
+ const data = await client.get(`/architecture/diagrams/${tenant}/${project}`);
642
+ diagramIds.push(...(data.diagrams ?? []).map(d => d.id));
643
+ }
644
+ let totalOrphans = 0;
645
+ for (const diagId of diagramIds) {
646
+ const [blockData, connData] = await Promise.all([
647
+ client.get(`/architecture/blocks/${tenant}/${project}/${diagId}`),
648
+ client.get(`/architecture/connectors/${tenant}/${project}/${diagId}`),
649
+ ]);
650
+ const blockIds = new Set((blockData.blocks ?? []).map(b => b.id));
651
+ const orphans = (connData.connectors ?? []).filter(c => !blockIds.has(c.source) || !blockIds.has(c.target));
652
+ if (orphans.length === 0)
653
+ continue;
654
+ console.log(` ${diagId}: ${orphans.length} orphan connector(s)`);
655
+ for (const c of orphans) {
656
+ if (opts.apply) {
657
+ await client.delete(`/architecture/connectors/${tenant}/${project}/${c.id}?diagramId=${diagId}`);
658
+ }
659
+ else {
660
+ console.log(` [dry-run] ${c.id}: ${c.source} → ${c.target} (${c.kind ?? "?"})`);
661
+ }
662
+ }
663
+ totalOrphans += orphans.length;
664
+ }
665
+ console.log(`${opts.apply ? "Removed" : "Would remove"} ${totalOrphans} orphan connector(s).`);
666
+ });
508
667
  connectors
509
668
  .command("create")
510
669
  .description("Create a connector between blocks")
@@ -518,7 +677,21 @@ export function registerDiagramCommands(program, client) {
518
677
  .option("--source-port <id>", "Source port ID")
519
678
  .option("--target-port <id>", "Target port ID")
520
679
  .option("--line-style <style>", "Line style: straight, smoothstep, step, polyline, bezier")
680
+ .option("--upsert", "Return existing connector if source+target+diagram matches")
521
681
  .action(async (tenant, projectKey, opts) => {
682
+ if (opts.upsert) {
683
+ const existing = await client.get(`/architecture/connectors/${tenant}/${projectKey}/${opts.diagram}`);
684
+ const match = (existing.connectors ?? []).find(c => c.source === opts.source && c.target === opts.target);
685
+ if (match) {
686
+ if (isJsonMode()) {
687
+ output({ connector: match, deduplicated: true });
688
+ }
689
+ else {
690
+ console.log(`Connector already exists: ${match.id} (${match.source} → ${match.target})`);
691
+ }
692
+ return;
693
+ }
694
+ }
522
695
  const data = await client.post("/architecture/connectors", {
523
696
  tenant,
524
697
  projectKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgen-cli",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "AIRGen CLI — requirements engineering from the command line",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",