Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/adbayb/termost

📦 A framework to build your next CLI application: Get the most of your terminal 🚀
https://github.com/adbayb/termost

cli cli-framework command-line command-line-interface command-line-tool framework library node tool

Last synced: 5 days ago
JSON representation

📦 A framework to build your next CLI application: Get the most of your terminal 🚀

Awesome Lists containing this project

README

        




💻 Termost


Get the most of your terminal




## ✨ Features

Termost allows building command line tools in a minute thanks to its:

- [Fluent](https://en.wikipedia.org/wiki/Fluent_interface) syntax to express your CLI configurations with instructions such as:
- [Subcommand](examples/command/src/index.ts) support
- Long and short [option](examples/option/src/index.ts) support
- [User input](examples/input/src/index.ts) support
- [Task](examples/task/src/index.ts) support
- Shareable output between instructions
- Auto-generated help and version metadata
- TypeScript support to foster a type-safe API
- Built-in helpers to make stdin/stdout management a breeze (including exec, and message helpers...)


## 🚀 Quickstart

Install the library:

```bash
# Npm
npm install termost
# Pnpm
pnpm add termost
# Yarn
yarn add termost
```

Once you're done, you can play with the API:

```ts
#!/usr/bin/env node

import { helpers, termost } from "termost";
import { name, version } from "../package.json" with { type: "json" }; // Depending on your `package.json` location.

type ProgramContext = {
globalFlag: string;
};

type DebugCommandContext = {
localFlag: string;
};

const program = termost({
name,
description: "CLI description",
version,
onException(error) {
console.error(`Error logic ${error.message}`);
},
onShutdown() {
console.log("Clean-up logic");
},
});

program.option({
key: "globalFlag",
name: { long: "global", short: "g" },
description:
"A global flag/option example accessible by all commands (key is used to persist the value into the context object)",
defaultValue:
"A default value can be set if no flag is provided by the user",
});

program
.command({
name: "build",
description:
"A custom command example runnable via `bin-name build` (command help available via `bin-name build --help`)",
})
.task({
label: "A label can be displayed to follow the task progress",
async handler() {
await fakeBuild();
},
});

program
.command({
name: "debug",
description: "A command to play with Termost capabilities",
})
.option({
key: "localFlag",
name: "local",
description: "A local flag accessible only by the `debug` command",
defaultValue: "local-value",
})
.task({
handler(context, argv) {
helpers.message(`Hello, I'm the ${argv.command} command`);
helpers.message(`Context value = ${JSON.stringify(context)}`);
helpers.message(`Argv value = ${JSON.stringify(argv)}`);
},
});

const fakeBuild = async () => {
return new Promise((resolve) => {
setTimeout(resolve, 3000);
});
};
```

Depending on the command, the output will look like this (`bin-name` is the program name automatically retrieved from the `package.json>name`):

| Command | Preview |
| :---------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------: |
| `bin-name --help` | Global help |
| `bin-name debug --help` | Local help |
| `bin-name build` | Subcommand with task example |
| `bin-name debug` | Subcommand with option and context example |


## ✍️ Usage

Here's an API overview:

command({ name, description })

The `command` API creates a new subcommand context.
Please note that the root command context is shared across subcommands but subcommand's contexts are scoped and not accessible between each other.

```ts
#!/usr/bin/env node

import { termost, helpers } from "termost";
import { name, version } from "../package.json" with { type: "json" }; // Depending on your `package.json` location.

const program = termost({
name,
description: "CLI description",
version,
});

program
.command({
name: "build",
description: "Transpile and bundle in production mode",
})
.task({
handler(context, argv) {
helpers.message(`👋 Hello, I'm the ${argv.command} command`);
},
});

program
.command({
name: "watch",
description: "Rebuild your assets on any code change",
})
.task({
handler(context, argv) {
helpers.message(`👋 Hello, I'm the ${argv.command} command`, {
type: "warning",
});
},
});
```

input({ key, label, type, skip, ...typeParameters })

The `input` API creates an interactive prompt.
It supports several types:

```ts
#!/usr/bin/env node

import { termost, helpers } from "termost";
import { name, version } from "../package.json" with { type: "json" }; // Depending on your `package.json` location.

type ProgramContext = {
input1: "singleOption1" | "singleOption2";
input2: Array<"multipleOption1" | "multipleOption2">;
input3: boolean;
input4: string;
};

const program = termost({
name,
description: "CLI description",
version,
});

