Skip to content

Instantly share code, notes, and snippets.

@tobiashochguertel
Last active March 11, 2026 07:37
Show Gist options
  • Select an option

  • Save tobiashochguertel/261c54d64fff6dc1493619e2924161b4 to your computer and use it in GitHub Desktop.

Select an option

Save tobiashochguertel/261c54d64fff6dc1493619e2924161b4 to your computer and use it in GitHub Desktop.
task-help: pretty-print Taskfile tasks grouped by namespace with ANSI colours (PEP 723 uv inline script)

task-help — pretty Taskfile task listing

A standalone Python script (PEP 723 uv inline, zero external deps) that pretty-prints all Taskfile tasks grouped by namespace with ANSI colours and emoji headers — including descriptions, task aliases, and optional multi-line summaries.

Run task with no arguments to get a structured overview instead of a raw alphabetical dump.

Default output (compact — no summaries, aliases in namespace colour):

──────────────────────────────────────────────────────────────────────
  My Project — Task Runner
──────────────────────────────────────────────────────────────────────

  Usage:  task <name>         Run a task
          task <name> --dry    Preview commands
          task --list          Full task list

  ⚙️   Core / Setup
  ────────────────────────────────────────────────────────────
    build   Build the project
    test    Run all tests
    lint    Lint the code

  🐳  Docker Services
  ────────────────────────────────────────────────────────────
    docker:up    Start all containers
    docker:down  Stop all containers
    docker:logs  Tail container logs

  🚀  Deployment
  ────────────────────────────────────────────────────────────
    deploy:staging   Deploy to staging      (aliases: ds)
    deploy:prod      Deploy to production   (aliases: dp | deploy:production)

With --summary (shows multi-line summaries below each task):

  🚀  Deployment
  ────────────────────────────────────────────────────────────
    deploy:staging   Deploy to staging    (aliases: ds)
      │ Runs the staging pipeline with smoke tests.
      │ Requires: AWS_PROFILE set in your shell.

    deploy:prod      Deploy to production   (aliases: dp | deploy:production)
      │ Full production rollout — requires PR approval first.

Quick install

curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4/raw/install.sh | bash

Installs to ~/.taskfiles/taskscripts/task-help/ (a real git repo — updatable with a single command). Creates the parent directory structure automatically.

Or clone manually:

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

Update

task task-help:update

Or manually:

cd ~/.taskfiles/taskscripts/task-help && git fetch origin && git reset --hard origin/HEAD

Wire up in your Taskfile.yml

The recommended default task checks if task-help is installed on the host, runs it if present, and falls back to task --list with a coloured install hint if not. This means the Taskfile works on every machine, even without task-help installed.

vars:
  # Set to "false" or "0" to suppress the "task-help not installed" warning
  TASK_HELP_WARN: "true"

tasks:
  default:
    silent: true
    desc: "Show grouped task list (falls back to 'task --list' if task-help not installed)"
    cmds:
      - |
        SCRIPT="$HOME/.taskfiles/taskscripts/task-help/task_help.py"
        if [ -x "$SCRIPT" ]; then
          "$SCRIPT"
        else
          WARN="{{.TASK_HELP_WARN}}"
          if [ "$WARN" != "false" ] && [ "$WARN" != "0" ]; then
            printf '\033[33m⚠  task-help is not installed.\033[0m\n'
            printf '\033[33m   Install it for a prettier task list:\033[0m\n'
            printf '\033[2m   curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4/raw/install.sh | bash\033[0m\n'
            printf '\033[2m   (Set TASK_HELP_WARN=false to suppress this message)\033[0m\n'
            echo ""
          fi
          task --list
        fi

Suppress the install hint permanently by setting TASK_HELP_WARN: "false" in your vars: block. Suppress it for a single run with TASK_HELP_WARN=false task.

See Taskfile.example.yml in the Gist for a full copy-paste example with alternative variants (--summary, env-var config, namespace overrides).


Global taskfile management

The installer also sets up a dedicated global Taskfile for managing task-help itself, so you never need to embed scripts:install / scripts:update in project Taskfiles:

~/.taskfiles/
├── Taskfile.yml                       ← your global root (includes taskscripts with flatten)
├── Taskfile.taskscripts.yml           ← orchestrator (auto-created by installer)
└── taskscripts/
    └── Taskfile.task-help.yml         ← task-help lifecycle tasks (auto-deployed by installer)

After install, from any directory:

task task-help:install    # install or reinstall
task task-help:update     # pull latest from Gist (also re-deploys Taskfile.task-help.yml)
task task-help:remove     # uninstall (prompts for confirmation, keeps a backup)
task task-help:status     # show installed commit and script path

Wire the orchestrator into your global ~/.taskfiles/Taskfile.yml:

includes:
  taskscripts:
    taskfile: Taskfile.taskscripts.yml
    optional: true
    flatten: true          # removes "taskscripts:" prefix → task-help:install, not taskscripts:task-help:install
    dir: ~/.taskfiles

