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.
- package/CHANGELOG.md +8 -0
- package/CONTRIBUTING.md +23 -0
- package/LICENSE +21 -0
- package/LICENSE.txt +21 -0
- package/README.md +169 -0
- package/SECURITY.md +5 -0
- package/SUPPORT.md +5 -0
- package/bin/codex-terminal-themes.mjs +12 -0
- package/docs/app.js +1393 -0
- package/docs/assets/theme-gallery-header.png +0 -0
- package/docs/favicon.svg +10 -0
- package/docs/index.html +240 -0
- package/docs/preview.svg +24 -0
- package/docs/site-data.json +51078 -0
- package/docs/styles.css +780 -0
- package/metadata/README.md +71 -0
- package/metadata/themes.json +16185 -0
- package/metadata/themes.schema.json +218 -0
- package/package.json +109 -0
- package/src/cli.mjs +1763 -0
- package/stylelint.config.mjs +12 -0
- package/themes/80s.tmTheme +229 -0
- package/themes/Active4D.tmTheme +407 -0
- package/themes/All Hallow's Eve Custom.tmTheme +275 -0
- package/themes/All Hallow's Eve.tmTheme +277 -0
- package/themes/All Hallows Eve.tmTheme +277 -0
- package/themes/Amy.tmTheme +559 -0
- package/themes/Artic Fall.tmTheme +275 -0
- package/themes/BBEdit.tmTheme +439 -0
- package/themes/Bespin.tmTheme +516 -0
- package/themes/Black Pearl II.tmTheme +496 -0
- package/themes/Black Pearl.tmTheme +400 -0
- package/themes/Blackboard 2.tmTheme +348 -0
- package/themes/Blackboard Black.tmTheme +350 -0
- package/themes/Blackboard.tmTheme +348 -0
- package/themes/Blueberry Jelly.tmTheme +242 -0
- package/themes/Bluesy.tmTheme +242 -0
- package/themes/Blurb 2.tmTheme +229 -0
- package/themes/Blurb.tmTheme +229 -0
- package/themes/Bongzilla 2.tmTheme +223 -0
- package/themes/Bongzilla.tmTheme +223 -0
- package/themes/Brightly Dark.tmTheme +242 -0
- package/themes/Brilliance Black.tmTheme +2619 -0
- package/themes/Brilliance Dull.tmTheme +2243 -0
- package/themes/Bromozoid.tmTheme +227 -0
- package/themes/CSSEdit.tmTheme +203 -0
- package/themes/Classic Modified.tmTheme +469 -0
- package/themes/Clouds Midnight.tmTheme +361 -0
- package/themes/Clouds of Ruby.tmTheme +649 -0
- package/themes/Clouds.tmTheme +348 -0
- package/themes/Cloudy Fields.tmTheme +240 -0
- package/themes/Coal Graal.tmTheme +282 -0
- package/themes/Cobalt.tmTheme +561 -0
- package/themes/Coda.tmTheme +317 -0
- package/themes/Colorful.tmTheme +307 -0
- package/themes/Cool Glow.tmTheme +234 -0
- package/themes/Corona.tmTheme +290 -0
- package/themes/Cowabunga.tmTheme +242 -0
- package/themes/DanBurst.tmTheme +665 -0
- package/themes/Daniel Fischer.tmTheme +627 -0
- package/themes/Dark Ocean.tmTheme +242 -0
- package/themes/DarkNeon.tmTheme +818 -0
- package/themes/Dawn.tmTheme +437 -0
- package/themes/DawnCustom.tmTheme +443 -0
- package/themes/Django (Smoothy).tmTheme +453 -0
- package/themes/Django Blues.tmTheme +182 -0
- package/themes/Django Extended.tmTheme +495 -0
- package/themes/Django.tmTheme +436 -0
- package/themes/Dobdark.tmTheme +615 -0
- package/themes/Dominion Day.tmTheme +562 -0
- package/themes/Doo-Daa.tmTheme +242 -0
- package/themes/Drankin Purp.tmTheme +227 -0
- package/themes/Dreamweaver_Blackbam_Aptana303.tmTheme +980 -0
- package/themes/Easy on my Eyes There Buddy.tmTheme +227 -0
- package/themes/Eiffel.tmTheme +435 -0
- package/themes/Emacs Strict.tmTheme +241 -0
- package/themes/Emacs.tmTheme +241 -0
- package/themes/Epic Blue.tmTheme +320 -0
- package/themes/Erebus.tmTheme +467 -0
- package/themes/Espresso Libre.tmTheme +402 -0
- package/themes/Espresso Tutti.tmTheme +392 -0
- package/themes/Espresso.tmTheme +329 -0
- package/themes/Fade to Grey.tmTheme +308 -0
- package/themes/Fluidvision.tmTheme +443 -0
- package/themes/ForLaTeX.tmTheme +214 -0
- package/themes/Freckle.tmTheme +279 -0
- package/themes/Friendship Bracelet.tmTheme +303 -0
- package/themes/GaGaGaGroovy.tmTheme +227 -0
- package/themes/Gangrene.tmTheme +242 -0
- package/themes/GitHub.tmTheme +653 -0
- package/themes/GlitterBomb.tmTheme +387 -0
- package/themes/Halloween Night.tmTheme +303 -0
- package/themes/Happy happy joy joy.tmTheme +841 -0
- package/themes/Happydeluxe.tmTheme +184 -0
- package/themes/HelvectorLight.tmTheme +557 -0
- package/themes/Humane.tmTheme +220 -0
- package/themes/IDLE.tmTheme +235 -0
- package/themes/IR_Black.tmTheme +810 -0
- package/themes/IR_White.tmTheme +792 -0
- package/themes/Jane Fonda, Baby Redux.tmTheme +264 -0
- package/themes/Johnny.tmTheme +798 -0
- package/themes/Juicy.tmTheme +250 -0
- package/themes/Kuroir Theme.tmTheme +707 -0
- package/themes/LAZY.tmTheme +291 -0
- package/themes/Lowlight.tmTheme +605 -0
- package/themes/Mac Classic.tmTheme +476 -0
- package/themes/Made of Code.tmTheme +695 -0
- package/themes/MagicWB (Amiga).tmTheme +376 -0
- package/themes/Malibu Nights.tmTheme +257 -0
- package/themes/Menage A Trois.tmTheme +881 -0
- package/themes/Merbivore Soft.tmTheme +285 -0
- package/themes/Merbivore.tmTheme +285 -0
- package/themes/Midnight.tmTheme +321 -0
- package/themes/Mmm Sandy.tmTheme +227 -0
- package/themes/Monokai.tmTheme +289 -0
- package/themes/MultiMarkdown.tmTheme +183 -0
- package/themes/Mustang.tmTheme +339 -0
- package/themes/Neopro Inverted.tmTheme +328 -0
- package/themes/Neopro.tmTheme +330 -0
- package/themes/Nice One.tmTheme +222 -0
- package/themes/No Way.tmTheme +255 -0
- package/themes/Overcast.tmTheme +659 -0
- package/themes/Pastels on Dark.tmTheme +703 -0
- package/themes/Pastie.tmTheme +321 -0
- package/themes/Peridinkle.tmTheme +240 -0
- package/themes/Play!.tmTheme +736 -0
- package/themes/Puss.tmTheme +227 -0
- package/themes/Putty.tmTheme +275 -0
- package/themes/Quail.tmTheme +257 -0
- package/themes/RDark.tmTheme +235 -0
- package/themes/Rails Envy.tmTheme +299 -0
- package/themes/Railscasts 2.tmTheme +368 -0
- package/themes/Railscasts.tmTheme +278 -0
- package/themes/Resesif.tmTheme +298 -0
- package/themes/Ringo.tmTheme +240 -0
- package/themes/Ruby Blue.tmTheme +366 -0
- package/themes/RubyRobot.tmTheme +250 -0
- package/themes/Ryan Light.tmTheme +232 -0
- package/themes/Seafoam.tmTheme +242 -0
- package/themes/Sidewalk Chalk.tmTheme +276 -0
- package/themes/Sin City (that yellow bastard).tmTheme +585 -0
- package/themes/Slate.tmTheme +436 -0
- package/themes/Slush & Poppies.tmTheme +336 -0
- package/themes/Slush and Poppies.tmTheme +336 -0
- package/themes/Smokey Morning.tmTheme +229 -0
- package/themes/Smoothy original.tmTheme +623 -0
- package/themes/Smoothy.tmTheme +623 -0
- package/themes/Solarized (dark).tmTheme +2051 -0
- package/themes/Solarized-dark.tmTheme +312 -0
- package/themes/Solarized-light.tmTheme +305 -0
- package/themes/Sometheme.tmTheme +240 -0
- package/themes/SoylentTheme.tmTheme +353 -0
- package/themes/SpaceCadet.tmTheme +212 -0
- package/themes/Spectacular.tmTheme +436 -0
- package/themes/Starlight.tmTheme +857 -0
- package/themes/Stoneship Bright.tmTheme +348 -0
- package/themes/Stoneship.tmTheme +361 -0
- package/themes/Summer Camp Mod.tmTheme +229 -0
- package/themes/Summer Camp.tmTheme +229 -0
- package/themes/Summery Drink.tmTheme +242 -0
- package/themes/Sunburst.tmTheme +665 -0
- package/themes/Swyphs II.tmTheme +306 -0
- package/themes/Tango Bright.tmTheme +1 -0
- package/themes/Tango.tmTheme +450 -0
- package/themes/Teenage Dream.tmTheme +242 -0
- package/themes/Texari.tmTheme +727 -0
- package/themes/Text Ex Machina.tmTheme +295 -0
- package/themes/Tomorrow-Night-Blue.tmTheme +175 -0
- package/themes/Tomorrow-Night-Eighties.tmTheme +175 -0
- package/themes/Tomorrow-Night.tmTheme +175 -0
- package/themes/Tomorrow.tmTheme +349 -0
- package/themes/ToyChest.tmTheme +503 -0
- package/themes/TravisJeffery.tmTheme +1261 -0
- package/themes/Tubster.tmTheme +280 -0
- package/themes/Twilight BG FG.tmTheme +1000 -0
- package/themes/Twilight.tmTheme +516 -0
- package/themes/TwilightMod.tmTheme +529 -0
- package/themes/Two Days Ago.tmTheme +242 -0
- package/themes/Vibrant Fin.tmTheme +447 -0
- package/themes/Vibrant Ink.tmTheme +447 -0
- package/themes/Vibrant Scala.tmTheme +292 -0
- package/themes/Vibrant Tango.tmTheme +438 -0
- package/themes/Wandering.tmTheme +681 -0
- package/themes/Whimsy in Blue.tmTheme +240 -0
- package/themes/Why/342/200/231s Poignant.tmTheme" +191 -0
- package/themes/Windows XP.tmTheme +350 -0
- package/themes/Witch.tmTheme +282 -0
- package/themes/Yurple.tmTheme +227 -0
- package/themes/ZZZ.tmTheme +131 -0
- package/themes/Zachstronaut.tmTheme +381 -0
- package/themes/Zenburnesque.tmTheme +343 -0
- package/themes/[ Argonaut ].tmTheme +387 -0
- package/themes/barf.tmTheme +254 -0
- package/themes/converted-vscode-AmoledShinyBlack.tmTheme +528 -0
- package/themes/converted-vscode-AmoledShinyBlack2.tmTheme +609 -0
- package/themes/converted-vscode-AmoledShinyBlack3.tmTheme +683 -0
- package/themes/converted-vscode-AmoledShinyBlack4.tmTheme +896 -0
- package/themes/converted-vscode-AmoledShinyBlack5.tmTheme +1023 -0
- package/themes/converted-vscode-AmoledShinyBlack6.tmTheme +2092 -0
- package/themes/eclips3.media (ECLM).tmTheme +294 -0
- package/themes/evin.tmTheme +253 -0
- package/themes/fake.tmTheme +669 -0
- package/themes/fapfap.tmTheme +508 -0
- package/themes/helloKitty.tmTheme +293 -0
- package/themes/iLife 05.tmTheme +619 -0
- package/themes/iPlastic.tmTheme +397 -0
- package/themes/idleFingers.tmTheme +380 -0
- package/themes/krTheme.tmTheme +551 -0
- package/themes/mark.james.name.tmTheme +1117 -0
- package/themes/minimal Theme.tmTheme +551 -0
- package/themes/mint.tmTheme +617 -0
- package/themes/mintBlue Dark.tmTheme +653 -0
- package/themes/mintBlue.tmTheme +655 -0
- package/themes/modifiedPastels.tmTheme +745 -0
- package/themes/monoindustrial.tmTheme +451 -0
- package/themes/my-theme-blackboard.tmTheme +363 -0
- package/themes/my-theme-classic.tmTheme +465 -0
- package/themes/nppGLua.tmTheme +293 -0
- package/themes/reST testing theme.tmTheme +554 -0
- package/themes/rose-pine-dawn.tmTheme +329 -0
- package/themes/rose-pine-moon.tmTheme +329 -0
- package/themes/rose-pine.tmTheme +329 -0
- package/themes/ryan-light.tmTheme +232 -0
- package/themes/wut.tmTheme +255 -0
- package/tools/build-pages-site.mjs +465 -0
- package/tools/generate-theme-metadata.mjs +680 -0
- package/tools/serve-pages-site.mjs +196 -0
- package/tools/validate-themes.mjs +272 -0
- 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
|
+
}
|