PRJ-001 Planned

Python Command Hub

A command-line application for managing and organizing development workflows.

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 -y to skip). Unique prefixes work: hub run bu finds build.
  • 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

Terminal hub run build argparse sub-commands and options JSON store ~/.command_hub .json subprocess runs the shell command
Four pieces of the standard library do all the work.
  • argparse turns hub add build "npm run build" --tag web into a structured object, and generates --help for free.
  • json + pathlib read 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

ItemNotes
Python 3.8 or newerCheck with python3 --version. No packages to install.
A terminalmacOS/Linux shell, or Windows Terminal / PowerShell.
A text editorVS 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

  1. Create the file. Paste the code into hub.py and run python3 hub.py --help.
  2. Make it a real command. On macOS/Linux: chmod +x hub.py, then copy or symlink it to a folder on your PATH as hub (for example ~/.local/bin/hub). On Windows, create hub.bat containing @python "%~dp0hub.py" %* in a folder on your PATH.
  3. Save your first commands. hub add serial "python3 -m serial.tools.miniterm /dev/ttyUSB0 115200" --tag lab
  4. Use it for a week. Every time you look up a command twice, add it.
  5. Back it up. The JSON file is plain text — commit it to a private dotfiles repo, or point HUB_FILE at a synced folder.

Testing checklist

  • hub list on an empty store prints a hint, not an error.
  • Adding an existing name without --force is refused.
  • hub run with a unique prefix runs the right command; an ambiguous prefix lists the options.
  • Answering anything other than y cancels.
  • The exit code of hub run matches 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 for msg when it runs.
  • Per-project hubs: look for a .hub.json in 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.toml with a console-script entry point so pipx install . creates the hub command.
  • A TUI with arrow-key selection using the standard curses module.
← All projects