1import * as Console from "../../Console.ts"
2import * as Effect from "../../Effect.ts"
3import * as Option from "../../Option.ts"
4import * as Redacted from "../../Redacted.ts"
5import type * as Terminal from "../../Terminal.ts"
6import type * as CliError from "../CliError.ts"
7import type * as Command from "../Command.ts"
8import * as Param from "../Param.ts"
9import * as Primitive from "../Primitive.ts"
10import * as Prompt from "../Prompt.ts"
11import * as Ansi from "./ansi.ts"
12import { toImpl } from "./command.ts"
13
14export interface Options {
15 readonly commandPath?: ReadonlyArray<string> | undefined
16 readonly prefix?: ReadonlyArray<string> | undefined
17}
18
19export interface Result {
20 readonly args: Array<string>
21 readonly displayArgs: Array<string>
22}
23
24interface CommandLineArg {
25 readonly value: string
26 readonly displayValue: string
27}
28
29export const run: (
30 command: Command.Command.Any,
31 options?: Options
32) => Effect.Effect<Result, CliError.CliError | Terminal.QuitError, Command.Environment> = Effect.fnUntraced(
33 function*(command, options) {
34 const commandPath = options?.commandPath ?? [command.name]
35 const selected = getCommandAtPath(command, commandPath)
36 const commandLine = (options?.prefix ?? commandPath).map((value) => commandLineArg(value))
37 yield* logCurrentCommand(commandLine)
38 yield* promptCommand(selected, commandLine, selected === command ? "ROOT" : selected.name)
39 return {
40 args: commandLine.map((arg) => arg.value),
41 displayArgs: commandLine.map((arg) => arg.displayValue)
42 }
43 }
44)
45
46const getCommandAtPath = (
47 command: Command.Command.Any,
48 commandPath: ReadonlyArray<string>
49): Command.Command.Any => {
50 let current = command
51 for (const name of commandPath.slice(1)) {
52 const child = current.subcommands
53 .flatMap((group) => group.commands)
54 .find((candidate) => candidate.name === name || candidate.alias === name)
55 if (child === undefined) {
56 break
57 }
58 current = child
59 }
60 return current
61}
62
63const promptCommand: (
64 command: Command.Command.Any,
65 commandLine: Array<CommandLineArg>,
66 sectionName: string
67) => Effect.Effect<void, CliError.CliError | Terminal.QuitError, Command.Environment> = Effect.fnUntraced(
68 function*(command, commandLine, sectionName) {
69 const impl = toImpl(command)
70 const visibleSubcommands = command.subcommands.flatMap((group) => group.commands.filter((child) => !child.unlisted))
71 const config = visibleSubcommands.length === 0 ? impl.config : impl.contextConfig
72
73 if (config.flags.length > 0) {
74 yield* Console.log(renderSection(sectionName, "FLAGS"))
75 for (const param of config.flags) {
76 commandLine.push(...yield* promptParam(param))
77 }
78 if (config.arguments.length > 0 || visibleSubcommands.length > 0) {
79 yield* logCurrentCommand(commandLine)
80 }
81 }
82
83 if (config.arguments.length > 0) {
84 yield* Console.log(renderSection(sectionName, "ARGUMENTS"))
85 for (const param of config.arguments) {
86 commandLine.push(...yield* promptParam(param))
87 }
88 if (visibleSubcommands.length > 0) {
89 yield* logCurrentCommand(commandLine)
90 }
91 }
92
93 if (visibleSubcommands.length === 0) {
94 return
95 }
96
97 const child = yield* Prompt.run(Prompt.Select({
98 message: "Command",
99 choices: visibleSubcommands.map((command) => ({
100 title: command.name,
101 value: command,
102 ...(command.shortDescription !== undefined
103 ? { description: command.shortDescription }
104 : command.description !== undefined
105 ? { description: command.description }
106 : {})
107 }))
108 }))
109 yield* Console.log()
110 commandLine.push(commandLineArg(child.name))
111 if (hasWizardSteps(child)) {
112 yield* logCurrentCommand(commandLine)
113 }
114 yield* promptCommand(child, commandLine, child.name)
115 }
116)
117
118const hasWizardSteps = (command: Command.Command.Any): boolean => {
119 const hasVisibleSubcommands = command.subcommands.some((group) => group.commands.some((child) => !child.unlisted))
120 return hasVisibleSubcommands || toImpl(command).config.orderedParams.length > 0
121}
122
123const promptParam = Effect.fnUntraced(
124 function*(param: Param.Any): Effect.fn.Return<
125 Array<CommandLineArg>,
126 CliError.CliError | Terminal.QuitError,
127 Command.Environment
128 > {
129 const single = Param.getUnderlyingSingleOrThrow(param)
130 const metadata = Param.getParamMetadata(param)
131
132 if (metadata.isOptional) {
133 const include = yield* Prompt.run(Prompt.Confirm({
134 message: `Set ${renderParamLabel(single)}?`,
135 initial: false
136 }))
137 if (!include) {
138 yield* Console.log()
139 return []
140 }
141 }
142
143 const count = !metadata.isVariadic
144 ? 1
145 : yield* Prompt.run(Prompt.Int({
146 message: `${renderParamLabel(single)} count`,
147 default: Option.getOrElse(metadata.variadicMin, () => 0),
148 min: Option.getOrElse(metadata.variadicMin, () => 0),
149 ...(Option.isSome(metadata.variadicMax) ? { max: metadata.variadicMax.value } : {})
150 }))
151 const values: Array<CommandLineArg> = []
152 for (let i = 0; i < count; i++) {
153 values.push(yield* promptSingle(single))
154 }
155
156 const parsed = single.kind === Param.flagKind
157 ? {
158 flags: { [single.name]: values.map((arg) => arg.value) },
159 arguments: []
160 }
161 : {
162 flags: {},
163 arguments: values.map((arg) => arg.value)
164 }
165 yield* param.parse(parsed)
166 yield* Console.log()
167
168 if (single.kind === Param.argumentKind) {
169 return values
170 }
171 // Inline option-looking values so the lexer cannot interpret them as flags or the -- delimiter.
172 return values.flatMap((value) =>
173 value.value.startsWith("-") && value.value.length > 1
174 ? [commandLineArg(`--${single.name}=${value.value}`, `--${single.name}=${value.displayValue}`)]
175 : [commandLineArg(`--${single.name}`), value]
176 )
177 }
178)
179
180const promptSingle = (
181 single: Param.Single<Param.ParamKind, unknown>
182): Effect.Effect<CommandLineArg, Terminal.QuitError, Command.Environment> => {
183 const message = renderParamMessage(single)
184 switch (single.primitiveType._tag) {
185 case "Boolean":
186 return Effect.map(
187 Prompt.run(Prompt.Confirm({
188 message,
189 label: {
190 confirm: "true",
191 deny: "false"
192 },
193 placeholder: {
194 defaultConfirm: "(T/f)",
195 defaultDeny: "(t/F)"
196 }
197 })),
198 (value) => commandLineArg(String(value))
199 )
200 case "Choice": {
201 const choices = Primitive.getChoiceKeys(single.primitiveType) ?? []
202 return Effect.map(
203 Prompt.run(Prompt.Select({
204 message,
205 choices: choices.map((choice) => ({ title: choice, value: choice }))
206 })),
207 commandLineArg
208 )
209 }
210 case "Date":
211 return Effect.map(Prompt.run(Prompt.Date({ message })), (date) => commandLineArg(date.toISOString()))
212 case "Finite":
213 return Effect.map(Prompt.run(Prompt.Number({ message })), (value) => commandLineArg(String(value)))
214 case "Int":
215 return Effect.map(Prompt.run(Prompt.Int({ message })), (value) => commandLineArg(String(value)))
216 case "Redacted":
217 return Effect.map(
218 Prompt.run(Prompt.Password({ message })),
219 (value) => commandLineArg(Redacted.value(value), "<redacted>")
220 )
221 default:
222 return Effect.map(Prompt.run(Prompt.String({ message })), commandLineArg)
223 }
224}
225
226const commandLineArg = (value: string, displayValue: string = value): CommandLineArg => ({ value, displayValue })
227
228const formatName = (single: Param.Single<Param.ParamKind, unknown>): string =>
229 single.kind === Param.flagKind ? `--${single.name}` : single.name
230
231const renderParamMessage = (single: Param.Single<Param.ParamKind, unknown>): string => renderParamLabel(single)
232
233const renderParamLabel = (single: Param.Single<Param.ParamKind, unknown>): string => {
234 const description = Option.getOrUndefined(single.description)?.trim()
235 const label = single.kind === Param.flagKind && description !== undefined && description.length <= 32
236 ? description
237 : humanize(single.name)
238 return single.kind === Param.flagKind ? `${label} (${formatName(single)})` : label
239}
240
241const humanize = (name: string): string => {
242 const words = name.split(/[-_]+/).filter((word) => word.length > 0)
243 if (words.length === 0) return name
244 return [words[0][0].toUpperCase() + words[0].slice(1), ...words.slice(1)].join(" ")
245}
246
247const logCurrentCommand = (commandLine: ReadonlyArray<CommandLineArg>): Effect.Effect<void> =>
248 Console.log(renderCommandBlock("Current command", commandLine.map((arg) => arg.displayValue), Ansi.magenta))
249
250const renderSection = (commandName: string, section: string): string =>
251 `${Ansi.annotate(commandName.toUpperCase(), Ansi.bold, Ansi.cyanBright)} ${Ansi.annotate("·", Ansi.blackBright)} ${
252 Ansi.annotate(section, Ansi.bold, Ansi.white)
253 }`
254
255export const renderIntroduction = (name: string, version: string, summary: string | undefined): string => {
256 const title = `${Ansi.annotate(name, Ansi.bold, Ansi.cyanBright)} ${Ansi.annotate(`v${version}`, Ansi.white)} ${
257 Ansi.annotate("· Command wizard", Ansi.bold, Ansi.white)
258 }`
259 return [
260 title,
261 ...(summary === undefined || summary.length === 0 ? [] : [summary]),
262 Ansi.annotate("Build a command interactively. Press Ctrl+C to cancel.", Ansi.blackBright),
263 ""
264 ].join("\n")
265}
266
267export const renderCompletion = (commandLine: ReadonlyArray<string>): string =>
268 renderCommandBlock("Command ready", commandLine, Ansi.cyanBright, Ansi.green)
269
270export const renderQuit = (): string => `\n${Ansi.annotate("Wizard cancelled.", Ansi.red)}`
271
272const renderCommandBlock = (
273 label: string,
274 commandLine: ReadonlyArray<string>,
275 commandColor: string,
276 labelColor: string = Ansi.white
277): string => {
278 const lines = wrapCommand(commandLine)
279 return [
280 Ansi.annotate(label, Ansi.bold, labelColor),
281 ...lines.map((line) => Ansi.annotate(line, commandColor)),
282 ""
283 ].join("\n")
284}
285
286const wrapCommand = (commandLine: ReadonlyArray<string>): Array<string> => {
287 const width = 88
288 const firstIndent = " $ "
289 const continuationIndent = " "
290 const args = commandLine.map(formatShellArg)
291 const lines: Array<string> = []
292 let current = firstIndent
293
294 for (const arg of args) {
295 const separator = current === firstIndent || current === continuationIndent ? "" : " "
296 if (current.length + separator.length + arg.length > width && current !== firstIndent) {
297 lines.push(`${current} \\`)
298 current = `${continuationIndent}${arg}`
299 } else {
300 current += `${separator}${arg}`
301 }
302 }
303
304 if (current !== firstIndent) {
305 lines.push(current)
306 }
307 return lines
308}
309
310const formatShellArg = (arg: string): string =>
311 /^[A-Za-z0-9_./:@%+=,-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'"'"'`)}'`