Skip to content

Commit 8ce4795

Browse files
authored
Add CLI config for built-in flags (#6397)
1 parent 01d5a9c commit 8ce4795

6 files changed

Lines changed: 162 additions & 8 deletions

File tree

.changeset/cli-config-built-ins.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
Add a scoped `CliConfig` service for customizing the built-in global flags used by CLI command runners.
6+
7+
For example, provide an explicit list that omits `GlobalFlag.LogLevel` to remove the built-in `--log-level` flag:
8+
9+
```ts
10+
import { Effect } from "effect"
11+
import { CliConfig, Command, GlobalFlag } from "effect/unstable/cli"
12+
13+
const program = Command.run(command, { version: "1.0.0" }).pipe(
14+
Effect.provide(
15+
CliConfig.layer({
16+
builtIns: [
17+
GlobalFlag.Help,
18+
GlobalFlag.Version,
19+
GlobalFlag.Completions
20+
]
21+
})
22+
)
23+
)
24+
```
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* Configuration for Effect CLI command execution.
3+
*
4+
* @since 4.0.0
5+
*/
6+
import * as Context from "../../Context.ts"
7+
import * as Layer from "../../Layer.ts"
8+
import * as GlobalFlag from "./GlobalFlag.ts"
9+
10+
/**
11+
* Context reference for configuration shared by CLI parsing, help generation,
12+
* and command execution.
13+
*
14+
* **When to use**
15+
*
16+
* Use when you need to customize runner-wide CLI behavior, such as which
17+
* built-in global flags are available.
18+
*
19+
* @category services
20+
* @since 4.0.0
21+
*/
22+
export class CliConfig extends Context.Reference<CliConfig.Service>("effect/unstable/cli/CliConfig", {
23+
defaultValue: () => defaults
24+
}) {}
25+
26+
/**
27+
* Types used by the `CliConfig` context reference.
28+
*
29+
* @since 4.0.0
30+
*/
31+
export declare namespace CliConfig {
32+
/**
33+
* Configuration values used while running a CLI command.
34+
*
35+
* @category models
36+
* @since 4.0.0
37+
*/
38+
export interface Service {
39+
/** Ordered built-in global flags, with earlier action flags taking precedence. */
40+
readonly builtIns: ReadonlyArray<GlobalFlag.BuiltIn>
41+
}
42+
}
43+
44+
/**
45+
* Default CLI configuration containing every built-in global flag.
46+
*
47+
* @category defaults
48+
* @since 4.0.0
49+
*/
50+
export const defaults: CliConfig.Service = {
51+
builtIns: GlobalFlag.BuiltIns
52+
}
53+
54+
/**
55+
* Creates CLI configuration by merging the provided options over `defaults`.
56+
*
57+
* **When to use**
58+
*
59+
* Use when you need a configuration value to provide directly through the
60+
* `CliConfig` context reference.
61+
*
62+
* @see {@link layer} for providing configuration as a layer
63+
*
64+
* @category constructors
65+
* @since 4.0.0
66+
*/
67+
export const make = (options?: Partial<CliConfig.Service>): CliConfig.Service => ({
68+
...defaults,
69+
...options
70+
})
71+
72+
/**
73+
* Creates a layer that provides CLI configuration merged over `defaults`.
74+
*
75+
* **When to use**
76+
*
77+
* Use when wiring customized CLI behavior into an application layer.
78+
*
79+
* @see {@link make} for creating a configuration value directly
80+
*
81+
* @category layers
82+
* @since 4.0.0
83+
*/
84+
export const layer = (options?: Partial<CliConfig.Service>): Layer.Layer<never> =>
85+
Layer.succeed(CliConfig, make(options))

packages/effect/src/unstable/cli/Command.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import * as Stdio from "../../Stdio.ts"
2626
import * as Terminal from "../../Terminal.ts"
2727
import type { Contravariant, Covariant, NoInfer, Simplify } from "../../Types.ts"
2828
import type { ChildProcessSpawner } from "../process/ChildProcessSpawner.ts"
29+
import * as CliConfig from "./CliConfig.ts"
2930
import * as CliError from "./CliError.ts"
3031
import * as CliOutput from "./CliOutput.ts"
3132
import * as GlobalFlag from "./GlobalFlag.ts"
@@ -1412,8 +1413,9 @@ const showHelp = <Name extends string, Input, E, R, ContextInput>(
14121413
error: CliError.ShowHelp
14131414
): Effect.Effect<void, CliError.CliError, Environment> =>
14141415
Effect.gen(function*() {
1416+
const { builtIns } = yield* CliConfig.CliConfig
14151417
const formatter = yield* CliOutput.Formatter
1416-
const helpDoc = yield* getHelpForCommandPath(command, error.commandPath, GlobalFlag.BuiltIns)
1418+
const helpDoc = yield* getHelpForCommandPath(command, error.commandPath, builtIns)
14171419
yield* Console.log(formatter.formatHelpDoc(helpDoc))
14181420
if (error.errors.length > 0) {
14191421
yield* Console.error(formatter.formatErrors(error.errors as any))
@@ -1530,10 +1532,11 @@ export const runWith = <const Name extends string, Input, E, R, ContextInput>(
15301532
const commandImpl = toImpl(command)
15311533
return Effect.fnUntraced(
15321534
function*(args: ReadonlyArray<string>) {
1535+
const { builtIns } = yield* CliConfig.CliConfig
15331536
const { tokens, trailingOperands } = Lexer.lex(args)
15341537

15351538
// 1. Collect known global flags from the command tree
1536-
const allFlags = getGlobalFlagsForCommandTree(command, GlobalFlag.BuiltIns)
1539+
const allFlags = getGlobalFlagsForCommandTree(command, builtIns)
15371540

15381541
// 2. Extract global flag tokens
15391542
const allFlagParams = allFlags.flatMap((f) => Param.extractSingleParams(f.flag))
@@ -1548,8 +1551,8 @@ export const runWith = <const Name extends string, Input, E, R, ContextInput>(
15481551
// 3. Parse command arguments from remaining tokens
15491552
const parsedArgs = yield* Parser.parseArgs({ tokens: remainder, trailingOperands }, command)
15501553
const commandPath = [command.name, ...Parser.getCommandPath(parsedArgs)] as const
1551-
const handlerCtx: GlobalFlag.HandlerContext = { command, commandPath, version: config.version }
1552-
const activeFlags = getGlobalFlagsForCommandPath(command, commandPath, GlobalFlag.BuiltIns)
1554+
const handlerCtx: GlobalFlag.HandlerContext = { builtIns, command, commandPath, version: config.version }
1555+
const activeFlags = getGlobalFlagsForCommandPath(command, commandPath, builtIns)
15531556

15541557
// 4. Reject globals that were passed outside the active command scope
15551558
const outOfScopeErrors = getOutOfScopeGlobalFlagErrors(allFlags, activeFlags, flagMap, commandPath)
@@ -1589,7 +1592,9 @@ export const runWith = <const Name extends string, Input, E, R, ContextInput>(
15891592

15901593
// 7. Provide setting values
15911594
let program = commandImpl.handle(parseResult.success, [command.name])
1592-
const [, logLevel] = yield* GlobalFlag.LogLevel.flag.parse(emptyArgs)
1595+
const logLevel = activeFlags.includes(GlobalFlag.LogLevel)
1596+
? (yield* GlobalFlag.LogLevel.flag.parse(emptyArgs))[1]
1597+
: Option.none()
15931598
program = Effect.provideService(program, GlobalFlag.LogLevel, logLevel)
15941599
for (const flag of activeFlags) {
15951600
if (flag._tag !== "Setting" || flag === GlobalFlag.LogLevel) continue

packages/effect/src/unstable/cli/GlobalFlag.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface HandlerContext {
3737
readonly command: Command.Command.Any
3838
readonly commandPath: ReadonlyArray<string>
3939
readonly version: string
40+
readonly builtIns: ReadonlyArray<BuiltIn>
4041
}
4142

4243
/**
@@ -156,9 +157,9 @@ export const Help: Action<boolean> = action({
156157
Flag.withAlias("h"),
157158
Flag.withDescription("Show help information")
158159
),
159-
run: Effect.fnUntraced(function*(_, { command, commandPath }) {
160+
run: Effect.fnUntraced(function*(_, { builtIns, command, commandPath }) {
160161
const formatter = yield* CliOutput.Formatter
161-
const helpDoc = yield* HelpInternal.getHelpForCommandPath(command, commandPath, BuiltIns)
162+
const helpDoc = yield* HelpInternal.getHelpForCommandPath(command, commandPath, builtIns)
162163
yield* Console.log(formatter.formatHelpDoc(helpDoc))
163164
})
164165
})
@@ -282,3 +283,11 @@ export const BuiltIns: readonly [
282283
Action<Option.Option<"bash" | "zsh" | "fish">>,
283284
Setting<"log-level", Option.Option<LogLevelType>>
284285
] = [Help, Version, Completions, LogLevel]
286+
287+
/**
288+
* Global flag included in the default command-runner configuration.
289+
*
290+
* @category models
291+
* @since 4.0.0
292+
*/
293+
export type BuiltIn = typeof BuiltIns[number]

packages/effect/src/unstable/cli/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
*/
1010
export * as Argument from "./Argument.ts"
1111

12+
/**
13+
* @since 4.0.0
14+
*/
15+
export * as CliConfig from "./CliConfig.ts"
16+
1217
/**
1318
* @since 4.0.0
1419
*/

packages/effect/test/unstable/cli/Command.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { assert, describe, expect, it } from "@effect/vitest"
22
import { Context, Effect, FileSystem, Layer, Option, Path, Stdio } from "effect"
33
import { TestConsole } from "effect/testing"
4-
import { Argument, CliOutput, Command, Flag, GlobalFlag } from "effect/unstable/cli"
4+
import { Argument, CliConfig, CliOutput, Command, Flag, GlobalFlag } from "effect/unstable/cli"
55
import { toImpl } from "effect/unstable/cli/internal/command"
66
import { ChildProcessSpawner } from "effect/unstable/process"
77
import * as Cli from "./fixtures/ComprehensiveCli.ts"
@@ -346,6 +346,32 @@ describe("Command", () => {
346346
assert.deepStrictEqual(captured, ["eu-west-1", "us-west-2"])
347347
}).pipe(Effect.provide(TestLayer)))
348348

349+
it.effect("should configure built-in global flags per run", () =>
350+
Effect.gen(function*() {
351+
const command = Command.make("app", {}, () => Effect.void)
352+
const runCommand = Command.runWith(command, { version: "1.0.0" })
353+
const config = CliConfig.make({
354+
builtIns: GlobalFlag.BuiltIns.filter((flag) => flag !== GlobalFlag.LogLevel)
355+
})
356+
357+
yield* runCommand(["--help"]).pipe(Effect.provide(CliConfig.layer(config)))
358+
const configuredHelp = yield* TestConsole.logLines
359+
assert.isFalse(configuredHelp.some((line) => String(line).includes("--log-level")))
360+
361+
yield* runCommand(["--log-level", "debug"]).pipe(
362+
Effect.provideService(CliConfig.CliConfig, config),
363+
Effect.ignore
364+
)
365+
const errors = yield* TestConsole.errorLines
366+
assert.isTrue(errors.some((line) => String(line).includes("Unrecognized flag: --log-level")))
367+
368+
const helpCountBeforeDefault = (yield* TestConsole.logLines).length
369+
yield* runCommand(["--help"])
370+
const allHelp = yield* TestConsole.logLines
371+
const defaultHelp = allHelp.slice(helpCountBeforeDefault)
372+
assert.isTrue(defaultHelp.some((line) => String(line).includes("--log-level")))
373+
}).pipe(Effect.provide(TestLayer)))
374+
349375
it.effect("should support mixed action and setting global flags", () =>
350376
Effect.gen(function*() {
351377
const actions: Array<boolean> = []

0 commit comments

Comments
 (0)