A modular TypeScript library for building interactive CLI interfaces with menus, text inputs, multi-value selections, translations and fully customisable styles.
- Installation
- Quick start
- Core concepts
- Environment configuration (.env)
- Plugins
- Menus
- Actions
- Styles
- Configs
- Values
- Globals
- anchorGlobal
- Parents
- Translations
- Style behaviour and priority rules
- API documentation
- Contributing
- AI-assisted development
npm install @termun/coreimport { Cli } from "@termun/core";import { Cli } from "@termun/core";
const cli = new Cli();
cli.addPlugin({
name: "app",
menus: [
{
name: "main",
type: "choice",
values: [],
},
{
name: "press-to-continue",
type: "input",
value: "",
configs: {
clear: false,
fastSubmit: true,
callback: async ({ parent }): Promise<void> => {
await cli.run(parent ?? "main");
},
},
},
],
actions: [
{
name: "back",
type: "goto",
to: "main",
global: true,
},
{
name: "exit",
type: "function",
global: true,
styles: { idle: { color: "red", italic: true } },
callback: async (): Promise<void> => {
Cli.write("Goodbye!", "red");
process.exit(0);
},
},
],
});
await cli.run();The library is built around three entities:
| Entity | Description |
|---|---|
| Plugin | Container for menus, actions, and translations. A Cli instance can hold multiple plugins. |
| Menu | An interactive screen — either a choice list or a text input. |
| Action | Something that happens when a user selects a menu item: navigation (goto) or a custom function. |
Navigation is driven by parents (which menus show this item) and goto (where to send the user).
Create a .env file at the root of your project to set global defaults for all menus. The library reads it at runtime via dotenv, so you can override values without changing code.
# Language
DEFAULT_LANGUAGE=en
# Debug logging (writes to logs/ directory)
DEBUG_LOG=false
# Idle style (cursor elsewhere, not selected)
DEFAULT_CHOICE_IDLE_PREFIX=" "
DEFAULT_CHOICE_IDLE_COLOR=
DEFAULT_CHOICE_IDLE_UNDERLINE=false
# Hover style (cursor on the item)
DEFAULT_CHOICE_HOVER_PREFIX=❯
DEFAULT_CHOICE_HOVER_COLOR=
DEFAULT_CHOICE_HOVER_UNDERLINE=false
# Selected style (item is ticked)
DEFAULT_CHOICE_SELECTED_PREFIX=★
DEFAULT_CHOICE_SELECTED_COLOR=green
DEFAULT_CHOICE_SELECTED_UNDERLINE=false
# Italic modifiers
DEFAULT_CHOICE_IDLE_ITALIC=false
DEFAULT_CHOICE_HOVER_ITALIC=false
DEFAULT_CHOICE_SELECTED_ITALIC=falseAll values are optional. If unset, the corresponding style property is left undefined and the terminal's own theme is respected.
Tip: create a
.env.localfile (git-ignored) to override settings per-machine without touching the committed.env.
All defaults are managed by the built-in Env class:
import { Utility } from "@termun/core";
const env = Utility.getEnv();
env.getIdleColor(); // returns ColorName | undefined
env.setIdleColor("cyan"); // change at runtime
env.getLanguage(); // "en" | "it" | ...Call Utility.getEnv().load() once at startup if you need to force a .env reload.
A plugin is the top-level container. Menus and actions declared inside a plugin are namespaced automatically: my-plugin.my-menu.
cli.addPlugin({
name: "my-plugin",
menus: [ /* ... */ ],
actions: [ /* ... */ ],
translations: { /* ... */ },
});Multiple plugins can be chained:
cli.addPlugin({ name: "core", /* ... */ })
.addPlugin({ name: "settings", /* ... */ });An arrow-key navigation list. Supports single-select, multi-select, and custom styles per item.
{
name: "main",
type: "choice",
global: false,
index: 0,
parents: ["..."],
styles: {
idle: { prefix: " ", color: "blue" },
hover: { prefix: "❯ ", color: "cyan" },
selected: { prefix: "★ ", color: "green" },
},
labels: {
question: "main.question", // translation key
title: "main.title",
success: "main.success",
error: "main.error",
},
values: [ /* ... */ ],
configs: { /* ... */ },
}A free-text field. Supports validation, placeholder, and auto-submit.
{
name: "nickname",
type: "input",
parents: ["main"],
value: "",
labels: {
placeholder: "input.placeholder",
},
configs: {
clear: true,
fastSubmit: false,
validate: (value: string): boolean | string =>
value.trim().length > 0 || "Field cannot be empty",
callback: async ({ value, parent }): Promise<void> => {
await cli.run("press-to-continue", parent);
},
},
}validate can return:
true— validfalse— invalid (shows the menu's generic error label)string— invalid with a custom message (supports translation keys)
A composite menu combining a choice list and an optional input. Useful for forms where the user first picks an item and then provides additional text.
{
name: "edit-profile",
type: "field",
parents: ["main"],
values: [
{ value: "username", labels: { title: "field.username" } },
{ value: "email", labels: { title: "field.email" } },
],
configs: {
choice: { /* choice sub-configs */ },
input: { /* input sub-configs */ },
},
}A full-screen text editor (wraps @inquirer/editor). Useful when the user needs to review or edit a multi-line block of text (e.g. a config file, an .env.local).
{
name: "my-editor",
type: "editor",
parents: ["main"],
labels: { question: "editor.question" },
configs: {
default: (): string => loadCurrentFileContent(), // dynamic default content
callback: async ({ value }): Promise<void> => {
saveContent(value);
await cli.run("press-to-continue", "main");
},
},
}The default option accepts either a static string or a function returning a string, evaluated fresh each time the editor opens. The user saves and closes the temporary file in their $EDITOR.
Navigates to another menu or action.
{
name: "back",
type: "goto",
to: "main",
global: true,
styles: { idle: { color: "gray" } },
}Special
backbehaviour: an action namedbackwithglobal: trueis automatically transformed into a per-menu back action. Each menu receives aback_<menuName>action pointing to wherever the user came from. No manual tracking needed.
Runs a custom async callback.
{
name: "exit",
type: "function",
global: true,
styles: { idle: { color: "red", italic: true } },
callback: async (): Promise<void> => {
Cli.write("Goodbye!", "red");
process.exit(0);
},
}Every menu, action, and individual option can have three style states: idle, hover, and selected.
| Property | Type | Description |
|---|---|---|
prefix |
string |
String prepended to the label |
color |
ColorName (chalk) |
Text colour |
underline |
boolean |
Underlined text |
italic |
boolean |
Italic text |
Styles are resolved from most specific to least specific:
per-option style
→ menu configs style
→ .env defaults
The most specific level always wins.
configs are menu-level settings that apply to all items unless overridden per-item.
Choice configs:
configs: {
clear: true,
selectable: false,
defaultValues: ["en"],
callback: async ({ values, menu, parent }): Promise<void> => {
// values = confirmed selections
},
}Input configs:
configs: {
clear: true,
fastSubmit: false,
validate: (value: string): boolean | string => true,
callback: async ({ value, parent }): Promise<void> => { /* ... */ },
}selectable: true enables the selected style state. Use it on menus that represent persistent toggles (e.g., language picker, feature flags). Do not use it on plain navigation menus.
values: ["submenu1", "action1"]Or with per-item style overrides:
values: [
{
value: "analytics",
labels: { title: "analytics.title" },
styles: {
idle: { prefix: "~ ", color: "gray" },
hover: { prefix: "» ", color: "yellow" },
selected: { prefix: "★ ", color: "magenta" },
},
},
]Computed at runtime — useful when the list depends on external state:
values: (data): MenuFieldJsonValue[] =>
Translations.getLanguages().map((lang) => ({
value: lang,
labels: { title: data.menu.getLabels().getAnswer(lang)?.getName() },
}))Set multi: true on individual items. The user confirms with Space then Enter.
values: [
{ value: "notifications", multi: true },
{ value: "darkmode", multi: true },
{ value: "autosave", multi: true },
]A menu or action with global: true appears automatically at the bottom of every menu, separated by a divider.
{
name: "exit",
type: "function",
global: true,
styles: { idle: { color: "red" } },
callback: async (): Promise<void> => { process.exit(0); },
}Global behaviour:
- Rendered below a
──────────────separator - Never show the
selectedstyle, regardless ofselectable indexcontrols ordering among globals:- Globals without an
index(orindex: 0) appear first - Positive
indexvalues sort ascending after non-indexed globals - Negative
indexvalues sort at the very bottom, by absolute value ascending (-1before-2before-3) - Built-in defaults:
back→index: -3,language→index: -1,exit→index: -2
- Globals without an
anchorGlobal: true on a menu marks it as the entry point of a wizard or multi-step flow. Any global ActionGoto whose target is an anchorGlobal menu is automatically hidden from that menu and from all its descendants (menus that have it as a parents ancestor, recursively).
Use case: you have a global "Create" action that navigates to env-name-input. You don't want "Create" to appear while the user is already inside the Create wizard, because it would restart the flow. Mark env-name-input with anchorGlobal: true and declare parents on every wizard step menu — the library handles the rest.
// Entry point of the wizard
{
name: "env-name-input",
type: "input",
anchorGlobal: true, // hides the "create" global inside this wizard
/* ... */
}
// Step 2 — declares its parent so the ancestor chain can be traced
{
name: "repo-selection",
type: "choice",
parents: ["env-name-input"],
/* ... */
}
// Step 3 — also a descendant
{
name: "branch-select-tars",
type: "choice",
parents: ["repo-selection"],
/* ... */
}The global action that navigates to env-name-input:
{
name: "create",
type: "goto",
to: "env-name-input",
global: true,
}With anchorGlobal: true set, the "create" option disappears from env-name-input, repo-selection, branch-select-tars, and any other menu that has env-name-input anywhere in its ancestor chain.
Requirements:
parentsmust be declared statically on every menu in the wizard (the library usesgetParents()to walk the ancestor chain — it does not infer parents from navigation calls)- Only
ActionGotoglobals are affected;ActionFunctionglobals are never hidden byanchorGlobal
parents declares which menus an item appears in automatically.
{
name: "settings",
type: "choice",
parents: ["main"], // automatically added to the "main" menu
}Equivalent to calling mainMenu.addOption(settingsMenu) manually, but managed by the library at load() time. An item can have multiple parents:
parents: ["main", "submenu1"]parents also serves as the ancestor chain used by anchorGlobal: the library walks parents recursively to determine whether a menu is a descendant of an anchorGlobal entry point. For this reason, parents must be declared statically on every menu that belongs to a wizard flow — even if navigation is driven programmatically via cli.run().
Translation keys follow the format <plugin>.<menu>.<field>.
translations: {
"my-plugin.my-menu.title": { en: "My Menu", it: "Il mio menu" },
"my-plugin.my-menu.question": { en: "Choose:", it: "Scegli:" },
"my-plugin.my-menu.success": { en: "Done!", it: "Fatto!" },
"my-plugin.my-menu.error": { en: "Invalid", it: "Non valido" },
"my-plugin.my-menu.answer.en": { en: "English", it: "Inglese" },
}Supported label fields:
| Key suffix | Used for |
|---|---|
.title |
Label of this item when shown in a parent menu |
.question |
Prompt text shown above the menu |
.success |
Message shown after a successful callback |
.error |
Validation error message |
.answer.<value> |
Label for a specific option |
.placeholder |
Placeholder text for input menus |
Static API:
Translations.setCurrentLanguage("it");
Translations.getSelectedLanguage(); // "it"
Translations.getDefaultLanguage(); // from .env DEFAULT_LANGUAGE
Translations.getLanguages(); // all registered languages| State | Prefix | Colour |
|---|---|---|
| Cursor on item | hover › idle |
hover › idle |
| At rest | idle |
idle |
| State | Prefix | Label colour |
|---|---|---|
| Cursor on + just selected (Space) | selected › hover › idle |
selected › hover › idle |
| Cursor on + already selected | selected › hover › idle |
hover › selected › idle |
| Selected, cursor elsewhere | selected › idle |
selected › idle |
| At rest | idle |
idle |
"Just selected" logic: in the single frame immediately after Space is pressed, the label colour uses the selected style for immediate visual feedback. On the next cursor move it reverts to the standard priority.
Globals never show the selected style, even when selectable: true.
Full HTML documentation is generated automatically from JSDoc comments using TypeDoc and published to GitHub Pages on every push to main.
View online: https://termun.github.io/core-ts/
Generate locally:
npm run docs:generate # generates ./docs/
npm run docs:serve # serves at http://localhost:8080 (opens browser)Documentation covers all public and protected classes, methods, types and overloads, organised by module:
cli—Clientry pointenv—Envenvironment defaultsutility—Utilitystatic helperactions—Action,ActionGoto,ActionFunction,ActionLabelsmenus—Menu,MenuChoice,MenuInput,MenuFieldand all configs/labels/stylesstyles—StyleIdle,StyleHover,StyleSelectedtranslations—Translations,Labelplugins— plugin JSON shape
See CONTRIBUTING.md for the full guide.
Key points:
- Open an issue before starting non-trivial work
- Follow the code style rules (enforced by ESLint — see below)
- Use
npm run git:commitfor conventional commits (usesczg) - CI must pass before merging (dependency safety + lint + tests)
Available scripts:
| Command | Description |
|---|---|
npm run build |
Compile to dist/ (ESM + CJS + types) |
npm run build:en |
Build with DEFAULT_LANGUAGE=en baked in |
npm run build:test |
Preview npm package contents via npm pack --dry-run |
npm run pack |
Build + create .tgz |
npm run style:dry |
Run ESLint (no fix) |
npm run style:apply |
Run ESLint with auto-fix |
npm run test |
Run the test suite |
npm run dev |
Run src/dev.ts (tsx) |
npm run example -- <name> |
Run src/examples/example-<name>.ts (e.g. npm run example -- mix) |
npm run publish:dry |
Build + simulate npm publish (no upload) |
npm run docs:generate |
Generate HTML docs via TypeDoc |
npm run docs:serve |
Serve generated docs at localhost:8080 |
npm run git:commit |
Interactive conventional commit via czg |
npm run prepare |
Install Husky git hooks (runs automatically on install) |
Before running npm run publish:dry, authenticate with npm:
npm ping
npm whoami || npm login --auth-type=legacyIf you switch between multiple npm accounts, use this identity check routine before publishing:
npm whoami
# if the user is not the expected one:
npm logout
npm login --auth-type=legacy
npm whoami
npm run publish:dryThis project has been set up so that any AI assistant used by contributors produces consistent, rule-compliant code.
All coding rules are stored in .skills/, organised by language:
.skills/
typescript/
code-style.md — control flow patterns (no early void return, single return variable, braces)
conventions.md — TypeScript conventions (no-any, explicit return types, @/ imports)
Tool-specific instruction files point back to .skills/ as the single source of truth:
| File | Tool |
|---|---|
.github/copilot-instructions.md |
GitHub Copilot |
SKILLS.md |
Cursor, Claude, and other AI agents |
- No early
voidreturn (flowstyle/no-early-void-return) — useif/elsenesting instead of barereturn;as a control-flow shortcut - Single return via variable — declare one result variable, assign in each branch, return once
- Explicit return types (
@typescript-eslint/explicit-function-return-type) — all functions must declare their return type - No
any(@typescript-eslint/no-explicit-any) — type everything explicitly @/path alias — useimport { Foo } from "@/components/foo"instead of relative paths insidesrc/- Braces always required (
curly: ["error", "all"]) — even for single-lineifbodies
- Edit the relevant file in
.skills/typescript/ - If the rule can be automated, add or update the corresponding ESLint rule in
eslint.config.mjs - Update
.github/copilot-instructions.mdif the rule requires a prose description for AI context
Any PR that breaks ESLint will be blocked by CI.