Zexi is a powerful terminal interface and CLI application framework for Node.js. It provides structured logging, interactive terminal input, dynamic terminal output, screen management, terminal events, and styling capabilities for building rich command-line applications.
Visit us at www.nasriya.net.
Made with ❤️ in Palestine 🇵🇸
Zexi is designed for applications that need more than simple console.log() output.
It provides a structured terminal environment where applications can:
- Log values using multiple severity levels.
- Capture and process terminal events.
- Collect interactive user input.
- Ask for confirmations.
- Display passwords and other sensitive input privately.
- Create dynamic terminal entries that can be updated in place.
- Build progress indicators and live status output.
- Clear and manage terminal output through a shared screen engine.
- Configure logging behavior independently for different terminal instances.
- Consume immutable JSON log events from application code.
- Render terminal output using Zexi's rendering and styling system.
The terminal API is asynchronous and designed around queued screen operations, allowing terminal updates and interactive operations to be coordinated without directly manipulating the underlying terminal screen.
Important
🌟 Support Our Development! 🌟
If you find Zexi or our other software useful, consider supporting our continued development.
Building a polished CLI application often requires much more than writing text to standard output.
Applications may need structured logging, interactive prompts, validation, password handling, progress indicators, dynamic status messages, and a reliable way to react to terminal operations. Implementing these capabilities independently can quickly lead to application-specific terminal infrastructure.
Zexi provides these capabilities through a unified terminal API.
- Structured logging — Log values using
debug,info,warn,error, andfatalseverity levels. - JSON log events — Every log operation produces a structured immutable event that can be consumed by application code.
- Interactive input — Prompt users for values directly through the terminal.
- Input privacy — Capture input as visible text, masked passwords, or completely hidden input.
- Input validation — Validate user input synchronously or asynchronously before accepting it.
- Confirmation prompts — Provide conventional
[Y/n]and[y/N]interactions. - Dynamic terminal entries — Create output regions that can be updated after they are rendered.
- Live application output — Build progress indicators, status displays, counters, and other changing terminal content.
- Terminal events — Subscribe to logging and terminal lifecycle events.
- Screen management — Clear and update terminal output through the shared screen engine.
- Configurable terminals — Create multiple terminal interfaces with independent logging configuration.
- Styling and rendering — Produce structured and styled terminal output without directly managing terminal escape sequences.
Zexi is particularly useful for:
- CLI applications.
- Interactive command-line tools.
- Installation and setup programs.
- Development and administration utilities.
- Build and deployment tools.
- Server management applications.
- Database and infrastructure utilities.
- Long-running terminal processes.
- Applications that need structured logs in addition to human-readable terminal output.
Zexi can also be used as a terminal infrastructure layer inside larger Node.js applications.
Install Zexi from npm:
npm install @nasriya/zexiFor ESM applications:
import zexi from '@nasriya/zexi';For CommonJS applications:
const zexi = require('@nasriya/zexi').default;The ZexiTerminal class provides the primary API for logging, terminal events, interactive input, dynamic entries, and screen management.
The package also exports a default terminal instance for applications that do not require separate terminal configurations.
import terminal from '@nasriya/zexi';The default exported terminal can be used immediately:
import terminal from '@nasriya/zexi';
await terminal.info('Server started.');When an application requires different logging policies for different parts of the application, additional terminal instances can be created with with():
const verboseTerminal = terminal.with({
logLevel: 'debug'
});
const productionTerminal = terminal.with({
logLevel: 'warn'
});These terminal instances have independent configuration while sharing the same underlying screen engine and event system.
This makes separate instances useful when different components of an application require different logging policies.
Zexi provides structured logging through five severity levels:
await terminal.debug('Detailed diagnostic information.');
await terminal.info('Application started.');
await terminal.warn('Configuration file was not found.');
await terminal.error('Unable to connect to the database.');
await terminal.fatal('Application cannot continue.');All logging methods accept any JavaScript value:
await terminal.info({
message: 'User authenticated.',
userId: 123,
method: 'password'
});The supplied value is rendered into Zexi's canonical representation before the log event is created.
The log operation produces a structured event containing the original value, serialized representation, and printable representation.
Zexi supports the following log levels, ordered by severity:
debug < info < warn < error < fatal
The terminal's logLevel controls which events are printed:
terminal.logLevel = 'warn';With this configuration:
await terminal.debug('Debug message.');
await terminal.info('Information.');
await terminal.warn('Warning.');
await terminal.error('Error.');
await terminal.fatal('Fatal error.');Only warn, error, and fatal messages are printed by that terminal instance.
Importantly, changing logLevel does not disable the corresponding events.
Events continue to be emitted and can still be consumed by application code. The setting only controls what the terminal instance prints.
Log metadata can be enabled when additional context should be displayed with terminal output:
terminal.includeMetadata = true;When enabled, printed log entries include information such as their timestamp and log level.
For example:
[2026-01-01T12:00:00.000Z] [INFO] Application started.
Metadata affects terminal presentation only. The underlying emitted log event retains its structured information independently of how the message is displayed.
One of Zexi's major features is that logging is not limited to terminal output.
Every log operation creates an immutable structured event that can be consumed by application code.
For example:
terminal.events.on('log', event => {
console.log(event);
});Individual log levels can also be observed:
terminal.events.on('log.error', event => {
// Send the error event to a monitoring service.
});This makes it possible to integrate Zexi with external logging and monitoring infrastructure.
For example, an application can forward Zexi events to:
- Monitoring platforms.
- Centralized logging systems.
- Log files.
- Remote observability services.
- Application-specific telemetry systems.
The terminal's human-readable output and the application's structured logging pipeline can therefore coexist without requiring the application to parse terminal output.
For a log operation, Zexi emits the level-specific event first and then the general log event.
Both emissions reference the same immutable event object.
terminal.events.on('log.info', event => {
// Level-specific event.
});
terminal.events.on('log', event => {
// General log event.
});Listeners can also be registered for a single invocation:
terminal.events.once('clear', event => {
console.log('Terminal was cleared.');
});Both on() and once() return an unsubscribe function:
const unsubscribe = terminal.events.on('log', event => {
// Handle event.
});
unsubscribe();Logging is appropriate for messages that remain part of the terminal history.
Some applications, however, need output that changes over time.
Zexi provides dynamic terminal entries for this purpose.
const entry = await terminal.createEntry({
value: 'Loading...'
});The entry can then be updated:
entry.update('Loading... 50%');And eventually finalized:
entry.update('Loading complete.');Dynamic entries are useful for:
- Progress indicators.
- Download progress.
- Installation status.
- Server status.
- Counters.
- Long-running operations.
- Live application state.
Entries can also use templates:
const entry = await terminal.createEntry({
template: 'Progress: ${value}%',
params: {
value: 0
}
});The parameters can then be updated independently:
entry.updateParams({
value: 50
});This allows an application to maintain a persistent terminal region while changing only the underlying data.
Dynamic entries can optionally emit log events:
const entry = await terminal.createEntry(
{
value: 'Server started.'
},
{
log: true,
level: 'info'
}
);This separates dynamic screen output from structured logging while still allowing applications to opt into logging when appropriate.
Zexi provides interactive prompts through terminal.prompt().
const name = await terminal.prompt({
message: 'Name: '
});
if (name !== null) {
await terminal.info(`Hello, ${name}!`);
}The returned value is either:
- A string containing the submitted input.
nullwhen the prompt is cancelled or times out.
An empty string is a valid submitted value:
const value = await terminal.prompt({
message: 'Value: '
});
if (value === null) {
await terminal.info('Prompt cancelled.');
} else if (value === '') {
await terminal.info('An empty value was submitted.');
} else {
await terminal.info(`Value: ${value}`);
}Prompt input can be configured for different privacy modes.
const username = await terminal.prompt({
message: 'Username: ',
privacy: 'visible'
});const password = await terminal.prompt({
message: 'Password: ',
privacy: 'password'
});With password privacy, each entered character is displayed as *.
Hidden Input
const secret = await terminal.prompt({
message: 'Secret: ',
privacy: 'hidden'
});The actual returned value is unaffected by the privacy mode.
Prompt input can be validated before it is accepted.
const port = await terminal.prompt({
message: 'Port: ',
onCustomValidation: (value, reject) => {
const number = Number(value);
if (!Number.isInteger(number) || number < 0 || number > 65535) {
reject('Please enter a valid port between 0 and 65535.');
}
}
});When validation fails, Zexi clears the current input and displays the validation message before allowing the user to try again.
Validation callbacks can also be asynchronous:
const username = await terminal.prompt({
message: 'Username: ',
onCustomValidation: async (value, reject) => {
if (value.length < 3) {
reject('Username must contain at least 3 characters.');
return;
}
const available = await checkUsernameAvailability(value);
if (!available) {
reject('That username is already taken.');
}
}
});While asynchronous validation is running, further input is temporarily disabled.
Applications can request explicit confirmation using confirm():
const confirmed = await terminal.confirm(
'Continue with the operation?'
);
if (confirmed === null) {
return;
}
if (confirmed) {
await performOperation();
}By default, the confirmation uses:
Continue with the operation? [y/N]:
A positive default can be selected:
const confirmed = await terminal.confirm(
'Continue with the installation?',
{
default: true
}
);This produces:
Continue with the installation? [Y/n]:
Pressing Enter accepts the default action.
The result can be:
true— the action was accepted.false— the action was rejected.null— the confirmation was cancelled or timed out.
Interactive operations can optionally expire after a period of inactivity.
const value = await terminal.prompt({
message: 'Enter your name: ',
timeoutAfter: 30_000
});When the timeout expires, the prompt resolves with null.
The timeout represents inactivity rather than a fixed maximum duration. User input resets the timeout.
Interactive operations can also be cancelled with Ctrl+C.
For prompts, the Escape key can be configured independently:
const value = await terminal.prompt({
message: 'Enter a value: ',
escapeBehavior: 'reset'
});Supported behaviors are:
cancel— cancel the prompt.reset— clear the current input and continue.ignore— ignore the Escape key.
The terminal can be cleared through the high-level clear() API:
await terminal.clear();The operation is queued through Zexi's terminal task system rather than directly manipulating the screen immediately.
The returned promise resolves after the screen has been cleared and the corresponding clear event has been emitted and processed.
Applications that need to wait for the operation can therefore use:
await terminal.clear();
await terminal.info('The terminal has been cleared.');The clear operation also emits a clear event:
terminal.events.on('clear', event => {
console.log('Terminal cleared:', event);
});The default terminal instance is suitable for most applications.
Applications that need separate terminal configurations can create additional instances:
const applicationTerminal = terminal.with({
logLevel: 'info'
});
const debugTerminal = terminal.with({
logLevel: 'debug',
includeMetadata: true
});Each instance maintains its own:
logLevel.includeMetadata.
The instances nevertheless share the underlying screen engine and event system.
This allows different parts of an application to have different output policies without creating independent terminal environments.
Zexi's event system is shared across terminal instances.
A listener registered through one terminal instance can receive events emitted through another:
const terminalA = terminal.with();
const terminalB = terminal.with();
terminalA.events.on('log', event => {
console.log('Received:', event);
});
await terminalB.info('Hello from terminal B.');This architecture allows an application to centralize logging and monitoring while allowing individual components to maintain their own terminal configuration.
The terminal exposes the names of events that currently have registered listeners:
console.log(terminal.events.eventNames);This list represents currently registered events rather than every event supported by Zexi.
Zexi provides a dedicated CLI API for building command-line applications. The entry point is zexi.cli, which provides factory methods for creating both CLI applications and commands.
Use zexi.cli.createApp() to create the root application:
const app = zexi.cli.createApp(
'my-cli',
'My awesome CLI tool'
);createApp() returns a ZexiApp instance. ZexiApp extends ZexiStaticCommand, so the application itself behaves as the root static command of the CLI command tree.
The application is responsible for:
- Defining the root CLI name and description
- Registering top-level commands
- Defining global options
- Registering application-level middleware and handlers
- Defining a root action
- Parsing and executing the current process arguments
For example:
const app = zexi.cli.createApp(
'my-cli',
'My awesome CLI tool'
);
app.onRun(() => {
zexi.terminal.info('Starting application...');
});The application can also have a root action:
const app = zexi.cli.createApp('my-cli');
app.action(() => {
zexi.terminal.info('Running my CLI...');
});The root action is executed when the application itself is the selected command.
Because ZexiApp extends ZexiStaticCommand, commands can be registered directly on the application using .command().
Commands can be created through zexi.cli.createCommand():
const app = zexi.cli.createApp(
'my-cli',
'My awesome CLI tool'
);
const build = zexi.cli
.createCommand('build')
.description('Build the project')
.action(() => {
zexi.terminal.info('Building project...');
});
app.command(build);The fluent API also makes it possible to construct and register a command in one expression:
app.command(
zexi.cli
.createCommand('build')
.description('Build the project')
.action(() => {
zexi.terminal.info('Building project...');
})
);Multiple commands can be registered as well:
const build = zexi.cli
.createCommand('build')
.description('Build the project')
.action(() => {
zexi.terminal.info('Building...');
});
const serve = zexi.cli
.createCommand('serve')
.description('Start the development server')
.action(() => {
zexi.terminal.info('Starting server...');
});
app.command([build, serve]);Commands created by default are static commands:
const build = zexi.cli.createCommand('build');A static command can contain nested commands, making it suitable for building hierarchical CLI interfaces.
For example:
const packages = zexi.cli
.createCommand('packages')
.description('Manage packages');
const install = zexi.cli
.createCommand('install')
.description('Install packages')
.action(() => {
zexi.terminal.info('Installing packages...');
});
packages.command(install);
app.command(packages);This produces a command hierarchy equivalent to:
my-cli
└── packages
└── install
Static commands can also define descriptions, aliases, options, onSeen() handlers, middleware, and an action.
For example:
const build = zexi.cli
.createCommand('build')
.description('Build the project')
.aliases(['b'])
.option({
name: 'production',
abbrev: 'p',
dataType: 'boolean',
defaultValue: false
})
.action(ctx => {
const production = ctx.options.get('production');
zexi.terminal.info(
production
? 'Building for production...'
: 'Building...'
);
});
app.command(build);Static commands are useful when the command structure is known when the application is configured.
Zexi also supports dynamic commands. A dynamic command is created by explicitly passing "dynamic" as the command mode:
const build = zexi.cli.createCommand(
'build',
'dynamic'
);Unlike static commands, dynamic commands cannot contain subcommands. Instead, they represent executable command endpoints that handle their own arguments and options.
For example:
const build = zexi.cli
.createCommand('build', 'dynamic')
.description('Build the project')
.option({
name: 'production',
abbrev: 'p',
dataType: 'boolean',
defaultValue: false
})
.action(ctx => {
const production = ctx.options.get('production');
zexi.terminal.info(
production
? 'Building for production...'
: 'Building...'
);
});
app.command(build);Dynamic commands are useful when a command should terminate the command hierarchy and execute an action directly.
Zexi also supports delegator commands. A delegator command is created using the dynamic command mode together with another ZexiApp instance:
const delegatedApp = zexi.cli.createApp(
'packages',
'Package management commands'
);
const install = zexi.cli
.createCommand('install')
.description('Install packages')
.action(() => {
zexi.terminal.info('Installing packages...');
});
delegatedApp.command(install);
const packages = zexi.cli.createCommand(
'packages',
'dynamic',
delegatedApp
);
app.command(packages);The delegator command connects one application's static command tree to another application. This allows command trees to be defined and maintained independently while still being exposed through another CLI application.
Both static and dynamic commands support the common fluent configuration API:
const command = zexi.cli
.createCommand('deploy')
.description('Deploy the application')
.aliases(['d'])
.option({
name: 'environment',
abbrev: 'e',
dataType: 'string'
})
.onSeen(ctx => {
zexi.terminal.debug(
`Executing ${ctx.command.name}`
);
})
.use((ctx, terminate) => {
if (!ctx.options.get('environment')) {
terminate({
ok: false,
reason: 'user_error',
message: 'Environment is required.'
});
}
})
.action(ctx => {
const environment = ctx.options.get('environment');
zexi.terminal.info(
`Deploying to ${environment}...`
);
});The command lifecycle consists of several stages. When a command is encountered during command resolution, its onSeen() handlers are executed. Middleware then runs before the command action or delegation. Middleware can terminate execution when validation, authorization, or other prerequisites fail. If execution continues and the command is the selected endpoint, its action is executed.
Applications provide onRun() for application-level behavior:
const app = zexi.cli.createApp(
'my-cli',
'My awesome CLI tool'
);
app.onRun(() => {
zexi.terminal.info('Initializing CLI...');
});The application handler runs whenever the root application command is encountered, including when a nested command is ultimately executed.
This makes onRun() suitable for application-wide initialization such as:
- Loading configuration
- Initializing shared resources
- Startup logging
- Enabling global behavior
- Preparing application-wide state
onRun() does not replace the root command's action. If you need an action for the application itself, use .action().
For example:
const app = zexi.cli.createApp(
'my-cli',
'My awesome CLI tool'
);
app.onRun(() => {
loadConfiguration();
})
.action(() => {
zexi.terminal.info('Running CLI...');
});Once the application and its command tree have been configured, call app.run():
const app = zexi.cli.createApp(
'my-cli',
'My awesome CLI tool'
);
const build = zexi.cli
.createCommand('build')
.description('Build the project')
.action(() => {
zexi.terminal.info('Building project...');
});
app.command(build);
const result = await app.run();run() executes the application using the current process arguments.
During execution, Zexi handles:
- Command resolution
- Option parsing
- Application and command handlers
- Middleware execution
- Command actions
- Delegation when applicable
The returned promise resolves to the value produced by the command or action that was ultimately executed.
For example:
const app = zexi.cli.createApp('my-cli');
app.action(() => {
return {
success: true
};
});
const result = await app.run();
console.log(result);The result is therefore not limited to terminal output. A command action can return arbitrary application data, which becomes the result of the CLI execution.
The following example combines an application, nested static commands, options, middleware, and dynamic commands:
const app = zexi.cli.createApp(
'my-cli',
'My awesome CLI tool'
);
app.onRun(() => {
zexi.terminal.debug('Initializing CLI...');
});
const packages = zexi.cli
.createCommand('packages')
.description('Manage packages');
const install = zexi.cli
.createCommand('install', 'dynamic')
.description('Install packages')
.option({
name: 'production',
abbrev: 'p',
dataType: 'boolean',
defaultValue: false
})
.use((ctx, terminate) => {
if (!ctx.options.get('production')) {
terminate({
ok: false,
reason: 'user_error',
message: 'Production mode is required.'
});
}
})
.action(() => {
zexi.terminal.info('Installing production packages...');
});
packages.command(install);
app.command(packages);
await app.run();The resulting structure is conceptually:
my-cli
└── packages
└── install
Here, my-cli is the root ZexiApp, packages is a static command that provides the command hierarchy, and install is a dynamic command that acts as the executable endpoint.
This architecture allows Zexi CLI applications to be composed from reusable command definitions while keeping command hierarchy, middleware, option handling, and execution behavior explicit.
Zexi is licensed under the Nasriya Personal & Commercial License (NPCL), version 2.0.
The NPCL allows personal use under its applicable terms and requires a paid commercial license for commercial use.
See the complete license text in the repository:
For commercial licensing inquiries, contact:
Email: contact@nasriya.net
