Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 83 additions & 33 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,17 +448,73 @@ npx tsx echo.ts -b "This is a test"

Both commands will display "This is a test" in bold text, assuming your terminal supports ANSI escape codes.

## Important Note on Argument Order
## Note on Argument Order

When using your CLI, it's crucial to understand the order in which you specify options and arguments. By default, the `@effect/cli` parses `Options` and `Args` **before** any subcommands. This means that options need to be placed directly after the main command, and before any subcommands or additional arguments.

For example, the command:
Options can appear before or after positional arguments. Both of the following are valid:

```sh
npx tsx echo.ts -b "This is a test"
npx tsx echo.ts "This is a test" -b
```

**would not work** because the `-b` option appears after the text argument `"This is a test"`. The parser expects options to be specified before any standalone arguments or subcommands. This ensures that the options are correctly associated with the main command and not misinterpreted as arguments for a subcommand or additional text.
This flexibility also applies to parent command options when using subcommands:

```ts
import { Args, Command, Options } from "@effect/cli"
import { NodeContext, NodeRuntime } from "@effect/platform-node"
import { Console, Effect } from "effect"

const app = Command.make("app", {
verbose: Options.boolean("verbose").pipe(Options.withAlias("v"))
})

const deploy = Command.make("deploy", {
target: Args.text({ name: "target" })
}, ({ target }) =>
Effect.flatMap(app, ({ verbose }) =>
Console.log(`Deploying ${target}${verbose ? " (verbose)" : ""}`)
)
)

const command = app.pipe(Command.withSubcommands([deploy]))
const cli = Command.run(command, { name: "app", version: "1.0.0" })
cli(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain)
```

```sh
# All equivalent — parent options can appear anywhere:
npx tsx app.ts --verbose deploy prod
npx tsx app.ts deploy prod --verbose
npx tsx app.ts deploy --verbose prod
```

**Shared options:** When a parent and subcommand define the same option, position determines ownership — before the subcommand it belongs to the parent, after it belongs to the child:

```ts
// Parent "app" has --verbose, child "status" also has --verbose
const status = Command.make("status", {
verbose: Options.boolean("verbose").pipe(Options.withAlias("v")),
path: Args.text({ name: "path" })
}, ({ verbose, path }) =>
Effect.flatMap(app, (parent) =>
Console.log(
`Status ${path}: child verbose=${verbose}, parent verbose=${parent.verbose}`
)
)
)
```

```sh
# --verbose before subcommand → parent gets it
npx tsx app.ts --verbose status .
# Output: Status .: child verbose=false, parent verbose=true

# --verbose after subcommand → child gets it
npx tsx app.ts status . --verbose
# Output: Status .: child verbose=true, parent verbose=false
```

> **Note:** This positional flexibility applies only to **options** (flags like `--verbose` or `-v`), not to **arguments** (positional values). Arguments are always parsed in order.

## Adding Valued Options to Commands

Expand Down Expand Up @@ -1196,45 +1252,39 @@ npx tsx minigit.ts -c key1=value1 clone --depth 1 https://github.com/Effect-TS/c

Understanding how command-line arguments are parsed in your applications is crucial for designing effective and user-friendly command interfaces. Here are the key rules that the internal command-line argument parser follows:

### 1. Order of Options and Subcommands
### 1. Flexible Option Placement

Options and arguments (collectively referred to as `Options` / `Args`) associated with a command must be specified **before** any subcommands. This rule helps the parser determine which command the options apply to.
Options can appear before or after positional arguments and subcommands. The parser scans all arguments to find matching options regardless of position.

**Examples:**

- **Correct Usage**:

```sh
program -v subcommand
```

In this example, the `-v` option applies to the main program before the subcommand is processed.

- **Incorrect Usage**:
```sh
program subcommand -v
```
Here, placing `-v` after the subcommand causes confusion as to whether `-v` applies to the main program or the subcommand.
```sh
# All of these are equivalent:
program -v subcommand
program subcommand -v
```

### 2. Parsing Options Before Positional Arguments
When a parent command and subcommand share the same option name, position determines ownership:

The parser is designed to recognize options before any positional arguments. This ordering ensures clarity and prevents confusion between options and regular arguments.
```sh
# -v belongs to the parent (before subcommand)
program -v subcommand

**Examples:**
# -v belongs to the subcommand (after subcommand)
program subcommand -v
```

- **Valid Command**:
If only the parent defines an option, it is extracted regardless of position:

```sh
program --option arg
```
```sh
# --config belongs to the parent in both cases
program --config prod.json subcommand arg
program subcommand arg --config prod.json
```

This command correctly places the `--option` before the positional argument `arg`.
### 2. Positional Arguments Are Order-Dependent

- **Invalid Command**:
```sh
program arg --option
```
Placing an argument before an option is not allowed and can lead to errors in command processing.
Unlike options, positional arguments are always parsed in the order they appear. They cannot be rearranged.

### 3. Handling Excess Arguments

