docrev 0.2.1 → 0.5.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/bin/rev.js CHANGED
@@ -1048,6 +1048,148 @@ program
1048
1048
  }
1049
1049
  });
1050
1050
 
1051
+ // ============================================================================
1052
+ // MERGE command - Combine feedback from multiple reviewers
1053
+ // ============================================================================
1054
+
1055
+ program
1056
+ .command('merge')
1057
+ .description('Merge feedback from multiple Word documents')
1058
+ .argument('<original>', 'Original markdown file')
1059
+ .argument('<docx...>', 'Word documents from reviewers')
1060
+ .option('-o, --output <file>', 'Output file (default: original-merged.md)')
1061
+ .option('--names <names>', 'Reviewer names (comma-separated, in order of docx files)')
1062
+ .option('--auto', 'Auto-resolve conflicts by taking first change')
1063
+ .option('--dry-run', 'Show conflicts without writing')
1064
+ .action(async (original, docxFiles, options) => {
1065
+ const { mergeReviewerDocs, formatConflict, applyChangesAsAnnotations, resolveConflict } = await import('../lib/merge.js');
1066
+
1067
+ if (!fs.existsSync(original)) {
1068
+ console.error(fmt.status('error', `Original file not found: ${original}`));
1069
+ process.exit(1);
1070
+ }
1071
+
1072
+ // Validate all docx files exist
1073
+ for (const docx of docxFiles) {
1074
+ if (!fs.existsSync(docx)) {
1075
+ console.error(fmt.status('error', `Reviewer file not found: ${docx}`));
1076
+ process.exit(1);
1077
+ }
1078
+ }
1079
+
1080
+ // Parse reviewer names
1081
+ const names = options.names
1082
+ ? options.names.split(',').map(n => n.trim())
1083
+ : docxFiles.map((f, i) => `Reviewer ${i + 1}`);
1084
+
1085
+ if (names.length < docxFiles.length) {
1086
+ // Pad with default names
1087
+ for (let i = names.length; i < docxFiles.length; i++) {
1088
+ names.push(`Reviewer ${i + 1}`);
1089
+ }
1090
+ }
1091
+
1092
+ const reviewerDocs = docxFiles.map((p, i) => ({
1093
+ path: p,
1094
+ name: names[i],
1095
+ }));
1096
+
1097
+ console.log(fmt.header('Multi-Reviewer Merge'));
1098
+ console.log();
1099
+ console.log(chalk.dim(` Original: ${original}`));
1100
+ console.log(chalk.dim(` Reviewers: ${names.join(', ')}`));
1101
+ console.log();
1102
+
1103
+ const spin = fmt.spinner('Analyzing changes...').start();
1104
+
1105
+ try {
1106
+ const { merged, conflicts, stats, originalText } = await mergeReviewerDocs(original, reviewerDocs, {
1107
+ autoResolve: options.auto,
1108
+ });
1109
+
1110
+ spin.stop();
1111
+
1112
+ // Show stats
1113
+ console.log(fmt.table(['Metric', 'Count'], [
1114
+ ['Total changes', stats.totalChanges.toString()],
1115
+ ['Non-conflicting', stats.nonConflicting.toString()],
1116
+ ['Conflicts', stats.conflicts.toString()],
1117
+ ['Comments', stats.comments.toString()],
1118
+ ]));
1119
+ console.log();
1120
+
1121
+ // Handle conflicts
1122
+ if (conflicts.length > 0) {
1123
+ console.log(chalk.yellow(`Found ${conflicts.length} conflict(s):\n`));
1124
+
1125
+ let resolvedMerged = merged;
1126
+
1127
+ for (let i = 0; i < conflicts.length; i++) {
1128
+ const conflict = conflicts[i];
1129
+ console.log(chalk.bold(`Conflict ${i + 1}/${conflicts.length}:`));
1130
+ console.log(formatConflict(conflict, originalText));
1131
+ console.log();
1132
+
1133
+ if (options.auto) {
1134
+ // Auto-resolve: take first reviewer's change
1135
+ console.log(chalk.dim(` Auto-resolved: using ${conflict.changes[0].reviewer}'s change`));
1136
+ resolvedMerged = resolveConflict(resolvedMerged, conflict, 0, originalText);
1137
+ } else if (!options.dryRun) {
1138
+ // Interactive resolution
1139
+ const rl = await import('readline');
1140
+ const readline = rl.createInterface({
1141
+ input: process.stdin,
1142
+ output: process.stdout,
1143
+ });
1144
+
1145
+ const answer = await new Promise((resolve) =>
1146
+ readline.question(chalk.cyan(` Choose (1-${conflict.changes.length}, s=skip): `), resolve)
1147
+ );
1148
+ readline.close();
1149
+
1150
+ if (answer.toLowerCase() !== 's' && !isNaN(parseInt(answer))) {
1151
+ const choice = parseInt(answer) - 1;
1152
+ if (choice >= 0 && choice < conflict.changes.length) {
1153
+ resolvedMerged = resolveConflict(resolvedMerged, conflict, choice, originalText);
1154
+ console.log(chalk.green(` Applied: ${conflict.changes[choice].reviewer}'s change`));
1155
+ }
1156
+ } else {
1157
+ console.log(chalk.dim(' Skipped'));
1158
+ }
1159
+ console.log();
1160
+ }
1161
+ }
1162
+
1163
+ if (!options.dryRun) {
1164
+ const outPath = options.output || original.replace(/\.md$/, '-merged.md');
1165
+ fs.writeFileSync(outPath, resolvedMerged, 'utf-8');
1166
+ console.log(fmt.status('success', `Merged output written to ${outPath}`));
1167
+ }
1168
+ } else {
1169
+ // No conflicts
1170
+ if (!options.dryRun) {
1171
+ const outPath = options.output || original.replace(/\.md$/, '-merged.md');
1172
+ fs.writeFileSync(outPath, merged, 'utf-8');
1173
+ console.log(fmt.status('success', `Merged output written to ${outPath}`));
1174
+ } else {
1175
+ console.log(fmt.status('info', 'Dry run - no output written'));
1176
+ }
1177
+ }
1178
+
1179
+ if (!options.dryRun && stats.nonConflicting > 0) {
1180
+ console.log();
1181
+ console.log(chalk.dim('Next steps:'));
1182
+ console.log(chalk.dim(' 1. rev review <merged.md> - Review all changes'));
1183
+ console.log(chalk.dim(' 2. rev comments <merged.md> - Address comments'));
1184
+ }
1185
+ } catch (err) {
1186
+ spin.stop();
1187
+ console.error(fmt.status('error', err.message));
1188
+ if (process.env.DEBUG) console.error(err.stack);
1189
+ process.exit(1);
1190
+ }
1191
+ });
1192
+
1051
1193
  // ============================================================================
1052
1194
  // REFS command - Show figure/table reference status
1053
1195
  // ============================================================================
@@ -1381,6 +1523,7 @@ program
1381
1523
  .option('-d, --dir <directory>', 'Project directory', '.')
1382
1524
  .option('--no-crossref', 'Skip pandoc-crossref filter')
1383
1525
  .option('--toc', 'Include table of contents')
1526
+ .option('--show-changes', 'Export DOCX with visible track changes (audit mode)')
1384
1527
  .action(async (formats, options) => {
1385
1528
  const dir = path.resolve(options.dir);
1386
1529
 
@@ -1414,6 +1557,7 @@ program
1414
1557
  console.log(chalk.dim(` Formats: ${targetFormats.join(', ')}`));
1415
1558
  console.log(chalk.dim(` Crossref: ${hasPandocCrossref() && options.crossref !== false ? 'enabled' : 'disabled'}`));
1416
1559
  if (tocEnabled) console.log(chalk.dim(` TOC: enabled`));
1560
+ if (options.showChanges) console.log(chalk.dim(` Track changes: visible`));
1417
1561
  console.log('');
1418
1562
 
1419
1563
  // Override config with CLI options
@@ -1422,6 +1566,57 @@ program
1422
1566
  config.docx.toc = true;
1423
1567
  }
1424
1568
 
1569
+ // Handle --show-changes mode (audit export)
1570
+ if (options.showChanges) {
1571
+ if (!targetFormats.includes('docx') && !targetFormats.includes('all')) {
1572
+ console.error(fmt.status('error', '--show-changes only applies to DOCX output'));
1573
+ process.exit(1);
1574
+ }
1575
+
1576
+ const { combineSections } = await import('../lib/build.js');
1577
+ const { buildWithTrackChanges } = await import('../lib/trackchanges.js');
1578
+
1579
+ const spin = fmt.spinner('Building with track changes...').start();
1580
+
1581
+ try {
1582
+ // Combine sections first
1583
+ const paperPath = combineSections(dir, config);
1584
+ spin.stop();
1585
+ console.log(chalk.cyan('Combined sections → paper.md'));
1586
+ console.log(chalk.dim(` ${paperPath}\n`));
1587
+
1588
+ // Build DOCX with track changes
1589
+ const baseName = config.title
1590
+ ? config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50)
1591
+ : 'paper';
1592
+ const outputPath = path.join(dir, `${baseName}-changes.docx`);
1593
+
1594
+ const spinTc = fmt.spinner('Applying track changes...').start();
1595
+ const result = await buildWithTrackChanges(paperPath, outputPath, {
1596
+ author: getUserName() || 'Author',
1597
+ });
1598
+ spinTc.stop();
1599
+
1600
+ if (result.success) {
1601
+ console.log(chalk.cyan('Output (with track changes):'));
1602
+ console.log(` DOCX: ${path.basename(outputPath)}`);
1603
+ if (result.stats) {
1604
+ console.log(chalk.dim(` ${result.stats.insertions} insertions, ${result.stats.deletions} deletions, ${result.stats.substitutions} substitutions`));
1605
+ }
1606
+ console.log(chalk.green('\nBuild complete!'));
1607
+ } else {
1608
+ console.error(fmt.status('error', result.message));
1609
+ process.exit(1);
1610
+ }
1611
+ } catch (err) {
1612
+ spin.stop();
1613
+ console.error(fmt.status('error', err.message));
1614
+ if (process.env.DEBUG) console.error(err.stack);
1615
+ process.exit(1);
1616
+ }
1617
+ return;
1618
+ }
1619
+
1425
1620
  const spin = fmt.spinner('Building...').start();
1426
1621
 
1427
1622
  try {
@@ -1726,6 +1921,103 @@ program
1726
1921
  console.log(fmt.status('success', `Created ${outputPath}`));
1727
1922
  });
1728
1923
 
