SLOPSHOPPER

Claude of Alexandria Study Desk

Optional study-desk extras for the coa plugin: see which data lookups grounded an answer and read a passage beside the conversation. Terminal and desktop app…

paneguardcommandtoastpromptnetwork
★ 7v0.1.0GPL-3.0updated 2026-10-12davebream/claude-of-alexandria/plugins/coa-desk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · coa-desk
│ ┃ coa evidence ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No coa lookups yet in this session. │ coa-desk │ │ ⏺ Read(src/auth.ts) │ Usage: /passage <book> │ │ ⎿ Read 6 lines │ <chapter:verse[-verse]> [translations], │ │ ⏺ Update(src/auth.ts) │ for example /passage Rom 8:1-4 BSB,KJV │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /evidence │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · coa evidence
No coa lookups yet in this session.
README

Claude of Alexandria Study Desk (coa-desk)

Optional extras for the coa plugin. The skills work the same with or without it.

What it adds

  • A grounding line under each answer. When a coa skill or agent looks data up, the answer ends with a line such as Grounded in 3 lookups: query_morphology, bible_lookup ×2 · data: opengnt, bible_bsb. When a coa skill answers without any lookup (for example after "no need to look anything up"), the line warns that the answer is unverified instead.
  • /evidence opens a pane listing every coa lookup made in the session: the tool, the passage and the datasets it returned.
  • /passage <book> <chapter:verse> [translations] shows a passage in parallel translations beside the conversation, with no model call. Example: /passage Rom 8:1-4 BSB,KJV. Translations: BSB, WEB, KJV, ASV, YLT, DBY.
  • Book-name autocomplete in the prompt (1co suggests 1 Corinthians).

The guard that keeps coa agents off Haiku is part of coa itself, so every install has it (ADR 0006).

Install

claude plugin install coa-desk --marketplace davebream/claude-of-alexandria

Installing coa-desk also installs coa.

Where it works

coa-desk is a Claude Code mod: code that runs inside Claude Code. It needs Claude Code 2.1.296 or later.

WhereGrounding line, panes, autocomplete
claude in a terminalyes
Claude Desktop, Code tabyes
VS Code extension, claude -p, Agent SDKno; /evidence and /passage print their text instead

Anthropic can switch mods off for an account or organisation. When that happens, coa-desk does nothing and coa keeps working.

What it can reach

Mods run with your permissions and are not sandboxed. coa-desk:

  • sends /passage requests to https://coa.davebream.com/mcp, the same public server the coa plugin uses, and nowhere else;
  • reads no files or environment variables and starts no processes;
  • watches coa tool calls and coa skill text, and changes neither.

To check this yourself, run claude plugin validate plugins/coa-desk and read its hooks: and calls: lines.

