rv-bible-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,561 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import chalk from 'chalk';
4
+ import clipboard from 'clipboardy';
5
+ import { parseRefList, resolveBook } from './parser.js';
6
+ import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getFootnote, getVerse, getBookInfo, getAllBooks, isInConcordance, getTopicVerses, searchFTS, } from './db.js';
7
+ import { renderVerses, renderVerseInline, renderFootnoteBlock, renderNoteDisplay, renderNoteDisplayAll, stripMarkers, highlightTerms, } from './format.js';
8
+ import { getLastRead, saveLastRead } from './state.js';
9
+ // Strip ANSI escape codes — cleans rendered output before copying to clipboard
10
+ function stripAnsi(s) {
11
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
12
+ }
13
+ program
14
+ .name('rv')
15
+ .description('Recovery Version Bible CLI')
16
+ .version('0.1.0')
17
+ .enablePositionalOptions();
18
+ // Formats a ParsedRef + book name into a display label: "John 3", "John 3:16", "John 3:16–18"
19
+ function formatRefLabel(ref, bookName) {
20
+ if (!ref.verses)
21
+ return `${bookName} ${ref.chapter}`;
22
+ if (ref.verses.type === 'single')
23
+ return `${bookName} ${ref.chapter}:${ref.verses.verse}`;
24
+ if (ref.verses.type === 'range')
25
+ return `${bookName} ${ref.chapter}:${ref.verses.start}–${ref.verses.end}`;
26
+ return `${bookName} ${ref.chapter}:${ref.verses.verses.join(', ')}`;
27
+ }
28
+ // Generates clean plain-text copy string for verse mode, respecting format flags.
29
+ // opts.ref: true = include "Book ch:v" prefix (default), false = --no-ref
30
+ // opts.numbered: prefix with verse number only
31
+ // opts.md: markdown block-quote format
32
+ function versesToCopyText(verses, bookName, opts) {
33
+ if (opts.md) {
34
+ const text = verses.map(v => stripMarkers(v.text)).join(' ');
35
+ const refLabel = verses.length === 1
36
+ ? `${bookName} ${verses[0].chapter}:${verses[0].verse}`
37
+ : `${bookName} ${verses[0].chapter}:${verses[0].verse}–${verses[verses.length - 1].verse}`;
38
+ return `> ${text}\n> — ${refLabel}`;
39
+ }
40
+ return verses.map(v => {
41
+ const text = stripMarkers(v.text);
42
+ if (!opts.ref)
43
+ return text;
44
+ if (opts.numbered)
45
+ return `${v.verse} ${text}`;
46
+ return `${bookName} ${v.chapter}:${v.verse} ${text}`;
47
+ }).join('\n');
48
+ }
49
+ async function writeClipboard(text, label) {
50
+ await clipboard.write(text);
51
+ process.stderr.write(chalk.dim(`✓ Copied ${label} to clipboard\n`));
52
+ }
53
+ // Interactive "press for more" prompt between result pages.
54
+ // Returns true = show next page, false = stop. Ctrl+C exits the process.
55
+ async function promptForMore(shown, total) {
56
+ if (!process.stdin.isTTY || !process.stderr.isTTY)
57
+ return false;
58
+ return new Promise(resolve => {
59
+ process.stderr.write(chalk.dim(`\n ── ${shown} of ${total} ── Space/Enter next page · q quit ──`));
60
+ process.stdin.setRawMode(true);
61
+ process.stdin.resume();
62
+ process.stdin.once('data', (key) => {
63
+ process.stdin.setRawMode(false);
64
+ process.stdin.pause();
65
+ process.stderr.write('\r\x1b[2K'); // erase prompt line
66
+ const ch = key.toString();
67
+ if (ch === '\x03') {
68
+ process.stderr.write('\n');
69
+ process.exit(0);
70
+ } // Ctrl+C
71
+ resolve(ch === '\r' || ch === '\n' || ch === ' ' || ch === 'n');
72
+ });
73
+ });
74
+ }
75
+ // ── Homepage (rv with no args) ───────────────────────────────────────────────
76
+ function renderHomepage() {
77
+ const COLS = 12; // books per row
78
+ const COL_W = 5; // column width (3-char abbr + padding)
79
+ const lines = [];
80
+ lines.push('');
81
+ lines.push(chalk.bold(' Recovery Version Bible'));
82
+ lines.push('');
83
+ // Last-read context
84
+ const last = getLastRead();
85
+ if (last) {
86
+ const bookName = getBookInfo(last.book)?.full_name ?? last.book;
87
+ lines.push(chalk.dim(` Last read: ${bookName} ${last.chapter}`));
88
+ lines.push(chalk.dim(' › rv continue'));
89
+ }
90
+ else {
91
+ lines.push(chalk.dim(' Get started: rv john 1'));
92
+ }
93
+ lines.push('');
94
+ // Book grid
95
+ const books = getAllBooks();
96
+ const ot = books.filter(b => b.testament === 'OT');
97
+ const nt = books.filter(b => b.testament === 'NT');
98
+ function renderGrid(bookList) {
99
+ const gridLines = [];
100
+ for (let i = 0; i < bookList.length; i += COLS) {
101
+ const row = bookList.slice(i, i + COLS);
102
+ gridLines.push(' ' + row.map(b => b.abbr.padEnd(COL_W)).join(''));
103
+ }
104
+ return gridLines;
105
+ }
106
+ lines.push(chalk.dim(' OLD TESTAMENT'));
107
+ lines.push(...renderGrid(ot));
108
+ lines.push('');
109
+ lines.push(chalk.dim(' NEW TESTAMENT'));
110
+ lines.push(...renderGrid(nt));
111
+ lines.push('');
112
+ // Hints
113
+ lines.push(chalk.dim(' rv <book> <chapter> rv search <query> rv continue'));
114
+ lines.push('');
115
+ console.log(lines.join('\n'));
116
+ }
117
+ // ── rv <ref> [--notes] [--outline] [--full] [--copy] ─────────────────────────
118
+ program
119
+ .argument('[ref...]', 'Bible reference (e.g. john 3, john 3:16, john 3:16-18)')
120
+ .option('--notes', 'show footnote markers inline + footnote block below')
121
+ .option('--outline', 'show section headers inline (chapter mode only)')
122
+ .option('--full', 'show both section headers and footnotes (shorthand for --outline --notes)')
123
+ .option('--copy', 'copy displayed output to clipboard')
124
+ .option('--no-ref', 'with --copy: omit reference prefix (verse mode)')
125
+ .option('--numbered', 'with --copy: prefix with verse number only (verse mode)')
126
+ .option('--md', 'with --copy: markdown block-quote format (verse mode)')
127
+ .option('--raw', 'plain text output, no pager (default for now)')
128
+ .action(async (refTokens, opts) => {
129
+ if (opts.full) {
130
+ opts.notes = true;
131
+ opts.outline = true;
132
+ }
133
+ if (refTokens.length === 0) {
134
+ if (process.stdout.isTTY) {
135
+ const { launchPagerHome } = await import('./ui/Pager.js');
136
+ await launchPagerHome();
137
+ }
138
+ else {
139
+ renderHomepage();
140
+ }
141
+ return;
142
+ }
143
+ let refs;
144
+ try {
145
+ refs = parseRefList(refTokens);
146
+ }
147
+ catch (e) {
148
+ console.error(`Error: ${e.message}`);
149
+ process.exit(1);
150
+ }
151
+ // Chapter mode: full display with title + optional headers/footnotes
152
+ // Verse mode: compact inline "Book ch:v text", one line per verse
153
+ const isChapterMode = refs.every(r => !r.verses);
154
+ // Format flags only apply to verse-mode copy text
155
+ const hasCopyFormat = !opts.ref || !!opts.numbered || !!opts.md;
156
+ const outputs = [];
157
+ const copyOutputs = []; // only populated for verse mode + copy format flags
158
+ const copyLabels = [];
159
+ if (isChapterMode) {
160
+ for (const ref of refs) {
161
+ const verses = getVersesByRef(ref);
162
+ if (verses.length === 0)
163
+ continue;
164
+ const bookName = getBookInfo(ref.book)?.full_name ?? ref.book;
165
+ const headers = opts.outline ? getSectionHeaders(ref.book) : [];
166
+ const footnotes = opts.notes ? getFootnotesForChapter(ref.book, ref.chapter) : [];
167
+ const label = formatRefLabel(ref, bookName);
168
+ outputs.push(renderVerses(verses, headers, footnotes, {
169
+ notes: opts.notes ?? false,
170
+ title: label,
171
+ }));
172
+ copyLabels.push(label);
173
+ }
174
+ }
175
+ else {
176
+ for (const ref of refs) {
177
+ const verses = getVersesByRef(ref);
178
+ if (verses.length === 0)
179
+ continue;
180
+ const bookName = getBookInfo(ref.book)?.full_name ?? ref.book;
181
+ const notes = opts.notes ?? false;
182
+ const lines = verses.map(v => renderVerseInline(v, bookName, notes));
183
+ if (notes) {
184
+ const footnotes = getFootnotesForVerses(ref.book, ref.chapter, verses.map(v => v.verse));
185
+ const inlineMarkers = new Set(verses.flatMap(v => [...v.text.matchAll(/\[([^\]]+)\]/g)].map(m => m[1])));
186
+ const scoped = footnotes.filter(f => inlineMarkers.has(f.marker));
187
+ if (scoped.length > 0)
188
+ lines.push('', renderFootnoteBlock(scoped));
189
+ }
190
+ outputs.push(lines.join('\n'));
191
+ if (opts.copy && hasCopyFormat) {
192
+ copyOutputs.push(versesToCopyText(verses, bookName, {
193
+ ref: opts.ref, numbered: opts.numbered, md: opts.md,
194
+ }));
195
+ }
196
+ copyLabels.push(formatRefLabel(ref, bookName));
197
+ }
198
+ }
199
+ if (outputs.length === 0)
200
+ return;
201
+ // Pager: chapter mode + TTY + no --raw + no --copy → launch interactive pager
202
+ if (isChapterMode && process.stdout.isTTY && !opts.raw && !opts.copy) {
203
+ const { launchPager } = await import('./ui/Pager.js');
204
+ const ref = refs[0];
205
+ saveLastRead(ref.book, ref.chapter);
206
+ await launchPager(ref.book, ref.chapter, {
207
+ notes: opts.notes ?? false,
208
+ outline: opts.outline ?? false,
209
+ });
210
+ return;
211
+ }
212
+ const displayed = outputs.join('\n\n');
213
+ console.log(displayed);
214
+ // Auto-track: save the last ref read (first ref's book + chapter)
215
+ const lastRef = refs[0];
216
+ saveLastRead(lastRef.book, lastRef.chapter);
217
+ if (opts.copy) {
218
+ const copyText = (hasCopyFormat && !isChapterMode && copyOutputs.length > 0)
219
+ ? copyOutputs.join('\n\n')
220
+ : stripAnsi(displayed);
221
+ await writeClipboard(copyText, copyLabels.join(', '));
222
+ }
223
+ });
224
+ // ── rv note john 3:16 [marker] [--copy] ──────────────────────────────────────
225
+ program
226
+ .command('note')
227
+ .argument('<args...>', 'verse ref + optional marker, e.g. john 3:16 or john 3:16 1a')
228
+ .description('Show footnote(s) for a verse or range')
229
+ .option('--copy', 'copy displayed output to clipboard')
230
+ .action(async (args, opts) => {
231
+ if (args.length === 0) {
232
+ console.error('Usage: rv note <ref> [marker] (e.g. rv note john 3:16 or rv note john 3:16 1a)');
233
+ process.exit(1);
234
+ }
235
+ // Try no-marker mode: parse all args as verse refs (every ref must have verses).
236
+ let noMarkerRefs = null;
237
+ try {
238
+ const parsed = parseRefList(args);
239
+ if (parsed.length >= 1 && parsed.every(r => r.verses))
240
+ noMarkerRefs = parsed;
241
+ }
242
+ catch { /* fall through to marker mode */ }
243
+ if (noMarkerRefs) {
244
+ const blocks = [];
245
+ const copyLabels = [];
246
+ for (const ref of noMarkerRefs) {
247
+ const bookName = getBookInfo(ref.book)?.full_name ?? ref.book;
248
+ for (const v of getVersesByRef(ref)) {
249
+ const footnotes = getFootnotesForVerses(ref.book, ref.chapter, [v.verse]);
250
+ const inlineMarkers = new Set([...v.text.matchAll(/\[([^\]]+)\]/g)].map(m => m[1]));
251
+ blocks.push(renderNoteDisplayAll(v, footnotes.filter(f => inlineMarkers.has(f.marker)), bookName));
252
+ copyLabels.push(`${bookName} ${v.chapter}:${v.verse}`);
253
+ }
254
+ }
255
+ if (blocks.length > 0) {
256
+ const sep = '\n' + chalk.dim('─'.repeat(process.stdout.columns ?? 60)) + '\n';
257
+ const output = blocks.join(sep);
258
+ console.log(output);
259
+ if (opts.copy)
260
+ await writeClipboard(stripAnsi(output), copyLabels.join(', '));
261
+ }
262
+ return;
263
+ }
264
+ // Marker mode: last token is the marker, everything before is the ref.
265
+ if (args.length < 2) {
266
+ console.error('Usage: rv note <ref> <marker> (e.g. rv note john 3:16 1a)');
267
+ process.exit(1);
268
+ }
269
+ const marker = args[args.length - 1];
270
+ const refTokens = args.slice(0, -1);
271
+ let ref;
272
+ try {
273
+ const parsed = parseRefList(refTokens);
274
+ ref = parsed[0];
275
+ if (!ref)
276
+ throw new Error('Could not parse reference');
277
+ }
278
+ catch (e) {
279
+ console.error(`Error: ${e.message}`);
280
+ process.exit(1);
281
+ }
282
+ if (!ref.verses || ref.verses.type !== 'single') {
283
+ console.error('Error: note command with a marker requires a single verse (e.g. john 3:16)');
284
+ process.exit(1);
285
+ }
286
+ const verse = getVerse(ref.book, ref.chapter, ref.verses.verse);
287
+ if (!verse) {
288
+ console.error(`Verse not found: ${refTokens.join(' ')}`);
289
+ process.exit(1);
290
+ }
291
+ const fn = getFootnote(ref.book, ref.chapter, ref.verses.verse, marker);
292
+ if (!fn) {
293
+ console.error(`No footnote "${marker}" found for ${refTokens.join(' ')}`);
294
+ process.exit(1);
295
+ }
296
+ const bookName = getBookInfo(ref.book)?.full_name ?? ref.book;
297
+ const output = renderNoteDisplay(verse, fn, bookName);
298
+ console.log(output);
299
+ if (opts.copy) {
300
+ await writeClipboard(stripAnsi(output), `note ${marker} for ${bookName} ${ref.chapter}:${ref.verses.verse}`);
301
+ }
302
+ });
303
+ // ── Search & Topic helpers ────────────────────────────────────────────────────
304
+ const RESULT_LIMIT = 20;
305
+ // Scans args right-to-left for "in <book>" scope suffix.
306
+ // "grace in romans" → { queryTokens: ["grace"], book: "Rom" }
307
+ // "sin in john" → { queryTokens: ["sin"], book: "Joh" }
308
+ // "faith in christ" → { queryTokens: ["faith", "in", "christ"], book: undefined }
309
+ function parseScope(args) {
310
+ for (let nameLen = 3; nameLen >= 1; nameLen--) {
311
+ const inIdx = args.length - nameLen - 1;
312
+ if (inIdx < 1)
313
+ continue; // need at least one query token before "in"
314
+ if (args[inIdx]?.toLowerCase() !== 'in')
315
+ continue;
316
+ const book = resolveBook(args.slice(inIdx + 1, inIdx + 1 + nameLen).join(' '));
317
+ if (book)
318
+ return { queryTokens: args.slice(0, inIdx), book };
319
+ }
320
+ return { queryTokens: args, book: undefined };
321
+ }
322
+ // Builds an FTS5 query string and extracts terms for highlighting.
323
+ // Single shell-quoted arg with spaces → phrase: "only begotten"
324
+ // Multiple tokens → AND: "eternal" AND "life"
325
+ function buildFTSQuery(tokens) {
326
+ if (tokens.length === 1 && tokens[0].includes(' ')) {
327
+ const phrase = tokens[0];
328
+ return {
329
+ fts: `"${phrase.replace(/"/g, '')}"`,
330
+ terms: phrase.split(/\s+/).filter(Boolean),
331
+ display: `"${phrase}"`,
332
+ };
333
+ }
334
+ const terms = tokens.map(t => t.replace(/"/g, '').trim()).filter(Boolean);
335
+ return {
336
+ fts: terms.length === 1 ? `"${terms[0]}"` : terms.map(t => `"${t}"`).join(' AND '),
337
+ terms,
338
+ display: terms.length === 1 ? terms[0] : terms.join(' '),
339
+ };
340
+ }
341
+ // ── rv search <query> [in <book>] [--fts] [--all] [--copy] ───────────────────
342
+ // Concordance-first: if the query is a single known concordance word, use the
343
+ // curated concordance index. Otherwise (or with --fts) falls back to FTS5.
344
+ program
345
+ .command('search')
346
+ .argument('<query...>', 'word, phrase, or "quoted phrase", e.g. grace or "only begotten"')
347
+ .description('Search verses — concordance for known words, FTS5 otherwise')
348
+ .option('--fts', 'force raw full-text search, bypass concordance')
349
+ .option('--all', `show all results (default: ${RESULT_LIMIT})`)
350
+ .option('--copy', 'copy results to clipboard')
351
+ .action(async (queryArgs, opts) => {
352
+ const { queryTokens, book } = parseScope(queryArgs);
353
+ if (queryTokens.length === 0) {
354
+ console.error('Usage: rv search <query> [in <book>]');
355
+ process.exit(1);
356
+ }
357
+ const word = queryTokens.join(' ');
358
+ const scopeLabel = book ? ` in ${getBookInfo(book)?.full_name ?? book}` : '';
359
+ // Concordance path: single-word queries that exist in the curated index,
360
+ // unless the user explicitly wants raw FTS with --fts.
361
+ const useConcordance = !opts.fts && queryTokens.length === 1 && isInConcordance(word);
362
+ let all;
363
+ let terms;
364
+ let sourceLabel;
365
+ if (useConcordance) {
366
+ all = getTopicVerses(word, book);
367
+ terms = [word];
368
+ sourceLabel = `Concordance: ${word}${scopeLabel} · ${all.length} occurrence${all.length === 1 ? '' : 's'}`;
369
+ }
370
+ else {
371
+ const { fts, terms: ftsTerms, display } = buildFTSQuery(queryTokens);
372
+ all = searchFTS(fts, book);
373
+ terms = ftsTerms;
374
+ const notInNote = !opts.fts && queryTokens.length === 1 ? ' (not in concordance)' : '';
375
+ sourceLabel = all.length > 0
376
+ ? `Search: "${display}"${scopeLabel} · ${all.length} match${all.length === 1 ? '' : 'es'}${notInNote}`
377
+ : `"${display}" not found`;
378
+ }
379
+ if (all.length === 0) {
380
+ console.log(chalk.dim(` No results for "${word}"${scopeLabel}`));
381
+ return;
382
+ }
383
+ // Print header (total count always visible upfront)
384
+ console.log(chalk.dim(` ${sourceLabel}`));
385
+ console.log('');
386
+ // Paginate: interactive page-by-page in a TTY, single page + hint when piped
387
+ const isTTY = process.stdout.isTTY ?? false;
388
+ let offset = 0;
389
+ while (offset < all.length) {
390
+ const pageEnd = opts.all ? all.length : Math.min(offset + RESULT_LIMIT, all.length);
391
+ for (let i = offset; i < pageEnd; i++) {
392
+ const v = all[i];
393
+ const bookName = getBookInfo(v.book)?.full_name ?? v.book;
394
+ const ref = `${bookName} ${v.chapter}:${v.verse}`;
395
+ const cleanText = stripMarkers(v.text);
396
+ // Truncate to ~150 chars for scanability
397
+ const truncated = cleanText.length > 150 ? cleanText.substring(0, 147) + '...' : cleanText;
398
+ const highlighted = highlightTerms(truncated, terms);
399
+ // Wrap the text at ~76 chars with 4-space indent
400
+ const maxW = Math.min((process.stdout.columns ?? 80) - 4, 76);
401
+ const words = highlighted.split(' ');
402
+ const wrapLines = [];
403
+ let cur = '';
404
+ for (const w of words) {
405
+ const wLen = w.replace(/\x1b\[[0-9;]*m/g, '').length;
406
+ const curLen = cur.replace(/\x1b\[[0-9;]*m/g, '').length;
407
+ if (!cur) {
408
+ cur = w;
409
+ }
410
+ else if (curLen + 1 + wLen <= maxW) {
411
+ cur += ' ' + w;
412
+ }
413
+ else {
414
+ wrapLines.push(cur);
415
+ cur = w;
416
+ }
417
+ }
418
+ if (cur)
419
+ wrapLines.push(cur);
420
+ console.log(` ${chalk.bold(ref)}`);
421
+ for (const line of wrapLines)
422
+ console.log(` ${line}`);
423
+ console.log('');
424
+ }
425
+ offset = pageEnd;
426
+ if (offset >= all.length || opts.all)
427
+ break;
428
+ if (!isTTY) {
429
+ // Non-interactive: show "showing N of M" hint and stop
430
+ const ftsFlag = opts.fts ? ' --fts' : '';
431
+ const cmd = `rv search ${word}${book ? ` in ${(getBookInfo(book)?.full_name ?? book).toLowerCase()}` : ''}${ftsFlag}`;
432
+ console.log('');
433
+ console.log(chalk.dim(` showing ${offset} of ${all.length} · ${cmd} --all to show all`));
434
+ break;
435
+ }
436
+ const more = await promptForMore(offset, all.length);
437
+ if (!more)
438
+ break;
439
+ }
440
+ // --copy always copies the full result set regardless of how many pages were viewed
441
+ if (opts.copy) {
442
+ const copyLines = all.map(v => {
443
+ const bookName = getBookInfo(v.book)?.full_name ?? v.book;
444
+ return `${bookName} ${v.chapter}:${v.verse} ${stripMarkers(v.text)}`;
445
+ });
446
+ await writeClipboard(copyLines.join('\n'), `${word} (${all.length} verse${all.length === 1 ? '' : 's'})`);
447
+ }
448
+ });
449
+ // ── rv continue ───────────────────────────────────────────────────────────────
450
+ program
451
+ .command('continue')
452
+ .description('Re-display last read passage')
453
+ .option('--notes', 'show footnote markers inline + footnote block below')
454
+ .option('--outline', 'show section headers inline')
455
+ .option('--full', 'shorthand for --outline --notes')
456
+ .option('--copy', 'copy displayed output to clipboard')
457
+ .action(async (opts) => {
458
+ const last = getLastRead();
459
+ if (!last) {
460
+ console.log(chalk.dim(' Nothing read yet — try: rv john 1'));
461
+ return;
462
+ }
463
+ if (opts.full) {
464
+ opts.notes = true;
465
+ opts.outline = true;
466
+ }
467
+ // Pager for continue: TTY + no --copy → launch pager
468
+ if (process.stdout.isTTY && !opts.copy) {
469
+ const { launchPager } = await import('./ui/Pager.js');
470
+ await launchPager(last.book, last.chapter, {
471
+ notes: opts.notes ?? false,
472
+ outline: opts.outline ?? false,
473
+ });
474
+ return;
475
+ }
476
+ const bookInfo = getBookInfo(last.book);
477
+ const bookName = bookInfo?.full_name ?? last.book;
478
+ const ref = { book: last.book, chapter: last.chapter };
479
+ const verses = getVersesByRef(ref);
480
+ if (verses.length === 0) {
481
+ console.error(`Could not load ${bookName} ${last.chapter}`);
482
+ process.exit(1);
483
+ }
484
+ const headers = opts.outline ? getSectionHeaders(last.book) : [];
485
+ const footnotes = opts.notes ? getFootnotesForChapter(last.book, last.chapter) : [];
486
+ const label = `${bookName} ${last.chapter}`;
487
+ console.log(chalk.dim(` Continuing: ${label}`));
488
+ console.log('');
489
+ const output = renderVerses(verses, headers, footnotes, {
490
+ notes: opts.notes ?? false,
491
+ title: label,
492
+ });
493
+ console.log(output);
494
+ if (opts.copy) {
495
+ await writeClipboard(stripAnsi(output), label);
496
+ }
497
+ });
498
+ // ── rv help ──────────────────────────────────────────────────────────────────
499
+ program
500
+ .command('help')
501
+ .description('Show all commands and pager shortcuts')
502
+ .action(() => {
503
+ const d = chalk.dim;
504
+ const b = chalk.bold;
505
+ const c = chalk.cyan;
506
+ console.log(`
507
+ ${b('rv — Recovery Version Bible CLI')}
508
+
509
+ ${b('READING')}
510
+ ${c('rv john 3')} Read a chapter (opens pager in terminal)
511
+ ${c('rv john 3:16')} Read a single verse
512
+ ${c('rv john 3:16-18')} Read a verse range
513
+ ${c('rv john 3:16,18,20')} Multiple specific verses
514
+ ${c('rv john 3:16 rom 8:28')} Verses across books
515
+ ${c('rv jn 3')} Fuzzy book names ${d('(jn → John, gen → Genesis, rm → Romans)')}
516
+ ${c('rv john 3 --notes')} Show footnote markers + footnote block
517
+ ${c('rv john 3 --outline')} Show section headers inline
518
+ ${c('rv john 3 --full')} Both notes and outline
519
+ ${c('rv john 3 --raw')} Plain text, no pager ${d('(also auto when piped)')}
520
+
521
+ ${b('FOOTNOTES')}
522
+ ${c('rv note john 3:16')} All footnotes for a verse
523
+ ${c('rv note john 3:16 2d')} Specific footnote by marker
524
+ ${c('rv note john 3:14-16')} Footnotes for a range
525
+
526
+ ${b('SEARCH')} ${d('concordance-first for known words, FTS5 for everything else')}
527
+ ${c('rv search grace')} Concordance lookup ${d('(1,129 curated words)')}
528
+ ${c('rv search grace in rom')} Scoped to a book
529
+ ${c('rv search "only begotten"')} Phrase match via FTS5
530
+ ${c('rv search eternal life')} Multi-word AND match
531
+ ${c('rv search grace --fts')} Force raw FTS5
532
+ ${c('rv search grace --all')} Show all results ${d('(default: 20 per page)')}
533
+
534
+ ${b('NAVIGATION')}
535
+ ${c('rv')} Home screen — browse books, pick chapters
536
+ ${c('rv continue')} Resume last-read chapter
537
+
538
+ ${b('COPY')} ${d('add --copy to any read command')}
539
+ ${c('rv john 3:16 --copy')} Copy with reference prefix
540
+ ${c('rv john 3:16 --copy --no-ref')} Plain text only
541
+ ${c('rv john 3:16 --copy --numbered')} Verse number prefix
542
+ ${c('rv john 3:16 --copy --md')} Markdown block quote
543
+ ${c('rv search grace --copy')} Copy all search results
544
+
545
+ ${b('PAGER SHORTCUTS')} ${d('chapter mode in terminal')}
546
+ ${d('Scroll')} ↑↓ or j/k line · Space/b page · g top · G bottom
547
+ ${d('Chapter')} ←→ or n/p next/prev ${d('(cross-book)')}
548
+ ${d('Toggles')} f footnotes · o outline
549
+ ${d('Navigate')} : goto ${d('(type ref, e.g. rom 8)')} · H home · [/] back/fwd
550
+ ${d('Study')} d study mode — browse footnotes + follow cross-references
551
+ ${d('Copy')} c copy mode — select verses with cursor
552
+ ${d('Search')} / find in chapter · n/N cycle matches
553
+ ${d('Quit')} q or Esc
554
+
555
+ ${b('STUDY MODE')} ${d('press d in pager')}
556
+ ↑↓ move verse cursor · type # to follow cross-ref
557
+ v start range · c copy verse/range · ←→ chapter
558
+ : goto · [/] back/fwd · d exit
559
+ `);
560
+ });
561
+ program.parseAsync();
@@ -0,0 +1,20 @@
1
+ export type VerseSpec = {
2
+ type: 'single';
3
+ verse: string;
4
+ } | {
5
+ type: 'range';
6
+ start: string;
7
+ end: string;
8
+ } | {
9
+ type: 'list';
10
+ verses: string[];
11
+ };
12
+ export interface ParsedRef {
13
+ book: string;
14
+ chapter: number;
15
+ verses?: VerseSpec;
16
+ }
17
+ export declare function resolveBook(input: string): string | null;
18
+ export declare function parseRef(input: string): ParsedRef;
19
+ export declare function parseRefTokens(tokens: string[]): ParsedRef;
20
+ export declare function parseRefList(tokens: string[]): ParsedRef[];