Expand Down
129 changes: 123 additions & 6 deletions packages/cli/test/Command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,25 @@ const add = Command.make("add", {
Command.provideEffect(AddService, (_) => Effect.succeed("AddService" as const))
)

const status = Command.make("status", {
verbose: Options.boolean("verbose").pipe(Options.withAlias("v")),
pathspec: Args.text({ name: "pathspec" })
}, ({ verbose, pathspec }) =>
Effect.gen(function*() {
const { log } = yield* Messages
const parent = yield* git
if (verbose) {
yield* log(`Status verbose ${pathspec}`)
} else {
yield* log(`Status ${pathspec}`)
}
if (parent.verbose) {
yield* log("parent verbose")
}
})).pipe(Command.withDescription("Show the working tree status"))

const run = git.pipe(
Command.withSubcommands([clone, add]),
Command.withSubcommands([clone, add, status]),
Command.run({
name: "git",
version: "1.0.0"
Expand Down Expand Up @@ -130,30 +147,130 @@ describe("Command", () => {
Effect.runPromise
))

it("options after positional args", () =>
it("parent options after subcommand and its args", () =>
Effect.gen(function*() {
const messages = yield* Messages
// --verbose after the positional arg "repo"
yield* run(["node", "git.js", "clone", "repo", "--verbose"])
assert.deepStrictEqual(yield* messages.messages, [
"shared",
"Cloning repo"
])
}).pipe(Effect.provide(EnvLive), Effect.runPromise))

it("options after positional args with alias", () =>
it("parent options with alias after subcommand and its args", () =>
Effect.gen(function*() {
const messages = yield* Messages
yield* run(["node", "git.js", "add", "file", "-v"])
assert.deepStrictEqual(yield* messages.messages, [
"shared",
"Adding file"
])
}).pipe(Effect.provide(EnvLive), Effect.runPromise))

it("parent options both before and after subcommand", () =>
Effect.gen(function*() {
const messages = yield* Messages
// -v after the positional arg "repo"
yield* run(["node", "git.js", "clone", "repo", "-v"])
// Mix: some parent options before subcommand, parent option after subcommand
// Using --verbose before and testing it still works
yield* run(["node", "git.js", "--verbose", "clone", "repo"])
assert.deepStrictEqual(yield* messages.messages, [
"shared",
"Cloning repo"
])
}).pipe(Effect.provide(EnvLive), Effect.runPromise))

it("shared option: child wins over parent", () =>
Effect.gen(function*() {
const messages = yield* Messages
yield* run(["node", "git.js", "status", ".", "--verbose"])
assert.deepStrictEqual(yield* messages.messages, [
"shared",
"Status verbose ."
])
}).pipe(Effect.provide(EnvLive), Effect.runPromise))
})
})

// Parent provides shared options as a service to subcommands

const SharedOptions = Context.GenericTag<{ verbose: boolean }>("SharedOptions")

const app = Command.make("app", {
verbose: Options.boolean("verbose").pipe(Options.withAlias("v"))
}).pipe(
Command.provideSync(SharedOptions, ({ verbose }) => ({ verbose }))
)

const push = Command.make("push", {
force: Options.boolean("force")
}, ({ force }) =>
Effect.gen(function*() {
const { log } = yield* Messages
const { verbose } = yield* SharedOptions
yield* log(`push force=${force} verbose=${verbose}`)
}))

const show = Command.make("show", {
ref: Args.text({ name: "ref" })
}, ({ ref }) =>
Effect.gen(function*() {
const { log } = yield* Messages
const { verbose } = yield* SharedOptions
yield* log(`show ${ref} verbose=${verbose}`)
}))

const runApp = app.pipe(
Command.withSubcommands([push, show]),
Command.run({ name: "app", version: "1.0.0" })
)

describe("shared parent options provided as service", () => {
it("parent --verbose before subcommand", () =>
Effect.gen(function*() {
const messages = yield* Messages
yield* runApp(["node", "app", "--verbose", "push", "--force"])
assert.deepStrictEqual(yield* messages.messages, [
"push force=true verbose=true"
])
}).pipe(Effect.provide(EnvLive), Effect.runPromise))

it("parent --verbose after subcommand options", () =>
Effect.gen(function*() {
const messages = yield* Messages
yield* runApp(["node", "app", "push", "--force", "--verbose"])
assert.deepStrictEqual(yield* messages.messages, [
"push force=true verbose=true"
])
}).pipe(Effect.provide(EnvLive), Effect.runPromise))

it("parent -v after subcommand options", () =>
Effect.gen(function*() {
const messages = yield* Messages
yield* runApp(["node", "app", "push", "--force", "-v"])
assert.deepStrictEqual(yield* messages.messages, [
"push force=true verbose=true"
])
}).pipe(Effect.provide(EnvLive), Effect.runPromise))

it("parent --verbose after subcommand positional arg", () =>
Effect.gen(function*() {
const messages = yield* Messages
yield* runApp(["node", "app", "show", "main", "--verbose"])
assert.deepStrictEqual(yield* messages.messages, [
"show main verbose=true"
])
}).pipe(Effect.provide(EnvLive), Effect.runPromise))

it("no --verbose defaults to false", () =>
Effect.gen(function*() {
const messages = yield* Messages
yield* runApp(["node", "app", "push", "--force"])
assert.deepStrictEqual(yield* messages.messages, [
"push force=true verbose=false"
])
}).pipe(Effect.provide(EnvLive), Effect.runPromise))
})

// --

interface Messages {
Expand Down
Loading