champollion 0.3.3

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 (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Command: doctor
3
+ *
4
+ * Comprehensive diagnostics tool for Champollion installations.
5
+ * Checks system health, validates configuration, and troubleshoots
6
+ * common issues with language cards, FSTs, methods, and scoring.
7
+ *
8
+ * Subcommands:
9
+ * (none) — Run all checks (full system health report)
10
+ * cards — Verify language cards load correctly
11
+ * config — Validate project configuration
12
+ * fst [code] — Check FST installation for a specific language
13
+ * methods — Check method dependencies and API keys
14
+ *
15
+ * Exit codes:
16
+ * 0 — All checks passed (or warnings only)
17
+ * 1 — One or more checks failed
18
+ *
19
+ * WHY this command exists:
20
+ * Champollion has many moving parts — language cards, FSTs, API keys,
21
+ * Python plugins, scoring specs. When something breaks, users need a
22
+ * single command that tells them exactly what's wrong and how to fix it.
23
+ * This is the "easy DX" philosophy: clear diagnostics, not cryptic errors.
24
+ */
25
+
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { output } from '../output.js';
29
+ import { getAllLanguageCodes, getLanguageCard, resolveCode } from '../registers.js';
30
+ import { getEnvOrFileVar } from '../api-key.js';
31
+ import { resolveProviderEnv, canonicalEnvName } from '../methods/provider-env.js';
32
+
33
+ // ── Result tracking ──────────────────────────────────────────────
34
+
35
+ /**
36
+ * Accumulates check results for the summary.
37
+ * Each check pushes a { status, label, detail } entry.
38
+ */
39
+ class DiagnosticReport {
40
+ constructor() {
41
+ this.results = [];
42
+ }
43
+
44
+ pass(label, detail = '') {
45
+ this.results.push({ status: 'pass', label, detail });
46
+ output.raw(` ✅ ${label}${detail ? ` ${detail}` : ''}`);
47
+ }
48
+
49
+ warn(label, detail = '') {
50
+ this.results.push({ status: 'warn', label, detail });
51
+ output.raw(` ⚠️ ${label}${detail ? ` ${detail}` : ''}`);
52
+ }
53
+
54
+ fail(label, detail = '') {
55
+ this.results.push({ status: 'fail', label, detail });
56
+ output.raw(` ❌ ${label}${detail ? `\n ${detail}` : ''}`);
57
+ }
58
+
59
+ /** Pass/warn/fail tallies — shared by summarize() and the --json payload. */
60
+ counts() {
61
+ return {
62
+ passed: this.results.filter(r => r.status === 'pass').length,
63
+ warned: this.results.filter(r => r.status === 'warn').length,
64
+ failed: this.results.filter(r => r.status === 'fail').length,
65
+ };
66
+ }
67
+
68
+ /** Print summary line and return exit code. */
69
+ summarize() {
70
+ const { passed, warned, failed } = this.counts();
71
+
72
+ output.raw('');
73
+ const parts = [];
74
+ if (passed) parts.push(`${passed} passed`);
75
+ if (warned) parts.push(`${warned} warning${warned > 1 ? 's' : ''}`);
76
+ if (failed) parts.push(`${failed} failed`);
77
+ output.raw(` ${parts.join(', ')}`);
78
+ output.raw('');
79
+
80
+ return failed > 0 ? 1 : 0;
81
+ }
82
+ }
83
+
84
+ // ── Check: Language Cards ────────────────────────────────────────
85
+
86
+ /**
87
+ * Verify language cards load correctly and report coverage stats.
88
+ */
89
+ function checkCards(report) {
90
+ output.raw('');
91
+ output.raw(' ── Language Cards ──');
92
+ output.raw('');
93
+
94
+ try {
95
+ const allCodes = getAllLanguageCodes();
96
+ const cardCount = allCodes.length;
97
+
98
+ if (cardCount === 0) {
99
+ report.fail('Language cards', 'No cards loaded. Check shared/language-cards/ directory.');
100
+ return;
101
+ }
102
+
103
+ report.pass(`Language cards`, `${cardCount.toLocaleString()} cards loaded`);
104
+
105
+ // Check for cards with missing required fields
106
+ let missingScript = 0;
107
+ let missingName = 0;
108
+ let withFST = 0;
109
+ let withFormality = 0;
110
+ let withMetricModel = 0;
111
+
112
+ for (const code of allCodes) {
113
+ const card = getLanguageCard(code);
114
+ if (!card) continue;
115
+ if (!card.name) missingName++;
116
+ if (!card.script && !card.scriptUnicodeName) missingScript++;
117
+ const fsts = card.resources?.fsts || [];
118
+ if (fsts.length > 0 && fsts[0].install) withFST++;
119
+ if (card.formality) withFormality++;
120
+ if (card.metricModelSupport) withMetricModel++;
121
+ }
122
+
123
+ // Stats line
124
+ output.raw(` FST install metadata: ${withFST} cards`);
125
+ output.raw(` Formality systems: ${withFormality} cards`);
126
+ output.raw(` Metric model support: ${withMetricModel} cards`);
127
+
128
+ if (missingName > 0) {
129
+ report.warn(`Cards missing name`, `${missingName} cards have no 'name' field`);
130
+ }
131
+
132
+ // Check genus cards
133
+ const genusDir = path.join(
134
+ findCardsDir() || '',
135
+ 'genera'
136
+ );
137
+ if (fs.existsSync(genusDir)) {
138
+ const genusFiles = fs.readdirSync(genusDir).filter(f => f.endsWith('.json'));
139
+ report.pass(`Genus cards`, `${genusFiles.length} genus/family cards in genera/`);
140
+ }
141
+ } catch (err) {
142
+ report.fail('Language cards', `Failed to load: ${err.message}`);
143
+ }
144
+ }
145
+
146
+ // ── Check: Config ────────────────────────────────────────────────
147
+
148
+ /**
149
+ * Validate the project configuration file.
150
+ */
151
+ function checkConfig(report, cwd) {
152
+ output.raw('');
153
+ output.raw(' ── Project Config ──');
154
+ output.raw('');
155
+
156
+ // Find config file
157
+ const configNames = ['champollion.config.json', 'champollion.config.js', '.champollionrc.json'];
158
+ let configPath = null;
159
+
160
+ for (const name of configNames) {
161
+ const candidate = path.join(cwd, name);
162
+ if (fs.existsSync(candidate)) {
163
+ configPath = candidate;
164
+ break;
165
+ }
166
+ }
167
+
168
+ if (!configPath) {
169
+ report.warn('Config file', `No config file found in ${cwd}. Run 'champollion init' to create one.`);
170
+ return;
171
+ }
172
+
173
+ try {
174
+ const raw = fs.readFileSync(configPath, 'utf-8');
175
+ const config = JSON.parse(raw);
176
+
177
+ report.pass('Config file', path.basename(configPath));
178
+
179
+ // Validate language codes resolve
180
+ const languages = Array.isArray(config.languages)
181
+ ? config.languages
182
+ : (config.languages ? Object.keys(config.languages) : []);
183
+
184
+ if (languages.length === 0) {
185
+ report.warn('Languages', 'No target languages configured');
186
+ } else {
187
+ let unresolved = 0;
188
+ const unresolvedCodes = [];
189
+
190
+ for (const code of languages) {
191
+ const resolved = resolveCode(code);
192
+ if (!resolved) {
193
+ unresolved++;
194
+ unresolvedCodes.push(code);
195
+ }
196
+ }
197
+
198
+ if (unresolved > 0) {
199
+ report.fail(
200
+ 'Language resolution',
201
+ `${unresolved} code(s) not found in language cards: ${unresolvedCodes.join(', ')}`
202
+ );
203
+ } else {
204
+ report.pass('Language resolution', `${languages.length} language(s) all resolve to valid cards`);
205
+ }
206
+ }
207
+
208
+ // Check for input locale
209
+ if (config.inputLocale) {
210
+ const resolved = resolveCode(config.inputLocale);
211
+ if (resolved) {
212
+ report.pass('Input locale', `${config.inputLocale} → ${resolved}`);
213
+ } else {
214
+ report.fail('Input locale', `"${config.inputLocale}" does not resolve to a language card`);
215
+ }
216
+ }
217
+
218
+ // Check method. The config schema field is `defaultMethod` (see config.js
219
+ // DEFAULTS) — reading the non-existent `config.method` made doctor warn on
220
+ // EVERY correct project, including the config `champollion init` writes.
221
+ // Accept the legacy `method` alias too, and treat absence as the documented
222
+ // default ('llm') rather than a misconfiguration.
223
+ const method = config.defaultMethod || config.method;
224
+ if (method) {
225
+ report.pass('Method', method);
226
+ } else {
227
+ report.pass('Method', 'llm (default)');
228
+ }
229
+
230
+ } catch (err) {
231
+ if (err instanceof SyntaxError) {
232
+ report.fail('Config file', `Invalid JSON in ${path.basename(configPath)}: ${err.message}`);
233
+ } else {
234
+ report.fail('Config file', `Failed to read: ${err.message}`);
235
+ }
236
+ }
237
+ }
238
+
239
+ // ── Check: FST ───────────────────────────────────────────────────
240
+
241
+ /**
242
+ * Check FST installation for a specific language or all configured.
243
+ */
244
+ function checkFST(report, langCode) {
245
+ output.raw('');
246
+ output.raw(` ── FST: ${langCode || 'all'} ──`);
247
+ output.raw('');
248
+
249
+ // If a specific code was given, check just that one
250
+ const codesToCheck = langCode
251
+ ? [resolveCode(langCode) || langCode]
252
+ : getAllLanguageCodes().filter(code => {
253
+ const card = getLanguageCard(code);
254
+ const fsts = card?.resources?.fsts || [];
255
+ return fsts.length > 0 && fsts[0].install;
256
+ });
257
+
258
+ if (codesToCheck.length === 0) {
259
+ if (langCode) {
260
+ report.warn(`FST (${langCode})`, 'No FST install metadata on this language card');
261
+ } else {
262
+ report.warn('FST', 'No languages have FST install metadata on their cards');
263
+ }
264
+ return;
265
+ }
266
+
267
+ for (const code of codesToCheck) {
268
+ const card = getLanguageCard(code);
269
+ if (!card) {
270
+ report.fail(`FST (${code})`, `No language card found for "${code}"`);
271
+ continue;
272
+ }
273
+
274
+ const fsts = card.resources?.fsts || [];
275
+ const installInfo = fsts[0]?.install;
276
+
277
+ if (!installInfo) {
278
+ report.warn(`FST (${code})`, 'Card has resources.fsts but no install metadata');
279
+ continue;
280
+ }
281
+
282
+ // Check local cache
283
+ const cacheDir = path.join(
284
+ process.env.HOME || process.env.USERPROFILE || '/tmp',
285
+ '.mt-eval', 'fsts', code
286
+ );
287
+ const cacheExists = fs.existsSync(cacheDir);
288
+
289
+ if (cacheExists) {
290
+ // Check for .hfstol files
291
+ try {
292
+ const files = fs.readdirSync(cacheDir).filter(f =>
293
+ f.endsWith('.hfstol') || f.endsWith('.hfst')
294
+ );
295
+
296
+ if (files.length > 0) {
297
+ const totalSize = files.reduce((sum, f) => {
298
+ try { return sum + fs.statSync(path.join(cacheDir, f)).size; } catch { return sum; }
299
+ }, 0);
300
+ const sizeStr = totalSize > 1024 * 1024
301
+ ? `${(totalSize / (1024 * 1024)).toFixed(1)}MB`
302
+ : `${(totalSize / 1024).toFixed(0)}KB`;
303
+
304
+ const maturityNote = installInfo.maturity === 'stub'
305
+ ? ' (stub — limited vocabulary)'
306
+ : '';
307
+
308
+ report.pass(
309
+ `FST (${code} — ${card.name || code})`,
310
+ `Installed: ${files.join(', ')} (${sizeStr})${maturityNote}`
311
+ );
312
+ } else {
313
+ report.warn(
314
+ `FST (${code})`,
315
+ `Cache dir exists but no .hfstol/.hfst files found in ${cacheDir}`
316
+ );
317
+ }
318
+ } catch (err) {
319
+ report.warn(`FST (${code})`, `Could not read cache dir: ${err.message}`);
320
+ }
321
+ } else {
322
+ // Not installed — give install guidance
323
+ const detail = installInfo.format === 'manual'
324
+ ? `Not installed. Manual setup required — see ${installInfo.repo}`
325
+ : `Not installed. Run the eval harness to auto-download from ${installInfo.repo}@${installInfo.releaseTag || 'latest'}`;
326
+
327
+ report.warn(`FST (${code} — ${card.name || code})`, detail);
328
+ }
329
+ }
330
+ }
331
+
332
+ // ── Check: Methods ───────────────────────────────────────────────
333
+
334
+ /**
335
+ * Check method dependencies: API keys, packages, server reachability.
336
+ *
337
+ * @param {DiagnosticReport} report
338
+ * @param {string} cwd - Project root, for .env.local/.env key lookup
339
+ */
340
+ async function checkMethods(report, cwd) {
341
+ output.raw('');
342
+ output.raw(' ── Methods ──');
343
+ output.raw('');
344
+
345
+ // API key checks.
346
+ //
347
+ // Every lookup goes through getEnvOrFileVar (process.env → .env.local →
348
+ // .env) — the SAME resolution chain the translation engine uses. Checking
349
+ // process.env alone made doctor report "not set" for a key the engine
350
+ // would happily read from .env.local (the very file our own setup help
351
+ // tells users to put it in) — a false negative that sent users chasing a
352
+ // problem they didn't have.
353
+ //
354
+ // For self-contained MT providers (deepl/google/microsoft) we resolve
355
+ // through the provider-env SSOT so doctor agrees with the method loader:
356
+ // it must never report "ready" under a name the loader won't read, nor
357
+ // "not set" for a name the loader *would* read. `method` keys into the
358
+ // SSOT; `key` is a single hardcoded name for providers not in the SSOT.
359
+ const apiKeyChecks = [
360
+ { method: 'deepl', label: 'DeepL' },
361
+ { method: 'google-translate', label: 'Google Translate' },
362
+ { method: 'microsoft-translator', label: 'Microsoft Translator' },
363
+ { key: 'OPENAI_API_KEY', label: 'OpenAI' },
364
+ { key: 'ANTHROPIC_API_KEY', label: 'Anthropic' },
365
+ { key: 'GEMINI_API_KEY', label: 'Gemini' },
366
+ ];
367
+
368
+ // LLM via OpenRouter
369
+ const openRouterKey = getEnvOrFileVar('OPENROUTER_API_KEY', cwd);
370
+ if (openRouterKey) {
371
+ const source = process.env.OPENROUTER_API_KEY ? 'environment' : '.env.local/.env';
372
+ report.pass('OpenRouter API key', `Set via ${source} (LLM methods available)`);
373
+
374
+ // Try to validate by checking the /api/v1/models endpoint
375
+ try {
376
+ const resp = await fetch('https://openrouter.ai/api/v1/models', {
377
+ headers: { 'Authorization': `Bearer ${openRouterKey}` },
378
+ signal: AbortSignal.timeout(5000),
379
+ });
380
+ if (resp.ok) {
381
+ const data = await resp.json();
382
+ const modelCount = data.data?.length || 0;
383
+ report.pass('OpenRouter API', `Connected — ${modelCount} models available`);
384
+ } else {
385
+ report.warn('OpenRouter API', `HTTP ${resp.status} — key may be invalid`);
386
+ }
387
+ } catch (err) {
388
+ report.warn('OpenRouter API', `Could not reach API: ${err.message}`);
389
+ }
390
+ } else {
391
+ report.warn('OpenRouter API key', 'OPENROUTER_API_KEY not set (checked environment, .env.local, .env) — LLM methods unavailable');
392
+ }
393
+
394
+ // Check individual provider API keys
395
+ for (const { method, key, label } of apiKeyChecks) {
396
+ if (method) {
397
+ // SSOT-backed provider: accept canonical + any alias, and report
398
+ // exactly which name resolved so doctor and the loader can't disagree.
399
+ const { value, name } = resolveProviderEnv(method, cwd);
400
+ const canonical = canonicalEnvName(method);
401
+ if (value) {
402
+ const via = name === canonical ? `${name} is set` : `${name} is set (alias for ${canonical})`;
403
+ report.pass(`${label}`, via);
404
+ } else {
405
+ // Not an error — just informational. Most users only use one or two providers.
406
+ output.raw(` ◻️ ${label} ${canonical} not set`);
407
+ }
408
+ } else if (getEnvOrFileVar(key, cwd)) {
409
+ report.pass(`${label}`, `${key} is set`);
410
+ } else {
411
+ // Not an error — just informational. Most users only use one or two providers.
412
+ output.raw(` ◻️ ${label} ${key} not set`);
413
+ }
414
+ }
415
+
416
+ // Check LibreTranslate server (if configured). Resolve the endpoint through
417
+ // the SSOT (LIBRETRANSLATE_API_URL canonical, LIBRETRANSLATE_URL / LIBRE_URL
418
+ // aliases) so doctor probes the same URL the LibreTranslate method reads.
419
+ const libreUrl = resolveProviderEnv('libretranslate', cwd).value;
420
+ if (libreUrl) {
421
+ // LIBRETRANSLATE_API_URL is the full translate endpoint (…/translate),
422
+ // but /languages hangs off the server base. Strip a trailing /translate
423
+ // so we probe the same server the method talks to, whether the user set
424
+ // the canonical endpoint var or a legacy base-URL alias.
425
+ const libreBase = libreUrl.replace(/\/translate\/?$/, '').replace(/\/$/, '');
426
+ try {
427
+ const resp = await fetch(`${libreBase}/languages`, {
428
+ signal: AbortSignal.timeout(5000),
429
+ });
430
+ if (resp.ok) {
431
+ const langs = await resp.json();
432
+ report.pass('LibreTranslate', `Server reachable at ${libreBase} — ${langs.length} languages`);
433
+ } else {
434
+ report.warn('LibreTranslate', `Server at ${libreBase} returned HTTP ${resp.status}`);
435
+ }
436
+ } catch (err) {
437
+ report.fail('LibreTranslate', `Cannot reach ${libreBase}: ${err.message}`);
438
+ }
439
+ }
440
+
441
+ // Check if crk-translate (or other external methods) are pip-installed
442
+ try {
443
+ const { execSync } = await import('node:child_process');
444
+ const pipList = execSync('pip3 list --format=json 2>/dev/null || pip list --format=json 2>/dev/null', {
445
+ encoding: 'utf-8',
446
+ timeout: 10000,
447
+ });
448
+ const packages = JSON.parse(pipList);
449
+ const mtPackages = packages.filter(p =>
450
+ p.name.includes('translate') || p.name.includes('mt-eval') || p.name.includes('champollion')
451
+ );
452
+ if (mtPackages.length > 0) {
453
+ report.pass(
454
+ 'Python packages',
455
+ mtPackages.map(p => `${p.name}==${p.version}`).join(', ')
456
+ );
457
+ }
458
+ } catch {
459
+ // pip not available or failed — not critical for CLI-only users
460
+ }
461
+ }
462
+
463
+ // ── Helpers ──────────────────────────────────────────────────────
464
+
465
+ /**
466
+ * Find the language cards directory by walking up from CWD.
467
+ */
468
+ function findCardsDir() {
469
+ // The cards are always at cli/shared/language-cards/ relative to the monorepo root
470
+ // Walk up looking for a cli/shared/language-cards/ directory
471
+ let dir = process.cwd();
472
+ for (let i = 0; i < 8; i++) {
473
+ const candidate = path.join(dir, 'cli', 'shared', 'language-cards');
474
+ if (fs.existsSync(candidate)) return candidate;
475
+
476
+ // Also check shared/language-cards/ (when CWD is cli/)
477
+ const candidate2 = path.join(dir, 'shared', 'language-cards');
478
+ if (fs.existsSync(candidate2)) return candidate2;
479
+
480
+ const parent = path.dirname(dir);
481
+ if (parent === dir) break;
482
+ dir = parent;
483
+ }
484
+ return null;
485
+ }
486
+
487
+ // ── Main Entry Point ─────────────────────────────────────────────
488
+
489
+ /**
490
+ * @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
491
+ * @param {string} cwd - Working directory
492
+ * @returns {Promise<number>} Exit code (0 = success, 1 = errors found)
493
+ */
494
+ async function run(args, cwd) {
495
+ const subcommand = args._[1];
496
+ const report = new DiagnosticReport();
497
+
498
+ // --json: stdout carries exactly one JSON document. Quiet mode suppresses
499
+ // every check's raw line; the results array captures the same information.
500
+ const json = !!args.json;
501
+ if (json) output.setMode('quiet');
502
+
503
+ output.raw('');
504
+ output.raw(' Champollion Doctor — System Health Check');
505
+ output.raw(' ────────────────────────────────────────');
506
+
507
+ if (!subcommand || subcommand === 'all') {
508
+ // Full diagnostic
509
+ checkCards(report);
510
+ checkConfig(report, cwd);
511
+ checkFST(report, null);
512
+ await checkMethods(report, cwd);
513
+ } else if (subcommand === 'cards') {
514
+ checkCards(report);
515
+ } else if (subcommand === 'config') {
516
+ checkConfig(report, cwd);
517
+ } else if (subcommand === 'fst') {
518
+ const langCode = args._[2] || null;
519
+ checkFST(report, langCode);
520
+ } else if (subcommand === 'methods') {
521
+ await checkMethods(report, cwd);
522
+ } else {
523
+ if (json) {
524
+ console.log(JSON.stringify({
525
+ command: 'doctor',
526
+ error: `Unknown subcommand "${subcommand}"`,
527
+ subcommands: ['all', 'cards', 'config', 'fst', 'methods'],
528
+ }, null, 2));
529
+ return 0;
530
+ }
531
+ output.raw(`
532
+ Doctor Subcommands:
533
+
534
+ champollion doctor Run all checks
535
+ champollion doctor cards Verify language cards
536
+ champollion doctor config Validate project config
537
+ champollion doctor fst [code] Check FST installation
538
+ champollion doctor methods Check API keys and dependencies
539
+ `);
540
+ return 0;
541
+ }
542
+
543
+ if (json) {
544
+ const { passed, warned, failed } = report.counts();
545
+ console.log(JSON.stringify({
546
+ command: 'doctor',
547
+ subcommand: subcommand || 'all',
548
+ results: report.results,
549
+ passed,
550
+ warned,
551
+ failed,
552
+ }, null, 2));
553
+ return failed > 0 ? 1 : 0;
554
+ }
555
+
556
+ return report.summarize();
557
+ }
558
+
559
+ export { run };