cli-atelier
v1.0.2
Published
A complete terminal UI framework for bun and typescript with Zero dependencies (Pure ANSI)
Maintainers
Readme
cli-atelier
A complete terminal UI framework for Bun + TypeScript. Zero dependencies. Pure ANSI.
# Bun
bun add cli-atelier
# or Node / bundlers
npm install cli-atelier
# or
pnpm add cli-atelier
# or
yarn add cli-atelierWhy
Most CLI libraries are heavy, Node-centric, or force you into their patterns. cli-atelier is a zero-dependency, pure-ANSI terminal framework built specifically for Bun and TypeScript. Every module is a named export so you import only what you need.
Requirements
| Runtime | Minimum version |
| ------- | --------------- |
| Bun | >= 1.1.0 |
| Node | >= 18.0.0 |
The
"bun"export condition ships raw.tssource so Bun users get zero-overhead imports. Node/bundler consumers get the compileddist/.
Installation
bun add cli-atelier # Bun
npm install cli-atelier # Node / bundlersFull API Reference
CLI: a Command router
import { CLI } from "cli-atelier"
const app = new CLI("my-app")
app.command("greet", "Say hello", (args, flags) => {
const name = (args[0] as string) ?? (flags["name"] as string) ?? "world"
console.log("Hello, " + name)
})
app.run()bun index.ts greet Nolly
bun index.ts greet --name Nolly
bun index.ts helpCLI methods
| Method | Description |
| ------------------------------------- | ---------------------------------------- |
| new CLI(name) | Create a new CLI app with a display name |
| .command(name, description, action) | Register a command |
| .run() | Parse process.argv and dispatch |
The action callback receives (args: string[], flags: Record<string, string | boolean>).
Display: ANSI engine
The foundational primitive everything else is built on.
import { Display } from "cli-atelier"
// Paint text with one or more codes
console.log(Display.paint("Hello", Display.color.cyan, Display.color.bold))
// Compose styles freely
console.log(Display.paint("Error", Display.color.bgRed, Display.color.white, Display.color.bold))
// xterm-256 gradient (char by char)
console.log(Display.gradient("Atelier", 51, 21))
// Truecolor RGB gradient
console.log(Display.gradientRGB("Atelier", [0, 255, 255], [255, 0, 255]))
// ANSI-safe string utilities
const raw = Display.strip("\x1b[32mHello\x1b[0m") // -> "Hello"
const len = Display.len("\x1b[32mHello\x1b[0m") // -> 5
const pad = Display.pad("Hi", 10, "center") // -> " Hi "Display methods
| Method | Description |
| --------------------------------------------- | ------------------------------------------------------------------- |
| Display.paint(text, ...codes) | Wrap text in ANSI codes, always reset at end |
| Display.gradient(text, from, to) | xterm-256 horizontal gradient (code indices 0–255) |
| Display.gradientRGB(text, [r,g,b], [r,g,b]) | Truecolor 24-bit horizontal gradient |
| Display.strip(text) | Remove all ANSI escape codes |
| Display.len(text) | Visible character length (ANSI-safe) |
| Display.pad(text, width, align?) | Pad to visible width -> 'left' (default) / 'right' / 'center' |
| Display.clear() | Clear the terminal |
| Display.cursorTo(x, y) | Move cursor to absolute position |
| Display.hideCursor() | Hide terminal cursor |
| Display.showCursor() | Show terminal cursor |
| Display.moveUp(n?) | Move cursor up n lines |
| Display.moveDown(n?) | Move cursor down n lines |
| Display.clearLine() | Clear the current line |
| Display.clearDown() | Clear from cursor to bottom |
| Display.width() | Terminal column count (fallback: 80) |
Display.color tokens
// Text styles
Display.color.reset | bold | dim | italic | underline | strike
// Foreground
Display.color.black | red | green | yellow | blue | magenta | cyan | white | gray
// Bright foreground
Display.color.brightRed | brightGreen | brightYellow | brightBlue
Display.color.brightMagenta | brightCyan | brightWhite
// Background
Display.color.bgBlack | bgRed | bgGreen | bgYellow | bgBlue
Display.color.bgMagenta | bgCyan | bgWhite | bgGrayLog: Structured logger
import { Log } from "cli-atelier"
Log.info("Server started on :3000")
Log.success("Deployment complete")
Log.warn("Memory usage above 80%")
Log.error("Connection refused")
Log.debug("Cache hit rate: 94.2%")
Log.blank()| Method | Icon | Color |
| ------------------ | ---- | ---------- |
| Log.info(msg) | ℹ | Cyan |
| Log.success(msg) | ✔ | Green |
| Log.warn(msg) | ⚠ | Yellow |
| Log.error(msg) | ✖ | Red |
| Log.debug(msg) | ⬡ | Gray dim |
| Log.blank() | - | Empty line |
Header: Section headers
import { Header } from "cli-atelier"
Header.render("Deployment Pipeline", { style: "banner", color: Display.color.magenta })
Header.render("Services", { style: "section", color: Display.color.cyan })
Header.render("Step 1", { style: "inline", color: Display.color.yellow })
Header.divider()
Header.divider(40) // custom widthOptions
| Option | Type | Default | Description |
| ------- | ------------------------------------------------ | -------------------- | ---------------------- |
| style | 'banner' \| 'section' \| 'divider' \| 'inline' | 'section' | Visual style |
| color | string | Display.color.cyan | Border and title color |
| width | number | terminal width | Override render width |
| align | 'left' \| 'center' \| 'right' | 'center' | Title alignment |
Badge: Inline status badges
import { Badge } from "cli-atelier"
// Logged on its own line
Badge.log("success", "All systems operational")
Badge.log("error", "Connection refused", "API") // custom label
// Inline in a string
const status = Badge.render("warn", "RATE") + " Rate limit at 90%"
console.log(status)Variants: 'success' 'error' 'warn' 'info' 'neutral' 'debug'
Box: Bordered box
import { Box } from "cli-atelier"
Box.render([
Display.paint("System Info", Display.color.white, Display.color.bold),
"",
" Hostname: prod-node-01",
" Uptime: 22d 7h",
], { style: "round", color: Display.color.cyan, title: "Server" })Options
| Option | Type | Default |
| --------- | ------------------------------------------------------ | -------------------- |
| style | 'single' \| 'double' \| 'round' \| 'bold' \| 'ascii' | 'round' |
| color | string | Display.color.gray |
| padding | number | 1 |
| title | string | - |
| width | number | auto |
Table: data table
import { Table } from "cli-atelier"
Table.render(
[
{ name: "nginx", status: "running", port: "80" },
{ name: "postgres", status: "running", port: "5432" },
{ name: "redis", status: "stopped", port: "6379" },
],
[
{ key: "name", header: "Service", align: "left" },
{ key: "status", header: "Status", align: "center" },
{ key: "port", header: "Port", align: "right" },
],
{ style: "round", zebra: true }
)ColumnDef
| Property | Type | Description |
| -------- | ------------------------------- | -------------------------------------------- |
| key | string | Object key to read |
| header | string | Column heading |
| align | 'left' \| 'right' \| 'center' | Cell alignment |
| color | string | ANSI color code for all cells in this column |
| width | number | Minimum column width override |
Table options
| Option | Type | Default |
| ------------- | ------------------------------------------------------------ | ----------------------- |
| style | 'single' \| 'double' \| 'round' \| 'minimal' \| 'markdown' | 'round' |
| headerColor | string | Display.color.cyan |
| borderColor | string | Display.color.gray |
| zebra | boolean | false |
| zebraColor | string | Display.color.bgBlack |
Progress: Progress bar
import { Progress } from "cli-atelier"
const bar = new Progress({ label: "Uploading", width: 35, color: Display.color.cyan })
for (let i = 0; i <= 100; i += 5) {
bar.update(i)
await Bun.sleep(40)
}
bar.finish("Upload complete")Constructor options
| Option | Type | Default |
| ------------ | --------- | -------------------- |
| label | string | '' |
| width | number | 30 |
| color | string | Display.color.cyan |
| emptyColor | string | Display.color.gray |
| filled | string | '█' |
| empty | string | '░' |
| showPct | boolean | true |
Spinner: Async indicator
import { Spinner } from "cli-atelier"
const s = new Spinner("Deploying...")
s.start()
await doWork()
s.stop("Deployed successfully") // ✔ green
s.stop("Deployment failed", false) // ✖ redPrompt: Text input
import { Prompt } from "cli-atelier"
const name = await Prompt.text("Your name:")
const pass = await Prompt.text("Password:", { mask: true })
const email = await Prompt.text("Email:", {
validate: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "Invalid email"
})Options
| Option | Type | Description |
| ---------- | ------------------------------- | ------------------------------------------- |
| mask | boolean | Replace characters with • (password mode) |
| validate | (v: string) => string \| null | Return error string or null to pass |
Confirm: Yes/No prompt
import { Confirm } from "cli-atelier"
const ok = await Confirm.ask("Deploy to production?", { defaultYes: false })Press Y / N or Enter to accept the default. Returns boolean.
Select: Single-choice menu
import { Select } from "cli-atelier"
const env = await Select.prompt("Target environment:", [
{ label: "Production", value: "prod", hint: "Live - be careful" },
{ label: "Staging", value: "staging", hint: "Pre-release" },
{ label: "Development", value: "dev", hint: "Local only" },
])Controls: ^ / ↓ or j / k to move, Enter to confirm.
SelectOption<T>
| Property | Type | Description |
| -------- | -------- | ------------------------------- |
| label | string | Display text |
| value | T | Returned value |
| hint | string | Dim secondary text shown inline |
| color | string | Override item color |
Config options
| Option | Type | Default |
| --------------- | -------- | -------------------- |
| pointer | string | '❯' |
| selectedColor | string | Display.color.cyan |
| hintColor | string | Display.color.gray |
| maxVisible | number | 8 |
MultiSelect: Multi-choice menu
import { MultiSelect } from "cli-atelier"
const services = await MultiSelect.prompt("Services to restart:", [
{ label: "nginx", value: "nginx" },
{ label: "postgres", value: "postgres" },
{ label: "redis", value: "redis" },
], { min: 1, max: 2 })Controls: ↑ / ↓ to navigate, Space to toggle, a to select all / none, Enter to confirm.
Additional config options (extends Select config)
| Option | Type | Default | Description |
| --------------- | -------- | --------------------- | --------------------------- |
| checkedIcon | string | '◉' | Icon for selected items |
| uncheckedIcon | string | '◯' | Icon for unselected items |
| checkedColor | string | Display.color.green | Color for checked items |
| min | number | 0 | Minimum required selections |
| max | number | Infinity | Maximum allowed selections |
Input: Raw input
import { Input } from "cli-atelier"
// Standard readline prompt
const answer = await Input.ask("Primary OS? ")
// Raw keypress listener (returns cleanup function)
const stop = Input.listen((key) => {
if (key === "q") { stop(); process.exit(0) }
if (key === "\x1b[A") console.log("up arrow")
})Tree: Recursive tree renderer
import { Tree } from "cli-atelier"
import type { TreeNode } from "cli-atelier"
Tree.render({
label: "my-project",
children: [
{
label: "src",
children: [
{ label: "index.ts", hint: "entry point" },
{ label: "router.ts" },
]
},
{ label: "package.json" },
]
})TreeNode
| Property | Type | Description |
| ---------- | ------------ | ------------------------------ |
| label | string | Display text |
| hint | string | Dim text shown after the label |
| color | string | Override label color |
| children | TreeNode[] | Nested children |
Columns: Multi-column layout
import { Columns } from "cli-atelier"
// Plain strings - auto-fit columns
Columns.render(["index.ts", "router.ts", "display.ts", "input.ts", "prompt.ts"])
// Colored items with a prefix
Columns.render(
[
{ label: "nginx", color: Display.color.green },
{ label: "redis", color: Display.color.red },
],
{ prefix: "● ", prefixColor: Display.color.gray, cols: 3 }
)Options
| Option | Type | Default | Description |
| ------------- | -------- | --------------------- | ----------------------- |
| cols | number | auto | Fixed column count |
| minWidth | number | auto | Minimum column width |
| gap | number | 2 | Spaces between columns |
| color | string | Display.color.white | Default item color |
| prefix | string | '' | Prefix before each item |
| prefixColor | string | Display.color.gray | Prefix color |
Diff: Unified diff renderer
import { Diff } from "cli-atelier"
Diff.render(oldText, newText, {
labelOld: "--- before.ts",
labelNew: "+++ after.ts",
lineNumbers: true,
contextLines: 3,
})
// Raw output for custom rendering
const lines = Diff.compute(oldText, newText)Options
| Option | Type | Default |
| -------------- | --------- | ----------- |
| lineNumbers | boolean | true |
| contextLines | number | 3 |
| gutterWidth | number | 4 |
| labelOld | string | '--- old' |
| labelNew | string | '+++ new' |
Form: Multi-step form runner
import { Form } from "cli-atelier"
const result = await Form.run([
{ key: "name", label: "Your name:", type: "text" },
{ key: "env", label: "Environment:", type: "select",
options: [
{ label: "Production", value: "prod" },
{ label: "Staging", value: "staging" },
]
},
{ key: "wipe", label: "Wipe data?", type: "confirm",
when: (v) => v["env"] === "prod"
},
], { title: "Deploy Config" })
console.log(result.env, result.wipe)Field types
| type | Extra properties | Description |
| --------------- | ----------------------- | ------------------ |
| "text" | mask, validate | Free text input |
| "select" | options | Single-choice menu |
| "multiselect" | options, min, max | Multi-choice menu |
| "confirm" | defaultYes | Yes/no prompt |
All fields support when: (values) => boolean for conditional rendering.
Form options
| Option | Type | Default |
| ------------ | --------- | -------------------- |
| title | string | - |
| titleColor | string | Display.color.cyan |
| summary | boolean | true |
Notify: Non-blocking notifications
import { Notify } from "cli-atelier"
// Auto-dismiss after 3 s
Notify.show("success", "Deployment complete!")
Notify.show("error", "Connection refused", { duration: 5000 })
// Countdown bar
Notify.show("info", "Running migrations...", { duration: 4000, countdown: true })
// Manual control
const id = Notify.show("warn", "Still processing...", { duration: 0 })
await doWork()
Notify.clear(id)
// Clear everything
Notify.clearAll()Options
| Option | Type | Default | Description |
| ----------- | --------- | ------- | ---------------------------- |
| duration | number | 3000 | Auto-dismiss ms. 0 = never |
| countdown | boolean | false | Show animated countdown bar |
Variants: 'info' 'success' 'warn' 'error' 'debug'
Menu: Nested interactive menu
import { Menu } from "cli-atelier"
await Menu.run([
{
label: "Deploy",
children: [
{ label: "Production", action: async () => deploy("prod") },
{ label: "Staging", action: async () => deploy("staging") },
],
},
{
label: "Locked feature",
disabled: true,
hint: "requires admin",
},
{ label: "Quit", action: () => process.exit(0) },
])Controls: ↑ / ↓ / j / k navigate, Enter selects or enters sub-menu, ← / Backspace goes back, q quits.
MenuItem
| Property | Type | Description |
| ---------- | ----------------------------- | ------------------------- |
| label | string | Display text |
| hint | string | Dim secondary text |
| color | string | Label color override |
| children | MenuItem[] | Sub-menu items |
| action | () => void \| Promise<void> | Executed on selection |
| disabled | boolean | Greyed out, un-selectable |
Autocomplete: Live-filter input
import { Autocomplete } from "cli-atelier"
const pkg = await Autocomplete.prompt("Install package:", [
{ label: "typescript", value: "typescript", hint: "TS compiler" },
{ label: "express", value: "express", hint: "web framework" },
{ label: "zod", value: "zod", hint: "schema validation" },
{ label: "drizzle-orm", value: "drizzle-orm", hint: "ORM for TypeScript" },
])Controls: Type to filter, ↑ / ↓ or Tab to navigate suggestions, Enter to accept, Escape to dismiss suggestions.
Config options
| Option | Type | Default |
| ---------------- | ---------------------------- | --------------------- |
| maxSuggestions | number | 6 |
| minChars | number | 1 |
| matchColor | string | Display.color.cyan |
| unmatchedColor | string | Display.color.white |
| hintColor | string | Display.color.gray |
| filter | (query, option) => boolean | substring match |
Layout: Split-pane panels
import { Layout } from "cli-atelier"
Layout.render([
{
title: "Services",
color: Display.color.cyan,
lines: [
Display.paint("● nginx", Display.color.green) + " :80",
Display.paint("● postgres", Display.color.green) + " :5432",
],
},
{
title: "Logs",
color: Display.color.yellow,
lines: [
"[INFO] Server started",
"[WARN] Memory at 82%",
],
},
])
// Horizontal rule with optional label
Layout.rule("End of section", Display.color.gray)Panel
| Property | Type | Default | Description |
| ----------- | ----------------------------------------------------- | -------------------- | --------------------------- |
| title | string | - | Title in top border |
| lines | string[] | - | Content lines (ANSI-safe) |
| border | 'single' \| 'double' \| 'round' \| 'bold' \| 'none' | 'round' | Border style |
| color | string | Display.color.gray | Border color |
| width | number | auto-split | Fixed panel width |
| emptyText | string | '' | Shown when lines is empty |
| align | 'left' \| 'right' \| 'center' | 'left' | Content alignment |
Layout options
| Option | Type | Default |
| ---------- | --------- | ------------- |
| gap | number | 1 |
| height | number | tallest panel |
| equalise | boolean | true |
Task: Multi-task runner
import { Task } from "cli-atelier"
const results = await Task.run([
{
label: "Install dependencies",
run: async (log) => {
await installDeps()
log("22 packages resolved")
},
},
{
label: "Compile TypeScript",
run: async () => compile(),
},
{
label: "Deploy (prod only)",
skip: (results) => {
const env = results["Check env"]?.output as { env: string } | undefined
return env?.env !== "prod"
},
run: async () => deploy(),
},
], { title: "CI Pipeline", bail: true, parallel: false })The run callback receives a log(msg) helper. Messages appear indented below the task row in real time.
Options
| Option | Type | Default | Description |
| ---------- | --------- | ------- | -------------------------------------- |
| title | string | - | Section header above the task list |
| parallel | boolean | false | Run all tasks concurrently |
| bail | boolean | false | Abort remaining tasks on first failure |
| summary | boolean | true | Print result table when complete |
TaskDef
| Property | Type | Description |
| -------- | --------------------------- | ------------------------------- |
| label | string | Display text |
| run | (log) => Promise<unknown> | Async work |
| skip | (results) => boolean | Return true to skip this task |
TaskResult (returned per task in the result map)
| Property | Type |
| ---------- | ----------------------------------------------------------- |
| label | string |
| status | 'pending' \| 'running' \| 'done' \| 'failed' \| 'skipped' |
| duration | number (ms) |
| output | unknown |
| error | string \| undefined |
Pager: Scrollable viewport
import { Pager } from "cli-atelier"
await Pager.show(logLines, {
title: "application.log",
lineNumbers: true,
searchable: true,
})Controls
| Key | Action |
| ----------------- | ----------------------- |
| ↑ / k | Scroll up one line |
| ↓ / j | Scroll down one line |
| u / Page Up | Scroll up half a page |
| d / Page Down | Scroll down half a page |
| g / Home | Jump to top |
| G / End | Jump to bottom |
| / | Enter search mode |
| n | Next match |
| N | Previous match |
| Escape | Clear search |
| q / Ctrl-C | Quit pager |
Options
| Option | Type | Default |
| ------------- | --------- | ---------------------- |
| title | string | '' |
| height | number | terminal rows - 3 |
| lineNumbers | boolean | true |
| wrap | boolean | false |
| statusColor | string | Display.color.bgGray |
| gutterColor | string | Display.color.gray |
| searchable | boolean | true |
Complete example
import {
CLI, Display, Header, Log, Badge, Box,
Table, Progress, Spinner, Prompt, Confirm,
Select, MultiSelect, Form, Task, Pager,
Tree, Columns, Diff, Layout, Menu,
Autocomplete, Notify,
} from "cli-atelier"
const app = new CLI("myapp")
app.command("deploy", "Full deployment workflow", async () => {
Display.clear()
Header.render("Deployment", { style: "banner", color: Display.color.magenta })
const result = await Form.run([
{
key: "env", label: "Environment:", type: "select",
options: [
{ label: "Production", value: "prod" },
{ label: "Staging", value: "staging" },
]
},
{
key: "wipe", label: "Wipe data?", type: "confirm", defaultYes: false,
when: (v) => v["env"] === "prod"
},
])
await Task.run([
{ label: "Build", run: async (log) => { await build(); log("dist/index.js - 142 kB") } },
{ label: "Test", run: async () => test() },
{ label: "Upload", run: async () => upload() },
{ label: "Restart", run: async () => restart() },
], { title: "Pipeline", bail: true })
Log.success("Done - deployed to " + result["env"])
})
app.run()Module index
| Module | Export | Type |
| ------------------ | -------------- | ------ |
| Command router | CLI | Class |
| ANSI engine | Display | Object |
| Structured logger | Log | Object |
| Section headers | Header | Object |
| Status badges | Badge | Object |
| Bordered boxes | Box | Object |
| Data table | Table | Object |
| Progress bar | Progress | Class |
| Async spinner | Spinner | Class |
| Text input | Prompt | Object |
| Yes/no prompt | Confirm | Object |
| Single-choice menu | Select | Object |
| Multi-choice menu | MultiSelect | Object |
| Raw keypress | Input | Object |
| Tree renderer | Tree | Object |
| Column layout | Columns | Object |
| Unified diff | Diff | Object |
| Multi-step form | Form | Object |
| Notifications | Notify | Object |
| Nested menu | Menu | Object |
| Live autocomplete | Autocomplete | Object |
| Split-pane layout | Layout | Object |
| Task list runner | Task | Object |
| Scrollable pager | Pager | Object |
License
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <[email protected]>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.