qwerty-cli 0.0.1-alpha.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/cli.js ADDED
@@ -0,0 +1,1558 @@
1
+ // src/cli.ts
2
+ import { Command as Command6 } from "commander";
3
+
4
+ // src/commands/config.ts
5
+ import { Command } from "commander";
6
+ import chalk from "chalk";
7
+
8
+ // src/infra/config-store.ts
9
+ import { z } from "zod";
10
+
11
+ // src/infra/paths.ts
12
+ import { homedir } from "os";
13
+ import { join, resolve } from "path";
14
+ import { mkdir } from "fs/promises";
15
+ import { fileURLToPath } from "url";
16
+ var ROOT = join(homedir(), ".qwerty-cli");
17
+ var paths = {
18
+ root: ROOT,
19
+ config: join(ROOT, "config.json"),
20
+ registry: join(ROOT, "registry.json"),
21
+ dictsDir: join(ROOT, "dicts"),
22
+ dictFile: (id) => join(ROOT, "dicts", `${id}.json`),
23
+ dictMeta: (id) => join(ROOT, "dicts", `${id}.meta.json`),
24
+ stats: join(ROOT, "stats.jsonl"),
25
+ mistakes: join(ROOT, "mistakes.json"),
26
+ audioCacheDir: join(ROOT, "cache", "audio"),
27
+ audioCache: (word, accent) => join(ROOT, "cache", "audio", `${encodeURIComponent(word.toLowerCase())}-${accent}.mp3`)
28
+ };
29
+ async function ensureDirs() {
30
+ await mkdir(paths.dictsDir, { recursive: true });
31
+ await mkdir(paths.audioCacheDir, { recursive: true });
32
+ }
33
+ function packageAssetsDir() {
34
+ const here = fileURLToPath(import.meta.url);
35
+ return resolve(here, "..", "..", "assets");
36
+ }
37
+
38
+ // src/infra/fs-store.ts
39
+ import { mkdir as mkdir2, readFile, rename, writeFile, appendFile, access } from "fs/promises";
40
+ import { dirname } from "path";
41
+ import { randomBytes } from "crypto";
42
+ async function exists(path) {
43
+ try {
44
+ await access(path);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+ async function readJson(path) {
51
+ try {
52
+ const buf = await readFile(path, "utf8");
53
+ return JSON.parse(buf);
54
+ } catch (err) {
55
+ if (err.code === "ENOENT") return null;
56
+ throw err;
57
+ }
58
+ }
59
+ async function writeJsonAtomic(path, value) {
60
+ await mkdir2(dirname(path), { recursive: true });
61
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
62
+ await writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
63
+ await rename(tmp, path);
64
+ }
65
+ async function appendJsonl(path, line) {
66
+ await mkdir2(dirname(path), { recursive: true });
67
+ await appendFile(path, JSON.stringify(line) + "\n", "utf8");
68
+ }
69
+ async function readJsonl(path) {
70
+ try {
71
+ const buf = await readFile(path, "utf8");
72
+ return buf.split("\n").filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
73
+ } catch (err) {
74
+ if (err.code === "ENOENT") return [];
75
+ throw err;
76
+ }
77
+ }
78
+
79
+ // src/infra/config-store.ts
80
+ var ConfigSchema = z.object({
81
+ mirror: z.enum(["jsdelivr", "github"]).default("jsdelivr"),
82
+ accent: z.enum(["us", "uk"]).default("us"),
83
+ chapterSize: z.number().int().positive().max(200).default(20),
84
+ sounds: z.object({
85
+ master: z.boolean().default(true),
86
+ keystroke: z.boolean().default(true),
87
+ feedback: z.boolean().default(true),
88
+ keySoundName: z.string().default("default")
89
+ }).default({ master: true, keystroke: true, feedback: true, keySoundName: "default" }),
90
+ autoplayPronunciation: z.boolean().default(true),
91
+ defaultMode: z.enum(["order", "dictation", "review", "random", "loop"]).default("order"),
92
+ defaultDict: z.string().optional()
93
+ });
94
+ var DEFAULTS = ConfigSchema.parse({});
95
+ async function loadConfig() {
96
+ const raw = await readJson(paths.config);
97
+ if (!raw) return DEFAULTS;
98
+ const result = ConfigSchema.safeParse(raw);
99
+ if (!result.success) {
100
+ throw new Error(`Invalid config at ${paths.config}: ${result.error.message}`);
101
+ }
102
+ return result.data;
103
+ }
104
+ async function saveConfig(cfg) {
105
+ await writeJsonAtomic(paths.config, cfg);
106
+ }
107
+ function getByPath(cfg, path) {
108
+ const parts = path.split(".");
109
+ let cur = cfg;
110
+ for (const p of parts) {
111
+ if (cur === null || typeof cur !== "object") return void 0;
112
+ cur = cur[p];
113
+ }
114
+ return cur;
115
+ }
116
+ function setByPath(cfg, path, rawValue) {
117
+ const parts = path.split(".");
118
+ if (parts.length === 0) throw new Error("Empty config key");
119
+ const clone = JSON.parse(JSON.stringify(cfg));
120
+ let cur = clone;
121
+ for (let i = 0; i < parts.length - 1; i++) {
122
+ const k = parts[i];
123
+ const next = cur[k];
124
+ if (typeof next !== "object" || next === null) {
125
+ throw new Error(`Cannot set ${path}: ${parts.slice(0, i + 1).join(".")} is not an object`);
126
+ }
127
+ cur = next;
128
+ }
129
+ const leaf = parts[parts.length - 1];
130
+ cur[leaf] = coerce(rawValue);
131
+ const validated = ConfigSchema.safeParse(clone);
132
+ if (!validated.success) {
133
+ throw new Error(`Invalid value for ${path}: ${validated.error.issues[0]?.message ?? "unknown"}`);
134
+ }
135
+ return validated.data;
136
+ }
137
+ function coerce(v) {
138
+ if (v === "true") return true;
139
+ if (v === "false") return false;
140
+ if (v === "null") return null;
141
+ if (/^-?\d+$/.test(v)) return Number(v);
142
+ if (/^-?\d+\.\d+$/.test(v)) return Number(v);
143
+ return v;
144
+ }
145
+
146
+ // src/commands/config.ts
147
+ function buildConfigCommand() {
148
+ const cmd = new Command("config").description("Manage CLI configuration");
149
+ cmd.command("list").description("Show the effective merged config").action(async () => {
150
+ const cfg = await loadConfig();
151
+ console.log(JSON.stringify(cfg, null, 2));
152
+ });
153
+ cmd.command("get <key>").description("Get a config value by dotted path (e.g. sounds.keystroke)").action(async (key) => {
154
+ const cfg = await loadConfig();
155
+ const v = getByPath(cfg, key);
156
+ if (v === void 0) {
157
+ console.error(chalk.red(`Key not found: ${key}`));
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ console.log(typeof v === "string" ? v : JSON.stringify(v));
162
+ });
163
+ cmd.command("set <key> <value>").description("Set a config value by dotted path").action(async (key, value) => {
164
+ const cfg = await loadConfig();
165
+ const next = setByPath(cfg, key, value);
166
+ await saveConfig(next);
167
+ console.log(chalk.green(`Set ${key} = ${value}`));
168
+ });
169
+ return cmd;
170
+ }
171
+
172
+ // src/commands/dict.ts
173
+ import { Command as Command2 } from "commander";
174
+ import chalk2 from "chalk";
175
+
176
+ // src/domain/dictionary.ts
177
+ import { z as z2 } from "zod";
178
+ var DictionaryEntrySchema = z2.object({
179
+ id: z2.string(),
180
+ name: z2.string(),
181
+ description: z2.string().default(""),
182
+ category: z2.string().default(""),
183
+ tags: z2.array(z2.string()).default([]),
184
+ url: z2.string(),
185
+ length: z2.number().int().nonnegative().default(0),
186
+ language: z2.string().default("en"),
187
+ languageCategory: z2.string().default("en"),
188
+ defaultPronIndex: z2.number().int().optional()
189
+ });
190
+ var RegistrySchema = z2.array(DictionaryEntrySchema);
191
+ var WordSchema = z2.object({
192
+ name: z2.string(),
193
+ trans: z2.array(z2.string()).default([]),
194
+ usphone: z2.string().optional(),
195
+ ukphone: z2.string().optional(),
196
+ notation: z2.string().optional()
197
+ }).passthrough();
198
+ var WordArraySchema = z2.array(WordSchema);
199
+ function filterRegistry(registry, query, opts = {}) {
200
+ const q = query.trim().toLowerCase();
201
+ return registry.filter((d) => {
202
+ if (opts.category && d.category !== opts.category) return false;
203
+ if (opts.language && d.language !== opts.language) return false;
204
+ if (!q) return true;
205
+ return d.id.toLowerCase().includes(q) || d.name.toLowerCase().includes(q) || d.description.toLowerCase().includes(q) || d.category.toLowerCase().includes(q) || d.tags.some((t) => t.toLowerCase().includes(q));
206
+ });
207
+ }
208
+
209
+ // src/infra/registry-store.ts
210
+ import { readFile as readFile2 } from "fs/promises";
211
+ import { join as join2 } from "path";
212
+ var cached = null;
213
+ async function loadRegistry() {
214
+ if (cached) return cached;
215
+ const userOverride = await readJson(paths.registry);
216
+ if (userOverride) {
217
+ const parsed2 = RegistrySchema.safeParse(userOverride);
218
+ if (parsed2.success) {
219
+ cached = parsed2.data;
220
+ return cached;
221
+ }
222
+ console.warn(`Ignoring corrupt user registry at ${paths.registry}: ${parsed2.error.message}`);
223
+ }
224
+ const bundled = await readFile2(join2(packageAssetsDir(), "registry.snapshot.json"), "utf8");
225
+ const parsed = RegistrySchema.parse(JSON.parse(bundled));
226
+ cached = parsed;
227
+ return cached;
228
+ }
229
+ async function findEntry(id) {
230
+ const reg = await loadRegistry();
231
+ return reg.find((d) => d.id === id);
232
+ }
233
+
234
+ // src/infra/dict-downloader.ts
235
+ import { createHash } from "crypto";
236
+ import { stat, copyFile } from "fs/promises";
237
+ import { request } from "undici";
238
+ var JSDELIVR_BASE = "https://cdn.jsdelivr.net/gh/RealKai42/qwerty-learner@master";
239
+ var GITHUB_RAW_BASE = "https://raw.githubusercontent.com/RealKai42/qwerty-learner/master";
240
+ async function fetchWithRetry(url, retries = 2) {
241
+ let lastErr;
242
+ for (let i = 0; i <= retries; i++) {
243
+ try {
244
+ const res = await request(url, { headersTimeout: 1e4, bodyTimeout: 3e4 });
245
+ if (res.statusCode >= 400) throw new Error(`HTTP ${res.statusCode}`);
246
+ return await res.body.text();
247
+ } catch (err) {
248
+ lastErr = err;
249
+ if (i < retries) await new Promise((r) => setTimeout(r, 500 * (i + 1)));
250
+ }
251
+ }
252
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
253
+ }
254
+ function buildUrls(mirror, relUrl) {
255
+ const path = relUrl.startsWith("/") ? relUrl : `/${relUrl}`;
256
+ const repoPath = `/public${path}`;
257
+ const jsd = `${JSDELIVR_BASE}${repoPath}`;
258
+ const gh = `${GITHUB_RAW_BASE}${repoPath}`;
259
+ return mirror === "jsdelivr" ? [jsd, gh] : [gh, jsd];
260
+ }
261
+ async function pullDictionary(id) {
262
+ const entry = await findEntry(id);
263
+ if (!entry) throw new Error(`Unknown dictionary id: ${id}`);
264
+ const cfg = await loadConfig();
265
+ await ensureDirs();
266
+ const urls = buildUrls(cfg.mirror, entry.url);
267
+ let body = null;
268
+ let lastErr;
269
+ for (const u of urls) {
270
+ try {
271
+ body = await fetchWithRetry(u);
272
+ break;
273
+ } catch (err) {
274
+ lastErr = err;
275
+ }
276
+ }
277
+ if (!body) {
278
+ throw new Error(
279
+ `Failed to download dictionary ${id} from any mirror: ${lastErr instanceof Error ? lastErr.message : lastErr}`
280
+ );
281
+ }
282
+ let json;
283
+ try {
284
+ json = JSON.parse(body);
285
+ } catch (err) {
286
+ throw new Error(`Dictionary ${id} is not valid JSON: ${err.message}`);
287
+ }
288
+ const parsed = WordArraySchema.safeParse(json);
289
+ if (!parsed.success) {
290
+ throw new Error(`Dictionary ${id} failed schema validation: ${parsed.error.issues[0]?.message}`);
291
+ }
292
+ const sha = createHash("sha256").update(body).digest("hex");
293
+ await writeJsonAtomic(paths.dictFile(id), parsed.data);
294
+ const meta = { sha256: sha, size: body.length, fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), id };
295
+ await writeJsonAtomic(paths.dictMeta(id), meta);
296
+ return { words: parsed.data, size: body.length };
297
+ }
298
+ async function importDictionary(file, id) {
299
+ if (!/^[a-z0-9-]+$/.test(id)) {
300
+ throw new Error(`Invalid id "${id}". Must match /^[a-z0-9-]+$/`);
301
+ }
302
+ await ensureDirs();
303
+ if (!await exists(file)) throw new Error(`File not found: ${file}`);
304
+ const raw = await readJson(file);
305
+ const parsed = WordArraySchema.safeParse(raw);
306
+ if (!parsed.success) {
307
+ throw new Error(`Import rejected: ${parsed.error.issues[0]?.message}`);
308
+ }
309
+ await copyFile(file, paths.dictFile(id));
310
+ const size = (await stat(paths.dictFile(id))).size;
311
+ const sha = createHash("sha256").update(JSON.stringify(parsed.data)).digest("hex");
312
+ const meta = { sha256: sha, size, fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), id };
313
+ await writeJsonAtomic(paths.dictMeta(id), meta);
314
+ return { words: parsed.data, size };
315
+ }
316
+ async function loadLocalDictionary(id) {
317
+ const data = await readJson(paths.dictFile(id));
318
+ if (!data) return null;
319
+ const parsed = WordArraySchema.safeParse(data);
320
+ if (!parsed.success) throw new Error(`Local dictionary ${id} corrupt: ${parsed.error.message}`);
321
+ return parsed.data;
322
+ }
323
+ async function isLocallyAvailable(id) {
324
+ return exists(paths.dictFile(id));
325
+ }
326
+ async function ensureDictionary(id) {
327
+ const local = await loadLocalDictionary(id);
328
+ if (local) return local;
329
+ const { words } = await pullDictionary(id);
330
+ return words;
331
+ }
332
+ async function removeDictionary(id) {
333
+ const { unlink } = await import("fs/promises");
334
+ let removed = false;
335
+ for (const p of [paths.dictFile(id), paths.dictMeta(id)]) {
336
+ try {
337
+ await unlink(p);
338
+ removed = true;
339
+ } catch (err) {
340
+ if (err.code !== "ENOENT") throw err;
341
+ }
342
+ }
343
+ return removed;
344
+ }
345
+
346
+ // src/commands/dict.ts
347
+ function fmtBytes(n) {
348
+ if (n < 1024) return `${n} B`;
349
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
350
+ return `${(n / 1024 / 1024).toFixed(2)} MB`;
351
+ }
352
+ async function renderTable(entries) {
353
+ const flagged = await Promise.all(
354
+ entries.map(async (e) => ({ entry: e, local: await isLocallyAvailable(e.id) }))
355
+ );
356
+ const widths = {
357
+ local: 3,
358
+ id: Math.max(2, ...flagged.map((f) => f.entry.id.length)),
359
+ name: Math.max(4, ...flagged.map((f) => f.entry.name.length)),
360
+ category: Math.max(8, ...flagged.map((f) => f.entry.category.length)),
361
+ length: Math.max(5, ...flagged.map((f) => String(f.entry.length).length))
362
+ };
363
+ const pad = (s, n) => s + " ".repeat(Math.max(0, n - visibleWidth(s)));
364
+ const header = [
365
+ pad(" ", widths.local),
366
+ chalk2.bold(pad("ID", widths.id)),
367
+ chalk2.bold(pad("Name", widths.name)),
368
+ chalk2.bold(pad("Category", widths.category)),
369
+ chalk2.bold(pad("Words", widths.length)),
370
+ chalk2.bold("Description")
371
+ ].join(" ");
372
+ console.log(header);
373
+ for (const { entry, local } of flagged) {
374
+ const mark = local ? chalk2.green(" \u2713") : " ";
375
+ console.log(
376
+ [
377
+ pad(mark, widths.local),
378
+ pad(entry.id, widths.id),
379
+ pad(entry.name, widths.name),
380
+ chalk2.dim(pad(entry.category, widths.category)),
381
+ pad(String(entry.length), widths.length),
382
+ chalk2.dim(entry.description)
383
+ ].join(" ")
384
+ );
385
+ }
386
+ }
387
+ function visibleWidth(s) {
388
+ let w = 0;
389
+ const plain = s.replace(/\x1b\[[0-9;]*m/g, "");
390
+ for (const ch of plain) {
391
+ const code = ch.codePointAt(0);
392
+ w += code > 11904 && code < 64256 ? 2 : 1;
393
+ }
394
+ return w;
395
+ }
396
+ function buildDictCommand() {
397
+ const cmd = new Command2("dict").description("Manage dictionaries");
398
+ cmd.command("list").description("List dictionaries (\u2713 = locally available)").option("-c, --category <category>", "filter by category").option("--local-only", "show only locally downloaded dictionaries").action(async (opts) => {
399
+ const reg = await loadRegistry();
400
+ let entries = opts.category ? reg.filter((e) => e.category === opts.category) : reg;
401
+ if (opts.localOnly) {
402
+ const flags = await Promise.all(entries.map((e) => isLocallyAvailable(e.id)));
403
+ entries = entries.filter((_, i) => flags[i]);
404
+ }
405
+ await renderTable(entries);
406
+ console.log(chalk2.dim(`
407
+ ${entries.length} dictionaries`));
408
+ });
409
+ cmd.command("search <keyword>").description("Search the upstream registry by name/description/category/tags").option("-c, --category <category>", "restrict to category").option("-l, --language <language>", "restrict to language").action(async (keyword, opts) => {
410
+ const reg = await loadRegistry();
411
+ const matches = filterRegistry(reg, keyword, opts);
412
+ if (matches.length === 0) {
413
+ console.log(chalk2.yellow(`No dictionaries matched "${keyword}"`));
414
+ return;
415
+ }
416
+ await renderTable(matches);
417
+ console.log(chalk2.dim(`
418
+ ${matches.length} matches`));
419
+ });
420
+ cmd.command("pull <id>").description("Download an upstream dictionary into the local cache").action(async (id) => {
421
+ try {
422
+ const { words, size } = await pullDictionary(id);
423
+ console.log(chalk2.green(`Saved ${words.length} words (${fmtBytes(size)}) \u2192 dict ${chalk2.bold(id)}`));
424
+ } catch (err) {
425
+ console.error(chalk2.red(err.message));
426
+ process.exitCode = 1;
427
+ }
428
+ });
429
+ cmd.command("import <file>").description("Import a local qwerty-native JSON dictionary").requiredOption("--id <id>", "dictionary id (lowercase, digits, dashes)").action(async (file, opts) => {
430
+ try {
431
+ const { words, size } = await importDictionary(file, opts.id);
432
+ console.log(chalk2.green(`Imported ${words.length} words (${fmtBytes(size)}) as ${chalk2.bold(opts.id)}`));
433
+ } catch (err) {
434
+ console.error(chalk2.red(err.message));
435
+ process.exitCode = 1;
436
+ }
437
+ });
438
+ cmd.command("rm <id>").description("Remove a local dictionary").action(async (id) => {
439
+ const removed = await removeDictionary(id);
440
+ if (removed) console.log(chalk2.green(`Removed ${id}`));
441
+ else console.log(chalk2.yellow(`Nothing to remove for ${id}`));
442
+ });
443
+ return cmd;
444
+ }
445
+
446
+ // src/commands/practice.ts
447
+ import { Command as Command3 } from "commander";
448
+ import chalk3 from "chalk";
449
+ import { render } from "ink";
450
+ import { createElement } from "react";
451
+
452
+ // src/util/shuffle.ts
453
+ function shuffle(arr, rng = Math.random) {
454
+ const out = [...arr];
455
+ for (let i = out.length - 1; i > 0; i--) {
456
+ const j = Math.floor(rng() * (i + 1));
457
+ const tmp = out[i];
458
+ out[i] = out[j];
459
+ out[j] = tmp;
460
+ }
461
+ return out;
462
+ }
463
+ function mulberry32(seed) {
464
+ let t = seed >>> 0;
465
+ return () => {
466
+ t = t + 1831565813 >>> 0;
467
+ let r = Math.imul(t ^ t >>> 15, 1 | t);
468
+ r = r + Math.imul(r ^ r >>> 7, 61 | r) ^ r;
469
+ return ((r ^ r >>> 14) >>> 0) / 4294967296;
470
+ };
471
+ }
472
+
473
+ // src/domain/chapters.ts
474
+ function chunkChapters(words, chapterSize) {
475
+ if (chapterSize <= 0) throw new Error("chapterSize must be positive");
476
+ const chunks = [];
477
+ for (let i = 0; i < words.length; i += chapterSize) {
478
+ chunks.push(words.slice(i, i + chapterSize));
479
+ }
480
+ return chunks;
481
+ }
482
+ function chapterCount(totalWords, chapterSize) {
483
+ return Math.ceil(totalWords / chapterSize);
484
+ }
485
+ function buildPlaylist(chapter, mode, seed) {
486
+ if (mode === "random") {
487
+ const rng = seed === void 0 ? Math.random : mulberry32(seed);
488
+ return shuffle(chapter, rng);
489
+ }
490
+ return chapter;
491
+ }
492
+
493
+ // src/domain/stats.ts
494
+ import { z as z3 } from "zod";
495
+ var SessionRecordSchema = z3.object({
496
+ ts: z3.string(),
497
+ dictId: z3.string(),
498
+ chapter: z3.number().int().nonnegative(),
499
+ mode: z3.string(),
500
+ wordCount: z3.number().int().nonnegative(),
501
+ errors: z3.number().int().nonnegative(),
502
+ durationMs: z3.number().int().nonnegative(),
503
+ perWordErrors: z3.record(z3.string(), z3.number().int().nonnegative()).default({})
504
+ });
505
+ async function appendSession(record) {
506
+ await appendJsonl(paths.stats, record);
507
+ }
508
+ async function loadSessions() {
509
+ const rows = await readJsonl(paths.stats);
510
+ return rows.map((r) => SessionRecordSchema.safeParse(r)).filter((r) => r.success).map((r) => r.data);
511
+ }
512
+ function computeWPM(record) {
513
+ if (record.durationMs === 0) return 0;
514
+ const minutes = record.durationMs / 6e4;
515
+ return Math.round(record.wordCount / minutes * 10) / 10;
516
+ }
517
+ function accuracy(record) {
518
+ if (record.wordCount === 0) return 1;
519
+ const wordsWithErrors = Object.keys(record.perWordErrors).length;
520
+ return Math.max(0, Math.min(1, (record.wordCount - wordsWithErrors) / record.wordCount));
521
+ }
522
+ function dailyStreak(sessions, now = /* @__PURE__ */ new Date()) {
523
+ if (sessions.length === 0) return 0;
524
+ const days = /* @__PURE__ */ new Set();
525
+ for (const s of sessions) days.add(s.ts.slice(0, 10));
526
+ let streak = 0;
527
+ const cur = new Date(now);
528
+ while (true) {
529
+ const key = cur.toISOString().slice(0, 10);
530
+ if (!days.has(key)) break;
531
+ streak++;
532
+ cur.setUTCDate(cur.getUTCDate() - 1);
533
+ }
534
+ return streak;
535
+ }
536
+ var SPARK = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
537
+ function sparkline(values) {
538
+ if (values.length === 0) return "";
539
+ const max = Math.max(...values, 1);
540
+ const min = Math.min(...values, 0);
541
+ const range = Math.max(1, max - min);
542
+ return values.map((v) => {
543
+ const idx = Math.floor((v - min) / range * (SPARK.length - 1));
544
+ return SPARK[Math.max(0, Math.min(SPARK.length - 1, idx))];
545
+ }).join("");
546
+ }
547
+ function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
548
+ const out = [];
549
+ const byDay = /* @__PURE__ */ new Map();
550
+ for (const s of sessions) {
551
+ const key = s.ts.slice(0, 10);
552
+ const arr = byDay.get(key) ?? [];
553
+ arr.push(s);
554
+ byDay.set(key, arr);
555
+ }
556
+ const cur = new Date(now);
557
+ cur.setUTCDate(cur.getUTCDate() - (days - 1));
558
+ for (let i = 0; i < days; i++) {
559
+ const key = cur.toISOString().slice(0, 10);
560
+ const todays = byDay.get(key) ?? [];
561
+ if (todays.length === 0) {
562
+ out.push({ date: key, wpm: 0, accuracy: 0, sessions: 0 });
563
+ } else {
564
+ const wpm = todays.reduce((a, s) => a + computeWPM(s), 0) / todays.length;
565
+ const acc = todays.reduce((a, s) => a + accuracy(s), 0) / todays.length;
566
+ out.push({ date: key, wpm, accuracy: acc, sessions: todays.length });
567
+ }
568
+ cur.setUTCDate(cur.getUTCDate() + 1);
569
+ }
570
+ return out;
571
+ }
572
+
573
+ // src/domain/mistakes.ts
574
+ import { z as z4 } from "zod";
575
+ var MistakeBookSchema = z4.record(
576
+ z4.string(),
577
+ z4.object({
578
+ count: z4.number().int().nonnegative(),
579
+ lastSeen: z4.string(),
580
+ dictIds: z4.array(z4.string()).default([])
581
+ })
582
+ );
583
+ async function loadMistakes() {
584
+ const raw = await readJson(paths.mistakes);
585
+ if (!raw) return {};
586
+ const parsed = MistakeBookSchema.safeParse(raw);
587
+ if (!parsed.success) {
588
+ console.warn("Mistake book is corrupt; starting fresh");
589
+ return {};
590
+ }
591
+ return parsed.data;
592
+ }
593
+ async function saveMistakes(book) {
594
+ await writeJsonAtomic(paths.mistakes, book);
595
+ }
596
+ function bump(book, word, dictId, delta = 1) {
597
+ const prev = book[word] ?? { count: 0, lastSeen: (/* @__PURE__ */ new Date(0)).toISOString(), dictIds: [] };
598
+ const dictIds = prev.dictIds.includes(dictId) ? prev.dictIds : [...prev.dictIds, dictId];
599
+ return {
600
+ ...book,
601
+ [word]: {
602
+ count: prev.count + delta,
603
+ lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
604
+ dictIds
605
+ }
606
+ };
607
+ }
608
+ function topN(book, n) {
609
+ return Object.entries(book).sort((a, b) => b[1].count - a[1].count).slice(0, n);
610
+ }
611
+
612
+ // src/ui/screens/PracticeScreen.tsx
613
+ import { useState as useState2, useEffect as useEffect3, useRef as useRef3 } from "react";
614
+ import { Box as Box4, Text as Text5, useApp as useApp2, useInput as useInput2 } from "ink";
615
+
616
+ // src/ui/hooks/useWordLoop.ts
617
+ import { useEffect, useReducer, useRef, useState } from "react";
618
+ import { useInput, useApp } from "ink";
619
+
620
+ // src/domain/input-buffer.ts
621
+ function initialState(target) {
622
+ return { target, typed: "", errorsThisWord: 0 };
623
+ }
624
+ function reduce(state, ev) {
625
+ switch (ev.type) {
626
+ case "reset":
627
+ return { state: { ...state, typed: "" }, effect: "none" };
628
+ case "backspace": {
629
+ if (state.typed.length === 0) return { state, effect: "none" };
630
+ return { state: { ...state, typed: state.typed.slice(0, -1) }, effect: "none" };
631
+ }
632
+ case "char": {
633
+ const candidate = state.typed + ev.ch;
634
+ const targetUpToCandidate = [...state.target].slice(0, [...candidate].length).join("");
635
+ if (candidate === targetUpToCandidate) {
636
+ if (candidate.length === state.target.length) {
637
+ return { state: { ...state, typed: candidate }, effect: "correct" };
638
+ }
639
+ return { state: { ...state, typed: candidate }, effect: "progress" };
640
+ }
641
+ return {
642
+ state: { ...state, typed: "", errorsThisWord: state.errorsThisWord + 1 },
643
+ effect: "wrong"
644
+ };
645
+ }
646
+ }
647
+ }
648
+
649
+ // src/domain/session.ts
650
+ function startSession(playlist, now = Date.now()) {
651
+ if (playlist.length === 0) {
652
+ return { startedAt: now, results: [], current: null, finishedAt: now, playlist };
653
+ }
654
+ return {
655
+ startedAt: now,
656
+ results: [],
657
+ current: { wordIndex: 0, wordStartedAt: now, input: initialState(playlist[0].name) },
658
+ finishedAt: null,
659
+ playlist
660
+ };
661
+ }
662
+ function feedSession(session, ev, now = Date.now()) {
663
+ if (!session.current) return { session, effect: "none" };
664
+ const { state, effect } = reduce(session.current.input, ev);
665
+ if (effect === "correct") {
666
+ const finished = {
667
+ word: state.target,
668
+ errors: state.errorsThisWord,
669
+ durationMs: now - session.current.wordStartedAt
670
+ };
671
+ const nextIndex = session.current.wordIndex + 1;
672
+ const results = [...session.results, finished];
673
+ if (nextIndex >= session.playlist.length) {
674
+ return {
675
+ session: { ...session, results, current: null, finishedAt: now },
676
+ effect
677
+ };
678
+ }
679
+ return {
680
+ session: {
681
+ ...session,
682
+ results,
683
+ current: {
684
+ wordIndex: nextIndex,
685
+ wordStartedAt: now,
686
+ input: initialState(session.playlist[nextIndex].name)
687
+ }
688
+ },
689
+ effect
690
+ };
691
+ }
692
+ return {
693
+ session: {
694
+ ...session,
695
+ current: { ...session.current, input: state }
696
+ },
697
+ effect
698
+ };
699
+ }
700
+ function sessionSummary(session) {
701
+ const errors = session.results.reduce((a, r) => a + r.errors, 0);
702
+ const durationMs = (session.finishedAt ?? Date.now()) - session.startedAt;
703
+ const perWordErrors = {};
704
+ for (const r of session.results) {
705
+ if (r.errors > 0) perWordErrors[r.word] = (perWordErrors[r.word] ?? 0) + r.errors;
706
+ }
707
+ return { wordCount: session.results.length, errors, durationMs, perWordErrors };
708
+ }
709
+
710
+ // src/ui/hooks/useWordLoop.ts
711
+ function reducer(state, action) {
712
+ if (action.type === "start") {
713
+ return { session: startSession(action.playlist, action.now), lastEffect: null };
714
+ }
715
+ if (action.type === "event") {
716
+ if (action.key.backspace || action.key.delete) {
717
+ const r = feedSession(state.session, { type: "backspace" }, action.now);
718
+ return { session: r.session, lastEffect: r.effect };
719
+ }
720
+ if (action.input.length === 0) return state;
721
+ let session = state.session;
722
+ let lastEffect = state.lastEffect;
723
+ for (const c of action.input) {
724
+ const r = feedSession(session, { type: "char", ch: c }, action.now);
725
+ session = r.session;
726
+ lastEffect = r.effect;
727
+ if (session.finishedAt !== null) break;
728
+ }
729
+ return { session, lastEffect };
730
+ }
731
+ return state;
732
+ }
733
+ function useWordLoop({ playlist, onComplete, onTab, onEscape, enabled = true }) {
734
+ const [state, dispatch] = useReducer(reducer, void 0, () => ({
735
+ session: startSession(playlist, Date.now()),
736
+ lastEffect: null
737
+ }));
738
+ const completedRef = useRef(false);
739
+ const [tick, setTick] = useState(0);
740
+ const { exit } = useApp();
741
+ useInput(
742
+ (input, key) => {
743
+ if (key.ctrl && input === "c") {
744
+ exit();
745
+ return;
746
+ }
747
+ if (key.escape) {
748
+ onEscape?.();
749
+ return;
750
+ }
751
+ if (key.tab) {
752
+ onTab?.();
753
+ return;
754
+ }
755
+ if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return) return;
756
+ if (key.ctrl || key.meta) return;
757
+ const cleaned = [...input].filter((c) => {
758
+ const code = c.codePointAt(0);
759
+ return code === 32 || code >= 33 && code !== 127;
760
+ }).join("");
761
+ if (cleaned.length === 0) return;
762
+ dispatch({ type: "event", input: cleaned, key, now: Date.now() });
763
+ },
764
+ { isActive: enabled }
765
+ );
766
+ useEffect(() => {
767
+ if (state.session.finishedAt !== null && !completedRef.current) {
768
+ completedRef.current = true;
769
+ onComplete(state.session);
770
+ }
771
+ }, [state.session, onComplete]);
772
+ useEffect(() => {
773
+ if (state.session.finishedAt !== null) return;
774
+ const id = setInterval(() => setTick((t) => t + 1), 1e3);
775
+ return () => clearInterval(id);
776
+ }, [state.session.finishedAt]);
777
+ return { session: state.session, lastEffect: state.lastEffect, tick };
778
+ }
779
+
780
+ // src/ui/hooks/useAudio.ts
781
+ import { useEffect as useEffect2, useRef as useRef2 } from "react";
782
+
783
+ // src/infra/audio.ts
784
+ import { spawn } from "child_process";
785
+ import { mkdir as mkdir3, rename as rename2, writeFile as writeFile2 } from "fs/promises";
786
+ import { join as join3, dirname as dirname2 } from "path";
787
+ import { request as request2 } from "undici";
788
+ import PQueue from "p-queue";
789
+ var CANDIDATES = [
790
+ { kind: "afplay", cmd: "afplay", args: (f) => [f], supports: "both" },
791
+ { kind: "ffplay", cmd: "ffplay", args: (f) => ["-nodisp", "-autoexit", "-loglevel", "quiet", f], supports: "both" },
792
+ { kind: "mpg123", cmd: "mpg123", args: (f) => ["-q", f], supports: "mp3" },
793
+ { kind: "paplay", cmd: "paplay", args: (f) => [f], supports: "wav" },
794
+ { kind: "aplay", cmd: "aplay", args: (f) => ["-q", f], supports: "wav" },
795
+ {
796
+ kind: "powershell",
797
+ cmd: "powershell",
798
+ args: (f) => ["-NoProfile", "-Command", `(New-Object Media.SoundPlayer '${f}').PlaySync();`],
799
+ supports: "wav"
800
+ }
801
+ ];
802
+ var PRON_API = "https://dict.youdao.com/dictvoice?audio=";
803
+ var runtime = null;
804
+ async function isExecutable(cmd) {
805
+ return new Promise((resolve2) => {
806
+ const probe = spawn(cmd, ["--version"], { stdio: "ignore" });
807
+ probe.on("error", () => resolve2(false));
808
+ probe.on("exit", () => resolve2(true));
809
+ setTimeout(() => {
810
+ probe.kill();
811
+ resolve2(false);
812
+ }, 500);
813
+ });
814
+ }
815
+ async function detect() {
816
+ let wav = null;
817
+ let mp3 = null;
818
+ for (const p of CANDIDATES) {
819
+ if (!await isExecutable(p.cmd)) continue;
820
+ if (!wav && (p.supports === "wav" || p.supports === "both")) wav = p;
821
+ if (!mp3 && (p.supports === "mp3" || p.supports === "both")) mp3 = p;
822
+ if (wav && mp3) break;
823
+ }
824
+ return { wav, mp3 };
825
+ }
826
+ async function initAudio(disabledByConfig) {
827
+ if (runtime) return runtime;
828
+ if (disabledByConfig) {
829
+ runtime = {
830
+ disabled: true,
831
+ wavPlayer: null,
832
+ mp3Player: null,
833
+ warning: null,
834
+ keyQueue: new PQueue({ concurrency: 1 }),
835
+ feedbackQueue: new PQueue({ concurrency: 1 }),
836
+ pronQueue: new PQueue({ concurrency: 1 })
837
+ };
838
+ return runtime;
839
+ }
840
+ const { wav, mp3 } = await detect();
841
+ let warning = null;
842
+ if (!wav && !mp3) {
843
+ warning = "No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled.";
844
+ } else if (!mp3) {
845
+ warning = "No MP3 player found; word pronunciations will be skipped.";
846
+ }
847
+ runtime = {
848
+ disabled: !wav && !mp3,
849
+ wavPlayer: wav,
850
+ mp3Player: mp3,
851
+ warning,
852
+ keyQueue: new PQueue({ concurrency: 2 }),
853
+ feedbackQueue: new PQueue({ concurrency: 1 }),
854
+ pronQueue: new PQueue({ concurrency: 1 })
855
+ };
856
+ return runtime;
857
+ }
858
+ function spawnPlay(player, file) {
859
+ try {
860
+ const child = spawn(player.cmd, player.args(file), {
861
+ detached: true,
862
+ stdio: "ignore"
863
+ });
864
+ child.on("error", () => {
865
+ });
866
+ child.unref();
867
+ } catch {
868
+ }
869
+ }
870
+ function playFile(file, kind) {
871
+ if (!runtime || runtime.disabled) return;
872
+ const player = kind === "wav" ? runtime.wavPlayer : runtime.mp3Player;
873
+ if (!player) return;
874
+ spawnPlay(player, file);
875
+ }
876
+ function playKeystroke() {
877
+ if (!runtime || runtime.disabled) return;
878
+ if (runtime.keyQueue.size >= 2) return;
879
+ void runtime.keyQueue.add(async () => {
880
+ playFile(join3(packageAssetsDir(), "sounds", "key-default.wav"), "wav");
881
+ await new Promise((r) => setTimeout(r, 30));
882
+ });
883
+ }
884
+ function playCorrect() {
885
+ if (!runtime || runtime.disabled) return;
886
+ void runtime.feedbackQueue.add(async () => {
887
+ playFile(join3(packageAssetsDir(), "sounds", "correct.wav"), "wav");
888
+ await new Promise((r) => setTimeout(r, 50));
889
+ });
890
+ }
891
+ function playWrong() {
892
+ if (!runtime || runtime.disabled) return;
893
+ void runtime.feedbackQueue.add(async () => {
894
+ playFile(join3(packageAssetsDir(), "sounds", "wrong.wav"), "wav");
895
+ await new Promise((r) => setTimeout(r, 50));
896
+ });
897
+ }
898
+ async function downloadPronunciation(word, accent) {
899
+ const cacheFile = paths.audioCache(word, accent);
900
+ if (await exists(cacheFile)) return cacheFile;
901
+ await mkdir3(dirname2(cacheFile), { recursive: true });
902
+ const type = accent === "us" ? 2 : 1;
903
+ const url = `${PRON_API}${encodeURIComponent(word)}&type=${type}`;
904
+ try {
905
+ const res = await request2(url, { headersTimeout: 8e3, bodyTimeout: 2e4 });
906
+ if (res.statusCode >= 400) return null;
907
+ const buf = Buffer.from(await res.body.arrayBuffer());
908
+ if (buf.length < 1024) return null;
909
+ const tmp = `${cacheFile}.tmp`;
910
+ await writeFile2(tmp, buf);
911
+ await rename2(tmp, cacheFile);
912
+ return cacheFile;
913
+ } catch {
914
+ return null;
915
+ }
916
+ }
917
+ async function playPronunciation(word, accent) {
918
+ if (!runtime || runtime.disabled || !runtime.mp3Player) return;
919
+ await ensureDirs();
920
+ await runtime.pronQueue.add(async () => {
921
+ const file = await downloadPronunciation(word, accent);
922
+ if (file) playFile(file, "mp3");
923
+ });
924
+ }
925
+ async function prefetchPronunciation(word, accent) {
926
+ if (!runtime || runtime.disabled || !runtime.mp3Player) return;
927
+ await ensureDirs();
928
+ void runtime.pronQueue.add(async () => {
929
+ await downloadPronunciation(word, accent);
930
+ });
931
+ }
932
+ function audioWarning() {
933
+ return runtime?.warning ?? null;
934
+ }
935
+
936
+ // src/ui/hooks/useAudio.ts
937
+ function useAudio(opts) {
938
+ const initedRef = useRef2(false);
939
+ useEffect2(() => {
940
+ if (initedRef.current) return;
941
+ initedRef.current = true;
942
+ initAudio(!opts.enabled).then(() => {
943
+ const w = audioWarning();
944
+ if (w) opts.onWarning?.(w);
945
+ }).catch(() => void 0);
946
+ }, [opts]);
947
+ return {
948
+ keystroke: () => opts.enabled && playKeystroke(),
949
+ correct: () => opts.enabled && playCorrect(),
950
+ wrong: () => opts.enabled && playWrong(),
951
+ pronounce: (word) => {
952
+ if (!opts.enabled) return;
953
+ if (opts.autoplayPronunciation) void playPronunciation(word, opts.accent);
954
+ },
955
+ prefetch: (word) => {
956
+ if (!opts.enabled) return;
957
+ void prefetchPronunciation(word, opts.accent);
958
+ }
959
+ };
960
+ }
961
+
962
+ // src/ui/components/WordInput.tsx
963
+ import { Box, Text } from "ink";
964
+ import { jsx } from "react/jsx-runtime";
965
+ function WordInput({ state, hideTarget = false, flashError = false }) {
966
+ const target = [...state.target];
967
+ const typed = [...state.typed];
968
+ return /* @__PURE__ */ jsx(Box, { children: target.map((ch, i) => {
969
+ const t = typed[i];
970
+ if (t !== void 0) {
971
+ return /* @__PURE__ */ jsx(Text, { bold: true, color: "white", children: t }, i);
972
+ }
973
+ if (hideTarget) {
974
+ return /* @__PURE__ */ jsx(Text, { dimColor: true, children: "_" }, i);
975
+ }
976
+ return /* @__PURE__ */ jsx(Text, { dimColor: true, underline: true, color: flashError ? "red" : void 0, children: ch }, i);
977
+ }) });
978
+ }
979
+
980
+ // src/ui/components/Phonetic.tsx
981
+ import { Text as Text2 } from "ink";
982
+ import { jsxs } from "react/jsx-runtime";
983
+ function Phonetic({ word, accent }) {
984
+ const phon = accent === "us" ? word.usphone : word.ukphone;
985
+ if (!phon) return null;
986
+ return /* @__PURE__ */ jsxs(Text2, { dimColor: true, children: [
987
+ "/",
988
+ phon,
989
+ "/"
990
+ ] });
991
+ }
992
+
993
+ // src/ui/components/Translation.tsx
994
+ import { Box as Box2, Text as Text3 } from "ink";
995
+ import { jsx as jsx2 } from "react/jsx-runtime";
996
+ function Translation({ word }) {
997
+ if (!word.trans || word.trans.length === 0) return null;
998
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: word.trans.map((t, i) => /* @__PURE__ */ jsx2(Text3, { color: "cyan", children: t }, i)) });
999
+ }
1000
+
1001
+ // src/ui/components/Progress.tsx
1002
+ import { Box as Box3, Text as Text4 } from "ink";
1003
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1004
+ function Progress({ current, total, errors, elapsedMs }) {
1005
+ const minutes = elapsedMs / 6e4;
1006
+ const wpm = minutes > 0 ? Math.round(current / minutes) : 0;
1007
+ const seconds = Math.floor(elapsedMs / 1e3);
1008
+ return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsxs2(Text4, { children: [
1009
+ /* @__PURE__ */ jsx3(Text4, { dimColor: true, children: "progress " }),
1010
+ /* @__PURE__ */ jsx3(Text4, { bold: true, children: current }),
1011
+ /* @__PURE__ */ jsxs2(Text4, { dimColor: true, children: [
1012
+ "/",
1013
+ total
1014
+ ] }),
1015
+ /* @__PURE__ */ jsx3(Text4, { dimColor: true, children: " errors " }),
1016
+ /* @__PURE__ */ jsx3(Text4, { color: errors > 0 ? "red" : "green", children: errors }),
1017
+ /* @__PURE__ */ jsx3(Text4, { dimColor: true, children: " wpm " }),
1018
+ /* @__PURE__ */ jsx3(Text4, { children: wpm }),
1019
+ /* @__PURE__ */ jsx3(Text4, { dimColor: true, children: " time " }),
1020
+ /* @__PURE__ */ jsxs2(Text4, { children: [
1021
+ Math.floor(seconds / 60),
1022
+ ":",
1023
+ String(seconds % 60).padStart(2, "0")
1024
+ ] })
1025
+ ] }) });
1026
+ }
1027
+
1028
+ // src/ui/screens/PracticeScreen.tsx
1029
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1030
+ function PracticeScreen(props) {
1031
+ const [paused, setPaused] = useState2(false);
1032
+ const [finished, setFinished] = useState2(null);
1033
+ const [audioWarn, setAudioWarn] = useState2(null);
1034
+ const { exit } = useApp2();
1035
+ const audio = useAudio({
1036
+ enabled: props.audio.master,
1037
+ accent: props.accent,
1038
+ autoplayPronunciation: props.audio.autoplayPronunciation,
1039
+ onWarning: setAudioWarn
1040
+ });
1041
+ const lastEffectRef = useRef3(null);
1042
+ const lastIndexRef = useRef3(-1);
1043
+ const { session, lastEffect, tick } = useWordLoop({
1044
+ playlist: props.playlist,
1045
+ enabled: !paused && finished === null,
1046
+ onComplete: (s) => {
1047
+ setFinished(s);
1048
+ Promise.resolve(props.onSessionComplete(sessionSummary(s))).catch((err) => {
1049
+ console.error("Failed to persist session:", err);
1050
+ });
1051
+ },
1052
+ onEscape: () => setPaused((p) => !p),
1053
+ onTab: () => {
1054
+ const cur = session.current ? props.playlist[session.current.wordIndex] : void 0;
1055
+ if (cur) void audio.pronounce(cur.name);
1056
+ }
1057
+ });
1058
+ useEffect3(() => {
1059
+ if (lastEffect === null) return;
1060
+ if (lastEffect === lastEffectRef.current) return;
1061
+ lastEffectRef.current = lastEffect;
1062
+ if (lastEffect === "wrong" && props.audio.feedback) audio.wrong();
1063
+ if (lastEffect === "progress" && props.audio.keystroke) audio.keystroke();
1064
+ if (lastEffect === "correct") {
1065
+ if (props.audio.feedback) audio.correct();
1066
+ if (props.audio.keystroke) audio.keystroke();
1067
+ }
1068
+ }, [lastEffect, audio, props.audio.feedback, props.audio.keystroke]);
1069
+ useEffect3(() => {
1070
+ const idx = session.current?.wordIndex ?? -1;
1071
+ if (idx === -1) return;
1072
+ if (idx === lastIndexRef.current) return;
1073
+ lastIndexRef.current = idx;
1074
+ const cur = props.playlist[idx];
1075
+ const next = props.playlist[idx + 1];
1076
+ if (cur && props.audio.autoplayPronunciation) audio.pronounce(cur.name);
1077
+ if (next) audio.prefetch(next.name);
1078
+ }, [session.current?.wordIndex, audio, props.audio.autoplayPronunciation, props.playlist]);
1079
+ void tick;
1080
+ useInput2(
1081
+ (input) => {
1082
+ if (input === "q") exit();
1083
+ if (input === "r") setPaused(false);
1084
+ },
1085
+ { isActive: paused && finished === null }
1086
+ );
1087
+ useInput2(
1088
+ (input) => {
1089
+ if (input === "q") {
1090
+ props.onQuit?.();
1091
+ exit();
1092
+ }
1093
+ if (input === "n") {
1094
+ props.onAdvanceChapter?.();
1095
+ exit();
1096
+ }
1097
+ if (input === "m") {
1098
+ props.onReviewMistakes?.();
1099
+ exit();
1100
+ }
1101
+ },
1102
+ { isActive: finished !== null }
1103
+ );
1104
+ if (finished) {
1105
+ const summary = sessionSummary(finished);
1106
+ const minutes = summary.durationMs / 6e4;
1107
+ const wpm = minutes > 0 ? Math.round(summary.wordCount / minutes * 10) / 10 : 0;
1108
+ const errorWords = Object.keys(summary.perWordErrors).length;
1109
+ const acc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - errorWords) / summary.wordCount);
1110
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", paddingX: 1, paddingY: 1, borderStyle: "round", borderColor: "green", children: [
1111
+ /* @__PURE__ */ jsxs3(Text5, { bold: true, color: "green", children: [
1112
+ "Chapter complete: ",
1113
+ props.dictId,
1114
+ " chapter ",
1115
+ props.chapterNumber,
1116
+ "/",
1117
+ props.totalChapters
1118
+ ] }),
1119
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, flexDirection: "column", children: /* @__PURE__ */ jsxs3(Text5, { children: [
1120
+ /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: "words " }),
1121
+ /* @__PURE__ */ jsx4(Text5, { bold: true, children: summary.wordCount }),
1122
+ /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: " errors " }),
1123
+ /* @__PURE__ */ jsx4(Text5, { color: summary.errors > 0 ? "red" : "green", children: summary.errors }),
1124
+ /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: " wpm " }),
1125
+ /* @__PURE__ */ jsx4(Text5, { children: wpm }),
1126
+ /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: " accuracy " }),
1127
+ /* @__PURE__ */ jsxs3(Text5, { children: [
1128
+ Math.round(acc * 1e3) / 10,
1129
+ "%"
1130
+ ] })
1131
+ ] }) }),
1132
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: "[n] next chapter [m] review mistakes [q] quit" }) })
1133
+ ] });
1134
+ }
1135
+ if (paused) {
1136
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", paddingX: 1, borderStyle: "round", borderColor: "yellow", children: [
1137
+ /* @__PURE__ */ jsx4(Text5, { bold: true, color: "yellow", children: "Paused" }),
1138
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: "[r] resume [q] quit" }) })
1139
+ ] });
1140
+ }
1141
+ const currentWord = session.current ? props.playlist[session.current.wordIndex] : props.playlist[props.playlist.length - 1];
1142
+ const inputState = session.current?.input ?? initialState("");
1143
+ const elapsedMs = Date.now() - session.startedAt;
1144
+ const completed = session.results.length;
1145
+ return /* @__PURE__ */ jsxs3(Box4, { flexDirection: "column", paddingX: 1, paddingY: 0, children: [
1146
+ /* @__PURE__ */ jsxs3(Box4, { children: [
1147
+ /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: props.dictId }),
1148
+ /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
1149
+ " \xB7 chapter ",
1150
+ props.chapterNumber,
1151
+ "/",
1152
+ props.totalChapters
1153
+ ] }),
1154
+ /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
1155
+ " \xB7 mode ",
1156
+ props.mode
1157
+ ] }),
1158
+ /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
1159
+ " \xB7 accent ",
1160
+ props.accent
1161
+ ] })
1162
+ ] }),
1163
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(WordInput, { state: inputState, hideTarget: props.mode === "dictation", flashError: lastEffect === "wrong" }) }),
1164
+ currentWord && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Phonetic, { word: currentWord, accent: props.accent }) }),
1165
+ currentWord && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Translation, { word: currentWord }) }),
1166
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Progress, { current: completed, total: props.playlist.length, errors: inputState.errorsThisWord, elapsedMs }) }),
1167
+ /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text5, { dimColor: true, children: "[Esc] pause [Tab] replay pronunciation [Ctrl+C] quit" }) }),
1168
+ audioWarn && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text5, { color: "yellow", children: audioWarn }) })
1169
+ ] });
1170
+ }
1171
+
1172
+ // src/commands/practice.ts
1173
+ var MODES = ["order", "dictation", "review", "random", "loop"];
1174
+ function isMode(v) {
1175
+ return MODES.includes(v);
1176
+ }
1177
+ async function runChapter(opts) {
1178
+ let outcome = "done";
1179
+ const onSessionComplete = async (summary) => {
1180
+ const rec = {
1181
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1182
+ dictId: opts.dictId,
1183
+ chapter: opts.chapterIndex,
1184
+ mode: opts.mode,
1185
+ wordCount: summary.wordCount,
1186
+ errors: summary.errors,
1187
+ durationMs: summary.durationMs,
1188
+ perWordErrors: summary.perWordErrors
1189
+ };
1190
+ await appendSession(rec);
1191
+ if (Object.keys(summary.perWordErrors).length > 0) {
1192
+ let book = await loadMistakes();
1193
+ for (const [word, n] of Object.entries(summary.perWordErrors)) {
1194
+ book = bump(book, word, opts.dictId, n);
1195
+ }
1196
+ await saveMistakes(book);
1197
+ }
1198
+ };
1199
+ const { waitUntilExit } = render(
1200
+ createElement(PracticeScreen, {
1201
+ dictId: opts.dictId,
1202
+ chapterNumber: opts.chapterIndex + 1,
1203
+ totalChapters: opts.totalChapters,
1204
+ playlist: opts.playlist,
1205
+ mode: opts.mode,
1206
+ accent: opts.accent,
1207
+ audio: opts.audio,
1208
+ onSessionComplete,
1209
+ onAdvanceChapter: () => {
1210
+ outcome = "next";
1211
+ },
1212
+ onReviewMistakes: () => {
1213
+ outcome = "review";
1214
+ },
1215
+ onQuit: () => {
1216
+ outcome = "quit";
1217
+ }
1218
+ })
1219
+ );
1220
+ await waitUntilExit();
1221
+ return outcome;
1222
+ }
1223
+ async function runPractice(dictIdArg, options) {
1224
+ if (!process.stdout.isTTY) {
1225
+ console.error(chalk3.red("Practice requires an interactive TTY."));
1226
+ process.exitCode = 1;
1227
+ return;
1228
+ }
1229
+ const cfg = await loadConfig();
1230
+ const dictId = dictIdArg ?? cfg.defaultDict;
1231
+ if (!dictId) {
1232
+ console.error(chalk3.red("No dictionary specified. Pass an id or set config.defaultDict."));
1233
+ process.exitCode = 1;
1234
+ return;
1235
+ }
1236
+ const mode = options.mode ?? cfg.defaultMode;
1237
+ if (!isMode(mode)) {
1238
+ console.error(chalk3.red(`Invalid mode "${mode}". Valid: ${MODES.join(", ")}`));
1239
+ process.exitCode = 1;
1240
+ return;
1241
+ }
1242
+ const words = await ensureDictionary(dictId);
1243
+ const chapters = chunkChapters(words, cfg.chapterSize);
1244
+ if (chapters.length === 0) {
1245
+ console.error(chalk3.red(`Dictionary ${dictId} is empty.`));
1246
+ process.exitCode = 1;
1247
+ return;
1248
+ }
1249
+ let idx = Math.max(0, Math.min(chapters.length - 1, Number(options.chapter ?? 1) - 1));
1250
+ const total = chapters.length;
1251
+ const accent = cfg.accent;
1252
+ const audioCfg = {
1253
+ master: cfg.sounds.master,
1254
+ keystroke: cfg.sounds.keystroke,
1255
+ feedback: cfg.sounds.feedback,
1256
+ autoplayPronunciation: cfg.autoplayPronunciation
1257
+ };
1258
+ if (mode === "review") {
1259
+ const book = await loadMistakes();
1260
+ const reviewWords = words.filter((w) => book[w.name]?.count);
1261
+ if (reviewWords.length === 0) {
1262
+ console.log(chalk3.yellow("Mistake book is empty for this dictionary. Practice some chapters first."));
1263
+ return;
1264
+ }
1265
+ await runChapter({
1266
+ dictId,
1267
+ chapterIndex: 0,
1268
+ totalChapters: 1,
1269
+ playlist: reviewWords.slice(0, cfg.chapterSize),
1270
+ mode,
1271
+ accent,
1272
+ audio: audioCfg
1273
+ });
1274
+ return;
1275
+ }
1276
+ while (idx < chapters.length) {
1277
+ const chapter = chapters[idx];
1278
+ const playlist = buildPlaylist(chapter, mode);
1279
+ const result = await runChapter({
1280
+ dictId,
1281
+ chapterIndex: idx,
1282
+ totalChapters: total,
1283
+ playlist,
1284
+ mode,
1285
+ accent,
1286
+ audio: audioCfg
1287
+ });
1288
+ if (result === "next") {
1289
+ if (mode === "loop") continue;
1290
+ idx++;
1291
+ } else if (result === "review") {
1292
+ const book = await loadMistakes();
1293
+ const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
1294
+ if (reviewWords.length === 0) {
1295
+ console.log(chalk3.yellow("No mistakes to review."));
1296
+ return;
1297
+ }
1298
+ await runChapter({
1299
+ dictId,
1300
+ chapterIndex: 0,
1301
+ totalChapters: 1,
1302
+ playlist: reviewWords,
1303
+ mode: "order",
1304
+ accent,
1305
+ audio: audioCfg
1306
+ });
1307
+ return;
1308
+ } else {
1309
+ return;
1310
+ }
1311
+ }
1312
+ console.log(chalk3.green(`Reached end of ${dictId} (${total} chapters). Nice work.`));
1313
+ void chapterCount;
1314
+ }
1315
+ function buildPracticeCommand() {
1316
+ return new Command3("practice").argument("[dictId]", "dictionary id; falls back to config.defaultDict").description("Start a typing practice session").option("-c, --chapter <n>", "chapter number (1-based)", "1").option("-m, --mode <mode>", "order | dictation | review | random | loop").action(async (dictIdArg, options) => {
1317
+ await runPractice(dictIdArg, options);
1318
+ });
1319
+ }
1320
+
1321
+ // src/commands/stats.ts
1322
+ import { Command as Command4 } from "commander";
1323
+ import chalk4 from "chalk";
1324
+ function buildStatsCommand() {
1325
+ return new Command4("stats").description("Show practice history and trends").option("-d, --days <n>", "window size for trend (default 14)", "14").option("--top <n>", "how many top mistakes to show (default 10)", "10").action(async (opts) => {
1326
+ const days = Math.max(1, Number(opts.days) || 14);
1327
+ const topCount = Math.max(1, Number(opts.top) || 10);
1328
+ const sessions = await loadSessions();
1329
+ const book = await loadMistakes();
1330
+ if (sessions.length === 0) {
1331
+ console.log(chalk4.yellow("No practice history yet. Run `qwerty practice <dict>` to get started."));
1332
+ return;
1333
+ }
1334
+ const buckets = dailyBuckets(sessions, days);
1335
+ const streak = dailyStreak(sessions);
1336
+ const totalWords = sessions.reduce((a, s) => a + s.wordCount, 0);
1337
+ const totalErrors = sessions.reduce((a, s) => a + s.errors, 0);
1338
+ const totalMs = sessions.reduce((a, s) => a + s.durationMs, 0);
1339
+ const firstTryWords = sessions.reduce(
1340
+ (a, s) => a + (s.wordCount - Object.keys(s.perWordErrors).length),
1341
+ 0
1342
+ );
1343
+ const overallWpm = totalMs > 0 ? Math.round(totalWords / (totalMs / 6e4) * 10) / 10 : 0;
1344
+ const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
1345
+ console.log(chalk4.bold("\nLifetime"));
1346
+ console.log(` ${chalk4.dim("sessions")} ${sessions.length} ${chalk4.dim("words")} ${totalWords} ${chalk4.dim("errors")} ${totalErrors}`);
1347
+ console.log(` ${chalk4.dim("avg wpm")} ${overallWpm} ${chalk4.dim("avg accuracy")} ${Math.round(overallAcc * 1e3) / 10}% ${chalk4.dim("streak")} ${chalk4.bold(streak)}d`);
1348
+ console.log(chalk4.bold(`
1349
+ Last ${days} days`));
1350
+ console.log(` ${chalk4.dim("wpm ")} ${sparkline(buckets.map((b) => b.wpm))} ${chalk4.dim("max")} ${Math.round(Math.max(...buckets.map((b) => b.wpm)))}`);
1351
+ console.log(` ${chalk4.dim("accuracy")} ${sparkline(buckets.map((b) => b.accuracy * 100))} ${chalk4.dim("range")} ${Math.round(Math.min(...buckets.map((b) => b.accuracy * 100)))}-${Math.round(Math.max(...buckets.map((b) => b.accuracy * 100)))}%`);
1352
+ console.log(` ${chalk4.dim("sessions")} ${sparkline(buckets.map((b) => b.sessions))}`);
1353
+ const recent = sessions.slice(-5).reverse();
1354
+ console.log(chalk4.bold("\nLast 5 sessions"));
1355
+ for (const s of recent) {
1356
+ const wpm = computeWPM(s);
1357
+ const acc = Math.round(accuracy(s) * 1e3) / 10;
1358
+ console.log(
1359
+ ` ${chalk4.dim(s.ts.replace("T", " ").slice(0, 16))} ${chalk4.cyan(s.dictId.padEnd(14))} ch${String(s.chapter + 1).padStart(3)} ${s.mode.padEnd(9)} ${String(s.wordCount).padStart(3)}w ${s.errors}err ${wpm}wpm ${acc}%`
1360
+ );
1361
+ }
1362
+ const top = topN(book, topCount);
1363
+ if (top.length > 0) {
1364
+ console.log(chalk4.bold(`
1365
+ Top ${top.length} mistakes`));
1366
+ for (const [word, entry] of top) {
1367
+ console.log(` ${chalk4.red(String(entry.count).padStart(3))} ${chalk4.bold(word.padEnd(20))} ${chalk4.dim(entry.dictIds.join(", "))}`);
1368
+ }
1369
+ } else {
1370
+ console.log(chalk4.bold("\nTop mistakes"));
1371
+ console.log(chalk4.dim(" none \u2014 keep going"));
1372
+ }
1373
+ console.log();
1374
+ });
1375
+ }
1376
+
1377
+ // src/commands/word.ts
1378
+ import { Command as Command5 } from "commander";
1379
+ import chalk5 from "chalk";
1380
+ import { readdir } from "fs/promises";
1381
+ async function listLocalDictIds() {
1382
+ try {
1383
+ const files = await readdir(paths.dictsDir);
1384
+ return files.filter((f) => f.endsWith(".json") && !f.endsWith(".meta.json")).map((f) => f.replace(/\.json$/, ""));
1385
+ } catch {
1386
+ return [];
1387
+ }
1388
+ }
1389
+ function buildWordCommand() {
1390
+ return new Command5("word").argument("<keyword>").description("Look up a word across local dictionaries").option("--exact", "require exact (case-insensitive) match").action(async (keyword, opts) => {
1391
+ const q = keyword.toLowerCase();
1392
+ const ids = await listLocalDictIds();
1393
+ if (ids.length === 0) {
1394
+ console.log(chalk5.yellow("No local dictionaries. Run `qwerty dict pull <id>` first."));
1395
+ return;
1396
+ }
1397
+ const hits = [];
1398
+ for (const id of ids) {
1399
+ const words = await loadLocalDictionary(id);
1400
+ if (!words) continue;
1401
+ for (const w of words) {
1402
+ const wl = w.name.toLowerCase();
1403
+ const match = opts.exact ? wl === q : wl.includes(q);
1404
+ if (match) hits.push({ dictId: id, word: w });
1405
+ }
1406
+ }
1407
+ if (hits.length === 0) {
1408
+ console.log(chalk5.yellow(`No matches for "${keyword}" in ${ids.length} local dictionaries`));
1409
+ return;
1410
+ }
1411
+ const byName = /* @__PURE__ */ new Map();
1412
+ for (const h of hits) {
1413
+ const arr = byName.get(h.word.name) ?? [];
1414
+ arr.push(h);
1415
+ byName.set(h.word.name, arr);
1416
+ }
1417
+ const book = await loadMistakes();
1418
+ for (const [name, group] of byName) {
1419
+ const first = group[0].word;
1420
+ console.log();
1421
+ console.log(chalk5.bold.white(name));
1422
+ const us = first.usphone ? `US /${first.usphone}/` : "";
1423
+ const uk = first.ukphone ? `UK /${first.ukphone}/` : "";
1424
+ if (us || uk) console.log(chalk5.dim(` ${[us, uk].filter(Boolean).join(" ")}`));
1425
+ for (const t of first.trans ?? []) console.log(chalk5.cyan(` \xB7 ${t}`));
1426
+ const sources = await Promise.all(
1427
+ group.map(async (h) => {
1428
+ const reg = await findEntry(h.dictId);
1429
+ return reg?.name ?? h.dictId;
1430
+ })
1431
+ );
1432
+ console.log(chalk5.dim(` in: ${sources.join(", ")}`));
1433
+ const mistake = book[name];
1434
+ if (mistake) {
1435
+ console.log(chalk5.dim(` mistakes: ${mistake.count} (last ${mistake.lastSeen.slice(0, 10)})`));
1436
+ }
1437
+ }
1438
+ console.log();
1439
+ });
1440
+ }
1441
+
1442
+ // src/commands/menu.ts
1443
+ import { render as render2 } from "ink";
1444
+ import { createElement as createElement2 } from "react";
1445
+ import chalk6 from "chalk";
1446
+
1447
+ // src/ui/screens/MainMenu.tsx
1448
+ import { useState as useState3 } from "react";
1449
+ import { Box as Box5, Text as Text6, useApp as useApp3, useInput as useInput3 } from "ink";
1450
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1451
+ var ITEMS = [
1452
+ { key: "p", label: "Practice", hint: "qwerty practice <dictId>" },
1453
+ { key: "d", label: "Dictionaries", hint: "qwerty dict list / search / pull" },
1454
+ { key: "w", label: "Word lookup", hint: "qwerty word <keyword>" },
1455
+ { key: "s", label: "Stats", hint: "qwerty stats" },
1456
+ { key: "c", label: "Config", hint: "qwerty config list" },
1457
+ { key: "q", label: "Quit", hint: "Ctrl+C also exits" }
1458
+ ];
1459
+ function MainMenu({
1460
+ defaultDict,
1461
+ onAction
1462
+ }) {
1463
+ const [selected, setSelected] = useState3(0);
1464
+ const { exit } = useApp3();
1465
+ useInput3((input, key) => {
1466
+ if (key.upArrow) setSelected((i) => (i - 1 + ITEMS.length) % ITEMS.length);
1467
+ if (key.downArrow) setSelected((i) => (i + 1) % ITEMS.length);
1468
+ if (key.return) {
1469
+ const item = ITEMS[selected];
1470
+ if (item.key === "q") {
1471
+ exit();
1472
+ return;
1473
+ }
1474
+ if (item.key === "p") onAction("practice");
1475
+ }
1476
+ for (const it of ITEMS) {
1477
+ if (input === it.key) {
1478
+ if (it.key === "q") {
1479
+ exit();
1480
+ return;
1481
+ }
1482
+ if (it.key === "p") onAction("practice");
1483
+ }
1484
+ }
1485
+ });
1486
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
1487
+ /* @__PURE__ */ jsx5(Text6, { bold: true, color: "cyan", children: "qwerty-cli" }),
1488
+ /* @__PURE__ */ jsx5(Text6, { dimColor: true, children: "typing practice for English vocabulary, in your terminal" }),
1489
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, flexDirection: "column", children: ITEMS.map((it, i) => /* @__PURE__ */ jsxs4(Text6, { children: [
1490
+ i === selected ? chalkArrow() : " ",
1491
+ /* @__PURE__ */ jsxs4(Text6, { bold: i === selected, color: i === selected ? "cyan" : void 0, children: [
1492
+ "[",
1493
+ it.key,
1494
+ "]"
1495
+ ] }),
1496
+ " ",
1497
+ /* @__PURE__ */ jsx5(Text6, { bold: i === selected, children: it.label.padEnd(14) }),
1498
+ /* @__PURE__ */ jsx5(Text6, { dimColor: true, children: it.hint })
1499
+ ] }, it.key)) }),
1500
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
1501
+ "default dict: ",
1502
+ defaultDict ?? "none \u2014 set with `qwerty config set defaultDict <id>`"
1503
+ ] }) }),
1504
+ /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text6, { dimColor: true, children: "\u2191/\u2193 navigate \xB7 Enter to select \xB7 letters jump" }) })
1505
+ ] });
1506
+ }
1507
+ function chalkArrow() {
1508
+ return "> ";
1509
+ }
1510
+
1511
+ // src/commands/menu.ts
1512
+ async function runMainMenu() {
1513
+ if (!process.stdout.isTTY) {
1514
+ console.log("qwerty-cli \u2014 run `qwerty --help` for available commands.");
1515
+ return;
1516
+ }
1517
+ const cfg = await loadConfig();
1518
+ let chosen = null;
1519
+ const { waitUntilExit, unmount } = render2(
1520
+ createElement2(MainMenu, {
1521
+ defaultDict: cfg.defaultDict,
1522
+ onAction: (action) => {
1523
+ chosen = action;
1524
+ unmount();
1525
+ }
1526
+ })
1527
+ );
1528
+ await waitUntilExit();
1529
+ if (chosen === "practice") {
1530
+ if (cfg.defaultDict) {
1531
+ console.log(chalk6.dim(`Tip: \`qwerty practice ${cfg.defaultDict}\` skips this menu next time.`));
1532
+ await runPractice(cfg.defaultDict, {});
1533
+ } else {
1534
+ console.log(
1535
+ chalk6.yellow(
1536
+ "No default dictionary set. Run `qwerty dict pull <id>` then `qwerty config set defaultDict <id>`."
1537
+ )
1538
+ );
1539
+ }
1540
+ }
1541
+ }
1542
+
1543
+ // src/cli.ts
1544
+ var program = new Command6();
1545
+ program.name("qwerty").description("Terminal clone of qwerty-learner \u2014 typing practice for English vocabulary").version("0.1.0");
1546
+ program.addCommand(buildPracticeCommand());
1547
+ program.addCommand(buildDictCommand());
1548
+ program.addCommand(buildWordCommand());
1549
+ program.addCommand(buildStatsCommand());
1550
+ program.addCommand(buildConfigCommand());
1551
+ program.action(async () => {
1552
+ await runMainMenu();
1553
+ });
1554
+ program.parseAsync(process.argv).catch((err) => {
1555
+ console.error(err instanceof Error ? err.message : err);
1556
+ process.exit(1);
1557
+ });
1558
+ //# sourceMappingURL=cli.js.map