Skip to content
This repository has been archived by the owner on Jul 16, 2024. It is now read-only.

localized handlers for commands #390

Merged
merged 43 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
215bf5b
initial naval fate example
IMax153 Nov 26, 2023
b5f750a
add HandledCommand module
tim-smart Nov 27, 2023
26a63c0
cleanup
tim-smart Nov 27, 2023
e4f400b
.make
tim-smart Nov 27, 2023
3b51110
add toString to errors
tim-smart Nov 27, 2023
0769a5d
fix docs generation and add ValidationError.helpRequested
IMax153 Nov 27, 2023
c78e324
fix wizard mode
IMax153 Nov 27, 2023
e6248ae
Merge branch 'chore/naval-fate' into handled
IMax153 Nov 27, 2023
600e652
Merge branch 'handled' of https://github.com/Effect-TS/cli into handled
tim-smart Nov 27, 2023
ac280c8
handled context (#391)
tim-smart Nov 27, 2023
e9325cf
ParsedConfig
tim-smart Nov 27, 2023
9962fcd
parse config tuples
tim-smart Nov 27, 2023
8175005
only add proxy if required
tim-smart Nov 27, 2023
18c4a38
remove default for boolean option
IMax153 Nov 28, 2023
ee21343
use name for context tracking
tim-smart Nov 28, 2023
585b602
fix boolean options wizard mode
IMax153 Nov 28, 2023
a6fe132
rename HandledCommand to Command (#392)
tim-smart Nov 28, 2023
c6272ac
update prompt example
tim-smart Nov 28, 2023
957f153
update minigit example
tim-smart Nov 28, 2023
ca74efb
changeset
tim-smart Nov 28, 2023
695f8f6
remove help variants
tim-smart Nov 28, 2023
034bd00
fix withDefault types
tim-smart Nov 28, 2023
0dda63f
fix naval fate example
IMax153 Nov 28, 2023
367b976
significantly improve wizard mode
IMax153 Nov 28, 2023
273aa84
more wizard improvements
IMax153 Nov 28, 2023
47a0cb5
add colors for accent
IMax153 Nov 28, 2023
5252134
fix wizard types
tim-smart Nov 28, 2023
1fb5d09
add description to args
IMax153 Nov 28, 2023
543b155
consolidate Command.make
tim-smart Nov 28, 2023
36c99dc
Merge branch 'handled' of https://github.com/Effect-TS/cli into handled
tim-smart Nov 28, 2023
59813c7
remove orElse apis
tim-smart Nov 28, 2023
4483763
add accessors to Command module
tim-smart Nov 28, 2023
4d43480
add Command.wizard
tim-smart Nov 28, 2023
24fed34
cleanup naval fate
IMax153 Nov 28, 2023
79216ca
remove Command.mapDescriptor api
tim-smart Nov 28, 2023
c2c2a77
Merge branch 'handled' of https://github.com/Effect-TS/cli into handled
tim-smart Nov 28, 2023
7bf09a1
update example
tim-smart Nov 28, 2023
42a5e76
fix fromDescriptor
tim-smart Nov 28, 2023
e8c7625
fix tests
tim-smart Nov 28, 2023
38e5291
add some Command tests
tim-smart Nov 28, 2023
b0d7e9f
update test TODOs
tim-smart Nov 28, 2023
2b5fceb
register descriptors at point of consumption
tim-smart Nov 29, 2023
0249133
cleanup options parsing
IMax153 Nov 29, 2023
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
5 changes: 5 additions & 0 deletions .changeset/fresh-dingos-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/cli": minor
---

add localized handlers for Command's
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ build/
dist/
.direnv/
docs/
# Naval Fate Example
naval-fate-store/
148 changes: 54 additions & 94 deletions examples/minigit.ts
Original file line number Diff line number Diff line change
@@ -1,124 +1,84 @@
import * as Args from "@effect/cli/Args"
import * as CliApp from "@effect/cli/CliApp"
import * as Command from "@effect/cli/Command"
import * as Options from "@effect/cli/Options"
import * as NodeContext from "@effect/platform-node/NodeContext"
import * as Runtime from "@effect/platform-node/Runtime"
import * as Console from "effect/Console"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import { pipe } from "effect/Function"
import type * as HashMap from "effect/HashMap"
import * as Option from "effect/Option"
import * as ReadonlyArray from "effect/ReadonlyArray"

// =============================================================================
// Models
// =============================================================================

type Subcommand = AddSubcommand | CloneSubcommand

interface AddSubcommand extends Data.Case {
readonly _tag: "AddSubcommand"
readonly verbose: boolean
}
const AddSubcommand = Data.tagged<AddSubcommand>("AddSubcommand")

interface CloneSubcommand extends Data.Case {
readonly _tag: "CloneSubcommand"
readonly depth: Option.Option<number>
readonly repository: string
readonly directory: Option.Option<string>
}
const CloneSubcommand = Data.tagged<CloneSubcommand>("CloneSubcommand")

// =============================================================================
// Commands
// =============================================================================

// minigit [--version] [-h | --help] [-c <name>=<value>]
const minigitOptions = Options.keyValueMap("c").pipe(Options.optional)
const minigit = Command.make("minigit", { options: minigitOptions })
const minigit = Command.make(
"minigit",
{ configs: Options.keyValueMap("c").pipe(Options.optional) },
({ configs }) =>
Option.match(configs, {
onNone: () => Console.log("Running 'minigit'"),
onSome: (configs) => {
const keyValuePairs = Array.from(configs).map(([key, value]) => `${key}=${value}`).join(
", "
)
return Console.log(`Running 'minigit' with the following configs: ${keyValuePairs}`)
}
})
)

// minigit add [-v | --verbose] [--] [<pathspec>...]
const minigitAddOptions = Options.boolean("verbose").pipe(Options.withAlias("v"))
const minigitAdd = Command.make("add", { options: minigitAddOptions }).pipe(
Command.map((parsed) => AddSubcommand({ verbose: parsed.options }))
const configsString = Effect.map(
minigit,
({ configs }) =>
Option.match(configs, {
onNone: () => "",
onSome: (configs) => {
const keyValuePairs = Array.from(configs).map(([key, value]) => `${key}=${value}`).join(
", "
)
return ` with the following configs: ${keyValuePairs}`
}
})
)

// minigit add [-v | --verbose] [--] [<pathspec>...]
const minigitAdd = Command.make("add", {
verbose: Options.boolean("verbose").pipe(Options.withAlias("v"))
}, ({ verbose }) =>
Effect.gen(function*(_) {
const configs = yield* _(configsString)
yield* _(Console.log(`Running 'minigit add' with '--verbose ${verbose}'${configs}`))
}))

// minigit clone [--depth <depth>] [--] <repository> [<directory>]
const minigitCloneArgs = Args.all([
Args.text({ name: "repository" }),
Args.directory().pipe(Args.optional)
])
const minigitCloneOptions = Options.integer("depth").pipe(Options.optional)
const minigitClone = Command.make("clone", {
options: minigitCloneOptions,
args: minigitCloneArgs
}).pipe(Command.map((parsed) =>
CloneSubcommand({
depth: parsed.options,
repository: parsed.args[0],
directory: parsed.args[1]
})
))
repository: Args.text({ name: "repository" }),
directory: Args.directory().pipe(Args.optional),
depth: Options.integer("depth").pipe(Options.optional)
}, ({ depth, directory, repository }) => {
const optionsAndArgs = pipe(
ReadonlyArray.compact([
Option.map(depth, (depth) => `--depth ${depth}`),
Option.some(repository),
directory
]),
ReadonlyArray.join(", ")
)
return Console.log(
`Running 'minigit clone' with the following options and arguments: '${optionsAndArgs}'`
)
})

const finalCommand = minigit.pipe(Command.withSubcommands([minigitAdd, minigitClone]))

// =============================================================================
// Application
// =============================================================================

const cliApp = CliApp.make({
const run = Command.run(finalCommand, {
name: "MiniGit Distributed Version Control",
version: "v2.42.1",
command: finalCommand
})

// =============================================================================
// Execution
// =============================================================================

const handleRootCommand = (configs: Option.Option<HashMap.HashMap<string, string>>) =>
Option.match(configs, {
onNone: () => Console.log("Running 'minigit'"),
onSome: (configs) => {
const keyValuePairs = Array.from(configs).map(([key, value]) => `${key}=${value}`).join(", ")
return Console.log(`Running 'minigit' with the following configs: ${keyValuePairs}`)
}
})

const handleSubcommand = (subcommand: Subcommand) => {
switch (subcommand._tag) {
case "AddSubcommand": {
return Console.log(`Running 'minigit add' with '--verbose ${subcommand.verbose}'`)
}
case "CloneSubcommand": {
const optionsAndArgs = pipe(
ReadonlyArray.compact([
Option.map(subcommand.depth, (depth) => `--depth ${depth}`),
Option.some(subcommand.repository),
subcommand.directory
]),
ReadonlyArray.join(", ")
)
return Console.log(
`Running 'minigit clone' with the following options and arguments: '${optionsAndArgs}'`
)
}
}
}

const program = Effect.gen(function*(_) {
const args = yield* _(Effect.sync(() => globalThis.process.argv.slice(2)))
return yield* _(CliApp.run(cliApp, args, (parsed) =>
Option.match(parsed.subcommand, {
onNone: () => handleRootCommand(parsed.options),
onSome: (subcommand) => handleSubcommand(subcommand)
})))
version: "v2.42.1"
})

program.pipe(
Effect.suspend(() => run(process.argv.slice(2))).pipe(
Effect.provide(NodeContext.layer),
Runtime.runMain
)
121 changes: 121 additions & 0 deletions examples/naval-fate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Args, Command, Options } from "@effect/cli"
import * as KeyValueStore from "@effect/platform-node/KeyValueStore"
import * as NodeContext from "@effect/platform-node/NodeContext"
import * as Runtime from "@effect/platform-node/Runtime"
import * as Console from "effect/Console"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as NavalFateStore from "./naval-fate/store.js"

const { createShip, moveShip, removeMine, setMine, shoot } = Effect.serviceFunctions(
NavalFateStore.NavalFateStore
)

// naval_fate [-h | --help] [--version]
// naval_fate ship new <name>...
// naval_fate ship move [--speed=<kn>] <name> <x> <y>
// naval_fate ship shoot <x> <y>
// naval_fate mine set <x> <y> [--moored]
// naval_fate mine remove <x> <y> [--moored]

const nameArg = Args.text({ name: "name" }).pipe(Args.withDescription("The name of the ship"))
const xArg = Args.integer({ name: "x" }).pipe(Args.withDescription("The x coordinate"))
const yArg = Args.integer({ name: "y" }).pipe(Args.withDescription("The y coordinate"))
const coordinatesArg = { x: xArg, y: yArg }
const nameAndCoordinatesArg = { name: nameArg, ...coordinatesArg }

const mooredOption = Options.boolean("moored").pipe(
Options.withDescription("Whether the mine is moored (anchored) or drifting")
)
const speedOption = Options.integer("speed").pipe(
Options.withDescription("Speed in knots"),
Options.withDefault(10)
)

const shipCommand = Command.make("ship", {
verbose: Options.boolean("verbose")
})

const newShipCommand = Command.make("new", {
name: nameArg
}, ({ name }) =>
Effect.gen(function*(_) {
const { verbose } = yield* _(shipCommand)
yield* _(createShip(name))
yield* _(Console.log(`Created ship: '${name}'`))
if (verbose) {
yield* _(Console.log(`Verbose mode enabled`))
}
}))

const moveShipCommand = Command.make("move", {
...nameAndCoordinatesArg,
speed: speedOption
}, ({ name, speed, x, y }) =>
Effect.gen(function*(_) {
yield* _(moveShip(name, x, y))
yield* _(Console.log(`Moving ship '${name}' to coordinates (${x}, ${y}) at ${speed} knots`))
}))

const shootShipCommand = Command.make(
"shoot",
{ ...coordinatesArg },
({ x, y }) =>
Effect.gen(function*(_) {
yield* _(shoot(x, y))
yield* _(Console.log(`Shot cannons at coordinates (${x}, ${y})`))
})
)

const mineCommand = Command.make("mine")

const setMineCommand = Command.make("set", {
...coordinatesArg,
moored: mooredOption
}, ({ moored, x, y }) =>
Effect.gen(function*(_) {
yield* _(setMine(x, y))
yield* _(
Console.log(`Set ${moored ? "moored" : "drifting"} mine at coordinates (${x}, ${y})`)
)
}))

const removeMineCommand = Command.make("remove", {
...coordinatesArg
}, ({ x, y }) =>
Effect.gen(function*(_) {
yield* _(removeMine(x, y))
yield* _(Console.log(`Removing mine at coordinates (${x}, ${y}), if present`))
}))

const run = Command.make("naval_fate").pipe(
Command.withDescription("An implementation of the Naval Fate CLI application."),
Command.withSubcommands([
shipCommand.pipe(Command.withSubcommands([
newShipCommand,
moveShipCommand,
shootShipCommand
])),
mineCommand.pipe(Command.withSubcommands([
setMineCommand,
removeMineCommand
]))
]),
Command.run({
name: "Naval Fate",
version: "1.0.0"
})
)

const main = Effect.suspend(() => run(globalThis.process.argv.slice(2)))

const MainLayer = NavalFateStore.layer.pipe(
Layer.use(KeyValueStore.layerFileSystem("naval-fate-store")),
Layer.merge(NodeContext.layer)
)

main.pipe(
Effect.provide(MainLayer),
Effect.tapErrorCause(Effect.logError),
Runtime.runMain
)
80 changes: 80 additions & 0 deletions examples/naval-fate/domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as Schema from "@effect/schema/Schema"
import * as Data from "effect/Data"

/**
* An error that occurs when attempting to create a Naval Fate ship that already
* exists.
*/
export class ShipExistsError extends Data.TaggedError("ShipExistsError")<{
readonly name: string
}> {
toString(): string {
return `ShipExistsError: ship with name '${this.name}' already exists`
}
}

/**
* An error that occurs when attempting to move a Naval Fate ship that does not
* exist.
*/
export class ShipNotFoundError extends Data.TaggedError("ShipNotFoundError")<{
readonly name: string
readonly x: number
readonly y: number
}> {
toString(): string {
return `ShipNotFoundError: ship with name '${this.name}' does not exist`
}
}

/**
* An error that occurs when attempting to move a Naval Fate ship to coordinates
* already occupied by another ship.
*/
export class CoordinatesOccupiedError extends Data.TaggedError("CoordinatesOccupiedError")<{
readonly name: string
readonly x: number
readonly y: number
}> {
toString(): string {
return `CoordinatesOccupiedError: ship with name '${this.name}' already occupies coordinates (${this.x}, ${this.y})`
}
}

/**
* Represents a Naval Fate ship.
*/
export class Ship extends Schema.Class<Ship>()({
name: Schema.string,
x: Schema.number,
y: Schema.number,
status: Schema.literal("sailing", "destroyed")
}) {
static readonly create = (name: string) => new Ship({ name, x: 0, y: 0, status: "sailing" })

hasCoordinates(x: number, y: number): boolean {
return this.x === x && this.y === y
}

move(x: number, y: number): Ship {
return new Ship({ name: this.name, x, y, status: this.status })
}

destroy(): Ship {
return new Ship({ name: this.name, x: this.x, y: this.y, status: "destroyed" })
}
}

/**
* Represents a Naval Fate mine.
*/
export class Mine extends Schema.Class<Mine>()({
x: Schema.number,
y: Schema.number
}) {
static readonly create = (x: number, y: number) => new Mine({ x, y })

hasCoordinates(x: number, y: number): boolean {
return this.x === x && this.y === y
}
}
Loading
Loading