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