codex-terminal-themes 1.0.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.
Files changed (229) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/CONTRIBUTING.md +23 -0
  3. package/LICENSE +21 -0
  4. package/LICENSE.txt +21 -0
  5. package/README.md +169 -0
  6. package/SECURITY.md +5 -0
  7. package/SUPPORT.md +5 -0
  8. package/bin/codex-terminal-themes.mjs +12 -0
  9. package/docs/app.js +1393 -0
  10. package/docs/assets/theme-gallery-header.png +0 -0
  11. package/docs/favicon.svg +10 -0
  12. package/docs/index.html +240 -0
  13. package/docs/preview.svg +24 -0
  14. package/docs/site-data.json +51078 -0
  15. package/docs/styles.css +780 -0
  16. package/metadata/README.md +71 -0
  17. package/metadata/themes.json +16185 -0
  18. package/metadata/themes.schema.json +218 -0
  19. package/package.json +109 -0
  20. package/src/cli.mjs +1763 -0
  21. package/stylelint.config.mjs +12 -0
  22. package/themes/80s.tmTheme +229 -0
  23. package/themes/Active4D.tmTheme +407 -0
  24. package/themes/All Hallow's Eve Custom.tmTheme +275 -0
  25. package/themes/All Hallow's Eve.tmTheme +277 -0
  26. package/themes/All Hallows Eve.tmTheme +277 -0
  27. package/themes/Amy.tmTheme +559 -0
  28. package/themes/Artic Fall.tmTheme +275 -0
  29. package/themes/BBEdit.tmTheme +439 -0
  30. package/themes/Bespin.tmTheme +516 -0
  31. package/themes/Black Pearl II.tmTheme +496 -0
  32. package/themes/Black Pearl.tmTheme +400 -0
  33. package/themes/Blackboard 2.tmTheme +348 -0
  34. package/themes/Blackboard Black.tmTheme +350 -0
  35. package/themes/Blackboard.tmTheme +348 -0
  36. package/themes/Blueberry Jelly.tmTheme +242 -0
  37. package/themes/Bluesy.tmTheme +242 -0
  38. package/themes/Blurb 2.tmTheme +229 -0
  39. package/themes/Blurb.tmTheme +229 -0
  40. package/themes/Bongzilla 2.tmTheme +223 -0
  41. package/themes/Bongzilla.tmTheme +223 -0
  42. package/themes/Brightly Dark.tmTheme +242 -0
  43. package/themes/Brilliance Black.tmTheme +2619 -0
  44. package/themes/Brilliance Dull.tmTheme +2243 -0
  45. package/themes/Bromozoid.tmTheme +227 -0
  46. package/themes/CSSEdit.tmTheme +203 -0
  47. package/themes/Classic Modified.tmTheme +469 -0
  48. package/themes/Clouds Midnight.tmTheme +361 -0
  49. package/themes/Clouds of Ruby.tmTheme +649 -0
  50. package/themes/Clouds.tmTheme +348 -0
  51. package/themes/Cloudy Fields.tmTheme +240 -0
  52. package/themes/Coal Graal.tmTheme +282 -0
  53. package/themes/Cobalt.tmTheme +561 -0
  54. package/themes/Coda.tmTheme +317 -0
  55. package/themes/Colorful.tmTheme +307 -0
  56. package/themes/Cool Glow.tmTheme +234 -0
  57. package/themes/Corona.tmTheme +290 -0
  58. package/themes/Cowabunga.tmTheme +242 -0
  59. package/themes/DanBurst.tmTheme +665 -0
  60. package/themes/Daniel Fischer.tmTheme +627 -0
  61. package/themes/Dark Ocean.tmTheme +242 -0
  62. package/themes/DarkNeon.tmTheme +818 -0
  63. package/themes/Dawn.tmTheme +437 -0
  64. package/themes/DawnCustom.tmTheme +443 -0
  65. package/themes/Django (Smoothy).tmTheme +453 -0
  66. package/themes/Django Blues.tmTheme +182 -0
  67. package/themes/Django Extended.tmTheme +495 -0
  68. package/themes/Django.tmTheme +436 -0
  69. package/themes/Dobdark.tmTheme +615 -0
  70. package/themes/Dominion Day.tmTheme +562 -0
  71. package/themes/Doo-Daa.tmTheme +242 -0
  72. package/themes/Drankin Purp.tmTheme +227 -0
  73. package/themes/Dreamweaver_Blackbam_Aptana303.tmTheme +980 -0
  74. package/themes/Easy on my Eyes There Buddy.tmTheme +227 -0
  75. package/themes/Eiffel.tmTheme +435 -0
  76. package/themes/Emacs Strict.tmTheme +241 -0
  77. package/themes/Emacs.tmTheme +241 -0
  78. package/themes/Epic Blue.tmTheme +320 -0
  79. package/themes/Erebus.tmTheme +467 -0
  80. package/themes/Espresso Libre.tmTheme +402 -0
  81. package/themes/Espresso Tutti.tmTheme +392 -0
  82. package/themes/Espresso.tmTheme +329 -0
  83. package/themes/Fade to Grey.tmTheme +308 -0
  84. package/themes/Fluidvision.tmTheme +443 -0
  85. package/themes/ForLaTeX.tmTheme +214 -0
  86. package/themes/Freckle.tmTheme +279 -0
  87. package/themes/Friendship Bracelet.tmTheme +303 -0
  88. package/themes/GaGaGaGroovy.tmTheme +227 -0
  89. package/themes/Gangrene.tmTheme +242 -0
  90. package/themes/GitHub.tmTheme +653 -0
  91. package/themes/GlitterBomb.tmTheme +387 -0
  92. package/themes/Halloween Night.tmTheme +303 -0
  93. package/themes/Happy happy joy joy.tmTheme +841 -0
  94. package/themes/Happydeluxe.tmTheme +184 -0
  95. package/themes/HelvectorLight.tmTheme +557 -0
  96. package/themes/Humane.tmTheme +220 -0
  97. package/themes/IDLE.tmTheme +235 -0
  98. package/themes/IR_Black.tmTheme +810 -0
  99. package/themes/IR_White.tmTheme +792 -0
  100. package/themes/Jane Fonda, Baby Redux.tmTheme +264 -0
  101. package/themes/Johnny.tmTheme +798 -0
  102. package/themes/Juicy.tmTheme +250 -0
  103. package/themes/Kuroir Theme.tmTheme +707 -0
  104. package/themes/LAZY.tmTheme +291 -0
  105. package/themes/Lowlight.tmTheme +605 -0
  106. package/themes/Mac Classic.tmTheme +476 -0
  107. package/themes/Made of Code.tmTheme +695 -0
  108. package/themes/MagicWB (Amiga).tmTheme +376 -0
  109. package/themes/Malibu Nights.tmTheme +257 -0
  110. package/themes/Menage A Trois.tmTheme +881 -0
  111. package/themes/Merbivore Soft.tmTheme +285 -0
  112. package/themes/Merbivore.tmTheme +285 -0
  113. package/themes/Midnight.tmTheme +321 -0
  114. package/themes/Mmm Sandy.tmTheme +227 -0
  115. package/themes/Monokai.tmTheme +289 -0
  116. package/themes/MultiMarkdown.tmTheme +183 -0
  117. package/themes/Mustang.tmTheme +339 -0
  118. package/themes/Neopro Inverted.tmTheme +328 -0
  119. package/themes/Neopro.tmTheme +330 -0
  120. package/themes/Nice One.tmTheme +222 -0
  121. package/themes/No Way.tmTheme +255 -0
  122. package/themes/Overcast.tmTheme +659 -0
  123. package/themes/Pastels on Dark.tmTheme +703 -0
  124. package/themes/Pastie.tmTheme +321 -0
  125. package/themes/Peridinkle.tmTheme +240 -0
  126. package/themes/Play!.tmTheme +736 -0
  127. package/themes/Puss.tmTheme +227 -0
  128. package/themes/Putty.tmTheme +275 -0
  129. package/themes/Quail.tmTheme +257 -0
  130. package/themes/RDark.tmTheme +235 -0
  131. package/themes/Rails Envy.tmTheme +299 -0
  132. package/themes/Railscasts 2.tmTheme +368 -0
  133. package/themes/Railscasts.tmTheme +278 -0
  134. package/themes/Resesif.tmTheme +298 -0
  135. package/themes/Ringo.tmTheme +240 -0
  136. package/themes/Ruby Blue.tmTheme +366 -0
  137. package/themes/RubyRobot.tmTheme +250 -0
  138. package/themes/Ryan Light.tmTheme +232 -0
  139. package/themes/Seafoam.tmTheme +242 -0
  140. package/themes/Sidewalk Chalk.tmTheme +276 -0
  141. package/themes/Sin City (that yellow bastard).tmTheme +585 -0
  142. package/themes/Slate.tmTheme +436 -0
  143. package/themes/Slush & Poppies.tmTheme +336 -0
  144. package/themes/Slush and Poppies.tmTheme +336 -0
  145. package/themes/Smokey Morning.tmTheme +229 -0
  146. package/themes/Smoothy original.tmTheme +623 -0
  147. package/themes/Smoothy.tmTheme +623 -0
  148. package/themes/Solarized (dark).tmTheme +2051 -0
  149. package/themes/Solarized-dark.tmTheme +312 -0
  150. package/themes/Solarized-light.tmTheme +305 -0
  151. package/themes/Sometheme.tmTheme +240 -0
  152. package/themes/SoylentTheme.tmTheme +353 -0
  153. package/themes/SpaceCadet.tmTheme +212 -0
  154. package/themes/Spectacular.tmTheme +436 -0
  155. package/themes/Starlight.tmTheme +857 -0
  156. package/themes/Stoneship Bright.tmTheme +348 -0
  157. package/themes/Stoneship.tmTheme +361 -0
  158. package/themes/Summer Camp Mod.tmTheme +229 -0
  159. package/themes/Summer Camp.tmTheme +229 -0
  160. package/themes/Summery Drink.tmTheme +242 -0
  161. package/themes/Sunburst.tmTheme +665 -0
  162. package/themes/Swyphs II.tmTheme +306 -0
  163. package/themes/Tango Bright.tmTheme +1 -0
  164. package/themes/Tango.tmTheme +450 -0
  165. package/themes/Teenage Dream.tmTheme +242 -0
  166. package/themes/Texari.tmTheme +727 -0
  167. package/themes/Text Ex Machina.tmTheme +295 -0
  168. package/themes/Tomorrow-Night-Blue.tmTheme +175 -0
  169. package/themes/Tomorrow-Night-Eighties.tmTheme +175 -0
  170. package/themes/Tomorrow-Night.tmTheme +175 -0
  171. package/themes/Tomorrow.tmTheme +349 -0
  172. package/themes/ToyChest.tmTheme +503 -0
  173. package/themes/TravisJeffery.tmTheme +1261 -0
  174. package/themes/Tubster.tmTheme +280 -0
  175. package/themes/Twilight BG FG.tmTheme +1000 -0
  176. package/themes/Twilight.tmTheme +516 -0
  177. package/themes/TwilightMod.tmTheme +529 -0
  178. package/themes/Two Days Ago.tmTheme +242 -0
  179. package/themes/Vibrant Fin.tmTheme +447 -0
  180. package/themes/Vibrant Ink.tmTheme +447 -0
  181. package/themes/Vibrant Scala.tmTheme +292 -0
  182. package/themes/Vibrant Tango.tmTheme +438 -0
  183. package/themes/Wandering.tmTheme +681 -0
  184. package/themes/Whimsy in Blue.tmTheme +240 -0
  185. package/themes/Why/342/200/231s Poignant.tmTheme" +191 -0
  186. package/themes/Windows XP.tmTheme +350 -0
  187. package/themes/Witch.tmTheme +282 -0
  188. package/themes/Yurple.tmTheme +227 -0
  189. package/themes/ZZZ.tmTheme +131 -0
  190. package/themes/Zachstronaut.tmTheme +381 -0
  191. package/themes/Zenburnesque.tmTheme +343 -0
  192. package/themes/[ Argonaut ].tmTheme +387 -0
  193. package/themes/barf.tmTheme +254 -0
  194. package/themes/converted-vscode-AmoledShinyBlack.tmTheme +528 -0
  195. package/themes/converted-vscode-AmoledShinyBlack2.tmTheme +609 -0
  196. package/themes/converted-vscode-AmoledShinyBlack3.tmTheme +683 -0
  197. package/themes/converted-vscode-AmoledShinyBlack4.tmTheme +896 -0
  198. package/themes/converted-vscode-AmoledShinyBlack5.tmTheme +1023 -0
  199. package/themes/converted-vscode-AmoledShinyBlack6.tmTheme +2092 -0
  200. package/themes/eclips3.media (ECLM).tmTheme +294 -0
  201. package/themes/evin.tmTheme +253 -0
  202. package/themes/fake.tmTheme +669 -0
  203. package/themes/fapfap.tmTheme +508 -0
  204. package/themes/helloKitty.tmTheme +293 -0
  205. package/themes/iLife 05.tmTheme +619 -0
  206. package/themes/iPlastic.tmTheme +397 -0
  207. package/themes/idleFingers.tmTheme +380 -0
  208. package/themes/krTheme.tmTheme +551 -0
  209. package/themes/mark.james.name.tmTheme +1117 -0
  210. package/themes/minimal Theme.tmTheme +551 -0
  211. package/themes/mint.tmTheme +617 -0
  212. package/themes/mintBlue Dark.tmTheme +653 -0
  213. package/themes/mintBlue.tmTheme +655 -0
  214. package/themes/modifiedPastels.tmTheme +745 -0
  215. package/themes/monoindustrial.tmTheme +451 -0
  216. package/themes/my-theme-blackboard.tmTheme +363 -0
  217. package/themes/my-theme-classic.tmTheme +465 -0
  218. package/themes/nppGLua.tmTheme +293 -0
  219. package/themes/reST testing theme.tmTheme +554 -0
  220. package/themes/rose-pine-dawn.tmTheme +329 -0
  221. package/themes/rose-pine-moon.tmTheme +329 -0
  222. package/themes/rose-pine.tmTheme +329 -0
  223. package/themes/ryan-light.tmTheme +232 -0
  224. package/themes/wut.tmTheme +255 -0
  225. package/tools/build-pages-site.mjs +465 -0
  226. package/tools/generate-theme-metadata.mjs +680 -0
  227. package/tools/serve-pages-site.mjs +196 -0
  228. package/tools/validate-themes.mjs +272 -0
  229. package/types/index.d.ts +49 -0