1924
+ // ============================================================================
1925
+ // VALIDATE command - Check manuscript against journal requirements
1926
+ // ============================================================================
1927
+
1928
+ program
1929
+ .command('validate')
1930
+ .description('Validate manuscript against journal requirements')
1931
+ .argument('[files...]', 'Markdown files to validate (default: all section files)')
1932
+ .option('-j, --journal <name>', 'Journal profile (e.g., nature, plos-one, science)')
1933
+ .option('--list', 'List available journal profiles')
1934
+ .action(async (files, options) => {
1935
+ const { listJournals, validateProject, getJournalProfile } = await import('../lib/journals.js');
1936
+
1937
+ if (options.list) {
1938
+ console.log(fmt.header('Available Journal Profiles'));
1939
+ console.log();
1940
+ const journals = listJournals();
1941
+ for (const j of journals) {
1942
+ console.log(` ${chalk.bold(j.id)} - ${j.name}`);
1943
+ console.log(chalk.dim(` ${j.url}`));
1944
+ }
1945
+ console.log();
1946
+ console.log(chalk.dim('Usage: rev validate --journal <name>'));
1947
+ return;
1948
+ }
1949
+
1950
+ if (!options.journal) {
1951
+ console.error(fmt.status('error', 'Please specify a journal with --journal <name>'));
1952
+ console.error(chalk.dim('Use --list to see available profiles'));
1953
+ process.exit(1);
1954
+ }
1955
+
1956
+ const profile = getJournalProfile(options.journal);
1957
+ if (!profile) {
1958
+ console.error(fmt.status('error', `Unknown journal: ${options.journal}`));
1959
+ console.error(chalk.dim('Use --list to see available profiles'));
1960
+ process.exit(1);
1961
+ }
1962
+
1963
+ // Find files to validate
1964
+ let mdFiles = files;
1965
+ if (!mdFiles || mdFiles.length === 0) {
1966
+ mdFiles = fs.readdirSync('.').filter(f =>
1967
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
1968
+ );
1969
+ }
1970
+
1971
+ if (mdFiles.length === 0) {
1972
+ console.error(fmt.status('error', 'No markdown files found'));
1973
+ process.exit(1);
1974
+ }
1975
+
1976
+ console.log(fmt.header(`Validating for ${profile.name}`));
1977
+ console.log(chalk.dim(` ${profile.url}`));
1978
+ console.log();
1979
+
1980
+ const result = validateProject(mdFiles, options.journal);
1981
+
1982
+ // Show stats
1983
+ console.log(chalk.cyan('Manuscript Stats:'));
1984
+ console.log(fmt.table(['Metric', 'Value'], [
1985
+ ['Word count', result.stats.wordCount.toString()],
1986
+ ['Abstract', `${result.stats.abstractWords} words`],
1987
+ ['Title', `${result.stats.titleChars} chars`],
1988
+ ['Figures', result.stats.figures.toString()],
1989
+ ['Tables', result.stats.tables.toString()],
1990
+ ['References', result.stats.references.toString()],
1991
+ ]));
1992
+ console.log();
1993
+
1994
+ // Show errors
1995
+ if (result.errors.length > 0) {
1996
+ console.log(chalk.red('Errors:'));
1997
+ for (const err of result.errors) {
1998
+ console.log(chalk.red(` ✗ ${err}`));
1999
+ }
2000
+ console.log();
2001
+ }
2002
+
2003
+ // Show warnings
2004
+ if (result.warnings.length > 0) {
2005
+ console.log(chalk.yellow('Warnings:'));
2006
+ for (const warn of result.warnings) {
2007
+ console.log(chalk.yellow(` ⚠ ${warn}`));
2008
+ }
2009
+ console.log();
2010
+ }
2011
+
2012
+ // Summary
2013
+ if (result.valid) {
2014
+ console.log(fmt.status('success', `Manuscript meets ${profile.name} requirements`));
2015
+ } else {
2016
+ console.log(fmt.status('error', `Manuscript has ${result.errors.length} error(s)`));
2017
+ process.exit(1);
2018
+ }
2019
+ });
2020
+
1729
2021
  // ============================================================================
1730
2022
  // ANONYMIZE command - Prepare document for blind review
1731
2023
  // ============================================================================
@@ -2497,6 +2789,188 @@ program
2497
2789
  }
2498
2790
  });
2499
2791
 
2792
+ // ============================================================================
2793
+ // DIFF command - Compare sections against git history
2794
+ // ============================================================================
2795
+
2796
+ program
2797
+ .command('diff')
2798
+ .description('Compare sections against git history')
2799
+ .argument('[ref]', 'Git reference to compare against (default: main/master)')
2800
+ .option('-f, --files <files>', 'Specific files to compare (comma-separated)')
2801
+ .option('--stat', 'Show only statistics, not full diff')
2802
+ .action(async (ref, options) => {
2803
+ const {
2804
+ isGitRepo,
2805
+ getDefaultBranch,
2806
+ getCurrentBranch,
2807
+ getChangedFiles,
2808
+ getWordCountDiff,
2809
+ compareFileVersions,
2810
+ } = await import('../lib/git.js');
2811
+
2812
+ if (!isGitRepo()) {
2813
+ console.error(fmt.status('error', 'Not a git repository'));
2814
+ process.exit(1);
2815
+ }
2816
+
2817
+ const compareRef = ref || getDefaultBranch();
2818
+ const currentBranch = getCurrentBranch();
2819
+
2820
+ console.log(fmt.header('Git Diff'));
2821
+ console.log(chalk.dim(` Comparing: ${compareRef} → ${currentBranch || 'HEAD'}`));
2822
+ console.log();
2823
+
2824
+ // Get files to compare
2825
+ let filesToCompare;
2826
+ if (options.files) {
2827
+ filesToCompare = options.files.split(',').map(f => f.trim());
2828
+ } else {
2829
+ // Default to markdown section files
2830
+ filesToCompare = fs.readdirSync('.').filter(f =>
2831
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f)
2832
+ );
2833
+ }
2834
+
2835
+ if (filesToCompare.length === 0) {
2836
+ console.log(fmt.status('info', 'No markdown files found'));
2837
+ return;
2838
+ }
2839
+
2840
+ // Get changed files from git
2841
+ const changedFiles = getChangedFiles(compareRef);
2842
+ const changedSet = new Set(changedFiles.map(f => f.file));
2843
+
2844
+ // Get word count differences
2845
+ const { total, byFile } = getWordCountDiff(filesToCompare, compareRef);
2846
+
2847
+ // Show results
2848
+ const rows = [];
2849
+ for (const file of filesToCompare) {
2850
+ const stats = byFile[file];
2851
+ if (stats && (stats.added > 0 || stats.removed > 0)) {
2852
+ const status = changedSet.has(file)
2853
+ ? changedFiles.find(f => f.file === file)?.status || 'modified'
2854
+ : 'unchanged';
2855
+ rows.push([
2856
+ file,
2857
+ status,
2858
+ chalk.green(`+${stats.added}`),
2859
+ chalk.red(`-${stats.removed}`),
2860
+ ]);
2861
+ }
2862
+ }
2863
+
2864
+ if (rows.length === 0) {
2865
+ console.log(fmt.status('success', 'No changes detected'));
2866
+ return;
2867
+ }
2868
+
2869
+ console.log(fmt.table(['File', 'Status', 'Added', 'Removed'], rows));
2870
+ console.log();
2871
+ console.log(chalk.dim(`Total: ${chalk.green(`+${total.added}`)} words, ${chalk.red(`-${total.removed}`)} words`));
2872
+
2873
+ // Show detailed diff if not --stat
2874
+ if (!options.stat && rows.length > 0) {
2875
+ console.log();
2876
+ console.log(chalk.cyan('Changed sections:'));
2877
+ for (const file of filesToCompare) {
2878
+ const stats = byFile[file];
2879
+ if (stats && (stats.added > 0 || stats.removed > 0)) {
2880
+ const { changes } = compareFileVersions(file, compareRef);
2881
+ console.log(chalk.bold(`\n ${file}:`));
2882
+
2883
+ // Show first few significant changes
2884
+ let shown = 0;
2885
+ for (const change of changes) {
2886
+ if (shown >= 3) {
2887
+ console.log(chalk.dim(' ...'));
2888
+ break;
2889
+ }
2890
+ const preview = change.text.slice(0, 60).replace(/\n/g, ' ');
2891
+ if (change.type === 'add') {
2892
+ console.log(chalk.green(` + "${preview}..."`));
2893
+ } else {
2894
+ console.log(chalk.red(` - "${preview}..."`));
2895
+ }
2896
+ shown++;
2897
+ }
2898
+ }
2899
+ }
2900
+ }
2901
+ });
2902
+
2903
+ // ============================================================================
2904
+ // HISTORY command - Show revision history
2905
+ // ============================================================================
2906
+
2907
+ program
2908
+ .command('history')
2909
+ .description('Show revision history for section files')
2910
+ .argument('[file]', 'Specific file (default: all sections)')
2911
+ .option('-n, --limit <count>', 'Number of commits to show', '10')
2912
+ .action(async (file, options) => {
2913
+ const {
2914
+ isGitRepo,
2915
+ getFileHistory,
2916
+ getRecentCommits,
2917
+ hasUncommittedChanges,
2918
+ } = await import('../lib/git.js');
2919
+
2920
+ if (!isGitRepo()) {
2921
+ console.error(fmt.status('error', 'Not a git repository'));
2922
+ process.exit(1);
2923
+ }
2924
+
2925
+ const limit = parseInt(options.limit) || 10;
2926
+
2927
+ console.log(fmt.header('Revision History'));
2928
+ console.log();
2929
+
2930
+ if (file) {
2931
+ // Show history for specific file
2932
+ if (!fs.existsSync(file)) {
2933
+ console.error(fmt.status('error', `File not found: ${file}`));
2934
+ process.exit(1);
2935
+ }
2936
+
2937
+ const history = getFileHistory(file, limit);
2938
+
2939
+ if (history.length === 0) {
2940
+ console.log(fmt.status('info', 'No history found (file may not be committed)'));
2941
+ return;
2942
+ }
2943
+
2944
+ console.log(chalk.cyan(`History for ${file}:`));
2945
+ console.log();
2946
+
2947
+ for (const commit of history) {
2948
+ const date = new Date(commit.date).toLocaleDateString();
2949
+ console.log(` ${chalk.yellow(commit.hash)} ${chalk.dim(date)}`);
2950
+ console.log(` ${commit.message}`);
2951
+ }
2952
+ } else {
2953
+ // Show recent commits affecting any file
2954
+ const commits = getRecentCommits(limit);
2955
+
2956
+ if (commits.length === 0) {
2957
+ console.log(fmt.status('info', 'No commits found'));
2958
+ return;
2959
+ }
2960
+
2961
+ if (hasUncommittedChanges()) {
2962
+ console.log(chalk.yellow(' * Uncommitted changes'));
2963
+ console.log();
2964
+ }
2965
+
2966
+ for (const commit of commits) {
2967
+ const date = new Date(commit.date).toLocaleDateString();
2968
+ console.log(` ${chalk.yellow(commit.hash)} ${chalk.dim(date)} ${chalk.blue(commit.author)}`);
2969
+ console.log(` ${commit.message}`);
2970
+ }
2971
+ }
2972
+ });
2973
+
2500
2974
  // ============================================================================
2501
2975
  // HELP command - Comprehensive help
2502
2976
  // ============================================================================
