zero config · bash & zsh · any language · Go engine

Your file tree
is your command tree.

Drop executables into your repo's folders, in any language, and they're already a CLI: monom db migrate, with Tab completion in bash and zsh. No config file, no registration, nothing generated.

install$ curl -fsSL https://monom.dev/install.sh | bash
command tree
autoplaying — click to take over

A live port of mnmd filter running against the example project used across this site. Folders complete as groups, scripts as commands.

adopting monom

01

The boilerplate is a folder of scripts.

Install monom once per machine. After that, a project adopts it by doing nothing: the scripts it already has become commands the moment you cd into it.

  • acme/
    • db/
      • migrate.py
      • seed.js
    • infra/
      • cloud/
        • deploy.sh
      • local/
        • start
    • tools/
      • lint
      • Format.sh
    • _lib/
      • log.sh
    • node_modules/
    • README.md
    • release.rb

02

monom finds every executable file.

It looks at the executable files in your project and registers each one with a shebang or a command-style name, or that you declared. Then it maps the file tree to a ready-to-use CLI, with Tab completion in bash and zsh.

Any language works: Python, Node, bash, Ruby, a compiled Go binary. The path is the command, minus the file's extension, so db/migrate.py is monom db migrate. A command-style name matches ^[a-z0-9][a-z0-9_-]*$ (lowercase, no dots), so a file with an extension needs a shebang, and a compiled binary like tools/lint gets in by name.

Hidden and _-prefixed entries, node_modules, vendor, venv, target, dist, __pycache__ and nested monom projects are never scanned. Add discover.hide patterns to skip more.

  • acme/
    • db/
      • migrate.pypymonom db migrate#!/usr/bin/env python3
      • seed.jsjsmonom db seed#!/usr/bin/env node
    • infra/
      • cloud/
        • deploy.shbashmonom infra cloud deploy#!/usr/bin/env bash
      • local/
        • startbashmonom infra local start#!/usr/bin/env bash, no extension
    • tools/
      • lintgomonom tools lintcompiled Go binary: no shebang, command-shaped name
      • Format.shshmnmd check warnsno shebang, and the name has a capital and a dot
      • bash
    • README.mdmdignorednot executable
    • release.rbrbmonom release#!/usr/bin/env ruby

03

Your paths are your commands.

Each folder is a word, and the file, without its extension, is the last one. The spaces you type are the slashes in the path, so monom infra cloud deploy runs infra/cloud/deploy.sh, from any folder inside the project.

file tree
  • acme/
    • db/
      • migrate.pypy
      • seed.jsjs
    • infra/
      • cloud/
        • deploy.shbash
      • local/
        • startbash
    • tools/
      • lintgo
      • Format.shsh
      • bash
    • README.mdmd
    • release.rbrb

Hover or tap a file to see its command.

command line
~/acme $ monom db migrate
running db migrations...

Then keep it honest in CI

mnmd check is the doctor. It flags executables discovery found but didn't register, as a warning by default, so a fresh project passes. When you're ready to enforce it, one line in a monom file makes it an error.

Outside git? touch monom marks a root. An empty file is a valid config.

$ mnmd check
warning: [root-contents] executable not registered: tools/Format.sh (no shebang, and the name does not match ^[a-z0-9][a-z0-9_-]*$); add a shebang, rename it, or declare it in the monom config file
✔ 6 commands OK (default discovery), 1 warning(s)
$ echo $?
0
$ echo 'check.root-contents = error' > monom
$ mnmd check
error: [root-contents] executable not registered: tools/Format.sh (no shebang, and the name does not match ^[a-z0-9][a-z0-9_-]*$); add a shebang, rename it, or declare it in the monom config file
mnmd check: 1 error(s), 0 warning(s)
$ echo $?
1

the monom file

Optional. Add one only when you need it.

A file named monom at the root marks the project and customizes it. It comes in three shapes, decided by its first line.

Absent

Zero config. The git root is the project and default discovery supplies the command tree. Most projects can stay here.

$ cd ~/code/acme/infra/cloud
$ mnmd root
/home/you/code/acme

Declarative

Any file without a shebang, including an empty one. monom parses it and never runs it: declare paths the gate skips, hide entries, set mnmd check's severity.

monom
# register an executable the gate would skip
tools/Format.sh
# make mnmd check fail on unregistered executables
check.root-contents = error
# hide more entries, like dot-files (repeatable)
discover.hide = infra/local

Hook script

An executable with a shebang. monom runs it for hooks, all optional: complete, run, config, debug. Leave one out and the default applies.

