genshin-persona 3.7.0 → 3.8.1

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 (31) hide show
  1. package/package.json +3 -2
  2. package/scripts/genshin.ts +11 -420
  3. package/scripts/spinner.ts +19 -4
  4. package/src/models/GenshinContext.ts +15 -0
  5. package/src/services/cli/commands/genshinCommand.ts +48 -0
  6. package/src/services/cli/commands/languageCommand.ts +78 -0
  7. package/src/services/cli/commands/linesCommand.ts +25 -0
  8. package/src/services/cli/commands/muteCommand.ts +15 -0
  9. package/src/services/cli/commands/pinCommand.ts +34 -0
  10. package/src/services/cli/commands/replyCommand.ts +31 -0
  11. package/src/services/cli/commands/rosterCommand.ts +14 -0
  12. package/src/services/cli/commands/setupCommand.ts +30 -0
  13. package/src/services/cli/commands/statusCommand.ts +15 -0
  14. package/src/services/cli/commands/teardownCommand.ts +21 -0
  15. package/src/services/cli/commands/todayCommand.ts +16 -0
  16. package/src/services/cli/commands/uncardedCommand.ts +14 -0
  17. package/src/services/cli/commands/unmuteCommand.ts +15 -0
  18. package/src/services/cli/commands/unpinCommand.ts +26 -0
  19. package/src/services/cli/commands/untranslatedCommand.ts +26 -0
  20. package/src/services/cli/commands/unverbedCommand.ts +14 -0
  21. package/src/services/cli/commands/useCommand.ts +27 -0
  22. package/src/services/cli/commands/voiceCommand.ts +115 -0
  23. package/src/services/cli/commands/volumeCommand.ts +24 -0
  24. package/src/services/cli/getCurrentCharacter.ts +23 -0
  25. package/src/services/cli/getRosterLine.ts +6 -0
  26. package/src/services/cli/getStatusReport.ts +46 -0
  27. package/src/services/cli/printCard.ts +16 -0
  28. package/src/services/cli/printQueue.ts +20 -0
  29. package/src/services/cli/readGenshinContext.ts +19 -0
  30. package/src/services/cli/readNamedCharacter.ts +16 -0
  31. package/src/services/cli/switchSessionCharacter.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genshin-persona",
3
- "version": "3.7.0",
3
+ "version": "3.8.1",
4
4
  "description": "A Claude Code plugin that speaks as the Genshin Impact character whose birthday is nearest: an answer opens with one spoken line in the character's voice and the rest is plain, a joke or a hello is spoken whole, and every line is read aloud in the character's own cloned voice as the reply is written.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -50,10 +50,11 @@
50
50
  "dependencies": {
51
51
  "@typesafe-ai/sdk": "^0.6.0",
52
52
  "@wasm-audio-decoders/ogg-vorbis": "^0.1.20",
53
+ "citty": "^0.2.2",
53
54
  "genshin-db": "^5.2.14"
54
55
  },
55
56
  "engines": {
56
57
  "node": ">=26.10.0"
57
58
  },
58
- "gitHead": "7fd06688d06adf6dafff4bd869cd7a33d8028688"
59
+ "gitHead": "1cc0b24081a5fd56ec0c57b784f978f1ad0df0f4"
59
60
  }