program
.input({
type: "select",
key: "input1",
label: "What is your single choice?",
options: ["singleOption1", "singleOption2"],
defaultValue: "singleOption2",
})
.input({
type: "multiselect",
key: "input2",
label: "What is your multiple choices?",
options: ["multipleOption1", "multipleOption2"],
defaultValue: ["multipleOption2"],
})
.input({
type: "confirm",
key: "input3",
label: "Are you sure to skip next input?",
defaultValue: false,
})
.input({
type: "text",
key: "input4",
label: (context) =>
`Dynamic input label generated from a contextual value: ${context.input1}`,
defaultValue: "Empty input",
skip(context) {
return Boolean(context.input3);
},
})
.task({
handler(context) {
helpers.message(JSON.stringify(context, null, 4));
},
});
```

option({ key, name, description, defaultValue, skip })

The `option` API defines a contextual CLI option.
The option value can be accessed through its `key` property from the current context.

```ts
#!/usr/bin/env node

import { termost, helpers } from "termost";
import { name, version } from "../package.json" with { type: "json" }; // Depending on your `package.json` location.

type ProgramContext = {
optionWithAlias: number;
optionWithoutAlias: string;
};

const program = termost({
name,
description: "CLI description",
version,
});

program
.option({
key: "optionWithAlias",
name: { long: "shortOption", short: "s" },
description: "Useful CLI flag",
defaultValue: 0,
})
.option({
key: "optionWithoutAlias",
name: "longOption",
description: "Useful CLI flag",
defaultValue: "defaultValue",
})
.task({
handler(context) {
helpers.message(JSON.stringify(context, null, 2));
},
});
```

task({ key, label, handler, skip })

The `task` executes a handler (either a synchronous or an asynchronous one).
The output can be either:

- Displayed gradually if no `label` is provided
- Displayed until the promise is fulfilled if a `label` property is specified (in the meantime, a spinner with the label is showcased)

```ts
#!/usr/bin/env node

import { helpers, termost } from "../src";
import { name, version } from "../package.json" with { type: "json" }; // Depending on your `package.json` location.

type ProgramContext = {
computedFromOtherTaskValues: "big" | "small";
execOutput: string;
size: number;
};

const program = termost({
name,
description: "CLI description",
version,
});

program
.task({
key: "size",
label: "Task with returned value (persisted)",
async handler() {
return 45;
},
})
.task({
label: "Task with side-effect only (no persisted value)",
async handler() {
await wait(500);
// @note: side-effect only handler
},
})
.task({
key: "computedFromOtherTaskValues",
label: "Task can also access other persisted task values",
handler(context) {
if (context.size > 2000) {
return Promise.resolve("big");
}

return Promise.resolve("small");
},
})
.task({
key: "execOutput",
label: "Or even execute external commands thanks to its provided helpers",
handler() {
return helpers.exec("echo 'Hello from shell'");
},
})
.task({
label: "A task can be skipped as well",
async handler() {
await wait(2000);

return Promise.resolve("Super long task");
},
skip(context) {
const needOptimization = context.size > 2000;

return !needOptimization;
},
})
.task({
label: (context) =>
`A task can have a dynamic label generated from contextual values: ${context.computedFromOtherTaskValues}`,
async handler() {},
})
.task({
handler(context) {
helpers.message(
`If you don't specify a label, the handler is executed in "live mode" (the output is not hidden by the label and is displayed gradually).`,
{ label: "Label & console output" },
);

helpers.message(
`A task with a specified "key" can be retrieved here. Size = ${context.size}. If no "key" was specified the task returned value cannot be persisted across program instructions.`,
{ label: "Context management" },
);
},
})
.task({
handler(context) {
const content =
"The `message` helpers can be used to display task content in a nice way";

helpers.message(content, {
label: "Output formatting",
});
helpers.message(content, { type: "warning" });
helpers.message(content, { type: "error" });
helpers.message(content, { type: "success" });
helpers.message(content, {
type: "information",
label: "👋 You can also customize the label",
});
console.log(
helpers.format(
"\nYou can also have a total control on the formatting through the `format` helper.",
{
color: "white",
modifiers: ["italic", "strikethrough", "bold"],
},
),
);

console.info(JSON.stringify(context, null, 2));
},
});

const wait = (delay: number) => {
return new Promise((resolve) => setTimeout(resolve, delay));
};
```


## 🤩 Built with Termost

- [Quickbundle](https://github.com/adbayb/quickbundle) The zero-configuration transpiler and bundler for the web.


## 💙 Acknowledgements

This project is built upon solid open-source foundations. We'd like to thank:

- [`enquirer`](https://www.npmjs.com/package/enquirer) for managing `input` internals
- [`listr2`](https://www.npmjs.com/package/listr2) for managing `task` internals


## 📖 License

[MIT](https://github.com/adbayb/termost/blob/main/LICENSE "License MIT")