docrev 0.6.7 → 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,307 @@
1
+ /**
2
+ * History commands: diff, history, contributors
3
+ *
4
+ * Commands for git-based revision tracking and author statistics.
5
+ */
6
+
7
+ import {
8
+ chalk,
9
+ fs,
10
+ path,
11
+ fmt,
12
+ loadBuildConfig,
13
+ } from './context.js';
14
+
15
+ /**
16
+ * Register history commands with the program
17
+ * @param {import('commander').Command} program
18
+ */
19
+ export function register(program) {
20
+ // ==========================================================================
21
+ // DIFF command - Compare sections against git history
22
+ // ==========================================================================
23
+
24
+ program
25
+ .command('diff')
26
+ .description('Compare sections against git history')
27
+ .argument('[ref]', 'Git reference to compare against (default: main/master)')
28
+ .option('-f, --files <files>', 'Specific files to compare (comma-separated)')
29
+ .option('--stat', 'Show only statistics, not full diff')
30
+ .action(async (ref, options) => {
31
+ const {
32
+ isGitRepo,
33
+ getDefaultBranch,
34
+ getCurrentBranch,
35
+ getChangedFiles,
36
+ getWordCountDiff,
37
+ compareFileVersions,
38
+ } = await import('../git.js');
39
+
40
+ if (!isGitRepo()) {
41
+ console.error(fmt.status('error', 'Not a git repository'));
42
+ process.exit(1);
43
+ }
44
+
45
+ const compareRef = ref || getDefaultBranch();
46
+ const currentBranch = getCurrentBranch();
47
+
48
+ console.log(fmt.header('Git Diff'));
49
+ console.log(chalk.dim(` Comparing: ${compareRef} → ${currentBranch || 'HEAD'}`));
50
+ console.log();
51
+
52
+ // Get files to compare
53
+ let filesToCompare;
54
+ if (options.files) {
55
+ filesToCompare = options.files.split(',').map(f => f.trim());
56
+ } else {
57
+ // Default to markdown section files
58
+ filesToCompare = fs.readdirSync('.').filter(f =>
59
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md'].includes(f)
60
+ );
61
+ }
62
+
63
+ if (filesToCompare.length === 0) {
64
+ console.log(fmt.status('info', 'No markdown files found'));
65
+ return;
66
+ }
67
+
68
+ // Get changed files from git
69
+ const changedFiles = getChangedFiles(compareRef);
70
+ const changedSet = new Set(changedFiles.map(f => f.file));
71
+
72
+ // Get word count differences
73
+ const { total, byFile } = getWordCountDiff(filesToCompare, compareRef);
74
+
75
+ // Show results
76
+ const rows = [];
77
+ for (const file of filesToCompare) {
78
+ const stats = byFile[file];
79
+ if (stats && (stats.added > 0 || stats.removed > 0)) {
80
+ const status = changedSet.has(file)
81
+ ? changedFiles.find(f => f.file === file)?.status || 'modified'
82
+ : 'unchanged';
83
+ rows.push([
84
+ file,
85
+ status,
86
+ chalk.green(`+${stats.added}`),
87
+ chalk.red(`-${stats.removed}`),
88
+ ]);
89
+ }
90
+ }
91
+
92
+ if (rows.length === 0) {
93
+ console.log(fmt.status('success', 'No changes detected'));
94
+ return;
95
+ }
96
+
97
+ console.log(fmt.table(['File', 'Status', 'Added', 'Removed'], rows));
98
+ console.log();
99
+ console.log(chalk.dim(`Total: ${chalk.green(`+${total.added}`)} words, ${chalk.red(`-${total.removed}`)} words`));
100
+
101
+ // Show detailed diff if not --stat
102
+ if (!options.stat && rows.length > 0) {
103
+ console.log();
104
+ console.log(chalk.cyan('Changed sections:'));
105
+ for (const file of filesToCompare) {
106
+ const stats = byFile[file];
107
+ if (stats && (stats.added > 0 || stats.removed > 0)) {
108
+ const { changes } = compareFileVersions(file, compareRef);
109
+ console.log(chalk.bold(`\n ${file}:`));
110
+
111
+ // Show first few significant changes
112
+ let shown = 0;
113
+ for (const change of changes) {
114
+ if (shown >= 3) {
115
+ console.log(chalk.dim(' ...'));
116
+ break;
117
+ }
118
+ const preview = change.text.slice(0, 60).replace(/\n/g, ' ');
119
+ if (change.type === 'add') {
120
+ console.log(chalk.green(` + "${preview}..."`));
121
+ } else {
122
+ console.log(chalk.red(` - "${preview}..."`));
123
+ }
124
+ shown++;
125
+ }
126
+ }
127
+ }
128
+ }
129
+ });
130
+
131
+ // ==========================================================================
132
+ // HISTORY command - Show revision history
133
+ // ==========================================================================
134
+
135
+ program
136
+ .command('history')
137
+ .description('Show revision history for section files')
138
+ .argument('[file]', 'Specific file (default: all sections)')
139
+ .option('-n, --limit <count>', 'Number of commits to show', '10')
140
+ .action(async (file, options) => {
141
+ const {
142
+ isGitRepo,
143
+ getFileHistory,
144
+ getRecentCommits,
145
+ hasUncommittedChanges,
146
+ } = await import('../git.js');
147
+
148
+ if (!isGitRepo()) {
149
+ console.error(fmt.status('error', 'Not a git repository'));
150
+ process.exit(1);
151
+ }
152
+
153
+ const limit = parseInt(options.limit) || 10;
154
+
155
+ console.log(fmt.header('Revision History'));
156
+ console.log();
157
+
158
+ if (file) {
159
+ // Show history for specific file
160
+ if (!fs.existsSync(file)) {
161
+ console.error(fmt.status('error', `File not found: ${file}`));
162
+ process.exit(1);
163
+ }
164
+
165
+ const history = getFileHistory(file, limit);
166
+
167
+ if (history.length === 0) {
168
+ console.log(fmt.status('info', 'No history found (file may not be committed)'));
169
+ return;
170
+ }
171
+
172
+ console.log(chalk.cyan(`History for ${file}:`));
173
+ console.log();
174
+
175
+ for (const commit of history) {
176
+ const date = new Date(commit.date).toLocaleDateString();
177
+ console.log(` ${chalk.yellow(commit.hash)} ${chalk.dim(date)}`);
178
+ console.log(` ${commit.message}`);
179
+ }
180
+ } else {
181
+ // Show recent commits affecting any file
182
+ const commits = getRecentCommits(limit);
183
+
184
+ if (commits.length === 0) {
185
+ console.log(fmt.status('info', 'No commits found'));
186
+ return;
187
+ }
188
+
189
+ if (hasUncommittedChanges()) {
190
+ console.log(chalk.yellow(' * Uncommitted changes'));
191
+ console.log();
192
+ }
193
+
194
+ for (const commit of commits) {
195
+ const date = new Date(commit.date).toLocaleDateString();
196
+ console.log(` ${chalk.yellow(commit.hash)} ${chalk.dim(date)} ${chalk.blue(commit.author)}`);
197
+ console.log(` ${commit.message}`);
198
+ }
199
+ }
200
+ });
201
+
202
+ // ==========================================================================
203
+ // CONTRIBUTORS command - Show who wrote what
204
+ // ==========================================================================
205
+
206
+ program
207
+ .command('contributors')
208
+ .alias('authors')
209
+ .description('Show author contributions across section files')
210
+ .argument('[file]', 'Specific file (default: all sections)')
211
+ .option('--blame', 'Show detailed line-by-line blame for a file')
212
+ .action(async (file, options) => {
213
+ const { isGitRepo, getAuthorStats, getContributors, getFileBlame } = await import('../git.js');
214
+
215
+ if (!isGitRepo()) {
216
+ console.error(fmt.status('error', 'Not a git repository'));
217
+ process.exit(1);
218
+ }
219
+
220
+ console.log(fmt.header('Contributors'));
221
+ console.log();
222
+
223
+ if (file) {
224
+ // Show stats for specific file
225
+ if (!fs.existsSync(file)) {
226
+ console.error(fmt.status('error', `File not found: ${file}`));
227
+ process.exit(1);
228
+ }
229
+
230
+ if (options.blame) {
231
+ // Detailed blame output
232
+ const blame = getFileBlame(file);
233
+ if (blame.length === 0) {
234
+ console.log(fmt.status('info', 'No git history (file may not be committed)'));
235
+ return;
236
+ }
237
+
238
+ console.log(chalk.cyan(`Blame for ${file}:`));
239
+ console.log();
240
+
241
+ for (const entry of blame) {
242
+ const authorShort = entry.author.slice(0, 15).padEnd(15);
243
+ const content = entry.content.length > 60 ? entry.content.slice(0, 60) + '...' : entry.content;
244
+ console.log(` ${chalk.dim(entry.hash)} ${chalk.blue(authorShort)} ${chalk.dim(`L${String(entry.line).padStart(3)}`)} ${content}`);
245
+ }
246
+ } else {
247
+ // Summary stats
248
+ const stats = getAuthorStats(file);
249
+ if (Object.keys(stats).length === 0) {
250
+ console.log(fmt.status('info', 'No git history (file may not be committed)'));
251
+ return;
252
+ }
253
+
254
+ console.log(chalk.cyan(`Authors for ${file}:`));
255
+ console.log();
256
+
257
+ const sorted = Object.entries(stats).sort((a, b) => b[1].lines - a[1].lines);
258
+ for (const [author, data] of sorted) {
259
+ const bar = '█'.repeat(Math.ceil(data.percentage / 5));
260
+ console.log(` ${chalk.blue(author.padEnd(25))} ${chalk.dim(String(data.lines).padStart(4))} lines ${chalk.green(bar)} ${data.percentage}%`);
261
+ }
262
+ }
263
+ } else {
264
+ // Show contributors across all sections
265
+ let config = {};
266
+ try {
267
+ config = loadBuildConfig() || {};
268
+ } catch {
269
+ // Not in a rev project
270
+ }
271
+
272
+ let sections = config.sections || [];
273
+ if (sections.length === 0) {
274
+ sections = fs.readdirSync('.').filter(f =>
275
+ f.endsWith('.md') && !['README.md', 'CLAUDE.md', 'paper.md'].includes(f)
276
+ );
277
+ }
278
+
279
+ if (sections.length === 0) {
280
+ console.error(fmt.status('error', 'No section files found'));
281
+ process.exit(1);
282
+ }
283
+
284
+ const contributors = getContributors(sections);
285
+
286
+ if (Object.keys(contributors).length === 0) {
287
+ console.log(fmt.status('info', 'No git history found'));
288
+ return;
289
+ }
290
+
291
+ const sorted = Object.entries(contributors).sort((a, b) => b[1].lines - a[1].lines);
292
+ const totalLines = sorted.reduce((sum, [, data]) => sum + data.lines, 0);
293
+
294
+ console.log(chalk.cyan('Project contributors:'));
295
+ console.log();
296
+
297
+ for (const [author, data] of sorted) {
298
+ const pct = Math.round((data.lines / totalLines) * 100);
299
+ const bar = '█'.repeat(Math.ceil(pct / 5));
300
+ console.log(` ${chalk.blue(author.padEnd(25))} ${chalk.dim(String(data.lines).padStart(5))} lines ${chalk.dim(String(data.files))} files ${chalk.green(bar)} ${pct}%`);
301
+ }
302
+
303
+ console.log();
304
+ console.log(chalk.dim(` Total: ${totalLines} lines across ${sections.length} files`));
305
+ }
306
+ });
307
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Command module index
3
+ *
4
+ * Exports registration functions for all command modules.
5
+ * Each module's register() function adds commands to the Commander program.
6
+ */
7
+
8
+ import { register as registerCoreCommands } from './core.js';
9
+ import { register as registerCommentCommands } from './comments.js';
10
+ import { register as registerInitCommands } from './init.js';
11
+ import { register as registerSectionCommands } from './sections.js';
12
+ import { register as registerBuildCommands } from './build.js';
13
+ import { register as registerResponseCommands } from './response.js';
14
+ import { register as registerCitationCommands } from './citations.js';
15
+ import { register as registerDoiCommands } from './doi.js';
16
+ import { register as registerHistoryCommands } from './history.js';
17
+ import { register as registerUtilityCommands } from './utilities.js';
18
+
19
+ export {
20
+ registerCoreCommands,
21
+ registerCommentCommands,
22
+ registerInitCommands,
23
+ registerSectionCommands,
24
+ registerBuildCommands,
25
+ registerResponseCommands,
26
+ registerCitationCommands,
27
+ registerDoiCommands,
28
+ registerHistoryCommands,
29
+ registerUtilityCommands,
30
+ };
31
+
32
+ // Re-export context utilities for use by the main CLI
33
+ export {
34
+ setQuietMode,
35
+ setJsonMode,
36
+ quietMode,
37
+ jsonMode,
38
+ } from './context.js';
39
+
40
+ /**
41
+ * Register all command modules with the program.
42
+ * @param {import('commander').Command} program
43
+ * @param {object} [pkg] - Package.json object for version info (optional)
44
+ */
45
+ export function registerAllCommands(program, pkg) {
46
+ registerCoreCommands(program);
47
+ registerCommentCommands(program);
48
+ registerInitCommands(program);
49
+ registerSectionCommands(program);
50
+ registerBuildCommands(program, pkg);
51
+ registerResponseCommands(program);
52
+ registerCitationCommands(program);
53
+ registerDoiCommands(program);
54
+ registerHistoryCommands(program);
55
+ registerUtilityCommands(program, pkg);
56
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Init commands: init, new, config
3
+ *
4
+ * Project initialization and configuration commands.
5
+ */
6
+
7
+ import {
8
+ chalk,
9
+ fs,
10
+ path,
11
+ fmt,
12
+ generateConfig,
13
+ loadConfig,
14
+ saveConfig,
15
+ getTemplate,
16
+ listTemplates,
17
+ generateCustomTemplate,
18
+ getUserName,
19
+ setUserName,
20
+ getConfigPath,
21
+ getDefaultSections,
22
+ setDefaultSections,
23
+ } from './context.js';
24
+
25
+ /**
26
+ * Register init commands with the program
27
+ * @param {import('commander').Command} program
28
+ */
29
+ export function register(program) {
30
+ // ==========================================================================
31
+ // INIT command - Generate sections.yaml config
32
+ // ==========================================================================
33
+
34
+ program
35
+ .command('init')
36
+ .description('Generate sections.yaml from existing .md files')
37
+ .option('-d, --dir <directory>', 'Directory to scan', '.')
38
+ .option('-o, --output <file>', 'Output config file', 'sections.yaml')
39
+ .option('--force', 'Overwrite existing config')
40
+ .action((options) => {
41
+ const dir = path.resolve(options.dir);
42
+
43
+ if (!fs.existsSync(dir)) {
44
+ console.error(chalk.red(`Directory not found: ${dir}`));
45
+ process.exit(1);
46
+ }
47
+
48
+ const outputPath = path.resolve(options.dir, options.output);
49
+
50
+ if (fs.existsSync(outputPath) && !options.force) {
51
+ console.error(chalk.yellow(`Config already exists: ${outputPath}`));
52
+ console.error(chalk.dim('Use --force to overwrite'));
53
+ process.exit(1);
54
+ }
55
+
56
+ console.log(chalk.cyan(`Scanning ${dir} for .md files...`));
57
+
58
+ const config = generateConfig(dir);
59
+ const sectionCount = Object.keys(config.sections).length;
60
+
61
+ if (sectionCount === 0) {
62
+ console.error(chalk.yellow('No .md files found (excluding paper.md, README.md)'));
63
+ process.exit(1);
64
+ }
65
+
66
+ saveConfig(outputPath, config);
67
+
68
+ console.log(chalk.green(`\nCreated ${outputPath} with ${sectionCount} sections:\n`));
69
+
70
+ for (const [file, section] of Object.entries(config.sections)) {
71
+ console.log(` ${chalk.bold(file)}`);
72
+ console.log(chalk.dim(` header: "${section.header}"`));
73
+ if (section.aliases?.length > 0) {
74
+ console.log(chalk.dim(` aliases: ${JSON.stringify(section.aliases)}`));
75
+ }
76
+ }
77
+
78
+ console.log(chalk.cyan('\nEdit this file to:'));
79
+ console.log(chalk.dim(' • Add aliases for header variations'));
80
+ console.log(chalk.dim(' • Adjust order if needed'));
81
+ console.log(chalk.dim(' • Update headers if they change'));
82
+ });
83
+
84
+ // ==========================================================================
85
+ // NEW command - Create new paper project
86
+ // ==========================================================================
87
+
88
+ program
89
+ .command('new')
90
+ .description('Create a new paper project from template')
91
+ .argument('[name]', 'Project directory name')
92
+ .option('-t, --template <name>', 'Template: paper, minimal, thesis, review', 'paper')
93
+ .option('-s, --sections <sections>', 'Comma-separated section names (e.g., intro,methods,results)')
94
+ .option('--list', 'List available templates')
95
+ .action(async (name, options) => {
96
+ if (options.list) {
97
+ console.log(chalk.cyan('Available templates:\n'));
98
+ for (const t of listTemplates()) {
99
+ console.log(` ${chalk.bold(t.id)} - ${t.description}`);
100
+ }
101
+ return;
102
+ }
103
+
104
+ if (!name) {
105
+ console.error(chalk.red('Error: project name is required'));
106
+ console.error(chalk.dim('Usage: rev new <name>'));
107
+ process.exit(1);
108
+ }
109
+
110
+ const projectDir = path.resolve(name);
111
+
112
+ if (fs.existsSync(projectDir)) {
113
+ console.error(chalk.red(`Directory already exists: ${name}`));
114
+ process.exit(1);
115
+ }
116
+
117
+ let template;
118
+ let sections = null;
119
+
120
+ // Determine sections: CLI option > user config > prompt
121
+ if (options.sections) {
122
+ // Parse CLI sections
123
+ sections = options.sections.split(',').map((s) => s.trim().toLowerCase().replace(/\.md$/, ''));
124
+ } else {
125
+ // Check user config for default sections
126
+ const defaultSections = getDefaultSections();
127
+ if (defaultSections && defaultSections.length > 0) {
128
+ sections = defaultSections;
129
+ }
130
+ }
131
+
132
+ // If no sections from CLI or config, and not using a named template with --template, prompt
133
+ // Only prompt if stdin is a TTY (interactive terminal)
134
+ if (!sections && options.template === 'paper') {
135
+ if (process.stdin.isTTY) {
136
+ const rl = (await import('readline')).createInterface({
137
+ input: process.stdin,
138
+ output: process.stdout,
139
+ });
140
+
141
+ const ask = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
142
+
143
+ console.log(chalk.cyan('Enter your document sections (comma-separated):'));
144
+ console.log(chalk.dim(' Example: introduction,methods,results,discussion'));
145
+ console.log(chalk.dim(' Press Enter to use default: introduction,methods,results,discussion\n'));
146
+
147
+ const answer = await ask(chalk.cyan('Sections: '));
148
+ rl.close();
149
+
150
+ if (answer.trim()) {
151
+ sections = answer.split(',').map((s) => s.trim().toLowerCase().replace(/\.md$/, ''));
152
+ } else {
153
+ // Use default paper template sections
154
+ sections = ['introduction', 'methods', 'results', 'discussion'];
155
+ }
156
+ } else {
157
+ // Non-interactive: use default sections
158
+ sections = ['introduction', 'methods', 'results', 'discussion'];
159
+ }
160
+ }
161
+
162
+ // Generate template based on sections
163
+ if (sections) {
164
+ template = generateCustomTemplate(sections);
165
+ console.log(chalk.cyan(`Creating project with sections: ${sections.join(', ')}\n`));
166
+ } else {
167
+ template = getTemplate(options.template);
168
+ if (!template) {
169
+ console.error(chalk.red(`Unknown template: ${options.template}`));
170
+ console.error(chalk.dim('Use --list to see available templates.'));
171
+ process.exit(1);
172
+ }
173
+ console.log(chalk.cyan(`Creating ${template.name} project in ${name}/...\n`));
174
+ }
175
+
176
+ // Create directory
177
+ fs.mkdirSync(projectDir, { recursive: true });
178
+
179
+ // Create subdirectories
180
+ for (const subdir of template.directories || []) {
181
+ fs.mkdirSync(path.join(projectDir, subdir), { recursive: true });
182
+ console.log(chalk.dim(` Created ${subdir}/`));
183
+ }
184
+
185
+ // Create files
186
+ for (const [filename, content] of Object.entries(template.files)) {
187
+ const filePath = path.join(projectDir, filename);
188
+ fs.writeFileSync(filePath, content, 'utf-8');
189
+ console.log(chalk.dim(` Created ${filename}`));
190
+ }
191
+
192
+ console.log(chalk.green(`\nProject created!`));
193
+ console.log(chalk.cyan('\nNext steps:'));
194
+ console.log(chalk.dim(` cd ${name}`));
195
+ console.log(chalk.dim(' # Edit rev.yaml with your paper details'));
196
+ console.log(chalk.dim(' # Write your sections'));
197
+ console.log(chalk.dim(' rev build # Build PDF and DOCX'));
198
+ console.log(chalk.dim(' rev build pdf # Build PDF only'));
199
+ });
200
+
201
+ // ==========================================================================
202
+ // CONFIG command - Set user preferences
203
+ // ==========================================================================
204
+
205
+ program
206
+ .command('config')
207
+ .description('Set user preferences')
208
+ .argument('<key>', 'Config key: user, sections')
209
+ .argument('[value]', 'Value to set')
210
+ .action((key, value) => {
211
+ if (key === 'user') {
212
+ if (value) {
213
+ setUserName(value);
214
+ console.log(chalk.green(`User name set to: ${value}`));
215
+ console.log(chalk.dim(`Saved to ${getConfigPath()}`));
216
+ } else {
217
+ const name = getUserName();
218
+ if (name) {
219
+ console.log(`Current user: ${chalk.bold(name)}`);
220
+ } else {
221
+ console.log(chalk.yellow('No user name set.'));
222
+ console.log(chalk.dim('Set with: rev config user "Your Name"'));
223
+ }
224
+ }
225
+ } else if (key === 'sections') {
226
+ if (value) {
227
+ const sections = value.split(',').map((s) => s.trim().toLowerCase().replace(/\.md$/, ''));
228
+ setDefaultSections(sections);
229
+ console.log(chalk.green(`Default sections set to: ${sections.join(', ')}`));
230
+ console.log(chalk.dim(`Saved to ${getConfigPath()}`));
231
+ } else {
232
+ const sections = getDefaultSections();
233
+ if (sections && sections.length > 0) {
234
+ console.log(`Default sections: ${chalk.bold(sections.join(', '))}`);
235
+ } else {
236
+ console.log(chalk.yellow('No default sections set.'));
237
+ console.log(chalk.dim('Set with: rev config sections "intro,methods,results,discussion"'));
238
+ console.log(chalk.dim('When not set, rev new will prompt for sections.'));
239
+ }
240
+ }
241
+ } else {
242
+ console.error(chalk.red(`Unknown config key: ${key}`));
243
+ console.error(chalk.dim('Available keys: user, sections'));
244
+ process.exit(1);
245
+ }
246
+ });
247
+ }