Development

  • Hook wiring tests: claude plugin test plugins/coa-desk (needs a signed-in Claude Code with mods available).
  • Helper tests run without Claude Code: node --test tests/desk/*.test.mjs.
  • hooks/books.ts is generated from the server's book table by node scripts/generate-desk-books.mjs. A test fails when the two drift.
  • Design and evidence: ADR 0005.
Source 8 files
hooks/register.ts 150 lines
1// coa-desk: an opt-in companion to the coa plugin. Nothing here is required for
2// the coa skills to work; see docs/adr/0005-companion-mods-plugin.md.
3//
4// - A line under each answer names the coa lookups that grounded it, or warns
5//   when a coa skill answered without any.
6// - /evidence lists the session's lookups; /passage <ref> shows parallel text.
7// - Book names autocomplete in the prompt.
8// The guard that keeps coa agents off Haiku lives in coa itself (docs/adr/0006).
9
10import { BOOKS } from './books'
11import { groundingWarning } from './grounding'
12import { createLedger } from './ledger'
13import { MCP_HEADERS, MCP_URL, parseRpcResponse, rpcBody } from './mcp'
14import { renderPassage } from './passage'
15import { datasetIdsOf } from './provenance'
16import { parseReference, suggestBooks, USAGE } from './reference'
17
18const COA_TOOL = /^mcp__plugin_coa_coa__/
19const EVIDENCE_PANE = 'coa-evidence'
20const PASSAGE_PANE = 'coa-passage'
21
22const ledger = createLedger()
23let passageMarkdown = ''
24
25function targetOf(e: Record<string, unknown>): string {
26  if (typeof e.book === 'string') {
27    return typeof e.range === 'string' ? `${e.book} ${e.range}` : e.book
28  }
29  const first = Object.entries(e).find(
30    ([key, value]) => !['tool', 'tool_use_id', 'agentId'].includes(key) && typeof value === 'string',
31  )
32  return first ? String(first[1]) : ''
33}
34
35function evidenceMarkdown(): string {
36  const entries = ledger.sessionEntries()
37  if (entries.length === 0) return 'No coa lookups yet in this session.'
38  return entries
39    .map((entry) => {
40      const where = entry.target ? ` ${entry.target}` : ''
41      const data = entry.datasetIds.length > 0 ? ` (${entry.datasetIds.join(', ')})` : ''
42      const failed = entry.isError ? ' [failed]' : ''
43      const who = entry.agentId ? ' [subagent]' : ''
44      return `- ${entry.tool}${where}${data}${failed}${who}`
45    })
46    .join('\n')
47}
48
49export function register(on) {
50  on('session.start', async ($, e, next) => {
51    try {
52      await $.command.register({ name: 'evidence', description: 'List the coa data lookups made in this session', immediate: true })
53      await $.command.register({ name: 'passage', description: 'Show a Bible passage in parallel translations', argumentHint: '<book> <chapter:verse> [BSB,KJV,…]', immediate: true })
54    } catch (error) {
55      await $.ui.log(`coa-desk could not add its commands: ${String(error)}`)
56    }
57    return next(e)
58  })
59
60  on('session.end', async ($, e, next) => {
61    ledger.reset()
62    return next(e)
63  })
64
65  on('turn.start', async ($, e, next) => {
66    ledger.startTurn()
67    return next(e)
68  })
69
70  on('skill.prompt', { skill: /^coa:/ }, async ($, e, next) => {
71    ledger.noteSkill(e.skill)
72    return next(e)
73  })
74
75  on('tool.call', { tool: /^mcp__plugin_coa_coa__/ }, async ($, e, next) => {
76    const result = await next(e)
77    ledger.record({
78      tool: String(e.tool).replace(COA_TOOL, ''),
79      target: targetOf(e),
80      datasetIds: datasetIdsOf(result),
81      isError: Boolean(result?.isError || result?.deny),
82      agentId: e.agentId,
83    })
84    $.ui.invalidate('ui.render')
85    return result
86    // Observe only: if recording fails, the tool result still reaches Claude.
87  }).catch(async ($, e, next) => next(e))
88
89  on('turn.complete', async ($, e, next) => {
90    const result = await next(e)
91    if (e.agentId) return result
92    const summary =
93      ledger.turnSummary() ??
94      groundingWarning({ skills: ledger.turnSkills(), lookups: ledger.turnLookups(), answer: String(e.answer ?? '') })
95    if (!summary) return result
96    const earlier = result?.text && result.text !== e.answer ? `${result.text}\n` : ''
97    return { ...result, text: `${earlier}${summary}` }
98  })
99
100  on('command.run', { command: 'evidence' }, async ($) => {
101    const opened = await $.ui.open({ id: EVIDENCE_PANE, title: 'coa evidence', closeOnEscape: true })
102    if (opened?.isPlaced) return {}
103    return { text: evidenceMarkdown() }
104  })
105
106  on('command.run', { command: 'passage' }, async ($, e) => {
107    const reference = parseReference(e.args ?? '', BOOKS)
108    if ('error' in reference) {
109      await $.ui.toast(reference.error)
110      return {}
111    }
112    const args: Record<string, unknown> = { book: reference.book, range: reference.range, page_size: 200 }
113    if (reference.translations.length > 0) args.translations = reference.translations
114    let response
115    try {
116      response = await $.http.fetch(MCP_URL, { method: 'POST', headers: MCP_HEADERS, body: rpcBody('parallel_text', args) })
117    } catch (error) {
118      await $.ui.toast(`Could not reach the coa server: ${String(error)}`)
119      return {}
120    }
121    if (!response.ok) {
122      await $.ui.toast(`The coa server answered ${response.status}. ${USAGE}`)
123      return {}
124    }
125    const parsed = parseRpcResponse(response.text)
126    if (parsed.isError) {
127      await $.ui.toast(parsed.message)
128      return {}
129    }
130    passageMarkdown = renderPassage(parsed.payload)
131    const opened = await $.ui.open({ id: PASSAGE_PANE, title: `${reference.book} ${reference.range}`, closeOnEscape: true })
132    $.ui.invalidate('ui.render')
133    if (opened?.isPlaced) return {}
134    return { text: passageMarkdown }
135  })
136
137  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
138    if (e.requestId !== EVIDENCE_PANE && e.requestId !== PASSAGE_PANE) return next(e)
139    const { Markdown } = $.ui.resolve(e)
140    const text = e.requestId === EVIDENCE_PANE ? evidenceMarkdown() : passageMarkdown
141    return Markdown({ key: e.requestId, text })
142  })
143
144  on('prompt.autocomplete', async ($, e, next) => {
145    const suggestions = suggestBooks(String(e.token ?? ''), BOOKS)
146    if (suggestions.length === 0) return next(e)
147    return { suggestions }
148  })
149}
150
hooks/books.ts 71 lines
1// Generated by scripts/generate-desk-books.mjs from server/src/db/books.ts. Do not edit directly.
2// Aliases are lower case with spaces, hyphens and underscores removed.
3export const BOOKS = [
4  { name: "Genesis", testament: "ot", aliases: ["genesis","gen"] },
5  { name: "Exodus", testament: "ot", aliases: ["exodus","exod","ex"] },
6  { name: "Leviticus", testament: "ot", aliases: ["leviticus","lev"] },
7  { name: "Numbers", testament: "ot", aliases: ["numbers","num"] },
8  { name: "Deuteronomy", testament: "ot", aliases: ["deuteronomy","deut","dt"] },
9  { name: "Joshua", testament: "ot", aliases: ["joshua","josh"] },
10  { name: "Judges", testament: "ot", aliases: ["judges","judg"] },
11  { name: "Ruth", testament: "ot", aliases: ["ruth"] },
12  { name: "1 Samuel", testament: "ot", aliases: ["1samuel","1sam"] },
13  { name: "2 Samuel", testament: "ot", aliases: ["2samuel","2sam"] },
14  { name: "1 Kings", testament: "ot", aliases: ["1kings","1kgs"] },
15  { name: "2 Kings", testament: "ot", aliases: ["2kings","2kgs"] },
16  { name: "1 Chronicles", testament: "ot", aliases: ["1chronicles","1chr"] },
17  { name: "2 Chronicles", testament: "ot", aliases: ["2chronicles","2chr"] },
18  { name: "Ezra", testament: "ot", aliases: ["ezra"] },
19  { name: "Nehemiah", testament: "ot", aliases: ["nehemiah","neh"] },
20  { name: "Esther", testament: "ot", aliases: ["esther","esth"] },
21  { name: "Job", testament: "ot", aliases: ["job"] },
22  { name: "Psalms", testament: "ot", aliases: ["psalms","psalm","ps"] },
23  { name: "Proverbs", testament: "ot", aliases: ["proverbs","prov"] },
24  { name: "Ecclesiastes", testament: "ot", aliases: ["ecclesiastes","eccl","qoh"] },
25  { name: "Song of Songs", testament: "ot", aliases: ["songofsolomon","songofsongs","canticles","ss"] },
26  { name: "Isaiah", testament: "ot", aliases: ["isaiah","isa"] },
27  { name: "Jeremiah", testament: "ot", aliases: ["jeremiah","jer"] },
28  { name: "Lamentations", testament: "ot", aliases: ["lamentations","lam"] },
29  { name: "Ezekiel", testament: "ot", aliases: ["ezekiel","ezek"] },
30  { name: "Daniel", testament: "ot", aliases: ["daniel","dan"] },
31  { name: "Hosea", testament: "ot", aliases: ["hosea","hos"] },
32  { name: "Joel", testament: "ot", aliases: ["joel"] },
33  { name: "Amos", testament: "ot", aliases: ["amos"] },
34  { name: "Obadiah", testament: "ot", aliases: ["obadiah","obad"] },
35  { name: "Jonah", testament: "ot", aliases: ["jonah","jon"] },
36  { name: "Micah", testament: "ot", aliases: ["micah","mic"] },
37  { name: "Nahum", testament: "ot", aliases: ["nahum","nah"] },
38  { name: "Habakkuk", testament: "ot", aliases: ["habakkuk","hab"] },
39  { name: "Zephaniah", testament: "ot", aliases: ["zephaniah","zeph"] },
40  { name: "Haggai", testament: "ot", aliases: ["haggai","hag"] },
41  { name: "Zechariah", testament: "ot", aliases: ["zechariah","zech"] },
42  { name: "Malachi", testament: "ot", aliases: ["malachi","mal"] },
43  { name: "Matthew", testament: "nt", aliases: ["matthew","matt"] },
44  { name: "Mark", testament: "nt", aliases: ["mark"] },
45  { name: "Luke", testament: "nt", aliases: ["luke"] },
46  { name: "John", testament: "nt", aliases: ["john"] },
47  { name: "Acts", testament: "nt", aliases: ["acts"] },
48  { name: "Romans", testament: "nt", aliases: ["romans","rom"] },
49  { name: "1 Corinthians", testament: "nt", aliases: ["1corinthians","1cor"] },
50  { name: "2 Corinthians", testament: "nt", aliases: ["2corinthians","2cor"] },
51  { name: "Galatians", testament: "nt", aliases: ["galatians","gal"] },
52  { name: "Ephesians", testament: "nt", aliases: ["ephesians","eph"] },
53  { name: "Philippians", testament: "nt", aliases: ["philippians","phil"] },
54  { name: "Colossians", testament: "nt", aliases: ["colossians","col"] },
55  { name: "1 Thessalonians", testament: "nt", aliases: ["1thessalonians","1thess","1th"] },
56  { name: "2 Thessalonians", testament: "nt", aliases: ["2thessalonians","2thess","2th"] },
57  { name: "1 Timothy", testament: "nt", aliases: ["1timothy","1tim"] },
58  { name: "2 Timothy", testament: "nt", aliases: ["2timothy","2tim"] },
59  { name: "Titus", testament: "nt", aliases: ["titus","tit"] },
60  { name: "Philemon", testament: "nt", aliases: ["philemon","phlm","phm"] },
61  { name: "Hebrews", testament: "nt", aliases: ["hebrews","heb"] },
62  { name: "James", testament: "nt", aliases: ["james","jas"] },
63  { name: "1 Peter", testament: "nt", aliases: ["1peter","1pet","1pe"] },
64  { name: "2 Peter", testament: "nt", aliases: ["2peter","2pet","2pe"] },
65  { name: "1 John", testament: "nt", aliases: ["1john","1jn"] },
66  { name: "2 John", testament: "nt", aliases: ["2john","2jn"] },
67  { name: "3 John", testament: "nt", aliases: ["3john","3jn"] },
68  { name: "Jude", testament: "nt", aliases: ["jude"] },
69  { name: "Revelation", testament: "nt", aliases: ["revelation","rev"] },
70]
71
hooks/grounding.ts 25 lines
1// Decides when to flag an answer that a coa skill gave without any coa data
2// lookups. Under "no need to look anything up" pressure, the skills answer from
3// memory despite their MCP-first rule (docs/adr/0005, RED probe).
4
5// Skills whose contract requires MCP evidence, directly or through their agents.
6export const GROUNDED_SKILLS = [
7  'coa:argument-flow',
8  'coa:biblical-segmentation',
9  'coa:consult-biblical-scholar',
10  'coa:exegetical-notes',
11  'coa:passage-glossary',
12  'coa:pericope-delimitation',
13]
14
15// Skills that legitimately run without lookups.
16export const EXEMPT_SKILLS = ['coa:smoke-test', 'coa:study-team']
17
18export function groundingWarning(turn: { skills: readonly string[]; lookups: number; answer: string }): string | null {
19  if (turn.lookups > 0) return null
20  if (turn.answer.trim().endsWith('?')) return null
21  const grounded = turn.skills.filter((skill) => GROUNDED_SKILLS.includes(skill))
22  if (grounded.length === 0) return null
23  return `⚠ ${grounded.join(', ')} answered without any coa data lookups. Treat the answer as unverified, or ask again and let it fetch the data.`
24}
25
hooks/ledger.ts 68 lines
1// Records the coa lookups made in a session, including the ones subagents
2// make, and summarises the current turn.
3
4export type LedgerEntry = {
5  tool: string
6  target: string
7  datasetIds: readonly string[]
8  isError: boolean
9  agentId?: string
10}
11
12export type Ledger = {
13  startTurn(): void
14  record(entry: LedgerEntry): void
15  noteSkill(skill: string): void
16  turnSkills(): readonly string[]
17  turnLookups(): number
18  turnSummary(): string | null
19  sessionEntries(): readonly LedgerEntry[]
20  reset(): void
21}
22
23export function createLedger(): Ledger {
24  let session: LedgerEntry[] = []
25  let turn: LedgerEntry[] = []
26  let skills: string[] = []
27  return {
28    startTurn() {
29      turn = []
30      skills = []
31    },
32    noteSkill(skill) {
33      if (!skills.includes(skill)) skills.push(skill)
34    },
35    turnSkills() {
36      return skills
37    },
38    turnLookups() {
39      return turn.filter((entry) => !entry.isError).length
40    },
41    record(entry) {
42      session.push(entry)
43      turn.push(entry)
44    },
45    turnSummary() {
46      const ok = turn.filter((entry) => !entry.isError)
47      const failed = turn.length - ok.length
48      if (ok.length === 0) return null
49      const counts = new Map<string, number>()
50      for (const entry of ok) counts.set(entry.tool, (counts.get(entry.tool) ?? 0) + 1)
51      const tools = [...counts].map(([tool, count]) => (count > 1 ? `${tool} ×${count}` : tool)).join(', ')
52      const datasets = [...new Set(ok.flatMap((entry) => entry.datasetIds))]
53      const parts = [`Grounded in ${ok.length} lookup${ok.length === 1 ? '' : 's'}: ${tools}`]
54      if (datasets.length > 0) parts.push(`data: ${datasets.join(', ')}`)
55      if (failed > 0) parts.push(`${failed} failed lookup${failed === 1 ? '' : 's'}`)
56      return parts.join(' · ')
57    },
58    sessionEntries() {
59      return session
60    },
61    reset() {
62      session = []
63      turn = []
64      skills = []
65    },
66  }
67}
68
hooks/mcp.ts 55 lines
1// Talks to the coa MCP server with plain JSON-RPC over HTTP. The desk does not
2// declare the server itself: that would give Claude a second copy of every
3// tool. The server is stateless, so tools/call needs no session.
4
5export const MCP_URL = 'https://coa.davebream.com/mcp'
6export const MCP_HEADERS = {
7  'content-type': 'application/json',
8  accept: 'application/json, text/event-stream',
9}
10
11export type RpcResponse =
12  | { isError: false; payload: any }
13  | { isError: true; message: string }
14
15export function rpcBody(tool: string, args: Record<string, unknown>, id = 1): string {
16  return JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name: tool, arguments: args } })
17}
18
19function lastMessage(text: string): any {
20  const data = text
21    .split('\n')
22    .filter((line) => line.startsWith('data: '))
23    .map((line) => line.slice(6))
24  return JSON.parse(data.length > 0 ? data[data.length - 1] : text)
25}
26
27function errorText(content: unknown): string {
28  const text = Array.isArray(content) ? content.find((block) => block?.type === 'text')?.text : undefined
29  if (typeof text !== 'string') return 'The server returned an error'
30  try {
31    return JSON.parse(text)?.error?.message ?? text
32  } catch {
33    return text
34  }
35}
36
37export function parseRpcResponse(text: string): RpcResponse {
38  let message: any
39  try {
40    message = lastMessage(text)
41  } catch {
42    return { isError: true, message: 'The server sent a response that is not JSON' }
43  }
44  if (message?.error) return { isError: true, message: String(message.error.message ?? 'Request failed') }
45  const result = message?.result
46  if (!result) return { isError: true, message: 'The server sent no result' }
47  if (result.isError) return { isError: true, message: errorText(result.content) }
48  if (result.structuredContent) return { isError: false, payload: result.structuredContent }
49  try {
50    return { isError: false, payload: JSON.parse(result.content?.[0]?.text ?? '') }
51  } catch {
52    return { isError: true, message: 'The server sent a result without data' }
53  }
54}
55
hooks/passage.ts 26 lines
1// Renders a parallel_text payload as Markdown for the passage pane.
2
3type Verse = { chapter: number; verse: number; translations: Record<string, string> }
4type Dataset = { id: string; title?: string }
5
6export function renderPassage(payload: any): string {
7  const verses: Verse[] = Array.isArray(payload?.verses) ? payload.verses : []
8  const lines = [`## ${payload?.book ?? ''} ${payload?.range ?? ''}`.trim(), '']
9  for (const verse of verses) {
10    lines.push(`**${verse.chapter}:${verse.verse}**`)
11    for (const [translation, text] of Object.entries(verse.translations ?? {})) {
12      lines.push(`- ${translation}: ${text}`)
13    }
14    lines.push('')
15  }
16  if (Array.isArray(payload?.missing_verses) && payload.missing_verses.length > 0) {
17    lines.push(`Missing in some translations: ${payload.missing_verses.join(', ')}`, '')
18  }
19  if (payload?.versification_note) lines.push(`_${payload.versification_note}_`, '')
20  const datasets: Dataset[] = Array.isArray(payload?.provenance?.datasets) ? payload.provenance.datasets : []
21  if (datasets.length > 0) {
22    lines.push(`Sources: ${datasets.map((dataset) => dataset.title ?? dataset.id).join('; ')}`)
23  }
24  return lines.join('\n').trimEnd()
25}
26
hooks/provenance.ts 36 lines
1// Pulls dataset ids out of a coa tool result. Every successful coa response
2// carries provenance.datasets[].id; a tool.call result can hold it as an
3// object, a JSON string, an MCP content block, or wrapped in { result, text }.
4
5function fromValue(value: unknown, depth: number): string[] {
6  if (depth > 4 || value === null || value === undefined) return []
7  if (typeof value === 'string') {
8    try {
9      return fromValue(JSON.parse(value), depth + 1)
10    } catch {
11      return []
12    }
13  }
14  if (typeof value !== 'object') return []
15  const record = value as Record<string, any>
16  const datasets = record.provenance?.datasets
17  if (Array.isArray(datasets)) {
18    return datasets.map((dataset) => dataset?.id).filter((id): id is string => typeof id === 'string')
19  }
20  for (const key of ['structuredContent', 'text', 'result']) {
21    const ids = fromValue(record[key], depth + 1)
22    if (ids.length > 0) return ids
23  }
24  if (Array.isArray(record.content)) {
25    for (const block of record.content) {
26      const ids = fromValue(block?.text, depth + 1)
27      if (ids.length > 0) return ids
28    }
29  }
30  return []
31}
32
33export function datasetIdsOf(value: unknown): string[] {
34  return [...new Set(fromValue(value, 0))]
35}
36
hooks/reference.ts 51 lines
1// Parses "/passage" arguments and suggests book names. A leaf module: the
2// book table is passed in, so tests can import this file on its own.
3
4export type Book = { name: string; testament: string; aliases: readonly string[] }
5export type Reference = { book: string; range: string; translations: string[] }
6export type Suggestion = { text: string; label: string; description: string }
7
8export const TRANSLATIONS = ['BSB', 'WEB', 'KJV', 'ASV', 'YLT', 'DBY']
9export const USAGE = 'Usage: /passage <book> <chapter:verse[-verse]> [translations], for example /passage Rom 8:1-4 BSB,KJV'
10
11const REFERENCE = /^\s*((?:[1-3]\s*)?[A-Za-z][A-Za-z .]*?)\s*(\d+:\d+(?:-\d+(?::\d+)?)?)\s*(.*)$/
12
13export function normalizeBook(text: string): string {
14  return text.toLowerCase().replace(/[\s._-]+/g, '')
15}
16
17export function findBook(text: string, books: readonly Book[]): Book | undefined {
18  const key = normalizeBook(text)
19  return books.find((book) => book.aliases.includes(key) || normalizeBook(book.name) === key)
20}
21
22export function parseReference(text: string, books: readonly Book[]): Reference | { error: string } {
23  if (!text.trim()) return { error: USAGE }
24  const match = REFERENCE.exec(text)
25  if (!match) {
26    return { error: `Give a chapter:verse range after the book. ${USAGE}` }
27  }
28  const [, bookText, range, rest] = match
29  const book = findBook(bookText, books)
30  if (!book) return { error: `Unknown book "${bookText.trim()}". ${USAGE}` }
31  const translations = rest.split(/[\s,]+/).filter(Boolean).map((item) => item.toUpperCase())
32  const unknown = translations.filter((item) => !TRANSLATIONS.includes(item))
33  if (unknown.length > 0) {
34    return { error: `Unknown translation ${unknown.join(', ')}. Available: ${TRANSLATIONS.join(', ')}.` }
35  }
36  return { book: book.name, range, translations }
37}
38
39export function suggestBooks(token: string, books: readonly Book[], limit = 5): Suggestion[] {
40  const key = normalizeBook(token)
41  if (key.length < 2 || !/^[1-3]?[a-z]/.test(key)) return []
42  return books
43    .filter((book) => book.aliases.some((alias) => alias.startsWith(key)) || normalizeBook(book.name).startsWith(key))
44    .slice(0, limit)
45    .map((book) => ({
46      text: book.name,
47      label: book.name,
48      description: book.testament === 'ot' ? 'Old Testament' : 'New Testament',
49    }))
50}
51