monom
#!/usr/bin/env bash
# every hook is optional: without complete,
# default discovery lists the commands
case "$1" in
run)
shift
# monom ship → infra cloud deploy
if [ "$1" = ship ]; then echo infra cloud deploy; fi
;;
config)
echo "discover.hide = tools"
;;
esac
How the root is found, first match wins:
  1. $_MONOM_PROJECT_ROOT, the pin an alias or your rc file sets;
  2. the nearest directory upward containing a file named monom;
  3. the nearest git root.
The current directory alone is never a root.

why monom

A CLI framework that stays out of your scripts.

Any language, even compiled

monom resolves a path and execs it. Your script never imports, sources, or links anything; a shebang or a binary is all it needs.

  • db migrate#!/usr/bin/env python3
  • db seed#!/usr/bin/env node
  • infra cloud deploy#!/usr/bin/env bash
  • infra local start#!/usr/bin/env bash
  • tools lintcompiled Go binary
  • release#!/usr/bin/env ruby

Go owns logic, shell owns surface

Discovery, filtering and resolution live in mnmd, a compiled Go binary. The shell layer is only what Go can't do: define monom, register completion, exec.

$ type -t monom mnmd
function
function

Tab tested with real key presses

Completion is covered by YAML cases that start interactive bash and zsh in a pseudo-terminal, press Tab, and check the edit line and the listing on screen.

tests/cases/monom_keys.yaml
- name: tab completes a unique prefix and adds a space
input: monom in
action: keys
tabs: 1
line: "monom infra "

Completion never breaks mid-typing

mnmd filter always exits 0. A broken path yields no suggestion, not an error splashed over your prompt. Diagnostics live in mnmd check.

$ echo 'bad path/x' | mnmd filter bad
$ echo $?
0

Your code can own the list

A complete hook that prints paths replaces default discovery entirely: read git ls-files, parse a manifest, anything that prints lines.

complete)
cd "$(dirname "$0")" || exit
# tracked executables, minus this file
git ls-files -s | awk '$1 == 100755 { print $4 }' | grep -vx monom
;;

No lock-in

Your commands are plain executables in plain folders. Delete monom tomorrow and ./infra/cloud/deploy.sh still runs.

$ ./infra/cloud/deploy.sh
deploying to cloud...

nouns and verbs

Folders are nouns. Scripts are verbs.

Type a folder and monom tells you what's inside instead of failing, using the same pipeline as Tab, so the listing always matches what completion would offer.

Want monom infra to do something? Map it in the run hook. A group is never runnable by accident.

$ monom infra
monom: 'infra' is a command group
available: cloud, local

who it's for

One framework, two kinds of CLI.

The repo's front door

Every repo grows a pile of scripts nobody can navigate. With monom installed, a teammate's first monom Tab in the repo shows every workflow the team has, with nothing committed for monom's sake.

$ monom <Tab>
db infra release tools
$ monom db <Tab>
migrate seed

Your own toolbox

Pin monom to a folder of personal scripts and they're tab-completable from any directory, organized by topic instead of prefixed my-thing-do-x.sh.

# ~/.zshrc, after the source line mnmd install added
export _MONOM_PROJECT_ROOT="$HOME/scripts"

compare

Honest about what it isn't.

monom is closest to Basecamp's sub, a folder of scripts with completion, grown nested and given a Go engine. It is not a build tool: if you need a task graph or caching, reach for just, Task, or mise. And today every word you type is part of the command path: commands don't take arguments yet.

monomsubjustTaskmisebashly
Each command is its own executable file●●○recipes in a justfile○tasks in Taskfile.yml●file tasks○compiled into one script
Nested command groups from folders●any depth○flat◐modules◐includes●as a:b●declared in YAML
Commands in any language●●●shebang recipes◐via shell●○bash
No generate or build step●●●●●○bashly generate
Your code can decide the command list●optional complete hook○○○○○
Arguments, flag parsing and generated help○on the backlog◐help from comments●◐●usage spec●
Dependencies between tasks○not a build tool○●●+ caching●○

Compiled from each project's documentation, September 2026. Spotted something wrong? Open an issue.

principles

Built on a written constitution.

Go owns logic, shell owns surface

Shell code exists only where Go is technically impossible: sourcing into your session, registering completion, exec.

Minimize subprocess roundtrips

Every process boundary on the Tab path has to justify itself. Fewer hops, less latency, less to debug.

Errors carry their own exit code

One central registry. A command group exits 3, always, and nothing else does.

The config interface is a contract

monom requires nothing from your monom file, not even that it exists. Changing that takes a constitutional amendment.

Read the constitution →

Give your scripts a front door.

One command, one rc line. Then open any repo and press Tab.

$ curl -fsSL https://monom.dev/install.sh | bash

Zero-config discovery, mnmd check and bash/zsh completion work today. Commands don't take arguments yet; flag parsing and scaffolding (mnmd init, mnmd new command) are on the backlog.