docrev 0.6.13 → 0.7.6

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.
@@ -0,0 +1,2272 @@
1
+ /**
2
+ * Utility commands: help, completions, word-count, stats, search, backup, archive,
3
+ * export, preview, watch, lint, grammar, annotate, apply, comment, clean, check,
4
+ * open, spelling, upgrade, batch, install-cli-skill, uninstall-cli-skill
5
+ *
6
+ * Miscellaneous utility commands for project management.
7
+ */
8
+
9
+ import {
10
+ chalk,
11
+ fs,
12
+ path,
13
+ fmt,
14
+ findFiles,
15
+ loadBuildConfig,
16
+ getComments,
17
+ setCommentStatus,
18
+ countAnnotations,
19
+ stripAnnotations,
20
+ parseAnnotations,
21
+ getUserName,
22
+ } from './context.js';
23
+
24
+ /**
25
+ * Register utility commands with the program
26
+ * @param {import('commander').Command} program
27
+ * @param {object} [pkg] - Package.json object for version info
28
+ */
29
+ export function register(program, pkg) {
30
+ // ==========================================================================
31
+ // HELP command - Comprehensive help
32
+ // ==========================================================================
33
+
34
+ program
35
+ .command('help')
36
+ .description('Show detailed help and workflow guide')
37
+ .argument('[topic]', 'Help topic: workflow, syntax, commands')
38
+ .action((topic) => {
39
+ if (!topic || topic === 'all') {
40
+ showFullHelp(pkg);
41
+ } else if (topic === 'workflow') {
42
+ showWorkflowHelp();
43
+ } else if (topic === 'syntax') {
44
+ showSyntaxHelp();
45
+ } else if (topic === 'commands') {
46
+ showCommandsHelp();
47
+ } else {
48
+ console.log(chalk.yellow(`Unknown topic: ${topic}`));
49
+ console.log(chalk.dim('Available topics: workflow, syntax, commands'));
50
+ }
51
+ });
52
+
53
+ // ==========================================================================
54
+ // COMPLETIONS command - Shell completions
55
+ // ==========================================================================
56
+
57
+ program
58
+ .command('completions')
59
+ .description('Output shell completions')
60
+ .argument('<shell>', 'Shell type: bash, zsh, powershell')
61
+ .action((shell) => {
62
+ const completionsDir = path.join(import.meta.dirname, '..', '..', 'completions');
63
+
64
+ if (shell === 'bash') {
65
+ const bashFile = path.join(completionsDir, 'rev.bash');
66
+ if (fs.existsSync(bashFile)) {
67
+ console.log(fs.readFileSync(bashFile, 'utf-8'));
68
+ } else {
69
+ console.error(chalk.red('Bash completions not found'));
70
+ process.exit(1);
71
+ }
72
+ } else if (shell === 'zsh') {
73
+ const zshFile = path.join(completionsDir, 'rev.zsh');
74
+ if (fs.existsSync(zshFile)) {
75
+ console.log(fs.readFileSync(zshFile, 'utf-8'));
76
+ } else {
77
+ console.error(chalk.red('Zsh completions not found'));
78
+ process.exit(1);
79
+ }
80
+ } else if (shell === 'powershell' || shell === 'pwsh') {
81
+ const psFile = path.join(completionsDir, 'rev.ps1');
82
+ if (fs.existsSync(psFile)) {
83
+ console.log(fs.readFileSync(psFile, 'utf-8'));
84
+ } else {
85
+ console.error(chalk.red('PowerShell completions not found'));
86
+ process.exit(1);
87
+ }
88
+ } else {
89
+ console.error(chalk.red(`Unknown shell: ${shell}`));
90
+ console.log(chalk.dim('Supported shells: bash, zsh, powershell'));
91
+ process.exit(1);
92
+ }
93
+ });
94
+
95
+ // ==========================================================================
96
+ // WORD-COUNT command - Per-section word counts
97
+ // ==========================================================================
98
+
99
+ program
100
+ .command('word-count')
101
+ .alias('wc')
102
+ .description('Show word counts per section')
103
+ .option('-l, --limit <number>', 'Warn if total exceeds limit', parseInt)
104
+ .option('-j, --journal <name>', 'Use journal word limit')
105
+ .action(async (options) => {
106
+ let config = {};
107
+ try {
108
+ config = loadBuildConfig() || {};
109
+ } catch {
110
+ // Not in a rev project, that's ok
111
+ }
112
+ const sections = config.sections || [];
113
+
114
+ if (sections.length === 0) {
115
+ // Try to find .md files
116
+ const mdFiles = fs.readdirSync('.').filter(f =>
117
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
118
+ );
119
+ if (mdFiles.length === 0) {
120
+ console.error(chalk.red('No section files found. Run from a rev project directory.'));
121
+ process.exit(1);
122
+ }
123
+ sections.push(...mdFiles);
124
+ }
125
+
126
+ const countWords = (text) => {
127
+ return text
128
+ .replace(/^---[\s\S]*?---/m, '')
129
+ .replace(/!\[.*?\]\(.*?\)/g, '')
130
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
131
+ .replace(/#+\s*/g, '')
132
+ .replace(/\*\*|__|[*_`]/g, '')
133
+ .replace(/```[\s\S]*?```/g, '')
134
+ .replace(/\{[^}]+\}/g, '')
135
+ .replace(/@\w+:\w+/g, '')
136
+ .replace(/@\w+/g, '')
137
+ .replace(/\|[^|]+\|/g, ' ')
138
+ .replace(/\n+/g, ' ')
139
+ .trim()
140
+ .split(/\s+/)
141
+ .filter(w => w.length > 0).length;
142
+ };
143
+
144
+ let total = 0;
145
+ const rows = [];
146
+
147
+ for (const section of sections) {
148
+ if (!fs.existsSync(section)) continue;
149
+ const text = fs.readFileSync(section, 'utf-8');
150
+ const words = countWords(text);
151
+ total += words;
152
+ rows.push([section, words.toLocaleString()]);
153
+ }
154
+
155
+ rows.push(['', '']);
156
+ rows.push([chalk.bold('Total'), chalk.bold(total.toLocaleString())]);
157
+
158
+ console.log(fmt.header('Word Count'));
159
+ console.log(fmt.table(['Section', 'Words'], rows));
160
+
161
+ // Check limit
162
+ let limit = options.limit;
163
+ if (options.journal) {
164
+ const { getJournalProfile } = await import('../journals.js');
165
+ const profile = getJournalProfile(options.journal);
166
+ if (profile?.requirements?.wordLimit?.main) {
167
+ limit = profile.requirements.wordLimit.main;
168
+ console.log(chalk.dim(`\nUsing ${profile.name} word limit: ${limit.toLocaleString()}`));
169
+ }
170
+ }
171
+
172
+ if (limit && total > limit) {
173
+ console.log(chalk.red(`\n⚠ Over limit by ${(total - limit).toLocaleString()} words`));
174
+ } else if (limit) {
175
+ console.log(chalk.green(`\n✓ Within limit (${(limit - total).toLocaleString()} words remaining)`));
176
+ }
177
+ });
178
+
179
+ // ==========================================================================
180
+ // STATS command - Project dashboard
181
+ // ==========================================================================
182
+
183
+ program
184
+ .command('stats')
185
+ .description('Show project statistics dashboard')
186
+ .action(async () => {
187
+ let config = {};
188
+ try {
189
+ config = loadBuildConfig() || {};
190
+ } catch {
191
+ // Not in a rev project, that's ok
192
+ }
193
+ let sections = config.sections || [];
194
+
195
+ if (sections.length === 0) {
196
+ sections = fs.readdirSync('.').filter(f =>
197
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
198
+ );
199
+ }
200
+
201
+ const countWords = (text) => {
202
+ return text
203
+ .replace(/^---[\s\S]*?---/m, '')
204
+ .replace(/!\[.*?\]\(.*?\)/g, '')
205
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
206
+ .replace(/[#*_`]/g, '')
207
+ .replace(/\{[^}]+\}/g, '')
208
+ .replace(/@\w+/g, '')
209
+ .replace(/\n+/g, ' ')
210
+ .trim()
211
+ .split(/\s+/)
212
+ .filter(w => w.length > 0).length;
213
+ };
214
+
215
+ let totalWords = 0;
216
+ let totalFigures = 0;
217
+ let totalTables = 0;
218
+ let totalComments = 0;
219
+ let pendingComments = 0;
220
+ const citations = new Set();
221
+
222
+ for (const section of sections) {
223
+ if (!fs.existsSync(section)) continue;
224
+ const text = fs.readFileSync(section, 'utf-8');
225
+
226
+ totalWords += countWords(text);
227
+ totalFigures += (text.match(/!\[.*?\]\(.*?\)/g) || []).length;
228
+ totalTables += (text.match(/^\|[^|]+\|/gm) || []).length / 5; // Approximate
229
+
230
+ const comments = getComments(text);
231
+ totalComments += comments.length;
232
+ pendingComments += comments.filter(c => !c.resolved).length;
233
+
234
+ const cites = text.match(/@(\w+)(?![:\w])/g) || [];
235
+ cites.forEach(c => citations.add(c.slice(1)));
236
+ }
237
+
238
+ console.log(fmt.header('Project Statistics'));
239
+ console.log();
240
+
241
+ const stats = [
242
+ ['Sections', sections.length],
243
+ ['Words', totalWords.toLocaleString()],
244
+ ['Figures', Math.round(totalFigures)],
245
+ ['Tables', Math.round(totalTables)],
246
+ ['Citations', citations.size],
247
+ ['Comments', `${totalComments} (${pendingComments} pending)`],
248
+ ];
249
+
250
+ for (const [label, value] of stats) {
251
+ console.log(` ${chalk.dim(label.padEnd(12))} ${chalk.bold(value)}`);
252
+ }
253
+
254
+ // Bibliography stats
255
+ const bibPath = config.bibliography || 'references.bib';
256
+ if (fs.existsSync(bibPath)) {
257
+ const bibContent = fs.readFileSync(bibPath, 'utf-8');
258
+ const bibEntries = (bibContent.match(/@\w+\s*\{/g) || []).length;
259
+ console.log(` ${chalk.dim('Bib entries'.padEnd(12))} ${chalk.bold(bibEntries)}`);
260
+ }
261
+
262
+ console.log();
263
+ });
264
+
265
+ // ==========================================================================
266
+ // SEARCH command - Search across section files
267
+ // ==========================================================================
268
+
269
+ program
270
+ .command('search')
271
+ .description('Search across all section files')
272
+ .argument('<query>', 'Search query (supports regex)')
273
+ .option('-i, --ignore-case', 'Case-insensitive search')
274
+ .option('-c, --context <lines>', 'Show context lines', parseInt, 1)
275
+ .action((query, options) => {
276
+ let config = {};
277
+ try {
278
+ config = loadBuildConfig() || {};
279
+ } catch {
280
+ // Not in a rev project, that's ok
281
+ }
282
+ let sections = config.sections || [];
283
+
284
+ if (sections.length === 0) {
285
+ sections = fs.readdirSync('.').filter(f =>
286
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f)
287
+ );
288
+ }
289
+
290
+ const flags = options.ignoreCase ? 'gi' : 'g';
291
+ let pattern;
292
+ try {
293
+ pattern = new RegExp(query, flags);
294
+ } catch {
295
+ pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags);
296
+ }
297
+
298
+ let totalMatches = 0;
299
+
300
+ for (const section of sections) {
301
+ if (!fs.existsSync(section)) continue;
302
+ const text = fs.readFileSync(section, 'utf-8');
303
+ const lines = text.split('\n');
304
+
305
+ const matches = [];
306
+ for (let i = 0; i < lines.length; i++) {
307
+ if (pattern.test(lines[i])) {
308
+ matches.push({ line: i + 1, text: lines[i] });
309
+ pattern.lastIndex = 0;
310
+ }
311
+ }
312
+
313
+ if (matches.length > 0) {
314
+ console.log(chalk.cyan.bold(`\n${section}`));
315
+ for (const match of matches) {
316
+ const highlighted = match.text.replace(pattern, (m) => chalk.yellow.bold(m));
317
+ console.log(` ${chalk.dim(match.line + ':')} ${highlighted}`);
318
+ }
319
+ totalMatches += matches.length;
320
+ }
321
+ }
322
+
323
+ if (totalMatches === 0) {
324
+ console.log(chalk.yellow(`No matches found for "${query}"`));
325
+ } else {
326
+ console.log(chalk.dim(`\n${totalMatches} match${totalMatches === 1 ? '' : 'es'} found`));
327
+ }
328
+ });
329
+
330
+ // ==========================================================================
331
+ // BACKUP command - Timestamped project backup
332
+ // ==========================================================================
333
+
334
+ program
335
+ .command('backup')
336
+ .description('Create timestamped project backup')
337
+ .option('-n, --name <name>', 'Custom backup name')
338
+ .option('-o, --output <dir>', 'Output directory', '.')
339
+ .action(async (options) => {
340
+ const { default: AdmZip } = await import('adm-zip');
341
+ const zip = new AdmZip();
342
+
343
+ const date = new Date().toISOString().slice(0, 10);
344
+ const name = options.name || `backup-${date}`;
345
+ const outputPath = path.join(options.output, `${name}.zip`);
346
+
347
+ // Files to exclude
348
+ const excludePatterns = [
349
+ 'node_modules', '.git', '.DS_Store', '*.zip',
350
+ 'paper.md' // Generated file
351
+ ];
352
+
353
+ const shouldInclude = (file) => {
354
+ for (const pattern of excludePatterns) {
355
+ if (file.includes(pattern.replace('*', ''))) return false;
356
+ }
357
+ return true;
358
+ };
359
+
360
+ const addDir = (dir, zipPath = '') => {
361
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
362
+ for (const entry of entries) {
363
+ const fullPath = path.join(dir, entry.name);
364
+ const entryZipPath = path.join(zipPath, entry.name);
365
+
366
+ if (!shouldInclude(entry.name)) continue;
367
+
368
+ if (entry.isDirectory()) {
369
+ addDir(fullPath, entryZipPath);
370
+ } else {
371
+ zip.addLocalFile(fullPath, zipPath || undefined);
372
+ }
373
+ }
374
+ };
375
+
376
+ // Add current directory
377
+ const entries = fs.readdirSync('.', { withFileTypes: true });
378
+ for (const entry of entries) {
379
+ if (!shouldInclude(entry.name)) continue;
380
+
381
+ if (entry.isDirectory()) {
382
+ addDir(entry.name, entry.name);
383
+ } else if (entry.isFile()) {
384
+ zip.addLocalFile(entry.name);
385
+ }
386
+ }
387
+
388
+ zip.writeZip(outputPath);
389
+ console.log(fmt.status('success', `Backup created: ${outputPath}`));
390
+ });
391
+
392
+ // ==========================================================================
393
+ // ARCHIVE command - Archive reviewer docx files
394
+ // ==========================================================================
395
+
396
+ program
397
+ .command('archive')
398
+ .description('Move reviewer .docx files to archive folder')
399
+ .argument('[files...]', 'Specific files to archive (default: all .docx)')
400
+ .option('-d, --dir <folder>', 'Archive folder name', 'archive')
401
+ .option('--by <name>', 'Reviewer name (auto-detected if single commenter)')
402
+ .option('--no-rename', 'Keep original filenames')
403
+ .option('--dry-run', 'Preview without moving files')
404
+ .action(async (files, options) => {
405
+ const { extractWordComments } = await import('../import.js');
406
+ const { default: YAML } = await import('yaml');
407
+
408
+ // Find docx files to archive
409
+ let docxFiles = files && files.length > 0
410
+ ? files.filter(f => f.endsWith('.docx') && fs.existsSync(f))
411
+ : findFiles('.docx');
412
+
413
+ // Exclude our own build outputs
414
+ let projectSlug = null;
415
+ const configPath = path.join(process.cwd(), 'rev.yaml');
416
+ if (fs.existsSync(configPath)) {
417
+ try {
418
+ const config = YAML.parse(fs.readFileSync(configPath, 'utf-8'));
419
+ if (config.title) {
420
+ projectSlug = config.title
421
+ .toLowerCase()
422
+ .replace(/[^a-z0-9]+/g, '-')
423
+ .replace(/^-|-$/g, '')
424
+ .slice(0, 50);
425
+ }
426
+ } catch (e) {
427
+ // Ignore config errors
428
+ }
429
+ }
430
+
431
+ // Filter out build outputs
432
+ if (projectSlug && files.length === 0) {
433
+ const buildPatterns = [
434
+ `${projectSlug}.docx`,
435
+ `${projectSlug}_comments.docx`,
436
+ `${projectSlug}-changes.docx`,
437
+ 'paper.docx',
438
+ 'paper_comments.docx',
439
+ 'paper-changes.docx',
440
+ ];
441
+ const excluded = [];
442
+ docxFiles = docxFiles.filter(f => {
443
+ const base = path.basename(f).toLowerCase();
444
+ const isBuilt = buildPatterns.includes(base);
445
+ if (isBuilt) excluded.push(f);
446
+ return !isBuilt;
447
+ });
448
+ if (excluded.length > 0) {
449
+ console.log(chalk.dim(` Skipping build outputs: ${excluded.join(', ')}`));
450
+ console.log();
451
+ }
452
+ }
453
+
454
+ if (docxFiles.length === 0) {
455
+ console.log(fmt.status('info', 'No .docx files to archive.'));
456
+ return;
457
+ }
458
+
459
+ const projectTitle = projectSlug;
460
+
461
+ // Create archive folder
462
+ const archiveDir = path.resolve(options.dir);
463
+ if (!options.dryRun && !fs.existsSync(archiveDir)) {
464
+ fs.mkdirSync(archiveDir, { recursive: true });
465
+ }
466
+
467
+ console.log(fmt.header('Archive'));
468
+ console.log();
469
+
470
+ const moved = [];
471
+ for (const file of docxFiles) {
472
+ const stat = fs.statSync(file);
473
+ const mtime = stat.mtime;
474
+ const timestamp = mtime.toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '_');
475
+
476
+ // Determine reviewer name
477
+ let reviewer = options.by || null;
478
+ if (!reviewer && options.rename !== false) {
479
+ try {
480
+ const comments = await extractWordComments(file);
481
+ const authors = [...new Set(comments.map(c => c.author).filter(a => a && a !== 'Unknown'))];
482
+ if (authors.length === 1) {
483
+ reviewer = authors[0].replace(/[^a-zA-Z0-9]/g, '-').replace(/^-|-$/g, '');
484
+ }
485
+ } catch (e) {
486
+ // Ignore extraction errors
487
+ }
488
+ }
489
+
490
+ // Generate new name
491
+ let newName;
492
+ if (options.rename === false) {
493
+ newName = path.basename(file);
494
+ } else {
495
+ const base = path.basename(file, '.docx');
496
+ if (/^\d{8}_\d{6}_/.test(base)) {
497
+ newName = path.basename(file);
498
+ } else {
499
+ const namePart = projectTitle || base;
500
+ if (reviewer) {
501
+ newName = `${timestamp}_${reviewer}_${namePart}.docx`;
502
+ } else {
503
+ newName = `${timestamp}_${namePart}.docx`;
504
+ }
505
+ }
506
+ }
507
+
508
+ const destPath = path.join(archiveDir, newName);
509
+
510
+ if (options.dryRun) {
511
+ console.log(` ${chalk.dim(file)} → ${chalk.cyan(path.join(options.dir, newName))}`);
512
+ } else {
513
+ // Handle name collision
514
+ let finalPath = destPath;
515
+ let counter = 1;
516
+ while (fs.existsSync(finalPath)) {
517
+ const ext = path.extname(newName);
518
+ const base = path.basename(newName, ext);
519
+ finalPath = path.join(archiveDir, `${base}_${counter}${ext}`);
520
+ counter++;
521
+ }
522
+ fs.renameSync(file, finalPath);
523
+ console.log(` ${chalk.dim(file)} → ${chalk.green(path.relative(process.cwd(), finalPath))}`);
524
+ }
525
+ moved.push(file);
526
+ }
527
+
528
+ console.log();
529
+ if (options.dryRun) {
530
+ console.log(fmt.status('info', `Would archive ${moved.length} file(s). Run without --dry-run to proceed.`));
531
+ } else {
532
+ console.log(fmt.status('success', `Archived ${moved.length} file(s) to ${options.dir}/`));
533
+ }
534
+ });
535
+
536
+ // ==========================================================================
537
+ // EXPORT command - Export project as distributable zip
538
+ // ==========================================================================
539
+
540
+ program
541
+ .command('export')
542
+ .description('Export project as distributable zip')
543
+ .option('-o, --output <file>', 'Output filename')
544
+ .option('--include-output', 'Include built PDF/DOCX files')
545
+ .action(async (options) => {
546
+ const { default: AdmZip } = await import('adm-zip');
547
+ const { build } = await import('../build.js');
548
+
549
+ let config = {};
550
+ try {
551
+ config = loadBuildConfig() || {};
552
+ } catch {
553
+ // Not in a rev project, that's ok
554
+ }
555
+
556
+ // Build first if including output
557
+ if (options.includeOutput) {
558
+ console.log(chalk.dim('Building documents...'));
559
+ await build(['pdf', 'docx']);
560
+ }
561
+
562
+ const zip = new AdmZip();
563
+ const projectName = config.title?.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'project';
564
+ const outputPath = options.output || `${projectName}-export.zip`;
565
+
566
+ const exclude = ['node_modules', '.git', '.DS_Store', '*.zip'];
567
+
568
+ const shouldInclude = (name) => {
569
+ if (!options.includeOutput && (name.endsWith('.pdf') || name.endsWith('.docx'))) {
570
+ return false;
571
+ }
572
+ for (const pattern of exclude) {
573
+ if (name === pattern || name.includes(pattern.replace('*', ''))) return false;
574
+ }
575
+ return true;
576
+ };
577
+
578
+ const addDir = (dir, zipPath = '') => {
579
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
580
+ for (const entry of entries) {
581
+ const fullPath = path.join(dir, entry.name);
582
+ const entryZipPath = path.join(zipPath, entry.name);
583
+
584
+ if (!shouldInclude(entry.name)) continue;
585
+
586
+ if (entry.isDirectory()) {
587
+ addDir(fullPath, entryZipPath);
588
+ } else {
589
+ zip.addLocalFile(fullPath, zipPath || undefined);
590
+ }
591
+ }
592
+ };
593
+
594
+ const entries = fs.readdirSync('.', { withFileTypes: true });
595
+ for (const entry of entries) {
596
+ if (!shouldInclude(entry.name)) continue;
597
+
598
+ if (entry.isDirectory()) {
599
+ addDir(entry.name, entry.name);
600
+ } else if (entry.isFile()) {
601
+ zip.addLocalFile(entry.name);
602
+ }
603
+ }
604
+
605
+ zip.writeZip(outputPath);
606
+ console.log(fmt.status('success', `Exported: ${outputPath}`));
607
+ });
608
+
609
+ // ==========================================================================
610
+ // PREVIEW command - Build and open document
611
+ // ==========================================================================
612
+
613
+ program
614
+ .command('preview')
615
+ .description('Build and open document in default app')
616
+ .argument('[format]', 'Format to preview: pdf, docx', 'pdf')
617
+ .action(async (format) => {
618
+ const { exec } = await import('child_process');
619
+ const { build } = await import('../build.js');
620
+
621
+ let config = {};
622
+ try {
623
+ config = loadBuildConfig() || {};
624
+ } catch (err) {
625
+ console.error(chalk.red('Not in a rev project directory (no rev.yaml found)'));
626
+ process.exit(1);
627
+ }
628
+
629
+ console.log(chalk.dim(`Building ${format}...`));
630
+ const results = await build([format]);
631
+
632
+ const result = results.find(r => r.format === format);
633
+ if (!result?.success) {
634
+ console.error(chalk.red(`Build failed: ${result?.error || 'Unknown error'}`));
635
+ process.exit(1);
636
+ }
637
+
638
+ const outputFile = result.output;
639
+ if (!fs.existsSync(outputFile)) {
640
+ console.error(chalk.red(`Output file not found: ${outputFile}`));
641
+ process.exit(1);
642
+ }
643
+
644
+ // Open with system default
645
+ const openCmd = process.platform === 'darwin' ? 'open' :
646
+ process.platform === 'win32' ? 'start' : 'xdg-open';
647
+
648
+ exec(`${openCmd} "${outputFile}"`, (err) => {
649
+ if (err) {
650
+ console.error(chalk.red(`Could not open file: ${err.message}`));
651
+ } else {
652
+ console.log(fmt.status('success', `Opened ${outputFile}`));
653
+ }
654
+ });
655
+ });
656
+
657
+ // ==========================================================================
658
+ // WATCH command - Auto-rebuild on changes
659
+ // ==========================================================================
660
+
661
+ program
662
+ .command('watch')
663
+ .description('Watch files and auto-rebuild on changes')
664
+ .argument('[format]', 'Format to build: pdf, docx, all', 'pdf')
665
+ .option('--no-open', 'Do not open after first build')
666
+ .action(async (format, options) => {
667
+ const { exec } = await import('child_process');
668
+ const { build } = await import('../build.js');
669
+
670
+ let config = {};
671
+ try {
672
+ config = loadBuildConfig() || {};
673
+ } catch (err) {
674
+ console.error(chalk.red('Not in a rev project directory (no rev.yaml found)'));
675
+ process.exit(1);
676
+ }
677
+ let sections = config.sections || [];
678
+
679
+ if (sections.length === 0) {
680
+ sections = fs.readdirSync('.').filter(f =>
681
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
682
+ );
683
+ }
684
+
685
+ const filesToWatch = [
686
+ ...sections,
687
+ 'rev.yaml',
688
+ config.bibliography || 'references.bib'
689
+ ].filter(f => fs.existsSync(f));
690
+
691
+ console.log(fmt.header('Watch Mode'));
692
+ console.log(chalk.dim(`Watching: ${filesToWatch.join(', ')}`));
693
+ console.log(chalk.dim('Press Ctrl+C to stop\n'));
694
+
695
+ let building = false;
696
+ let pendingBuild = false;
697
+
698
+ const doBuild = async () => {
699
+ if (building) {
700
+ pendingBuild = true;
701
+ return;
702
+ }
703
+
704
+ building = true;
705
+ console.log(chalk.dim(`\n[${new Date().toLocaleTimeString()}] Rebuilding...`));
706
+
707
+ try {
708
+ const formats = format === 'all' ? ['pdf', 'docx'] : [format];
709
+ const results = await build(formats);
710
+
711
+ for (const r of results) {
712
+ if (r.success) {
713
+ console.log(chalk.green(` ✓ ${r.format}: ${r.output}`));
714
+ } else {
715
+ console.log(chalk.red(` ✗ ${r.format}: ${r.error}`));
716
+ }
717
+ }
718
+ } catch (err) {
719
+ console.error(chalk.red(` Build error: ${err.message}`));
720
+ }
721
+
722
+ building = false;
723
+ if (pendingBuild) {
724
+ pendingBuild = false;
725
+ doBuild();
726
+ }
727
+ };
728
+
729
+ // Initial build
730
+ await doBuild();
731
+
732
+ // Open after first build
733
+ if (options.open) {
734
+ const outputFile = format === 'docx' ?
735
+ (config.title?.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'paper') + '.docx' :
736
+ (config.title?.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() || 'paper') + '.pdf';
737
+
738
+ if (fs.existsSync(outputFile)) {
739
+ const openCmd = process.platform === 'darwin' ? 'open' :
740
+ process.platform === 'win32' ? 'start' : 'xdg-open';
741
+ exec(`${openCmd} "${outputFile}"`);
742
+ }
743
+ }
744
+
745
+ // Watch files
746
+ for (const file of filesToWatch) {
747
+ fs.watch(file, { persistent: true }, (eventType) => {
748
+ if (eventType === 'change') {
749
+ doBuild();
750
+ }
751
+ });
752
+ }
753
+ });
754
+
755
+ // ==========================================================================
756
+ // LINT command - Check for common issues
757
+ // ==========================================================================
758
+
759
+ program
760
+ .command('lint')
761
+ .description('Check for common issues in the project')
762
+ .option('--fix', 'Auto-fix issues where possible')
763
+ .action(async (options) => {
764
+ let config = {};
765
+ try {
766
+ config = loadBuildConfig() || {};
767
+ } catch {
768
+ // Not in a rev project, that's ok
769
+ }
770
+ let sections = config.sections || [];
771
+
772
+ if (sections.length === 0) {
773
+ sections = fs.readdirSync('.').filter(f =>
774
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
775
+ );
776
+ }
777
+
778
+ const issues = [];
779
+ const warnings = [];
780
+
781
+ // Collect all content
782
+ let allText = '';
783
+ for (const section of sections) {
784
+ if (fs.existsSync(section)) {
785
+ allText += fs.readFileSync(section, 'utf-8') + '\n';
786
+ }
787
+ }
788
+
789
+ // Check 1: Broken cross-references
790
+ const figAnchors = new Set();
791
+ const tblAnchors = new Set();
792
+ const eqAnchors = new Set();
793
+
794
+ const anchorPattern = /\{#(fig|tbl|eq):([^}]+)\}/g;
795
+ let match;
796
+ while ((match = anchorPattern.exec(allText)) !== null) {
797
+ if (match[1] === 'fig') figAnchors.add(match[2]);
798
+ else if (match[1] === 'tbl') tblAnchors.add(match[2]);
799
+ else if (match[1] === 'eq') eqAnchors.add(match[2]);
800
+ }
801
+
802
+ const refPattern = /@(fig|tbl|eq):([a-zA-Z0-9_-]+)/g;
803
+ while ((match = refPattern.exec(allText)) !== null) {
804
+ const type = match[1];
805
+ const label = match[2];
806
+ const anchors = type === 'fig' ? figAnchors : type === 'tbl' ? tblAnchors : eqAnchors;
807
+
808
+ if (!anchors.has(label)) {
809
+ issues.push({
810
+ type: 'error',
811
+ message: `Broken reference: @${type}:${label}`,
812
+ fix: null
813
+ });
814
+ }
815
+ }
816
+
817
+ // Check 2: Orphaned figures
818
+ for (const label of figAnchors) {
819
+ if (!allText.includes(`@fig:${label}`)) {
820
+ warnings.push({
821
+ type: 'warning',
822
+ message: `Unreferenced figure: {#fig:${label}}`,
823
+ });
824
+ }
825
+ }
826
+
827
+ // Check 3: Missing citations
828
+ const bibPath = config.bibliography || 'references.bib';
829
+ if (fs.existsSync(bibPath)) {
830
+ const bibContent = fs.readFileSync(bibPath, 'utf-8');
831
+ const bibKeys = new Set();
832
+ const bibPattern = /@\w+\s*\{\s*([^,]+)/g;
833
+ while ((match = bibPattern.exec(bibContent)) !== null) {
834
+ bibKeys.add(match[1].trim());
835
+ }
836
+
837
+ const citePattern = /@([a-zA-Z][a-zA-Z0-9_-]*)(?![:\w])/g;
838
+ while ((match = citePattern.exec(allText)) !== null) {
839
+ const key = match[1];
840
+ if (!bibKeys.has(key) && !['fig', 'tbl', 'eq'].includes(key)) {
841
+ issues.push({
842
+ type: 'error',
843
+ message: `Missing citation: @${key}`,
844
+ });
845
+ }
846
+ }
847
+ }
848
+
849
+ // Check 4: Unresolved comments
850
+ const comments = getComments(allText);
851
+ const pending = comments.filter(c => !c.resolved);
852
+ if (pending.length > 0) {
853
+ warnings.push({
854
+ type: 'warning',
855
+ message: `${pending.length} unresolved comment${pending.length === 1 ? '' : 's'}`,
856
+ });
857
+ }
858
+
859
+ // Check 5: Empty sections
860
+ for (const section of sections) {
861
+ if (fs.existsSync(section)) {
862
+ const content = fs.readFileSync(section, 'utf-8').trim();
863
+ if (content.length < 50) {
864
+ warnings.push({
865
+ type: 'warning',
866
+ message: `Section appears empty: ${section}`,
867
+ });
868
+ }
869
+ }
870
+ }
871
+
872
+ // Output results
873
+ console.log(fmt.header('Lint Results'));
874
+ console.log();
875
+
876
+ if (issues.length === 0 && warnings.length === 0) {
877
+ console.log(chalk.green('✓ No issues found'));
878
+ return;
879
+ }
880
+
881
+ for (const issue of issues) {
882
+ console.log(chalk.red(` ✗ ${issue.message}`));
883
+ }
884
+
885
+ for (const warning of warnings) {
886
+ console.log(chalk.yellow(` ⚠ ${warning.message}`));
887
+ }
888
+
889
+ console.log();
890
+ console.log(chalk.dim(`${issues.length} error${issues.length === 1 ? '' : 's'}, ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`));
891
+
892
+ if (issues.length > 0) {
893
+ process.exit(1);
894
+ }
895
+ });
896
+
897
+ // ==========================================================================
898
+ // GRAMMAR command - Check grammar and style
899
+ // ==========================================================================
900
+
901
+ program
902
+ .command('grammar')
903
+ .description('Check grammar and style issues')
904
+ .argument('[files...]', 'Markdown files to check')
905
+ .option('--learn <word>', 'Add word to custom dictionary')
906
+ .option('--forget <word>', 'Remove word from custom dictionary')
907
+ .option('--list', 'List custom dictionary words')
908
+ .option('--rules', 'List available grammar rules')
909
+ .option('--no-scientific', 'Disable scientific writing rules')
910
+ .option('-s, --severity <level>', 'Minimum severity: error, warning, info', 'info')
911
+ .action(async (files, options) => {
912
+ const {
913
+ checkGrammar,
914
+ getGrammarSummary,
915
+ loadDictionary,
916
+ addToDictionary,
917
+ removeFromDictionary,
918
+ listRules,
919
+ } = await import('../grammar.js');
920
+
921
+ // Handle dictionary management
922
+ if (options.learn) {
923
+ const added = addToDictionary(options.learn);
924
+ if (added) {
925
+ console.log(fmt.status('success', `Added "${options.learn}" to dictionary`));
926
+ } else {
927
+ console.log(chalk.dim(`"${options.learn}" already in dictionary`));
928
+ }
929
+ return;
930
+ }
931
+
932
+ if (options.forget) {
933
+ const removed = removeFromDictionary(options.forget);
934
+ if (removed) {
935
+ console.log(fmt.status('success', `Removed "${options.forget}" from dictionary`));
936
+ } else {
937
+ console.log(chalk.yellow(`"${options.forget}" not in dictionary`));
938
+ }
939
+ return;
940
+ }
941
+
942
+ if (options.list) {
943
+ const words = loadDictionary();
944
+ console.log(fmt.header('Custom Dictionary'));
945
+ console.log();
946
+ if (words.size === 0) {
947
+ console.log(chalk.dim(' No custom words defined'));
948
+ console.log(chalk.dim(' Use --learn <word> to add words'));
949
+ } else {
950
+ const sorted = [...words].sort();
951
+ for (const word of sorted) {
952
+ console.log(` ${word}`);
953
+ }
954
+ console.log();
955
+ console.log(chalk.dim(`${words.size} word(s)`));
956
+ }
957
+ return;
958
+ }
959
+
960
+ if (options.rules) {
961
+ const rules = listRules(options.scientific);
962
+ console.log(fmt.header('Grammar Rules'));
963
+ console.log();
964
+ for (const rule of rules) {
965
+ const icon = rule.severity === 'error' ? chalk.red('●') :
966
+ rule.severity === 'warning' ? chalk.yellow('●') :
967
+ chalk.blue('●');
968
+ console.log(` ${icon} ${chalk.bold(rule.id)}`);
969
+ console.log(chalk.dim(` ${rule.message}`));
970
+ }
971
+ return;
972
+ }
973
+
974
+ // Get files to check
975
+ let mdFiles = files;
976
+ if (!mdFiles || mdFiles.length === 0) {
977
+ let config = {};
978
+ try {
979
+ config = loadBuildConfig() || {};
980
+ } catch {
981
+ // Not in a rev project
982
+ }
983
+ mdFiles = config.sections || [];
984
+
985
+ if (mdFiles.length === 0) {
986
+ mdFiles = fs.readdirSync('.').filter(f =>
987
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
988
+ );
989
+ }
990
+ }
991
+
992
+ if (mdFiles.length === 0) {
993
+ console.error(chalk.red('No markdown files found'));
994
+ process.exit(1);
995
+ }
996
+
997
+ console.log(fmt.header('Grammar Check'));
998
+ console.log();
999
+
1000
+ const severityLevels = { error: 3, warning: 2, info: 1 };
1001
+ const minSeverity = severityLevels[options.severity] || 1;
1002
+
1003
+ let allIssues = [];
1004
+
1005
+ for (const file of mdFiles) {
1006
+ if (!fs.existsSync(file)) continue;
1007
+
1008
+ const text = fs.readFileSync(file, 'utf-8');
1009
+ const issues = checkGrammar(text, { scientific: options.scientific });
1010
+
1011
+ // Filter by severity
1012
+ const filtered = issues.filter(i => severityLevels[i.severity] >= minSeverity);
1013
+
1014
+ if (filtered.length > 0) {
1015
+ console.log(chalk.cyan.bold(file));
1016
+
1017
+ for (const issue of filtered) {
1018
+ const icon = issue.severity === 'error' ? chalk.red('●') :
1019
+ issue.severity === 'warning' ? chalk.yellow('●') :
1020
+ chalk.blue('●');
1021
+
1022
+ console.log(` ${chalk.dim(`L${issue.line}:`)} ${icon} ${issue.message}`);
1023
+ console.log(chalk.dim(` "${issue.match}" in: ${issue.context.slice(0, 60)}...`));
1024
+ }
1025
+ console.log();
1026
+ allIssues.push(...filtered.map(i => ({ ...i, file })));
1027
+ }
1028
+ }
1029
+
1030
+ const summary = getGrammarSummary(allIssues);
1031
+
1032
+ if (summary.total === 0) {
1033
+ console.log(chalk.green('✓ No issues found'));
1034
+ } else {
1035
+ console.log(chalk.dim(`Found ${summary.total} issue(s): ${summary.errors} errors, ${summary.warnings} warnings, ${summary.info} info`));
1036
+ console.log();
1037
+ console.log(chalk.dim('Tip: Use --learn <word> to add words to dictionary'));
1038
+ }
1039
+ });
1040
+
1041
+ // ==========================================================================
1042
+ // ANNOTATE command - Add comments to Word document
1043
+ // ==========================================================================
1044
+
1045
+ program
1046
+ .command('annotate')
1047
+ .description('Add comment to Word document')
1048
+ .argument('<docx>', 'Word document')
1049
+ .option('-m, --message <text>', 'Comment text')
1050
+ .option('-s, --search <text>', 'Text to attach comment to')
1051
+ .option('-a, --author <name>', 'Comment author')
1052
+ .action(async (docxPath, options) => {
1053
+ if (!fs.existsSync(docxPath)) {
1054
+ console.error(chalk.red(`File not found: ${docxPath}`));
1055
+ process.exit(1);
1056
+ }
1057
+
1058
+ if (!options.message) {
1059
+ console.error(chalk.red('Comment message required (-m)'));
1060
+ process.exit(1);
1061
+ }
1062
+
1063
+ const { default: AdmZip } = await import('adm-zip');
1064
+ const zip = new AdmZip(docxPath);
1065
+
1066
+ // Read document.xml
1067
+ const docEntry = zip.getEntry('word/document.xml');
1068
+ if (!docEntry) {
1069
+ console.error(chalk.red('Invalid Word document'));
1070
+ process.exit(1);
1071
+ }
1072
+
1073
+ let docXml = zip.readAsText(docEntry);
1074
+
1075
+ // Read or create comments.xml
1076
+ let commentsEntry = zip.getEntry('word/comments.xml');
1077
+ let commentsXml;
1078
+ let nextCommentId = 1;
1079
+
1080
+ if (commentsEntry) {
1081
+ commentsXml = zip.readAsText(commentsEntry);
1082
+ const idMatches = commentsXml.match(/w:id="(\d+)"/g) || [];
1083
+ for (const m of idMatches) {
1084
+ const id = parseInt(m.match(/\d+/)[0]);
1085
+ if (id >= nextCommentId) nextCommentId = id + 1;
1086
+ }
1087
+ } else {
1088
+ commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1089
+ <w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1090
+ </w:comments>`;
1091
+ }
1092
+
1093
+ const author = options.author || getUserName() || 'Claude';
1094
+ const date = new Date().toISOString();
1095
+ const commentId = nextCommentId;
1096
+
1097
+ // Add comment to comments.xml
1098
+ const newComment = `<w:comment w:id="${commentId}" w:author="${author}" w:date="${date}">
1099
+ <w:p><w:r><w:t>${options.message}</w:t></w:r></w:p>
1100
+ </w:comment>`;
1101
+
1102
+ commentsXml = commentsXml.replace('</w:comments>', `${newComment}\n</w:comments>`);
1103
+
1104
+ // Find text and add comment markers
1105
+ if (options.search) {
1106
+ const searchText = options.search;
1107
+ const textPattern = new RegExp(`(<w:t[^>]*>)([^<]*${searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^<]*)(<\/w:t>)`, 'i');
1108
+
1109
+ if (textPattern.test(docXml)) {
1110
+ docXml = docXml.replace(textPattern, (match, start, text, end) => {
1111
+ return `<w:commentRangeStart w:id="${commentId}"/>${start}${text}${end}<w:commentRangeEnd w:id="${commentId}"/><w:r><w:commentReference w:id="${commentId}"/></w:r>`;
1112
+ });
1113
+ } else {
1114
+ console.log(chalk.yellow(`Text "${searchText}" not found in document. Comment added without anchor.`));
1115
+ }
1116
+ }
1117
+
1118
+ // Update zip
1119
+ zip.updateFile('word/document.xml', Buffer.from(docXml));
1120
+
1121
+ if (commentsEntry) {
1122
+ zip.updateFile('word/comments.xml', Buffer.from(commentsXml));
1123
+ } else {
1124
+ zip.addFile('word/comments.xml', Buffer.from(commentsXml));
1125
+
1126
+ // Update [Content_Types].xml
1127
+ const ctEntry = zip.getEntry('[Content_Types].xml');
1128
+ if (ctEntry) {
1129
+ let ctXml = zip.readAsText(ctEntry);
1130
+ if (!ctXml.includes('comments.xml')) {
1131
+ ctXml = ctXml.replace('</Types>',
1132
+ '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>\n</Types>');
1133
+ zip.updateFile('[Content_Types].xml', Buffer.from(ctXml));
1134
+ }
1135
+ }
1136
+
1137
+ // Update document.xml.rels
1138
+ const relsEntry = zip.getEntry('word/_rels/document.xml.rels');
1139
+ if (relsEntry) {
1140
+ let relsXml = zip.readAsText(relsEntry);
1141
+ if (!relsXml.includes('comments.xml')) {
1142
+ const newRelId = `rId${Date.now()}`;
1143
+ relsXml = relsXml.replace('</Relationships>',
1144
+ `<Relationship Id="${newRelId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>\n</Relationships>`);
1145
+ zip.updateFile('word/_rels/document.xml.rels', Buffer.from(relsXml));
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ // Write back
1151
+ zip.writeZip(docxPath);
1152
+ console.log(fmt.status('success', `Added comment to ${docxPath}`));
1153
+ });
1154
+
1155
+ // ==========================================================================
1156
+ // APPLY command - Apply MD annotations as Word track changes
1157
+ // ==========================================================================
1158
+
1159
+ program
1160
+ .command('apply')
1161
+ .description('Apply markdown annotations to Word document as track changes')
1162
+ .argument('<md>', 'Markdown file with annotations')
1163
+ .argument('<docx>', 'Output Word document')
1164
+ .option('-a, --author <name>', 'Author name for track changes')
1165
+ .action(async (mdPath, docxPath, options) => {
1166
+ if (!fs.existsSync(mdPath)) {
1167
+ console.error(chalk.red(`File not found: ${mdPath}`));
1168
+ process.exit(1);
1169
+ }
1170
+
1171
+ const mdContent = fs.readFileSync(mdPath, 'utf-8');
1172
+ const annotations = parseAnnotations(mdContent);
1173
+
1174
+ if (annotations.length === 0) {
1175
+ console.log(chalk.yellow('No annotations found in markdown file'));
1176
+ }
1177
+
1178
+ const author = options.author || getUserName() || 'Author';
1179
+
1180
+ // Build document with track changes
1181
+ const { buildWithTrackChanges } = await import('../trackchanges.js');
1182
+
1183
+ try {
1184
+ const result = await buildWithTrackChanges(mdPath, docxPath, { author });
1185
+
1186
+ if (result.success) {
1187
+ console.log(fmt.status('success', result.message));
1188
+ console.log(chalk.dim(` ${annotations.length} annotations applied as track changes`));
1189
+ } else {
1190
+ console.error(chalk.red(result.message));
1191
+ process.exit(1);
1192
+ }
1193
+ } catch (err) {
1194
+ console.error(chalk.red(`Error: ${err.message}`));
1195
+ process.exit(1);
1196
+ }
1197
+ });
1198
+
1199
+ // ==========================================================================
1200
+ // COMMENT command - Interactive comment addition to DOCX
1201
+ // ==========================================================================
1202
+
1203
+ program
1204
+ .command('comment')
1205
+ .description('Add comments to Word document interactively')
1206
+ .argument('<docx>', 'Word document')
1207
+ .option('-a, --author <name>', 'Comment author')
1208
+ .action(async (docxPath, options) => {
1209
+ if (!fs.existsSync(docxPath)) {
1210
+ console.error(chalk.red(`File not found: ${docxPath}`));
1211
+ process.exit(1);
1212
+ }
1213
+
1214
+ const { default: AdmZip } = await import('adm-zip');
1215
+ const rl = (await import('readline')).createInterface({
1216
+ input: process.stdin,
1217
+ output: process.stdout,
1218
+ });
1219
+
1220
+ const ask = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
1221
+
1222
+ const author = options.author || getUserName() || 'Reviewer';
1223
+
1224
+ console.log(fmt.header('Interactive Comment Mode'));
1225
+ console.log(chalk.dim(` Document: ${docxPath}`));
1226
+ console.log(chalk.dim(` Author: ${author}`));
1227
+ console.log(chalk.dim(' Type your comment, then the text to attach it to.'));
1228
+ console.log(chalk.dim(' Enter empty comment to quit.\n'));
1229
+
1230
+ let commentsAdded = 0;
1231
+
1232
+ while (true) {
1233
+ const message = await ask(chalk.cyan('Comment: '));
1234
+
1235
+ if (!message.trim()) {
1236
+ break;
1237
+ }
1238
+
1239
+ const searchText = await ask(chalk.cyan('Attach to text: '));
1240
+
1241
+ // Load document fresh each time
1242
+ const zip = new AdmZip(docxPath);
1243
+ const docEntry = zip.getEntry('word/document.xml');
1244
+
1245
+ if (!docEntry) {
1246
+ console.error(chalk.red('Invalid Word document'));
1247
+ rl.close();
1248
+ process.exit(1);
1249
+ }
1250
+
1251
+ let docXml = zip.readAsText(docEntry);
1252
+
1253
+ // Read or create comments.xml
1254
+ let commentsEntry = zip.getEntry('word/comments.xml');
1255
+ let commentsXml;
1256
+ let nextCommentId = 1;
1257
+
1258
+ if (commentsEntry) {
1259
+ commentsXml = zip.readAsText(commentsEntry);
1260
+ const idMatches = commentsXml.match(/w:id="(\d+)"/g) || [];
1261
+ for (const m of idMatches) {
1262
+ const id = parseInt(m.match(/\d+/)[0]);
1263
+ if (id >= nextCommentId) nextCommentId = id + 1;
1264
+ }
1265
+ } else {
1266
+ commentsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1267
+ <w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
1268
+ </w:comments>`;
1269
+ }
1270
+
1271
+ const date = new Date().toISOString();
1272
+ const commentId = nextCommentId;
1273
+
1274
+ // Add comment to comments.xml
1275
+ const newComment = `<w:comment w:id="${commentId}" w:author="${author}" w:date="${date}">
1276
+ <w:p><w:r><w:t>${message}</w:t></w:r></w:p>
1277
+ </w:comment>`;
1278
+
1279
+ commentsXml = commentsXml.replace('</w:comments>', `${newComment}\n</w:comments>`);
1280
+
1281
+ // Find text and add comment markers
1282
+ if (searchText.trim()) {
1283
+ const escapedSearch = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1284
+ const textPattern = new RegExp(`(<w:t[^>]*>)([^<]*${escapedSearch}[^<]*)(<\/w:t>)`, 'i');
1285
+
1286
+ if (textPattern.test(docXml)) {
1287
+ docXml = docXml.replace(textPattern, (match, start, text, end) => {
1288
+ return `<w:commentRangeStart w:id="${commentId}"/>${start}${text}${end}<w:commentRangeEnd w:id="${commentId}"/><w:r><w:commentReference w:id="${commentId}"/></w:r>`;
1289
+ });
1290
+ console.log(chalk.green(` ✓ Comment added at "${searchText}"`));
1291
+ } else {
1292
+ console.log(chalk.yellow(` Text not found. Comment added without anchor.`));
1293
+ }
1294
+ } else {
1295
+ console.log(chalk.dim(` Comment added without anchor.`));
1296
+ }
1297
+
1298
+ // Update zip
1299
+ zip.updateFile('word/document.xml', Buffer.from(docXml));
1300
+
1301
+ if (commentsEntry) {
1302
+ zip.updateFile('word/comments.xml', Buffer.from(commentsXml));
1303
+ } else {
1304
+ zip.addFile('word/comments.xml', Buffer.from(commentsXml));
1305
+
1306
+ // Update [Content_Types].xml
1307
+ const ctEntry = zip.getEntry('[Content_Types].xml');
1308
+ if (ctEntry) {
1309
+ let ctXml = zip.readAsText(ctEntry);
1310
+ if (!ctXml.includes('comments.xml')) {
1311
+ ctXml = ctXml.replace('</Types>',
1312
+ '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>\n</Types>');
1313
+ zip.updateFile('[Content_Types].xml', Buffer.from(ctXml));
1314
+ }
1315
+ }
1316
+
1317
+ // Update document.xml.rels
1318
+ const relsEntry = zip.getEntry('word/_rels/document.xml.rels');
1319
+ if (relsEntry) {
1320
+ let relsXml = zip.readAsText(relsEntry);
1321
+ if (!relsXml.includes('comments.xml')) {
1322
+ const newRelId = `rId${Date.now()}`;
1323
+ relsXml = relsXml.replace('</Relationships>',
1324
+ `<Relationship Id="${newRelId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>\n</Relationships>`);
1325
+ zip.updateFile('word/_rels/document.xml.rels', Buffer.from(relsXml));
1326
+ }
1327
+ }
1328
+ }
1329
+
1330
+ zip.writeZip(docxPath);
1331
+ commentsAdded++;
1332
+ console.log();
1333
+ }
1334
+
1335
+ rl.close();
1336
+ console.log();
1337
+
1338
+ if (commentsAdded > 0) {
1339
+ console.log(fmt.status('success', `Added ${commentsAdded} comment(s) to ${docxPath}`));
1340
+ } else {
1341
+ console.log(chalk.dim('No comments added.'));
1342
+ }
1343
+ });
1344
+
1345
+ // ==========================================================================
1346
+ // CLEAN command - Remove generated files
1347
+ // ==========================================================================
1348
+
1349
+ program
1350
+ .command('clean')
1351
+ .description('Remove generated files (paper.md, PDFs, DOCXs)')
1352
+ .option('-n, --dry-run', 'Show what would be deleted without deleting')
1353
+ .option('--all', 'Also remove backup and export zips')
1354
+ .action((options) => {
1355
+ let config = {};
1356
+ try {
1357
+ config = loadBuildConfig() || {};
1358
+ } catch {
1359
+ // Not in a rev project, that's ok
1360
+ }
1361
+
1362
+ const projectName = config.title?.toLowerCase().replace(/\s+/g, '-') || 'paper';
1363
+
1364
+ // Files to clean
1365
+ const patterns = [
1366
+ 'paper.md',
1367
+ '*.pdf',
1368
+ `${projectName}.docx`,
1369
+ `${projectName}.pdf`,
1370
+ `${projectName}.tex`,
1371
+ '.paper-*.md', // Temp build files
1372
+ ];
1373
+
1374
+ if (options.all) {
1375
+ patterns.push('*.zip', 'backup-*.zip', '*-export.zip');
1376
+ }
1377
+
1378
+ const toDelete = [];
1379
+
1380
+ for (const pattern of patterns) {
1381
+ if (pattern.includes('*')) {
1382
+ const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
1383
+ const files = fs.readdirSync('.').filter(f => regex.test(f));
1384
+ toDelete.push(...files);
1385
+ } else if (fs.existsSync(pattern)) {
1386
+ toDelete.push(pattern);
1387
+ }
1388
+ }
1389
+
1390
+ if (toDelete.length === 0) {
1391
+ console.log(chalk.dim('No generated files to clean.'));
1392
+ return;
1393
+ }
1394
+
1395
+ console.log(fmt.header('Clean'));
1396
+ console.log();
1397
+
1398
+ for (const file of toDelete) {
1399
+ if (options.dryRun) {
1400
+ console.log(chalk.dim(` Would delete: ${file}`));
1401
+ } else {
1402
+ fs.unlinkSync(file);
1403
+ console.log(chalk.red(` Deleted: ${file}`));
1404
+ }
1405
+ }
1406
+
1407
+ console.log();
1408
+ if (options.dryRun) {
1409
+ console.log(chalk.dim(`Would delete ${toDelete.length} file(s). Run without --dry-run to delete.`));
1410
+ } else {
1411
+ console.log(fmt.status('success', `Cleaned ${toDelete.length} file(s)`));
1412
+ }
1413
+ });
1414
+
1415
+ // ==========================================================================
1416
+ // CHECK command - Pre-submission check
1417
+ // ==========================================================================
1418
+
1419
+ program
1420
+ .command('check')
1421
+ .description('Run all checks before submission (lint + grammar + citations)')
1422
+ .option('--fix', 'Auto-fix issues where possible')
1423
+ .option('-s, --severity <level>', 'Minimum grammar severity', 'warning')
1424
+ .action(async (options) => {
1425
+ const { validateCitations } = await import('../citations.js');
1426
+ const { checkGrammar, getGrammarSummary } = await import('../grammar.js');
1427
+
1428
+ console.log(fmt.header('Pre-Submission Check'));
1429
+ console.log();
1430
+
1431
+ let hasErrors = false;
1432
+ let totalIssues = 0;
1433
+
1434
+ // 1. Run lint
1435
+ console.log(chalk.cyan.bold('1. Linting...'));
1436
+ let config = {};
1437
+ try {
1438
+ config = loadBuildConfig() || {};
1439
+ } catch {
1440
+ // Not in a rev project
1441
+ }
1442
+
1443
+ let sections = config.sections || [];
1444
+ if (sections.length === 0) {
1445
+ sections = fs.readdirSync('.').filter(f =>
1446
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
1447
+ );
1448
+ }
1449
+
1450
+ const lintIssues = [];
1451
+ const lintWarnings = [];
1452
+
1453
+ for (const file of sections) {
1454
+ if (!fs.existsSync(file)) continue;
1455
+ const content = fs.readFileSync(file, 'utf-8');
1456
+
1457
+ // Check for broken cross-references
1458
+ const refs = content.match(/@(fig|tbl|eq|sec):\w+/g) || [];
1459
+ const anchors = content.match(/\{#(fig|tbl|eq|sec):[^}]+\}/g) || [];
1460
+ const anchorLabels = anchors.map(a => a.match(/#([^}]+)/)[1]);
1461
+
1462
+ for (const ref of refs) {
1463
+ const label = ref.slice(1);
1464
+ if (!anchorLabels.includes(label)) {
1465
+ lintIssues.push({ file, message: `Broken reference: ${ref}` });
1466
+ }
1467
+ }
1468
+
1469
+ // Check for unresolved comments
1470
+ const unresolvedComments = (content.match(/\{>>[^<]*<<\}/g) || [])
1471
+ .filter(c => !c.includes('[RESOLVED]'));
1472
+ if (unresolvedComments.length > 0) {
1473
+ lintWarnings.push({ file, message: `${unresolvedComments.length} unresolved comment(s)` });
1474
+ }
1475
+ }
1476
+
1477
+ if (lintIssues.length > 0) {
1478
+ for (const issue of lintIssues) {
1479
+ console.log(chalk.red(` ✗ ${issue.file}: ${issue.message}`));
1480
+ }
1481
+ hasErrors = true;
1482
+ totalIssues += lintIssues.length;
1483
+ }
1484
+ for (const warning of lintWarnings) {
1485
+ console.log(chalk.yellow(` ⚠ ${warning.file}: ${warning.message}`));
1486
+ totalIssues++;
1487
+ }
1488
+ if (lintIssues.length === 0 && lintWarnings.length === 0) {
1489
+ console.log(chalk.green(' ✓ No lint issues'));
1490
+ }
1491
+ console.log();
1492
+
1493
+ // 2. Run grammar check
1494
+ console.log(chalk.cyan.bold('2. Grammar check...'));
1495
+
1496
+ const severityLevels = { error: 3, warning: 2, info: 1 };
1497
+ const minSeverity = severityLevels[options.severity] || 2;
1498
+ let grammarIssues = [];
1499
+
1500
+ for (const file of sections) {
1501
+ if (!fs.existsSync(file)) continue;
1502
+ const text = fs.readFileSync(file, 'utf-8');
1503
+ const issues = checkGrammar(text, { scientific: true });
1504
+ const filtered = issues.filter(i => severityLevels[i.severity] >= minSeverity);
1505
+ grammarIssues.push(...filtered.map(i => ({ ...i, file })));
1506
+ }
1507
+
1508
+ const grammarSummary = getGrammarSummary(grammarIssues);
1509
+ if (grammarSummary.errors > 0) {
1510
+ hasErrors = true;
1511
+ }
1512
+ totalIssues += grammarSummary.total;
1513
+
1514
+ if (grammarSummary.total > 0) {
1515
+ console.log(chalk.yellow(` ⚠ ${grammarSummary.total} grammar issue(s): ${grammarSummary.errors} errors, ${grammarSummary.warnings} warnings`));
1516
+ } else {
1517
+ console.log(chalk.green(' ✓ No grammar issues'));
1518
+ }
1519
+ console.log();
1520
+
1521
+ // 3. Run citation check
1522
+ console.log(chalk.cyan.bold('3. Citation check...'));
1523
+ const bibFile = config.bibliography || 'references.bib';
1524
+ if (fs.existsSync(bibFile)) {
1525
+ const allContent = sections
1526
+ .filter(f => fs.existsSync(f))
1527
+ .map(f => fs.readFileSync(f, 'utf-8'))
1528
+ .join('\n');
1529
+ const bibContent = fs.readFileSync(bibFile, 'utf-8');
1530
+
1531
+ const result = validateCitations(allContent, bibContent);
1532
+
1533
+ if (result.missing.length > 0) {
1534
+ console.log(chalk.red(` ✗ ${result.missing.length} missing citation(s): ${result.missing.slice(0, 3).join(', ')}${result.missing.length > 3 ? '...' : ''}`));
1535
+ hasErrors = true;
1536
+ totalIssues += result.missing.length;
1537
+ }
1538
+ if (result.unused.length > 0) {
1539
+ console.log(chalk.yellow(` ⚠ ${result.unused.length} unused citation(s)`));
1540
+ totalIssues += result.unused.length;
1541
+ }
1542
+ if (result.missing.length === 0 && result.unused.length === 0) {
1543
+ console.log(chalk.green(' ✓ All citations valid'));
1544
+ }
1545
+ } else {
1546
+ console.log(chalk.dim(' - No bibliography file found'));
1547
+ }
1548
+ console.log();
1549
+
1550
+ // Summary
1551
+ console.log(chalk.bold('Summary'));
1552
+ if (hasErrors) {
1553
+ console.log(chalk.red(` ${totalIssues} issue(s) found. Please fix before submission.`));
1554
+ process.exit(1);
1555
+ } else if (totalIssues > 0) {
1556
+ console.log(chalk.yellow(` ${totalIssues} warning(s). Review before submission.`));
1557
+ } else {
1558
+ console.log(chalk.green(' ✓ All checks passed! Ready for submission.'));
1559
+ }
1560
+ });
1561
+
1562
+ // ==========================================================================
1563
+ // OPEN command - Open project folder or file
1564
+ // ==========================================================================
1565
+
1566
+ program
1567
+ .command('open')
1568
+ .description('Open project folder or file in default app')
1569
+ .argument('[file]', 'File to open (default: project folder)')
1570
+ .action(async (file) => {
1571
+ const { exec } = await import('child_process');
1572
+ const target = file || '.';
1573
+
1574
+ if (!fs.existsSync(target)) {
1575
+ console.error(chalk.red(`File not found: ${target}`));
1576
+ process.exit(1);
1577
+ }
1578
+
1579
+ const platform = process.platform;
1580
+ let command;
1581
+
1582
+ if (platform === 'darwin') {
1583
+ command = `open "${target}"`;
1584
+ } else if (platform === 'win32') {
1585
+ command = `start "" "${target}"`;
1586
+ } else {
1587
+ command = `xdg-open "${target}"`;
1588
+ }
1589
+
1590
+ exec(command, (err) => {
1591
+ if (err) {
1592
+ console.error(chalk.red(`Failed to open: ${err.message}`));
1593
+ process.exit(1);
1594
+ }
1595
+ console.log(fmt.status('success', `Opened ${target}`));
1596
+ });
1597
+ });
1598
+
1599
+ // ==========================================================================
1600
+ // INSTALL-CLI-SKILL command - Install Claude Code skill
1601
+ // ==========================================================================
1602
+
1603
+ program
1604
+ .command('install-cli-skill')
1605
+ .description('Install docrev skill for Claude Code')
1606
+ .action(() => {
1607
+ const homedir = process.env.HOME || process.env.USERPROFILE;
1608
+ const skillDir = path.join(homedir, '.claude', 'skills', 'docrev');
1609
+ const sourceDir = path.join(import.meta.dirname, '..', '..', 'skill');
1610
+
1611
+ // Check if source skill files exist
1612
+ const skillFile = path.join(sourceDir, 'SKILL.md');
1613
+ if (!fs.existsSync(skillFile)) {
1614
+ console.error(chalk.red('Skill files not found in package'));
1615
+ process.exit(1);
1616
+ }
1617
+
1618
+ // Create skill directory
1619
+ fs.mkdirSync(skillDir, { recursive: true });
1620
+
1621
+ // Copy skill files
1622
+ const files = ['SKILL.md', 'REFERENCE.md'];
1623
+ for (const file of files) {
1624
+ const src = path.join(sourceDir, file);
1625
+ const dest = path.join(skillDir, file);
1626
+ if (fs.existsSync(src)) {
1627
+ fs.copyFileSync(src, dest);
1628
+ }
1629
+ }
1630
+
1631
+ console.log(fmt.status('success', 'Installed docrev skill for Claude Code'));
1632
+ console.log(chalk.dim(` Location: ${skillDir}`));
1633
+ console.log(chalk.dim(' Restart Claude Code to activate'));
1634
+ });
1635
+
1636
+ program
1637
+ .command('uninstall-cli-skill')
1638
+ .description('Remove docrev skill from Claude Code')
1639
+ .action(() => {
1640
+ const homedir = process.env.HOME || process.env.USERPROFILE;
1641
+ const skillDir = path.join(homedir, '.claude', 'skills', 'docrev');
1642
+
1643
+ if (fs.existsSync(skillDir)) {
1644
+ fs.rmSync(skillDir, { recursive: true });
1645
+ console.log(fmt.status('success', 'Removed docrev skill from Claude Code'));
1646
+ } else {
1647
+ console.log(chalk.yellow('Skill not installed'));
1648
+ }
1649
+ });
1650
+
1651
+ // ==========================================================================
1652
+ // SPELLING command - Spellcheck with global dictionary
1653
+ // ==========================================================================
1654
+
1655
+ program
1656
+ .command('spelling')
1657
+ .description('Check spelling in markdown files')
1658
+ .argument('[files...]', 'Files to check (default: section files)')
1659
+ .option('--learn <word>', 'Add word to global dictionary')
1660
+ .option('--learn-project <word>', 'Add word to project dictionary')
1661
+ .option('--forget <word>', 'Remove word from global dictionary')
1662
+ .option('--forget-project <word>', 'Remove word from project dictionary')
1663
+ .option('--list', 'List global dictionary words')
1664
+ .option('--list-project', 'List project dictionary words')
1665
+ .option('--list-all', 'List all custom words (global + project)')
1666
+ .option('--british', 'Use British English dictionary')
1667
+ .option('--add-names', 'Add detected names to global dictionary')
1668
+ .action(async (files, options) => {
1669
+ const spelling = await import('../spelling.js');
1670
+
1671
+ // Handle dictionary management
1672
+ if (options.learn) {
1673
+ const added = spelling.addWord(options.learn, true);
1674
+ if (added) {
1675
+ console.log(fmt.status('success', `Added "${options.learn}" to global dictionary`));
1676
+ } else {
1677
+ console.log(chalk.yellow(`"${options.learn}" already in dictionary`));
1678
+ }
1679
+ return;
1680
+ }
1681
+
1682
+ if (options.learnProject) {
1683
+ const added = spelling.addWord(options.learnProject, false);
1684
+ if (added) {
1685
+ console.log(fmt.status('success', `Added "${options.learnProject}" to project dictionary`));
1686
+ } else {
1687
+ console.log(chalk.yellow(`"${options.learnProject}" already in dictionary`));
1688
+ }
1689
+ return;
1690
+ }
1691
+
1692
+ if (options.forget) {
1693
+ const removed = spelling.removeWord(options.forget, true);
1694
+ if (removed) {
1695
+ console.log(fmt.status('success', `Removed "${options.forget}" from global dictionary`));
1696
+ } else {
1697
+ console.log(chalk.yellow(`"${options.forget}" not in dictionary`));
1698
+ }
1699
+ return;
1700
+ }
1701
+
1702
+ if (options.forgetProject) {
1703
+ const removed = spelling.removeWord(options.forgetProject, false);
1704
+ if (removed) {
1705
+ console.log(fmt.status('success', `Removed "${options.forgetProject}" from project dictionary`));
1706
+ } else {
1707
+ console.log(chalk.yellow(`"${options.forgetProject}" not in dictionary`));
1708
+ }
1709
+ return;
1710
+ }
1711
+
1712
+ if (options.list) {
1713
+ const words = spelling.listWords(true);
1714
+ console.log(fmt.header('Global Dictionary'));
1715
+ if (words.length === 0) {
1716
+ console.log(chalk.dim(' No custom words'));
1717
+ console.log(chalk.dim(' Use --learn <word> to add words'));
1718
+ } else {
1719
+ for (const word of words) {
1720
+ console.log(` ${word}`);
1721
+ }
1722
+ console.log(chalk.dim(`\n${words.length} word(s)`));
1723
+ }
1724
+ return;
1725
+ }
1726
+
1727
+ if (options.listProject) {
1728
+ const words = spelling.listWords(false);
1729
+ console.log(fmt.header('Project Dictionary'));
1730
+ if (words.length === 0) {
1731
+ console.log(chalk.dim(' No custom words'));
1732
+ console.log(chalk.dim(' Use --learn-project <word> to add words'));
1733
+ } else {
1734
+ for (const word of words) {
1735
+ console.log(` ${word}`);
1736
+ }
1737
+ console.log(chalk.dim(`\n${words.length} word(s)`));
1738
+ }
1739
+ return;
1740
+ }
1741
+
1742
+ if (options.listAll) {
1743
+ const globalWords = spelling.listWords(true);
1744
+ const projectWords = spelling.listWords(false);
1745
+
1746
+ console.log(fmt.header('Global Dictionary'));
1747
+ if (globalWords.length === 0) {
1748
+ console.log(chalk.dim(' No custom words'));
1749
+ } else {
1750
+ for (const word of globalWords) {
1751
+ console.log(` ${word}`);
1752
+ }
1753
+ }
1754
+
1755
+ console.log(fmt.header('Project Dictionary'));
1756
+ if (projectWords.length === 0) {
1757
+ console.log(chalk.dim(' No custom words'));
1758
+ } else {
1759
+ for (const word of projectWords) {
1760
+ console.log(` ${word}`);
1761
+ }
1762
+ }
1763
+
1764
+ console.log(chalk.dim(`\nTotal: ${globalWords.length + projectWords.length} word(s)`));
1765
+ return;
1766
+ }
1767
+
1768
+ // Check spelling in files
1769
+ let filesToCheck = files;
1770
+
1771
+ if (filesToCheck.length === 0) {
1772
+ if (fs.existsSync('rev.yaml')) {
1773
+ const { getSectionFiles } = await import('../sections.js');
1774
+ filesToCheck = getSectionFiles('.');
1775
+ } else {
1776
+ filesToCheck = fs.readdirSync('.')
1777
+ .filter(f => f.endsWith('.md') && !f.startsWith('.'));
1778
+ }
1779
+ }
1780
+
1781
+ if (filesToCheck.length === 0) {
1782
+ console.log(chalk.yellow('No markdown files found'));
1783
+ return;
1784
+ }
1785
+
1786
+ const lang = options.british ? 'en-gb' : 'en';
1787
+ console.log(fmt.header(`Spelling Check (${options.british ? 'British' : 'US'} English)`));
1788
+ let totalMisspelled = 0;
1789
+ const allNames = new Set();
1790
+
1791
+ for (const file of filesToCheck) {
1792
+ if (!fs.existsSync(file)) {
1793
+ console.log(chalk.yellow(`File not found: ${file}`));
1794
+ continue;
1795
+ }
1796
+
1797
+ const result = await spelling.checkFile(file, { projectDir: '.', lang });
1798
+ const { misspelled, possibleNames } = result;
1799
+
1800
+ // Collect names
1801
+ for (const n of possibleNames) {
1802
+ allNames.add(n.word);
1803
+ }
1804
+
1805
+ if (misspelled.length > 0) {
1806
+ console.log(chalk.cyan(`\n${file}:`));
1807
+ for (const issue of misspelled) {
1808
+ const suggestions = issue.suggestions.length > 0
1809
+ ? chalk.dim(` → ${issue.suggestions.join(', ')}`)
1810
+ : '';
1811
+ console.log(` ${chalk.yellow(issue.word)} ${chalk.dim(`(line ${issue.line})`)}${suggestions}`);
1812
+ }
1813
+ totalMisspelled += misspelled.length;
1814
+ }
1815
+ }
1816
+
1817
+ // Show possible names separately
1818
+ if (allNames.size > 0) {
1819
+ const nameList = [...allNames].sort();
1820
+
1821
+ if (options.addNames) {
1822
+ console.log(fmt.header('Adding Names to Dictionary'));
1823
+ for (const name of nameList) {
1824
+ spelling.addWord(name, true);
1825
+ console.log(chalk.green(` ✓ ${name}`));
1826
+ }
1827
+ console.log(chalk.dim(`\nAdded ${nameList.length} name(s) to global dictionary`));
1828
+ } else {
1829
+ console.log(fmt.header('Possible Names'));
1830
+ console.log(chalk.dim(` ${nameList.join(', ')}`));
1831
+ console.log(chalk.dim(`\n Run with --add-names to add all to dictionary`));
1832
+ }
1833
+ }
1834
+
1835
+ if (totalMisspelled === 0 && allNames.size === 0) {
1836
+ console.log(fmt.status('success', 'No spelling errors found'));
1837
+ } else {
1838
+ if (totalMisspelled > 0) {
1839
+ console.log(chalk.yellow(`\n${totalMisspelled} spelling error(s)`));
1840
+ }
1841
+ if (allNames.size > 0) {
1842
+ console.log(chalk.blue(`${allNames.size} possible name(s)`));
1843
+ }
1844
+ console.log(chalk.dim('Use --learn <word> to add words to dictionary'));
1845
+ }
1846
+ });
1847
+
1848
+ // ==========================================================================
1849
+ // UPGRADE command - Self-update via npm
1850
+ // ==========================================================================
1851
+
1852
+ program
1853
+ .command('upgrade')
1854
+ .description('Check for updates and upgrade docrev')
1855
+ .option('--check', 'Only check for updates, do not install')
1856
+ .action(async (options) => {
1857
+ const { execSync, spawn } = await import('child_process');
1858
+
1859
+ console.log(chalk.cyan('Checking for updates...'));
1860
+
1861
+ try {
1862
+ // Get current version
1863
+ const currentVersion = pkg?.version || 'unknown';
1864
+
1865
+ // Get latest version from npm
1866
+ let latestVersion;
1867
+ try {
1868
+ latestVersion = execSync('npm view docrev version', { encoding: 'utf-8' }).trim();
1869
+ } catch {
1870
+ console.error(chalk.red('Failed to check npm registry'));
1871
+ console.error(chalk.dim('Check your internet connection'));
1872
+ process.exit(1);
1873
+ }
1874
+
1875
+ if (currentVersion === latestVersion) {
1876
+ console.log(fmt.status('success', `Already on latest version (${currentVersion})`));
1877
+ return;
1878
+ }
1879
+
1880
+ console.log(` Current: ${chalk.yellow(currentVersion)}`);
1881
+ console.log(` Latest: ${chalk.green(latestVersion)}`);
1882
+ console.log();
1883
+
1884
+ if (options.check) {
1885
+ console.log(chalk.cyan('Run "rev upgrade" to install the update'));
1886
+ return;
1887
+ }
1888
+
1889
+ console.log(chalk.cyan('Upgrading...'));
1890
+
1891
+ // Run npm update
1892
+ const child = spawn('npm', ['install', '-g', 'docrev@latest'], {
1893
+ stdio: 'inherit',
1894
+ shell: true,
1895
+ });
1896
+
1897
+ child.on('close', (code) => {
1898
+ if (code === 0) {
1899
+ console.log();
1900
+ console.log(fmt.status('success', `Upgraded to ${latestVersion}`));
1901
+ } else {
1902
+ console.error(chalk.red('Upgrade failed'));
1903
+ console.error(chalk.dim('Try running: npm install -g docrev@latest'));
1904
+ process.exit(1);
1905
+ }
1906
+ });
1907
+
1908
+ child.on('error', (err) => {
1909
+ console.error(chalk.red(`Upgrade failed: ${err.message}`));
1910
+ console.error(chalk.dim('Try running: npm install -g docrev@latest'));
1911
+ process.exit(1);
1912
+ });
1913
+ } catch (err) {
1914
+ console.error(chalk.red(`Error: ${err.message}`));
1915
+ process.exit(1);
1916
+ }
1917
+ });
1918
+
1919
+ // ==========================================================================
1920
+ // BATCH command - Run operations on multiple documents
1921
+ // ==========================================================================
1922
+
1923
+ program
1924
+ .command('batch')
1925
+ .description('Run operations on multiple documents')
1926
+ .argument('<command>', 'Command to run (status, strip, resolve)')
1927
+ .argument('[pattern]', 'File pattern (default: *.md)')
1928
+ .option('--parallel', 'Run operations in parallel')
1929
+ .option('--dry-run', 'Preview files without running')
1930
+ .option('-a, --all', 'Include all .md files (not just sections)')
1931
+ .action(async (command, pattern, options) => {
1932
+ const validCommands = ['status', 'strip', 'resolve', 'comments'];
1933
+
1934
+ if (!validCommands.includes(command)) {
1935
+ console.error(fmt.status('error', `Unknown batch command: ${command}`));
1936
+ console.error(chalk.dim(`Available: ${validCommands.join(', ')}`));
1937
+ process.exit(1);
1938
+ }
1939
+
1940
+ // Find files
1941
+ let files = [];
1942
+ if (pattern) {
1943
+ if (pattern.includes('*')) {
1944
+ files = fs.readdirSync('.').filter(f =>
1945
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
1946
+ );
1947
+ } else {
1948
+ files = [pattern];
1949
+ }
1950
+ } else {
1951
+ files = fs.readdirSync('.').filter(f =>
1952
+ f.endsWith('.md') &&
1953
+ (options.all || !['README.md', 'CLAUDE.md', 'paper.md'].includes(f))
1954
+ );
1955
+ }
1956
+
1957
+ if (files.length === 0) {
1958
+ console.error(fmt.status('error', 'No files found'));
1959
+ process.exit(1);
1960
+ }
1961
+
1962
+ console.log(fmt.header(`Batch ${command} on ${files.length} file(s)`));
1963
+ console.log();
1964
+
1965
+ if (options.dryRun) {
1966
+ console.log(chalk.dim('Dry run - files that would be processed:'));
1967
+ for (const file of files) {
1968
+ console.log(chalk.dim(` ${file}`));
1969
+ }
1970
+ return;
1971
+ }
1972
+
1973
+ // Process files
1974
+ const results = [];
1975
+ const progressBar = fmt.progressBar(files.length, 'Processing');
1976
+ progressBar.update(0);
1977
+
1978
+ for (let i = 0; i < files.length; i++) {
1979
+ const file = files[i];
1980
+ progressBar.update(i + 1);
1981
+
1982
+ if (!fs.existsSync(file)) {
1983
+ results.push({ file, status: 'not found' });
1984
+ continue;
1985
+ }
1986
+
1987
+ try {
1988
+ const text = fs.readFileSync(file, 'utf-8');
1989
+ let result = { file, status: 'ok' };
1990
+
1991
+ switch (command) {
1992
+ case 'status': {
1993
+ const counts = countAnnotations(text);
1994
+ const comments = getComments(text);
1995
+ result.annotations = counts.total;
1996
+ result.comments = comments.length;
1997
+ result.pending = comments.filter(c => !c.resolved).length;
1998
+ break;
1999
+ }
2000
+
2001
+ case 'comments': {
2002
+ const comments = getComments(text);
2003
+ result.total = comments.length;
2004
+ result.pending = comments.filter(c => !c.resolved).length;
2005
+ result.resolved = comments.filter(c => c.resolved).length;
2006
+ break;
2007
+ }
2008
+
2009
+ case 'resolve': {
2010
+ const comments = getComments(text);
2011
+ const pending = comments.filter(c => !c.resolved);
2012
+ if (pending.length > 0) {
2013
+ let newText = text;
2014
+ for (const c of pending) {
2015
+ newText = setCommentStatus(newText, c, true);
2016
+ }
2017
+ fs.writeFileSync(file, newText, 'utf-8');
2018
+ result.resolved = pending.length;
2019
+ } else {
2020
+ result.resolved = 0;
2021
+ }
2022
+ break;
2023
+ }
2024
+
2025
+ case 'strip': {
2026
+ const clean = stripAnnotations(text, { keepComments: false });
2027
+ const hasChanges = clean !== text;
2028
+ if (hasChanges) {
2029
+ fs.writeFileSync(file, clean, 'utf-8');
2030
+ result.stripped = true;
2031
+ } else {
2032
+ result.stripped = false;
2033
+ }
2034
+ break;
2035
+ }
2036
+ }
2037
+
2038
+ results.push(result);
2039
+ } catch (err) {
2040
+ results.push({ file, status: 'error', error: err.message });
2041
+ }
2042
+ }
2043
+
2044
+ progressBar.done();
2045
+ console.log();
2046
+
2047
+ // Show results
2048
+ console.log(fmt.header('Results'));
2049
+ console.log();
2050
+
2051
+ for (const r of results) {
2052
+ const statusIcon = r.status === 'ok'
2053
+ ? chalk.green('✓')
2054
+ : r.status === 'error'
2055
+ ? chalk.red('✗')
2056
+ : chalk.yellow('?');
2057
+
2058
+ let details = '';
2059
+ switch (command) {
2060
+ case 'status':
2061
+ details = chalk.dim(`${r.annotations || 0} annotations, ${r.pending || 0} pending comments`);
2062
+ break;
2063
+ case 'comments':
2064
+ details = chalk.dim(`${r.total || 0} total, ${r.pending || 0} pending`);
2065
+ break;
2066
+ case 'resolve':
2067
+ details = r.resolved > 0
2068
+ ? chalk.green(`${r.resolved} resolved`)
2069
+ : chalk.dim('no pending');
2070
+ break;
2071
+ case 'strip':
2072
+ details = r.stripped
2073
+ ? chalk.green('cleaned')
2074
+ : chalk.dim('no changes');
2075
+ break;
2076
+ }
2077
+
2078
+ console.log(` ${statusIcon} ${r.file} ${details}`);
2079
+ if (r.error) {
2080
+ console.log(chalk.red(` ${r.error}`));
2081
+ }
2082
+ }
2083
+
2084
+ // Summary
2085
+ console.log();
2086
+ const successful = results.filter(r => r.status === 'ok').length;
2087
+ const failed = results.filter(r => r.status === 'error').length;
2088
+ console.log(chalk.dim(`${successful} succeeded, ${failed} failed`));
2089
+ });
2090
+ }
2091
+
2092
+ // Helper functions for help text
2093
+
2094
+ function showFullHelp(pkg) {
2095
+ console.log(`
2096
+ ${chalk.bold.cyan('rev')} ${chalk.dim(`v${pkg?.version || 'unknown'}`)} - Revision workflow for Word ↔ Markdown round-trips
2097
+
2098
+ ${chalk.bold('DESCRIPTION')}
2099
+ Handle reviewer feedback when collaborating on academic papers.
2100
+ Import changes from Word, review them interactively, and preserve
2101
+ comments for discussion with Claude.
2102
+
2103
+ ${chalk.bold('GLOBAL OPTIONS')}
2104
+
2105
+ ${chalk.bold('--no-color')} Disable colored output
2106
+ ${chalk.bold('-q, --quiet')} Suppress non-essential output
2107
+ ${chalk.bold('--json')} Output in JSON format (for scripting)
2108
+
2109
+ ${chalk.bold('TYPICAL WORKFLOW')}
2110
+
2111
+ ${chalk.dim('1.')} Build and send: ${chalk.green('rev build docx')} ${chalk.dim('(or rev b docx)')}
2112
+ ${chalk.dim('2.')} Reviewers return ${chalk.yellow('reviewed.docx')} with edits and comments
2113
+ ${chalk.dim('3.')} Sync their feedback: ${chalk.green('rev sync reviewed.docx')}
2114
+ ${chalk.dim('4.')} Work through comments: ${chalk.green('rev next')} ${chalk.dim('(n)')} / ${chalk.green('rev todo')} ${chalk.dim('(t)')}
2115
+ ${chalk.dim('5.')} Accept/reject changes: ${chalk.green('rev accept -a')} ${chalk.dim('(a)')} or ${chalk.green('rev review')}
2116
+ ${chalk.dim('6.')} Rebuild: ${chalk.green('rev build docx')}
2117
+ ${chalk.dim('7.')} Archive old files: ${chalk.green('rev archive')}
2118
+
2119
+ ${chalk.bold('MORE HELP')}
2120
+
2121
+ rev help workflow Detailed workflow guide
2122
+ rev help syntax Annotation syntax reference
2123
+ rev help commands All commands with options
2124
+ `);
2125
+ }
2126
+
2127
+ function showWorkflowHelp() {
2128
+ console.log(`
2129
+ ${chalk.bold.cyan('rev')} ${chalk.dim('- Workflow Guide')}
2130
+
2131
+ ${chalk.bold('OVERVIEW')}
2132
+
2133
+ The rev workflow solves a common problem: you write in Markdown,
2134
+ but collaborators review in Word. When they return edited documents,
2135
+ you need to merge their changes back into your source files.
2136
+
2137
+ ${chalk.bold('STEP 1: BUILD & SEND')}
2138
+
2139
+ ${chalk.green('rev build docx')}
2140
+ ${chalk.dim('# Send the .docx to reviewers')}
2141
+
2142
+ ${chalk.bold('STEP 2: RECEIVE FEEDBACK')}
2143
+
2144
+ Reviewers edit the document, adding:
2145
+ ${chalk.dim('•')} Track changes (insertions, deletions)
2146
+ ${chalk.dim('•')} Comments (questions, suggestions)
2147
+
2148
+ ${chalk.bold('STEP 3: SYNC CHANGES')}
2149
+
2150
+ ${chalk.green('rev sync reviewed.docx')}
2151
+ ${chalk.dim('# Or just: rev sync (auto-detects most recent .docx)')}
2152
+
2153
+ Your markdown files now contain their feedback as annotations.
2154
+
2155
+ ${chalk.bold('STEP 4: WORK THROUGH COMMENTS')}
2156
+
2157
+ ${chalk.green('rev todo')} ${chalk.dim('# See all pending comments')}
2158
+ ${chalk.green('rev next')} ${chalk.dim('# Show next pending comment')}
2159
+ ${chalk.green('rev reply file.md -n 1 -m "Done"')}
2160
+ ${chalk.green('rev resolve file.md -n 1')}
2161
+
2162
+ ${chalk.bold('STEP 5: ACCEPT/REJECT CHANGES')}
2163
+
2164
+ ${chalk.green('rev accept file.md -a')} ${chalk.dim('# Accept all changes')}
2165
+ ${chalk.green('rev reject file.md -n 2')} ${chalk.dim('# Reject specific change')}
2166
+ ${chalk.dim('# Or use interactive mode:')}
2167
+ ${chalk.green('rev review file.md')}
2168
+
2169
+ ${chalk.bold('STEP 6: REBUILD')}
2170
+
2171
+ ${chalk.green('rev build docx')}
2172
+ ${chalk.green('rev build docx --dual')} ${chalk.dim('# Clean + comments version')}
2173
+
2174
+ ${chalk.bold('STEP 7: ARCHIVE & REPEAT')}
2175
+
2176
+ ${chalk.green('rev archive')} ${chalk.dim('# Move reviewer files to archive/')}
2177
+ ${chalk.dim('# Send new .docx, repeat cycle')}
2178
+ `);
2179
+ }
2180
+
2181
+ function showSyntaxHelp() {
2182
+ console.log(`
2183
+ ${chalk.bold.cyan('rev')} ${chalk.dim('- Annotation Syntax (CriticMarkup)')}
2184
+
2185
+ ${chalk.bold('INSERTIONS')}
2186
+
2187
+ Syntax: ${chalk.green('{++inserted text++}')}
2188
+ Meaning: This text was added by the reviewer
2189
+
2190
+ Example:
2191
+ We ${chalk.green('{++specifically++}')} focused on neophytes.
2192
+ → Reviewer added the word "specifically"
2193
+
2194
+ ${chalk.bold('DELETIONS')}
2195
+
2196
+ Syntax: ${chalk.red('{--deleted text--}')}
2197
+ Meaning: This text was removed by the reviewer
2198
+
2199
+ Example:
2200
+ We focused on ${chalk.red('{--recent--}')} neophytes.
2201
+ → Reviewer removed the word "recent"
2202
+
2203
+ ${chalk.bold('SUBSTITUTIONS')}
2204
+
2205
+ Syntax: ${chalk.yellow('{~~old text~>new text~~}')}
2206
+ Meaning: Text was changed from old to new
2207
+
2208
+ Example:
2209
+ The effect was ${chalk.yellow('{~~significant~>substantial~~}')}.
2210
+ → Reviewer changed "significant" to "substantial"
2211
+
2212
+ ${chalk.bold('COMMENTS')}
2213
+
2214
+ Syntax: ${chalk.blue('{>>Author: comment text<<}')}
2215
+ Meaning: Reviewer left a comment at this location
2216
+
2217
+ Example:
2218
+ The results were significant. ${chalk.blue('{>>Dr. Smith: Add p-value<<}')}
2219
+ → Dr. Smith commented asking for a p-value
2220
+
2221
+ Comments are placed ${chalk.bold('after')} the text they reference.
2222
+ `);
2223
+ }
2224
+
2225
+ function showCommandsHelp() {
2226
+ console.log(`
2227
+ ${chalk.bold.cyan('rev')} ${chalk.dim('- Command Reference')}
2228
+
2229
+ ${chalk.bold('rev import')} <docx> <original-md>
2230
+
2231
+ Import changes from a Word document by comparing against your
2232
+ original Markdown source.
2233
+
2234
+ ${chalk.bold('Arguments:')}
2235
+ docx Word document from reviewer
2236
+ original-md Your original Markdown file
2237
+
2238
+ ${chalk.bold('Options:')}
2239
+ -o, --output <file> Write to different file (default: overwrites original)
2240
+ -a, --author <name> Author name for changes (default: "Reviewer")
2241
+ --dry-run Preview changes without saving
2242
+
2243
+ ${chalk.bold('rev review')} <file>
2244
+
2245
+ Interactively review and accept/reject track changes.
2246
+ Comments are preserved; only track changes are processed.
2247
+
2248
+ ${chalk.bold('Keys:')}
2249
+ a Accept this change
2250
+ r Reject this change
2251
+ s Skip (decide later)
2252
+ A Accept all remaining changes
2253
+ L Reject all remaining changes
2254
+ q Quit without saving
2255
+
2256
+ ${chalk.bold('rev strip')} <file>
2257
+
2258
+ Remove annotations, outputting clean Markdown.
2259
+ Track changes are applied (insertions kept, deletions removed).
2260
+
2261
+ ${chalk.bold('Options:')}
2262
+ -o, --output <file> Write to file (default: stdout)
2263
+ -c, --keep-comments Keep comment annotations
2264
+
2265
+ ${chalk.bold('rev help')} [topic]
2266
+
2267
+ Show help. Optional topics:
2268
+ workflow Step-by-step workflow guide
2269
+ syntax Annotation syntax reference
2270
+ commands This command reference
2271
+ `);
2272
+ }