package/src/cli.mjs ADDED
@@ -0,0 +1,1763 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import { SyntaxValidator } from "fast-xml-validator";
3
+ import { spawn } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { constants as fsConstants } from "node:fs";
6
+ import {
7
+ access,
8
+ copyFile,
9
+ mkdir,
10
+ readdir,
11
+ readFile,
12
+ stat,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
20
+ const manifestPath = path.join(packageRoot, "metadata", "themes.json");
21
+ const themeRoot = path.join(packageRoot, "themes");
22
+ const reset = "\u001B[0m";
23
+
24
+ const parser = new XMLParser({
25
+ ignoreAttributes: false,
26
+ preserveOrder: true,
27
+ trimValues: false,
28
+ });
29
+
30
+ const configKeys = new Set([
31
+ "batDir",
32
+ "codexDir",
33
+ "defaultTarget",
34
+ "defaultTheme",
35
+ "skipBatCache",
36
+ ]);
37
+
38
+ const sampleTokens = [
39
+ { label: "keyword", scope: "keyword", text: "const" },
40
+ { label: "function", scope: "entity.name.function", text: "renderTheme" },
41
+ { label: "string", scope: "string", text: '"AMOLED"' },
42
+ { label: "number", scope: "constant.numeric", text: "203" },
43
+ { label: "comment", scope: "comment", text: "// preview" },
44
+ { label: "variable", scope: "variable", text: "theme" },
45
+ { label: "type", scope: "storage.type", text: "readonly" },
46
+ { label: "invalid", scope: "invalid", text: "error" },
47
+ ];
48
+
49
+ /**
50
+ * @typedef {{
51
+ * readonly background: string | null;
52
+ * readonly caret: string | null;
53
+ * readonly foreground: string | null;
54
+ * readonly invisibles: string | null;
55
+ * readonly lineHighlight: string | null;
56
+ * readonly selection: string | null;
57
+ * }} ThemeColors
58
+ *
59
+ * @typedef {{
60
+ * readonly colorReferences: number;
61
+ * readonly scopedSettings: number;
62
+ * readonly settings: number;
63
+ * readonly uniqueScopes: number;
64
+ * }} ThemeStatistics
65
+ *
66
+ * @typedef {{
67
+ * readonly appearance: "dark" | "light" | "unknown";
68
+ * readonly author: string | null;
69
+ * readonly colors: ThemeColors;
70
+ * readonly colorSpace: string | null;
71
+ * readonly fileName: string;
72
+ * readonly id: string;
73
+ * readonly name: string;
74
+ * readonly path: string;
75
+ * readonly scopes: readonly string[];
76
+ * readonly semanticClass: string | null;
77
+ * readonly statistics: ThemeStatistics;
78
+ * readonly uuid: string;
79
+ * }} Theme
80
+ *
81
+ * @typedef {{
82
+ * readonly schemaVersion: number;
83
+ * readonly themeCount: number;
84
+ * readonly themes: readonly Theme[];
85
+ * }} ThemeManifest
86
+ *
87
+ * @typedef {{
88
+ * readonly batDir?: string;
89
+ * readonly codexDir?: string;
90
+ * readonly defaultTarget?: string;
91
+ * readonly defaultTheme?: string;
92
+ * readonly skipBatCache?: boolean;
93
+ * }} CliConfig
94
+ *
95
+ * @typedef {{
96
+ * readonly args: readonly string[];
97
+ * readonly flags: Map<string, string | true>;
98
+ * readonly positionals: readonly string[];
99
+ * }} ParsedArgs
100
+ *
101
+ * @typedef {{
102
+ * readonly cwd: string;
103
+ * readonly env: NodeJS.ProcessEnv;
104
+ * readonly stderr: NodeJS.WritableStream;
105
+ * readonly stdin: NodeJS.ReadStream & { readonly isTTY?: boolean };
106
+ * readonly stdout: NodeJS.WritableStream & {
107
+ * readonly columns?: number;
108
+ * readonly isTTY?: boolean;
109
+ * readonly rows?: number;
110
+ * };
111
+ * }} CliIo
112
+ *
113
+ * @typedef {{
114
+ * readonly foreground?: string;
115
+ * readonly background?: string;
116
+ * readonly fontStyle?: string;
117
+ * readonly scopeParts?: readonly string[];
118
+ * }} ThemeStyle
119
+ *
120
+ * @typedef {{
121
+ * readonly name: string;
122
+ * readonly ok: boolean;
123
+ * readonly detail: string;
124
+ * }} DoctorCheck
125
+ */
126
+
127
+ /**
128
+ * @param {string[]} args
129
+ * @param {CliIo} io
130
+ *
131
+ * @returns {Promise<number>}
132
+ */
133
+ export async function runCli(args, io) {
134
+ try {
135
+ const parsedArgs = parseArgs(args);
136
+ const command = parsedArgs.positionals[0] ?? "help";
137
+
138
+ if (parsedArgs.flags.has("help") || parsedArgs.flags.has("h")) {
139
+ writeHelp(io.stdout);
140
+ return 0;
141
+ }
142
+
143
+ if (parsedArgs.flags.has("version") || parsedArgs.flags.has("v")) {
144
+ const packageJson = await readPackageJson();
145
+ io.stdout.write(`${String(packageJson.version)}\n`);
146
+ return 0;
147
+ }
148
+
149
+ switch (command) {
150
+ case "config": {
151
+ return handleConfig(parsedArgs, io);
152
+ }
153
+
154
+ case "doctor": {
155
+ return handleDoctor(parsedArgs, io);
156
+ }
157
+
158
+ case "help": {
159
+ writeHelp(io.stdout);
160
+ return 0;
161
+ }
162
+
163
+ case "install": {
164
+ return handleInstall(parsedArgs, io);
165
+ }
166
+
167
+ case "list": {
168
+ return handleList(parsedArgs, io);
169
+ }
170
+
171
+ case "path": {
172
+ return handlePath(parsedArgs, io);
173
+ }
174
+
175
+ case "pick":
176
+ case "picker": {
177
+ return handlePicker(parsedArgs, io);
178
+ }
179
+
180
+ case "show": {
181
+ return handleShow(parsedArgs, io);
182
+ }
183
+
184
+ default: {
185
+ io.stderr.write(`Unknown command: ${command}\n\n`);
186
+ writeHelp(io.stderr);
187
+ return 1;
188
+ }
189
+ }
190
+ } catch (error) {
191
+ io.stderr.write(`${getErrorMessage(error)}\n`);
192
+ return 1;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * @param {ParsedArgs} parsedArgs
198
+ * @param {CliIo} io
199
+ *
200
+ * @returns {Promise<number>}
201
+ */
202
+ async function handleConfig(parsedArgs, io) {
203
+ const subcommand = parsedArgs.positionals[1] ?? "list";
204
+ const configPath = getConfigPath(parsedArgs, io.env);
205
+
206
+ if (subcommand === "path") {
207
+ io.stdout.write(`${configPath}\n`);
208
+ return 0;
209
+ }
210
+
211
+ const config = await readConfig(configPath);
212
+
213
+ if (subcommand === "list") {
214
+ writeJsonOrText(parsedArgs, io.stdout, config, formatConfig(config));
215
+ return 0;
216
+ }
217
+
218
+ const rawKey = parsedArgs.positionals[2];
219
+ if (rawKey === undefined || !configKeys.has(rawKey)) {
220
+ throw new Error(
221
+ `Expected one of these config keys: ${[...configKeys].join(", ")}`
222
+ );
223
+ }
224
+ const key = /** @type {keyof CliConfig} */ (rawKey);
225
+
226
+ if (subcommand === "get") {
227
+ const value = config[key];
228
+ if (value !== undefined) {
229
+ io.stdout.write(`${String(value)}\n`);
230
+ }
231
+ return 0;
232
+ }
233
+
234
+ if (subcommand === "set") {
235
+ const rawValue = parsedArgs.positionals[3];
236
+ if (rawValue === undefined) {
237
+ throw new Error(`Missing value for config key: ${key}`);
238
+ }
239
+
240
+ const nextConfig = {
241
+ ...config,
242
+ [key]: parseConfigValue(key, rawValue),
243
+ };
244
+ await writeConfig(configPath, nextConfig);
245
+ io.stdout.write(`Set ${key} in ${configPath}\n`);
246
+ return 0;
247
+ }
248
+
249
+ if (["clear", "unset"].includes(subcommand)) {
250
+ const nextConfig = { ...config };
251
+ delete nextConfig[key];
252
+ await writeConfig(configPath, nextConfig);
253
+ io.stdout.write(`Cleared ${key} in ${configPath}\n`);
254
+ return 0;
255
+ }
256
+
257
+ throw new Error(`Unknown config command: ${subcommand}`);
258
+ }
259
+
260
+ /**
261
+ * @param {ParsedArgs} parsedArgs
262
+ * @param {CliIo} io
263
+ *
264
+ * @returns {Promise<number>}
265
+ */
266
+ async function handleDoctor(parsedArgs, io) {
267
+ const manifest = await loadManifest();
268
+ const config = await readConfig(getConfigPath(parsedArgs, io.env));
269
+ const checks = await runDoctorChecks(manifest, config, parsedArgs, io);
270
+ const failedChecks = checks.filter((check) => !check.ok);
271
+
272
+ if (isJson(parsedArgs)) {
273
+ io.stdout.write(
274
+ `${JSON.stringify({ checks, ok: failedChecks.length === 0 }, null, 4)}\n`
275
+ );
276
+ } else {
277
+ for (const check of checks) {
278
+ io.stdout.write(
279
+ `${check.ok ? "ok" : "fail"} ${check.name}: ${check.detail}\n`
280
+ );
281
+ }
282
+ }
283
+
284
+ return failedChecks.length === 0 ? 0 : 1;
285
+ }
286
+
287
+ /**
288
+ * @param {ParsedArgs} parsedArgs
289
+ * @param {CliIo} io
290
+ *
291
+ * @returns {Promise<number>}
292
+ */
293
+ async function handleInstall(parsedArgs, io) {
294
+ const manifest = await loadManifest();
295
+ const config = await readConfig(getConfigPath(parsedArgs, io.env));
296
+ const all = hasFlag(parsedArgs, "all");
297
+ const requestedThemes = parsedArgs.positionals.slice(1);
298
+
299
+ if (
300
+ !all &&
301
+ requestedThemes.length === 0 &&
302
+ config.defaultTheme === undefined
303
+ ) {
304
+ throw new Error(
305
+ "Specify a theme id/name/path, --all, or config defaultTheme."
306
+ );
307
+ }
308
+
309
+ const themes = all
310
+ ? manifest.themes
311
+ : requestedThemes.length > 0
312
+ ? resolveThemes(manifest.themes, requestedThemes)
313
+ : [resolveTheme(manifest.themes, config.defaultTheme ?? "")];
314
+ const targets = await resolveInstallTargets(parsedArgs, config, io);
315
+ const dryRun = hasFlag(parsedArgs, "dry-run");
316
+ const force = hasFlag(parsedArgs, "force");
317
+ const skipBatCache =
318
+ hasFlag(parsedArgs, "skip-bat-cache") || config.skipBatCache === true;
319
+ const results = [];
320
+
321
+ for (const target of targets) {
322
+ await ensureTargetDirectory(target.directory, dryRun);
323
+
324
+ for (const theme of themes) {
325
+ const source = getThemeFilePath(theme);
326
+ const destination = path.join(target.directory, theme.fileName);
327
+ const result = await copyTheme({
328
+ destination,
329
+ dryRun,
330
+ force,
331
+ source,
332
+ target: target.name,
333
+ theme,
334
+ });
335
+ results.push(result);
336
+ }
337
+ }
338
+
339
+ const copiedBatThemes = results.some(
340
+ (result) => result.target === "bat" && result.status === "copied"
341
+ );
342
+ if (!dryRun && copiedBatThemes && !skipBatCache) {
343
+ const batCacheResult = await runBatCache(io);
344
+ results.push(batCacheResult);
345
+ }
346
+
347
+ if (isJson(parsedArgs)) {
348
+ io.stdout.write(`${JSON.stringify(results, null, 4)}\n`);
349
+ } else {
350
+ for (const result of results) {
351
+ io.stdout.write(formatInstallResult(result));
352
+ }
353
+ }
354
+
355
+ return results.some((result) => result.status === "failed") ? 1 : 0;
356
+ }
357
+
358
+ /**
359
+ * @param {ParsedArgs} parsedArgs
360
+ * @param {CliIo} io
361
+ *
362
+ * @returns {Promise<number>}
363
+ */
364
+ async function handleList(parsedArgs, io) {
365
+ const manifest = await loadManifest();
366
+ const themes = filterThemes(manifest.themes, parsedArgs);
367
+
368
+ if (isJson(parsedArgs)) {
369
+ io.stdout.write(`${JSON.stringify(themes, null, 4)}\n`);
370
+ return 0;
371
+ }
372
+
373
+ io.stdout.write(formatThemeTable(themes));
374
+ return 0;
375
+ }
376
+
377
+ /**
378
+ * @param {ParsedArgs} parsedArgs
379
+ * @param {CliIo} io
380
+ *
381
+ * @returns {Promise<number>}
382
+ */
383
+ async function handlePath(parsedArgs, io) {
384
+ const manifest = await loadManifest();
385
+ const query = parsedArgs.positionals[1];
386
+
387
+ if (query === undefined) {
388
+ throw new Error("Specify a theme id, name, or file name.");
389
+ }
390
+
391
+ const theme = resolveTheme(manifest.themes, query);
392
+ io.stdout.write(`${getThemeFilePath(theme)}\n`);
393
+ return 0;
394
+ }
395
+
396
+ /**
397
+ * @param {ParsedArgs} parsedArgs
398
+ * @param {CliIo} io
399
+ *
400
+ * @returns {Promise<number>}
401
+ */
402
+ async function handlePicker(parsedArgs, io) {
403
+ const manifest = await loadManifest();
404
+ const themes = filterThemes(manifest.themes, parsedArgs);
405
+
406
+ if (!io.stdin.isTTY || !io.stdout.isTTY) {
407
+ throw new Error(
408
+ "Interactive picker requires a TTY. Use `list` or `show` in scripts."
409
+ );
410
+ }
411
+
412
+ const selectedTheme = await runPicker(themes, io);
413
+ if (selectedTheme === null) {
414
+ io.stdout.write("No theme selected.\n");
415
+ return 1;
416
+ }
417
+
418
+ if (hasFlag(parsedArgs, "install")) {
419
+ const installArgs = [
420
+ "install",
421
+ selectedTheme.id,
422
+ ...serializeForwardedInstallFlags(parsedArgs),
423
+ ];
424
+ return runCli(installArgs, io);
425
+ }
426
+
427
+ io.stdout.write(
428
+ `${selectedTheme.id}\t${selectedTheme.name}\t${selectedTheme.fileName}\n`
429
+ );
430
+ return 0;
431
+ }
432
+
433
+ /**
434
+ * @param {ParsedArgs} parsedArgs
435
+ * @param {CliIo} io
436
+ *
437
+ * @returns {Promise<number>}
438
+ */
439
+ async function handleShow(parsedArgs, io) {
440
+ const manifest = await loadManifest();
441
+ const query = parsedArgs.positionals[1];
442
+
443
+ if (query === undefined) {
444
+ throw new Error("Specify a theme id, name, or file name.");
445
+ }
446
+
447
+ const theme = resolveTheme(manifest.themes, query);
448
+
449
+ if (isJson(parsedArgs)) {
450
+ io.stdout.write(`${JSON.stringify(theme, null, 4)}\n`);
451
+ return 0;
452
+ }
453
+
454
+ io.stdout.write(await formatThemeDetails(theme));
455
+ return 0;
456
+ }
457
+
458
+ /**
459
+ * @param {{
460
+ * readonly destination: string;
461
+ * readonly dryRun: boolean;
462
+ * readonly force: boolean;
463
+ * readonly source: string;
464
+ * readonly target: string;
465
+ * readonly theme: Theme;
466
+ * }} options
467
+ *
468
+ * @returns {Promise<Record<string, string>>}
469
+ */
470
+ async function copyTheme(options) {
471
+ const validation = await validateThemeFile(options.source);
472
+
473
+ if (!validation.ok) {
474
+ return {
475
+ destination: options.destination,
476
+ reason: validation.reason,
477
+ source: options.source,
478
+ status: "failed",
479
+ target: options.target,
480
+ theme: options.theme.id,
481
+ };
482
+ }
483
+
484
+ if (
485
+ !options.force &&
486
+ (await sameFileContent(options.source, options.destination))
487
+ ) {
488
+ return {
489
+ destination: options.destination,
490
+ source: options.source,
491
+ status: "unchanged",
492
+ target: options.target,
493
+ theme: options.theme.id,
494
+ };
495
+ }
496
+
497
+ if (!options.dryRun) {
498
+ await copyFile(options.source, options.destination);
499
+ }
500
+
501
+ return {
502
+ destination: options.destination,
503
+ source: options.source,
504
+ status: options.dryRun ? "dry-run" : "copied",
505
+ target: options.target,
506
+ theme: options.theme.id,
507
+ };
508
+ }
509
+
510
+ /**
511
+ * @param {string} value
512
+ *
513
+ * @returns {string}
514
+ */
515
+ function dim(value) {
516
+ return `\u001B[2m${value}${reset}`;
517
+ }
518
+
519
+ /**
520
+ * @param {string} directory
521
+ * @param {boolean} dryRun
522
+ *
523
+ * @returns {Promise<void>}
524
+ */
525
+ async function ensureTargetDirectory(directory, dryRun) {
526
+ if (dryRun) {
527
+ return;
528
+ }
529
+
530
+ await mkdir(directory, { recursive: true });
531
+ }
532
+
533
+ /**
534
+ * @param {readonly Theme[]} themes
535
+ * @param {ParsedArgs} parsedArgs
536
+ *
537
+ * @returns {readonly Theme[]}
538
+ */
539
+ function filterThemes(themes, parsedArgs) {
540
+ const search = getStringFlag(parsedArgs, "search")?.toLowerCase();
541
+ const appearance = getStringFlag(parsedArgs, "appearance");
542
+ const limit = getNumberFlag(parsedArgs, "limit");
543
+ const filteredThemes = themes.filter((theme) => {
544
+ const matchesSearch =
545
+ search === undefined ||
546
+ [
547
+ theme.id,
548
+ theme.name,
549
+ theme.fileName,
550
+ ]
551
+ .join(" ")
552
+ .toLowerCase()
553
+ .includes(search);
554
+ const matchesAppearance =
555
+ appearance === undefined || theme.appearance === appearance;
556
+ return matchesSearch && matchesAppearance;
557
+ });
558
+
559
+ return limit === undefined
560
+ ? filteredThemes
561
+ : filteredThemes.slice(0, limit);
562
+ }
563
+
564
+ /**
565
+ * @param {CliConfig} config
566
+ *
567
+ * @returns {string}
568
+ */
569
+ function formatConfig(config) {
570
+ const entries = Object.entries(config).toSorted(([left], [right]) =>
571
+ left.localeCompare(right)
572
+ );
573
+
574
+ if (entries.length === 0) {
575
+ return "No CLI config is set.\n";
576
+ }
577
+
578
+ return `${entries.map(([key, value]) => `${key}: ${String(value)}`).join("\n")}\n`;
579
+ }
580
+
581
+ /**
582
+ * @param {Record<string, string>} result
583
+ *
584
+ * @returns {string}
585
+ */
586
+ function formatInstallResult(result) {
587
+ const reason = result.reason === undefined ? "" : ` (${result.reason})`;
588
+ return `${result.status} ${result.target}:${result.theme} -> ${result.destination}${reason}\n`;
589
+ }
590
+
591
+ /**
592
+ * @param {Theme} theme
593
+ *
594
+ * @returns {Promise<string>}
595
+ */
596
+ async function formatThemeDetails(theme) {
597
+ const lines = [
598
+ `${theme.name} (${theme.id})`,
599
+ `File: ${theme.path}`,
600
+ `Appearance: ${theme.appearance}`,
601
+ `UUID: ${theme.uuid}`,
602
+ `Author: ${theme.author ?? "unknown"}`,
603
+ `Stats: ${theme.statistics.settings} settings, ${theme.statistics.uniqueScopes} unique scopes, ${theme.statistics.colorReferences} colors`,
604
+ "",
605
+ formatSwatches(theme.colors),
606
+ "",
607
+ await renderThemePreview(theme),
608
+ "",
609
+ `Scopes: ${theme.scopes.slice(0, 20).join(", ")}${theme.scopes.length > 20 ? ", ..." : ""}`,
610
+ ];
611
+
612
+ return `${lines.join("\n")}\n`;
613
+ }
614
+
615
+ /**
616
+ * @param {readonly Theme[]} themes
617
+ *
618
+ * @returns {string}
619
+ */
620
+ function formatThemeTable(themes) {
621
+ const rows = themes.map((theme) => [
622
+ theme.id,
623
+ theme.appearance,
624
+ theme.colors.background ?? "",
625
+ theme.name,
626
+ ]);
627
+ const widths = [
628
+ Math.max(2, ...rows.map((row) => row[0].length)),
629
+ 10,
630
+ 10,
631
+ ];
632
+ const lines = [
633
+ `${"id".padEnd(widths[0])} ${"appearance".padEnd(widths[1])} ${"background".padEnd(widths[2])} name`,
634
+ `${"-".repeat(widths[0])} ${"-".repeat(widths[1])} ${"-".repeat(widths[2])} ${"-".repeat(4)}`,
635
+ ...rows.map(
636
+ (row) =>
637
+ `${row[0].padEnd(widths[0])} ${row[1].padEnd(widths[1])} ${row[2].padEnd(widths[2])} ${row[3]}`
638
+ ),
639
+ ];
640
+
641
+ return `${lines.join("\n")}\n`;
642
+ }
643
+
644
+ /**
645
+ * @param {ThemeColors} colors
646
+ *
647
+ * @returns {string}
648
+ */
649
+ function formatSwatches(colors) {
650
+ return Object.entries(colors)
651
+ .map(([name, color]) => {
652
+ const swatch =
653
+ color === null ? " " : colorBlock(color, " ");
654
+ return `${name.padEnd(13)} ${swatch} ${color ?? "n/a"}`;
655
+ })
656
+ .join("\n");
657
+ }
658
+
659
+ /**
660
+ * @param {ParsedArgs} parsedArgs
661
+ * @param {NodeJS.ProcessEnv} env
662
+ *
663
+ * @returns {string}
664
+ */
665
+ function getConfigPath(parsedArgs, env) {
666
+ const explicitPath = getStringFlag(parsedArgs, "config");
667
+ if (explicitPath !== undefined) {
668
+ return path.resolve(explicitPath);
669
+ }
670
+
671
+ if (env.CODEX_TERMINAL_THEMES_CONFIG !== undefined) {
672
+ return path.resolve(env.CODEX_TERMINAL_THEMES_CONFIG);
673
+ }
674
+
675
+ if (process.platform === "win32" && env.APPDATA !== undefined) {
676
+ return path.join(env.APPDATA, "codex-terminal-themes", "config.json");
677
+ }
678
+
679
+ const configHome =
680
+ env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
681
+ return path.join(configHome, "codex-terminal-themes", "config.json");
682
+ }
683
+
684
+ /**
685
+ * @param {unknown} error
686
+ *
687
+ * @returns {string}
688
+ */
689
+ function getErrorMessage(error) {
690
+ return error instanceof Error ? error.message : String(error);
691
+ }
692
+
693
+ /**
694
+ * @param {ParsedArgs} parsedArgs
695
+ * @param {string} name
696
+ *
697
+ * @returns {number | undefined}
698
+ */
699
+ function getNumberFlag(parsedArgs, name) {
700
+ const value = getStringFlag(parsedArgs, name);
701
+ if (value === undefined) {
702
+ return undefined;
703
+ }
704
+
705
+ const parsedValue = Number.parseInt(value, 10);
706
+ if (Number.isNaN(parsedValue)) {
707
+ throw new Error(`Expected --${name} to be a number.`);
708
+ }
709
+
710
+ return parsedValue;
711
+ }
712
+
713
+ /**
714
+ * @param {ParsedArgs} parsedArgs
715
+ * @param {string} name
716
+ *
717
+ * @returns {string | undefined}
718
+ */
719
+ function getStringFlag(parsedArgs, name) {
720
+ const value = parsedArgs.flags.get(name);
721
+ return typeof value === "string" ? value : undefined;
722
+ }
723
+
724
+ /**
725
+ * @param {Theme} theme
726
+ *
727
+ * @returns {string}
728
+ */
729
+ function getThemeFilePath(theme) {
730
+ return path.join(packageRoot, theme.path);
731
+ }
732
+
733
+ /**
734
+ * @param {ParsedArgs} parsedArgs
735
+ * @param {string} name
736
+ *
737
+ * @returns {boolean}
738
+ */
739
+ function hasFlag(parsedArgs, name) {
740
+ return parsedArgs.flags.has(name);
741
+ }
742
+
743
+ /**
744
+ * @param {ParsedArgs} parsedArgs
745
+ *
746
+ * @returns {boolean}
747
+ */
748
+ function isJson(parsedArgs) {
749
+ return hasFlag(parsedArgs, "json");
750
+ }
751
+
752
+ /**
753
+ * @param {unknown} value
754
+ *
755
+ * @returns {value is Record<string, unknown>}
756
+ */
757
+ function isRecord(value) {
758
+ return typeof value === "object" && value !== null && !Array.isArray(value);
759
+ }
760
+
761
+ /**
762
+ * @returns {Promise<ThemeManifest>}
763
+ */
764
+ async function loadManifest() {
765
+ const manifestText = await readFile(manifestPath, "utf8");
766
+ const manifest = /** @type {ThemeManifest} */ (JSON.parse(manifestText));
767
+
768
+ if (!Array.isArray(manifest.themes)) {
769
+ throw new Error("Invalid metadata/themes.json: missing themes array.");
770
+ }
771
+
772
+ return manifest;
773
+ }
774
+
775
+ /**
776
+ * @param {string[]} args
777
+ *
778
+ * @returns {ParsedArgs}
779
+ */
780
+ function parseArgs(args) {
781
+ /** @type {Map<string, string | true>} */
782
+ const flags = new Map();
783
+ /** @type {string[]} */
784
+ const positionals = [];
785
+
786
+ for (let index = 0; index < args.length; index += 1) {
787
+ const arg = args[index];
788
+
789
+ if (arg === "--") {
790
+ positionals.push(...args.slice(index + 1));
791
+ break;
792
+ }
793
+
794
+ if (arg.startsWith("--")) {
795
+ const [rawName, inlineValue] = arg.slice(2).split("=", 2);
796
+ const nextArg = args[index + 1];
797
+ if (inlineValue !== undefined) {
798
+ flags.set(rawName, inlineValue);
799
+ } else if (nextArg !== undefined && !nextArg.startsWith("-")) {
800
+ flags.set(rawName, nextArg);
801
+ index += 1;
802
+ } else {
803
+ flags.set(rawName, true);
804
+ }
805
+ continue;
806
+ }
807
+
808
+ if (arg.startsWith("-") && arg.length > 1) {
809
+ for (const flag of arg.slice(1)) {
810
+ flags.set(flag, true);
811
+ }
812
+ continue;
813
+ }
814
+
815
+ positionals.push(arg);
816
+ }
817
+
818
+ return { args, flags, positionals };
819
+ }
820
+
821
+ /**
822
+ * @param {string} key
823
+ * @param {string} value
824
+ *
825
+ * @returns {boolean | string}
826
+ */
827
+ function parseConfigValue(key, value) {
828
+ if (key === "skipBatCache") {
829
+ if (
830
+ [
831
+ "1",
832
+ "true",
833
+ "yes",
834
+ ].includes(value.toLowerCase())
835
+ ) {
836
+ return true;
837
+ }
838
+
839
+ if (
840
+ [
841
+ "0",
842
+ "false",
843
+ "no",
844
+ ].includes(value.toLowerCase())
845
+ ) {
846
+ return false;
847
+ }
848
+
849
+ throw new Error("skipBatCache must be true or false.");
850
+ }
851
+
852
+ if (key === "defaultTarget") {
853
+ parseTargets(value);
854
+ }
855
+
856
+ return value;
857
+ }
858
+
859
+ /**
860
+ * @param {string} value
861
+ *
862
+ * @returns {readonly string[]}
863
+ */
864
+ function parseTargets(value) {
865
+ const targets = value === "both" ? ["codex", "bat"] : value.split(",");
866
+ const normalizedTargets = targets
867
+ .map((target) => target.trim())
868
+ .filter(Boolean);
869
+
870
+ for (const target of normalizedTargets) {
871
+ if (!["bat", "codex"].includes(target)) {
872
+ throw new Error("Target must be codex, bat, or both.");
873
+ }
874
+ }
875
+
876
+ return [...new Set(normalizedTargets)];
877
+ }
878
+
879
+ /**
880
+ * @returns {Promise<Record<string, unknown>>}
881
+ */
882
+ async function readPackageJson() {
883
+ return /** @type {Record<string, unknown>} */ (
884
+ JSON.parse(
885
+ await readFile(path.join(packageRoot, "package.json"), "utf8")
886
+ )
887
+ );
888
+ }
889
+
890
+ /**
891
+ * @param {string} configPath
892
+ *
893
+ * @returns {Promise<CliConfig>}
894
+ */
895
+ async function readConfig(configPath) {
896
+ try {
897
+ const configText = await readFile(configPath, "utf8");
898
+ const config = /** @type {CliConfig} */ (JSON.parse(configText));
899
+
900
+ if (!isRecord(config)) {
901
+ throw new Error(`Config file is not a JSON object: ${configPath}`);
902
+ }
903
+
904
+ return config;
905
+ } catch (error) {
906
+ if (isNodeError(error) && error.code === "ENOENT") {
907
+ return {};
908
+ }
909
+
910
+ throw error;
911
+ }
912
+ }
913
+
914
+ /**
915
+ * @param {unknown} error
916
+ *
917
+ * @returns {error is NodeJS.ErrnoException}
918
+ */
919
+ function isNodeError(error) {
920
+ return error instanceof Error && "code" in error;
921
+ }
922
+
923
+ /**
924
+ * @param {string} configPath
925
+ * @param {CliConfig} config
926
+ *
927
+ * @returns {Promise<void>}
928
+ */
929
+ async function writeConfig(configPath, config) {
930
+ await mkdir(path.dirname(configPath), { recursive: true });
931
+
932
+ await writeFile(configPath, `${JSON.stringify(config, null, 4)}\n`, "utf8");
933
+ }
934
+
935
+ /**
936
+ * @param {Theme} theme
937
+ *
938
+ * @returns {Promise<string>}
939
+ */
940
+ async function renderThemePreview(theme) {
941
+ const styles = await readThemeStyles(theme);
942
+ const globalStyle = {
943
+ background: theme.colors.background ?? "#000000",
944
+ foreground: theme.colors.foreground ?? "#FFFFFF",
945
+ };
946
+ const tokenLine = sampleTokens
947
+ .map((token) => {
948
+ const style = resolveStyle(styles, token.scope);
949
+ return colorText(token.text, {
950
+ background: style.background ?? globalStyle.background,
951
+ foreground: style.foreground ?? globalStyle.foreground,
952
+ });
953
+ })
954
+ .join(" ");
955
+ const scopeLines = sampleTokens.map((token) => {
956
+ const style = resolveStyle(styles, token.scope);
957
+ const foreground = style.foreground ?? globalStyle.foreground;
958
+ const background = style.background ?? globalStyle.background;
959
+ return `${token.label.padEnd(10)} ${colorBlock(background, " ")} ${foreground.padEnd(9)} ${colorText(token.scope, { background, foreground })}`;
960
+ });
961
+ return [
962
+ colorText(" Theme preview ", globalStyle),
963
+ tokenLine,
964
+ ...scopeLines,
965
+ ].join("\n");
966
+ }
967
+
968
+ /**
969
+ * @param {readonly ThemeStyle[]} styles
970
+ * @param {string} scope
971
+ *
972
+ * @returns {ThemeStyle}
973
+ */
974
+ function resolveStyle(styles, scope) {
975
+ return (
976
+ styles.find((style) => style.scopeParts?.includes(scope)) ??
977
+ styles.find((style) =>
978
+ style.scopeParts?.some(
979
+ (scopePart) =>
980
+ scopePart === scope ||
981
+ scopePart.startsWith(`${scope}.`) ||
982
+ scope.startsWith(`${scopePart}.`)
983
+ )
984
+ ) ??
985
+ {}
986
+ );
987
+ }
988
+
989
+ /**
990
+ * @param {Theme} theme
991
+ *
992
+ * @returns {Promise<
993
+ * readonly (ThemeStyle & { readonly scopeParts?: readonly string[] })[]
994
+ * >}
995
+ */
996
+ async function readThemeStyles(theme) {
997
+ const source = getThemeFilePath(theme);
998
+
999
+ const text = await readFile(source, "utf8");
1000
+ const parsedDocument = /** @type {unknown} */ (parser.parse(text));
1001
+ const topLevelEntries = getTopLevelDictionary(parsedDocument);
1002
+ const settingsNode = topLevelEntries.get("settings");
1003
+ const settingsArray =
1004
+ isRecord(settingsNode) && Array.isArray(settingsNode.array)
1005
+ ? settingsNode.array
1006
+ : [];
1007
+
1008
+ return settingsArray.flatMap((item) => {
1009
+ if (!isRecord(item) || !Array.isArray(item.dict)) {
1010
+ return [];
1011
+ }
1012
+
1013
+ const entries = getDictionaryEntries(item.dict);
1014
+ const scope = getStringValue(entries.get("scope"));
1015
+ const settings = getDictionaryValue(entries, "settings");
1016
+ const foreground = getStringValue(settings.get("foreground"));
1017
+ const background = getStringValue(settings.get("background"));
1018
+ const fontStyle = getStringValue(settings.get("fontStyle"));
1019
+
1020
+ if (
1021
+ scope === undefined ||
1022
+ (foreground === undefined && background === undefined)
1023
+ ) {
1024
+ return [];
1025
+ }
1026
+
1027
+ return [
1028
+ {
1029
+ background,
1030
+ fontStyle,
1031
+ foreground,
1032
+ scopeParts: scope
1033
+ .split(",")
1034
+ .map((scopePart) => scopePart.trim())
1035
+ .filter(Boolean),
1036
+ },
1037
+ ];
1038
+ });
1039
+ }
1040
+
1041
+ /**
1042
+ * @param {readonly unknown[]} dictChildren
1043
+ *
1044
+ * @returns {Map<string, unknown>}
1045
+ */
1046
+ function getDictionaryEntries(dictChildren) {
1047
+ /** @type {Map<string, unknown>} */
1048
+ const entries = new Map();
1049
+ /** @type {string | undefined} */
1050
+ let currentKey;
1051
+
1052
+ for (const child of dictChildren) {
1053
+ if (isRecord(child) && Object.hasOwn(child, "key")) {
1054
+ currentKey = getTextNodeValue(child.key);
1055
+ continue;
1056
+ }
1057
+
1058
+ if (
1059
+ currentKey !== undefined &&
1060
+ !(isRecord(child) && Object.hasOwn(child, "#text"))
1061
+ ) {
1062
+ entries.set(currentKey, child);
1063
+ currentKey = undefined;
1064
+ }
1065
+ }
1066
+
1067
+ return entries;
1068
+ }
1069
+
1070
+ /**
1071
+ * @param {Map<string, unknown>} entries
1072
+ * @param {string} key
1073
+ *
1074
+ * @returns {Map<string, unknown>}
1075
+ */
1076
+ function getDictionaryValue(entries, key) {
1077
+ const value = entries.get(key);
1078
+ return isRecord(value) && Array.isArray(value.dict)
1079
+ ? getDictionaryEntries(value.dict)
1080
+ : new Map();
1081
+ }
1082
+
1083
+ /**
1084
+ * @param {unknown} parsedDocument
1085
+ *
1086
+ * @returns {Map<string, unknown>}
1087
+ */
1088
+ function getTopLevelDictionary(parsedDocument) {
1089
+ if (!Array.isArray(parsedDocument)) {
1090
+ return new Map();
1091
+ }
1092
+
1093
+ const plistNode = parsedDocument.find(
1094
+ (node) => isRecord(node) && Object.hasOwn(node, "plist")
1095
+ );
1096
+ if (!isRecord(plistNode) || !Array.isArray(plistNode.plist)) {
1097
+ return new Map();
1098
+ }
1099
+
1100
+ const dictNode = plistNode.plist.find(
1101
+ (node) => isRecord(node) && Object.hasOwn(node, "dict")
1102
+ );
1103
+ return isRecord(dictNode) && Array.isArray(dictNode.dict)
1104
+ ? getDictionaryEntries(dictNode.dict)
1105
+ : new Map();
1106
+ }
1107
+
1108
+ /**
1109
+ * @param {unknown} value
1110
+ *
1111
+ * @returns {string | undefined}
1112
+ */
1113
+ function getStringValue(value) {
1114
+ if (!isRecord(value)) {
1115
+ return undefined;
1116
+ }
1117
+
1118
+ return getTextNodeValue(value.string);
1119
+ }
1120
+
1121
+ /**
1122
+ * @param {unknown} value
1123
+ *
1124
+ * @returns {string | undefined}
1125
+ */
1126
+ function getTextNodeValue(value) {
1127
+ if (!Array.isArray(value) || !isRecord(value[0])) {
1128
+ return undefined;
1129
+ }
1130
+
1131
+ const textValue = value[0]["#text"];
1132
+ return typeof textValue === "string" ? textValue : undefined;
1133
+ }
1134
+
1135
+ /**
1136
+ * @param {ParsedArgs} parsedArgs
1137
+ * @param {CliConfig} config
1138
+ * @param {CliIo} io
1139
+ *
1140
+ * @returns {Promise<
1141
+ * readonly { readonly directory: string; readonly name: string }[]
1142
+ * >}
1143
+ */
1144
+ async function resolveInstallTargets(parsedArgs, config, io) {
1145
+ const targetValue =
1146
+ getStringFlag(parsedArgs, "target") ?? config.defaultTarget ?? "both";
1147
+ const targetNames = parseTargets(targetValue);
1148
+ const targets = [];
1149
+
1150
+ if (targetNames.includes("codex")) {
1151
+ targets.push({
1152
+ directory:
1153
+ getStringFlag(parsedArgs, "codex-dir") ??
1154
+ config.codexDir ??
1155
+ path.join(
1156
+ io.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"),
1157
+ "themes"
1158
+ ),
1159
+ name: "codex",
1160
+ });
1161
+ }
1162
+
1163
+ if (targetNames.includes("bat")) {
1164
+ const batDirectory =
1165
+ getStringFlag(parsedArgs, "bat-dir") ??
1166
+ config.batDir ??
1167
+ (await resolveBatThemeDirectory(io));
1168
+ targets.push({
1169
+ directory: batDirectory,
1170
+ name: "bat",
1171
+ });
1172
+ }
1173
+
1174
+ return targets;
1175
+ }
1176
+
1177
+ /**
1178
+ * @param {CliIo} io
1179
+ *
1180
+ * @returns {Promise<string>}
1181
+ */
1182
+ async function resolveBatThemeDirectory(io) {
1183
+ const result = await spawnText("bat", ["--config-dir"], io);
1184
+ if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
1185
+ throw new Error(
1186
+ "bat is not available. Use --target codex or --bat-dir."
1187
+ );
1188
+ }
1189
+
1190
+ return path.join(result.stdout.trim(), "themes");
1191
+ }
1192
+
1193
+ /**
1194
+ * @param {readonly Theme[]} themes
1195
+ * @param {readonly string[]} queries
1196
+ *
1197
+ * @returns {readonly Theme[]}
1198
+ */
1199
+ function resolveThemes(themes, queries) {
1200
+ return queries.map((query) => resolveTheme(themes, query));
1201
+ }
1202
+
1203
+ /**
1204
+ * @param {readonly Theme[]} themes
1205
+ * @param {string} query
1206
+ *
1207
+ * @returns {Theme}
1208
+ */
1209
+ function resolveTheme(themes, query) {
1210
+ const normalizedQuery = query.toLowerCase();
1211
+ const exactMatch = themes.find(
1212
+ (theme) =>
1213
+ theme.id.toLowerCase() === normalizedQuery ||
1214
+ theme.name.toLowerCase() === normalizedQuery ||
1215
+ theme.fileName.toLowerCase() === normalizedQuery ||
1216
+ theme.path.toLowerCase() === normalizedQuery
1217
+ );
1218
+
1219
+ if (exactMatch !== undefined) {
1220
+ return exactMatch;
1221
+ }
1222
+
1223
+ const fuzzyMatches = themes.filter((theme) =>
1224
+ [
1225
+ theme.id,
1226
+ theme.name,
1227
+ theme.fileName,
1228
+ ]
1229
+ .join(" ")
1230
+ .toLowerCase()
1231
+ .includes(normalizedQuery)
1232
+ );
1233
+
1234
+ if (fuzzyMatches.length === 1) {
1235
+ return fuzzyMatches[0];
1236
+ }
1237
+
1238
+ if (fuzzyMatches.length > 1) {
1239
+ throw new Error(
1240
+ `Theme query is ambiguous: ${query}. Matches: ${fuzzyMatches
1241
+ .slice(0, 8)
1242
+ .map((theme) => theme.id)
1243
+ .join(", ")}`
1244
+ );
1245
+ }
1246
+
1247
+ throw new Error(`Theme not found: ${query}`);
1248
+ }
1249
+
1250
+ /**
1251
+ * @param {ThemeManifest} manifest
1252
+ * @param {CliConfig} config
1253
+ * @param {ParsedArgs} parsedArgs
1254
+ * @param {CliIo} io
1255
+ *
1256
+ * @returns {Promise<readonly DoctorCheck[]>}
1257
+ */
1258
+ async function runDoctorChecks(manifest, config, parsedArgs, io) {
1259
+ const sampleThemes = manifest.themes.slice(0, 20);
1260
+ const codexDir =
1261
+ getStringFlag(parsedArgs, "codex-dir") ??
1262
+ config.codexDir ??
1263
+ path.join(
1264
+ io.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"),
1265
+ "themes"
1266
+ );
1267
+ const checks = [
1268
+ {
1269
+ detail: `${manifest.themeCount} themes in metadata`,
1270
+ name: "manifest",
1271
+ ok:
1272
+ manifest.themeCount === manifest.themes.length &&
1273
+ manifest.themeCount > 0,
1274
+ },
1275
+ {
1276
+ detail: await directoryDetail(themeRoot),
1277
+ name: "theme directory",
1278
+ ok: await isDirectory(themeRoot),
1279
+ },
1280
+ {
1281
+ detail: await validateSampleThemes(sampleThemes),
1282
+ name: "theme validation sample",
1283
+ ok: (
1284
+ await Promise.all(
1285
+ sampleThemes.map((theme) =>
1286
+ validateThemeFile(getThemeFilePath(theme))
1287
+ )
1288
+ )
1289
+ ).every((result) => result.ok),
1290
+ },
1291
+ {
1292
+ detail: await directoryAccessDetail(codexDir),
1293
+ name: "codex theme directory",
1294
+ ok: await canCreateOrWriteDirectory(codexDir),
1295
+ },
1296
+ ];
1297
+ const batResult = await spawnText("bat", ["--version"], io);
1298
+ checks.push({
1299
+ detail:
1300
+ batResult.exitCode === 0
1301
+ ? batResult.stdout.trim()
1302
+ : "bat not found or not executable",
1303
+ name: "bat executable",
1304
+ ok: batResult.exitCode === 0,
1305
+ });
1306
+
1307
+ return checks;
1308
+ }
1309
+
1310
+ /**
1311
+ * @param {readonly Theme[]} themes
1312
+ * @param {CliIo} io
1313
+ *
1314
+ * @returns {Promise<Theme | null>}
1315
+ */
1316
+ async function runPicker(themes, io) {
1317
+ let index = 0;
1318
+ let query = "";
1319
+ let filteredThemes = themes;
1320
+
1321
+ const render = async () => {
1322
+ filteredThemes = themes.filter((theme) =>
1323
+ [
1324
+ theme.id,
1325
+ theme.name,
1326
+ theme.fileName,
1327
+ ]
1328
+ .join(" ")
1329
+ .toLowerCase()
1330
+ .includes(query.toLowerCase())
1331
+ );
1332
+ index = Math.min(index, Math.max(0, filteredThemes.length - 1));
1333
+ const selectedTheme = filteredThemes[index];
1334
+ const height = io.stdout.rows ?? 30;
1335
+ const listHeight = Math.max(5, Math.min(12, height - 14));
1336
+ const start = Math.max(0, index - Math.floor(listHeight / 2));
1337
+ const visibleThemes = filteredThemes.slice(start, start + listHeight);
1338
+ const lines = [
1339
+ "\u001B[?25l\u001B[2J\u001B[H",
1340
+ "codex-terminal-themes picker",
1341
+ dim(
1342
+ "Type to filter, use arrows or j/k, Enter selects, q/Esc cancels."
1343
+ ),
1344
+ `Search: ${query || dim("(all)")}`,
1345
+ "",
1346
+ ...visibleThemes.map((theme, visibleIndex) => {
1347
+ const actualIndex = start + visibleIndex;
1348
+ const marker = actualIndex === index ? ">" : " ";
1349
+ return `${marker} ${theme.id.padEnd(40).slice(0, 40)} ${theme.appearance.padEnd(7)} ${theme.name}`;
1350
+ }),
1351
+ "",
1352
+ selectedTheme === undefined
1353
+ ? "No matches."
1354
+ : await renderThemePreview(selectedTheme),
1355
+ ];
1356
+ io.stdout.write(lines.join("\n"));
1357
+ };
1358
+
1359
+ io.stdin.setRawMode(true);
1360
+ io.stdin.resume();
1361
+ io.stdin.setEncoding("utf8");
1362
+
1363
+ try {
1364
+ await render();
1365
+
1366
+ return await new Promise((resolve) => {
1367
+ /**
1368
+ * @param {Buffer | string} chunk
1369
+ *
1370
+ * @returns {void}
1371
+ */
1372
+ const onData = (chunk) => {
1373
+ const input = String(chunk);
1374
+
1375
+ if (input === "\u0003" || input === "\u001B" || input === "q") {
1376
+ cleanup();
1377
+ resolve(null);
1378
+ return;
1379
+ }
1380
+
1381
+ if (input === "\r" || input === "\n") {
1382
+ const selectedTheme = filteredThemes[index] ?? null;
1383
+ cleanup();
1384
+ resolve(selectedTheme);
1385
+ return;
1386
+ }
1387
+
1388
+ if (input === "\u001B[A" || input === "k") {
1389
+ index = Math.max(0, index - 1);
1390
+ } else if (input === "\u001B[B" || input === "j") {
1391
+ index = Math.min(
1392
+ Math.max(0, filteredThemes.length - 1),
1393
+ index + 1
1394
+ );
1395
+ } else if (input === "\u001B[5~") {
1396
+ index = Math.max(0, index - 10);
1397
+ } else if (input === "\u001B[6~") {
1398
+ index = Math.min(
1399
+ Math.max(0, filteredThemes.length - 1),
1400
+ index + 10
1401
+ );
1402
+ } else if (input === "\u007F" || input === "\b") {
1403
+ query = query.slice(0, -1);
1404
+ index = 0;
1405
+ } else if (isPickerSearchCharacter(input)) {
1406
+ query += input;
1407
+ index = 0;
1408
+ }
1409
+
1410
+ void render();
1411
+ };
1412
+
1413
+ const cleanup = () => {
1414
+ io.stdin.off("data", onData);
1415
+ io.stdin.setRawMode(false);
1416
+ io.stdout.write("\u001B[2J\u001B[H\u001B[?25h");
1417
+ };
1418
+
1419
+ io.stdin.on("data", onData);
1420
+ });
1421
+ } catch (error) {
1422
+ io.stdin.setRawMode(false);
1423
+ io.stdout.write("\u001B[?25h");
1424
+ throw error;
1425
+ }
1426
+ }
1427
+
1428
+ /**
1429
+ * @param {CliIo} io
1430
+ *
1431
+ * @returns {Promise<Record<string, string>>}
1432
+ */
1433
+ async function runBatCache(io) {
1434
+ const result = await spawnText("bat", ["cache", "--build"], io);
1435
+ return {
1436
+ destination: "bat cache",
1437
+ reason: result.exitCode === 0 ? "" : result.stderr.trim(),
1438
+ source: "bat cache --build",
1439
+ status: result.exitCode === 0 ? "rebuilt" : "failed",
1440
+ target: "bat",
1441
+ theme: "cache",
1442
+ };
1443
+ }
1444
+
1445
+ /**
1446
+ * @param {ParsedArgs} parsedArgs
1447
+ *
1448
+ * @returns {readonly string[]}
1449
+ */
1450
+ function serializeForwardedInstallFlags(parsedArgs) {
1451
+ const forwardedFlags = [
1452
+ "bat-dir",
1453
+ "codex-dir",
1454
+ "config",
1455
+ "dry-run",
1456
+ "force",
1457
+ "json",
1458
+ "skip-bat-cache",
1459
+ "target",
1460
+ ];
1461
+ return forwardedFlags.flatMap((flag) => {
1462
+ const value = parsedArgs.flags.get(flag);
1463
+ if (value === undefined) {
1464
+ return [];
1465
+ }
1466
+
1467
+ return value === true ? [`--${flag}`] : [`--${flag}`, value];
1468
+ });
1469
+ }
1470
+
1471
+ /**
1472
+ * @param {string} source
1473
+ * @param {string} destination
1474
+ *
1475
+ * @returns {Promise<boolean>}
1476
+ */
1477
+ async function sameFileContent(source, destination) {
1478
+ try {
1479
+ const [sourceHash, destinationHash] = await Promise.all([
1480
+ hashFile(source),
1481
+ hashFile(destination),
1482
+ ]);
1483
+ return sourceHash === destinationHash;
1484
+ } catch (error) {
1485
+ if (isNodeError(error) && error.code === "ENOENT") {
1486
+ return false;
1487
+ }
1488
+
1489
+ throw error;
1490
+ }
1491
+ }
1492
+
1493
+ /**
1494
+ * @param {string} filePath
1495
+ *
1496
+ * @returns {Promise<string>}
1497
+ */
1498
+ async function hashFile(filePath) {
1499
+ return createHash("sha256")
1500
+ .update(await readFile(filePath))
1501
+ .digest("hex");
1502
+ }
1503
+
1504
+ /**
1505
+ * @param {string} command
1506
+ * @param {readonly string[]} args
1507
+ * @param {CliIo} io
1508
+ *
1509
+ * @returns {Promise<{
1510
+ * readonly exitCode: number;
1511
+ * readonly stderr: string;
1512
+ * readonly stdout: string;
1513
+ * }>}
1514
+ */
1515
+ async function spawnText(command, args, io) {
1516
+ return new Promise((resolve) => {
1517
+ const childProcess = spawn(command, args, {
1518
+ cwd: io.cwd,
1519
+ env: io.env,
1520
+ shell: false,
1521
+ stdio: [
1522
+ "ignore",
1523
+ "pipe",
1524
+ "pipe",
1525
+ ],
1526
+ });
1527
+ /** @type {Buffer[]} */
1528
+ const stdout = [];
1529
+ /** @type {Buffer[]} */
1530
+ const stderr = [];
1531
+
1532
+ childProcess.stdout.on("data", (chunk) => {
1533
+ stdout.push(Buffer.from(chunk));
1534
+ });
1535
+ childProcess.stderr.on("data", (chunk) => {
1536
+ stderr.push(Buffer.from(chunk));
1537
+ });
1538
+ childProcess.on("error", (error) => {
1539
+ resolve({
1540
+ exitCode: 1,
1541
+ stderr: getErrorMessage(error),
1542
+ stdout: "",
1543
+ });
1544
+ });
1545
+ childProcess.on("close", (exitCode) => {
1546
+ resolve({
1547
+ exitCode: exitCode ?? 1,
1548
+ stderr: Buffer.concat(stderr).toString("utf8"),
1549
+ stdout: Buffer.concat(stdout).toString("utf8"),
1550
+ });
1551
+ });
1552
+ });
1553
+ }
1554
+
1555
+ /**
1556
+ * @param {string} input
1557
+ *
1558
+ * @returns {boolean}
1559
+ */
1560
+ function isPickerSearchCharacter(input) {
1561
+ return input.length === 1 && input >= " " && input !== "\u007F";
1562
+ }
1563
+
1564
+ /**
1565
+ * @param {string} filePath
1566
+ *
1567
+ * @returns {Promise<
1568
+ * | { readonly ok: true; readonly reason: "" }
1569
+ * | { readonly ok: false; readonly reason: string }
1570
+ * >}
1571
+ */
1572
+ async function validateThemeFile(filePath) {
1573
+ try {
1574
+ const text = await readFile(filePath, "utf8");
1575
+ const validation = SyntaxValidator.validate(text);
1576
+
1577
+ if (validation !== true) {
1578
+ const { col, line, msg } = validation.err;
1579
+ return {
1580
+ ok: false,
1581
+ reason: `XML parse error at ${line}:${col}: ${msg}`,
1582
+ };
1583
+ }
1584
+
1585
+ return { ok: true, reason: "" };
1586
+ } catch (error) {
1587
+ return { ok: false, reason: getErrorMessage(error) };
1588
+ }
1589
+ }
1590
+
1591
+ /**
1592
+ * @param {readonly Theme[]} sampleThemes
1593
+ *
1594
+ * @returns {Promise<string>}
1595
+ */
1596
+ async function validateSampleThemes(sampleThemes) {
1597
+ const results = await Promise.all(
1598
+ sampleThemes.map((theme) => validateThemeFile(getThemeFilePath(theme)))
1599
+ );
1600
+ const failedResults = results.filter((result) => !result.ok);
1601
+ return failedResults.length === 0
1602
+ ? `validated ${sampleThemes.length} sample themes`
1603
+ : `${failedResults.length} sample themes failed validation`;
1604
+ }
1605
+
1606
+ /**
1607
+ * @param {ParsedArgs} parsedArgs
1608
+ * @param {NodeJS.WritableStream} stdout
1609
+ * @param {unknown} jsonValue
1610
+ * @param {string} textValue
1611
+ *
1612
+ * @returns {void}
1613
+ */
1614
+ function writeJsonOrText(parsedArgs, stdout, jsonValue, textValue) {
1615
+ stdout.write(
1616
+ isJson(parsedArgs)
1617
+ ? `${JSON.stringify(jsonValue, null, 4)}\n`
1618
+ : textValue
1619
+ );
1620
+ }
1621
+
1622
+ /**
1623
+ * @param {NodeJS.WritableStream} stream
1624
+ *
1625
+ * @returns {void}
1626
+ */
1627
+ function writeHelp(stream) {
1628
+ stream.write(
1629
+ 'codex-terminal-themes\n\nUsage:\n codex-terminal-themes list [--search text] [--appearance dark|light|unknown] [--json]\n codex-terminal-themes show <theme> [--json]\n codex-terminal-themes path <theme>\n codex-terminal-themes install <theme...|--all> [--target codex|bat|both] [--dry-run]\n codex-terminal-themes pick [--install] [--target codex|bat|both]\n codex-terminal-themes doctor [--json]\n codex-terminal-themes config list|get|set|unset|path\n\nInstall options:\n --codex-dir <path> Override the Codex themes directory.\n --bat-dir <path> Override the bat themes directory.\n --skip-bat-cache Do not run "bat cache --build" after bat installs.\n --force Copy even when source and destination hashes match.\n --config <path> Use a specific CLI config file.\n\nConfig keys:\n defaultTheme, defaultTarget, codexDir, batDir, skipBatCache\n'
1630
+ );
1631
+ }
1632
+
1633
+ /**
1634
+ * @param {string} directory
1635
+ *
1636
+ * @returns {Promise<boolean>}
1637
+ */
1638
+ async function canCreateOrWriteDirectory(directory) {
1639
+ try {
1640
+ if (await isDirectory(directory)) {
1641
+ await access(directory, fsConstants.W_OK);
1642
+ return true;
1643
+ }
1644
+
1645
+ await access(path.dirname(directory), fsConstants.W_OK);
1646
+ return true;
1647
+ } catch {
1648
+ return false;
1649
+ }
1650
+ }
1651
+
1652
+ /**
1653
+ * @param {string} directory
1654
+ *
1655
+ * @returns {Promise<string>}
1656
+ */
1657
+ async function directoryAccessDetail(directory) {
1658
+ return (await canCreateOrWriteDirectory(directory))
1659
+ ? `writable or creatable: ${directory}`
1660
+ : `not writable or parent missing: ${directory}`;
1661
+ }
1662
+
1663
+ /**
1664
+ * @param {string} directory
1665
+ *
1666
+ * @returns {Promise<string>}
1667
+ */
1668
+ async function directoryDetail(directory) {
1669
+ if (!(await isDirectory(directory))) {
1670
+ return `missing: ${directory}`;
1671
+ }
1672
+
1673
+ const files = await readdir(directory);
1674
+ return `${files.filter((fileName) => fileName.endsWith(".tmTheme")).length} .tmTheme files`;
1675
+ }
1676
+
1677
+ /**
1678
+ * @param {string} directory
1679
+ *
1680
+ * @returns {Promise<boolean>}
1681
+ */
1682
+ async function isDirectory(directory) {
1683
+ try {
1684
+ return (await stat(directory)).isDirectory();
1685
+ } catch {
1686
+ return false;
1687
+ }
1688
+ }
1689
+
1690
+ /**
1691
+ * @param {string | undefined} color
1692
+ * @param {string} text
1693
+ *
1694
+ * @returns {string}
1695
+ */
1696
+ function colorBlock(color, text) {
1697
+ const parsedColor = color === undefined ? null : parseHexColor(color);
1698
+ return parsedColor === null
1699
+ ? text
1700
+ : `\u001B[48;2;${parsedColor.red};${parsedColor.green};${parsedColor.blue}m${text}${reset}`;
1701
+ }
1702
+
1703
+ /**
1704
+ * @param {string} text
1705
+ * @param {{ readonly background?: string; readonly foreground?: string }} style
1706
+ *
1707
+ * @returns {string}
1708
+ */
1709
+ function colorText(text, style) {
1710
+ const foreground = parseHexColor(style.foreground);
1711
+ const background = parseHexColor(style.background);
1712
+ const codes = [];
1713
+
1714
+ if (foreground !== null) {
1715
+ codes.push(
1716
+ `38;2;${foreground.red};${foreground.green};${foreground.blue}`
1717
+ );
1718
+ }
1719
+
1720
+ if (background !== null) {
1721
+ codes.push(
1722
+ `48;2;${background.red};${background.green};${background.blue}`
1723
+ );
1724
+ }
1725
+
1726
+ return codes.length === 0
1727
+ ? text
1728
+ : `\u001B[${codes.join(";")}m${text}${reset}`;
1729
+ }
1730
+
1731
+ /**
1732
+ * @param {string | undefined} color
1733
+ *
1734
+ * @returns {{
1735
+ * readonly blue: number;
1736
+ * readonly green: number;
1737
+ * readonly red: number;
1738
+ * } | null}
1739
+ */
1740
+ function parseHexColor(color) {
1741
+ if (color === undefined) {
1742
+ return null;
1743
+ }
1744
+
1745
+ const match = /^#(?<hex>[\da-f]{3}|[\da-f]{6}|[\da-f]{8})$/iv.exec(
1746
+ color.trim()
1747
+ );
1748
+ if (match?.groups === undefined) {
1749
+ return null;
1750
+ }
1751
+
1752
+ const { hex } = match.groups;
1753
+ const expandedHex =
1754
+ hex.length === 3
1755
+ ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`
1756
+ : hex.slice(0, 6);
1757
+
1758
+ return {
1759
+ blue: Number.parseInt(expandedHex.slice(4, 6), 16),
1760
+ green: Number.parseInt(expandedHex.slice(2, 4), 16),
1761
+ red: Number.parseInt(expandedHex.slice(0, 2), 16),
1762
+ };
1763
+ }