@@ -1,424 +1,15 @@
1
- import type { CardedCharacter } from "#src/models/CardedCharacter";
2
- import type { Character } from "#src/models/Character";
3
-
4
- import { GenshinVerb, GenshinVerbs } from "#src/models/GenshinVerb";
5
- import { VoiceRequestType } from "#src/models/VoiceRequestType";
6
- import { VoiceStatus } from "#src/models/VoiceStatus";
7
- import { checkHasWeights } from "#src/services/checkHasWeights";
8
- import { checkIsMuted } from "#src/services/checkIsMuted";
9
- import { checkIsOwnVoiceLine } from "#src/services/checkIsOwnVoiceLine";
10
- import { checkIsPluginHookEntry } from "#src/services/checkIsPluginHookEntry";
11
- import { checkIsPluginSpinner } from "#src/services/checkIsPluginSpinner";
12
- import { checkIsPluginStatusLine } from "#src/services/checkIsPluginStatusLine";
13
- import { checkIsRuntimeInstalled } from "#src/services/checkIsRuntimeInstalled";
14
- import { checkIsVoiceLanguage } from "#src/services/checkIsVoiceLanguage";
15
- import { checkIsVolume } from "#src/services/checkIsVolume";
16
- import { connectVoiceServer } from "#src/services/connectVoiceServer";
17
- import {
18
- CARD_DETAIL_SEPARATOR,
19
- DEFAULT_LANGUAGE,
20
- MAX_VOLUME,
21
- MODELS_DIRECTORY,
22
- RUNTIME_MANIFEST_PATH,
23
- SESSION_ID_ENVIRONMENT_VARIABLE,
24
- STATUS_LAUNCHER_PATH,
25
- STATUS_SCRIPT_PATH,
26
- VOICE_CPU_DEVICE,
27
- VOICE_LOG_PATH,
28
- VOICE_PROOF_TEXT,
29
- VOICE_STATUS_SEPARATOR,
30
- VoiceLanguageNameMap,
31
- } from "#src/services/constants";
32
- import { createVoiceProgressPrinter } from "#src/services/createVoiceProgressPrinter";
33
- import { createVoiceSynthesizer } from "#src/services/createVoiceSynthesizer";
34
- import { deletePin } from "#src/services/deletePin";
35
- import { deleteVoiceDevice } from "#src/services/deleteVoiceDevice";
36
- import { deleteVoiceState } from "#src/services/deleteVoiceState";
37
- import { findCharacterByName } from "#src/services/findCharacterByName";
38
- import { formatCard } from "#src/services/formatCard";
39
- import { getCanonicalLanguage } from "#src/services/getCanonicalLanguage";
40
- import { getCard } from "#src/services/getCard";
41
- import { getLanguageDisplayName } from "#src/services/getLanguageDisplayName";
42
- import { getSettingsWithoutPluginEntries } from "#src/services/getSettingsWithoutPluginEntries";
43
- import { getSettingsWithStatusLine } from "#src/services/getSettingsWithStatusLine";
44
- import { getSpeechRequest } from "#src/services/getSpeechRequest";
45
- import { getWarmRequest } from "#src/services/getWarmRequest";
46
- import { installVoiceRuntime } from "#src/services/installVoiceRuntime";
47
- import { pickCurrentCharacter } from "#src/services/pickCurrentCharacter";
48
- import { readCardedRoster } from "#src/services/readCardedRoster";
49
- import { readCharacterReference } from "#src/services/readCharacterReference";
1
+ import { GenshinVerbs } from "#src/models/GenshinVerb";
2
+ import { genshinCommand } from "#src/services/cli/commands/genshinCommand";
50
3
  import { readInterfaceLanguage } from "#src/services/readInterfaceLanguage";
51
- import { readLanguageNames } from "#src/services/readLanguageNames";
52
4
  import { readLocalization } from "#src/services/readLocalization";
