prunify 0.1.7 → 0.1.8

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/dist/cli.js CHANGED
@@ -489,6 +489,12 @@ ${entry}
489
489
  `, "utf-8");
490
490
  }
491
491
  }
492
+ function formatBytes(bytes) {
493
+ if (bytes < 1024) return `${bytes} B`;
494
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
495
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
496
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
497
+ }
492
498
  function writeMarkdown(report, outputPath) {
493
499
  const lines = [
494
500
  `# ${report.title}`,
@@ -656,62 +662,136 @@ function getFileSize(filePath) {
656
662
  return 0;
657
663
  }
658
664
  }
665
+ var RULE = "\u2500".repeat(78);
666
+ var RULE2 = "\u2550".repeat(78);
659
667
  function buildDeadCodeReport(safeToDelete, transitivelyDead, chains, deadExports, rootDir) {
660
668
  const rel = (p) => path5.relative(rootDir, p).replaceAll("\\", "/");
661
- const allDeadFiles = [...safeToDelete, ...transitivelyDead];
662
- const totalBytes = allDeadFiles.reduce((sum, f) => sum + getFileSize(f), 0);
663
- const totalKb = (totalBytes / 1024).toFixed(1);
669
+ const sortPaths = (paths) => [...paths].sort((a, b) => rel(a).localeCompare(rel(b), "en"));
670
+ const safeSorted = sortPaths(safeToDelete);
671
+ const transSorted = sortPaths(transitivelyDead);
672
+ const safeBytes = safeSorted.reduce((s, f) => s + getFileSize(f), 0);
673
+ const transBytes = transSorted.reduce((s, f) => s + getFileSize(f), 0);
674
+ const fileDeleteBytes = safeBytes + transBytes;
675
+ const totalKb = (fileDeleteBytes / 1024).toFixed(1);
676
+ const deadExportFiles = new Set(deadExports.map((e) => e.filePath));
677
+ const deadExportFileBytes = [...deadExportFiles].reduce((s, f) => s + getFileSize(f), 0);
664
678
  const lines = [
665
- "========================================",
679
+ RULE2,
666
680
  " DEAD CODE REPORT",
681
+ RULE2,
667
682
  ` Safe to delete : ${safeToDelete.length}`,
668
683
  ` Transitively dead : ${transitivelyDead.length}`,
669
684
  ` Dead exports : ${deadExports.length}`,
670
- ` Recoverable : ~${totalKb} KB`,
671
- "========================================",
685
+ ` Recoverable (files): ~${totalKb} KB (${formatBytes(fileDeleteBytes)})`,
686
+ RULE2,
687
+ "",
688
+ " HOW TO READ THIS",
689
+ "",
690
+ " 1. SAFE TO DELETE \u2014 No other source file imports these. You can delete them",
691
+ " first (after review).",
692
+ " 2. TRANSITIVELY DEAD \u2014 Only referenced by files that are already dead. After",
693
+ " removing safe-to-delete files, these become removable too.",
694
+ " 3. DEAD EXPORTS \u2014 The file is still used, but the listed export is never",
695
+ " imported by name. Remove the export (or use it) to tidy the API.",
696
+ " Framework routes (pages/, app/, etc.) are not listed here.",
697
+ "",
698
+ RULE,
672
699
  ""
673
700
  ];
674
- if (safeToDelete.length > 0) {
701
+ if (safeSorted.length > 0) {
675
702
  lines.push(
676
703
  "\u2500\u2500 SAFE TO DELETE \u2500\u2500",
677
- "(These files are not imported by any other file in the codebase)",
704
+ "",
705
+ " Nothing in the project imports these paths. Review, then delete.",
678
706
  ""
679
707
  );
680
- for (const filePath of safeToDelete) {
708
+ for (const filePath of safeSorted) {
681
709
  const chain = chains.get(filePath) ?? [];
682
- const sizeKb = (getFileSize(filePath) / 1024).toFixed(1);
683
- lines.push(` ${rel(filePath)} (~${sizeKb} KB)`);
710
+ const kb = (getFileSize(filePath) / 1024).toFixed(1);
711
+ lines.push(` \u2022 ${rel(filePath)} (~${kb} KB)`);
684
712
  if (chain.length > 0) {
685
- lines.push(` \u2514\u2500 also makes dead: ${chain.map(rel).join(", ")}`);
713
+ lines.push(
714
+ ` \u21B3 Also becomes orphaned if you delete the line above:`,
715
+ ` ${chain.map(rel).join(", ")}`
716
+ );
686
717
  }
687
718
  }
688
- lines.push("");
719
+ lines.push(
720
+ "",
721
+ ` SUBTOTAL \u2014 ${safeSorted.length} file(s) \xB7 ${formatBytes(safeBytes)}`,
722
+ "",
723
+ RULE,
724
+ ""
725
+ );
689
726
  }
690
- if (transitivelyDead.length > 0) {
727
+ if (transSorted.length > 0) {
691
728
  lines.push(
692
729
  "\u2500\u2500 TRANSITIVELY DEAD \u2500\u2500",
693
- "(These files are only imported by dead files \u2014 they become orphaned too)",
730
+ "",
731
+ " Every importer of these files is already in the dead set above.",
694
732
  ""
695
733
  );
696
- for (const filePath of transitivelyDead) {
697
- const sizeKb = (getFileSize(filePath) / 1024).toFixed(1);
698
- lines.push(` ${rel(filePath)} (~${sizeKb} KB)`);
734
+ for (const filePath of transSorted) {
735
+ const kb = (getFileSize(filePath) / 1024).toFixed(1);
736
+ lines.push(` \u2022 ${rel(filePath)} (~${kb} KB)`);
699
737
  }
700
- lines.push("");
738
+ lines.push(
739
+ "",
740
+ ` SUBTOTAL \u2014 ${transSorted.length} file(s) \xB7 ${formatBytes(transBytes)}`,
741
+ "",
742
+ RULE,
743
+ ""
744
+ );
701
745
  }
702
746
  if (deadExports.length > 0) {
703
747
  lines.push(
704
748
  "\u2500\u2500 DEAD EXPORTS \u2500\u2500",
705
- "(Exported but never imported by any other file; pages/routes omitted)",
749
+ "",
750
+ " Symbol is exported but never imported by name elsewhere (namespace imports",
751
+ " count as \u201Call used\u201D). Route and framework files are excluded.",
706
752
  ""
707
753
  );
708
- for (const entry of deadExports) {
709
- lines.push(
710
- ` ${rel(entry.filePath)} \u2192 ${entry.exportName} [line ${entry.line}]`
711
- );
754
+ const byFile = /* @__PURE__ */ new Map();
755
+ for (const e of deadExports) {
756
+ if (!byFile.has(e.filePath)) byFile.set(e.filePath, []);
757
+ byFile.get(e.filePath).push(e);
758
+ }
759
+ const fileKeys = [...byFile.keys()].sort((a, b) => rel(a).localeCompare(rel(b), "en"));
760
+ for (const fp of fileKeys) {
761
+ lines.push(` ${rel(fp)}`);
762
+ for (const entry of byFile.get(fp)) {
763
+ lines.push(` \u2022 export \u201C${entry.exportName}\u201D (line ${entry.line})`);
764
+ }
765
+ lines.push("");
712
766
  }
713
- lines.push("");
767
+ lines.push(
768
+ ` SUBTOTAL \u2014 ${deadExports.length} export(s) across ${deadExportFiles.size} file(s)`,
769
+ "",
770
+ RULE,
771
+ ""
772
+ );
714
773
  }
774
+ lines.push(
775
+ RULE2,
776
+ " END OF REPORT \u2014 DISK SPACE & CLEANUP SUMMARY",
777
+ RULE2,
778
+ "",
779
+ " If you DELETE every file under \u201CSafe to delete\u201D and \u201CTransitively dead\u201D:",
780
+ "",
781
+ ` Files removed : ${safeSorted.length + transSorted.length}`,
782
+ ` Disk freed : ${formatBytes(fileDeleteBytes)} (approx., from file sizes on disk)`,
783
+ "",
784
+ " Dead exports do not remove whole files by themselves. Cleaning them saves",
785
+ ` maintainability first; the ${deadExportFiles.size} file(s) that contain them`,
786
+ ` total about ${formatBytes(deadExportFileBytes)} on disk (entire files, not`,
787
+ " \u201Cexport-only\u201D savings).",
788
+ "",
789
+ " Suggested order: (1) Delete safe-to-delete files \u2192 (2) Delete transitively",
790
+ " dead files \u2192 (3) Remove or use dead exports in remaining files.",
791
+ "",
792
+ RULE2,
793
+ ""
794
+ );
715
795
  return lines.join("\n");
716
796
  }
717
797
  async function runDeadCode(dir, opts) {
@@ -1264,22 +1344,38 @@ async function runAssetCheck(rootDir, opts) {
1264
1344
  writeMarkdown(
1265
1345
  {
1266
1346
  title: "Unused Assets Report",
1267
- summary: `${result.unusedAssets.length} unused asset(s) found in public/`,
1347
+ summary: `${result.unusedAssets.length} unused \xB7 ${formatBytes(result.unusedAssets.reduce((s, a) => s + a.sizeBytes, 0))} recoverable \u2014 see companion .txt for full detail`,
1268
1348
  sections: [
1269
1349
  {
1270
1350
  title: "Unused Assets",
1271
1351
  headers: ["Asset", "Size (KB)"],
1272
- rows: result.unusedAssets.map((a) => [
1273
- a.relativePath,
1274
- (a.sizeBytes / 1024).toFixed(1)
1275
- ])
1352
+ rows: result.unusedAssets.slice().sort((a, b) => a.relativePath.localeCompare(b.relativePath, "en")).map((a) => [a.relativePath, (a.sizeBytes / 1024).toFixed(1)])
1353
+ },
1354
+ {
1355
+ title: "If you delete all listed files",
1356
+ rows: [
1357
+ [
1358
+ "Files removed",
1359
+ String(result.unusedAssets.length)
1360
+ ],
1361
+ [
1362
+ "Approx. disk freed",
1363
+ formatBytes(result.unusedAssets.reduce((s, a) => s + a.sizeBytes, 0))
1364
+ ]
1365
+ ]
1276
1366
  }
1277
1367
  ],
1278
1368
  generatedAt: /* @__PURE__ */ new Date()
1279
1369
  },
1280
1370
  opts.output
1281
1371
  );
1282
- console.log(chalk6.cyan(` Report written to ${opts.output}`));
1372
+ const txtPath = path8.join(
1373
+ path8.dirname(opts.output),
1374
+ `${path8.basename(opts.output, path8.extname(opts.output))}.txt`
1375
+ );
1376
+ fs8.writeFileSync(txtPath, result.report, "utf-8");
1377
+ console.log(chalk6.cyan(` Reports written \u2192 ${opts.output}`));
1378
+ console.log(chalk6.cyan(` \u2192 ${txtPath}`));
1283
1379
  }
1284
1380
  return result.unusedAssets;
1285
1381
  } catch (err) {
@@ -1317,32 +1413,163 @@ function getFileSize2(filePath) {
1317
1413
  }
1318
1414
  function buildAssetReport(unused, totalAssets, rootDir) {
1319
1415
  const totalBytes = unused.reduce((s, a) => s + a.sizeBytes, 0);
1320
- const totalKb = (totalBytes / 1024).toFixed(1);
1416
+ const usedCount = totalAssets - unused.length;
1417
+ const pct = totalAssets > 0 ? (unused.length / totalAssets * 100).toFixed(1) : "0.0";
1418
+ const RULE22 = "\u2550".repeat(78);
1419
+ const RULE3 = "\u2500".repeat(78);
1321
1420
  const lines = [
1322
- "========================================",
1323
- " UNUSED ASSETS REPORT",
1324
- ` Total assets : ${totalAssets}`,
1325
- ` Unused assets : ${unused.length}`,
1326
- ` Recoverable : ~${totalKb} KB`,
1327
- "========================================",
1421
+ RULE22,
1422
+ " UNUSED ASSETS REPORT (public/)",
1423
+ RULE22,
1424
+ ` Total files scanned : ${totalAssets}`,
1425
+ ` Referenced in code : ${usedCount}`,
1426
+ ` Unused (listed) : ${unused.length} (${pct}% of scanned)`,
1427
+ ` Disk size (unused) : ${formatBytes(totalBytes)}`,
1428
+ RULE22,
1429
+ "",
1430
+ " HOW TO READ THIS",
1431
+ "",
1432
+ " Files below live under public/ but their filename or public URL path was not",
1433
+ " found in your source scan (TS, JS, CSS, HTML, JSON). They may still be used",
1434
+ " if loaded by URL from a CMS, CDN, or runtime string \u2014 verify before deleting.",
1435
+ "",
1436
+ RULE3,
1328
1437
  ""
1329
1438
  ];
1330
1439
  if (unused.length === 0) {
1331
- lines.push(" All public assets are referenced in source.", "");
1332
- return lines.join("\n");
1333
- }
1334
- lines.push("\u2500\u2500 UNUSED ASSETS \u2500\u2500", "");
1335
- for (const asset of unused) {
1336
1440
  lines.push(
1337
- `UNUSED \u2014 ${asset.relativePath}`,
1338
- `Size: ~${(asset.sizeBytes / 1024).toFixed(1)} KB`,
1339
- `Action: Safe to delete if not served directly via URL`,
1441
+ " No unused assets detected \u2014 everything scanned appears referenced.",
1442
+ "",
1443
+ RULE22,
1444
+ " END OF REPORT",
1445
+ RULE22,
1446
+ "",
1447
+ " If you removed nothing: disk savings = 0 (already optimal for this scan).",
1448
+ "",
1449
+ RULE22,
1340
1450
  ""
1341
1451
  );
1452
+ return lines.join("\n");
1453
+ }
1454
+ const sorted = [...unused].sort(
1455
+ (a, b) => a.relativePath.localeCompare(b.relativePath, "en")
1456
+ );
1457
+ lines.push("\u2500\u2500 UNUSED ASSET FILES \u2500\u2500", "", " Sorted by path.", "");
1458
+ for (const asset of sorted) {
1459
+ const kb = (asset.sizeBytes / 1024).toFixed(1);
1460
+ lines.push(` \u2022 ${asset.relativePath} (~${kb} KB)`);
1461
+ lines.push(` On disk: ${formatBytes(asset.sizeBytes)}`);
1462
+ lines.push("");
1342
1463
  }
1464
+ lines.push(
1465
+ ` SUBTOTAL \u2014 ${sorted.length} file(s) \xB7 ${formatBytes(totalBytes)}`,
1466
+ "",
1467
+ RULE3,
1468
+ "",
1469
+ RULE22,
1470
+ " END OF REPORT \u2014 DISK SPACE SUMMARY",
1471
+ RULE22,
1472
+ "",
1473
+ " If you DELETE every unused asset listed above from public/:",
1474
+ "",
1475
+ ` Files removed : ${sorted.length}`,
1476
+ ` Disk freed : ${formatBytes(totalBytes)} (sum of file sizes on disk)`,
1477
+ "",
1478
+ " This does not include savings from dead code \u2014 see dead-code.txt for source files.",
1479
+ "",
1480
+ RULE22,
1481
+ ""
1482
+ );
1343
1483
  return lines.join("\n");
1344
1484
  }
1345
1485
 
1486
+ // src/core/agent-prompt.ts
1487
+ function buildPromptBody(meta) {
1488
+ const { prunifyVersion, generatedAtIso, projectRootDisplay, reportsDirDisplay } = meta;
1489
+ return `You are assisting with a **careful, incremental** cleanup of a TypeScript/JavaScript codebase.
1490
+
1491
+ ## Context (prunify ${prunifyVersion})
1492
+
1493
+ - **Project root:** ${projectRootDisplay}
1494
+ - **Reports folder:** ${reportsDirDisplay}
1495
+ - **Report generated (this prompt file):** ${generatedAtIso}
1496
+
1497
+ The user has run **prunify** (static analysis). The reports are hints, not ground truth. **False positives are common.**
1498
+
1499
+ ## Attach these files to the chat (if they exist)
1500
+
1501
+ From \`${reportsDirDisplay}\`:
1502
+
1503
+ | File | What it means |
1504
+ |------|----------------|
1505
+ | **dead-code.txt** | Orphan files (safe / transitive), dead exports, sizes. **--delete** uses this for whole-file removal. |
1506
+ | **circular.txt** | Import cycles \u2014 usually **refactor**, not delete. |
1507
+ | **dupes.md** | Repeated code **blocks** \u2014 merge only after **semantic** review. |
1508
+ | **deps.md** | package.json vs imports \u2014 **unused** / **missing** / dev issues. |
1509
+ | **assets.md** (+ **assets.txt**) | Unreferenced files under **public/** \u2014 **--delete-assets** uses the report. |
1510
+
1511
+ If a file is missing, skip that section of work.
1512
+
1513
+ ---
1514
+
1515
+ ## Mandatory safety rules (do not skip)
1516
+
1517
+ 1. **No bulk deletes.** Work in small batches; one PR or one chat turn per logical change.
1518
+ 2. **Dead code \u2014 files:** Only delete files listed under **Safe to delete** (or explicitly marked removable in dead-code.txt) **after** you grep/search for dynamic imports, \`require(variable)\`, string-based paths, Next.js \`app/\`/\`pages/\` conventions, and config references.
1519
+ 3. **Dead code \u2014 exports:** Removing an export can break external consumers or barrels. After removal, run typecheck; fix barrel \`index.ts\` re-exports.
1520
+ 4. **Barrel files (\`index.ts\`):** A file may be "used" only via \`export * from './X'\`. Do not delete leaf modules until importers and barrels are updated.
1521
+ 5. **Transitively dead:** Deletion order matters \u2014 follow the report chains; remove importers before importers-only-used-by-dead-code where applicable.
1522
+ 6. **Circular imports:** Prefer extracting shared code or inverting dependencies; **do not** "fix" by deleting arbitrary cycle members.
1523
+ 7. **dupes.md:** Identical text \u2260 identical behavior (closures, imports, generics). **Merge duplicates only** when you can prove equivalence; prefer extracting a shared function with tests.
1524
+ 8. **deps.md \u2014 unused:** prunify scans **src/** imports only. Packages may still be used in **root config**, **scripts**, **CSS**, **tests outside src**, **Next.js** files outside \`src/\`, or **CLI**. Confirm with repo-wide search before \`npm uninstall\`.
1525
+ 9. **deps.md \u2014 missing:** May be transitive or tooling; verify before adding duplicates.
1526
+ 10. **assets:** URLs may be built at runtime, come from CMS, or live in strings prunify does not see. Confirm with search before deleting from **public/**.
1527
+
1528
+ ---
1529
+
1530
+ ## Suggested order (lowest risk first)
1531
+
1532
+ 1. **Verify deps.md** \u2014 search the whole repo; adjust package.json only with evidence.
1533
+ 2. **Dead exports** \u2014 remove unused exports / tidy barrels; typecheck after each batch.
1534
+ 3. **Safe-to-delete files** \u2014 small batches; run build after.
1535
+ 4. **Unused assets** \u2014 after string search in app and config.
1536
+ 5. **dupes.md** \u2014 last; highest judgment; add tests when merging.
1537
+
1538
+ ---
1539
+
1540
+ ## After every batch
1541
+
1542
+ - Run **lint** and **typecheck** (and **tests** if available).
1543
+ - Run **production build** before declaring done.
1544
+ - If something fails, **revert** that batch and narrow the change.
1545
+
1546
+ ---
1547
+
1548
+ ## What not to do
1549
+
1550
+ - Do not delete **pages**, **app routes**, **middleware**, or framework entry files unless the user explicitly asks and you verified they are unused.
1551
+ - Do not remove packages because deps.md says "unused" without a repo-wide check.
1552
+ - Do not merge duplicate blocks from dupes.md without reading surrounding code.
1553
+
1554
+ When uncertain, **list the risk** and ask the user to confirm before editing.`;
1555
+ }
1556
+ function buildAiPromptTsx(meta) {
1557
+ const body = buildPromptBody(meta);
1558
+ const literal = JSON.stringify(body);
1559
+ return `/* eslint-disable */
1560
+ /**
1561
+ * Auto-generated by prunify ${meta.prunifyVersion} \u2014 ${meta.generatedAtIso}
1562
+ * Do not edit by hand; re-run \`npx prunify\` to refresh.
1563
+ *
1564
+ * **For Cursor / Copilot:** attach the files listed inside PRUNIFY_CLEANUP_AGENT_PROMPT
1565
+ * (from prunify-reports/), then paste the prompt or use @-mention on those files plus this file.
1566
+ */
1567
+ export const PRUNIFY_CLEANUP_AGENT_PROMPT = ${literal} as const
1568
+
1569
+ export default PRUNIFY_CLEANUP_AGENT_PROMPT
1570
+ `;
1571
+ }
1572
+
1346
1573
  // src/core/report-parser.ts
1347
1574
  import fs9 from "fs";
1348
1575
  import path9 from "path";
@@ -1364,7 +1591,7 @@ function parseDeadCodeReportContent(content, rootDir) {
1364
1591
  return [...new Set(paths)];
1365
1592
  }
1366
1593
  let section = "none";
1367
- const fileLineRe = /^\s{2}(.+?)\s+\(~[\d.]+\s+KB\)\s*$/;
1594
+ const fileLineRe = /^\s*(?:•\s+)?(.+?)\s+\(~[\d.]+\s+KB\)\s*$/;
1368
1595
  for (const line of lines) {
1369
1596
  if (line.includes("\u2500\u2500 SAFE TO DELETE \u2500\u2500")) {
1370
1597
  section = "safe";
@@ -1385,7 +1612,9 @@ function parseDeadCodeReportContent(content, rootDir) {
1385
1612
  continue;
1386
1613
  }
1387
1614
  if (section === "safe" || section === "transitive") {
1388
- if (line.includes("\u2514\u2500") || line.includes("also makes dead")) continue;
1615
+ if (line.includes("\u2514\u2500") || line.includes("\u21B3") || line.includes("also makes dead") || line.includes("SUBTOTAL") || line.includes("\u2500\u2500\u2500")) {
1616
+ continue;
1617
+ }
1389
1618
  const m = fileLineRe.exec(line);
1390
1619
  if (m) {
1391
1620
  const rel = m[1].trim();
@@ -1406,7 +1635,7 @@ function countDeadExportsInReport(content) {
1406
1635
  continue;
1407
1636
  }
1408
1637
  if (section && line.startsWith("\u2500\u2500 ") && line.includes("\u2500\u2500")) break;
1409
- if (section && /\s→\s.+\[line \d+\]/.test(line)) n++;
1638
+ if (section && /\(line \d+\)/.test(line)) n++;
1410
1639
  }
1411
1640
  return n;
1412
1641
  }
@@ -1414,7 +1643,7 @@ function parseDeadCodeReportSummary(content) {
1414
1643
  const safe = content.match(/Safe to delete\s*:\s*(\d+)/);
1415
1644
  const trans = content.match(/Transitively dead\s*:\s*(\d+)/);
1416
1645
  const exp = content.match(/Dead exports\s*:\s*(\d+)/);
1417
- const rec = content.match(/Recoverable\s*:\s*(~[\d.]+\s*KB)/i);
1646
+ const rec = content.match(/Recoverable[^:\n]*:\s*(~[\d.]+\s*KB)/i);
1418
1647
  if (!safe || !trans || !exp) return null;
1419
1648
  return {
1420
1649
  safeToDelete: Number(safe[1]),
@@ -1582,7 +1811,8 @@ async function main(opts) {
1582
1811
  }
1583
1812
  }
1584
1813
  if (loadAssetsFromCache) {
1585
- assetsReportFile = "assets.md";
1814
+ const assetsTxtPath = path10.join(reportsDir, "assets.txt");
1815
+ assetsReportFile = fs10.existsSync(assetsTxtPath) ? "assets.md, assets.txt" : "assets.md";
1586
1816
  unusedAssetCount = unusedAssetPaths.length;
1587
1817
  }
1588
1818
  if (runDeadCode2) {
@@ -1654,7 +1884,7 @@ async function main(opts) {
1654
1884
  const unusedAssets = await runAssetCheck(rootDir, { output: outputPath });
1655
1885
  unusedAssetPaths = unusedAssets.map((a) => a.filePath);
1656
1886
  unusedAssetCount = unusedAssets.length;
1657
- if (unusedAssetCount > 0) assetsReportFile = "assets.md";
1887
+ if (unusedAssetCount > 0) assetsReportFile = "assets.md, assets.txt";
1658
1888
  }
1659
1889
  if (modules.includes("health")) {
1660
1890
  const outputPath = path10.join(reportsDir, "health-report.md");
@@ -1694,6 +1924,16 @@ async function main(opts) {
1694
1924
  );
1695
1925
  console.log(table.toString());
1696
1926
  console.log();
1927
+ const aiPromptContent = buildAiPromptTsx({
1928
+ prunifyVersion: PKG_VERSION,
1929
+ generatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
1930
+ projectRootDisplay: path10.relative(process.cwd(), rootDir) || ".",
1931
+ reportsDirDisplay: path10.relative(rootDir, reportsDir).replaceAll("\\", "/") || "prunify-reports"
1932
+ });
1933
+ const aiPromptPath = path10.join(reportsDir, "ai_prompt.tsx");
1934
+ fs10.writeFileSync(aiPromptPath, aiPromptContent, "utf-8");
1935
+ console.log(chalk7.cyan(` Agent prompt saved \u2192 ${aiPromptPath}`));
1936
+ console.log();
1697
1937
  if (opts.delete && deadFilePaths.length > 0) {
1698
1938
  console.log(chalk7.yellow(`Dead code files to delete (${deadFilePaths.length}):`));
1699
1939
  for (const f of deadFilePaths) {