The flatten: true key (Taskfile v3 feature) merges the taskscripts namespace into the root — so sub-namespaces like task-help: remain intact but the extra taskscripts: layer disappears.

Adding a new tool later is one line in Taskfile.taskscripts.yml:

includes:
  task-help:
    taskfile: taskscripts/Taskfile.task-help.yml
    optional: true
  my-tool:                              # ← add this
    taskfile: taskscripts/Taskfile.my-tool.yml
    optional: true

Configuration

Namespace groups and display settings can be customised without editing the shared script. Five configuration sources are supported, applied in priority order (last wins):

Priority Source How
1 (lowest) Built-in defaults DEFAULT_NAMESPACE_META in the script
2 Config file auto-discovered or --config PATH / TASK_HELP_CONFIG=PATH
3 Stdin piped JSON/YAML, or forced with --stdin
4 Env vars TASK_HELP_NS, TASK_HELP_NS_<NAME>, …
5 (highest) CLI options --ns, --ns-json, --header, …

Config file (recommended for projects)

Drop a .task-help.json in your project root (auto-discovered):

{
  "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"],
    "db":     ["🗄 ", "Database",    "BLUE"],
    "_top":   ["⚙️ ", "My Tasks",   "CYAN"]
  }
}

YAML is also supported (.task-help.yaml / .task-help.yml) when pyyaml is installed.

Global fallback: ~/.config/task-help/config.json

Environment variables

TASK_HELP_HEADER="My Project"           # override header title
TASK_HELP_SUBTITLE="v1.2.3"            # override subtitle
TASK_HELP_REPLACE=1                     # replace defaults entirely
TASK_HELP_NO_COLOR=1                    # disable ANSI colours
TASK_HELP_SUMMARY=1                     # enable multi-line 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=60             # truncate descriptions to 60 chars (-1 = no limit)
TASK_HELP_THEME=dark                    # colour theme: dark (default) or light
TASK_HELP_ALIAS_COLOR=namespace         # alias colour: "namespace" or a color name/code
TASK_HELP_ALIAS_COLOR_ADJUST=none       # alias brightness: none | dim | bright
TASK_HELP_ALIAS_FALLBACK_COLOR=WHITE    # alias colour when namespace has none
NO_COLOR=1                              # standard no-colour (no-color.org)
FORCE_COLOR=1                           # force colours when stdout is not a TTY

# Whole namespace object (JSON):
TASK_HELP_NS='{"deploy":["🚀","Deploy","GREEN"]}'

# Single namespace per env var:
TASK_HELP_NS_deploy="🚀,Deployment,GREEN"
TASK_HELP_NS_db="🗄 ,Database,BLUE"

CLI options

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 --no-color
task_help.py --summary                       # enable multi-line summaries
task_help.py --no-summary                    # explicitly disable (compat; default is off)
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       # aliases in bright cyan
task_help.py --alias-color namespace         # aliases in namespace colour (default)
task_help.py --alias-color-adjust dim        # aliases slightly dimmed
task_help.py --alias-color-adjust bright     # aliases bold

Stdin

echo '{"header":"CI","namespaces":{"ci":["🔁","CI/CD","YELLOW"]}}' | task_help.py
cat .task-help.json | task_help.py --stdin

Color names

Standard (normal intensity):

CYAN GREEN YELLOW BLUE MAGENTA RED WHITE DIM BOLD RESET

Bright variants (high intensity):

BRIGHT_CYAN BRIGHT_GREEN BRIGHT_YELLOW BRIGHT_BLUE BRIGHT_MAGENTA BRIGHT_RED BRIGHT_WHITE BRIGHT_BLACK GRAY

Style:

ITALIC UNDERLINE

256-color: use raw ANSI codes, e.g. "\033[38;5;208m" (orange).


Themes

Two built-in themes target different terminal backgrounds:

Theme Best for Colors
dark (default) Black / dark terminals CYAN header, GREEN tasks, WHITE aliases
light White / light terminals BRIGHT_BLUE header, BLUE tasks, GRAY aliases

Switch via --theme light, TASK_HELP_THEME=light, or "theme": "light" in config.


Aliases

Alias display format

Aliases are shown inline after the description, in the same colour as the namespace group header:

    gh-copilot:config   Show Copilot CLI config file    (aliases: ghc:config | ghco:config)

Multiple aliases are separated by |.

Alias color configuration

{
  "alias_color":          "namespace",
  "alias_color_adjust":   "none",
  "alias_fallback_color": "WHITE"
}
Key Values Description
alias_color "namespace" (default) Use the group's namespace colour
any color name / ANSI code Override with a fixed colour
alias_color_adjust "none" (default) No brightness adjustment
"dim" Apply DIM on top of the alias colour
"bright" Apply BOLD on top of the alias colour
alias_fallback_color any color name Used when no namespace colour is defined

Task-level aliases

