Every developer has a handful of long commands they retype or dig out of shell history every day — build scripts, deploy steps, serial-monitor flags, Git incantations. Command Hub is a small command-line tool that saves them under short names, groups them by tag and runs them on demand. One file, standard library only, and a good first "real" Python project.
What you'll build
hub add <name> "<command>"— save a command with an optional tag and note.hub list— every command, grouped by tag.hub run <name>— run it (with a confirmation prompt, or-yto skip). Unique prefixes work:hub run bufindsbuild.hub search,hub show,hub rm— find, print and delete.- Commands live in a human-readable JSON file you can back up or sync.
How it works
argparseturnshub add build "npm run build" --tag webinto a structured object, and generates--helpfor free.json+pathlibread and write~/.command_hub.json. Saving goes to a temporary file first and then replaces the old one, so a crash can never leave you with half a file.subprocess.run(..., shell=True)runs the saved text exactly as if you had typed it, so pipes,&&and environment variables work.
What you need
| Item | Notes |
|---|---|
| Python 3.8 or newer | Check with python3 --version. No packages to install. |
| A terminal | macOS/Linux shell, or Windows Terminal / PowerShell. |
| A text editor | VS Code, or anything you like. |
The code
Save this as hub.py. It is complete and runs as-is.
#!/usr/bin/env python3
"""Command Hub — save, organise and run the shell commands you use every day.
hub add build "npm run build" --tag web --note "production bundle"
hub list # all commands, grouped by tag
hub list --tag web # one tag
hub run build # run it (asks first)
hub run build -y # run without asking
hub show build # print the command (pipe-friendly)
hub rm build
hub search deploy
Commands are stored in ~/.command_hub.json (override with HUB_FILE).
Standard library only — Python 3.8+.
"""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
STORE = Path(os.environ.get("HUB_FILE", Path.home() / ".command_hub.json"))
def load():
if not STORE.exists():
return {}
try:
return json.loads(STORE.read_text(encoding="utf-8"))
except json.JSONDecodeError:
sys.exit(f"hub: {STORE} is not valid JSON — fix or delete it.")
def save(data):
tmp = STORE.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(STORE) # atomic: never leaves a half-written file
def cmd_add(args, data):
if args.name in data and not args.force:
sys.exit(f"hub: '{args.name}' exists — use --force to replace it.")
data[args.name] = {
"cmd": args.command,
"tag": args.tag or "general",
"note": args.note or "",
"runs": data.get(args.name, {}).get("runs", 0),
"added": datetime.now().isoformat(timespec="seconds"),
}
save(data)
print(f"saved {args.name} → {args.command}")
def cmd_list(args, data):
items = [(n, e) for n, e in data.items() if not args.tag or e["tag"] == args.tag]
if not items:
print("no commands yet — try: hub add hello \"echo hello\"")
return
width = max(len(n) for n, _ in items)
for tag in sorted({e["tag"] for _, e in items}):
print(f"\n[{tag}]")
for name, e in sorted(items):
if e["tag"] == tag:
note = f" # {e['note']}" if e["note"] else ""
print(f" {name:<{width}} {e['cmd']}{note}")
print()
def find(name, data):
if name in data:
return data[name]
close = [n for n in data if n.startswith(name)]
if len(close) == 1: # unique prefix: "hub run bu" → build
return data[close[0]]
hint = f" Did you mean: {', '.join(close)}?" if close else ""
sys.exit(f"hub: no command '{name}'.{hint}")
def cmd_run(args, data):
entry = find(args.name, data)
command = entry["cmd"] + ("" if not args.extra else " " + " ".join(args.extra))
print(f"$ {command}", flush=True)
if not args.yes and input("run it? [y/N] ").strip().lower() != "y":
print("cancelled")
return
entry["runs"] += 1
save(data)
sys.stdout.flush()
result = subprocess.run(command, shell=True)
sys.exit(result.returncode)
def cmd_show(args, data):
print(find(args.name, data)["cmd"])
def cmd_rm(args, data):
if data.pop(args.name, None) is None:
sys.exit(f"hub: no command '{args.name}'.")
save(data)
print(f"removed {args.name}")
def cmd_search(args, data):
q = args.text.lower()
hits = [n for n, e in data.items() if q in (n + " " + e["cmd"] + " " + e["note"]).lower()]
for n in sorted(hits):
print(f" {n:<16} {data[n]['cmd']}")
if not hits:
print("no matches")
def main(argv=None):
p = argparse.ArgumentParser(prog="hub", description="Save and run your everyday shell commands.")
sub = p.add_subparsers(dest="action", required=True)
a = sub.add_parser("add", help="save a command")
a.add_argument("name")
a.add_argument("command")
a.add_argument("--tag", help="group, e.g. git, web, lab")
a.add_argument("--note", help="short description")
a.add_argument("--force", action="store_true", help="replace an existing name")
a.set_defaults(fn=cmd_add)
l = sub.add_parser("list", help="show saved commands")
l.add_argument("--tag")
l.set_defaults(fn=cmd_list)
r = sub.add_parser("run", help="run a saved command")
r.add_argument("name")
r.add_argument("extra", nargs="*", help="extra arguments appended to the command")
r.add_argument("-y", "--yes", action="store_true", help="don't ask for confirmation")
r.set_defaults(fn=cmd_run)
s = sub.add_parser("show", help="print a command")
s.add_argument("name")
s.set_defaults(fn=cmd_show)
d = sub.add_parser("rm", help="delete a command")
d.add_argument("name")
d.set_defaults(fn=cmd_rm)
f = sub.add_parser("search", help="search names, commands and notes")
f.add_argument("text")
f.set_defaults(fn=cmd_search)
args = p.parse_args(argv)
args.fn(args, load())
if __name__ == "__main__":
main()
Watch out:
shell=True runs whatever text is stored, so only save commands you trust — and never load someone else's hub file without reading it first. The confirmation prompt is there for a reason.Build steps
- Create the file. Paste the code into
hub.pyand runpython3 hub.py --help. - Make it a real command. On macOS/Linux:
chmod +x hub.py, then copy or symlink it to a folder on yourPATHashub(for example~/.local/bin/hub). On Windows, createhub.batcontaining@python "%~dp0hub.py" %*in a folder on yourPATH. - Save your first commands.
hub add serial "python3 -m serial.tools.miniterm /dev/ttyUSB0 115200" --tag lab - Use it for a week. Every time you look up a command twice, add it.
- Back it up. The JSON file is plain text — commit it to a private dotfiles repo, or point
HUB_FILEat a synced folder.
Testing checklist
hub liston an empty store prints a hint, not an error.- Adding an existing name without
--forceis refused. hub runwith a unique prefix runs the right command; an ambiguous prefix lists the options.- Answering anything other than
ycancels. - The exit code of
hub runmatches the command's exit code (useful in scripts). - Corrupting the JSON file gives a clear message instead of a stack trace.
Results
Build log
Status: planned. Screenshots, the GitHub repository and the video walkthrough will be added here after the build.
Ideas for version 2
- Placeholders: save
git commit -m "{msg}"and get prompted formsgwhen it runs. - Per-project hubs: look for a
.hub.jsonin the current folder first, then fall back to the global one. - Shell completion for command names (argcomplete, or a small Bash/Zsh script).
- Packaging: a
pyproject.tomlwith a console-script entry point sopipx install .creates thehubcommand. - A TUI with arrow-key selection using the standard
cursesmodule.