1/** @jsxImportSource @alchemy.run/sigil */
2/** Branded help screens + the CliOutput formatter that renders them. */
3import { stripVTControlCharacters } from "node:util";
4import * as Option from "effect/Option";
5import * as CliOutput from "effect/cli/CliOutput";
6import type { HelpDoc } from "effect/cli/HelpDoc";
7import { Box, Heading, Text, useGlyphs } from "../ui/index.ts";
8import type { JSX } from "react";
9import packageJson from "../../../../package.json" with { type: "json" };
10import type { CliKit } from "../../CliKit/CliKit.ts";
11import {
12 ANSI_BOLD,
13 ANSI_DIM,
14 ansiFg,
15 glyphsFor,
16 paint,
17 truncate,
18 theme,
19} from "../../CliKit/index.ts";
20import { Logo } from "./Logo.tsx";
21
22const commandLabel = (command: {
23 readonly name: string;
24 readonly alias?: string | undefined;
25}) => (command.alias ? `${command.name}, ${command.alias}` : command.name);
26
27/** Shell-like highlighting shared by usage lines, examples, and help hints. */
28function CommandText({ command }: { readonly command: string }): JSX.Element {
29 const tokens = command.match(
30 /\s+|"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+/g,
31 ) ?? [command];
32 let word = 0;
33 let valueAfterFlag = false;
34
35 return (
36 <Text>
37 {tokens.map((token, index) => {
38 if (/^\s+$/.test(token)) return token;
39
40 const currentWord = word++;
41 const isFlag = token.startsWith("-");
42 const isPlaceholder =
43 (token.startsWith("<") && token.endsWith(">")) ||
44 (token.startsWith("[") && token.endsWith("]"));
45 const isQuoted =
46 (token.startsWith('"') && token.endsWith('"')) ||
47 (token.startsWith("'") && token.endsWith("'"));
48 const isValue = valueAfterFlag || isQuoted;
49 valueAfterFlag = isFlag;
50
51 return (
52 <Text
53 key={`${index}:${token}`}
54 bold={currentWord <= 1 || isFlag}
55 color={
56 currentWord === 0
57 ? theme.color.accent
58 : isFlag
59 ? theme.color.info
60 : isPlaceholder
61 ? theme.color.warning
62 : isValue
63 ? theme.color.success
64 : currentWord === 1
65 ? theme.color.accentBright
66 : undefined
67 }
68 >
69 {token}
70 </Text>
71 );
72 })}
73 </Text>
74 );
75}
76
77/** One aligned help row: fixed-width bold label cell + description. */
78type RowProps = {
79 label: JSX.Element | string;
80 description: string;
81 width: number;
82};
83
84function Row({ label, description, width }: RowProps) {
85 return (
86 <Box paddingLeft={2}>
87 <Box width={width} flexShrink={0}>
88 {typeof label === "string" ? (
89 <Text bold tone="emphasis">
90 {label}
91 </Text>
92 ) : (
93 label
94 )}
95 </Box>
96 <Text>{description}</Text>
97 </Box>
98 );
99}
100
101function BrandLine(): JSX.Element {
102 const glyphs = useGlyphs();
103 return (
104 <Text>
105 <Text tone="brand">{glyphs.selected}</Text>{" "}
106 <Text bold tone="emphasis">
107 alchemy
108 </Text>{" "}
109 <Text tone="muted">— Infrastructure as Effects</Text>
110 </Text>
111 );
112}
113
114/**
115 * Branded help for every command. The root gets a brand header and shows
116 * only `--help`/`--version` (as OPTIONS); subcommands list all global flags.
117 */
118type SubHelpProps = {
119 doc: HelpDoc;
120 root?: boolean;
121};
122
123function SubHelp({ doc, root = false }: SubHelpProps): JSX.Element {
124 const commands = doc.subcommands?.flatMap((group) => group.commands) ?? [];
125 const flags = doc.flags ?? [];
126 const globalFlags = root
127 ? (doc.globalFlags ?? []).filter(
128 (flag) => flag.name === "help" || flag.name === "version",
129 )
130 : (doc.globalFlags ?? []);
131 const args = doc.args ?? [];
132
133 const flagLabel = (flag: (typeof flags)[number]) => {
134 const aliases =
135 flag.aliases.length > 0 ? `, ${flag.aliases.join(", ")}` : "";
136 return `--${flag.name}${aliases}`;
137 };
138 const argLabel = (arg: (typeof args)[number]) =>
139 arg.required ? `<${arg.name}>` : `[${arg.name}]`;
140 // width from labels only — enum types (--log-level's choice list) would
141 // otherwise blow the column out; long types truncate inside the cell
142 const nameWidth =
143 Math.min(
144 30,
145 Math.max(
146 12,
147 ...commands.map((c) => commandLabel(c).length),
148 ...[...flags, ...globalFlags].map((f) => flagLabel(f).length),
149 ...args.map((a) => argLabel(a).length),
150 ),
151 ) + 4;
152
153 type FlagRowProps = { readonly flag: (typeof flags)[number] };
154
155 function FlagRow({ flag }: FlagRowProps) {
156 const label = flagLabel(flag);
157 const typeRoom = nameWidth - label.length - 2;
158 return (
159 <Row
160 width={nameWidth}
161 description={Option.getOrElse(flag.description, () => "")}
162 label={
163 <Text>
164 <Text bold color={theme.color.info}>
165 --{flag.name}
166 </Text>
167 {flag.aliases.map((alias) => (
168 <Text key={alias} color={theme.color.info}>
169 , {alias}
170 </Text>
171 ))}
172 {flag.type === "boolean" || typeRoom < 8 ? null : (
173 <Text color={theme.color.warning}>
174 {" "}
175 {truncate(flag.type, typeRoom)}
176 </Text>
177 )}
178 </Text>
179 }
180 />
181 );
182 }
183
184 return (
185 <Box flexDirection="column" flexShrink={root ? 0 : 1}>
186 {root ? <BrandLine /> : null}
187 {doc.description === "" ? null : (
188 <Box marginTop={root ? 1 : 0}>
189 <Text>{doc.description}</Text>
190 </Box>
191 )}
192 <Box marginTop={doc.description === "" && !root ? 0 : 1}>
193 <Heading glyph={false}>USAGE</Heading>
194 </Box>
195 <Text>
196 {" "}
197 <Text tone="brand">$</Text> <CommandText command={doc.usage} />
198 </Text>
199 {args.length === 0 ? null : (
200 <>
201 <Box marginTop={1}>
202 <Heading glyph={false}>ARGUMENTS</Heading>
203 </Box>
204 {args.map((arg) => (
205 <Row
206 key={arg.name}
207 width={nameWidth}
208 label={
209 <Text bold color={theme.color.warning}>
210 {argLabel(arg)}
211 </Text>
212 }
213 description={Option.getOrElse(arg.description, () => "")}
214 />
215 ))}
216 </>
217 )}
218 {commands.length === 0 ? null : (
219 <>
220 <Box marginTop={1}>
221 <Heading glyph={false}>COMMANDS</Heading>
222 </Box>
223 {commands.map((command) => (
224 <Row
225 key={command.name}
226 width={nameWidth}
227 label={
228 <Text>
229 <Text bold color={theme.color.accentBright}>
230 {command.name}
231 </Text>
232 {command.alias === undefined ? null : (
233 <Text color={theme.color.info}>, {command.alias}</Text>
234 )}
235 </Text>
236 }
237 description={command.description || ""}
238 />
239 ))}
240 </>
241 )}
242 {flags.length === 0 ? null : (
243 <>
244 <Box marginTop={1}>
245 <Heading glyph={false}>FLAGS</Heading>
246 </Box>
247 {flags.map((flag) => (
248 <FlagRow key={flag.name} flag={flag} />
249 ))}
250 </>
251 )}
252 {globalFlags.length === 0 ? null : (
253 <>
254 <Box marginTop={1}>
255 <Heading glyph={false}>{root ? "OPTIONS" : "GLOBAL FLAGS"}</Heading>
256 </Box>
257 {globalFlags.map((flag) => (
258 <FlagRow key={flag.name} flag={flag} />
259 ))}
260 </>
261 )}
262 {doc.examples === undefined || doc.examples.length === 0 ? null : (
263 <>
264 <Box marginTop={1}>
265 <Heading glyph={false}>EXAMPLES</Heading>
266 </Box>
267 {doc.examples.map((example) => (
268 <Text key={example.command}>
269 {" "}
270 <Text tone="brand">$</Text>{" "}
271 <CommandText command={example.command} />
272 </Text>
273 ))}
274 </>
275 )}
276 {commands.length === 0 ? null : (
277 <Box marginTop={1}>
278 <Text>
279 <Text color={theme.color.muted}>Run '</Text>
280 <CommandText
281 command={`${doc.usage.replace(/\s*<subcommand>.*$/, "")} <command> --help`}
282 />
283 <Text color={theme.color.muted}>
284 ' for more information on a command.
285 </Text>
286 </Text>
287 </Box>
288 )}
289 </Box>
290 );
291}
292
293const formatSubHelp = (cli: CliKit["Service"], doc: HelpDoc) => {
294 const termCols = cli.terminal.columns;
295 return cli.output.format(
296 <Box width={termCols}>
297 <SubHelp doc={doc} />
298 </Box>,
299 { columns: termCols },
300 );
301};
302
303/** Hide the logo entirely when it can't be at least this many columns wide. */
304const MIN_LOGO_COLS = 20;
305
306const formatRootHelp = (cli: CliKit["Service"], doc: HelpDoc) => {
307 const termCols = cli.terminal.columns;
308
309 // First pass: render the text alone to measure its exact footprint (post
310 // ANSI codes and wrapping), then size the logo into the leftover space.
311 const text = cli.output.format(
312 <Box width={termCols}>
313 <SubHelp doc={doc} root />
314 </Box>,
315 { columns: termCols },
316 );
317
318 // The braille logo is mojibake without Unicode support.
319 if (!cli.terminal.unicode) return text;
320
321 const lines = text.split("\n");
322 const textWidth = Math.max(
323 ...lines.map((line) => stripVTControlCharacters(line).trimEnd().length),
324 );
325 const textHeight = lines.length;
326
327 // Fill the free space beside the text: a logo of C columns is C/2 rows
328 // tall (braille cell is 1:2). Reserve two rows for the wordmark caption.
329 const logoCols = Math.min(termCols - textWidth - 2, (textHeight - 2) * 2);
330
331 if (logoCols < MIN_LOGO_COLS) return text;
332
333 return cli.output.format(
334 <Box flexDirection="row" width={termCols}>
335 <SubHelp doc={doc} root />
336 <Box
337 flexGrow={1}
338 flexDirection="column"
339 justifyContent="center"
340 alignItems="center"
341 >
342 <Logo cols={logoCols} />
343 <Box marginTop={1}>
344 <Text>
345 <Text bold color={theme.color.accent}>
346 ALCHEMY
347 </Text>{" "}
348 <Text tone="muted">v{packageJson.version}</Text>
349 </Text>
350 </Box>
351 </Box>
352 </Box>,
353 { columns: termCols },
354 );
355};
356
357export const brandedCliFormatter = (
358 cli: CliKit["Service"],
359): CliOutput.Formatter => {
360 const fallback = CliOutput.defaultFormatter();
361 const glyphs = glyphsFor(cli.terminal.unicode);
362 const formatErrorLine = (message: string) =>
363 `${paint(ansiFg(theme.color.danger), `${glyphs.error} error:`)} ${message}`;
364 return {
365 ...fallback,
366 formatHelpDoc: (doc) =>
367 doc.usage.startsWith("alchemy <subcommand>")
368 ? formatRootHelp(cli, doc)
369 : formatSubHelp(cli, doc),
370 formatVersion: (name, version) =>
371 `${paint(ansiFg(theme.color.brand), glyphs.selected)} ${paint(ANSI_BOLD, name)} ${paint(ANSI_DIM, `v${version}`)}`,
372 formatError: (error) => `\n${formatErrorLine(error.message)}`,
373 formatErrors: (errors) => {
374 if (errors.length === 0) return "";
375 if (errors.length === 1) return `\n${formatErrorLine(errors[0].message)}`;
376 return `\n${formatErrorLine(`${errors.length} problems`)}\n${errors
377 .map((error) => ` ${paint(ANSI_DIM, glyphs.bullet)} ${error.message}`)
378 .join("\n")}`;
379 },
380 };
381};