Define aliases in a task's aliases: field — they are shown inline:

tasks:
  deploy:staging:
    aliases: [ds]
    desc: "Deploy to staging"

Include-level aliases

When including a Taskfile with namespace aliases, all tasks in that file are also accessible under the alias prefix:

includes:
  zellij:
    taskfile: ~/.taskfiles/Taskfile.zellij.yml
    optional: true
    aliases:
      - z

Taskfile registers both zellij:start and z:start as separate task entries. task-help groups each by its namespace prefix, so you will see both zellij and z groups in the output. Use --no-aliases / TASK_HELP_NO_ALIASES=1 to suppress alias display for cleaner output.


Files in this Gist

File Description
task_help.py The script — PEP 723 uv inline, run directly or via uv run
install.sh One-liner installer (curl … | bash)
Taskfile.task-help.yml Lifecycle tasks (install/update/remove/status) — deployed to ~/.taskfiles/taskscripts/ by the installer
Taskfile.taskscripts.yml Orchestrator template — deployed to ~/.taskfiles/ if not present
Taskfile.example.yml Full project wiring example (smart default + fallback warning)
CHANGELOG.md Version history
README.md This file

Requirements

  • task in $PATH
  • uv ≥ 0.4 or plain python3 ≥ 3.11
  • git (for install / update)

Changelog

All notable changes to task-help are documented here.


[Unreleased]

[2026-03-11]

Added

  • Smart default task with graceful fallback — the recommended default task in Taskfile.example.yml now checks whether task_help.py is installed on the host at runtime. If present it runs the pretty grouped list; if not it falls back to task --list and prints a yellow warning with the Gist install URL. Suppress the warning permanently with TASK_HELP_WARN: "false" in vars:, or for a single run with TASK_HELP_WARN=false task.

  • Taskfile.task-help.yml — new file in the Gist, deployed to ~/.taskfiles/taskscripts/Taskfile.task-help.yml by the installer. Provides four global lifecycle tasks (available as task task-help:* anywhere):

    • task task-help:install — clone gist, deploy Taskfile, create orchestrator
    • task task-help:update — pull latest, re-deploy Taskfile (with backup)
    • task task-help:remove — delete clone and deployed Taskfile (with backup, confirmation prompt)
    • task task-help:status — show commit hash, date, and script executable state
  • Taskfile.taskscripts.yml — orchestrator template deployed to ~/.taskfiles/Taskfile.taskscripts.yml if the file does not already exist. Includes Taskfile.task-help.yml under the task-help: namespace; more tools can be added as further includes: entries.

  • task-help: namespace entry in DEFAULT_NAMESPACE_META — the management task group now displays as 🧰 task-help management in grouped output.

Changed

  • install.sh enhanced — now performs three steps after the clone/update:

    1. Deploys Taskfile.task-help.yml to ~/.taskfiles/taskscripts/ (with timestamped backup if replacing)
    2. Creates ~/.taskfiles/Taskfile.taskscripts.yml if it does not already exist
    3. Prints updated wiring instructions showing the flatten: true include pattern
  • Taskfile.example.yml updateddefault task uses the smart fallback pattern; scripts:install / scripts:update are kept as deprecated stubs that delegate to task task-help:install / task task-help:update.

  • Taskfile variable naming convention — all variables in Taskfile.task-help.yml are prefixed TASK_HELP_ (e.g. TASK_HELP_TARGET, TASK_HELP_GIST_URL). Uses | default for overridable values so the including Taskfile can override them.

  • flatten: true include pattern — the global Taskfile.yml now includes Taskfile.taskscripts.yml with flatten: true, which removes the taskscripts: prefix and exposes sub-namespaces (task-help:) directly at the root level. Avoids the triple-nesting problem (taskscripts:task-help:install).

  • README.md — updated sections: Update, Wire up in your Taskfile.yml (smart default snippet + TASK_HELP_WARN docs), new Global taskfile management section, Files in this Gist table now includes all 7 files.

[2026-03-10]

