Skip to main content

Creating Personal CLI Tools Without Global Node, npm, pnpm, or TypeScript

· 3 min read
Pere Pages
Software Engineer

I wanted a tiny CLI tool for a very specific workflow: take an image and expand its canvas to 1200x630, centered, without resizing the image itself. Useful for Open Graph images.

But the interesting part was not the image logic. The interesting part was this constraint:

I do not want global Node, npm, pnpm, TypeScript, or tsx.

That changes the setup.

A terminal running a personal CLI tool

The wrong mental model

The usual advice is:

npm install -g my-tool

Or:

npm link

Or:

pnpm exec tsx script.ts

But all of these assume some globally available language/runtime/package-manager entry point.

In my setup, I only wanted one global-ish thing: a shell command.

The runtime itself should be managed by mise.

The pattern

The clean pattern is:

~/tools/og-canvas/
├─ package.json
├─ og-canvas.ts
├─ node_modules/
└─ .mise.toml

~/bin/og-canvas

~/tools/og-canvas contains the real tool.

~/bin/og-canvas is just a small wrapper script exposed through PATH.

The wrapper

#!/usr/bin/env bash

CALLER_CWD="$PWD"
TOOL_DIR="$HOME/tools/og-canvas"

cd "$TOOL_DIR" || exit 1

CALLER_CWD="$CALLER_CWD" mise exec -- ./node_modules/.bin/tsx ./og-canvas.ts "$@"

This does three important things:

  1. Saves the original directory where the command was called.
  2. Moves into the tool folder so local dependencies resolve correctly.
  3. Uses mise exec to provide Node without needing global Node.

Why CALLER_CWD matters

At first, the tool tried to find the input image inside ~/tools/og-canvas, because the wrapper had changed directory.

So this:

og-canvas ./logo.webp ./og-logo.webp

was interpreted as:

~/tools/og-canvas/logo.webp

Instead of:

current-folder/logo.webp

The fix was to preserve the caller directory and resolve user paths from there.

Inside the TypeScript script:

const callerCwd = process.env.CALLER_CWD ?? process.cwd();

const resolveFromCaller = (value: string) =>
path.isAbsolute(value) ? value : path.resolve(callerCwd, value);

const inputPath = resolveFromCaller(inputArg);

Same for the output path.

The actual example tool

The tool itself is simple:

og-canvas ./logo.webp ./og-logo.webp

It creates a 1200x630 image by increasing the canvas around the original image.

Later, it can support background color options:

og-canvas ./logo.webp ./og-logo.webp --bg "#f7f3ea"
og-canvas ./logo.webp ./og-logo.webp --bg auto

The image stays centered. The canvas grows.

Where PATH belongs

On macOS with zsh:

export PATH="$HOME/bin:$PATH"

belongs in:

~/.zprofile

Not necessarily ~/.zshrc.

Rule of thumb:

~/.zprofile -> login shell setup: PATH, mise, Homebrew
~/.zshrc -> interactive shell setup: aliases, prompt, completions

The final mental model

Global:
- mise
- ~/bin/og-canvas wrapper

Managed by mise:
- node

Local to the tool:
- sharp
- tsx
- typescript

No global Node. No global npm. No global pnpm. No global TypeScript. No global tsx.

Just one executable wrapper in ~/bin.

The takeaway

For personal CLI tools, you do not need to publish a package or install language tooling globally.

A better pattern is:

local tool project + local dependencies + mise runtime + ~/bin wrapper

It gives you a real command available everywhere, while keeping the runtime and dependencies isolated.

This scales nicely for small personal tools: image utilities, text transformers, markdown helpers, metadata scripts, project generators, and anything else that should feel global without polluting your global environment.