53
- import { readPersonaCard } from "#src/services/readPersonaCard";
54
- import { readPin } from "#src/services/readPin";
55
- import { readReplyLanguage } from "#src/services/readReplyLanguage";
56
- import { readRoster } from "#src/services/readRoster";
57
- import { readSpinner } from "#src/services/readSpinner";
58
- import { readUserSettings } from "#src/services/readUserSettings";
59
- import { readVoiceDevice } from "#src/services/readVoiceDevice";
60
- import { readVoiceLanguage } from "#src/services/readVoiceLanguage";
61
- import { readVoiceLines } from "#src/services/readVoiceLines";
62
- import { readVoiceRuntime } from "#src/services/readVoiceRuntime";
63
- import { readVolume } from "#src/services/readVolume";
64
- import { recordSessionCharacter } from "#src/services/recordSessionCharacter";
65
- import { resolveSessionCharacter } from "#src/services/resolveSessionCharacter";
66
- import { sendVoiceRequest } from "#src/services/sendVoiceRequest";
67
- import { setMuted } from "#src/services/setMuted";
68
- import { writeInterfaceLanguage } from "#src/services/writeInterfaceLanguage";
69
- import { writeLauncher } from "#src/services/writeLauncher";
70
- import { writePin } from "#src/services/writePin";
71
- import { writeReplyLanguage } from "#src/services/writeReplyLanguage";
72
- import { writeSessionSpinner } from "#src/services/writeSessionSpinner";
73
- import { writeSpeakHook } from "#src/services/writeSpeakHook";
74
- import { writeSpinner } from "#src/services/writeSpinner";
75
- import { writeUserSettings } from "#src/services/writeUserSettings";
76
- import { writeVoiceLanguage } from "#src/services/writeVoiceLanguage";
77
- import { writeVolume } from "#src/services/writeVolume";
78
-
79
- const [verb, ...nameParts] = process.argv.slice(2);
80
- const name = nameParts.join(" ");
81
- const language = readInterfaceLanguage();
82
- const roster = readRoster(language);
83
- const { locale, strings } = await readLocalization(language);
84
- // A choice a verb offers is a list in the interface language's own punctuation, which the runtime knows
85
- const listFormat = new Intl.ListFormat(locale, { type: "disjunction" });
86
- const today = Temporal.Now.plainDateISO();
87
- const getRosterLine = ({ birthday, displayElement, displayName, region, title, version }: Character) =>
88
- [displayName, title, displayElement, region, birthday, `v${version}`].filter(Boolean).join(CARD_DETAIL_SEPARATOR);
89
- // An authoring queue prints the characters that meet it newest first, so a patch's arrivals are at the top
90
- const printQueue = async (checkIsQueued: (cardedCharacter: CardedCharacter) => boolean) => {
91
- const cardedRoster = await readCardedRoster(roster);
92
- for (const { character } of cardedRoster
93
- .filter((cardedCharacter) => checkIsQueued(cardedCharacter))
94
- .toSorted(
95
- ({ character: a }, { character: b }) =>
96
- b.version.localeCompare(a.version, undefined, { numeric: true }) || a.name.localeCompare(b.name),
97
- ))
98
- console.log(getRosterLine(character));
99
- };
100
- // The character a verb names by its argument, or nothing once the argument names none — which every such verb answers
101
- // The same way, so the refusal and its exit code are said here rather than at each
102
- const readNamedCharacter = () => {
103
- const character = findCharacterByName(roster, name);
104
- if (!character) {
105
- console.error(strings.noCharacterNamed(name));
106
- process.exitCode = 1;
107
- }
108
-
109
- return character;
110
- };
111
- // Empty from a shell, where the tool set nothing
112
- const sessionId = process.env[SESSION_ID_ENVIRONMENT_VARIABLE] ?? "";
113
- // The language is read again rather than closed over, because the `language` verb changes it and then prints the
114
- // Card: the card a verb prints is always in the language in force at the end of that verb
115
- const printCard = async (character: Character) => {
116
- const localization = await readLocalization(readInterfaceLanguage());
117
- const card = getCard(character, today, localization, await readPersonaCard(character.name));
118
- console.log(formatCard(card));
119
- };
120
- // This session's character when run inside one, else the pin, else a fresh pick: the resolution the start hook runs,
121
- // So `today` answers who is speaking now. Under the lore pick a fresh ask may answer differently, which is the point
122
- // Of it, and a fresh ask the tier did not answer says so beside the birthday pick that stood in
123
- const getCurrentCharacter = async () => {
124
- const pin = readPin();
125
- if (pin && !findCharacterByName(roster, pin.name)) console.log(strings.pinIgnored(pin.name));
126
-
127
- const pick = await resolveSessionCharacter(roster, sessionId, today);
128
- if (pick?.loreFailure) console.log(strings.lorePickUnanswered(pick.loreFailure));
129
- return pick?.character;
130
- };
131
- // The session speaks as the character from the reply that relays the card: its record is rewritten so every later
132
- // Start, the status line and the speech hook agree. The spinner is not rewritten here: the tool read its keys when
133
- // This session started, so a write now reaches only some other session, and a switch scoped to this one must not
134
- const switchSessionCharacter = async (character: Character) => {
135
- recordSessionCharacter(character, sessionId, today.toString());
136
- await printCard(character);
137
- };
138
- // Every knob in one read, for the verbs that report rather than change: the status verb, and either language verb
139
- // Given no argument
140
- const getStatusReport = async () => {
141
- const pin = readPin();
142
- const pick = sessionId ? await resolveSessionCharacter(roster, sessionId, today) : undefined;
143
- const replyLanguage = readReplyLanguage();
144
- const settings = readUserSettings();
145
- return {
146
- displayName: pick?.character.displayName ?? pin?.displayName ?? "",
147
- interfaceLanguage: getLanguageDisplayName(language, language),
148
- isFromSessionRecord: Boolean(pick),
149
- isMuted: checkIsMuted(),
150
- isPluginSpeakHook: settings.hooks?.MessageDisplay?.some((entry) => checkIsPluginHookEntry(entry)) ?? false,
151
- isPluginSpinner: checkIsPluginSpinner(settings),
152
- isPluginStatusLine: checkIsPluginStatusLine(settings.statusLine),
153
- isReplyLanguageCascaded: !replyLanguage,
154
- isRuntimeInstalled: checkIsRuntimeInstalled(),
155
- pinnedName: pin?.name ?? "",
156
- replyLanguage: getLanguageDisplayName(replyLanguage ?? language, language),
157
- voiceDevice: readVoiceDevice(),
158
- voiceLanguage: readVoiceLanguage(),
159
- volume: readVolume(),
160
- };
161
- };
162
-
163
- switch (verb) {
164
- case GenshinVerb.Language: {
165
- const languageNames = readLanguageNames();
166
- // Each language is offered in its own words beside the word a person types for it, and either resolves
167
- const offered = listFormat.format(
168
- languageNames.map((languageName) => {
169
- const ownName = getLanguageDisplayName(languageName, languageName);
170
- return ownName === languageName ? languageName : `${languageName} (${ownName})`;
171
- }),
172
- );
173
- if (!name) {
174
- console.log(strings.status(await getStatusReport()));
175
- console.log(strings.languageMustBeOneOf(offered));
176
- break;
177
- }
178
-
179
- const canonicalLanguage = getCanonicalLanguage(languageNames, name);
180
- if (!canonicalLanguage) {
181
- console.error(strings.languageMustBeOneOf(offered));
182
- process.exitCode = 1;
183
- break;
184
- }
185
-
186
- writeInterfaceLanguage(canonicalLanguage);
187
- // Everything downstream reads the language afresh: the roster in it, which builds that language's cache, the
188
- // Words that are ours in it, and the session's record and spinner, whose name is drawn from it
189
- const localizedRoster = readRoster(canonicalLanguage);
190
- const { strings: localizedStrings } = await readLocalization(canonicalLanguage);
191
- const pick = await resolveSessionCharacter(localizedRoster, sessionId, today);
192
- const character = pick?.character;
193
- if (character && sessionId) {
194
- recordSessionCharacter(character, sessionId, today.toString());
195
- await writeSessionSpinner(character, await readPersonaCard(character.name), canonicalLanguage);
196
- }
197
-
198
- const pin = readPin();
199
- const pinnedCharacter = findCharacterByName(localizedRoster, pin?.name ?? "");
200
- if (pinnedCharacter) writePin(pinnedCharacter);
201
- console.log(localizedStrings.interfaceLanguageSet(getLanguageDisplayName(canonicalLanguage, canonicalLanguage)));
202
- // A multi-gigabyte download is never a side effect of a labels setting, so the dub is reported on and left
203
- // Alone. Three states and three answers: no dub of this language, one that is not installed, and one that
204
- // Already is — which is the quiet case, since there is nothing for the person to do about it
205
- const [matchingDub] =
206
- Object.entries(VoiceLanguageNameMap).find(([, languageName]) => languageName === canonicalLanguage) ?? [];
207
- if (canonicalLanguage !== DEFAULT_LANGUAGE)
208
- if (!matchingDub) console.log(localizedStrings.voiceLanguageUnavailable);
209
- else if (matchingDub !== readVoiceLanguage()) console.log(localizedStrings.voiceLanguageAvailable(matchingDub));
210
-
211
- if (character) await printCard(character);
212
- break;
213
- }
214
- case GenshinVerb.Lines: {
215
- const character = readNamedCharacter();
216
- if (!character) break;
217
-
218
- console.log(`${getRosterLine(character)}\n${character.description}`);
219
- const voiceLines = await readVoiceLines(character.name, language);
220
- for (const { text, title } of voiceLines.filter((line) => checkIsOwnVoiceLine(line)))
221
- console.log(`- ${title}: ${text}`);
222
- break;
223
- }
224
- case GenshinVerb.Mute:
225
- setMuted(true);
226
- console.log(strings.muted);
227
- break;
228
- case GenshinVerb.Pin: {
229
- const pinnedCharacter = readNamedCharacter();
230
- if (!pinnedCharacter) break;
231
-
232
- writePin(pinnedCharacter);
233
- // The pinned character is what every later session speaks as, so the spinner may follow where `setup` opted it in
234
- await writeSessionSpinner(pinnedCharacter, await readPersonaCard(pinnedCharacter.name), language);
235
- if (sessionId) {
236
- await switchSessionCharacter(pinnedCharacter);
237
- console.log(strings.pinnedInSession);
238
- break;
239
- }
240
-
241
- await printCard(pinnedCharacter);
242
- console.log(strings.pinned);
243
- break;
244
- }
245
- case GenshinVerb.Reply: {
246
- if (!name) {
247
- console.log(strings.status(await getStatusReport()));
248
- break;
249
- }
250
- // Anything the model can write is a legal reply language, so this is not held to the data package's fifteen;
251
- // The canonical spelling is taken where it names one of them, so the common case reads as the language verb's
252
- const replyLanguage = getCanonicalLanguage(readLanguageNames(), name) ?? name;
253
- writeReplyLanguage(replyLanguage);
254
- console.log(strings.replyLanguageSet(getLanguageDisplayName(replyLanguage, language)));
255
- if (replyLanguage !== DEFAULT_LANGUAGE && readVoiceLanguage()) console.log(strings.replyLanguageSilencesVoice);
256
- break;
257
- }
258
- case GenshinVerb.Roster:
259
- for (const character of roster) console.log(getRosterLine(character));
260
- break;
261
- case GenshinVerb.Setup: {
262
- writeLauncher(STATUS_LAUNCHER_PATH, STATUS_SCRIPT_PATH);
263
- const userSettings = readUserSettings();
264
- const settings = getSettingsWithStatusLine(userSettings);
265
- writeUserSettings(settings);
266
- const character = await getCurrentCharacter();
267
- if (character) writeSpinner(await readSpinner(character, await readPersonaCard(character.name), language));
268
- console.log(checkIsPluginStatusLine(settings.statusLine) ? strings.setupDone : strings.setupStatusLineKept);
269
- break;
270
- }
271
- case GenshinVerb.Status:
272
- console.log(strings.status(await getStatusReport()));
273
- break;
274
- case GenshinVerb.Teardown: {
275
- const userSettings = readUserSettings();
276
- const settings = getSettingsWithoutPluginEntries(userSettings);
277
- writeUserSettings(settings);
278
- await deleteVoiceState();
279
- console.log(strings.teardownDone);
280
- break;
281
- }
282
- case GenshinVerb.Today: {
283
- const character = await getCurrentCharacter();
284
- if (character) await printCard(character);
285
- break;
286
- }
287
- case GenshinVerb.Uncarded:
288
- await printQueue(({ personaCard }) => !personaCard);
289
- break;
290
- case GenshinVerb.Unmute:
291
- setMuted(false);
292
- console.log(strings.unmuted);
293
- break;
294
- case GenshinVerb.Unpin: {
295
- deletePin();
296
- const pick = sessionId ? await pickCurrentCharacter(roster, today) : undefined;
297
- if (!pick) {
298
- console.log(strings.pinRemoved);
299
- break;
300
- }
301
-
302
- if (pick.loreFailure) console.log(strings.lorePickUnanswered(pick.loreFailure));
303
- await switchSessionCharacter(pick.character);
304
- console.log(strings.pinRemovedInSession);
305
- break;
306
- }
307
- case GenshinVerb.Untranslated: {
308
- // The queue the language modules are filled from, the way `unverbed` is the queue the cards' verbs are: who has
309
- // No gerunds in this language, or a card whose greeting it has not written. English reads both off the cards,
310
- // So it is never behind
311
- if (language === DEFAULT_LANGUAGE) break;
312
-
313
- const { characters } = await readLocalization(language);
314
- await printQueue(({ character: { name: characterName }, personaCard }) => {
315
- const localizedPersonaCard = characters[characterName];
316
- return !localizedPersonaCard?.verbs || (Boolean(personaCard) && !localizedPersonaCard.greeting);
317
- });
318
- break;
319
- }
320
- case GenshinVerb.Unverbed:
321
- await printQueue(({ personaCard }) => personaCard?.verbs.length === 0);
322
- break;
323
- case GenshinVerb.Use: {
324
- const character = readNamedCharacter();
325
- if (!character) break;
326
-
327
- if (!sessionId) {
328
- console.error(strings.noSession);
329
- process.exitCode = 1;
330
- break;
331
- }
332
-
333
- await switchSessionCharacter(character);
334
- console.log(strings.usingInSession);
335
- break;
336
- }
337
- case GenshinVerb.Voice: {
338
- if (!name) {
339
- const voiceLanguage = readVoiceLanguage();
340
- console.log(
341
- voiceLanguage
342
- ? strings.voiceStatus(checkIsRuntimeInstalled(), voiceLanguage, readVoiceDevice(), VOICE_LOG_PATH)
343
- : strings.voiceUnset,
344
- );
345
- break;
346
- }
347
-
348
- if (!checkIsVoiceLanguage(name)) {
349
- console.error(strings.voiceLanguageMustBeOneOf(listFormat.format(Object.keys(VoiceLanguageNameMap))));
350
- process.exitCode = 1;
351
- break;
352
- }
353
- // The proof walks the device ladder from the top, so a rung this machine once demoted is tried again here and
354
- // Nowhere else: the rung on file is cleared, and a synthesizer still running — on that rung, or on the runtime
355
- // Being replaced — is stopped, so the one the warm spawns loads afresh
356
- deleteVoiceDevice();
357
- await connectVoiceServer({ type: VoiceRequestType.Stop });
358
- if (checkIsRuntimeInstalled()) console.log(strings.runtimeInstalled);
359
- else {
360
- console.log(strings.runtimeInstalling);
361
- if (!installVoiceRuntime()) {
362
- console.error(strings.runtimeInstallFailed);
363
- process.exitCode = 1;
364
- break;
365
- }
366
- }
367
- // The runtime's own loader fetches what it is asked to load into the models directory, so the weights are
368
- // Downloaded by loading the engine once here — with progress, which the detached synthesizer cannot print —
369
- // And the synthesizer then loads them from the cache inside a hook's budget. A cache already holding them skips
370
- // The load, since the rung the engine speaks on is the proof's to report
371
- if (checkHasWeights(MODELS_DIRECTORY)) console.log(strings.weightsPresent);
372
- else {
373
- const runtime = readVoiceRuntime(RUNTIME_MANIFEST_PATH);
374
- const synthesizer = await createVoiceSynthesizer(runtime, MODELS_DIRECTORY, {
375
- onFallback: console.log,
376
- onProgress: createVoiceProgressPrinter(),
377
- });
378
- console.log(
379
- synthesizer.device === VOICE_CPU_DEVICE ? strings.weightsOnCpu : strings.weightsOnDevice(synthesizer.device),
380
- );
381
- }
382
- // The dub on disk is the gate every spoken reply passes, so it is written only where this run proved the
383
- // Voice — a setup that failed after it leaves the replies silent rather than broken in the hooks' silence
384
- const character = await getCurrentCharacter();
385
- if (!character) {
386
- writeVoiceLanguage(name);
387
- writeSpeakHook();
388
- console.log(strings.voiceLanguageWritten(name));
389
- break;
390
- }
391
-
392
- const personaCard = await readPersonaCard(character.name);
393
- if (!readCharacterReference(character.name, personaCard)) console.log(strings.noReference(character.displayName));
394
-
395
- const warmed = await sendVoiceRequest(getWarmRequest(character.name, personaCard, name));
396
- const [status, device] = warmed.split(VOICE_STATUS_SEPARATOR);
397
- if (status !== VoiceStatus.Ok) {
398
- console.error(strings.warmRequestUnanswered(status || "unreachable", VOICE_LOG_PATH));
399
- process.exitCode = 1;
400
- break;
401
- }
402
-
403
- writeVoiceLanguage(name);
404
- writeSpeakHook();
405
- await sendVoiceRequest(
406
- getSpeechRequest(VoiceRequestType.Speak, character.name, personaCard, name, [VOICE_PROOF_TEXT]),
407
- );
408
- console.log(strings.spoke(character.displayName, device ?? ""));
409
- break;
410
- }
411
- case GenshinVerb.Volume:
412
- if (!checkIsVolume(name)) {
413
- console.error(strings.volumeMustBeWholeNumber(MAX_VOLUME));
414
- process.exitCode = 1;
415
- break;
416
- }
5
+ import { runMain } from "citty";
417
6
 
418
- writeVolume(name);
419
- console.log(strings.volumeSet(name));
420
- break;
421
- default:
7
+ // Every line the plugin prints is in the interface language, so a verb it does not know, or none, or a request for help,
8
+ // Is answered with its own usage line, and exits before citty would add an English one of its own
9
+ await runMain(genshinCommand, {
10
+ showUsage: async () => {
11
+ const { strings } = await readLocalization(readInterfaceLanguage());
422
12
  console.error(strings.usage(GenshinVerbs.join(" | ")));
423
- process.exitCode = 1;
424
- }
13
+ process.exit(1);
14
+ },
15
+ });
@@ -4,13 +4,28 @@ import { readPersonaCard } from "#src/services/readPersonaCard";
4
4
  import { readRoster } from "#src/services/readRoster";