Added

  • Light / dark theme support (--theme, TASK_HELP_THEME, "theme" in config). Two built-in themes: dark (default — CYAN header, GREEN task names, white aliases) and light (BRIGHT_BLUE header, BLUE task names, grey aliases) for white-background terminals. The ThemeColors dataclass drives all role-named colour slots so themes are easy to extend.

  • Bright ANSI colour paletteBRIGHT_CYAN, BRIGHT_GREEN, BRIGHT_YELLOW, BRIGHT_BLUE, BRIGHT_MAGENTA, BRIGHT_RED, BRIGHT_WHITE, BRIGHT_BLACK, GRAY (alias for BRIGHT_BLACK), ITALIC, UNDERLINE. Added c256(n) helper for 256-colour escape codes. All new names are accepted in config files, env vars, and DEFAULT_NAMESPACE_META.

  • Alias colour tied to namespace — aliases are now rendered in the same colour as their group header by default. Three new config/env/CLI knobs:

    • alias_color / TASK_HELP_ALIAS_COLOR / --alias-color — set to "namespace" (default) to inherit the group colour, or any colour name/code for a fixed colour.
    • alias_color_adjust / TASK_HELP_ALIAS_COLOR_ADJUST / --alias-color-adjust"none" (default), "dim", or "bright" to tweak luminance relative to the namespace colour.
    • alias_fallback_color / TASK_HELP_ALIAS_FALLBACK_COLOR — colour used when no namespace colour is defined (default WHITE).
  • --summary / --summaries flag — explicitly enables multi-line task summaries. TASK_HELP_SUMMARY=1 env var also activates summaries.

  • desc_max_width / TASK_HELP_DESC_MAX_WIDTH / --desc-max-width — truncate task descriptions to N characters. Set to -1 (default) for no truncation.

  • New namespace entries in DEFAULT_NAMESPACE_META: gh-copilot 🐙, copilot 🤖, disk 💾, ios 📱, uv ⚡, mise 🔧, bun 🍞, tmux 📺.

Changed

  • show_summary default flipped to false — the output is compact (no summary lines) unless you pass --summary or set TASK_HELP_SUMMARY=1. --no-summary is kept for backwards compatibility.

  • Alias display format — multiple aliases are now shown as (aliases: ghc:config | ghco:config) with a | separator instead of a comma.

  • Fallback group title padding fixed — groups without an entry in DEFAULT_NAMESPACE_META used the emoji "▸ " (arrow + space) which produced misaligned indentation versus wide emoji headers. Changed to "▶" so the visual column count is consistent across all groups.

  • Taskfile.example.yml — replaced default-compact task with default-with-summaries to reflect the new default being compact.

  • README.md fully rewritten — default output example updated to compact style, --summary example added, Themes section added, Color names section expanded with bright variants, all new env vars and CLI options documented, recommended config file now sets "show_summary": false.

Fixed

  • Blank line printed after tasks that have aliases but no summary, when running with --no-summary. Blank lines are now only emitted after tasks that actually rendered summary content.

[2025] — earlier work

Added

  • Initial release: grouped task listing with namespace emoji headers, ANSI colours, one-line descriptions, and optional multi-line summaries (--summary).
  • Config file auto-discovery: .task-help.json (CWD) → .task-help.yaml/.yml (CWD) → ~/.config/task-help/config.json.
  • Namespace metadata (DEFAULT_NAMESPACE_META) — emoji, label, and colour for common namespaces: brew, ios, ghc, copilot, disk, uv, mise, bun, tmux, scripts, _top, etc.
  • Alias display(aliases: …) shown inline after the task description, column-aligned within each group.
  • Config priority chain: defaults → config file → stdin → TASK_HELP_NS → per-namespace env vars (TASK_HELP_NS_<name>) → CLI options.
  • No-color support: NO_COLOR / FORCE_COLOR / --no-color as per no-color.org.
  • PEP 723 inline script metadata — runnable directly with uv run task_help.py (zero external dependencies).
  • install.sh one-liner and Taskfile.example.yml wiring examples.
