|
#!/usr/bin/env -S uv run |
|
# /// script |
|
# requires-python = ">=3.11" |
|
# dependencies = [] |
|
# /// |
|
""" |
|
task-help — pretty-print all Taskfile tasks grouped by namespace with colours. |
|
|
|
Invoked by the default ``task`` (no args) target, but also usable standalone:: |
|
|
|
./scripts/task_help.py # inside a project that has a Taskfile.yml |
|
~/.taskfiles/taskscripts/task-help/task_help.py # from a shared installation |
|
|
|
Designed to be installed once and reused across many Taskfiles: |
|
|
|
mkdir -p ~/.taskfiles/taskscripts |
|
git clone https://gist.github.com/261c54d64fff6dc1493619e2924161b4.git \\ |
|
~/.taskfiles/taskscripts/task-help |
|
chmod +x ~/.taskfiles/taskscripts/task-help/task_help.py |
|
|
|
Then reference it from any Taskfile.yml: |
|
|
|
default: |
|
silent: true |
|
cmds: |
|
- ~/.taskfiles/taskscripts/task-help/task_help.py |
|
|
|
See docs/task-help-gist.md for full installation and update instructions. |
|
|
|
──────────────────────────────────────────────────────────────────────────────── |
|
Configuration |
|
──────────────────────────────────────────────────────────────────────────────── |
|
|
|
NAMESPACE_META and display settings can be customised. Sources are applied in |
|
priority order — later sources override earlier ones: |
|
|
|
1. Built-in defaults (DEFAULT_NAMESPACE_META in this file) |
|
2. Config file (auto-discovered or explicit) |
|
3. Stdin (piped JSON/YAML, or forced with --stdin) |
|
4. Environment vars (TASK_HELP_NS, TASK_HELP_NS_<NAME>, …) |
|
5. CLI options (--ns, --ns-json, --header, …) ← highest priority |
|
|
|
Config file — auto-discovered in this order: |
|
.task-help.json in the current working directory |
|
.task-help.yaml/.yml (requires pyyaml) |
|
~/.config/task-help/config.json |
|
|
|
Override auto-discovery with TASK_HELP_CONFIG=/path or --config PATH. |
|
|
|
Config file format (JSON example): |
|
{ |
|
"header": "My Project — Task Runner", |
|
"subtitle": "optional description line", |
|
"replace": false, |
|
"show_summary": false, |
|
"show_aliases": true, |
|
"desc_max_width": -1, |
|
"theme": "dark", |
|
"alias_color": "namespace", |
|
"alias_color_adjust": "none", |
|
"alias_fallback_color": "WHITE", |
|
"namespaces": { |
|
"deploy": ["🚀", "Deployment", "GREEN"], |
|
"_top": ["⚙️ ", "My Tasks", "CYAN"] |
|
} |
|
} |
|
|
|
Environment variables: |
|
TASK_HELP_CONFIG=PATH Path to config file |
|
TASK_HELP_HEADER="My Project" Override header title |
|
TASK_HELP_SUBTITLE="..." Override subtitle line |
|
TASK_HELP_NS='{"k":["e","l","C"]}' JSON object merged into namespaces |
|
TASK_HELP_NS_deploy="🚀,Dep,GREEN" Per-namespace (suffix → key, lower-cased) |
|
TASK_HELP_REPLACE=1 Replace defaults instead of merging |
|
TASK_HELP_NO_COLOR=1 Disable ANSI colours |
|
TASK_HELP_SUMMARY=1 Show multi-line task summaries (off by default) |
|
TASK_HELP_NO_SUMMARY=1 Explicitly disable summaries (compat alias) |
|
TASK_HELP_NO_ALIASES=1 Hide task aliases |
|
TASK_HELP_DESC_MAX_WIDTH=N Truncate descriptions to N chars (-1 = no limit) |
|
TASK_HELP_THEME=dark|light Colour theme (dark = default) |
|
TASK_HELP_ALIAS_COLOR=namespace Alias color: "namespace" or a color name/code |
|
TASK_HELP_ALIAS_COLOR_ADJUST=none Alias brightness: none | dim | bright |
|
TASK_HELP_ALIAS_FALLBACK_COLOR=WHITE Alias color when no namespace color |
|
NO_COLOR=1 Standard no-colour env (https://no-color.org/) |
|
FORCE_COLOR=1 Force colours even when stdout is not a TTY |
|
|
|
CLI options: |
|
--config PATH Config file path |
|
--header TEXT Header title |
|
--subtitle TEXT Subtitle / description line |
|
--ns KEY:emoji,label,COLOR Add/override a namespace (repeatable) |
|
--ns-json '{"k":[...]}' Namespace dict as JSON |
|
--replace Replace default namespaces entirely |
|
--no-color Disable ANSI colours |
|
--summary / --summaries Show multi-line task summaries (off by default) |
|
--no-summary Explicitly disable summaries (compat alias) |
|
--no-aliases Hide task aliases |
|
--desc-max-width N Truncate descriptions to N chars (-1 = no limit) |
|
--theme dark|light Colour theme |
|
--alias-color COLOR Alias color name/code or "namespace" |
|
--alias-color-adjust none|dim|bright Alias brightness adjustment |
|
--stdin Force-read JSON config from stdin |
|
|
|
Colors in config/env/CLI accept names: |
|
Standard: CYAN GREEN YELLOW BLUE MAGENTA RED WHITE DIM BOLD RESET |
|
Bright: BRIGHT_CYAN BRIGHT_GREEN BRIGHT_YELLOW BRIGHT_BLUE |
|
BRIGHT_MAGENTA BRIGHT_RED BRIGHT_WHITE BRIGHT_BLACK GRAY |
|
Style: ITALIC UNDERLINE |
|
256-color: use raw code "\033[38;5;Nm" (N = 0–255) |
|
|
|
Aliases |
|
─────── |
|
Task-level aliases defined in a task's ``aliases:`` field are shown inline as |
|
``(aliases: name1 | name2)`` in the namespace colour (or configured alias colour). |
|
Include-level aliases (defined in the ``includes:`` block of a Taskfile) cause |
|
Taskfile to register additional task entries with the alias as the namespace |
|
prefix — these appear as separate task entries and are grouped under their |
|
aliased namespace automatically. |
|
""" |
|
|
|
import argparse |
|
import json |
|
import os |
|
import subprocess |
|
import sys |
|
from collections import defaultdict |
|
from dataclasses import dataclass, field |
|
from pathlib import Path |
|
from typing import Any |
|
|
|
# ── ANSI colour codes ───────────────────────────────────────────────────────── |
|
RESET = "\033[0m" |
|
BOLD = "\033[1m" |
|
ITALIC = "\033[3m" |
|
UNDERLINE = "\033[4m" |
|
DIM = "\033[2m" |
|
RED = "\033[31m" |
|
GREEN = "\033[32m" |
|
YELLOW = "\033[33m" |
|
BLUE = "\033[34m" |
|
MAGENTA = "\033[35m" |
|
CYAN = "\033[36m" |
|
WHITE = "\033[37m" |
|
# Bright variants (high-intensity) |
|
BRIGHT_BLACK = "\033[90m" # dark-gray — alias: GRAY |
|
BRIGHT_RED = "\033[91m" |
|
BRIGHT_GREEN = "\033[92m" |
|
BRIGHT_YELLOW = "\033[93m" |
|
BRIGHT_BLUE = "\033[94m" |
|
BRIGHT_MAGENTA = "\033[95m" |
|
BRIGHT_CYAN = "\033[96m" |
|
BRIGHT_WHITE = "\033[97m" |
|
GRAY = BRIGHT_BLACK |
|
|
|
COLOR_MAP: dict[str, str] = { |
|
"RESET": RESET, "BOLD": BOLD, "DIM": DIM, "ITALIC": ITALIC, |
|
"UNDERLINE": UNDERLINE, |
|
"RED": RED, "GREEN": GREEN, "YELLOW": YELLOW, |
|
"BLUE": BLUE, "MAGENTA": MAGENTA, "CYAN": CYAN, "WHITE": WHITE, |
|
"BRIGHT_BLACK": BRIGHT_BLACK, "BRIGHT_RED": BRIGHT_RED, |
|
"BRIGHT_GREEN": BRIGHT_GREEN, "BRIGHT_YELLOW": BRIGHT_YELLOW, |
|
"BRIGHT_BLUE": BRIGHT_BLUE, "BRIGHT_MAGENTA": BRIGHT_MAGENTA, |
|
"BRIGHT_CYAN": BRIGHT_CYAN, "BRIGHT_WHITE": BRIGHT_WHITE, |
|
"GRAY": GRAY, |
|
} |
|
|
|
|
|
def c256(n: int) -> str: |
|
"""Return ANSI escape for 256-color foreground (0–255).""" |
|
return f"\033[38;5;{n}m" |
|
|
|
# ── Default namespace metadata ──────────────────────────────────────────────── |
|
# Keep these as the shipped defaults — projects extend or replace via config. |
|
DEFAULT_NAMESPACE_META: dict[str, tuple[str, str, str]] = { |
|
"_top": ("⚙️ ", "Core / Setup", CYAN), |
|
"test": ("🧪", "Testing", GREEN), |
|
"lint": ("🔍", "Linting & Formatting", YELLOW), |
|
"format": ("✨", "Formatting", YELLOW), |
|
"build": ("🔨", "Build", CYAN), |
|
"docker": ("🐳", "Docker Services", BLUE), |
|
"brew": ("🍺", "Homebrew", YELLOW), |
|
"git": ("🌿", "Git", GREEN), |
|
"ci": ("🔁", "CI / CD", BLUE), |
|
"deploy": ("🚀", "Deployment", GREEN), |
|
"db": ("🗄 ", "Database", BLUE), |
|
"ollama": ("🦙", "Ollama (local LLM server)", MAGENTA), |
|
"webui": ("🌐", "Open WebUI", CYAN), |
|
"mcpo": ("🔌", "MCPO Proxy (MCP servers)", GREEN), |
|
"pipelines": ("⚡", "Pipelines (custom Python functions)", MAGENTA), |
|
"rag": ("📚", "RAG (Retrieval-Augmented Generation)", BLUE), |
|
"aichat": ("💬", "aichat CLI (Copilot alternative)", YELLOW), |
|
"opencode": ("🤖", "opencode CLI (AI coding assistant)", BRIGHT_GREEN), |
|
"pre-commit": ("🔒", "Pre-commit hooks", DIM), |
|
"setup": ("🛠 ", "Project Setup", CYAN), |
|
"scripts": ("📦", "Shared scripts / Gist tooling", MAGENTA), |
|
"task-help": ("🧰", "task-help management", MAGENTA), |
|
# Global taskfile namespaces |
|
"gh-copilot": ("🐙", "GitHub Copilot CLI", CYAN), |
|
"copilot": ("🤖", "Copilot", BRIGHT_CYAN), |
|
"disk": ("💾", "Disk Management", RED), |
|
"ios": ("📱", "iOS Development", BLUE), |
|
"uv": ("🐍", "uv / Python", BRIGHT_GREEN), |
|
"mise": ("🔧", "mise / Tool Versions", YELLOW), |
|
"bun": ("🥐", "Bun / JavaScript", BRIGHT_YELLOW), |
|
"tmux": ("📺", "tmux", GREEN), |
|
} |
|
|
|
|
|
# ── Theme definitions ───────────────────────────────────────────────────────── |
|
|
|
@dataclass |
|
class ThemeColors: |
|
"""Named colour roles used throughout the display.""" |
|
header_box: str = CYAN |
|
task_name: str = GREEN |
|
summary_line: str = DIM |
|
separator: str = DIM |
|
alias_fallback: str = WHITE |
|
|
|
|
|
THEMES: dict[str, ThemeColors] = { |
|
"dark": ThemeColors( |
|
header_box=CYAN, task_name=GREEN, summary_line=DIM, |
|
separator=DIM, alias_fallback=WHITE, |
|
), |
|
"light": ThemeColors( |
|
header_box=BRIGHT_BLUE, task_name=BLUE, summary_line=BRIGHT_BLACK, |
|
separator=BRIGHT_BLACK, alias_fallback=BRIGHT_BLACK, |
|
), |
|
} |
|
|
|
|
|
# ── Config dataclass ────────────────────────────────────────────────────────── |
|
@dataclass |
|
class Config: |
|
"""Runtime display configuration, built from all config sources.""" |
|
namespaces: dict[str, tuple[str, str, str]] = field( |
|
default_factory=lambda: dict(DEFAULT_NAMESPACE_META) |
|
) |
|
header: str = "Task Runner" |
|
subtitle: str = "" |
|
no_color: bool = False |
|
show_summary: bool = False # default: compact (no summary); use --summary to enable |
|
show_aliases: bool = True |
|
desc_max_width: int = -1 # -1 = no truncation; positive = max visible chars |
|
theme: str = "dark" |
|
alias_color: str = "namespace" # "namespace" | any color name/code |
|
alias_color_adjust: str = "none" # "none" | "dim" | "bright" |
|
alias_fallback_color: str = "WHITE" # used when namespace has no color |
|
|
|
|
|
# ── Colour helpers ──────────────────────────────────────────────────────────── |
|
def _c(code: str, cfg: Config) -> str: |
|
"""Return ANSI code, or '' when no_color is active.""" |
|
return "" if cfg.no_color else code |
|
|
|
|
|
def resolve_color(c: str, no_color: bool = False) -> str: |
|
"""Map a colour name ('GREEN') or raw ANSI code; return '' if no_color.""" |
|
if no_color: |
|
return "" |
|
return COLOR_MAP.get(c.upper(), c) |
|
|
|
|
|
# ── Namespace merging ───────────────────────────────────────────────────────── |
|
def parse_ns_str(value: str, no_color: bool = False) -> tuple[str, str, str] | None: |
|
"""Parse 'emoji,label,COLOR' string → (emoji, label, ansi_code) or None.""" |
|
parts = value.split(",", 2) |
|
if len(parts) != 3: |
|
return None |
|
emoji, label, color = parts |
|
return (emoji.strip(), label.strip(), resolve_color(color.strip(), no_color)) |
|
|
|
|
|
def merge_ns_dict(cfg: Config, raw: dict[str, Any]) -> None: |
|
"""Merge raw namespace definitions (list/tuple/str values) into cfg.namespaces.""" |
|
for key, val in raw.items(): |
|
if isinstance(val, (list, tuple)) and len(val) == 3: |
|
emoji, label, color = val |
|
cfg.namespaces[key] = ( |
|
str(emoji), str(label), resolve_color(str(color), cfg.no_color) |
|
) |
|
elif isinstance(val, str): |
|
parsed = parse_ns_str(val, cfg.no_color) |
|
if parsed: |
|
cfg.namespaces[key] = parsed |
|
|
|
|
|
def apply_config_dict(cfg: Config, data: dict[str, Any]) -> None: |
|
"""Apply all recognised keys from a parsed config dict onto cfg.""" |
|
if data.get("replace"): |
|
cfg.namespaces = {} |
|
if "header" in data: |
|
cfg.header = str(data["header"]) |
|
if "subtitle" in data: |
|
cfg.subtitle = str(data["subtitle"]) |
|
if "show_summary" in data: |
|
cfg.show_summary = bool(data["show_summary"]) |
|
if "show_aliases" in data: |
|
cfg.show_aliases = bool(data["show_aliases"]) |
|
if "desc_max_width" in data: |
|
v = data["desc_max_width"] |
|
if isinstance(v, int): |
|
cfg.desc_max_width = v |
|
if "theme" in data and str(data["theme"]) in THEMES: |
|
cfg.theme = str(data["theme"]) |
|
if "alias_color" in data: |
|
cfg.alias_color = str(data["alias_color"]) |
|
if "alias_color_adjust" in data and str(data["alias_color_adjust"]) in ("none", "dim", "bright"): |
|
cfg.alias_color_adjust = str(data["alias_color_adjust"]) |
|
if "alias_fallback_color" in data: |
|
cfg.alias_fallback_color = str(data["alias_fallback_color"]) |
|
if isinstance(data.get("namespaces"), dict): |
|
merge_ns_dict(cfg, data["namespaces"]) |
|
|
|
|
|
# ── Config file I/O ─────────────────────────────────────────────────────────── |
|
def load_config_file(path: Path) -> dict[str, Any]: |
|
"""Parse a JSON or YAML config file; return {} on any failure.""" |
|
try: |
|
text = path.read_text() |
|
except OSError as e: |
|
print(f"⚠️ task-help: cannot read {path}: {e}", file=sys.stderr) |
|
return {} |
|
if path.suffix in (".yaml", ".yml"): |
|
try: |
|
import yaml # type: ignore[import] |
|
return yaml.safe_load(text) or {} |
|
except ImportError: |
|
print( |
|
f"⚠️ task-help: pyyaml not installed; cannot parse {path}. " |
|
"Use a .json config or install pyyaml.", |
|
file=sys.stderr, |
|
) |
|
return {} |
|
except Exception as e: |
|
print(f"⚠️ task-help: YAML parse error in {path}: {e}", file=sys.stderr) |
|
return {} |
|
try: |
|
return json.loads(text) |
|
except json.JSONDecodeError as e: |
|
print(f"⚠️ task-help: JSON parse error in {path}: {e}", file=sys.stderr) |
|
return {} |
|
|
|
|
|
def find_config_file() -> Path | None: |
|
"""Auto-discover a config file; returns the first match or None.""" |
|
for p in [ |
|
Path.cwd() / ".task-help.json", |
|
Path.cwd() / ".task-help.yaml", |
|
Path.cwd() / ".task-help.yml", |
|
Path.home() / ".config" / "task-help" / "config.json", |
|
]: |
|
if p.exists(): |
|
return p |
|
return None |
|
|
|
|
|
def load_from_stdin() -> dict[str, Any]: |
|
"""Read and parse JSON (or YAML if pyyaml available) from stdin.""" |
|
try: |
|
text = sys.stdin.read().strip() |
|
except (EOFError, OSError): |
|
return {} |
|
if not text: |
|
return {} |
|
try: |
|
return json.loads(text) |
|
except json.JSONDecodeError: |
|
pass |
|
try: |
|
import yaml # type: ignore[import] |
|
result = yaml.safe_load(text) |
|
if isinstance(result, dict): |
|
return result |
|
except (ImportError, Exception): |
|
pass |
|
print("⚠️ task-help: stdin: not valid JSON/YAML, ignoring.", file=sys.stderr) |
|
return {} |
|
|
|
|
|
# ── CLI argument parser ─────────────────────────────────────────────────────── |
|
_EPILOG = """\ |
|
examples: |
|
task_help.py --header "My Project" --subtitle "v1.2.3" |
|
task_help.py --ns deploy:🚀,Deployment,GREEN --ns db:🗄,Database,BLUE |
|
task_help.py --replace --ns-json '{"build":["🔨","Build","CYAN"]}' |
|
task_help.py --summary # show multi-line summaries |
|
task_help.py --no-aliases # hide task aliases |
|
task_help.py --desc-max-width 60 # truncate descriptions to 60 chars |
|
task_help.py --theme light # white-background terminal theme |
|
task_help.py --alias-color BRIGHT_CYAN --alias-color-adjust bright |
|
echo '{"header":"CI","namespaces":{"ci":["🔁","CI/CD","YELLOW"]}}' | task_help.py |
|
|
|
# in a Taskfile (env var approach): |
|
default: |
|
cmds: |
|
- TASK_HELP_HEADER="My App" ~/.taskfiles/taskscripts/task-help/task_help.py |
|
|
|
# project config file (.task-help.json in cwd — auto-discovered): |
|
{ "header": "My App", "show_summary": false, "theme": "dark", |
|
"namespaces": { "deploy": ["🚀","Deploy","GREEN"] } } |
|
|
|
color names (standard): CYAN GREEN YELLOW BLUE MAGENTA RED WHITE DIM BOLD |
|
color names (bright): BRIGHT_CYAN BRIGHT_GREEN BRIGHT_YELLOW BRIGHT_BLUE |
|
BRIGHT_MAGENTA BRIGHT_RED BRIGHT_WHITE BRIGHT_BLACK GRAY |
|
color names (style): ITALIC UNDERLINE |
|
""" |
|
|
|
|
|
def build_arg_parser() -> argparse.ArgumentParser: |
|
p = argparse.ArgumentParser( |
|
prog="task_help.py", |
|
description="Pretty-print Taskfile tasks grouped by namespace.", |
|
formatter_class=argparse.RawDescriptionHelpFormatter, |
|
epilog=_EPILOG, |
|
) |
|
p.add_argument("--config", metavar="PATH", |
|
help="Path to JSON/YAML config file (overrides auto-discovery)") |
|
p.add_argument("--header", metavar="TEXT", |
|
help="Override the header title line") |
|
p.add_argument("--subtitle", metavar="TEXT", |
|
help="Override the subtitle / description line") |
|
p.add_argument( |
|
"--ns", metavar="KEY:emoji,label,COLOR", action="append", |
|
help=( |
|
"Add or override one namespace entry. " |
|
"COLOR is a name (GREEN) or raw ANSI code. Repeatable." |
|
), |
|
) |
|
p.add_argument("--ns-json", metavar="JSON", |
|
help='Namespace dict as JSON: \'{"key":["emoji","label","COLOR"]}\'') |
|
p.add_argument("--replace", action="store_true", |
|
help="Replace DEFAULT_NAMESPACE_META entirely instead of merging") |
|
p.add_argument("--no-color", action="store_true", |
|
help="Disable ANSI colours") |
|
p.add_argument("--summary", "--summaries", dest="summary", action="store_true", |
|
help="Show multi-line task summaries (off by default)") |
|
p.add_argument("--no-summary", action="store_true", |
|
help="Explicitly hide summaries (compat; default is already off)") |
|
p.add_argument("--no-aliases", action="store_true", |
|
help="Hide task aliases") |
|
p.add_argument("--desc-max-width", type=int, metavar="N", default=None, |
|
help="Truncate task descriptions to N visible chars (-1 = no limit)") |
|
p.add_argument("--theme", choices=["dark", "light"], default=None, |
|
help="Colour theme: dark (default) or light (white-background terminals)") |
|
p.add_argument("--alias-color", metavar="COLOR", default=None, |
|
help='Alias text color: "namespace" (default) or a color name/ANSI code') |
|
p.add_argument("--alias-color-adjust", choices=["none", "dim", "bright"], default=None, |
|
help="Alias brightness adjustment: none (default), dim, or bright") |
|
p.add_argument("--stdin", action="store_true", |
|
help="Force-read JSON config from stdin (auto when stdin is piped)") |
|
return p |
|
|
|
|
|
# ── Config resolution pipeline ──────────────────────────────────────────────── |
|
def build_config(args: argparse.Namespace) -> Config: |
|
""" |
|
Build the final Config by applying all sources in priority order: |
|
defaults → config file → stdin → TASK_HELP_NS env → per-ns env → CLI |
|
""" |
|
cfg = Config() |
|
|
|
# ── 0. no-color (must be set before any resolve_color calls) ───────────── |
|
_no_color = ( |
|
args.no_color |
|
or os.environ.get("TASK_HELP_NO_COLOR", "").strip().lower() in ("1", "true", "yes") |
|
or os.environ.get("NO_COLOR", "") != "" # https://no-color.org/ |
|
or ( |
|
not sys.stdout.isatty() |
|
and os.environ.get("FORCE_COLOR", "") == "" |
|
) |
|
) |
|
if _no_color: |
|
cfg.no_color = True |
|
# Strip ANSI codes already embedded in the default namespace entries |
|
cfg.namespaces = {k: (e, l, "") for k, (e, l, _) in cfg.namespaces.items()} |
|
|
|
# ── 0b. replace mode — clear defaults before any source merges in ───────── |
|
_replace = ( |
|
args.replace |
|
or os.environ.get("TASK_HELP_REPLACE", "").strip().lower() in ("1", "true", "yes") |
|
) |
|
if _replace: |
|
cfg.namespaces = {} |
|
|
|
# ── 1. Config file ──────────────────────────────────────────────────────── |
|
config_path: Path | None = ( |
|
Path(args.config) if args.config |
|
else Path(os.environ["TASK_HELP_CONFIG"]) if "TASK_HELP_CONFIG" in os.environ |
|
else find_config_file() |
|
) |
|
if config_path: |
|
apply_config_dict(cfg, load_config_file(config_path)) |
|
|
|
# ── 2. Stdin (automatic when piped, or forced with --stdin) ────────────── |
|
if args.stdin or not sys.stdin.isatty(): |
|
stdin_data = load_from_stdin() |
|
if stdin_data: |
|
apply_config_dict(cfg, stdin_data) |
|
|
|
# ── 3. TASK_HELP_NS env var (JSON object) ───────────────────────────────── |
|
_env_ns = os.environ.get("TASK_HELP_NS", "").strip() |
|
if _env_ns: |
|
try: |
|
ns_data = json.loads(_env_ns) |
|
if isinstance(ns_data, dict): |
|
merge_ns_dict(cfg, ns_data) |
|
else: |
|
print("⚠️ task-help: TASK_HELP_NS must be a JSON object.", file=sys.stderr) |
|
except json.JSONDecodeError as e: |
|
print(f"⚠️ task-help: TASK_HELP_NS invalid JSON — {e}", file=sys.stderr) |
|
|
|
# ── 4. Per-namespace env vars: TASK_HELP_NS_<NAME>=emoji,label,COLOR ────── |
|
for env_key in sorted(os.environ): |
|
if env_key.startswith("TASK_HELP_NS_") and env_key != "TASK_HELP_NS": |
|
ns_name = env_key[len("TASK_HELP_NS_"):].lower().replace("_", "-") |
|
parsed = parse_ns_str(os.environ[env_key], cfg.no_color) |
|
if parsed: |
|
cfg.namespaces[ns_name] = parsed |
|
else: |
|
print( |
|
f"⚠️ task-help: {env_key} must be 'emoji,label,COLOR'.", |
|
file=sys.stderr, |
|
) |
|
|
|
# ── 5. CLI --ns options (repeatable) ───────────────────────────────────── |
|
for ns_str in args.ns or []: |
|
if ":" not in ns_str: |
|
print( |
|
f"⚠️ task-help: --ns '{ns_str}' must be 'KEY:emoji,label,COLOR'.", |
|
file=sys.stderr, |
|
) |
|
continue |
|
key, val = ns_str.split(":", 1) |
|
parsed = parse_ns_str(val.strip(), cfg.no_color) |
|
if parsed: |
|
cfg.namespaces[key.strip()] = parsed |
|
else: |
|
print( |
|
f"⚠️ task-help: --ns '{ns_str}': value must be 'emoji,label,COLOR'.", |
|
file=sys.stderr, |
|
) |
|
|
|
# ── 6. CLI --ns-json ────────────────────────────────────────────────────── |
|
if args.ns_json: |
|
try: |
|
ns_data = json.loads(args.ns_json) |
|
if isinstance(ns_data, dict): |
|
merge_ns_dict(cfg, ns_data) |
|
else: |
|
print("⚠️ task-help: --ns-json must be a JSON object.", file=sys.stderr) |
|
except json.JSONDecodeError as e: |
|
print(f"⚠️ task-help: --ns-json invalid JSON — {e}", file=sys.stderr) |
|
|
|
# ── 7. Header / subtitle (env then CLI — CLI wins) ──────────────────────── |
|
for attr, env_key in (("header", "TASK_HELP_HEADER"), ("subtitle", "TASK_HELP_SUBTITLE")): |
|
if v := os.environ.get(env_key, "").strip(): |
|
setattr(cfg, attr, v) |
|
if args.header: |
|
cfg.header = args.header |
|
if args.subtitle: |
|
cfg.subtitle = args.subtitle |
|
|
|
# ── 8. show_summary / show_aliases / desc_max_width / theme / alias (env then CLI) |
|
_truthy = ("1", "true", "yes") |
|
if os.environ.get("TASK_HELP_SUMMARY", "").strip().lower() in _truthy: |
|
cfg.show_summary = True |
|
if os.environ.get("TASK_HELP_NO_SUMMARY", "").strip().lower() in _truthy: |
|
cfg.show_summary = False |
|
if getattr(args, "summary", False): |
|
cfg.show_summary = True |
|
if args.no_summary: |
|
cfg.show_summary = False |
|
if os.environ.get("TASK_HELP_NO_ALIASES", "").strip().lower() in _truthy: |
|
cfg.show_aliases = False |
|
if args.no_aliases: |
|
cfg.show_aliases = False |
|
_env_dmw = os.environ.get("TASK_HELP_DESC_MAX_WIDTH", "").strip() |
|
if _env_dmw: |
|
try: |
|
cfg.desc_max_width = int(_env_dmw) |
|
except ValueError: |
|
print("⚠️ task-help: TASK_HELP_DESC_MAX_WIDTH must be an integer.", file=sys.stderr) |
|
if args.desc_max_width is not None: |
|
cfg.desc_max_width = args.desc_max_width |
|
_env_theme = os.environ.get("TASK_HELP_THEME", "").strip().lower() |
|
if _env_theme in THEMES: |
|
cfg.theme = _env_theme |
|
if args.theme: |
|
cfg.theme = args.theme |
|
if v := os.environ.get("TASK_HELP_ALIAS_COLOR", "").strip(): |
|
cfg.alias_color = v |
|
if args.alias_color: |
|
cfg.alias_color = args.alias_color |
|
if v := os.environ.get("TASK_HELP_ALIAS_COLOR_ADJUST", "").strip().lower(): |
|
if v in ("none", "dim", "bright"): |
|
cfg.alias_color_adjust = v |
|
if args.alias_color_adjust: |
|
cfg.alias_color_adjust = args.alias_color_adjust |
|
if v := os.environ.get("TASK_HELP_ALIAS_FALLBACK_COLOR", "").strip(): |
|
cfg.alias_fallback_color = v |
|
|
|
return cfg |
|
|
|
|
|
# ── Task list loading ───────────────────────────────────────────────────────── |
|
def get_tasks() -> list[dict[str, Any]]: |
|
try: |
|
result = subprocess.run( |
|
["task", "--list", "--json"], |
|
capture_output=True, text=True, check=True, |
|
) |
|
return json.loads(result.stdout).get("tasks", []) |
|
except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError): |
|
print("⚠️ task-help: Could not load task list. Is 'task' installed?", file=sys.stderr) |
|
return [] |
|
|
|
|
|
def group_tasks(tasks: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: |
|
groups: dict[str, list[dict[str, Any]]] = defaultdict(list) |
|
for t in tasks: |
|
name = t["name"] |
|
prefix = name.split(":")[0] if ":" in name else "_top" |
|
groups[prefix].append(t) |
|
return groups |
|
|
|
|
|
# ── Display ─────────────────────────────────────────────────────────────────── |
|
def _theme(cfg: Config) -> ThemeColors: |
|
"""Return the active ThemeColors, stripped of codes if no_color.""" |
|
t = THEMES.get(cfg.theme, THEMES["dark"]) |
|
if cfg.no_color: |
|
return ThemeColors("", "", "", "", "") |
|
return t |
|
|
|
|
|
def print_header(cfg: Config) -> None: |
|
R = _c(RESET, cfg); B = _c(BOLD, cfg); D = _c(DIM, cfg) |
|
tc = _theme(cfg) |
|
C = tc.header_box |
|
G = tc.task_name |
|
w = 70 |
|
print() |
|
print(f"{B}{C}{'─' * w}{R}") |
|
print(f"{B}{C} {cfg.header}{R}") |
|
if cfg.subtitle: |
|
print(f"{D} {cfg.subtitle}{R}") |
|
print(f"{B}{C}{'─' * w}{R}") |
|
print() |
|
print(f" {B}Usage:{R} {G}task <name>{R} Run a task") |
|
print(f" {G}task <name> --dry{R} Preview commands") |
|
print(f" {G}task --list{R} Full task list") |
|
print() |
|
|
|
|
|
def _resolve_alias_color(cfg: Config, ns_color: str) -> str: |
|
"""Compute the final ANSI code for alias text based on cfg settings.""" |
|
if cfg.no_color: |
|
return "" |
|
if cfg.alias_color == "namespace": |
|
base = ns_color or resolve_color(cfg.alias_fallback_color, cfg.no_color) |
|
else: |
|
base = resolve_color(cfg.alias_color, cfg.no_color) |
|
if cfg.alias_color_adjust == "dim": |
|
return DIM + base |
|
if cfg.alias_color_adjust == "bright": |
|
return BOLD + base |
|
return base |
|
|
|
|
|
def _truncate_desc(desc: str, max_width: int) -> str: |
|
"""Return desc truncated to max_width visible chars (with '…'). -1 = no limit.""" |
|
if max_width > 0 and len(desc) > max_width: |
|
return desc[:max_width - 1] + "…" |
|
return desc |
|
|
|
|
|
def print_group(namespace: str, tasks: list[dict[str, Any]], cfg: Config) -> None: |
|
R = _c(RESET, cfg); B = _c(BOLD, cfg) |
|
tc = _theme(cfg) |
|
fallback_color = _c(WHITE, cfg) |
|
fallback = ("▶", namespace.replace("-", " ").title(), fallback_color) |
|
emoji, label, color = cfg.namespaces.get(namespace, fallback) |
|
if cfg.no_color: |
|
color = "" |
|
print(f"{B}{color} {emoji} {label}{R}") |
|
print(f"{tc.separator} {'─' * 60}{R}") |
|
|
|
sorted_tasks = sorted( |
|
tasks, key=lambda t: (0 if ":" not in t["name"] else 1, t["name"]) |
|
) |
|
|
|
# Pre-process: apply desc truncation and alias filtering once |
|
task_data = [] |
|
for t in sorted_tasks: |
|
name = t["name"] |
|
desc = _truncate_desc(t.get("desc", "").strip(), cfg.desc_max_width) |
|
summary = t.get("summary", "").strip() |
|
aliases = [a for a in t.get("aliases", []) if a] if cfg.show_aliases else [] |
|
task_data.append((name, desc, summary, aliases)) |
|
|
|
# Column widths — computed per group for alignment |
|
max_name_len = max((len(name) for name, *_ in task_data), default=20) |
|
name_col = max_name_len + 2 # minimum 2 spaces after the longest name |
|
|
|
has_any_aliases = any(aliases for _, __, ___, aliases in task_data) |
|
max_desc_len = 0 |
|
if cfg.show_aliases and has_any_aliases: |
|
max_desc_len = max((len(desc) for _, desc, *_ in task_data), default=0) |
|
|
|
# Alias color derived from this group's namespace color |
|
alias_color = _resolve_alias_color(cfg, color) |
|
|
|
task_name_color = tc.task_name |
|
any_blank_printed = False |
|
|
|
for name, desc, summary, aliases in task_data: |
|
name_pad = " " * max(2, name_col - len(name)) |
|
|
|
if cfg.show_aliases and has_any_aliases: |
|
if aliases: |
|
alias_str = " | ".join(aliases) |
|
desc_padded = desc.ljust(max_desc_len) |
|
print(f" {task_name_color}{name}{R}{name_pad}{desc_padded} {alias_color}(aliases: {alias_str}){R}") |
|
else: |
|
print(f" {task_name_color}{name}{R}{name_pad}{desc}") |
|
else: |
|
print(f" {task_name_color}{name}{R}{name_pad}{desc}") |
|
|
|
# ── multi-line summary with │ prefix ────────────────────────────────── |
|
printed_summary = False |
|
if cfg.show_summary and summary: |
|
for line in summary.splitlines(): |
|
print(f" {tc.summary_line}│ {line}{R}") |
|
printed_summary = True |
|
|
|
# ── blank line only when this task rendered summary lines ───────────── |
|
if printed_summary: |
|
print() |
|
any_blank_printed = True |
|
|
|
# Always end the group with a blank line for spacing between groups |
|
if not any_blank_printed: |
|
print() |
|
|
|
|
|
def print_footer(total: int, cfg: Config) -> None: |
|
D = _c(DIM, cfg); R = _c(RESET, cfg); G = _c(GREEN, cfg) |
|
print(f"{D} {total} tasks total · Run {G}task --list{D} for full details{R}") |
|
print() |
|
|
|
|
|
# ── Entry point ─────────────────────────────────────────────────────────────── |
|
def main() -> None: |
|
args = build_arg_parser().parse_args() |
|
cfg = build_config(args) |
|
|
|
tasks = get_tasks() |
|
if not tasks: |
|
return |
|
|
|
groups = group_tasks(tasks) |
|
print_header(cfg) |
|
|
|
ordered = list(cfg.namespaces.keys()) |
|
extras = sorted(k for k in groups if k not in ordered) |
|
for ns in ordered + extras: |
|
if ns in groups: |
|
print_group(ns, groups[ns], cfg) |
|
|
|
print_footer(len(tasks), cfg) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |