commandstruct
Version:
Type safe and modular CLIs with Sade
483 lines (384 loc) • 11.4 kB
Markdown
# Commandstruct
🛠️ Typesafe and modular CLIs with [sade](https://github.com/lukeed/sade).
Commandstruct is a simple and powerful tool for building fast and typesafe command-line applications.
It supports CLI commands, sub-commands, positional arguments and flags. It also supports dependency injection using [getbox](https://github.com/eriicafes/getbox).
## Installation
Commandstruct has both `sade` and `getbox` as peer-dependencies.
```sh
npm i commandstruct
```
## Usage
### A Regular Program
```ts
import { arg, program, command, flag } from "commandstruct";
const commitCmd = command("commit")
.describe("make a commit")
.flags({
message: flag("commit message").char("m").requiredParam("string"),
})
.action(({ flags }) => {
console.log("committing with message", flags.message);
});
const pushCmd = command("push")
.describe("push changes")
.args({
repo: arg(),
branch: arg().optional(),
})
.action(({ args }) => {
console.log("pushing repo", args.repo, "at", args.branch || "HEAD");
});
const prog = program("notgit")
.describe("not your regular git")
.flags({ verbose: flag("display extra information on command run") })
.commands(commitCmd, pushCmd)
.build();
prog.run();
```
### A Single Program
```ts
import { arg, singleProgram, flag } from "commandstruct";
const prog = singleProgram("mycat")
.describe("print contents of file to stdout")
.args({
file: arg(),
})
.flags({
number: flag("number output lines").char("n"),
})
.action(({ args, flags, restArgs }) => {
console.log(
"printing contents of file",
args.file,
flags.n ? "with lines" : "without lines",
restArgs,
);
});
prog.run();
```
## Program
A program defines it's commands and an optional default command.
When running the program, one of it's commands must be executed otherwise you get a `"No command specified"` error if no default command is set. Program wide flags can be defined on the program.
```ts
const prog = program("notgit")
.describe("not your regular git")
.example("notgit commit -m 'my first commit'")
.example("notgit push origin main")
.version("v1.0.0")
.flags({
/** program flags */
})
.commands(/** commands */)
.default(/** default command */)
.build();
```
## Single Program
A single program unlike a regular program does not have any commands, the entire program executes as one command. A single program supports `args` and `action`.
```ts
const prog = singleProgram("mycat")
.describe("print contents of file to stdout")
.example("mycat")
.example("git push origin main")
.version("v1.0.0")
.args({
/** program args */
})
.flags({
/** program flags */
})
.action(() => {
/** program action */
});
```
## Command
A command defines its `flags`, `actions` and also additional `subcommands` if needed.
```ts
const programFlags = {
/** program flags */
};
const cmd = command("commit")
.describe("make a commit")
.alias("cm", "com")
.alias("comit")
.example("git commit -m 'my first commit'")
.example("git cm -m 'another commit")
.programFlags<typeof programFlags>()
.subcommands(/** subcommands */)
.args({
/** command args */
})
.flags({
/** command flags */
})
.action(({ args, flags, restArgs }, box) => {
/** command action - box is the getbox Box instance */
});
```
## Default Command
You can make any command or subcommand the default command. This will execute that command when no command was passed to the program.
```ts
import { commitCmd, pushCmd } from "./commands";
const prog = program("nogit")
.commands(commitCmd, pushCmd)
.default(commitCmd) // commitCmd will be executed by default
.build();
```
## Args
Positional arguments are declared in the order they should be provided. Optional arguments must be placed after required arguments.
```ts
command("example")
.args({
foo: arg(),
bar: arg(),
baz: arg().optional(),
})
.action(({ args, restArgs }) => {
/**
args: {
foo: string;
bar: string;
baz: string | undefined;
}
restArgs: string[]
*/
});
```
Variadic arguments can be accessed using restArgs.
## Flags
By default flags are boolean values and default to false. Flag keys are also converted to kebab-case except when explicitly disabled using `.preserveCase()`.
```ts
command("example")
.flags({
foo: flag("foo description"),
anotherFoo: flag("another foo description"),
lastFoo: flag("last foo description").preserveCase(),
})
.action(({ flags }) => {
/**
flags: {
foo: boolean;
"another-foo": boolean;
lastFoo: boolean;
}
*/
});
```
### Char Flag
Allow passing flags with short character.
```ts
command("example")
.flags({
foo: flag("foo description").char("f"),
})
.action(({ flags }) => {
// flags.foo === flags.f
});
```
### Flag Params
Accept values with flags.
```ts
command("example")
.flags({
foo: flag("foo description").requiredParam("string"),
bar: flag("bar description").optionalParam("number"),
baz: flag("baz description").optionalParam("array", ["1"]), // with default
})
.action(({ flags }) => {
/**
flags: {
foo: string;
bar: number | undefined;
baz: string[];
};
*/
});
```
CLI usage examples:
```sh
# String params
example --foo "hello world"
example --foo hello
# Number params
example --foo hello --bar 42
example --foo hello --bar 3.14
example --foo hello --bar -10
# Array params (can be specified multiple times)
example --foo hello --baz one
example --foo hello --baz one --baz two --baz three
# Mixed params
example --foo hello --bar 100 --baz item1 --baz item2
```
### Negated Flags
Sade supports negating flags by supplying `--no-xxx` where `xxx` is the name of the flag. Flag values are `false` by default and passing `--no-xxx` would set the flag value to `false`, hence, this has no effect by default.
However, many usecases of negated flags are for flags that should be defaulted to `true`. There are 3 ways to use negated flags in commandstruct.
All these 3 methods adds the `--no-xxx` option to the command description but they differ in the default value of the flag.
```ts
// using withNegated
command("example")
.flags({
foo: flag("foo description").withNegated("negated description"),
})
.action(({ flags }) => {
// flags.foo is true by default (default shown in description)
});
```
```ts
// only negated flag
command("example")
.flags({
noFoo: flag("turn off foo description"),
"no-bar": flag("turn off bar description"),
})
.action(({ flags }) => {
// flags.foo is true by default (default not shown in description)
// flags.bar is true by default (default not shown in description)
});
// NOTE: noFoo is converted to kebab-case
// if it's case was preserved it would not count as a negated flag.
```
You may not be able to use only negated flag if you enable the `errorOnUnknown` run option.
```ts
// both existing flag and negated flag
command("example")
.flags({
foo: flag("foo description"),
noFoo: flag("turn off bar description"),
})
.action(({ flags }) => {
// flags.foo is false by default (default not shown in description)
});
```
## Program Flags
Use program flags from a command. The program flags can then be accessed in the command action.
```ts
const programFlags = {
dryRun: flag("run command but do not commit results"),
};
const cmd = command("example")
.programFlags<typeof programFlags>()
.flags({
message: flag("commit message"),
})
.action(({ flags }) => {
/**
flags: {
message: boolean;
"dry-run": boolean;
}
*/
});
const prog = program("test").flags(programFlags).commands(cmd).build();
```
## Dependency Injection
Commandstruct is designed to work with [getbox](https://github.com/eriicafes/getbox). Commands receive the Box instance as the second parameter to their action function, allowing them to retrieve dependencies.
```sh
npm i getbox
```
### Example
```ts
import { arg, command, program } from "commandstruct";
import { Box } from "getbox";
class Logger {
log(message: string) {
console.log(`[LOG] ${message}`);
}
}
class UserService {
constructor(private logger: Logger) {}
static init = Box.init(UserService).get(Logger);
createUser(name: string) {
this.logger.log(`Creating user: ${name}`);
}
}
const createCmd = command("create")
.args({ name: arg() })
.action(({ args }, box) => {
// UserService is auto-initialized with Logger dependency
const service = box.get(UserService);
service.createUser(args.name);
});
// The program creates a default Box instance
const prog = program("user-utils").commands(createCmd).build();
// You can provide your own Box instance
const box = new Box();
const prog2 = program("user-utils", box).commands(createCmd).build();
```
## Run
Run executes any sade program with some useful options.
```ts
const prog = program("test")
.flags(/** program flags */)
.commands(/** commands */)
.build();
run(prog.program());
```
If you like to configure all available options.
```ts
run(
prog.program(),
{
errorOnUnknown(flag) {
return `flag not supported '${flag}'`;
},
onError(err) {
console.error(err);
},
},
process.argv,
);
```
### Run Options
- errorOnUnknown: by default unknown options/flags are allowed, set to true to return a default error message or pass a function that returns a custom error message when an unknown option/flag is encountered.
- onError: catch error thrown in the executing command before the program terminates.
As a convenience, a run methods exists on the commandstruct program itself.
```ts
const prog = program("test")
.flags(/** program flags */)
.commands(/** commands */)
.build();
prog.run();
```
## Incremental Adoption
Commandstruct can be added to an existing sade program.
```ts
import sade from "sade";
import { Box } from "getbox";
import { command, flag, run } from "commandstruct";
const prog = sade("notgit")
.command("push <repo> [branch]")
.describe("push changes")
.action((repo, branch, opts) => {
console.log("pushing repo", repo, "at", branch || "HEAD");
});
const cmd = command("commit")
.describe("make a commit")
.flags({
message: flag("commit message").char("m").requiredParam("string"),
})
.action(({ flags }) => {
console.log("committing with message", flags.message);
});
const box = new Box();
cmd.command(box, { program: prog, programFlags: {} });
run(prog);
```
A commandstruct program can also be further modified just like a regular sade program.
```ts
import { command, program, flag, run } from "commandstruct";
const cmd = command("commit")
.describe("make a commit")
.flags({
message: flag("commit message").char("m").requiredParam("string"),
})
.action(({ flags }) => {
console.log("committing with message", flags.message);
});
const prog = program("notgit").commands(cmd).build().program(); // returns a new sade instance
prog
.command("push <repo> [branch]")
.describe("push changes")
.action((repo, branch, opts) => {
console.log("pushing repo", repo, "at", branch || "HEAD");
});
run(prog);
```