135 lines
4.6 KiB
Python
Executable File
135 lines
4.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""odysseus — one entrypoint for every odysseus-* CLI.
|
|
|
|
Discovers `scripts/odysseus-<name>` siblings and dispatches to them, the
|
|
same way `git` finds `git-foo`. Pure passthrough: any flags after the
|
|
subcommand name are forwarded as-is, so `odysseus mail list --pretty`
|
|
behaves exactly like `odysseus-mail list --pretty`.
|
|
|
|
odysseus # list every subcommand + short help
|
|
odysseus <name> # run odysseus-<name>
|
|
odysseus <name> <args...> # run odysseus-<name> <args...>
|
|
odysseus help # alias for `odysseus` (the listing)
|
|
odysseus help <name> # show the subcommand's own --help
|
|
odysseus --version # print version
|
|
|
|
Designed to live in `scripts/`. Symlink it onto `$PATH` (`ln -s
|
|
$PWD/scripts/odysseus ~/.local/bin/odysseus`) to use it without typing
|
|
the full path.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
VERSION = "0.1.0"
|
|
|
|
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
|
|
|
|
def _list_subcommands() -> list[Path]:
|
|
"""Find every executable `odysseus-*` next to this file (excluding
|
|
the dispatcher itself + any .bak / .pre-* migration leftovers)."""
|
|
out = []
|
|
for p in sorted(SCRIPTS_DIR.iterdir()):
|
|
if not p.is_file():
|
|
continue
|
|
if p.name == "odysseus":
|
|
continue
|
|
if not p.name.startswith("odysseus-"):
|
|
continue
|
|
if any(p.name.endswith(suf) for suf in (".bak", ".pre-cli-refactor", ".pyc")):
|
|
continue
|
|
if not os.access(p, os.X_OK):
|
|
continue
|
|
out.append(p)
|
|
return out
|
|
|
|
|
|
def _short_help(path: Path) -> str:
|
|
"""Pull the first non-blank line of the docstring (after the shebang)
|
|
as a 1-line description. Falls back to '(no description)'."""
|
|
try:
|
|
text = path.read_text()
|
|
except Exception:
|
|
return "(unreadable)"
|
|
m = re.search(r'"""(.*?)"""', text, re.DOTALL)
|
|
if not m:
|
|
return ""
|
|
body = m.group(1).strip()
|
|
if not body:
|
|
return ""
|
|
# First non-blank line, strip the leading "name — " noise.
|
|
first = next((ln for ln in body.splitlines() if ln.strip()), "")
|
|
first = re.sub(r"^odysseus-\w+\s*—\s*", "", first).strip()
|
|
return first
|
|
|
|
|
|
def _is_runnable_subcommand(path: Path) -> bool:
|
|
return path.exists() and path.is_file() and os.access(path, os.X_OK)
|
|
|
|
|
|
def _print_listing() -> None:
|
|
"""`odysseus` with no args (or `odysseus help`) — print the table."""
|
|
sys.stdout.write(f"odysseus {VERSION} — every feature, on the shell.\n\n")
|
|
sys.stdout.write("Usage: odysseus <subcommand> [args...]\n\n")
|
|
sys.stdout.write("Available subcommands:\n")
|
|
subs = _list_subcommands()
|
|
width = max((len(p.name[len("odysseus-"):]) for p in subs), default=10)
|
|
for p in subs:
|
|
name = p.name[len("odysseus-"):]
|
|
desc = _short_help(p)
|
|
sys.stdout.write(f" {name:<{width + 2}}{desc}\n")
|
|
sys.stdout.write(
|
|
"\nRun `odysseus help <subcommand>` for that tool's own --help.\n"
|
|
)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
argv = list(sys.argv[1:] if argv is None else argv)
|
|
|
|
if not argv or argv[0] in ("-h", "--help", "help") and len(argv) == 1:
|
|
_print_listing()
|
|
return 0
|
|
|
|
if argv[0] in ("-v", "--version"):
|
|
sys.stdout.write(f"odysseus {VERSION}\n")
|
|
return 0
|
|
|
|
if argv[0] == "help":
|
|
# `odysseus help foo` → `odysseus-foo --help`
|
|
if len(argv) < 2:
|
|
_print_listing()
|
|
return 0
|
|
sub = SCRIPTS_DIR / f"odysseus-{argv[1]}"
|
|
if not _is_runnable_subcommand(sub):
|
|
sys.stderr.write(f"odysseus: unknown subcommand {argv[1]!r}\n")
|
|
return 1
|
|
return subprocess.call([str(sub), "--help"])
|
|
|
|
# `odysseus foo ...` → exec `odysseus-foo ...` under the project venv.
|
|
name = argv[0]
|
|
sub = SCRIPTS_DIR / f"odysseus-{name}"
|
|
if not _is_runnable_subcommand(sub):
|
|
sys.stderr.write(
|
|
f"odysseus: unknown subcommand {name!r}. "
|
|
f"Try `odysseus help` to see available ones.\n"
|
|
)
|
|
return 1
|
|
# Run the subcommand with the project's venv Python so dependencies
|
|
# (bcrypt, sqlalchemy, fastapi, ...) resolve. Fall back to whatever
|
|
# `python` is on PATH if no venv is found — useful in container
|
|
# installs that wire the deps system-wide.
|
|
repo_root = SCRIPTS_DIR.parent
|
|
venv_python = repo_root / "venv" / "bin" / "python"
|
|
py = str(venv_python) if venv_python.exists() else sys.executable
|
|
os.execv(py, [py, str(sub)] + argv[1:])
|
|
return 0 # unreachable
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|