5
5
  import { registerQuietExit } from "#src/services/registerQuietExit";
6
6
  import { writeSessionSpinner } from "#src/services/writeSessionSpinner";
7
+ import { defineCommand, runMain } from "citty";
7
8
 
8
9
  // Spawned detached by the session-start hook, which must not wait on it: this session's character's lines are read
9
10
  // And the spinner rewritten while the person reads the card, where `setup` opted the settings in. The character
10
11
  // Arrives as the English name, which is the identity, and the roster it is looked up in is the interface
11
12
  // Language's — so the label written is the name that language spells them by
12
13
  registerQuietExit();
13
- const [name = ""] = process.argv.slice(2);
14
- const language = readInterfaceLanguage();
15
- const character = name ? findCharacterByName(readRoster(language), name) : undefined;
16
- if (character) await writeSessionSpinner(character, await readPersonaCard(character.name), language);
14
+ await runMain(
15
+ defineCommand({
16
+ args: {
17
+ name: {
18
+ default: "",
19
+ description: "The session's character, by English name",
20
+ required: false,
21
+ type: "positional",
22
+ },
23
+ },
24
+ meta: { description: "Rewrite the spinner under the session's character", name: "spinner" },
25
+ run: async ({ args }) => {
26
+ const language = readInterfaceLanguage();
27
+ const character = args.name ? findCharacterByName(readRoster(language), args.name) : undefined;
28
+ if (character) await writeSessionSpinner(character, await readPersonaCard(character.name), language);
29
+ },
30
+ }),
31
+ );
@@ -0,0 +1,15 @@
1
+ import type { Character } from "#src/models/Character";
2
+ import type { LocalizationStrings } from "#src/models/LocalizationStrings";
3
+
4
+ // What every verb reads before it acts, once a run: the interface language and its roster and words, the day, and
5
+ // The session the tool started the verb in
6
+ export interface GenshinContext {
7
+ language: string;
8
+ // A choice a verb offers is a list in the interface language's own punctuation, which the runtime knows
9
+ listFormat: Intl.ListFormat;
10
+ roster: Character[];
11
+ // Empty from a shell, where the tool set nothing
12
+ sessionId: string;
13
+ strings: LocalizationStrings;
14
+ today: Temporal.PlainDate;
15
+ }
@@ -0,0 +1,48 @@
1
+ import type { CommandDef } from "citty";
2
+
3
+ import { GenshinVerb } from "#src/models/GenshinVerb";
4
+ import { languageCommand } from "#src/services/cli/commands/languageCommand";
5
+ import { linesCommand } from "#src/services/cli/commands/linesCommand";
6
+ import { muteCommand } from "#src/services/cli/commands/muteCommand";
7
+ import { pinCommand } from "#src/services/cli/commands/pinCommand";
8
+ import { replyCommand } from "#src/services/cli/commands/replyCommand";
9
+ import { rosterCommand } from "#src/services/cli/commands/rosterCommand";
10
+ import { setupCommand } from "#src/services/cli/commands/setupCommand";
11
+ import { statusCommand } from "#src/services/cli/commands/statusCommand";
12
+ import { teardownCommand } from "#src/services/cli/commands/teardownCommand";
13
+ import { todayCommand } from "#src/services/cli/commands/todayCommand";
14
+ import { uncardedCommand } from "#src/services/cli/commands/uncardedCommand";
15
+ import { unmuteCommand } from "#src/services/cli/commands/unmuteCommand";
16
+ import { unpinCommand } from "#src/services/cli/commands/unpinCommand";
17
+ import { untranslatedCommand } from "#src/services/cli/commands/untranslatedCommand";
18
+ import { unverbedCommand } from "#src/services/cli/commands/unverbedCommand";
19
+ import { useCommand } from "#src/services/cli/commands/useCommand";
20
+ import { voiceCommand } from "#src/services/cli/commands/voiceCommand";
21
+ import { volumeCommand } from "#src/services/cli/commands/volumeCommand";
22
+ import { defineCommand } from "citty";
23
+
24
+ // The plugin's verbs, each a subcommand; what each does is the README's command reference, and the usage a mistyped
25
+ // Verb earns is printed in the interface language by the entry rather than by citty, whose own words are English
26
+ export const genshinCommand: CommandDef = defineCommand({
27
+ meta: { name: "genshin" },
28
+ subCommands: {
29
+ [GenshinVerb.Language]: languageCommand,
30
+ [GenshinVerb.Lines]: linesCommand,
31
+ [GenshinVerb.Mute]: muteCommand,
32
+ [GenshinVerb.Pin]: pinCommand,
33
+ [GenshinVerb.Reply]: replyCommand,
34
+ [GenshinVerb.Roster]: rosterCommand,
35
+ [GenshinVerb.Setup]: setupCommand,
36
+ [GenshinVerb.Status]: statusCommand,
37
+ [GenshinVerb.Teardown]: teardownCommand,
38
+ [GenshinVerb.Today]: todayCommand,
39
+ [GenshinVerb.Uncarded]: uncardedCommand,
40
+ [GenshinVerb.Unmute]: unmuteCommand,
41
+ [GenshinVerb.Unpin]: unpinCommand,
42
+ [GenshinVerb.Untranslated]: untranslatedCommand,
43
+ [GenshinVerb.Unverbed]: unverbedCommand,
44
+ [GenshinVerb.Use]: useCommand,
45
+ [GenshinVerb.Voice]: voiceCommand,
46
+ [GenshinVerb.Volume]: volumeCommand,
47
+ },
48
+ });
@@ -0,0 +1,78 @@
1
+ import type { SubCommandsDef } from "citty";
2
+
3
+ import { GenshinVerb } from "#src/models/GenshinVerb";
4
+ import { getStatusReport } from "#src/services/cli/getStatusReport";
5
+ import { printCard } from "#src/services/cli/printCard";
6
+ import { readGenshinContext } from "#src/services/cli/readGenshinContext";
7
+ import { DEFAULT_LANGUAGE, VoiceLanguageNameMap } from "#src/services/constants";
8
+ import { findCharacterByName } from "#src/services/findCharacterByName";
9
+ import { getCanonicalLanguage } from "#src/services/getCanonicalLanguage";
10
+ import { getLanguageDisplayName } from "#src/services/getLanguageDisplayName";
11
+ import { readLanguageNames } from "#src/services/readLanguageNames";
12
+ import { readLocalization } from "#src/services/readLocalization";
13
+ import { readPersonaCard } from "#src/services/readPersonaCard";
14
+ import { readPin } from "#src/services/readPin";
15
+ import { readRoster } from "#src/services/readRoster";
16
+ import { readVoiceLanguage } from "#src/services/readVoiceLanguage";
17
+ import { recordSessionCharacter } from "#src/services/recordSessionCharacter";
18
+ import { resolveSessionCharacter } from "#src/services/resolveSessionCharacter";
19
+ import { writeInterfaceLanguage } from "#src/services/writeInterfaceLanguage";
20
+ import { writePin } from "#src/services/writePin";
21
+ import { writeSessionSpinner } from "#src/services/writeSessionSpinner";
22
+ import { defineCommand } from "citty";
23
+
24
+ export const languageCommand: SubCommandsDef[string] = defineCommand({
25
+ meta: { name: GenshinVerb.Language },
26
+ run: async ({ args }) => {
27
+ const context = await readGenshinContext();
28
+ const { listFormat, sessionId, strings, today } = context;
29
+ const name = args._.join(" ");
30
+ const languageNames = readLanguageNames();
31
+ // Each language is offered in its own words beside the word a person types for it, and either resolves
32
+ const offered = listFormat.format(
33
+ languageNames.map((languageName) => {
34
+ const ownName = getLanguageDisplayName(languageName, languageName);
35
+ return ownName === languageName ? languageName : `${languageName} (${ownName})`;
36
+ }),
37
+ );
38
+ if (!name) {
39
+ console.log(strings.status(await getStatusReport(context)));
40
+ console.log(strings.languageMustBeOneOf(offered));
41
+ return;
42
+ }
43
+
44
+ const canonicalLanguage = getCanonicalLanguage(languageNames, name);
45
+ if (!canonicalLanguage) {
46
+ console.error(strings.languageMustBeOneOf(offered));
47
+ process.exitCode = 1;
48
+ return;
49
+ }
50
+
51
+ writeInterfaceLanguage(canonicalLanguage);
52
+ // Everything downstream reads the language afresh: the roster in it, which builds that language's cache, the
53
+ // Words that are ours in it, and the session's record and spinner, whose name is drawn from it
54
+ const localizedRoster = readRoster(canonicalLanguage);
55
+ const { strings: localizedStrings } = await readLocalization(canonicalLanguage);
56
+ const pick = await resolveSessionCharacter(localizedRoster, sessionId, today);
57
+ const character = pick?.character;
58
+ if (character && sessionId) {
59
+ recordSessionCharacter(character, sessionId, today.toString());
60
+ await writeSessionSpinner(character, await readPersonaCard(character.name), canonicalLanguage);
61
+ }
62
+
63
+ const pin = readPin();
64
+ const pinnedCharacter = findCharacterByName(localizedRoster, pin?.name ?? "");
65
+ if (pinnedCharacter) writePin(pinnedCharacter);
66
+ console.log(localizedStrings.interfaceLanguageSet(getLanguageDisplayName(canonicalLanguage, canonicalLanguage)));
67
+ // A multi-gigabyte download is never a side effect of a labels setting, so the dub is reported on and left
68
+ // Alone. Three states and three answers: no dub of this language, one that is not installed, and one that
69
+ // Already is — which is the quiet case, since there is nothing for the person to do about it
70
+ const [matchingDub] =
71
+ Object.entries(VoiceLanguageNameMap).find(([, languageName]) => languageName === canonicalLanguage) ?? [];
72
+ if (canonicalLanguage !== DEFAULT_LANGUAGE)
73
+ if (!matchingDub) console.log(localizedStrings.voiceLanguageUnavailable);
74
+ else if (matchingDub !== readVoiceLanguage()) console.log(localizedStrings.voiceLanguageAvailable(matchingDub));
75
+
76
+ if (character) await printCard(context, character);
77
+ },
78
+ });
@@ -0,0 +1,25 @@
1
+ import type { SubCommandsDef } from "citty";
2
+
3
+ import { GenshinVerb } from "#src/models/GenshinVerb";
4
+ import { checkIsOwnVoiceLine } from "#src/services/checkIsOwnVoiceLine";
5
+ import { getRosterLine } from "#src/services/cli/getRosterLine";
6
+ import { readGenshinContext } from "#src/services/cli/readGenshinContext";
7
+ import { readNamedCharacter } from "#src/services/cli/readNamedCharacter";
8
+ import { readVoiceLines } from "#src/services/readVoiceLines";
9
+ import { defineCommand } from "citty";
10
+
11
+ export const linesCommand: SubCommandsDef[string] = defineCommand({
12
+ meta: { name: GenshinVerb.Lines },
13
+ run: async ({ args }) => {
14
+ const context = await readGenshinContext();
15
+ const { language } = context;
16
+ const name = args._.join(" ");
17
+ const character = readNamedCharacter(context, name);
18
+ if (!character) return;
19
+
20
+ console.log(`${getRosterLine(character)}\n${character.description}`);
21
+ const voiceLines = await readVoiceLines(character.name, language);
22
+ for (const { text, title } of voiceLines.filter((line) => checkIsOwnVoiceLine(line)))
23
+ console.log(`- ${title}: ${text}`);
24
+ },
25
+ });