One script to bootstrap a Python or Bash CLI project into a working, globally-installed command.
setup_cli.sh is a portable, idempotent, self-verifying installer you drop into the root of any CLI project. It figures out whether the project is Python or Bash, runs the appropriate pipeline, installs the command to ~/.local/bin, and confirms the result actually runs.
Install it once as a global setup-cli command (see Using it as a template) or copy it into each project — both work.
Re-running it is always safe.
Tested on: Fedora Linux 44 (Workstation Edition) x86_64, kernel
7.1.8-200.fc44.x86_64, Bash5.3.9(1)-release, CPython3.14.7, pipx1.15.0, hatchling1.32.0. These are the exact versionssetup_cli.sh2.2.0was developed and verified against. macOS/BSD paths exist in the code (readlink -fhas a Python fallback) but were not exercised on this machine.
| Step | Python mode | Bash mode |
|---|---|---|
| Check prerequisites | python3, pipx (auto-installs via dnf if missing) |
bash, optional shellcheck |
| Validate source file | BOM, LF, shebang, syntax (no .pyc written) |
BOM, LF, shebang, syntax (bash -n) |
| Set up isolation | create/reuse .venv, install requirements.txt |
(none — bash scripts have no deps to isolate) |
| Package metadata | generate pyproject.toml if missing (deps from RUNTIME_DEPS or requirements.txt) |
(none) |
| Install globally | pipx install --force [-e] . (isolated venv + launcher) |
install -m 0755 into ~/.local/bin |
| Verify | resolve $PATH, run --help |
resolve $PATH, run --help |
| Optional | git init + write .gitignore (skipped inside an existing repo) |
git init + write .gitignore (same rule) |
The result is the same either way: a command you can type from anywhere.
# 1. Drop setup_cli.sh into your project root
cp ~/path/to/setup_cli.sh my-project/
cd my-project
# 2. Run it — auto-detects the type
bash setup_cli.shThat's it. If the project contains mytool.py, you get a Python install via pipx. If it contains mytool.sh, you get a Bash install via install.
── md-to-docx setup ──
type: python
command: md-to-docx
directory: /home/you/Documents/Code/libs/md_to_docx
==> Checking prerequisites
✓ python3 3.14.7
✓ pipx 1.15.0
==> Verifying source file: md_to_docx.py
✓ no BOM
✓ LF line endings
✓ shebang correct
· source is not executable (not required — the install is mode 0755)
✓ syntax valid
==> Checking pyproject.toml
✓ generated pyproject.toml
· dependencies taken from requirements.txt
==> Setting up local .venv
✓ reusing existing .venv
✓ pip upgraded
✓ installed from requirements.txt
==> Installing md-to-docx via pipx
✓ installed md-to-docx
==> Verifying installation
✓ resolves to: /home/you/.local/bin/md-to-docx
✓ target: /home/you/.local/share/pipx/venvs/md-to-docx/bin/md-to-docx
✓ --help works
✓ Setup complete
Project: md-to-docx
Type: python
Command: md-to-docx
Source: /home/you/Documents/Code/libs/md_to_docx/md_to_docx.py
Venv: /home/you/Documents/Code/libs/md_to_docx/.venv
Launcher: /home/you/.local/bin/md-to-docx
All progress output goes to stderr, so
bash setup_cli.sh > somethingnever mixes logs into your data streams.
| Requirement | Minimum | Verified with | Needed by |
|---|---|---|---|
| OS | Linux or macOS | Fedora Linux 44 (Workstation Edition) x86_64 | both modes |
| Bash | 4.0 | 5.3.9(1)-release | both modes |
| Python | 3.10 | CPython 3.14.7 | Python mode |
| pipx | any recent | 1.15.0 | Python mode |
shellcheck |
optional | not installed on this host | Bash mode (lint warning only) |
| git | optional | — | the automatic git init step |
readlink -f has a Python fallback for BSD/macOS, but only the Linux path has
been verified on this machine.
Nothing else. No config files to write by hand unless you want to override defaults.
Usage: bash setup_cli.sh [OPTIONS]
| Option | Description |
|---|---|
-t, --type {python|bash|auto} |
Force the project type. Default: auto-detect. |
-c, --command NAME |
Override the installed command name. |
-n, --name NAME |
Override the project name. |
-s, --source PATH |
Override the source file path (also implies the type). |
--prefix DIR |
Install into DIR. Default: ~/.local/bin. |
--uninstall |
Remove an existing install and exit. |
--purge |
With --uninstall, also delete the local .venv. |
--no-verify |
Skip the post-install --help check. |
-V, --version |
Show version and exit. |
-h, --help |
Show help and exit. |
Long options also accept the --option=value form.
bash setup_cli.shDetection rules, in order:
--typewas passed → use it (acceptspython/pyandbash/sh).--sourcewas passed → infer from its extension (.py→ Python,.sh→ Bash).- Only
*.pyfiles at the project root → Python. - Only
*.shfiles at the project root → Bash. - Otherwise → error, pass
--type.
The running script itself and setup_cli.py / setup_cli.sh are never counted as candidates.
When more than one candidate exists, the script prefers the file whose name matches the project or command name (My_Tool → my-tool → my_tool.py). If that still doesn't disambiguate, it lists the candidates and stops instead of silently installing the wrong file:
! several .sh files found at the project root:
· build.sh
· deploy.sh
✗ ambiguous project — choose one with -s PATH (or narrow it with --type)
bash setup_cli.sh --type python
bash setup_cli.sh --type bashUse when the project contains both — e.g. a Python CLI plus a dev.sh helper.
bash setup_cli.sh -c convertNow the global command is convert, not the default derived from the folder name.
If the project already has a pyproject.toml with a [project.scripts] table, that table decides what gets installed — pipx creates exactly the names declared there, whatever the folder is called. setup_cli.sh reads it and verifies the real name:
==> Checking pyproject.toml
✓ pyproject.toml already exists — leaving it untouched
· command name comes from pyproject.toml: generate-structure (not 'md-to-file-structure')
So a folder named md_to_file_structure/ can legitimately install a command called generate-structure. --command only influences the name when there is no pyproject.toml to read (or when it declares no [project.scripts]).
--source also implies the type, so a subdirectory source works without --type:
bash setup_cli.sh -s scripts/build.sh
bash setup_cli.sh -s src/cli.pybash setup_cli.sh --prefix "$HOME/bin"In Python mode the prefix is passed to pipx via PIPX_BIN_DIR, so the launcher lands next to the venv it manages. If the prefix isn't on your PATH, the script says so and prints the exact line to add to your shell rc.
bash setup_cli.sh --uninstall # removes the command (and the pipx app)
bash setup_cli.sh --uninstall --purge # ...and the local .venv--uninstall never needs a source file, so it still works after you delete one. --purge only deletes a .venv that actually contains pyvenv.cfg, so it can never wander off into another directory.
bash setup_cli.sh --no-verifyUseful for libraries whose entry point legitimately requires arguments.
bash setup_cli.sh -t python -n my-tool -c mytool -s src/cli.py --prefix "$HOME/bin" --no-verifyThe top of setup_cli.sh has a config block. Edit it once per project, or leave the defaults and let auto-detection fill in the blanks. The most common values also have command-line flags (see Usage).
# --- Shared ---------------------------------------------------------------
PROJECT_NAME="" # auto -> basename of the project dir
CLI_COMMAND="" # auto -> kebab-case of PROJECT_NAME
INIT_GIT="1" # 1 = git init, unless already inside a repo
INSTALL_DIR="$HOME/.local/bin" # where the command lands (--prefix overrides)
AUTO_INSTALL_PIPX="1" # 1 = install pipx via dnf (Fedora) if missing
# --- Python-only ----------------------------------------------------------
MODULE_NAME="" # auto -> basename of SOURCE_FILE_PY minus .py
ENTRY_FUNCTION="main" # callable inside MODULE_NAME
SOURCE_FILE_PY="" # auto -> the *.py at root (name-matched if several)
REQUIREMENTS_FILE="requirements.txt"
RUNTIME_DEPS="" # space-separated pip packages
PYTHON_REQUIRES=">=3.10"
EDITABLE_INSTALL="1" # 1 = pipx install -e . (live edits)
# --- Bash-only ------------------------------------------------------------
SOURCE_FILE_SH="" # auto -> the *.sh at root (name-matched if several)| Want | Set |
|---|---|
Different entry function (run instead of main) |
ENTRY_FUNCTION="run" |
| Frozen install instead of editable | EDITABLE_INSTALL="0" |
Add runtime deps to pyproject.toml |
RUNTIME_DEPS="click rich pathvalidate" |
| Skip the git init step | INIT_GIT="0" |
| Install somewhere else | INSTALL_DIR="/usr/local/bin" (or --prefix) |
Never touch the system with sudo |
AUTO_INSTALL_PIPX="0" |
When generating pyproject.toml, dependencies are taken from RUNTIME_DEPS if it is non-empty. Otherwise they are read from requirements.txt (comments, -r/-e includes and URLs are skipped). This means the pipx-installed app really does get the dependencies your project declares, instead of only the local .venv having them. If pyproject.toml already exists, it is left completely untouched.
An existing pyproject.toml is also read for its [project] name and its [project.scripts] command names, so the summary, the launcher path and the pipx uninstall … hint all refer to the distribution that actually exists.
A freshly generated file looks like this:
# Generated by setup_cli.sh v2.2.0 — this file is yours to edit.
[project]
name = "my-tool"
version = "0.1.0"
description = "CLI tool: my-tool"
requires-python = ">=3.10"
license = "Apache-2.0"
license-files = ["LICENSE"]
dependencies = [
"click>=8.1",
]
[project.scripts]
my-test-tool = "my_tool:main"
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
include = ["my_tool.py"]Two details are worth calling out:
license = "Apache-2.0"uses the SPDX expression form (PEP 639), which is what modernhatchlingemits asLicense-Expressionmetadata.license-filesis only written when aLICENSEfile actually exists in the project. Feeding hatchling an unmatchedlicense-filesglob would fail the build, so the generator checks first.
my-project/
├── .venv/ ← local virtualenv (gitignored)
├── pyproject.toml ← generated if missing
├── requirements.txt ← unchanged, installed into .venv
├── my_tool.py ← unchanged (never chmod'd, never rewritten)
├── setup_cli.sh ← this script
└── .gitignore ← generated if missing
~/.local/bin/my-tool ← launcher on PATH (managed by pipx)
my-project/
├── my_tool.sh ← unchanged; the *copy* is installed mode 0755
├── setup_cli.sh ← this script
└── .gitignore ← generated if missing
~/.local/bin/my-tool ← real file copy (mode 0755)
Python installs use a symlink managed by
pipx; Bash installs use a real file copy. This is deliberate — see Design decisions below.
The script is idempotent. Running it twice changes nothing except refreshing the venv and reinstalling the app.
| What | Behaviour on re-run |
|---|---|
pyproject.toml |
Left untouched if it exists; its [project.scripts] name is adopted for verification |
.venv/ |
Reused; pip upgraded; requirements.txt reinstalled |
~/.local/bin/<cmd> |
Replaced (pipx install --force, or rm + install) |
my_tool.py / my_tool.sh |
Never modified — not even the mode bits |
.gitignore |
Left untouched if it exists |
.git/ |
Left alone; git init is skipped inside an existing repo |
Dangling symlinks in ~/.local/bin/ |
Detected and removed |
To pick up source edits:
- Python +
EDITABLE_INSTALL=1— nothing to do, edits are live. - Python +
EDITABLE_INSTALL=0— re-runbash setup_cli.sh. - Bash — re-run
bash setup_cli.sh(the installed copy is refreshed).
The script does a pre-flight on the source file before touching anything else. Every check corresponds to a real failure mode:
| Check | Why |
|---|---|
| No UTF-8 BOM | A BOM before #!/usr/bin/env python3 makes Linux treat the shebang as garbage → "bad interpreter" |
| LF, not CRLF line endings | A CRLF shebang line fails on Linux with the same misleading error |
| Shebang exists and matches the type | A missing or wrong shebang means the kernel can't exec the file (warning only — the file may still be callable) |
| Syntax valid | Catches typos before installing something that will fail on first run |
shellcheck clean (bash) |
Free linting for bash scripts (warning only; never blocks the install) |
| Executable bit (reported, not forced) | Tells you whether ./mytool.sh works, without this script ever modifying your file |
The destructive checks run first, so a BOM or a CRLF file produces one clear message instead of a shebang warning that looks byte-identical to the expected line.
If any of these fail, the script exits before modifying anything on your system. You get a clear message instead of a half-installed tool.
The Python syntax check uses compile() in a throwaway interpreter, so it does not create __pycache__/ the way python3 -m py_compile does.
Python tools have dependencies. python-docx, rich, click — these need a Python environment that contains them. Installing them into your system Python pollutes it and invites version conflicts. pipx gives each tool its own isolated venv, a launcher on ~/.local/bin, and pipx upgrade / pipx uninstall for lifecycle management.
Bash tools have no dependencies. A bash script either has all its commands available on the host or it doesn't. There's nothing to isolate. So the Bash pipeline is short: verify, copy, done.
This is why the two paths diverge so sharply — they're solving genuinely different problems.
The single most common setup failure is a dangling symlink. ln -sf relative/path ~/.local/bin/name creates a link whose target is resolved relative to ~/.local/bin/ — not the current directory. When the target doesn't exist, the link "exists" (so ls lists it) but chmod and exec fail with confusing errors.
install -m 0755 source dest copies the file and sets the mode in one atomic operation. No symlink, no ambiguity. When you want live edits, re-run the script.
The previous version added the executable bit to the file in your project. That is still a modification: it flips a tracked mode bit in git and shows up in git status as an unexpected change. The installed copy is what needs to be executable, and install -m 0755 (Bash) / pipx (Python) already guarantees that. So the bit is reported, never set.
--force handles "already installed", "installed but the launcher is missing", and "switching between editable and frozen" in a single command. It removes the need to parse pipx list output (which is a moving target across pipx versions) and to special-case a previous install.
The original version of this script was Python-only. Adapting it to Bash meant branching the pipeline at six different points. A flag is cleaner than maintaining two near-identical scripts, and it means the auto-detect path can handle either kind of project without you needing to remember which mode to run.
Sourcing a venv inside a bash script is a no-op. The activation only affects the subshell, which exits immediately. The script works around this by calling $VENV_DIR/bin/python -m pip directly, which achieves the same isolation without pretending to activate anything. At the end it prints the source line for you to run in your interactive shell.
The script produces no machine-readable data. Keeping diagnostics on stderr means SOURCE="$(pick_source ...)" can never be polluted by a log line, and bash setup_cli.sh > report.txt only captures what you actually asked for.
Debugging a BOM'd shebang or a CRLF line ending is a 20-minute rabbit hole that starts with a misleading error message. Checking for these before installation takes 200ms and eliminates an entire class of "why doesn't my script run" questions.
- Never modifies your source files. Not the contents, not the mode bits. Only
.venv/,pyproject.toml(if missing),.gitignore(if missing),~/.local/bin/and pipx's own directory are written. - Never overwrites existing configs.
pyproject.tomland.gitignoreare left alone if they already exist; an existingpyproject.tomlwithout[project.scripts]gets a warning, not an edit. - Never nests git repos.
git initis skipped when the project is already inside a repository. - Never leaves half-installed state.
set -euo pipefailmeans any failure aborts immediately; the final state is either "fully installed" or "unchanged". - Cleans up temp files on every exit path, including Ctrl-C.
- Removes dangling symlinks before installing over them.
- Verifies after installing. The command is resolved through
$PATHand--helpis run (--no-verifyto skip). If it doesn't work, you know here, not three commands later. - Tells you when
PATHwon't persist. If the install directory isn't on your loginPATH, you get the exactexportline to add. - Re-runnable without data loss. Every mutation is either idempotent (venv reuse,
--forcereinstall) or explicit (--uninstall). - Conservative
--purge. Only deletes.venvwhen it containspyvenv.cfg, i.e. when it really is a virtualenv.
| Symptom | Cause | Fix |
|---|---|---|
required command not found: python3 |
Python missing | sudo dnf install python3 |
pipx not found and dnf unavailable |
Non-Fedora, no sudo | Install pipx manually, or set AUTO_INSTALL_PIPX="0" and install it yourself |
could not auto-detect project type |
No .py/.sh at the project root |
bash setup_cli.sh --type python (or -s scripts/tool.sh) |
both .py and .sh files found at the project root |
Mixed project | Pass `--type {python |
ambiguous project |
Several same-extension candidates | Pick one with -s PATH |
option '-t' requires a value |
Flag used without a value | -t python or --type=python |
starts with a UTF-8 BOM |
Editor saved with BOM | sed -i '1s/^\xEF\xBB\xBF//' file.py |
has CRLF/CR line endings |
File saved on Windows or rewritten by a tool | dos2unix file.py |
syntax error in |
Broken Python/bash | Fix the file, re-run |
command '…' not found. Add … to your PATH |
Install dir not on PATH |
export PATH="$HOME/.local/bin:$PATH" (or pipx ensurepath) |
is not on your persistent PATH |
Added only for the current run | Append the printed export line to ~/.bashrc |
pipx install failed |
Missing/broken [project.scripts] or packaging metadata |
Read the pipx output printed above the error |
--help exited non-zero |
Entry function needs args, or wrong callable | Check ENTRY_FUNCTION in the config block, or pass --no-verify |
--type python requires a .py source |
-s and -t disagree |
Point -s at the right file, or drop --type |
command 'x' not found right after a successful install |
An existing pyproject.toml declares a different name in [project.scripts] |
Use the name from [project.scripts] — the script prints the commands actually installed in the prefix |
# What version of the bootstrapper am I running?
setup-cli --version
# What does the command resolve to?
type -a md-to-docx
# Is it a real file or a symlink, and does it point somewhere valid?
ls -la ~/.local/bin/md-to-docx
# Is the pipx venv intact?
pipx list
pipx runpip md-to-docx list
# Is ~/.local/bin on PATH?
echo "$PATH" | tr ':' '\n' | grep -F "$HOME/.local/bin"cd ~/Documents/Ashee-Softworks/Code/libs/setup_cli
bash install_setup_cli.sh # installs the `setup-cli` command
cd ~/Documents/Code/libs/my-project
setup-cli # bootstraps the project you are standing inThe global command acts on the caller's working directory, so you never copy the script around. Re-run install_setup_cli.sh after editing setup_cli.sh to refresh it. Use bash install_setup_cli.sh --prefix DIR to install elsewhere.
mkdir my-new-tool && cd my-new-tool
cp ~/path/to/setup_cli.sh .
# Write your CLI
cat > my_new_tool.py <<'PY'
#!/usr/bin/env python3
import click
@click.command()
def main():
click.echo("hello")
if __name__ == "__main__":
main()
PY
# Optionally add requirements (they are copied into the generated pyproject.toml)
echo "click" > requirements.txt
# Bootstrap it
bash setup_cli.sh
my-new-toolFor a Bash project:
mkdir my-bash-tool && cd my-bash-tool
cp ~/path/to/setup_cli.sh .
cat > my_bash_tool.sh <<'SH'
#!/usr/bin/env bash
set -euo pipefail
echo "hello from bash"
SH
bash setup_cli.sh
my-bash-tool-
--uninstallflag to cleanly remove the command (and its pipx venv) -
--purgeto also delete the local.venv -
--no-verifyto skip the post-install--helpcheck - Support for installing into a
--prefixother than~/.local/bin - Warn when an existing
pyproject.tomldoes not declare[project.scripts] - Read the command name(s) from an existing
pyproject.toml[project.scripts]instead of trusting the folder name - Correct behaviour when run as the global
setup-clicommand (operates on$PWD) - macOS/BSD portability for
readlink -f(Python fallback) -
--updateflag to refresh an existing install without re-running the whole setup - Pytest + BATS test suites for the parser and installer logic
setup_cli/
├── LICENSE Apache-2.0 (verbatim)
├── README.md this file
├── install_setup_cli.sh installs setup_cli.sh as the global `setup-cli`
└── setup_cli.sh the bootstrapper itself
Released under the Apache License 2.0. The full text is in
LICENSE.
Copyright (c) 2026 AsheeSoftworks
SPDX-License-Identifier: Apache-2.0
SPDX-FileCopyrightText: 2026 AsheeSoftworks
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
The Python path leans on pipx for isolation, and Fedora's packaging of pipx + python3-userpath for making the install path painless. The Bash path is just install(1), which has been doing this job since 1979 and still does it correctly.