run-user-nested-invokable-commands
v1.1.0
Published
Turn any script folder into a CLI
Maintainers
Readme
runic
Point a CLI at a folder. Drop in scripts. They become commands.
That's the whole idea.
eval "$(runic init zsh --name ops --dir ~/scripts)"Now your folder is a CLI:
~/scripts/ $ ops lint
├── lint.sh → $ ops format
├── format.sh $ ops dev start
├── dev/ $ ops gen client
│ ├── start.sh $ ops help
│ └── seed.py
└── gen/
├── client.sh
└── component.jsWant a new command? Drop a file.
$ cat > ~/scripts/pr.sh <<'SCRIPT'
#!/bin/bash
# Open a PR using our team template
gh pr create --template .github/pull_request_template.md --web
SCRIPT
$ chmod +x ~/scripts/pr.sh
$ ops pr # live. no reload, no registration, no restart.No manifest. No config file. The shell function reads the filesystem on every invocation, so the CLI is always whatever's currently on disk.
Scripts can be in any language. Directories become subcommands. Filenames become command names. And if you opt in, native executables (Rust, Go, C, anything the kernel can run) drop into the same folder as first-class commands — compiled speed tier, right next to your bash one-liners.
How it works
runic generates a shell function with the name you choose. That function does all the dispatching — no Node.js startup on every invocation, no manifest to maintain, no config files. It probes the filesystem directly, finds the file that matches your command, and runs it.
How it runs the file depends on what the file is:
- Known extension (
.sh,.py,.rb,.js,.ts, …) — invoked via the mapped interpreter. - Extensionless with a shebang — the shebang picks the interpreter.
- Extensionless and executable (opt-in via
--allow-binaries) — exec'd directly. Native binaries run at kernel speed with no interpreter in the loop.
The generated function handles everything: command dispatch, help, doctor diagnostics, tab completions. You interact with your CLI, not with runic.
Install
npm install -g run-user-nested-invokable-commandsSorry, all the good names were already taken.
Setup
Add one line to your shell config (.zshrc, .bashrc, or config.fish):
# zsh
eval "$(runic init zsh --name ops --dir ~/scripts)"
# bash
eval "$(runic init bash --name ops --dir ~/scripts)"
# fish
runic init fish --name ops --dir ~/scripts | sourceThat's it. You now have a CLI called ops.
Append --allow-binaries to also dispatch native executables from your script dir. Off by default — see Native binaries for why.
Supported runtimes
Most of what lands in your script dir is interpreted. runic knows the common extensions and invokes the right runner automatically:
| Extension | Runner |
|-----------|-----------|
| .sh | bash |
| .bash | bash |
| .zsh | zsh |
| .py | python3 |
| .rb | ruby |
| .js | node |
| .ts | npx tsx |
| .php | php |
| .pl | perl |
Extensionless files with a shebang line work too — the interpreter is read from the shebang. So #!/usr/bin/env python3 or #!/usr/bin/env -S python3 -u both resolve correctly with no extension in the filename.
Run runic runtimes to see which of these are installed on the current host, with versions.
Native binaries
Off by default. Pass --allow-binaries at init and runic will also dispatch extensionless executables — Mach-O, ELF, or any other format the kernel knows how to execve. Your Rust CLI, your Go tool, your hand-compiled C util: drop the binary in the script dir, it's a command.
eval "$(runic init zsh --name ops --dir ~/scripts --allow-binaries)"Why opt-in? Shell scripts are readable source. You can open ~/scripts/lint.sh and see exactly what it does before running it. Binaries are opaque — you're trusting the bytes. Making it a conscious flag means you've thought about whose bytes you're running, not just which folder they live in.
Per-call override without re-init:
__runic_allow_binaries=1 ops fastgrep foo # one-off, still off by defaultIn help and doctor, binaries show up as binary instead of a description — there's no shebang to read a comment from. doctor also skips the missing-shebang and runner-availability checks for binaries, since they're their own runner.
Descriptions
The first comment after the shebang becomes the command's description in help output:
#!/bin/bash
# Deploy the application to the target environment#!/usr/bin/env python3
# Run database migrations#!/usr/bin/env node
// Rotate API keys for all servicesNo config files needed. Your scripts document themselves.
Multiple directories — PATH-style overrides
Point at multiple directories. They're searched in order, like $PATH: the first one to match wins. This isn't a conflict to resolve, it's how you override.
eval "$(runic init zsh --name ops --dir ~/my-scripts --dir /shared/team-scripts)"Anything in ~/my-scripts overrides the same-named command in /shared/team-scripts. Same as how ~/bin:/usr/local/bin:/usr/bin lets you shadow a system binary by dropping a script in ~/bin.
What this enables:
- Personal overrides on a shared repo. Your team maintains
/shared/team-scripts/test.shthat runs the standard test suite. You want it to also reset your local DB first. Drop your owntest.shin~/my-scripts— it shadows the team version, just for you, no fork required. - Try a change without a PR. The team script has a rough edge you want to smooth out. Copy it to
~/my-scripts, edit, use it for a week, then PR the change once you're sure. The override evaporates when you delete it. - Per-context behavior. Same command name, different implementation in
~/my-scripts/work/vs~/my-scripts/personal/. Switch by reordering--dir. - Layered teams. Platform team's repo, then your squad's repo, then your personal scripts:
--dir ~/personal --dir ~/squad-scripts --dir /platform-scripts. Each layer can shadow the next.
runic doctor reports every shadowed command so nothing happens behind your back.
Whitelabeling
The --name flag is the CLI's identity. Create as many as you want:
eval "$(runic init zsh --name dev --dir ~/scripts/dev)"
eval "$(runic init zsh --name docs --dir ~/scripts/docs)"
eval "$(runic init zsh --name lint --dir /opt/lint-tools)"Three separate CLIs. Three names. Independent.
Tradeoffs
runic dispatches by reading the filesystem on every invocation. The consequences of that choice:
Upsides
- Shell-native everything. runic
execs your scripts, so they inherit how Unix already works: arg quoting, pipes (ops logs | grep ERROR), redirection, signals, TTY detection, exit codes, process groups, job control. - Login state, sudo prompts, file locks, tmux sessions. Scripts can
ssh-add, prompt forsudo, hold a flock, attach to a tmux session — anything any other shell process can do. - Polyglot. bash, python, node, ruby, typescript, perl, php — any combination in the same CLI. With
--allow-binaries, native executables (Rust, Go, C, …) drop into the same folder and run at kernel speed, no interpreter in the loop. - No registration step. Scripts on disk are the source of truth; there's no manifest.
ops helpand tab completions are generated from the filesystem. - Each script is independent. No framework coupling between commands. Delete one, the others still work. A broken script doesn't take down the CLI.
- Descriptions come from comments. The first comment after the shebang is what
ops helpprints for that command. - Migration is filesystem-only. Adopt by pointing
--dirat a folder of scripts. Stop using runic by deleting the eval line — the scripts remain as files with shebangs.
Downsides
- A filesystem read on every invocation. Each
ops <command>walks the script dirs. Millisecond-scale, proportional to script-dir size. - No global flags.
--verbosedoesn't transparently apply to every command — each script handles its own arg parsing. - No central validation. You can't declare "this command needs
--env=staging|prod" once. Each script enforces its own contract. - Auto-help is minimal. One-line description per command; no generated usage / options / examples. Scripts that need rich help implement their own
--help. - Refactor cost is filesystem cost. Rename
db/todatabase/and the command name moves with it. There's no manifest layer insulating command names from file paths. - Distribution still requires runic. Sharing your CLI means sharing the script folder and asking the recipient to install runic and
evalthe init line. There's no single-binary pack. - Binaries are opaque. Source-code scripts can be inspected before they run; compiled executables can't.
--allow-binariesputs that tradeoff in your hands rather than hiding it — enable it for dirs whose contents you trust, leave it off for dirs you don't.
Have an idea for addressing one of the downsides without changing the core model? Open an issue and let's talk.
Built-in commands
Every generated CLI has these built-in:
| Command | What it does |
|---------|-------------|
| ops help | List all commands with descriptions |
| ops doctor | Check permissions, shebangs, runners for your scripts |
| ops runtimes | Show which interpreters are installed on this host (with versions) |
| ops completions zsh | Generate tab-completion script |
These delegate to the runic binary for the heavy lifting (directory walking, formatting). Dispatch — the thing that runs 100 times a day — stays in the shell function at zero cost.
Note:
help,doctor,runtimes,completions, andcreateare reserved at the top level. A script literally namedhelp.sh(ordoctor.sh, etc.) at the root of your script folder will be unreachable — the built-in wins. Nest it (scripts/admin/help.sh) if you need that name.
runic runtimes
Lists every runtime runic supports, whether it's installed on this host, and what version. Run it before you start writing scripts to see what languages you can reach for; run it later when something unexpectedly stops working.
$ runic runtimes
runic runtimes
✓ bash 5.2.37 (.sh, .bash)
✓ zsh 5.9 (.zsh)
✓ python3 3.12.5 (.py)
✓ ruby 3.3.0 (.rb)
✓ node 20.10.0 (.js)
✓ tsx 4.19.2 (.ts — via npx tsx)
✗ php not installed (.php)
✓ perl 5.38.0 (.pl)
Extensionless files dispatch via their shebang line — any interpreter on PATH works.Tab completions
Generate and load completions for your shell:
# zsh — add to .zshrc after the eval line
source <(ops completions zsh)
# bash
eval "$(ops completions bash)"
# fish
ops completions fish | sourceScaffolding new scripts
runic create scripts/lint.sh # creates with #!/bin/bash
runic create scripts/dev/seed.py # creates with #!/usr/bin/env python3
runic create scripts/gen/component.js # creates with #!/usr/bin/env nodeCreates the file with the right shebang, a description placeholder, and executable permissions.
Environment variables
Every script receives context through environment variables:
| Variable | Example | Description |
|----------|---------|-------------|
| RUNIC_COMMAND | dev start | The command path as typed |
| RUNIC_CLI_NAME | ops | The CLI name |
| RUNIC_SCRIPT_PATH | /home/you/scripts/dev/start.sh | Absolute path to the script |
| RUNIC_DIR | /home/you/scripts | Which directory this script was found in |
| RUNIC_DIRS | /home/you/scripts:/shared/scripts | All directories (colon-separated) |
Scripts can detect they're running inside a generated CLI by checking for RUNIC_COMMAND.
Command resolution
runic uses greedy longest-match. Given ops test unit:
- Try
<dir>/test/unit.{sh,py,js,...}— if found, run it - Try
<dir>/test.{sh,py,js,...}withunitas an argument
This means a script and a directory can coexist with the same name:
scripts/
├── test.sh # ops test (default: run everything)
└── test/
└── unit.sh # ops test unit (just the unit tests)Both work. The longer match wins when it exists.
Extension probe order
When two scripts share a base name with different extensions (e.g., lint.sh and lint.py), the first match in probe order wins:
sh, bash, zsh, py, rb, js, ts, php, pl
ops doctor will flag this as a conflict.
Example: team CLI
A platform team maintains shared scripts in a git repo:
platform-tools/
├── dev/
│ ├── start.sh # Start the local dev stack
│ ├── seed.py # Seed dev DB with sample data
│ └── reset.sh # Reset local state to a clean slate
├── gen/
│ ├── client.sh # Generate typed API client from OpenAPI
│ ├── component.js # Scaffold a new React component
│ └── service.rb # Scaffold a new microservice from template
├── docs/
│ ├── serve.sh # Run the docs site locally
│ └── lint.py # Check docs for broken links
└── bin/
└── fastgrep # Rust binary for ripgrep-speed search
# (platform team vetted; opt-in with --allow-binaries)Each team member adds to their .zshrc:
eval "$(runic init zsh --name plat --dir ~/repos/platform-tools --dir ~/my-scripts --allow-binaries)"The Perl dev, the Python dev, and the bash purist all contribute scripts in their language. Nobody has to agree on a runtime. The Rust-shop subgroup ships a few compiled helpers in bin/ for anything that needs real throughput. The ~/my-scripts directory lets individuals add overrides or personal utilities without touching the shared repo — and --allow-binaries is a platform-level decision, not a per-script one.
Development
git clone https://github.com/rodrigopsasaki/runic.git
cd runic
pnpm install
pnpm test
pnpm run build # produces dist/runic.cjsLicense
MIT