#!/usr/bin/env bash
# install.sh — install task-help from GitHub Gist
#
# One-liner install:
# curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4/raw/install.sh | bash
#
# What it does:
# 1. Creates ~/.taskfiles/taskscripts/ if it does not exist
# 2. Clones the Gist as a git repo to ~/.taskfiles/taskscripts/task-help/
# (or pulls the latest if a clone already exists there)
# 3. Makes task_help.py executable
# 4. Deploys Taskfile.task-help.yml to ~/.taskfiles/taskscripts/
# (creates a timestamped backup if the file already exists)
# 5. Creates ~/.taskfiles/Taskfile.taskscripts.yml if it does not exist
# (the orchestrator that wires all taskscript Taskfiles globally)
# 6. Prints wiring instructions
#
# Update later with:
# task task-help:update
# or:
# cd ~/.taskfiles/taskscripts/task-help && git fetch origin && git reset --hard origin/HEAD
#
set -euo pipefail
GIST_ID="261c54d64fff6dc1493619e2924161b4"
GIST_URL="https://gist.github.com/${GIST_ID}.git"
TARGET="${HOME}/.taskfiles/taskscripts/task-help"
SCRIPT="${TARGET}/task_help.py"
TS_DIR="${HOME}/.taskfiles/taskscripts"
ORCH="${HOME}/.taskfiles/Taskfile.taskscripts.yml"
# ── helpers ──────────────────────────────────────────────────────────────────
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
err() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; }
# ── pre-flight ────────────────────────────────────────────────────────────────
if ! command -v git >/dev/null 2>&1; then
err "git is required but not found in PATH."
exit 1
fi
# ── ensure parent directories exist ──────────────────────────────────────────
mkdir -p "${TS_DIR}"
# ── step 1: install or update the gist clone ──────────────────────────────────
echo ""
if [ -d "${TARGET}/.git" ]; then
bold "↻ Updating existing installation at ~/.taskfiles/taskscripts/task-help ..."
cd "${TARGET}"
git fetch origin
git reset --hard origin/HEAD
chmod +x "${SCRIPT}"
green "✅ Updated to latest."
else
bold "⬇ Cloning task-help gist to ~/.taskfiles/taskscripts/task-help ..."
git clone "${GIST_URL}" "${TARGET}"
chmod +x "${SCRIPT}"
green "✅ Installed."
fi
# ── step 2: deploy Taskfile.task-help.yml to taskscripts/ ────────────────────
echo ""
bold "📋 Deploying Taskfile.task-help.yml ..."
SRC="${TARGET}/Taskfile.task-help.yml"
DEST="${TS_DIR}/Taskfile.task-help.yml"
if [ -f "${DEST}" ]; then
TS="$(date +%Y%m%d_%H%M%S)"
BACKUP="${TS_DIR}/Taskfile.task-help.backup-${TS}.yml"
cp "${DEST}" "${BACKUP}"
dim " 📦 Backed up existing file → taskscripts/Taskfile.task-help.backup-${TS}.yml"
fi
cp "${SRC}" "${DEST}"
dim " ✔ ${DEST}"
# ── step 3: create Taskfile.taskscripts.yml orchestrator if missing ───────────
echo ""
if [ ! -f "${ORCH}" ]; then
bold "📝 Creating ~/.taskfiles/Taskfile.taskscripts.yml ..."
cat > "${ORCH}" << 'ORCH_EOF'
# yaml-language-server: $schema=https://taskfile.dev/schema.json
# Taskfile.taskscripts.yml — global taskscripts orchestrator
# Auto-generated by task-help install.sh
# Include this in your ~/.taskfiles/Taskfile.yml (or ~/Taskfile.yml):
#
# includes:
# taskscripts:
# taskfile: Taskfile.taskscripts.yml
# optional: true
# dir: ~/.taskfiles
#
version: "3"
includes:
task-help:
taskfile: taskscripts/Taskfile.task-help.yml
optional: true
ORCH_EOF
dim " ✔ ${ORCH}"
green "✅ Orchestrator created."
else
dim " ℹ ${ORCH} already exists — not overwritten."
dim " Add this include manually if task-help is not listed:"
dim " task-help:"
dim " taskfile: taskscripts/Taskfile.task-help.yml"
dim " optional: true"
fi
# ── usage instructions ────────────────────────────────────────────────────────
echo ""
bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
bold " task-help installed successfully!"
bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
bold "1. Wire the global task-help tasks into your ~/.taskfiles/Taskfile.yml:"
echo ""
dim ' includes:'
dim ' taskscripts:'
dim ' taskfile: Taskfile.taskscripts.yml'
dim ' optional: true'
dim ' dir: ~/.taskfiles'
echo ""
bold " Then from any directory:"
dim ' task task-help:update # pull latest from Gist'
dim ' task task-help:status # show installed version'
dim ' task task-help:remove # uninstall'
echo ""
bold "2. Wire task-help into any project Taskfile.yml:"
echo ""
dim ' vars:'
dim ' TASK_HELP_WARN: "true" # set "false" to suppress the install hint'
echo ""
dim ' tasks:'
dim ' default:'
dim ' silent: true'
dim ' cmds:'
dim ' - |'
dim ' SCRIPT="$HOME/.taskfiles/taskscripts/task-help/task_help.py"'
dim ' if [ -x "$SCRIPT" ]; then'
dim ' "$SCRIPT"'
dim ' else'
dim ' if [ "{{.TASK_HELP_WARN}}" != "false" ]; then'
dim ' printf '"'"'\033[33m⚠ task-help not installed. '"'"''
dim ' printf '"'"'Install: https://gist.github.com/261c54d64fff6dc1493619e2924161b4\033[0m\n'"'"''
dim ' fi'
dim ' task --list'
dim ' fi'
echo ""
dim " (Copy the full example from Taskfile.example.yml in the Gist)"
echo ""
bold "3. Update any time:"
dim " task task-help:update"
dim " or: cd ~/.taskfiles/taskscripts/task-help && git fetch origin && git reset --hard origin/HEAD"
echo ""
#!/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()
# yaml-language-server: $schema=https://taskfile.dev/schema.json
# Taskfile.example.yml — minimal example showing how to wire task-help
#
# Install task-help first:
# curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4/raw/install.sh | bash
#
# Then copy the relevant snippets below into your own Taskfile.yml.
#
# ─── How the smart default task works ───────────────────────────────────────
# The `default` task below checks whether task-help is installed on the host.
# If it is → runs the pretty grouped task list.
# If it is not → falls back to `task --list` and prints a yellow install hint
# (unless TASK_HELP_WARN is set to "false" or "0").
# This means the Taskfile works on every machine, even without task-help.
version: "3"
vars:
# ── Set to "false" or "0" to suppress the "task-help not installed" hint ────
TASK_HELP_WARN: "true"
TASK_HELP_SCRIPT: "{{.HOME}}/.taskfiles/taskscripts/task-help/task_help.py"
tasks:
# ── default: smart grouped task list with graceful fallback ─────────────────
# Run `task` (no args) to see all tasks organised by namespace.
#
# • task-help installed → pretty grouped list (with emoji, colours, aliases)
# • task-help missing → `task --list` + optional install hint
#
# Suppress the hint: TASK_HELP_WARN=false task
# Or set permanently: vars: TASK_HELP_WARN: "false" (in your Taskfile.yml)
#
# Install task-help:
# curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4/raw/install.sh | bash
default:
silent: true
desc: "Show grouped task list (falls back to 'task --list' if task-help is not installed)"
cmds:
- |
SCRIPT="{{.TASK_HELP_SCRIPT}}"
if [ -x "$SCRIPT" ]; then
"$SCRIPT"
else
WARN="{{.TASK_HELP_WARN}}"
if [ "$WARN" != "false" ] && [ "$WARN" != "0" ]; then
printf '\033[33m⚠ task-help is not installed.\033[0m\n'
printf '\033[33m Install it for a prettier task list:\033[0m\n'
printf '\033[2m curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4/raw/install.sh | bash\033[0m\n'
printf '\033[2m (Set TASK_HELP_WARN=false to suppress this message)\033[0m\n'
echo ""
fi
task --list
fi
# ── Alternative: pass config inline via env vars ─────────────────────────────
# Use this if you don't want a .task-help.json file in your repo.
default-with-env:
silent: true
desc: "Show grouped task list (config via env vars)"
cmds:
- |
TASK_HELP_HEADER="My Project — Task Runner" \
TASK_HELP_SUBTITLE="v1.2.3" \
{{.TASK_HELP_SCRIPT}}
# ── Alternative: view with summaries enabled ──────────────────────────────────
default-with-summaries:
silent: true
desc: "Show grouped task list with multi-line summaries"
cmds:
- "{{.TASK_HELP_SCRIPT}} --summary"
# ── Alternative: pass namespace overrides via CLI ─────────────────────────────
default-with-cli:
silent: true
desc: "Show grouped task list (with extra namespace via CLI)"
cmds:
- |
{{.TASK_HELP_SCRIPT}} \
--header "My Project" \
--ns deploy:🚀,Deployment,GREEN \
--ns db:🗄 ,Database,BLUE
# ── scripts:install / scripts:update are now provided globally ───────────────
# If you installed via install.sh, use:
# task task-help:install (or task task-help:update / task task-help:remove)
# from any directory — they come from ~/.taskfiles/taskscripts/Taskfile.task-help.yml
# which the installer sets up automatically.
#
# The stubs below are kept for backwards compatibility with older setups.
scripts:install:
desc: "Install task-help (deprecated stub — use 'task task-help:install' instead)"
cmds:
- |
if command -v task >/dev/null 2>&1 && task --list 2>/dev/null | grep -q 'task-help:install'; then
task task-help:install
else
curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4/raw/install.sh | bash
fi
scripts:update:
desc: "Update task-help (deprecated stub — use 'task task-help:update' instead)"
cmds:
- |
if command -v task >/dev/null 2>&1 && task --list 2>/dev/null | grep -q 'task-help:update'; then
task task-help:update
else
TARGET="$HOME/.taskfiles/taskscripts/task-help"
[ -d "$TARGET/.git" ] || { echo "⚠️ Not installed. Run 'task scripts:install' first."; exit 1; }
cd "$TARGET" && git fetch origin && git reset --hard origin/HEAD
chmod +x "$TARGET/task_help.py"
echo "✅ Updated."
fi
# ── your project tasks below ────────────────────────────────────────────────
build:
desc: "Build the project"
cmds:
- echo "Build goes here"
test:
desc: "Run tests"
cmds:
- echo "Tests go here"
lint:
desc: "Lint the code"
cmds:
- echo "Lint goes here"
# yaml-language-server: $schema=https://taskfile.dev/schema.json
# Taskfile.task-help.yml — task-help lifecycle management
#
# Deployed to: ~/.taskfiles/taskscripts/Taskfile.task-help.yml
# Included as: task-help: (namespace) via Taskfile.taskscripts.yml
# which is included with flatten:true in the root Taskfile.yml
#
# Tasks exposed globally (via flatten + namespace):
# task task-help:install — install or reinstall from Gist
# task task-help:update — pull latest from Gist
# task task-help:remove — uninstall and clean up
# task task-help:status — show installed version / state
#
# Variable naming convention:
# TASK_HELP_<PROPERTY> — top-level vars for this Taskfile
# TASK_HELP__<OBJECT>_<PROP> — sub-object vars (__ = object separator)
#
version: "3"
vars:
TASK_HELP_GIST_ID: '{{.TASK_HELP_GIST_ID | default "261c54d64fff6dc1493619e2924161b4"}}'
TASK_HELP_GIST_URL: 'https://gist.github.com/{{.TASK_HELP_GIST_ID}}.git'
TASK_HELP_GIST_RAW: 'https://gist.githubusercontent.com/tobiashochguertel/{{.TASK_HELP_GIST_ID}}/raw'
TASK_HELP_TARGET: '{{.HOME}}/.taskfiles/taskscripts/task-help'
TASK_HELP_SCRIPT: '{{.HOME}}/.taskfiles/taskscripts/task-help/task_help.py'
TASK_HELP_TS_DIR: '{{.HOME}}/.taskfiles/taskscripts'
TASK_HELP_ORCH: '{{.HOME}}/.taskfiles/Taskfile.taskscripts.yml'
tasks:
# ── install ──────────────────────────────────────────────────────────────────
install:
desc: "Install task-help from GitHub Gist (clones to ~/.taskfiles/taskscripts/task-help/)"
silent: true
cmds:
- |
TASK_HELP_TARGET="{{.TASK_HELP_TARGET}}"
TASK_HELP_SCRIPT="{{.TASK_HELP_SCRIPT}}"
TASK_HELP_GIST_URL="{{.TASK_HELP_GIST_URL}}"
TASK_HELP_TS_DIR="{{.TASK_HELP_TS_DIR}}"
TASK_HELP_ORCH="{{.TASK_HELP_ORCH}}"
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
mkdir -p "$TASK_HELP_TS_DIR"
if [ -d "$TASK_HELP_TARGET/.git" ]; then
yellow "↻ Already installed — running update instead …"
cd "$TASK_HELP_TARGET"
git fetch origin
git reset --hard origin/HEAD
chmod +x "$TASK_HELP_SCRIPT"
green "✅ Updated to latest."
else
bold "⬇ Cloning task-help gist …"
git clone "$TASK_HELP_GIST_URL" "$TASK_HELP_TARGET"
chmod +x "$TASK_HELP_SCRIPT"
green "✅ Installed at $TASK_HELP_TARGET"
fi
# deploy Taskfile.task-help.yml to taskscripts/
TASK_HELP_SRC="$TASK_HELP_TARGET/Taskfile.task-help.yml"
TASK_HELP_DEST="$TASK_HELP_TS_DIR/Taskfile.task-help.yml"
if [ -f "$TASK_HELP_DEST" ]; then
TASK_HELP_TS="$(date +%Y%m%d_%H%M%S)"
cp "$TASK_HELP_DEST" "${TASK_HELP_DEST%.yml}.backup-${TASK_HELP_TS}.yml"
dim " 📦 Backed up → Taskfile.task-help.backup-${TASK_HELP_TS}.yml"
fi
cp "$TASK_HELP_SRC" "$TASK_HELP_DEST"
dim " ✔ $TASK_HELP_DEST updated"
# create Taskfile.taskscripts.yml orchestrator if missing
if [ ! -f "$TASK_HELP_ORCH" ]; then
bold "📝 Creating $TASK_HELP_ORCH …"
printf '%s\n' \
'# yaml-language-server: $schema=https://taskfile.dev/schema.json' \
'# Taskfile.taskscripts.yml — global taskscripts orchestrator' \
'# Auto-generated by: task task-help:install' \
'# Wire into ~/.taskfiles/Taskfile.yml:' \
'# includes:' \
'# taskscripts:' \
'# taskfile: Taskfile.taskscripts.yml' \
'# optional: true' \
'# flatten: true' \
'# dir: ~/.taskfiles' \
'version: "3"' \
'' \
'includes:' \
' task-help:' \
' taskfile: taskscripts/Taskfile.task-help.yml' \
' optional: true' \
> "$TASK_HELP_ORCH"
dim " ✔ $TASK_HELP_ORCH created"
fi
echo ""
bold "━━━ Done ━━━"
dim " task task-help:update — pull latest from Gist"
dim " task task-help:remove — uninstall"
dim " task task-help:status — show installed version"
# ── update ───────────────────────────────────────────────────────────────────
update:
desc: "Update task-help script from GitHub Gist (hard-reset to latest)"
silent: true
cmds:
- |
TASK_HELP_TARGET="{{.TASK_HELP_TARGET}}"
TASK_HELP_SCRIPT="{{.TASK_HELP_SCRIPT}}"
TASK_HELP_TS_DIR="{{.TASK_HELP_TS_DIR}}"
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
err() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; }
if [ ! -d "$TASK_HELP_TARGET/.git" ]; then
err "task-help is not installed. Run: task task-help:install"
exit 1
fi
bold "🔄 Pulling latest task-help from Gist …"
cd "$TASK_HELP_TARGET"
git fetch origin
git reset --hard origin/HEAD
chmod +x "$TASK_HELP_SCRIPT"
# re-deploy Taskfile.task-help.yml
TASK_HELP_DEST="$TASK_HELP_TS_DIR/Taskfile.task-help.yml"
if [ -f "$TASK_HELP_DEST" ]; then
TASK_HELP_TS="$(date +%Y%m%d_%H%M%S)"
cp "$TASK_HELP_DEST" "${TASK_HELP_DEST%.yml}.backup-${TASK_HELP_TS}.yml"
dim " 📦 Backed up → Taskfile.task-help.backup-${TASK_HELP_TS}.yml"
fi
cp "$TASK_HELP_TARGET/Taskfile.task-help.yml" "$TASK_HELP_DEST"
dim " ✔ $TASK_HELP_DEST updated"
green "✅ task-help updated to latest."
# ── remove ───────────────────────────────────────────────────────────────────
remove:
desc: "Uninstall task-help (removes clone and deployed Taskfile.task-help.yml)"
silent: true
prompt: "This will delete ~/.taskfiles/taskscripts/task-help/ and Taskfile.task-help.yml. Continue?"
cmds:
- |
TASK_HELP_TARGET="{{.TASK_HELP_TARGET}}"
TASK_HELP_TS_DIR="{{.TASK_HELP_TS_DIR}}"
TASK_HELP_DEST="$TASK_HELP_TS_DIR/Taskfile.task-help.yml"
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
if [ -d "$TASK_HELP_TARGET" ]; then
rm -rf "$TASK_HELP_TARGET"
dim " 🗑 Removed $TASK_HELP_TARGET"
else
dim " ⚠ $TASK_HELP_TARGET not found — nothing to remove"
fi
if [ -f "$TASK_HELP_DEST" ]; then
TASK_HELP_TS="$(date +%Y%m%d_%H%M%S)"
cp "$TASK_HELP_DEST" "${TASK_HELP_DEST%.yml}.backup-${TASK_HELP_TS}.yml"
rm "$TASK_HELP_DEST"
dim " 📦 Backed up and removed $TASK_HELP_DEST"
fi
green "✅ task-help removed."
dim " Re-install: curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4/raw/install.sh | bash"
# ── status ───────────────────────────────────────────────────────────────────
status:
desc: "Show task-help installation status and version"
silent: true
cmds:
- |
TASK_HELP_TARGET="{{.TASK_HELP_TARGET}}"
TASK_HELP_SCRIPT="{{.TASK_HELP_SCRIPT}}"
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
dim() { printf '\033[2m%s\033[0m\n' "$*"; }
bold "task-help status"
echo ""
if [ -d "$TASK_HELP_TARGET/.git" ]; then
green " ✅ Installed at $TASK_HELP_TARGET"
TASK_HELP_COMMIT=$(cd "$TASK_HELP_TARGET" && git --no-pager log -1 --format="%h %ai %s" 2>/dev/null || echo "unknown")
dim " Commit : $TASK_HELP_COMMIT"
if [ -x "$TASK_HELP_SCRIPT" ]; then
dim " Script : $TASK_HELP_SCRIPT (executable ✓)"
else
yellow " Script : $TASK_HELP_SCRIPT (NOT executable — run chmod +x)"
fi
else
yellow " ⚠ Not installed."
dim " Install: task task-help:install"
dim " or curl -fsSL https://gist.githubusercontent.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4/raw/install.sh | bash"
fi
# yaml-language-server: $schema=https://taskfile.dev/schema.json
# Taskfile.taskscripts.yml — global taskscripts orchestrator
#
# Location: ~/.taskfiles/Taskfile.taskscripts.yml
# Purpose: Groups all per-tool Taskfiles from ~/.taskfiles/taskscripts/
# under a single include so the root Taskfile can flatten them.
#
# ─── How it works ────────────────────────────────────────────────────────────
# The root ~/.taskfiles/Taskfile.yml includes this file with flatten:true:
#
# includes:
# taskscripts:
# taskfile: Taskfile.taskscripts.yml
# optional: true
# flatten: true ← removes the "taskscripts:" prefix
# dir: ~/.taskfiles
#
# Each sub-include here retains its own namespace (e.g. task-help:), so the
# final task names are: task task-help:install, task task-help:update, etc.
# No triple-nesting (taskscripts:task-help:install) thanks to flatten:true.
#
# ─── Adding more tools ───────────────────────────────────────────────────────
# When you install another gist/tool that ships a Taskfile.<tool>.yml, add it:
#
# my-tool:
# taskfile: taskscripts/Taskfile.my-tool.yml
# optional: true
#
version: "3"
includes:
# ── task-help — pretty Taskfile task listing ──────────────────────────────
# Exposes: task-help:install task-help:update task-help:remove task-help:status
task-help:
taskfile: taskscripts/Taskfile.task-help.yml
optional: true
# ── add more tools here ───────────────────────────────────────────────────
# my-tool:
# taskfile: taskscripts/Taskfile.my-tool.yml
# optional: true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment