oscli

Visual primitives

Compose boxes, tables, spinners, progress, links, trees, diffs, and more with the same output system.

Visual primitives are small on purpose. Some return strings for composition. Others print directly through the shared output system.

Primitive overview

Prop

Type

Box

Use cli.box() to frame content in a titled section.

await cli.run(async () => {
  cli.box({
    title: "Deployment",
    content: "Ready to deploy to production.",
  });
});
─ Deployment ───────────────────┐
  Ready to deploy to production. │
─────────────────────────────────┘

Table

Use cli.table() to return aligned column text. It composes directly into cli.box().

const summary = cli.table(
  ["Field", "Value"],
  [
    ["project", "oscli"],
    ["teamSize", 3],
    ["approved", true],
  ],
);
Field     Value
─────────
project   oscli
teamSize  3
approved  true

Spinner

Use cli.spin() around one async operation.

await cli.run(async () => {
  await cli.spin("Installing packages", async () => {
    await new Promise((resolve) => setTimeout(resolve, 300));
  });
});
⠸ Installing packages...   0.2s
 Installed packages       0.3s

If you do not pass doneLabel, oscli tries to convert common gerunds to a past-tense completion label.

Progress

Use cli.progress() when your work already has named steps.

await cli.run(async () => {
  const steps = ["Validate", "Build", "Finalize"] as const;

  await cli.progress("Running steps", steps, async (step) => {
    if (step === "Validate") {
      await new Promise((resolve) => setTimeout(resolve, 150));
    }

    if (step === "Build") {
      await new Promise((resolve) => setTimeout(resolve, 150));
    }

    if (step === "Finalize") {
      await new Promise((resolve) => setTimeout(resolve, 150));
    }
  });
});
⠸ Running steps  [######--------------]  [00:01]   33%
 Running steps  [####################]  [00:03]  100%

Pass { style } as the fourth argument to change the bar appearance. Available styles: "hash" (default), "line", "steps", "braille", "block", "gradient".

await cli.progress("Building", steps, fn, { style: "block" });

Divider

Use cli.divider() for visual section boundaries.

await cli.run(async () => {
  cli.divider("Results");
});
───────────────── Results ────────────────

Use cli.link() for docs or support URLs. Renders as a clickable OSC 8 hyperlink in supported terminals, otherwise falls back to label (url).

await cli.run(async () => {
  cli.link("oscli docs", "https://github.com/aidankmcalister/oscli");
});
oscli docs (https://github.com/aidankmcalister/oscli)

Tree

Use cli.tree() when you want to render directory-style output inside another primitive.

const structure = cli.tree({
  src: {
    "index.ts": null,
    "client.ts": null,
  },
  "package.json": null,
});

cli.box({
  title: "Project",
  content: structure,
});
─ Project ──────────────┐
  ├─ src                │
  │  ├─ index.ts        │
  │  └─ client.ts       │
  └─ package.json       │
────────────────────────┘

Diff

Use cli.diff() when you want to show a text change without pulling in an external diff library.

await cli.run(async () => {
  cli.diff("hello\nworld", "hello\noscli");
});
  hello
- world
+ oscli

Note

Use cli.note() for bordered callout blocks — info panels, warning notices, or structured metadata.

await cli.run(async () => {
  cli.note(
    "Environment   production\nRegion        us-east-1\nRuntime       Node.js 20",
    "Deploying",
  );
});
─ Deploying ──────────────────┐
  Environment   production    │
  Region        us-east-1     │
  Runtime       Node.js 20    │
──────────────────────────────┘

Use cli.banner() for rounded bordered boxes — version upgrade nags, announcements, or notices.

await cli.run(async () => {
  cli.banner({
    message: "Update available: 1.2.0 → 1.4.1\nRun npm i -g oscli to upgrade",
  });
});
───────────────────────────────────╮
  Update available: 1.2.0 → 1.4.1  │
  Run npm i -g oscli to upgrade    │
───────────────────────────────────╯

Key-Value

Use cli.keyValue() for aligned key-value pairs — summaries, compact diffs, or config output.

await cli.run(async () => {
  cli.keyValue({
    name: "my-app → my-app-v2",
    region: "us-west-2 → us-east-1",
    replicas: "1 → 3",
  });
});
name      my-app → my-app-v2
region    us-west-2 → us-east-1
replicas  1 → 3

Leaders

Use cli.leaders() for dot-leader lines connecting a label to a right-aligned value.

await cli.run(async () => {
  cli.leaders("Build", "✔  1.2s");
  cli.leaders("Typecheck", "✔  0.8s");
  cli.leaders("Lint", "▲  0.3s");
  cli.leaders("Test", "✗  2.1s");
});
Build ·························· ✔  1.2s
Typecheck ······················ ✔  0.8s
Lint ··························· ▲  0.3s
Test ···························   2.1s

Code Frame

Use cli.codeFrame() to show errors with inline source annotation and an optional hint.

await cli.run(async () => {
  cli.codeFrame({
    file: "src/index.ts",
    line: 12,
    column: 5,
    message: "Type 'string' is not assignable to type 'number'",
    source: [
      "const port: number = process.env.PORT",
      "                     ^^^^^^^^^^^^^^^^",
    ],
    hint: "wrap with Number() or use parseInt()",
  });
});
src/index.ts:12:5
 Type 'string' is not assignable to type 'number'
    11  const port: number = process.env.PORT
    12                       ^^^^^^^^^^^^^^^^
     ^^^^^^^^^^^^^^^^
  Hint: wrap with Number() or use parseInt()

Composablecli.table() and cli.tree() return strings so you can pass them into other primitives (e.g. cli.box({ content: cli.table(...) })).

Displaycli.box(), cli.note(), cli.banner(), cli.diff(), cli.keyValue(), cli.leaders(), cli.codeFrame(), cli.divider(), and cli.link() print to stdout immediately.

On this page