@@ -2859,4 +3333,1656 @@ ${chalk.bold('rev help')} [topic]
2859
3333
  `);
2860
3334
  }
2861
3335
 
3336
+ // ============================================================================
3337
+ // COMPLETIONS command - Shell completions
3338
+ // ============================================================================
3339
+
3340
+ program
3341
+ .command('completions')
3342
+ .description('Output shell completions')
3343
+ .argument('<shell>', 'Shell type: bash, zsh')
3344
+ .action((shell) => {
3345
+ const completionsDir = path.join(import.meta.dirname, '..', 'completions');
3346
+
3347
+ if (shell === 'bash') {
3348
+ const bashFile = path.join(completionsDir, 'rev.bash');
3349
+ if (fs.existsSync(bashFile)) {
3350
+ console.log(fs.readFileSync(bashFile, 'utf-8'));
3351
+ } else {
3352
+ console.error(chalk.red('Bash completions not found'));
3353
+ process.exit(1);
3354
+ }
3355
+ } else if (shell === 'zsh') {
3356
+ const zshFile = path.join(completionsDir, 'rev.zsh');
3357
+ if (fs.existsSync(zshFile)) {
3358
+ console.log(fs.readFileSync(zshFile, 'utf-8'));
3359
+ } else {
3360
+ console.error(chalk.red('Zsh completions not found'));
3361
+ process.exit(1);
3362
+ }
3363
+ } else {
3364
+ console.error(chalk.red(`Unknown shell: ${shell}`));
3365
+ console.log(chalk.dim('Supported shells: bash, zsh'));
3366
+ process.exit(1);
3367
+ }
3368
+ });
3369
+
3370
+ // ============================================================================
3371
+ // WORD-COUNT command - Per-section word counts
3372
+ // ============================================================================
3373
+
3374
+ program
3375
+ .command('word-count')
3376
+ .alias('wc')
3377
+ .description('Show word counts per section')
3378
+ .option('-l, --limit <number>', 'Warn if total exceeds limit', parseInt)
3379
+ .option('-j, --journal <name>', 'Use journal word limit')
3380
+ .action(async (options) => {
3381
+ let config = {};
3382
+ try {
3383
+ config = loadBuildConfig() || {};
3384
+ } catch {
3385
+ // Not in a rev project, that's ok
3386
+ }
3387
+ const sections = config.sections || [];
3388
+
3389
+ if (sections.length === 0) {
3390
+ // Try to find .md files
3391
+ const mdFiles = fs.readdirSync('.').filter(f =>
3392
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
3393
+ );
3394
+ if (mdFiles.length === 0) {
3395
+ console.error(chalk.red('No section files found. Run from a rev project directory.'));
3396
+ process.exit(1);
3397
+ }
3398
+ sections.push(...mdFiles);
3399
+ }
3400
+
3401
+ const countWords = (text) => {
3402
+ return text
3403
+ .replace(/^---[\s\S]*?---/m, '')
3404
+ .replace(/!\[.*?\]\(.*?\)/g, '')
3405
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
3406
+ .replace(/#+\s*/g, '')
3407
+ .replace(/\*\*|__|[*_`]/g, '')
3408
+ .replace(/```[\s\S]*?```/g, '')
3409
+ .replace(/\{[^}]+\}/g, '')
3410
+ .replace(/@\w+:\w+/g, '')
3411
+ .replace(/@\w+/g, '')
3412
+ .replace(/\|[^|]+\|/g, ' ')
3413
+ .replace(/\n+/g, ' ')
3414
+ .trim()
3415
+ .split(/\s+/)
3416
+ .filter(w => w.length > 0).length;
3417
+ };
3418
+
3419
+ let total = 0;
3420
+ const rows = [];
3421
+
3422
+ for (const section of sections) {
3423
+ if (!fs.existsSync(section)) continue;
3424
+ const text = fs.readFileSync(section, 'utf-8');
3425
+ const words = countWords(text);
3426
+ total += words;
3427
+ rows.push([section, words.toLocaleString()]);
3428
+ }
3429
+
3430
+ rows.push(['', '']);
3431
+ rows.push([chalk.bold('Total'), chalk.bold(total.toLocaleString())]);
3432
+
3433
+ console.log(fmt.header('Word Count'));
3434
+ console.log(fmt.table(['Section', 'Words'], rows));
3435
+
3436
+ // Check limit
3437
+ let limit = options.limit;
3438
+ if (options.journal) {
3439
+ const { getJournalProfile } = await import('../lib/journals.js');
3440
+ const profile = getJournalProfile(options.journal);
3441
+ if (profile?.requirements?.wordLimit?.main) {
3442
+ limit = profile.requirements.wordLimit.main;
3443
+ console.log(chalk.dim(`\nUsing ${profile.name} word limit: ${limit.toLocaleString()}`));
3444
+ }
3445
+ }
3446
+
3447
+ if (limit && total > limit) {
3448
+ console.log(chalk.red(`\n⚠ Over limit by ${(total - limit).toLocaleString()} words`));
3449
+ } else if (limit) {
3450
+ console.log(chalk.green(`\n✓ Within limit (${(limit - total).toLocaleString()} words remaining)`));
3451
+ }
3452
+ });
3453
+
3454
+ // ============================================================================
3455
+ // STATS command - Project dashboard
3456
+ // ============================================================================
3457
+
3458
+ program
3459
+ .command('stats')
3460
+ .description('Show project statistics dashboard')
3461
+ .action(async () => {
3462
+ let config = {};
3463
+ try {
3464
+ config = loadBuildConfig() || {};
3465
+ } catch {
3466
+ // Not in a rev project, that's ok
3467
+ }
3468
+ let sections = config.sections || [];
3469
+
3470
+ if (sections.length === 0) {
3471
+ sections = fs.readdirSync('.').filter(f =>
3472
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
3473
+ );
3474
+ }
3475
+
3476
+ const countWords = (text) => {
3477
+ return text
3478
+ .replace(/^---[\s\S]*?---/m, '')
3479
+ .replace(/!\[.*?\]\(.*?\)/g, '')
3480
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
3481
+ .replace(/[#*_`]/g, '')
3482
+ .replace(/\{[^}]+\}/g, '')
3483
+ .replace(/@\w+/g, '')
3484
+ .replace(/\n+/g, ' ')
3485
+ .trim()
3486
+ .split(/\s+/)
3487
+ .filter(w => w.length > 0).length;
3488
+ };
3489
+
3490
+ let totalWords = 0;
3491
+ let totalFigures = 0;
3492
+ let totalTables = 0;
3493
+ let totalComments = 0;
3494
+ let pendingComments = 0;
3495
+ const citations = new Set();
3496
+
3497
+ for (const section of sections) {
3498
+ if (!fs.existsSync(section)) continue;
3499
+ const text = fs.readFileSync(section, 'utf-8');
3500
+
3501
+ totalWords += countWords(text);
3502
+ totalFigures += (text.match(/!\[.*?\]\(.*?\)/g) || []).length;
3503
+ totalTables += (text.match(/^\|[^|]+\|/gm) || []).length / 5; // Approximate
3504
+
3505
+ const comments = getComments(text);
3506
+ totalComments += comments.length;
3507
+ pendingComments += comments.filter(c => !c.resolved).length;
3508
+
3509
+ const cites = text.match(/@(\w+)(?![:\w])/g) || [];
3510
+ cites.forEach(c => citations.add(c.slice(1)));
3511
+ }
3512
+
3513
+ console.log(fmt.header('Project Statistics'));
3514
+ console.log();
3515
+
3516
+ const stats = [
3517
+ ['Sections', sections.length],
3518
+ ['Words', totalWords.toLocaleString()],
3519
+ ['Figures', Math.round(totalFigures)],
3520
+ ['Tables', Math.round(totalTables)],
3521
+ ['Citations', citations.size],
3522
+ ['Comments', `${totalComments} (${pendingComments} pending)`],
3523
+ ];
3524
+
3525
+ for (const [label, value] of stats) {
3526
+ console.log(` ${chalk.dim(label.padEnd(12))} ${chalk.bold(value)}`);
3527
+ }
3528
+
3529
+ // Bibliography stats
3530
+ const bibPath = config.bibliography || 'references.bib';
3531
+ if (fs.existsSync(bibPath)) {
3532
+ const bibContent = fs.readFileSync(bibPath, 'utf-8');
3533
+ const bibEntries = (bibContent.match(/@\w+\s*\{/g) || []).length;
3534
+ console.log(` ${chalk.dim('Bib entries'.padEnd(12))} ${chalk.bold(bibEntries)}`);
3535
+ }
3536
+
3537
+ console.log();
3538
+ });
3539
+
3540
+ // ============================================================================
3541
+ // SEARCH command - Search across section files
3542
+ // ============================================================================
3543
+
3544
+ program
3545
+ .command('search')
3546
+ .description('Search across all section files')
3547
+ .argument('<query>', 'Search query (supports regex)')
3548
+ .option('-i, --ignore-case', 'Case-insensitive search')
3549
+ .option('-c, --context <lines>', 'Show context lines', parseInt, 1)
3550
+ .action((query, options) => {
3551
+ let config = {};
3552
+ try {
3553
+ config = loadBuildConfig() || {};
3554
+ } catch {
3555
+ // Not in a rev project, that's ok
3556
+ }
3557
+ let sections = config.sections || [];
3558
+
3559
+ if (sections.length === 0) {
3560
+ sections = fs.readdirSync('.').filter(f =>
3561
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f)
3562
+ );
3563
+ }
3564
+
3565
+ const flags = options.ignoreCase ? 'gi' : 'g';
3566
+ let pattern;
3567
+ try {
3568
+ pattern = new RegExp(query, flags);
3569
+ } catch {
3570
+ pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
3571
+ }
3572
+
3573
+ let totalMatches = 0;
3574
+
3575
+ for (const section of sections) {
3576
+ if (!fs.existsSync(section)) continue;
3577
+ const text = fs.readFileSync(section, 'utf-8');
3578
+ const lines = text.split('\n');
3579
+
3580
+ const matches = [];
3581
+ for (let i = 0; i < lines.length; i++) {
3582
+ if (pattern.test(lines[i])) {
3583
+ matches.push({ line: i + 1, text: lines[i] });
3584
+ pattern.lastIndex = 0;
3585
+ }
3586
+ }
3587
+
3588
+ if (matches.length > 0) {
3589
+ console.log(chalk.cyan.bold(`\n${section}`));
3590
+ for (const match of matches) {
3591
+ const highlighted = match.text.replace(pattern, (m) => chalk.yellow.bold(m));
3592
+ console.log(` ${chalk.dim(match.line + ':')} ${highlighted}`);
3593
+ }
3594
+ totalMatches += matches.length;
3595
+ }
3596
+ }
3597
+
3598
+ if (totalMatches === 0) {
3599
+ console.log(chalk.yellow(`No matches found for "${query}"`));
3600
+ } else {
3601
+ console.log(chalk.dim(`\n${totalMatches} match${totalMatches === 1 ? '' : 'es'} found`));
3602
+ }
3603
+ });
3604
+
3605
+ // ============================================================================
3606
+ // BACKUP command - Timestamped project backup
3607
+ // ============================================================================
3608
+
3609
+ program
3610
+ .command('backup')
3611
+ .description('Create timestamped project backup')
3612
+ .option('-n, --name <name>', 'Custom backup name')
3613
+ .option('-o, --output <dir>', 'Output directory', '.')
3614
+ .action(async (options) => {
3615
+ const { default: AdmZip } = await import('adm-zip');
3616
+ const zip = new AdmZip();
3617
+
3618
+ const date = new Date().toISOString().slice(0, 10);
3619
+ const name = options.name || `backup-${date}`;
3620
+ const outputPath = path.join(options.output, `${name}.zip`);
3621
+
3622
+ // Files to include
3623
+ const includePatterns = [
3624
+ '*.md', '*.yaml', '*.yml', '*.bib', '*.csl',
3625
+ 'figures/*', 'images/*', 'assets/*'
3626
+ ];
3627
+
3628
+ // Files to exclude
3629
+ const excludePatterns = [
3630
+ 'node_modules', '.git', '*.docx', '*.pdf', '*.zip',
3631
+ 'paper.md' // Generated file
3632
+ ];
3633
+
3634
+ const shouldInclude = (file) => {
3635
+ for (const pattern of excludePatterns) {
3636
+ if (file.includes(pattern.replace('*', ''))) return false;
3637
+ }
3638
+ return true;
3639
+ };
3640
+
3641
+ const addDir = (dir, zipPath = '') => {
3642
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
3643
+ for (const entry of entries) {
3644
+ const fullPath = path.join(dir, entry.name);
3645
+ const entryZipPath = path.join(zipPath, entry.name);
3646
+
3647
+ if (!shouldInclude(entry.name)) continue;
3648
+
3649
+ if (entry.isDirectory()) {
3650
+ addDir(fullPath, entryZipPath);
3651
+ } else {
3652
+ zip.addLocalFile(fullPath, zipPath || undefined);
3653
+ }
3654
+ }
3655
+ };
3656
+
3657
+ // Add current directory
3658
+ const entries = fs.readdirSync('.', { withFileTypes: true });
3659
+ for (const entry of entries) {
3660
+ if (!shouldInclude(entry.name)) continue;
3661
+
3662
+ if (entry.isDirectory()) {
3663
+ addDir(entry.name, entry.name);
3664
+ } else if (entry.isFile()) {
3665
+ zip.addLocalFile(entry.name);
3666
+ }
3667
+ }
3668
+
3669
+ zip.writeZip(outputPath);
3670
+ console.log(fmt.status('success', `Backup created: ${outputPath}`));
3671
+ });
3672
+
3673
+ // ============================================================================
3674
+ // EXPORT command - Export project as distributable zip
3675
+ // ============================================================================
3676
+
3677
+ program
3678
+ .command('export')
3679
+ .description('Export project as distributable zip')
3680
+ .option('-o, --output <file>', 'Output filename')
3681
+ .option('--include-output', 'Include built PDF/DOCX files')
3682
+ .action(async (options) => {
3683
+ const { default: AdmZip } = await import('adm-zip');
3684
+ let config = {};
3685
+ try {
3686
+ config = loadBuildConfig() || {};
3687
+ } catch {
3688
+ // Not in a rev project, that's ok
3689
+ }
3690
+
3691
+ // Build first if including output
3692
+ if (options.includeOutput) {
3693
+ console.log(chalk.dim('Building documents...'));
3694
+ await build(['pdf', 'docx']);
3695
+ }
3696
+
3697
+ const zip = new AdmZip();
3698
+ const projectName = config.title?.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'project';
3699
+ const outputPath = options.output || `${projectName}-export.zip`;
3700
+
3701
+ const exclude = ['node_modules', '.git', '.DS_Store', '*.zip'];
3702
+
3703
+ const shouldInclude = (name) => {
3704
+ if (!options.includeOutput && (name.endsWith('.pdf') || name.endsWith('.docx'))) {
3705
+ return false;
3706
+ }
3707
+ for (const pattern of exclude) {
3708
+ if (name === pattern || name.includes(pattern.replace('*', ''))) return false;
3709
+ }
3710
+ return true;
3711
+ };
3712
+
3713
+ const addDir = (dir, zipPath = '') => {
3714
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
3715
+ for (const entry of entries) {
3716
+ const fullPath = path.join(dir, entry.name);
3717
+ const entryZipPath = path.join(zipPath, entry.name);
3718
+
3719
+ if (!shouldInclude(entry.name)) continue;
3720
+
3721
+ if (entry.isDirectory()) {
3722
+ addDir(fullPath, entryZipPath);
3723
+ } else {
3724
+ zip.addLocalFile(fullPath, zipPath || undefined);
3725
+ }
3726
+ }
3727
+ };
3728
+
3729
+ const entries = fs.readdirSync('.', { withFileTypes: true });
3730
+ for (const entry of entries) {
3731
+ if (!shouldInclude(entry.name)) continue;
3732
+
3733
+ if (entry.isDirectory()) {
3734
+ addDir(entry.name, entry.name);
3735
+ } else if (entry.isFile()) {
3736
+ zip.addLocalFile(entry.name);
3737
+ }
3738
+ }
3739
+
3740
+ zip.writeZip(outputPath);
3741
+ console.log(fmt.status('success', `Exported: ${outputPath}`));
3742
+ });
3743
+
3744
+ // ============================================================================
3745
+ // PREVIEW command - Build and open document
3746
+ // ============================================================================
3747
+
3748
+ program
3749
+ .command('preview')
3750
+ .description('Build and open document in default app')
3751
+ .argument('[format]', 'Format to preview: pdf, docx', 'pdf')
3752
+ .action(async (format) => {
3753
+ const { exec } = require('child_process');
3754
+ let config = {};
3755
+ try {
3756
+ config = loadBuildConfig() || {};
3757
+ } catch (err) {
3758
+ console.error(chalk.red('Not in a rev project directory (no rev.yaml found)'));
3759
+ process.exit(1);
3760
+ }
3761
+
3762
+ console.log(chalk.dim(`Building ${format}...`));
3763
+ const results = await build([format]);
3764
+
3765
+ const result = results.find(r => r.format === format);
3766
+ if (!result?.success) {
3767
+ console.error(chalk.red(`Build failed: ${result?.error || 'Unknown error'}`));
3768
+ process.exit(1);
3769
+ }
3770
+
3771
+ const outputFile = result.output;
3772
+ if (!fs.existsSync(outputFile)) {
3773
+ console.error(chalk.red(`Output file not found: ${outputFile}`));
3774
+ process.exit(1);
3775
+ }
3776
+
3777
+ // Open with system default
3778
+ const openCmd = process.platform === 'darwin' ? 'open' :
3779
+ process.platform === 'win32' ? 'start' : 'xdg-open';
3780
+
3781
+ exec(`${openCmd} "${outputFile}"`, (err) => {
3782
+ if (err) {
3783
+ console.error(chalk.red(`Could not open file: ${err.message}`));
3784
+ } else {
3785
+ console.log(fmt.status('success', `Opened ${outputFile}`));
3786
+ }
3787
+ });
3788
+ });
3789
+
3790
+ // ============================================================================
3791
+ // WATCH command - Auto-rebuild on changes
3792
+ // ============================================================================
3793
+
3794
+ program
3795
+ .command('watch')
3796
+ .description('Watch files and auto-rebuild on changes')
3797
+ .argument('[format]', 'Format to build: pdf, docx, all', 'pdf')
3798
+ .option('--no-open', 'Do not open after first build')
3799
+ .action(async (format, options) => {
3800
+ const { exec } = require('child_process');
3801
+ let config = {};
3802
+ try {
3803
+ config = loadBuildConfig() || {};
3804
+ } catch (err) {
3805
+ console.error(chalk.red('Not in a rev project directory (no rev.yaml found)'));
3806
+ process.exit(1);
3807
+ }
3808
+ let sections = config.sections || [];
3809
+
3810
+ if (sections.length === 0) {
3811
+ sections = fs.readdirSync('.').filter(f =>
3812
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
3813
+ );
3814
+ }
3815
+
3816
+ const filesToWatch = [
3817
+ ...sections,
3818
+ 'rev.yaml',
3819
+ config.bibliography || 'references.bib'
3820
+ ].filter(f => fs.existsSync(f));
3821
+
3822
+ console.log(fmt.header('Watch Mode'));
3823
+ console.log(chalk.dim(`Watching: ${filesToWatch.join(', ')}`));
3824
+ console.log(chalk.dim('Press Ctrl+C to stop\n'));
3825
+
3826
+ let building = false;
3827
+ let pendingBuild = false;
3828
+
3829
+ const doBuild = async () => {
3830
+ if (building) {
3831
+ pendingBuild = true;
3832
+ return;
3833
+ }
3834
+
3835
+ building = true;
3836
+ console.log(chalk.dim(`\n[${new Date().toLocaleTimeString()}] Rebuilding...`));
3837
+
3838
+ try {
3839
+ const formats = format === 'all' ? ['pdf', 'docx'] : [format];
3840
+ const results = await build(formats);
3841
+
3842
+ for (const r of results) {
3843
+ if (r.success) {
3844
+ console.log(chalk.green(` ✓ ${r.format}: ${r.output}`));
3845
+ } else {
3846
+ console.log(chalk.red(` ✗ ${r.format}: ${r.error}`));
3847
+ }
3848
+ }
3849
+ } catch (err) {
3850
+ console.error(chalk.red(` Build error: ${err.message}`));
3851
+ }
3852
+
3853
+ building = false;
3854
+ if (pendingBuild) {
3855
+ pendingBuild = false;
3856
+ doBuild();
3857
+ }
3858
+ };
3859
+
3860
+ // Initial build
3861
+ await doBuild();
3862
+
3863
+ // Open after first build
3864
+ if (options.open) {
3865
+ const outputFile = format === 'docx' ?
3866
+ (config.title?.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'paper') + '.docx' :
3867
+ (config.title?.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'paper') + '.pdf';
3868
+
3869
+ if (fs.existsSync(outputFile)) {
3870
+ const openCmd = process.platform === 'darwin' ? 'open' :
3871
+ process.platform === 'win32' ? 'start' : 'xdg-open';
3872
+ exec(`${openCmd} "${outputFile}"`);
3873
+ }
3874
+ }
3875
+
3876
+ // Watch files
3877
+ for (const file of filesToWatch) {
3878
+ fs.watch(file, { persistent: true }, (eventType) => {
3879
+ if (eventType === 'change') {
3880
+ doBuild();
3881
+ }
3882
+ });
3883
+ }
3884
+ });
3885
+
3886
+ // ============================================================================
3887
+ // LINT command - Check for common issues
3888
+ // ============================================================================
3889
+
3890
+ program
3891
+ .command('lint')
3892
+ .description('Check for common issues in the project')
3893
+ .option('--fix', 'Auto-fix issues where possible')
3894
+ .action(async (options) => {
3895
+ let config = {};
3896
+ try {
3897
+ config = loadBuildConfig() || {};
3898
+ } catch {
3899
+ // Not in a rev project, that's ok
3900
+ }
3901
+ let sections = config.sections || [];
3902
+
3903
+ if (sections.length === 0) {
3904
+ sections = fs.readdirSync('.').filter(f =>
3905
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
3906
+ );
3907
+ }
3908
+
3909
+ const issues = [];
3910
+ const warnings = [];
3911
+
3912
+ // Collect all content
3913
+ let allText = '';
3914
+ for (const section of sections) {
3915
+ if (fs.existsSync(section)) {
3916
+ allText += fs.readFileSync(section, 'utf-8') + '\n';
3917
+ }
3918
+ }
3919
+
3920
+ // Check 1: Broken cross-references
3921
+ const figAnchors = new Set();
3922
+ const tblAnchors = new Set();
3923
+ const eqAnchors = new Set();
3924
+
3925
+ const anchorPattern = /\{#(fig|tbl|eq):([^}]+)\}/g;
3926
+ let match;
3927
+ while ((match = anchorPattern.exec(allText)) !== null) {
3928
+ if (match[1] === 'fig') figAnchors.add(match[2]);
3929
+ else if (match[1] === 'tbl') tblAnchors.add(match[2]);
3930
+ else if (match[1] === 'eq') eqAnchors.add(match[2]);
3931
+ }
3932
+
3933
+ const refPattern = /@(fig|tbl|eq):([a-zA-Z0-9_-]+)/g;
3934
+ while ((match = refPattern.exec(allText)) !== null) {
3935
+ const type = match[1];
3936
+ const label = match[2];
3937
+ const anchors = type === 'fig' ? figAnchors : type === 'tbl' ? tblAnchors : eqAnchors;
3938
+
3939
+ if (!anchors.has(label)) {
3940
+ issues.push({
3941
+ type: 'error',
3942
+ message: `Broken reference: @${type}:${label}`,
3943
+ fix: null
3944
+ });
3945
+ }
3946
+ }
3947
+
3948
+ // Check 2: Orphaned figures (defined but not referenced)
3949
+ for (const label of figAnchors) {
3950
+ if (!allText.includes(`@fig:${label}`)) {
3951
+ warnings.push({
3952
+ type: 'warning',
3953
+ message: `Unreferenced figure: {#fig:${label}}`,
3954
+ });
3955
+ }
3956
+ }
3957
+
3958
+ // Check 3: Missing citations
3959
+ const bibPath = config.bibliography || 'references.bib';
3960
+ if (fs.existsSync(bibPath)) {
3961
+ const bibContent = fs.readFileSync(bibPath, 'utf-8');
3962
+ const bibKeys = new Set();
3963
+ const bibPattern = /@\w+\s*\{\s*([^,]+)/g;
3964
+ while ((match = bibPattern.exec(bibContent)) !== null) {
3965
+ bibKeys.add(match[1].trim());
3966
+ }
3967
+
3968
+ const citePattern = /@([a-zA-Z][a-zA-Z0-9_-]*)(?![:\w])/g;
3969
+ while ((match = citePattern.exec(allText)) !== null) {
3970
+ const key = match[1];
3971
+ if (!bibKeys.has(key) && !['fig', 'tbl', 'eq'].includes(key)) {
3972
+ issues.push({
3973
+ type: 'error',
3974
+ message: `Missing citation: @${key}`,
3975
+ });
3976
+ }
3977
+ }
3978
+ }
3979
+
3980
+ // Check 4: Unresolved comments
3981
+ const comments = getComments(allText);
3982
+ const pending = comments.filter(c => !c.resolved);
3983
+ if (pending.length > 0) {
3984
+ warnings.push({
3985
+ type: 'warning',
3986
+ message: `${pending.length} unresolved comment${pending.length === 1 ? '' : 's'}`,
3987
+ });
3988
+ }
3989
+
3990
+ // Check 5: Empty sections
3991
+ for (const section of sections) {
3992
+ if (fs.existsSync(section)) {
3993
+ const content = fs.readFileSync(section, 'utf-8').trim();
3994
+ if (content.length < 50) {
3995
+ warnings.push({
3996
+ type: 'warning',
3997
+ message: `Section appears empty: ${section}`,
3998
+ });
3999
+ }
4000
+ }
4001
+ }
4002
+
4003
+ // Output results
4004
+ console.log(fmt.header('Lint Results'));
4005
+ console.log();
4006
+
4007
+ if (issues.length === 0 && warnings.length === 0) {
4008
+ console.log(chalk.green('✓ No issues found'));
4009
+ return;
4010
+ }
4011
+
4012
+ for (const issue of issues) {
4013
+ console.log(chalk.red(` ✗ ${issue.message}`));
4014
+ }
4015
+
4016
+ for (const warning of warnings) {
4017
+ console.log(chalk.yellow(` ⚠ ${warning.message}`));
4018
+ }
4019
+
4020
+ console.log();
4021
+ console.log(chalk.dim(`${issues.length} error${issues.length === 1 ? '' : 's'}, ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`));
4022
+
4023
+ if (issues.length > 0) {
4024
+ process.exit(1);
4025
+ }
4026
+ });
4027
+
4028
+ // ============================================================================
4029
+ // GRAMMAR command - Check grammar and style
4030
+ // ============================================================================
4031
+
4032
+ program
4033
+ .command('grammar')
4034
+ .description('Check grammar and style issues')
4035
+ .argument('[files...]', 'Markdown files to check')
4036
+ .option('--learn <word>', 'Add word to custom dictionary')
4037
+ .option('--forget <word>', 'Remove word from custom dictionary')
4038
+ .option('--list', 'List custom dictionary words')
4039
+ .option('--rules', 'List available grammar rules')
4040
+ .option('--no-scientific', 'Disable scientific writing rules')
4041
+ .option('-s, --severity <level>', 'Minimum severity: error, warning, info', 'info')
4042
+ .action(async (files, options) => {
4043
+ const {
4044
+ checkGrammar,
4045
+ getGrammarSummary,
4046
+ loadDictionary,
4047
+ addToDictionary,
4048
+ removeFromDictionary,
4049
+ listRules,
4050
+ } = await import('../lib/grammar.js');
4051
+
4052
+ // Handle dictionary management
4053
+ if (options.learn) {
4054
+ const added = addToDictionary(options.learn);
4055
+ if (added) {
4056
+ console.log(fmt.status('success', `Added "${options.learn}" to dictionary`));
4057
+ } else {
4058
+ console.log(chalk.dim(`"${options.learn}" already in dictionary`));
4059
+ }
4060
+ return;
4061
+ }
4062
+
4063
+ if (options.forget) {
4064
+ const removed = removeFromDictionary(options.forget);
4065
+ if (removed) {
4066
+ console.log(fmt.status('success', `Removed "${options.forget}" from dictionary`));
4067
+ } else {
4068
+ console.log(chalk.yellow(`"${options.forget}" not in dictionary`));
4069
+ }
4070
+ return;
4071
+ }
4072
+
4073
+ if (options.list) {
4074
+ const words = loadDictionary();
4075
+ console.log(fmt.header('Custom Dictionary'));
4076
+ console.log();
4077
+ if (words.size === 0) {
4078
+ console.log(chalk.dim(' No custom words defined'));
4079
+ console.log(chalk.dim(' Use --learn <word> to add words'));
4080
+ } else {
4081
+ const sorted = [...words].sort();
4082
+ for (const word of sorted) {
4083
+ console.log(` ${word}`);
4084
+ }
4085
+ console.log();
4086
+ console.log(chalk.dim(`${words.size} word(s)`));
4087
+ }
4088
+ return;
4089
+ }
4090
+
4091
+ if (options.rules) {
4092
+ const rules = listRules(options.scientific);
4093
+ console.log(fmt.header('Grammar Rules'));
4094
+ console.log();
4095
+ for (const rule of rules) {
4096
+ const icon = rule.severity === 'error' ? chalk.red('●') :
4097
+ rule.severity === 'warning' ? chalk.yellow('●') :
4098
+ chalk.blue('●');
4099
+ console.log(` ${icon} ${chalk.bold(rule.id)}`);
4100
+ console.log(chalk.dim(` ${rule.message}`));
4101
+ }
4102
+ return;
4103
+ }
4104
+
4105
+ // Get files to check
4106
+ let mdFiles = files;
4107
+ if (!mdFiles || mdFiles.length === 0) {
4108
+ let config = {};
4109
+ try {
4110
+ config = loadBuildConfig() || {};
4111
+ } catch {
4112
+ // Not in a rev project
4113
+ }
4114
+ mdFiles = config.sections || [];
4115
+
4116
+ if (mdFiles.length === 0) {
4117
+ mdFiles = fs.readdirSync('.').filter(f =>
4118
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
4119
+ );
4120
+ }
4121
+ }
4122
+
4123
+ if (mdFiles.length === 0) {
4124
+ console.error(chalk.red('No markdown files found'));
4125
+ process.exit(1);
4126
+ }
4127
+
4128
+ console.log(fmt.header('Grammar Check'));
4129
+ console.log();
4130
+
4131
+ const severityLevels = { error: 3, warning: 2, info: 1 };
4132
+ const minSeverity = severityLevels[options.severity] || 1;
4133
+
4134
+ let allIssues = [];
4135
+
4136
+ for (const file of mdFiles) {
4137
+ if (!fs.existsSync(file)) continue;
4138
+
4139
+ const text = fs.readFileSync(file, 'utf-8');
4140
+ const issues = checkGrammar(text, { scientific: options.scientific });
4141
+
4142
+ // Filter by severity
4143
+ const filtered = issues.filter(i => severityLevels[i.severity] >= minSeverity);
4144
+
4145
+ if (filtered.length > 0) {
4146
+ console.log(chalk.cyan.bold(file));
4147
+
4148
+ for (const issue of filtered) {
4149
+ const icon = issue.severity === 'error' ? chalk.red('●') :
4150
+ issue.severity === 'warning' ? chalk.yellow('●') :
4151
+ chalk.blue('●');
4152
+
4153
+ console.log(` ${chalk.dim(`L${issue.line}:`)} ${icon} ${issue.message}`);
4154
+ console.log(chalk.dim(` "${issue.match}" in: ${issue.context.slice(0, 60)}...`));
4155
+ }
4156
+ console.log();
4157
+ allIssues.push(...filtered.map(i => ({ ...i, file })));
4158
+ }
4159
+ }
4160
+
4161
+ const summary = getGrammarSummary(allIssues);
4162
+
4163
+ if (summary.total === 0) {
4164
+ console.log(chalk.green('✓ No issues found'));
4165
+ } else {
4166
+ console.log(chalk.dim(`Found ${summary.total} issue(s): ${summary.errors} errors, ${summary.warnings} warnings, ${summary.info} info`));
4167
+ console.log();
4168
+ console.log(chalk.dim('Tip: Use --learn <word> to add words to dictionary'));
4169
+ }
4170
+ });
4171
+
4172
+ // ============================================================================
4173
+ // ANNOTATE command - Add comments to Word document
4174
+ // ============================================================================
4175
+
4176
+ program
4177
+ .command('annotate')
4178
+ .description('Add comment to Word document')
4179
+ .argument('<docx>', 'Word document')
4180
+ .option('-m, --message <text>', 'Comment text')
4181
+ .option('-s, --search <text>', 'Text to attach comment to')
4182
+ .option('-a, --author <name>', 'Comment author')
4183
+ .action(async (docxPath, options) => {
4184
+ if (!fs.existsSync(docxPath)) {
4185
+ console.error(chalk.red(`File not found: ${docxPath}`));
4186
+ process.exit(1);
4187
+ }
4188
+
4189
+ if (!options.message) {
4190
+ console.error(chalk.red('Comment message required (-m)'));
4191
+ process.exit(1);
4192
+ }
4193
+
4194
+ const { default: AdmZip } = await import('adm-zip');
4195
+ const zip = new AdmZip(docxPath);
4196
+
4197
+ // Read document.xml
4198
+ const docEntry = zip.getEntry('word/document.xml');
4199
+ if (!docEntry) {
4200
+ console.error(chalk.red('Invalid Word document'));
4201
+ process.exit(1);
4202
+ }
4203
+
4204
+ let docXml = zip.readAsText(docEntry);
4205
+
4206
+ // Read or create comments.xml
4207
+ let commentsEntry = zip.getEntry('word/comments.xml');
4208
+ let commentsXml;
4209
+ let nextCommentId = 1;
4210
+
4211
+ if (commentsEntry) {
4212
+ commentsXml = zip.readAsText(commentsEntry);
4213
+ // Find highest existing comment ID
4214
+ const idMatches = commentsXml.match(/w:id="(\d+)"/g) || [];
4215
+ for (const m of idMatches) {
4216
+ const id = parseInt(m.match(/\d+/)[0]);
4217
+ if (id >= nextCommentId) nextCommentId = id + 1;
4218
+ }
4219
+ } else {
4220
+ commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4221
+ <w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4222
+ </w:comments>`;
4223
+ }
4224
+
4225
+ const author = options.author || getUserName() || 'Claude';
4226
+ const date = new Date().toISOString();
4227
+ const commentId = nextCommentId;
4228
+
4229
+ // Add comment to comments.xml
4230
+ const newComment = `<w:comment w:id="${commentId}" w:author="${author}" w:date="${date}">
4231
+ <w:p><w:r><w:t>${options.message}</w:t></w:r></w:p>
4232
+ </w:comment>`;
4233
+
4234
+ commentsXml = commentsXml.replace('</w:comments>', `${newComment}\n</w:comments>`);
4235
+
4236
+ // Find text and add comment markers
4237
+ if (options.search) {
4238
+ const searchText = options.search;
4239
+ const textPattern = new RegExp(`(<w:t[^>]*>)([^<]*${searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^<]*)(<\/w:t>)`, 'i');
4240
+
4241
+ if (textPattern.test(docXml)) {
4242
+ docXml = docXml.replace(textPattern, (match, start, text, end) => {
4243
+ return `<w:commentRangeStart w:id="${commentId}"/>${start}${text}${end}<w:commentRangeEnd w:id="${commentId}"/><w:r><w:commentReference w:id="${commentId}"/></w:r>`;
4244
+ });
4245
+ } else {
4246
+ console.log(chalk.yellow(`Text "${searchText}" not found in document. Comment added without anchor.`));
4247
+ }
4248
+ }
4249
+
4250
+ // Update zip
4251
+ zip.updateFile('word/document.xml', Buffer.from(docXml));
4252
+
4253
+ if (commentsEntry) {
4254
+ zip.updateFile('word/comments.xml', Buffer.from(commentsXml));
4255
+ } else {
4256
+ zip.addFile('word/comments.xml', Buffer.from(commentsXml));
4257
+
4258
+ // Update [Content_Types].xml to include comments
4259
+ const ctEntry = zip.getEntry('[Content_Types].xml');
4260
+ if (ctEntry) {
4261
+ let ctXml = zip.readAsText(ctEntry);
4262
+ if (!ctXml.includes('comments.xml')) {
4263
+ ctXml = ctXml.replace('</Types>',
4264
+ '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>\n</Types>');
4265
+ zip.updateFile('[Content_Types].xml', Buffer.from(ctXml));
4266
+ }
4267
+ }
4268
+
4269
+ // Update document.xml.rels
4270
+ const relsEntry = zip.getEntry('word/_rels/document.xml.rels');
4271
+ if (relsEntry) {
4272
+ let relsXml = zip.readAsText(relsEntry);
4273
+ if (!relsXml.includes('comments.xml')) {
4274
+ const newRelId = `rId${Date.now()}`;
4275
+ relsXml = relsXml.replace('</Relationships>',
4276
+ `<Relationship Id="${newRelId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>\n</Relationships>`);
4277
+ zip.updateFile('word/_rels/document.xml.rels', Buffer.from(relsXml));
4278
+ }
4279
+ }
4280
+ }
4281
+
4282
+ // Write back
4283
+ zip.writeZip(docxPath);
4284
+ console.log(fmt.status('success', `Added comment to ${docxPath}`));
4285
+ });
4286
+
4287
+ // ============================================================================
4288
+ // APPLY command - Apply MD annotations as Word track changes
4289
+ // ============================================================================
4290
+
4291
+ program
4292
+ .command('apply')
4293
+ .description('Apply markdown annotations to Word document as track changes')
4294
+ .argument('<md>', 'Markdown file with annotations')
4295
+ .argument('<docx>', 'Output Word document')
4296
+ .option('-a, --author <name>', 'Author name for track changes')
4297
+ .action(async (mdPath, docxPath, options) => {
4298
+ if (!fs.existsSync(mdPath)) {
4299
+ console.error(chalk.red(`File not found: ${mdPath}`));
4300
+ process.exit(1);
4301
+ }
4302
+
4303
+ const mdContent = fs.readFileSync(mdPath, 'utf-8');
4304
+ const annotations = parseAnnotations(mdContent);
4305
+
4306
+ if (annotations.length === 0) {
4307
+ console.log(chalk.yellow('No annotations found in markdown file'));
4308
+ // Still build the document
4309
+ }
4310
+
4311
+ const author = options.author || getUserName() || 'Author';
4312
+
4313
+ // Build document with track changes
4314
+ const { buildWithTrackChanges } = await import('../lib/trackchanges.js');
4315
+
4316
+ try {
4317
+ const result = await buildWithTrackChanges(mdPath, docxPath, { author });
4318
+
4319
+ if (result.success) {
4320
+ console.log(fmt.status('success', result.message));
4321
+ console.log(chalk.dim(` ${annotations.length} annotations applied as track changes`));
4322
+ } else {
4323
+ console.error(chalk.red(result.message));
4324
+ process.exit(1);
4325
+ }
4326
+ } catch (err) {
4327
+ console.error(chalk.red(`Error: ${err.message}`));
4328
+ process.exit(1);
4329
+ }
4330
+ });
4331
+
4332
+ // ============================================================================
4333
+ // COMMENT command - Interactive comment addition to DOCX
4334
+ // ============================================================================
4335
+
4336
+ program
4337
+ .command('comment')
4338
+ .description('Add comments to Word document interactively')
4339
+ .argument('<docx>', 'Word document')
4340
+ .option('-a, --author <name>', 'Comment author')
4341
+ .action(async (docxPath, options) => {
4342
+ if (!fs.existsSync(docxPath)) {
4343
+ console.error(chalk.red(`File not found: ${docxPath}`));
4344
+ process.exit(1);
4345
+ }
4346
+
4347
+ const { default: AdmZip } = await import('adm-zip');
4348
+ const rl = (await import('readline')).createInterface({
4349
+ input: process.stdin,
4350
+ output: process.stdout,
4351
+ });
4352
+
4353
+ const ask = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
4354
+
4355
+ const author = options.author || getUserName() || 'Reviewer';
4356
+
4357
+ console.log(fmt.header('Interactive Comment Mode'));
4358
+ console.log(chalk.dim(` Document: ${docxPath}`));
4359
+ console.log(chalk.dim(` Author: ${author}`));
4360
+ console.log(chalk.dim(' Type your comment, then the text to attach it to.'));
4361
+ console.log(chalk.dim(' Enter empty comment to quit.\n'));
4362
+
4363
+ let commentsAdded = 0;
4364
+
4365
+ while (true) {
4366
+ const message = await ask(chalk.cyan('Comment: '));
4367
+
4368
+ if (!message.trim()) {
4369
+ break;
4370
+ }
4371
+
4372
+ const searchText = await ask(chalk.cyan('Attach to text: '));
4373
+
4374
+ // Load document fresh each time
4375
+ const zip = new AdmZip(docxPath);
4376
+ const docEntry = zip.getEntry('word/document.xml');
4377
+
4378
+ if (!docEntry) {
4379
+ console.error(chalk.red('Invalid Word document'));
4380
+ rl.close();
4381
+ process.exit(1);
4382
+ }
4383
+
4384
+ let docXml = zip.readAsText(docEntry);
4385
+
4386
+ // Read or create comments.xml
4387
+ let commentsEntry = zip.getEntry('word/comments.xml');
4388
+ let commentsXml;
4389
+ let nextCommentId = 1;
4390
+
4391
+ if (commentsEntry) {
4392
+ commentsXml = zip.readAsText(commentsEntry);
4393
+ const idMatches = commentsXml.match(/w:id="(\d+)"/g) || [];
4394
+ for (const m of idMatches) {
4395
+ const id = parseInt(m.match(/\d+/)[0]);
4396
+ if (id >= nextCommentId) nextCommentId = id + 1;
4397
+ }
4398
+ } else {
4399
+ commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
4400
+ <w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
4401
+ </w:comments>`;
4402
+ }
4403
+
4404
+ const date = new Date().toISOString();
4405
+ const commentId = nextCommentId;
4406
+
4407
+ // Add comment to comments.xml
4408
+ const newComment = `<w:comment w:id="${commentId}" w:author="${author}" w:date="${date}">
4409
+ <w:p><w:r><w:t>${message}</w:t></w:r></w:p>
4410
+ </w:comment>`;
4411
+
4412
+ commentsXml = commentsXml.replace('</w:comments>', `${newComment}\n</w:comments>`);
4413
+
4414
+ // Find text and add comment markers
4415
+ if (searchText.trim()) {
4416
+ const escapedSearch = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4417
+ const textPattern = new RegExp(`(<w:t[^>]*>)([^<]*${escapedSearch}[^<]*)(<\/w:t>)`, 'i');
4418
+
4419
+ if (textPattern.test(docXml)) {
4420
+ docXml = docXml.replace(textPattern, (match, start, text, end) => {
4421
+ return `<w:commentRangeStart w:id="${commentId}"/>${start}${text}${end}<w:commentRangeEnd w:id="${commentId}"/><w:r><w:commentReference w:id="${commentId}"/></w:r>`;
4422
+ });
4423
+ console.log(chalk.green(` ✓ Comment added at "${searchText}"`));
4424
+ } else {
4425
+ console.log(chalk.yellow(` Text not found. Comment added without anchor.`));
4426
+ }
4427
+ } else {
4428
+ console.log(chalk.dim(` Comment added without anchor.`));
4429
+ }
4430
+
4431
+ // Update zip
4432
+ zip.updateFile('word/document.xml', Buffer.from(docXml));
4433
+
4434
+ if (commentsEntry) {
4435
+ zip.updateFile('word/comments.xml', Buffer.from(commentsXml));
4436
+ } else {
4437
+ zip.addFile('word/comments.xml', Buffer.from(commentsXml));
4438
+
4439
+ // Update [Content_Types].xml
4440
+ const ctEntry = zip.getEntry('[Content_Types].xml');
4441
+ if (ctEntry) {
4442
+ let ctXml = zip.readAsText(ctEntry);
4443
+ if (!ctXml.includes('comments.xml')) {
4444
+ ctXml = ctXml.replace('</Types>',
4445
+ '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>\n</Types>');
4446
+ zip.updateFile('[Content_Types].xml', Buffer.from(ctXml));
4447
+ }
4448
+ }
4449
+
4450
+ // Update document.xml.rels
4451
+ const relsEntry = zip.getEntry('word/_rels/document.xml.rels');
4452
+ if (relsEntry) {
4453
+ let relsXml = zip.readAsText(relsEntry);
4454
+ if (!relsXml.includes('comments.xml')) {
4455
+ const newRelId = `rId${Date.now()}`;
4456
+ relsXml = relsXml.replace('</Relationships>',
4457
+ `<Relationship Id="${newRelId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>\n</Relationships>`);
4458
+ zip.updateFile('word/_rels/document.xml.rels', Buffer.from(relsXml));
4459
+ }
4460
+ }
4461
+ }
4462
+
4463
+ zip.writeZip(docxPath);
4464
+ commentsAdded++;
4465
+ console.log();
4466
+ }
4467
+
4468
+ rl.close();
4469
+ console.log();
4470
+
4471
+ if (commentsAdded > 0) {
4472
+ console.log(fmt.status('success', `Added ${commentsAdded} comment(s) to ${docxPath}`));
4473
+ } else {
4474
+ console.log(chalk.dim('No comments added.'));
4475
+ }
4476
+ });
4477
+
4478
+ // ============================================================================
4479
+ // CLEAN command - Remove generated files
4480
+ // ============================================================================
4481
+
4482
+ program
4483
+ .command('clean')
4484
+ .description('Remove generated files (paper.md, PDFs, DOCXs)')
4485
+ .option('-n, --dry-run', 'Show what would be deleted without deleting')
4486
+ .option('--all', 'Also remove backup and export zips')
4487
+ .action((options) => {
4488
+ let config = {};
4489
+ try {
4490
+ config = loadBuildConfig() || {};
4491
+ } catch {
4492
+ // Not in a rev project, that's ok
4493
+ }
4494
+
4495
+ const projectName = config.title?.toLowerCase().replace(/\s+/g, '-') || 'paper';
4496
+
4497
+ // Files to clean
4498
+ const patterns = [
4499
+ 'paper.md',
4500
+ '*.pdf',
4501
+ `${projectName}.docx`,
4502
+ `${projectName}.pdf`,
4503
+ `${projectName}.tex`,
4504
+ '.paper-*.md', // Temp build files
4505
+ ];
4506
+
4507
+ if (options.all) {
4508
+ patterns.push('*.zip', 'backup-*.zip', '*-export.zip');
4509
+ }
4510
+
4511
+ const toDelete = [];
4512
+
4513
+ for (const pattern of patterns) {
4514
+ if (pattern.includes('*')) {
4515
+ // Glob pattern
4516
+ const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
4517
+ const files = fs.readdirSync('.').filter(f => regex.test(f));
4518
+ toDelete.push(...files);
4519
+ } else if (fs.existsSync(pattern)) {
4520
+ toDelete.push(pattern);
4521
+ }
4522
+ }
4523
+
4524
+ if (toDelete.length === 0) {
4525
+ console.log(chalk.dim('No generated files to clean.'));
4526
+ return;
4527
+ }
4528
+
4529
+ console.log(fmt.header('Clean'));
4530
+ console.log();
4531
+
4532
+ for (const file of toDelete) {
4533
+ if (options.dryRun) {
4534
+ console.log(chalk.dim(` Would delete: ${file}`));
4535
+ } else {
4536
+ fs.unlinkSync(file);
4537
+ console.log(chalk.red(` Deleted: ${file}`));
4538
+ }
4539
+ }
4540
+
4541
+ console.log();
4542
+ if (options.dryRun) {
4543
+ console.log(chalk.dim(`Would delete ${toDelete.length} file(s). Run without --dry-run to delete.`));
4544
+ } else {
4545
+ console.log(fmt.status('success', `Cleaned ${toDelete.length} file(s)`));
4546
+ }
4547
+ });
4548
+
4549
+ // ============================================================================
4550
+ // CHECK command - Pre-submission check (lint + grammar + citations)
4551
+ // ============================================================================
4552
+
4553
+ program
4554
+ .command('check')
4555
+ .description('Run all checks before submission (lint + grammar + citations)')
4556
+ .option('--fix', 'Auto-fix issues where possible')
4557
+ .option('-s, --severity <level>', 'Minimum grammar severity', 'warning')
4558
+ .action(async (options) => {
4559
+ console.log(fmt.header('Pre-Submission Check'));
4560
+ console.log();
4561
+
4562
+ let hasErrors = false;
4563
+ let totalIssues = 0;
4564
+
4565
+ // 1. Run lint
4566
+ console.log(chalk.cyan.bold('1. Linting...'));
4567
+ let config = {};
4568
+ try {
4569
+ config = loadBuildConfig() || {};
4570
+ } catch {
4571
+ // Not in a rev project
4572
+ }
4573
+
4574
+ let sections = config.sections || [];
4575
+ if (sections.length === 0) {
4576
+ sections = fs.readdirSync('.').filter(f =>
4577
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
4578
+ );
4579
+ }
4580
+
4581
+ const lintIssues = [];
4582
+ const lintWarnings = [];
4583
+
4584
+ for (const file of sections) {
4585
+ if (!fs.existsSync(file)) continue;
4586
+ const content = fs.readFileSync(file, 'utf-8');
4587
+
4588
+ // Check for broken cross-references
4589
+ const refs = content.match(/@(fig|tbl|eq|sec):\w+/g) || [];
4590
+ const anchors = content.match(/\{#(fig|tbl|eq|sec):[^}]+\}/g) || [];
4591
+ const anchorLabels = anchors.map(a => a.match(/#([^}]+)/)[1]);
4592
+
4593
+ for (const ref of refs) {
4594
+ const label = ref.slice(1);
4595
+ if (!anchorLabels.includes(label)) {
4596
+ lintIssues.push({ file, message: `Broken reference: ${ref}` });
4597
+ }
4598
+ }
4599
+
4600
+ // Check for unresolved comments
4601
+ const unresolvedComments = (content.match(/\{>>[^<]*<<\}/g) || [])
4602
+ .filter(c => !c.includes('[RESOLVED]'));
4603
+ if (unresolvedComments.length > 0) {
4604
+ lintWarnings.push({ file, message: `${unresolvedComments.length} unresolved comment(s)` });
4605
+ }
4606
+ }
4607
+
4608
+ if (lintIssues.length > 0) {
4609
+ for (const issue of lintIssues) {
4610
+ console.log(chalk.red(` ✗ ${issue.file}: ${issue.message}`));
4611
+ }
4612
+ hasErrors = true;
4613
+ totalIssues += lintIssues.length;
4614
+ }
4615
+ for (const warning of lintWarnings) {
4616
+ console.log(chalk.yellow(` ⚠ ${warning.file}: ${warning.message}`));
4617
+ totalIssues++;
4618
+ }
4619
+ if (lintIssues.length === 0 && lintWarnings.length === 0) {
4620
+ console.log(chalk.green(' ✓ No lint issues'));
4621
+ }
4622
+ console.log();
4623
+
4624
+ // 2. Run grammar check
4625
+ console.log(chalk.cyan.bold('2. Grammar check...'));
4626
+ const {
4627
+ checkGrammar,
4628
+ getGrammarSummary,
4629
+ } = await import('../lib/grammar.js');
4630
+
4631
+ const severityLevels = { error: 3, warning: 2, info: 1 };
4632
+ const minSeverity = severityLevels[options.severity] || 2;
4633
+ let grammarIssues = [];
4634
+
4635
+ for (const file of sections) {
4636
+ if (!fs.existsSync(file)) continue;
4637
+ const text = fs.readFileSync(file, 'utf-8');
4638
+ const issues = checkGrammar(text, { scientific: true });
4639
+ const filtered = issues.filter(i => severityLevels[i.severity] >= minSeverity);
4640
+ grammarIssues.push(...filtered.map(i => ({ ...i, file })));
4641
+ }
4642
+
4643
+ const grammarSummary = getGrammarSummary(grammarIssues);
4644
+ if (grammarSummary.errors > 0) {
4645
+ hasErrors = true;
4646
+ }
4647
+ totalIssues += grammarSummary.total;
4648
+
4649
+ if (grammarSummary.total > 0) {
4650
+ console.log(chalk.yellow(` ⚠ ${grammarSummary.total} grammar issue(s): ${grammarSummary.errors} errors, ${grammarSummary.warnings} warnings`));
4651
+ } else {
4652
+ console.log(chalk.green(' ✓ No grammar issues'));
4653
+ }
4654
+ console.log();
4655
+
4656
+ // 3. Run citation check
4657
+ console.log(chalk.cyan.bold('3. Citation check...'));
4658
+ const bibFile = config.bibliography || 'references.bib';
4659
+ if (fs.existsSync(bibFile)) {
4660
+ const { validateCitations } = await import('../lib/citations.js');
4661
+ const allContent = sections
4662
+ .filter(f => fs.existsSync(f))
4663
+ .map(f => fs.readFileSync(f, 'utf-8'))
4664
+ .join('\n');
4665
+ const bibContent = fs.readFileSync(bibFile, 'utf-8');
4666
+
4667
+ const result = validateCitations(allContent, bibContent);
4668
+
4669
+ if (result.missing.length > 0) {
4670
+ console.log(chalk.red(` ✗ ${result.missing.length} missing citation(s): ${result.missing.slice(0, 3).join(', ')}${result.missing.length > 3 ? '...' : ''}`));
4671
+ hasErrors = true;
4672
+ totalIssues += result.missing.length;
4673
+ }
4674
+ if (result.unused.length > 0) {
4675
+ console.log(chalk.yellow(` ⚠ ${result.unused.length} unused citation(s)`));
4676
+ totalIssues += result.unused.length;
4677
+ }
4678
+ if (result.missing.length === 0 && result.unused.length === 0) {
4679
+ console.log(chalk.green(' ✓ All citations valid'));
4680
+ }
4681
+ } else {
4682
+ console.log(chalk.dim(' - No bibliography file found'));
4683
+ }
4684
+ console.log();
4685
+
4686
+ // Summary
4687
+ console.log(chalk.bold('Summary'));
4688
+ if (hasErrors) {
4689
+ console.log(chalk.red(` ${totalIssues} issue(s) found. Please fix before submission.`));
4690
+ process.exit(1);
4691
+ } else if (totalIssues > 0) {
4692
+ console.log(chalk.yellow(` ${totalIssues} warning(s). Review before submission.`));
4693
+ } else {
4694
+ console.log(chalk.green(' ✓ All checks passed! Ready for submission.'));
4695
+ }
4696
+ });
4697
+
4698
+ // ============================================================================
4699
+ // OPEN command - Open project folder or file
4700
+ // ============================================================================
4701
+
4702
+ program
4703
+ .command('open')
4704
+ .description('Open project folder or file in default app')
4705
+ .argument('[file]', 'File to open (default: project folder)')
4706
+ .action(async (file) => {
4707
+ const { exec } = await import('child_process');
4708
+ const target = file || '.';
4709
+
4710
+ if (!fs.existsSync(target)) {
4711
+ console.error(chalk.red(`File not found: ${target}`));
4712
+ process.exit(1);
4713
+ }
4714
+
4715
+ // Platform-specific open command
4716
+ const platform = process.platform;
4717
+ let command;
4718
+
4719
+ if (platform === 'darwin') {
4720
+ command = `open "${target}"`;
4721
+ } else if (platform === 'win32') {
4722
+ command = `start "" "${target}"`;
4723
+ } else {
4724
+ command = `xdg-open "${target}"`;
4725
+ }
4726
+
4727
+ exec(command, (err) => {
4728
+ if (err) {
4729
+ console.error(chalk.red(`Failed to open: ${err.message}`));
4730
+ process.exit(1);
4731
+ }
4732
+ console.log(fmt.status('success', `Opened ${target}`));
4733
+ });
4734
+ });
4735
+
4736
+ // ============================================================================
4737
+ // INSTALL-CLI-SKILL command - Install Claude Code skill
4738
+ // ============================================================================
4739
+
4740
+ program
4741
+ .command('install-cli-skill')
4742
+ .description('Install docrev skill for Claude Code')
4743
+ .action(() => {
4744
+ const homedir = process.env.HOME || process.env.USERPROFILE;
4745
+ const skillDir = path.join(homedir, '.claude', 'skills', 'docrev');
4746
+ const sourceDir = path.join(import.meta.dirname, '..', 'skill');
4747
+
4748
+ // Check if source skill files exist
4749
+ const skillFile = path.join(sourceDir, 'SKILL.md');
4750
+ if (!fs.existsSync(skillFile)) {
4751
+ console.error(chalk.red('Skill files not found in package'));
4752
+ process.exit(1);
4753
+ }
4754
+
4755
+ // Create skill directory
4756
+ fs.mkdirSync(skillDir, { recursive: true });
4757
+
4758
+ // Copy skill files
4759
+ const files = ['SKILL.md', 'REFERENCE.md'];
4760
+ for (const file of files) {
4761
+ const src = path.join(sourceDir, file);
4762
+ const dest = path.join(skillDir, file);
4763
+ if (fs.existsSync(src)) {
4764
+ fs.copyFileSync(src, dest);
4765
+ }
4766
+ }
4767
+
4768
+ console.log(fmt.status('success', 'Installed docrev skill for Claude Code'));
4769
+ console.log(chalk.dim(` Location: ${skillDir}`));
4770
+ console.log(chalk.dim(' Restart Claude Code to activate'));
4771
+ });
4772
+
4773
+ program
4774
+ .command('uninstall-cli-skill')
4775
+ .description('Remove docrev skill from Claude Code')
4776
+ .action(() => {
4777
+ const homedir = process.env.HOME || process.env.USERPROFILE;
4778
+ const skillDir = path.join(homedir, '.claude', 'skills', 'docrev');
4779
+
4780
+ if (fs.existsSync(skillDir)) {
4781
+ fs.rmSync(skillDir, { recursive: true });
4782
+ console.log(fmt.status('success', 'Removed docrev skill from Claude Code'));
4783
+ } else {
4784
+ console.log(chalk.yellow('Skill not installed'));
4785
+ }
4786
+ });
4787
+
4788
+ // ============================================================================
4789
+ // SPELLING command - Spellcheck with global dictionary
4790
+ // ============================================================================
4791
+
4792
+ program
4793
+ .command('spelling')
4794
+ .description('Check spelling in markdown files')
4795
+ .argument('[files...]', 'Files to check (default: section files)')
4796
+ .option('--learn <word>', 'Add word to global dictionary')
4797
+ .option('--learn-project <word>', 'Add word to project dictionary')
4798
+ .option('--forget <word>', 'Remove word from global dictionary')
4799
+ .option('--forget-project <word>', 'Remove word from project dictionary')
4800
+ .option('--list', 'List global dictionary words')
4801
+ .option('--list-project', 'List project dictionary words')
4802
+ .option('--list-all', 'List all custom words (global + project)')
4803
+ .option('--british', 'Use British English dictionary')
4804
+ .option('--add-names', 'Add detected names to global dictionary')
4805
+ .action(async (files, options) => {
4806
+ const spelling = await import('../lib/spelling.js');
4807
+
4808
+ // Handle dictionary management
4809
+ if (options.learn) {
4810
+ const added = spelling.addWord(options.learn, true);
4811
+ if (added) {
4812
+ console.log(fmt.status('success', `Added "${options.learn}" to global dictionary`));
4813
+ } else {
4814
+ console.log(chalk.yellow(`"${options.learn}" already in dictionary`));
4815
+ }
4816
+ return;
4817
+ }
4818
+
4819
+ if (options.learnProject) {
4820
+ const added = spelling.addWord(options.learnProject, false);
4821
+ if (added) {
4822
+ console.log(fmt.status('success', `Added "${options.learnProject}" to project dictionary`));
4823
+ } else {
4824
+ console.log(chalk.yellow(`"${options.learnProject}" already in dictionary`));
4825
+ }
4826
+ return;
4827
+ }
4828
+
4829
+ if (options.forget) {
4830
+ const removed = spelling.removeWord(options.forget, true);
4831
+ if (removed) {
4832
+ console.log(fmt.status('success', `Removed "${options.forget}" from global dictionary`));
4833
+ } else {
4834
+ console.log(chalk.yellow(`"${options.forget}" not in dictionary`));
4835
+ }
4836
+ return;
4837
+ }
4838
+
4839
+ if (options.forgetProject) {
4840
+ const removed = spelling.removeWord(options.forgetProject, false);
4841
+ if (removed) {
4842
+ console.log(fmt.status('success', `Removed "${options.forgetProject}" from project dictionary`));
4843
+ } else {
4844
+ console.log(chalk.yellow(`"${options.forgetProject}" not in dictionary`));
4845
+ }
4846
+ return;
4847
+ }
4848
+
4849
+ if (options.list) {
4850
+ const words = spelling.listWords(true);
4851
+ console.log(fmt.header('Global Dictionary'));
4852
+ if (words.length === 0) {
4853
+ console.log(chalk.dim(' No custom words'));
4854
+ console.log(chalk.dim(' Use --learn <word> to add words'));
4855
+ } else {
4856
+ for (const word of words) {
4857
+ console.log(` ${word}`);
4858
+ }
4859
+ console.log(chalk.dim(`\n${words.length} word(s)`));
4860
+ }
4861
+ return;
4862
+ }
4863
+
4864
+ if (options.listProject) {
4865
+ const words = spelling.listWords(false);
4866
+ console.log(fmt.header('Project Dictionary'));
4867
+ if (words.length === 0) {
4868
+ console.log(chalk.dim(' No custom words'));
4869
+ console.log(chalk.dim(' Use --learn-project <word> to add words'));
4870
+ } else {
4871
+ for (const word of words) {
4872
+ console.log(` ${word}`);
4873
+ }
4874
+ console.log(chalk.dim(`\n${words.length} word(s)`));
4875
+ }
4876
+ return;
4877
+ }
4878
+
4879
+ if (options.listAll) {
4880
+ const globalWords = spelling.listWords(true);
4881
+ const projectWords = spelling.listWords(false);
4882
+
4883
+ console.log(fmt.header('Global Dictionary'));
4884
+ if (globalWords.length === 0) {
4885
+ console.log(chalk.dim(' No custom words'));
4886
+ } else {
4887
+ for (const word of globalWords) {
4888
+ console.log(` ${word}`);
4889
+ }
4890
+ }
4891
+
4892
+ console.log(fmt.header('Project Dictionary'));
4893
+ if (projectWords.length === 0) {
4894
+ console.log(chalk.dim(' No custom words'));
4895
+ } else {
4896
+ for (const word of projectWords) {
4897
+ console.log(` ${word}`);
4898
+ }
4899
+ }
4900
+
4901
+ console.log(chalk.dim(`\nTotal: ${globalWords.length + projectWords.length} word(s)`));
4902
+ return;
4903
+ }
4904
+
4905
+ // Check spelling in files
4906
+ let filesToCheck = files;
4907
+
4908
+ if (filesToCheck.length === 0) {
4909
+ // Default to section files if in a project
4910
+ if (fs.existsSync('rev.yaml')) {
4911
+ const { getSectionFiles } = await import('../lib/sections.js');
4912
+ filesToCheck = getSectionFiles('.');
4913
+ } else {
4914
+ // Check all .md files in current directory
4915
+ filesToCheck = fs.readdirSync('.')
4916
+ .filter(f => f.endsWith('.md') && !f.startsWith('.'));
4917
+ }
4918
+ }
4919
+
4920
+ if (filesToCheck.length === 0) {
4921
+ console.log(chalk.yellow('No markdown files found'));
4922
+ return;
4923
+ }
4924
+
4925
+ const lang = options.british ? 'en-gb' : 'en';
4926
+ console.log(fmt.header(`Spelling Check (${options.british ? 'British' : 'US'} English)`));
4927
+ let totalMisspelled = 0;
4928
+ const allNames = new Set();
4929
+
4930
+ for (const file of filesToCheck) {
4931
+ if (!fs.existsSync(file)) {
4932
+ console.log(chalk.yellow(`File not found: ${file}`));
4933
+ continue;
4934
+ }
4935
+
4936
+ const result = await spelling.checkFile(file, { projectDir: '.', lang });
4937
+ const { misspelled, possibleNames } = result;
4938
+
4939
+ // Collect names
4940
+ for (const n of possibleNames) {
4941
+ allNames.add(n.word);
4942
+ }
4943
+
4944
+ if (misspelled.length > 0) {
4945
+ console.log(chalk.cyan(`\n${file}:`));
4946
+ for (const issue of misspelled) {
4947
+ const suggestions = issue.suggestions.length > 0
4948
+ ? chalk.dim(` → ${issue.suggestions.join(', ')}`)
4949
+ : '';
4950
+ console.log(` ${chalk.yellow(issue.word)} ${chalk.dim(`(line ${issue.line})`)}${suggestions}`);
4951
+ }
4952
+ totalMisspelled += misspelled.length;
4953
+ }
4954
+ }
4955
+
4956
+ // Show possible names separately
4957
+ if (allNames.size > 0) {
4958
+ const nameList = [...allNames].sort();
4959
+
4960
+ if (options.addNames) {
4961
+ // Add all names to dictionary
4962
+ console.log(fmt.header('Adding Names to Dictionary'));
4963
+ for (const name of nameList) {
4964
+ spelling.addWord(name, true);
4965
+ console.log(chalk.green(` ✓ ${name}`));
4966
+ }
4967
+ console.log(chalk.dim(`\nAdded ${nameList.length} name(s) to global dictionary`));
4968
+ } else {
4969
+ console.log(fmt.header('Possible Names'));
4970
+ console.log(chalk.dim(` ${nameList.join(', ')}`));
4971
+ console.log(chalk.dim(`\n Run with --add-names to add all to dictionary`));
4972
+ }
4973
+ }
4974
+
4975
+ if (totalMisspelled === 0 && allNames.size === 0) {
4976
+ console.log(fmt.status('success', 'No spelling errors found'));
4977
+ } else {
4978
+ if (totalMisspelled > 0) {
4979
+ console.log(chalk.yellow(`\n${totalMisspelled} spelling error(s)`));
4980
+ }
4981
+ if (allNames.size > 0) {
4982
+ console.log(chalk.blue(`${allNames.size} possible name(s)`));
4983
+ }
4984
+ console.log(chalk.dim('Use --learn <word> to add words to dictionary'));
4985
+ }
4986
+ });
4987
+
2862
4988
  program.parse();