Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

setup_cli.sh

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, Bash 5.3.9(1)-release, CPython 3.14.7, pipx 1.15.0, hatchling 1.32.0. These are the exact versions setup_cli.sh 2.2.0 was developed and verified against. macOS/BSD paths exist in the code (readlink -f has a Python fallback) but were not exercised on this machine.


What it does

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.


Quick start

# 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.sh

That'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.

Example output (Python)

── 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 > something never mixes logs into your data streams.


Requirements

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

Usage: bash setup_cli.sh [OPTIONS]

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.

Auto-detect (recommended)

bash setup_cli.sh

Detection rules, in order:

  1. --type was passed → use it (accepts python/py and bash/sh).
  2. --source was passed → infer from its extension (.py → Python, .sh → Bash).
  3. Only *.py files at the project root → Python.
  4. Only *.sh files at the project root → Bash.
  5. Otherwise → error, pass --type.

The running script itself and setup_cli.py / setup_cli.sh are never counted as candidates.

Several source files?

When more than one candidate exists, the script prefers the file whose name matches the project or command name (My_Toolmy-toolmy_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)

Force the type

bash setup_cli.sh --type python
bash setup_cli.sh --type bash

Use when the project contains both — e.g. a Python CLI plus a dev.sh helper.

Override the command name

bash setup_cli.sh -c convert

Now the global command is convert, not the default derived from the folder name.

The command name comes from pyproject.toml when there is one

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]).

Point at a source file in a subdirectory

--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.py

Install somewhere other than ~/.local/bin

bash 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.

Uninstall

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.

Skip the --help smoke test

bash setup_cli.sh --no-verify

Useful for libraries whose entry point legitimately requires arguments.

Full non-interactive example

bash setup_cli.sh -t python -n my-tool -c mytool -s src/cli.py --prefix "$HOME/bin" --no-verify

Configuration

The 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)

Common tweaks

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"

Where Python dependencies come from

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.

What the generated pyproject.toml declares

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 modern hatchling emits as License-Expression metadata.
  • license-files is only written when a LICENSE file actually exists in the project. Feeding hatchling an unmatched license-files glob would fail the build, so the generator checks first.

What gets created

Python mode

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)

Bash mode

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.


Re-running

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-run bash setup_cli.sh.
  • Bash — re-run bash setup_cli.sh (the installed copy is refreshed).

What it checks (and why)

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.


Design decisions

Why pipx for Python but a plain copy for Bash

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.

Why install -m 0755 instead of ln -s for Bash

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.

Why the script never chmod +x's your source

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.

Why pipx install --force on every run

--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.

Why --type instead of hardcoding

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.

Why not source activate

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.

Why everything goes to stderr

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.

Why pre-flight checks

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.


Safety guarantees

  • 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.toml and .gitignore are left alone if they already exist; an existing pyproject.toml without [project.scripts] gets a warning, not an edit.
  • Never nests git repos. git init is skipped when the project is already inside a repository.
  • Never leaves half-installed state. set -euo pipefail means 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 $PATH and --help is run (--no-verify to skip). If it doesn't work, you know here, not three commands later.
  • Tells you when PATH won't persist. If the install directory isn't on your login PATH, you get the exact export line to add.
  • Re-runnable without data loss. Every mutation is either idempotent (venv reuse, --force reinstall) or explicit (--uninstall).
  • Conservative --purge. Only deletes .venv when it contains pyvenv.cfg, i.e. when it really is a virtualenv.

Troubleshooting

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

Diagnostics

# 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"

Using it as a template

Option A — install it once, use it everywhere (recommended)

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 in

The 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.

Option B — copy it into the project

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-tool

For 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

Roadmap

  • --uninstall flag to cleanly remove the command (and its pipx venv)
  • --purge to also delete the local .venv
  • --no-verify to skip the post-install --help check
  • Support for installing into a --prefix other than ~/.local/bin
  • Warn when an existing pyproject.toml does 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-cli command (operates on $PWD)
  • macOS/BSD portability for readlink -f (Python fallback)
  • --update flag to refresh an existing install without re-running the whole setup
  • Pytest + BATS test suites for the parser and installer logic

Project layout

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

License

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.

Acknowledgements

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.

About

Bootstrap a Python or Bash CLI project into a globally installed command.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages