Skip to content

Instantly share code, notes, and snippets.

@tobiashochguertel
Last active April 17, 2026 14:19
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-Print Taskfile Tasks with Rich Grouped Output
A PEP 723 uv inline Python script that displays Taskfile tasks grouped by
namespace with ANSI colors, emoji headers, and customizable configuration.
Install: curl -fsSL https://gist.github.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4/raw/install.sh | bash
__pycache__/

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.

With prelog and epilog (quick-start guide + footer notes):

──────────────────────────────────────────────────────────────────────
  My Project β€” Task Runner
──────────────────────────────────────────────────────────────────────

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

  First time?
    1. task install            β€” interactive setup wizard
    2. task build              β€” build the project
    3. task test               β€” run all tests

  βš™οΈ   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)

──────────────────────────────────────────────────────────────────────

  EXAMPLES
    $ task build
    $ task deploy:staging

  LEARN MORE
    Run task --list for the full task list.
    Read the README at https://github.com/you/project

The prelog appears after the Usage block, before the first group header. The epilog appears at the very end, after the footer rule.


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",
  "prelog":               "[bold]First time?[/bold]\n  1. task install\n  2. task test",
  "epilog":               "[dim]Run [green]task --list[/green] for the full list.[/dim]",
  "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",
  "namespace_order":      ["_top", "build", "test", "deploy"],
  "show_task_count":      false,
  "top_exclude":          ["default"],
  "show_summary_for":     ["install"],
  "hide_alias_patterns":  ["^\\w+$"],
  "groups": {
    "_top":   ["βš™οΈ ", "My Tasks",  "CYAN"],
    "deploy": ["πŸš€",  "Deployment", "GREEN"],
    "db":     ["πŸ—„ ", "Database",   "BLUE"]
  }
}

groups vs namespaces: Both keys are accepted and produce the same result for simple array values. Use groups for new configs β€” it also accepts rich descriptor objects with includes, parent, note, and more (see Group Descriptor Objects below). The namespaces key is deprecated since v1.1.0 and planned for removal in v2.0.

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

Multi-line prelog / epilog in YAML β€” use a literal block scalar (|):

# .task-help.yml
header: "My SSH Project"
prelog: |
  [bold]First time?[/bold]
    1. [green]task scripts:install[/green]    β€” install task-help
    2. [green]task install[/green]            β€” interactive setup wizard
    3. [green]task ssh:test[/green]           β€” verify the setup

  [bold]SSH Vault CLI:[/bold]
    [cyan]task cli:install[/cyan]           β€” install the CLI tool globally
epilog: |
  [dim]LEARN MORE[/dim]
    Use [green]task --list[/green] for the full task list.

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_PRELOG="[bold]First time?[/bold]\\n  1. task install"  # prelog (Rich markup OK)
TASK_HELP_EPILOG="[dim]Run task --list for more.[/dim]"          # epilog (Rich markup OK)
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
TASK_HELP_UPDATE_CHECK=on               # update check: on (default) | off | auto
TASK_HELP_NAMESPACE_ORDER='["_top","build","test","deploy"]'  # explicit group order (JSON array)
TASK_HELP_SHOW_TASK_COUNT=1             # show task count badge on each group header
TASK_HELP_TOP_EXCLUDE='["default"]'     # JSON array: task names to hide from _top
TASK_HELP_SHOW_SUMMARY_FOR='["install"]' # JSON array: namespaces that always show summaries
TASK_HELP_HIDE_ALIAS_PATTERNS='["^\\w+$"]'  # JSON array of regex; hide matching aliases
NO_COLOR=1                              # standard no-colour (no-color.org)
FORCE_COLOR=1                           # force colours when stdout is not a TTY

# Namespace / group definitions:
TASK_HELP_NS='{"deploy":["πŸš€","Deploy","GREEN"]}'   # old array style (still works)
TASK_HELP_GROUPS='{"deploy":{"emoji":"πŸš€","label":"Deployment","color":"GREEN","includes":["deploy"]}}'  # new rich style

# Single namespace per env var:
TASK_HELP_NS_deploy="πŸš€,Deployment,GREEN"
TASK_HELP_NS_db="πŸ—„ ,Database,BLUE"

Multi-line env vars: Use \n (backslash + n) as a newline escape in TASK_HELP_PRELOG and TASK_HELP_EPILOG. task-help expands \n β†’ real newline. In Taskfile.yml, YAML block scalars (|) are the cleanest option.

CLI options

task_help.py --header "My Project" --subtitle "v1.2.3"
task_help.py --prelog "[bold]First time?[/bold]\n  1. task install"
task_help.py --epilog "[dim]Run task --list for more.[/dim]"
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
task_help.py --show-task-count               # show "(N tasks)" badge on group headers

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 STRIKETHROUGH

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


Prelog & Epilog

Two optional free-text blocks for embedding usage guides or quick-start notes directly in the task listing output:

Option Position Purpose
prelog After the Usage block, before the first group Quick-start steps, "first time?" guides
epilog After the footer (very end of output) Examples, links, further reading

Both support Rich Console Markup (see below) and all four configuration methods: config file, env vars, CLI options, and stdin.

Example output with prelog & epilog

──────────────────────────────────────────────────────────────────────
  SSH Project β€” Task Runner
──────────────────────────────────────────────────────────────────────

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

  First time?
    1. task scripts:install    β€” install task-help pretty-printer
    2. task install            β€” interactive setup wizard
    3. task ssh:test           β€” verify the setup

 πŸ› οΈ   Setup & Maintenance
  ────────────────────────────────────────────────────────────
    clean    Remove sensitive artifacts
    install  Interactive setup wizard

  ...

  12 tasks total  Β·  Run task --list for full details

  LEARN MORE
    Use task --list for the full task list.
    Read the README at github.com/you/project

Rich Console Markup

Both prelog and epilog accept Rich Console Markup for styling with colours, bold, italic, underline, and strikethrough.

If rich is installed (e.g. pip install rich), it is used automatically for full markup support including 256-colours, background colours, and clickable links.

Without rich, a built-in fallback renderer handles the most common tags:

Markup Effect
[bold]text[/bold] (or [b]) Bold
[italic]text[/] (or [i]) Italic
[underline]text[/] (or [u]) Underlined
[strike]text[/] (or [s], [strikethrough]) Strikethrough
[dim]text[/] Dimmed / muted
[bold italic green]text[/] Compound styles β€” combine in one tag
[red] [green] [cyan] [blue] [magenta] [yellow] [white] Standard colours
[bright_red] [bright_green] [bright_cyan] … Bright colour variants
[gray] / [grey] Dark gray
[/] Close last open tag (shorthand)
[/tagname] Close a specific tag
\[literal] Literal [ β€” escape to prevent markup parsing

JSON config example

{
  "prelog": "[bold]First time?[/bold]\n  1. [green]task scripts:install[/green]    β€” install task-help\n  2. [green]task install[/green]            β€” interactive setup wizard",
  "epilog": "[dim]LEARN MORE\n  Run [green]task --list[/green] for all tasks.[/dim]"
}

YAML config example (recommended for multi-line)

# .task-help.yml
header: "My Project"
prelog: |
  [bold]First time?[/bold]
    1. [green]task scripts:install[/green]    β€” install task-help
    2. [green]task install[/green]            β€” interactive setup wizard
    3. [green]task test[/green]               β€” run all tests

epilog: |
  [bold]EXAMPLES[/bold]
    [green]task build[/green]
    [green]task test[/green]
    [green]task deploy:staging[/green]

  [bold]LEARN MORE[/bold]
    Use [green]task --list[/green] for the full task list.

Taskfile.yml env-var example

tasks:
  default:
    silent: true
    vars:
      PRELOG: |
        [bold]First time?[/bold]
          1. task install
          2. task test
    cmds:
      - TASK_HELP_PRELOG="{{.PRELOG}}" {{.TASK_HELP_SCRIPT}}

Nerd Fonts

Nerd Font glyphs work as plain Unicode characters β€” paste them directly. No special configuration needed; they render in any Nerd Font terminal:

prelog: |
   Quick start (requires Nerd Font)
    1. task install
    2. task test

Update notifications

task-help can check for newer Gist versions and either show a hint or auto-update. The check uses git ls-remote against the Gist remote and is cached for 24 hours (~/.cache/task-help/update-check.json) to avoid network overhead on every run.

TASK_HELP_UPDATE_CHECK Behaviour
on (default) Show a hint at the end of output when a newer version exists
off Disable all update checks
auto Auto-update before displaying the task list (prints a patience message)
# Disable the check for this run:
TASK_HELP_UPDATE_CHECK=off task

# Always auto-update in CI / shared environments:
TASK_HELP_UPDATE_CHECK=auto task

# Permanently disable in your project Taskfile.yml:
# vars:
#   TASK_HELP_UPDATE_CHECK: "off"

The update check only runs when task-help is installed as a git clone at ~/.taskfiles/taskscripts/task-help/ (the default install path). It is silently skipped if that directory does not exist.


Namespaces

How task names map to groups

task-help groups tasks by the namespace prefix β€” the segment before the first : in the task name. Tasks without a : go into the special _top group (shown as "Core / Setup" by default).

Task name Namespace / group
build _top (Core / Setup)
test _top (Core / Setup)
deploy:staging deploy
deploy:prod deploy
docker:up docker

Example Taskfile.yml

version: "3"
tasks:
  build:
    desc: "Build the project"           # β†’ _top group

  test:
    desc: "Run tests"                   # β†’ _top group

  deploy:staging:
    desc: "Deploy to staging"           # β†’ deploy group
    aliases: [ds]

  deploy:prod:
    desc: "Deploy to production"        # β†’ deploy group
    aliases: [dp]

  docker:up:
    desc: "Start containers"            # β†’ docker group

  docker:down:
    desc: "Stop containers"             # β†’ docker group

Example config file

Match this Taskfile with a .task-help.json (or .task-help.yml):

{
  "header": "My Project β€” Task Runner",
  "namespaces": {
    "_top":   ["βš™οΈ ", "My Tasks",   "CYAN"],
    "deploy": ["πŸš€",  "Deployment",  "GREEN"],
    "docker": ["🐳",  "Docker",      "BLUE"]
  }
}

Prefer groups over namespaces β€” the namespaces key still works (backward compatible) but is deprecated since v1.1. Use the groups key for new configs; it supports rich descriptors with includes, parent, note, order, and per-group show_summary. See Rich group descriptors below.

Or in YAML (.task-help.yml):

header: "My Project β€” Task Runner"
namespaces:
  _top:   ["βš™οΈ ", "My Tasks",  "CYAN"]
  deploy: ["πŸš€",  "Deployment", "GREEN"]
  docker: ["🐳",  "Docker",     "BLUE"]

If a task's namespace is not listed in the config, task-help falls back to β–Ά Deploy (title-cased from the key) in white.

Sub-namespaces (nested groups)

Define sub-namespaces by using : in the namespace key. task-help will group tasks by the longest matching prefix and display sub-groups visually nested under their parent:

{
  "namespaces": {
    "deploy":         ["πŸš€", "Deployment",          "GREEN"],
    "deploy:staging": ["🌐", "Staging Environment", "CYAN"],
    "deploy:prod":    ["πŸ”΄", "Production",           "RED"]
  }
}

With a Taskfile containing:

tasks:
  deploy:staging:run:
    desc: "Run staging deployment"

  deploy:staging:rollback:
    desc: "Rollback staging"

  deploy:prod:run:
    desc: "Run production deployment"

The output groups deploy:staging:* under deploy:staging and deploy:prod:* under deploy:prod, both nested after the deploy header:

  πŸš€  Deployment
  ────────────────────────────────────────────────────────────

    🌐  Staging Environment
    ────────────────────────────────────────────────────────
      deploy:staging:rollback  Rollback staging
      deploy:staging:run       Run staging deployment

    πŸ”΄  Production
    ────────────────────────────────────────────────────────
      deploy:prod:run          Run production deployment

Backward compatible: if no sub-namespaces are defined in the config, tasks are grouped exactly as before (by the first : segment).


Rich group descriptors (groups key)

Added in v1.1. The old namespaces key still works but is deprecated β€” migrate to groups for access to includes, parent, note, order, and per-group show_summary. Planned removal of namespaces in v2.0.

The groups config key accepts rich descriptor objects instead of plain ["emoji", "label", "COLOR"] arrays. Short-form arrays still work inside groups for gradual migration.

All fields

Field Type Default Description
emoji string "β–Ά" Leading icon in the section header
label string key name Human-readable section title
color string "WHITE" ANSI color name (CYAN, GREEN, …) or code
includes string[] [] Explicit task membership patterns (see below)
excludes string[] [] Patterns that opt tasks OUT of this group
parent string|null null Key of a parent group for visual nesting
note string "" Dim hint line shown before task rows
show_summary bool|null null Per-group summary override (null = inherit)
order int|null null Explicit sort position (lower = earlier)

Pattern matching (includes / excludes)

Task membership is resolved in priority order:

  1. Explicit includes patterns β€” checked deepest group first (child before parent).
  2. Longest colon-prefix β€” if no includes match, the standard colon-prefix rule applies.
  3. _top catch-all β€” tasks with no : that matched no includes rule.

Pattern rules (same for includes and excludes):

Pattern Matches
"deploy" Exact task "deploy" or any task starting with "deploy:"
"deploy:prod" Exact "deploy:prod" or any task starting with "deploy:prod:"
"deploy:prod:*" Glob β€” any task matching that glob (fnmatch)
"ci" Exact "ci" or any "ci:*" task

JSON example

{
  "header": "My Monorepo",
  "top_exclude": ["default"],
  "groups": {
    "_top": {
      "emoji": "βš™οΈ ", "label": "Core", "color": "CYAN",
      "order": 1
    },
    "ci-cd": {
      "emoji": "πŸ”", "label": "CI / CD", "color": "BLUE",
      "note": "Trigger full pipeline with 'task ci-cd:all'",
      "order": 2
    },
    "deploy-staging": {
      "emoji": "🌐", "label": "Staging",    "color": "CYAN",
      "parent": "ci-cd",
      "includes": ["deploy:staging"]
    },
    "deploy-prod": {
      "emoji": "πŸ”΄", "label": "Production", "color": "RED",
      "parent": "ci-cd",
      "includes": ["deploy:prod"],
      "show_summary": true,
      "order": 10
    },
    "schema": {
      "emoji": "πŸ“", "label": "Schema & Config", "color": "MAGENTA",
      "includes": ["validate", "generate:schema", "migrate:*"],
      "excludes": ["migrate:rollback"]
    }
  }
}

YAML example

header: "My Monorepo"
top_exclude: [default]
groups:
  _top:
    emoji: "βš™οΈ "
    label: "Core"
    color: CYAN
    order: 1

  ci-cd:
    emoji: "πŸ”"
    label: "CI / CD"
    color: BLUE
    note: "Trigger full pipeline with 'task ci-cd:all'"
    order: 2

  deploy-staging:
    emoji: "🌐"
    label: "Staging"
    color: CYAN
    parent: ci-cd
    includes: [deploy:staging]

  deploy-prod:
    emoji: "πŸ”΄"
    label: "Production"
    color: RED
    parent: ci-cd
    includes: [deploy:prod]
    show_summary: true
    order: 10

Output

  βš™οΈ   Core
  ────────────────────────────────────────────────────────────

  πŸ”  CI / CD
    Trigger full pipeline with 'task ci-cd:all'

    🌐  Staging
    ────────────────────────────────────────────────────────
      deploy:staging:rollback  Rollback staging
      deploy:staging:run       Run staging deployment

    πŸ”΄  Production
    ────────────────────────────────────────────────────────
      deploy:prod:run  Run production deployment
        β”‚ Full production rollout β€” requires PR approval first.

Migration from namespaces

Old namespaces format β€” still works:

{
  "namespaces": {
    "deploy": ["πŸš€", "Deployment", "GREEN"]
  }
}

New groups format β€” equivalent:

{
  "groups": {
    "deploy": { "emoji": "πŸš€", "label": "Deployment", "color": "GREEN" }
  }
}

Mixed migration (add groups for new entries, leave old namespaces intact):

{
  "namespaces": { "docker": ["🐳", "Docker", "BLUE"] },
  "groups": {
    "deploy": { "emoji": "πŸš€", "label": "Deployment", "color": "GREEN",
                "includes": ["deploy:staging", "deploy:prod"] }
  }
}

Both keys are merged; groups entries override namespaces entries with the same key.

TASK_HELP_GROUPS env var

Accepts a JSON object of rich descriptors, merged into cfg.groups:

TASK_HELP_GROUPS='{"schema":{"emoji":"πŸ“","label":"Schema","color":"MAGENTA","includes":["validate","migrate"]}}'

Group ordering

By default groups appear in the insertion order of cfg.namespaces (built-in defaults first, then user-defined keys). Use namespace_order to impose an explicit order:

{
  "namespace_order": ["_top", "build", "test", "lint", "deploy", "docker"]
}

Groups not listed in namespace_order are appended after the ordered groups, sorted alphabetically.


Task count badge

Show the number of tasks in each group header separator:

{ "show_task_count": true }
  πŸš€  Deployment
  ────────────────────────────────────────────────────────────  (3 tasks)
    deploy:run    ...

Also available via TASK_HELP_SHOW_TASK_COUNT=1 or --show-task-count.


Filtering the catch-all group

The _top group collects all tasks without a colon prefix. To hide noise tasks (e.g. the default task that just invokes task-help itself):

{ "top_exclude": ["default"] }

_default, _root, and "" are also accepted as aliases for _top in the namespaces key β€” they all map to the same built-in catch-all group.


Selective summaries

Show summaries for specific namespaces even when the global show_summary is off:

{ "show_summary_for": ["install", "setup"] }

This is useful when only a few groups have long, important summaries.


Alias noise suppression

Suppress aliases that match a regex pattern. A common pattern is hiding single-token aliases (e.g. dev, mcp-server) that appear as noisy "shorthand for the namespace default task" entries:

{ "hide_alias_patterns": ["^\\w+$"] }

^\\w+$ matches any alias containing only word characters (letters, digits, underscore) with no spaces or special chars. Task-level aliases that are genuinely multi-token (e.g. deploy:prod) are preserved.


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)
name task_help_dev
description Expert agent for developing and maintaining the task-help Taskfile pretty-printer.
applyTo **
priority high

task-help β€” Development Agent

You are an expert developer for the task-help project: a zero-dependency Python script (PEP 723 / uv run) that pretty-prints Taskfile tasks grouped by namespace with ANSI colours and emoji headers.

Persona

  • You specialise in Python script development, ANSI terminal output, and Taskfile tooling
  • You understand the PEP 723 inline-script metadata format and the uv runner
  • Your primary responsibility: extend and maintain task_help.py while keeping it backward compatible

Project Knowledge

Tech Stack:

  • Python β‰₯ 3.11 (PEP 723 uv inline script β€” #!/usr/bin/env -S uv run)
  • PyYAML (optional runtime dep β€” pyyaml in PEP 723 dependencies)
  • Rich (optional β€” graceful fallback when absent)
  • ANSI escape codes (hardcoded constants; no curses)
  • Taskfile v3 (task runner β€” used for lifecycle management and examples)

File Structure:

File Purpose Update when…
task_help.py Main script β€” all display, config, and runtime logic Adding/changing any feature
README.md User documentation Any user-visible change: new env var, config key, CLI option, feature, or behavior change
CHANGELOG.md Version history Every commit β€” always add an entry under ## [Unreleased]
Taskfile.example.yml Copy-paste wiring examples for projects New features need a working example here
Taskfile.task-help.yml Lifecycle tasks (install/update/remove/status) New lifecycle tasks or changed variable names
Taskfile.taskscripts.yml Orchestrator template β€” deployed to ~/.taskfiles/ Structural changes to the orchestrator only
install.sh One-liner installer Changes to install paths, new deploy steps

Key References:

  • Python Development Guide: <INSTRUCTION_STORAGE>/guidelines/software-development.guides/python/
  • Taskfile Guide: <INSTRUCTION_STORAGE>/guidelines/software-development.guides/taskfile/

βœ… Backward Compatibility β€” CRITICAL

ALWAYS preserve backward compatibility. This script is installed globally and used across many projects.

  • Never remove existing env vars, CLI options, or config keys β€” deprecate with a warning at most
  • Never change the default output format in a breaking way
  • Never add required configuration β€” all new options must have sensible defaults
  • Keep dependencies = ["pyyaml"] in the PEP 723 header (pyyaml may be absent; the code handles it gracefully)
  • Keep the 5-source config priority chain: defaults β†’ config file β†’ stdin β†’ env vars β†’ CLI

File Checklist β€” Run on Every Change

Before committing, verify each file that applies to your change:

1. task_help.py

  • New config key supported in all 5 sources: DEFAULT_NAMESPACE_META / Config default, apply_config_dict(), stdin, env var in build_config(), CLI flag in build_arg_parser()
  • New env var documented in the module docstring (TASK_HELP_… block)
  • New CLI flag documented in the module docstring (CLI options block)
  • PEP 723 dependencies list up to date

2. README.md

  • New env var added to the Environment variables table
  • New CLI flag added to the CLI options section
  • New config file key added to the Config file example (JSON + YAML)
  • New feature has a prose explanation and/or example

3. CHANGELOG.md

  • Entry added under ## [Unreleased] describing what changed (Added / Changed / Fixed / Removed)

4. Taskfile.example.yml

  • New feature has a working, copy-paste example task

5. Taskfile.task-help.yml (only if lifecycle tasks change)

  • New task added with desc: and correct variable naming (TASK_HELP_<PROPERTY>)

Commands

# Run task-help against any Taskfile
python3 task_help.py                      # uses task in $PATH
uv run task_help.py                       # explicit uv runner

# Lifecycle (from any directory after install)
task task-help:install                    # install/reinstall
task task-help:update                     # pull latest from Gist
task task-help:status                     # show installed version
task task-help:demo                       # demo prelog/epilog

# Git
git --no-pager log --oneline -10
git --no-pager diff
git add -A && git commit -m "feat: ..." && git push

Naming Conventions

  • Env vars: TASK_HELP_<PROPERTY> (e.g. TASK_HELP_HEADER, TASK_HELP_UPDATE_CHECK)
  • Taskfile vars: TASK_HELP_<PROPERTY> with | default for overridable values
  • Python functions: snake_case; private helpers prefixed with _
  • Config file keys: snake_case strings (e.g. show_summary, alias_color)

Code Style Examples

βœ… Good β€” new config option wired through all 5 sources:

# 1. Default in Config dataclass
@dataclass
class Config:
    my_option: bool = False

# 2. Config file (apply_config_dict)
if "my_option" in data:
    cfg.my_option = bool(data["my_option"])

# 3. Env var (build_config)
if os.environ.get("TASK_HELP_MY_OPTION", "").strip().lower() in _truthy:
    cfg.my_option = True

# 4. CLI (build_arg_parser)
p.add_argument("--my-option", action="store_true", help="...")

# 5. CLI application (build_config)
if args.my_option:
    cfg.my_option = True

❌ Anti-pattern β€” option only wired in one place:

# Bad: only checked in main(), not part of Config, not in config file
if os.environ.get("TASK_HELP_MY_OPTION"):
    do_thing()

βœ… Good β€” graceful optional import:

try:
    import yaml
    _HAS_YAML = True
except ImportError:
    _HAS_YAML = False

Workflows & Procedures

For Any Feature / Fix

  1. Understand: Read the relevant section of task_help.py and README.md
  2. Implement: Make changes in task_help.py following the 5-source wiring pattern
  3. Update docs: README.md (env vars, CLI, config), CHANGELOG.md, Taskfile.example.yml
  4. Test: Run task_help.py against Taskfile.example.yml and verify output
  5. Commit: git add -A && git commit -m "<type>: <description>" with Co-authored-by trailer
  6. Push: git push

For README.md Updates

  • Add new env vars to the Environment variables section (keep alphabetical order within each group)
  • Add new config keys to both the JSON and YAML config file examples
  • Add new features with a short description and example before pushing

For CHANGELOG.md

  • Always update ## [Unreleased] β€” never modify a dated entry
  • Use Added / Changed / Fixed / Removed headings

Boundaries

βœ… Always Do

  • Add CHANGELOG.md entry for every commit
  • Update README.md env vars and config sections when adding any new TASK_HELP_… variable
  • Wire new config options through all 5 sources (defaults, file, stdin, env, CLI)
  • Preserve backward compatibility β€” never break existing config or output format
  • Use Ask User tool when requirements are ambiguous

⚠️ Ask First

  • Changing the default output format or column widths
  • Adding an external dependency to PEP 723 dependencies
  • Modifying Taskfile.task-help.yml install/update/remove logic
  • Changing env var names or config key names (breaking change)

🚫 Never Do

  • Remove or rename existing env vars, CLI options, or config keys
  • Add required configuration (all new options must have defaults)
  • Skip updating CHANGELOG.md
  • Skip updating README.md env vars/config sections after adding a new TASK_HELP_… var
  • Commit __pycache__/ or .pyc files
  • Change the DEFAULT_NAMESPACE_META entries in a way that overrides user customisation

Changelog

All notable changes to task-help are documented here.


[Unreleased]

Added

  • GroupDescriptor dataclass (ADR-003) β€” each namespace/group entry is now a rich descriptor object with fields for display metadata AND membership rules AND layout hints:

    • emoji, label, color β€” display metadata (same as old array format)
    • includes β€” explicit task membership patterns (exact-or-prefix + fnmatch glob via */?). When set, tasks matching any pattern land in this group (first-match wins; deepest groups checked before their parents).
    • excludes β€” patterns that prevent a task from landing in this group even if it also matches includes.
    • parent β€” key of another group for visual nesting without requiring a shared colon prefix (layout-only; does not affect membership).
    • note β€” dim hint line rendered after the separator, before task rows; also shown on header-only parent groups.
    • show_summary β€” per-group override for summary display (true/false/null; null inherits the global show_summary setting).
    • order β€” explicit integer sort position (lower = earlier); supersedes namespace_order for individual groups.
  • groups config file key β€” the new preferred key for namespace/group definitions. Both groups and namespaces are accepted; values are merged in order. The namespaces key remains fully supported for backward compat.

    Short-form arrays (["emoji","label","COLOR"]) remain valid everywhere β€” they are parsed into GroupDescriptors with only the display fields set.

    Rich descriptor form (JSON):

    "groups": {
      "deploy": {
        "emoji": "πŸš€", "label": "Deployment", "color": "GREEN",
        "note": "Run 'task deploy:rollback' to undo", "order": 2
      },
      "deploy:staging": {
        "emoji": "🌐", "label": "Staging", "color": "CYAN",
        "parent": "deploy",
        "includes": ["deploy:staging"]
      }
    }
  • TASK_HELP_GROUPS env var β€” JSON object of rich group descriptors, merged into cfg.groups. Mirrors TASK_HELP_NS but accepts the full descriptor dict form in addition to the short array.

  • _get_display_depth() helper β€” computes visual nesting depth for a group considering both colon-prefix chains AND explicit parent field links. Cycle-safe. Used by print_group() for indentation and by group_tasks() for includes-matching priority.

  • __version__ = "1.1.0" β€” script version string, accessible at runtime.

  • fnmatch glob support in includes patterns β€” patterns containing * or ? are matched via fnmatch.fnmatch (built-in, no extra dependency).

  • Depth-sorted includes matching β€” groups are checked for includes membership in depth-descending order (deepest / most-specific first). This ensures child groups with parent links capture their intended tasks before a parent's broader includes patterns can match.

  • pyyaml added to PEP 723 inline dependencies β€” pyyaml is now listed in the # dependencies = [...] block so uv run installs it automatically. The YAML config file feature (.task-help.yaml / .task-help.yml) now works out-of-the-box without a separate pip install pyyaml.

  • Update notifications β€” task-help now checks whether a newer version of the Gist is available and shows a hint at the end of output.

    • Controlled by TASK_HELP_UPDATE_CHECK env var: on (default), off, or auto.
    • on: show a one-line hint after the epilog when a newer commit exists.
    • off: disable all checks.
    • auto: auto-update before displaying the task list (prints a patience message).
    • Results are cached for 24 hours at ~/.cache/task-help/update-check.json to avoid a network call on every invocation.
    • The check is silently skipped when task-help is not installed as a git clone.
  • Nested namespace / sub-group support β€” namespace keys with : are now treated as sub-namespaces. Tasks are grouped by the longest matching prefix and sub-groups are displayed visually nested after their parent group, with proportionally reduced indentation for each nesting level. Example config:

    "namespaces": {
      "deploy":         ["πŸš€", "Deployment",          "GREEN"],
      "deploy:staging": ["🌐", "Staging Environment", "CYAN"],
      "deploy:prod":    ["πŸ”΄", "Production",           "RED"]
    }

    Fully backward compatible β€” if no sub-namespaces are configured, behaviour is identical to the previous single-segment grouping.

  • _ordered_groups() helper β€” internal function that computes the display order with sub-groups immediately after their parent group. Parents with no direct tasks now render as a heading-only anchor so sub-groups remain visually anchored under a labelled section. Notes are shown on header-only parents too.

  • namespace_order config key β€” explicit display order for namespace groups. namespace_order is a JSON array of namespace keys in the desired sequence. Groups not listed are appended after, sorted alphabetically. Also: TASK_HELP_NAMESPACE_ORDER='["_top","build","deploy"]' env var.

  • show_task_count config key β€” show (N tasks) dim badge on each group header separator line. Default: false. Also: TASK_HELP_SHOW_TASK_COUNT=1 env var and --show-task-count CLI flag.

  • top_exclude config key β€” list of task names to hide from the _top catch-all group. Useful to hide the default task that merely invokes task-help itself. Also accepts _top_exclude spelling. Env: TASK_HELP_TOP_EXCLUDE='["default"]'.

  • show_summary_for config key β€” list of namespace keys for which summaries are shown even when the global show_summary is false. Env: TASK_HELP_SHOW_SUMMARY_FOR='["install"]'.

  • hide_alias_patterns config key β€” list of Python regex patterns; aliases matching any pattern are suppressed from display. Useful to hide single-token "namespace shorthand" aliases (e.g. ["^\\w+$"]). Env: TASK_HELP_HIDE_ALIAS_PATTERNS='["^\\\\w+$"]'.

  • _top aliases β€” _default, _root, and "" are now accepted as keys in the namespaces/groups config and all map transparently to _top. Allows more intuitive config file keys for the catch-all group.

  • AGENTS.md β€” new file at the project root that guides AI coding agents on the development conventions, file checklist, and backward-compatibility rules.

Changed

  • group_tasks() updated: assignment priority is now (1) explicit includes matching, (2) colon-prefix fallback, (3) _top catch-all. The function accepts a Config argument (needed to resolve groups descriptors).
  • print_group() now computes indentation via _get_display_depth() which considers both colon-prefix depth and explicit parent field chains.
  • _ordered_groups() now accepts a cfg parameter. Display order respects GroupDescriptor.order (explicit integer) and GroupDescriptor.parent (explicit parent nesting) in addition to the existing colon-prefix nesting.
  • Taskfile.example.yml extended with nested namespace task examples (deploy:staging:run, deploy:staging:rollback, deploy:prod:run).
  • README.md β€” new sections: Update notifications, Namespaces (with example Taskfile.yml and config), Rich group descriptors, Group ordering, Task count badge, Filtering the catch-all group, Selective summaries, Alias noise suppression.

Deprecated

  • Config.namespaces β€” deprecated since v1.1.0. The namespaces config file key and Config.namespaces Python field continue to work but are superseded by groups/Config.groups. Planned removal in v2.0.

[Unreleased β€” before this entry]

Added

  • README example output for prelog/epilog β€” new annotated code block showing a full run with both a prelog quick-start guide and an epilog footer.

  • task-help:demo task in Taskfile.task-help.yml β€” runs task_help.py with a Rich-markup prelog and epilog so users can preview the feature without editing their own Taskfile.


[2026-03-18]

Added

  • prelog message block β€” optional free-text block rendered after the Usage section and before the first group header. Ideal for quick-start guides, "first time?" checklists, and navigation hints. Configurable via:

    • Config file key: "prelog" (string or list of strings; YAML | block scalar recommended for multi-line)
    • Environment variable: TASK_HELP_PRELOG (\n is expanded to a real newline)
    • CLI option: --prelog TEXT
    • Stdin: "prelog" key in the piped JSON/YAML object
  • epilog message block β€” optional free-text block rendered at the very end of the output (after the footer). Ideal for examples, further reading, and links. Same configuration sources as prelog ("epilog", TASK_HELP_EPILOG, --epilog).

  • Rich Console Markup support in prelog and epilog β€” both blocks accept Rich markup syntax: [bold], [italic], [underline], [strike]/[s], [dim], colour names ([red], [bright_cyan], …), compound styles ([bold green]text[/]), and [/] / [/tagname] closing tags.

    • If the rich library is installed it is used automatically for full support.
    • Without rich, a built-in ANSI fallback handles all common tags with zero external dependencies.
  • STRIKETHROUGH ANSI code (\033[9m) added to the ANSI constants and COLOR_MAP. Accessible as STRIKETHROUGH and via Rich markup [strike] / [s] / [strikethrough].

  • Nerd Font glyphs work out of the box β€” paste them directly as Unicode in prelog, epilog, or any string value; no special configuration needed.

Changed

  • import re and import io added to the standard-library imports (no new external dependencies).
  • apply_config_dict now handles "prelog" and "epilog" keys; list values are joined with \n.
  • build_config reads TASK_HELP_PRELOG / TASK_HELP_EPILOG env vars and --prelog / --epilog CLI flags; literal \n sequences are expanded to real newlines in env-var and CLI values.
  • build_arg_parser gains --prelog and --epilog arguments.
  • README.md updated with prelog/epilog documentation, Rich markup reference table, YAML/JSON/env-var examples, Nerd Font notes, and STRIKETHROUGH in the color names list.
  • Taskfile.example.yml updated with prelog/epilog examples for all three configuration approaches (env var, config file, CLI).

[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 updated β€” default 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 palette β€” BRIGHT_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 or updates ~/.taskfiles/Taskfile.taskscripts.yml
# (auto-adds task-help include to existing orchestrator)
# 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"
TOOL_NAME="task-help"
# ── 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 or update Taskfile.taskscripts.yml orchestrator ───────────
echo ""
add_to_orchestrator() {
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
#
# 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 ───────────────────────────────────────────────────
ORCH_EOF
dim " βœ” ${ORCH}"
green "βœ… Orchestrator created with task-help."
else
# Check if already included
if grep -q "^ task-help:" "${ORCH}"; then
dim " β„Ή task-help already in orchestrator."
else
bold "βž• Adding task-help to existing orchestrator..."
# Create a temporary file with the new include added
awk '
/^ # ── add more tools here/ {
print " # ── task-help β€” pretty Taskfile task listing ──────────────────────────────"
print " # Exposes: task-help:install task-help:update task-help:remove task-help:status"
print " task-help:"
print " taskfile: taskscripts/Taskfile.task-help.yml"
print " optional: true"
print ""
}
{ print }
' "${ORCH}" >"${ORCH}.tmp" && mv "${ORCH}.tmp" "${ORCH}"
dim " βœ” Added task-help to ${ORCH}"
green "βœ… Orchestrator updated."
fi
fi
}
add_to_orchestrator
# ── step 4: check global Taskfile ────────────────────────────────────────────
echo ""
bold "πŸ” Checking global Taskfile..."
GLOBAL_TASKFILE="${HOME}/.taskfiles/Taskfile.yml"
if [ -f "${GLOBAL_TASKFILE}" ]; then
if grep -q "Taskfile.taskscripts.yml" "${GLOBAL_TASKFILE}"; then
green "βœ… Global Taskfile already includes taskscripts orchestrator."
else
yellow "⚠️ Your global Taskfile doesn't include the orchestrator yet."
dim " Add this to ${GLOBAL_TASKFILE}:"
echo ""
dim ' includes:'
dim ' taskscripts:'
dim ' taskfile: Taskfile.taskscripts.yml'
dim ' optional: true'
dim ' flatten: true'
dim ' dir: ~/.taskfiles'
echo ""
fi
else
yellow "⚠️ No global Taskfile found at ${GLOBAL_TASKFILE}"
dim " Create one to enable global task commands."
fi
# ── step 5: verify installation ───────────────────────────────────────────────
echo ""
bold "πŸ” Verifying installation..."
if [ -x "${SCRIPT}" ]; then
if "${SCRIPT}" --help >/dev/null 2>&1; then
green "βœ… Installation verified successfully."
else
err "Installation verification failed β€” script exists but may have errors."
fi
else
err "Installation verification failed β€” script not executable."
fi
# ── usage instructions ────────────────────────────────────────────────────────
echo ""
bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
bold " task-help installed successfully!"
bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
bold "πŸ“ Installation Path:"
dim " ${TARGET}"
echo ""
bold "πŸš€ Quick Start:"
dim ' SCRIPT="$HOME/.taskfiles/taskscripts/task-help/task_help.py"'
dim ' if [ -x "$SCRIPT" ]; then "$SCRIPT"; else task --list; fi'
echo ""
bold "πŸ”§ Task Commands (from any directory):"
dim ' task task-help:status # show installed version'
dim ' task task-help:update # pull latest from Gist'
dim ' task task-help:remove # uninstall'
echo ""
bold "πŸ“š Documentation:"
dim " https://gist.github.com/${GIST_ID}"
echo ""
bold "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""
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 β€” uses the new "groups" key, v1.1+):
{
"header": "My Project β€” Task Runner",
"subtitle": "optional description line",
"prelog": "[bold]First time?[/bold]\n 1. task install",
"epilog": "[dim]Run 'task --list' for all tasks.[/dim]",
"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",
"namespace_order": ["_top", "build", "test", "deploy"],
"show_task_count": false,
"top_exclude": ["default"],
"show_summary_for": ["install"],
"hide_alias_patterns": ["^\\w+$"],
"groups": {
"_top": {"emoji": "βš™οΈ ", "label": "My Tasks", "color": "CYAN", "order": 1},
"deploy": {
"emoji": "πŸš€",
"label": "Deployment",
"color": "GREEN",
"order": 2,
"includes": ["deploy"],
"note": "Run 'task deploy:prod' to release to production"
},
"deploy:staging": {"emoji": "🌐", "label": "Staging", "color": "CYAN", "parent": "deploy"},
"deploy:prod": {"emoji": "πŸ”΄", "label": "Production", "color": "RED", "parent": "deploy"}
}
}
Short-form arrays still work (backward compat β€” "namespaces" key or in "groups"):
"namespaces": { "deploy": ["πŸš€", "Deployment", "GREEN"] }
Group descriptor object fields (all optional except emoji/label/color):
emoji : Leading icon in the group header
label : Section title
color : ANSI colour name or code
includes : List of task/prefix patterns captured by this group
(exact-or-prefix match; glob patterns supported with * and ?)
excludes : Patterns that opt tasks OUT of this group even if includes matches
parent : Key of another group β€” renders this group nested after its parent
(layout hint only; does not affect task membership)
note : Dim hint line shown after the separator, before task rows
show_summary : Per-group summary override (true/false; null = inherit global)
order : Explicit sort position integer (lower = earlier in output)
Sub-namespaces β€” tasks are grouped by the longest matching namespace key:
A task named "deploy:staging:run" is placed in the "deploy:staging" group
(if that key exists), otherwise in "deploy". Sub-groups are displayed nested
after their parent group. The "parent" field enables the same visual nesting
for groups whose keys do NOT share a colon prefix.
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_PRELOG="..." Message after Usage, before first group (Rich markup OK)
TASK_HELP_EPILOG="..." Message at the very end (Rich markup OK)
TASK_HELP_NS='{"k":["e","l","C"]}' JSON object merged into namespaces (old style)
TASK_HELP_GROUPS='{"k":{...}}' JSON object merged into groups (new rich-descriptor style)
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
TASK_HELP_UPDATE_CHECK=on|off|auto Update notifications (default: on)
on β€” show a hint at the end when a newer version exists
off β€” disable all update checks
auto β€” auto-update before running (prints patience message)
TASK_HELP_NAMESPACE_ORDER='["ns1","ns2"]' JSON array: explicit group display order
TASK_HELP_SHOW_TASK_COUNT=1 Show task count badge on each group header
TASK_HELP_TOP_EXCLUDE='["default"]' JSON array: task names to hide from _top group
TASK_HELP_SHOW_SUMMARY_FOR='["ns"]' JSON array: namespaces that always show summaries
TASK_HELP_HIDE_ALIAS_PATTERNS='["^\\w+$"]' JSON array of regex patterns; matching
aliases are suppressed (e.g. hide single-word aliases)
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
--prelog TEXT Message after Usage block, before first group
--epilog TEXT Message at the very end of output
--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
--show-task-count Show '(N tasks)' badge on each group header
--stdin Force-read JSON config from stdin
Rich Markup (prelog / epilog)
─────────────────────────────
Both prelog and epilog support Rich Console Markup syntax:
https://rich.readthedocs.io/en/stable/markup.html
If the `rich` library is installed it is used automatically; otherwise a built-in
ANSI fallback handles the most common tags:
Style: [bold]text[/bold] [italic]text[/] [underline]text[/]
[dim]text[/] [strike]text[/] (alias: [s], [strikethrough])
Colors: [red] [green] [yellow] [blue] [magenta] [cyan] [white]
Bright: [bright_red] [bright_green] [bright_cyan] [bright_blue] …
Combo: [bold green]First time?[/bold green]
Close: [/] closes the last open tag (shorthand for [/tagname])
Nerd fonts work as plain Unicode β€” just paste the glyph: σ°„›
Install rich for full markup support (256-colours, links, etc.):
pip install rich (or: uv add rich / uv pip install rich)
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 fnmatch
import io
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
__version__ = "1.1.0"
# ── ANSI colour codes ─────────────────────────────────────────────────────────
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
ITALIC = "\033[3m"
UNDERLINE = "\033[4m"
STRIKETHROUGH = "\033[9m"
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, "STRIKETHROUGH": STRIKETHROUGH,
"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"
# ── Update check ──────────────────────────────────────────────────────────────
import time as _time # noqa: E402 (deferred import to keep top of file clean)
_UPDATE_CACHE_PATH = Path.home() / ".cache" / "task-help" / "update-check.json"
_UPDATE_CACHE_TTL = 86400 # 24 hours in seconds
def _get_update_check_mode() -> str:
"""Return 'off', 'on', or 'auto' from TASK_HELP_UPDATE_CHECK env var (default: on)."""
v = os.environ.get("TASK_HELP_UPDATE_CHECK", "on").strip().lower()
return v if v in ("off", "on", "auto") else "on"
def _get_install_dir() -> "Path | None":
"""Return the git clone dir of the installed task-help, or None if not found."""
d = Path.home() / ".taskfiles" / "taskscripts" / "task-help"
return d if (d / ".git").is_dir() else None
def _git_rev_parse(install_dir: "Path") -> "str | None":
"""Return the local HEAD commit hash."""
try:
r = subprocess.run(
["git", "-C", str(install_dir), "rev-parse", "HEAD"],
capture_output=True, text=True, timeout=3,
)
return r.stdout.strip() or None
except Exception:
return None
def _git_ls_remote(install_dir: "Path") -> "str | None":
"""Return the remote HEAD commit hash (requires a network call)."""
try:
r = subprocess.run(
["git", "-C", str(install_dir), "ls-remote", "origin", "HEAD"],
capture_output=True, text=True, timeout=10,
)
parts = r.stdout.strip().split()
return parts[0] if parts else None
except Exception:
return None
def _load_update_cache() -> "dict[str, Any]":
try:
if _UPDATE_CACHE_PATH.exists():
return json.loads(_UPDATE_CACHE_PATH.read_text())
except Exception:
pass
return {}
def _save_update_cache(data: "dict[str, Any]") -> None:
try:
_UPDATE_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_UPDATE_CACHE_PATH.write_text(json.dumps(data))
except Exception:
pass
def check_for_update(install_dir: "Path") -> "str | None":
"""
Check if a newer version of task-help is available on the Gist remote.
Uses a 24-hour cache (~/.cache/task-help/update-check.json) to avoid a
network call on every invocation. Returns the remote commit hash if an
update is available, or None otherwise.
"""
local_hash = _git_rev_parse(install_dir)
if not local_hash:
return None
cache = _load_update_cache()
now = _time.time()
# Re-use cached result if still fresh and taken against the same local version
if (
cache.get("local_hash") == local_hash
and (now - cache.get("checked_at", 0)) < _UPDATE_CACHE_TTL
):
remote_hash = cache.get("remote_hash")
else:
remote_hash = _git_ls_remote(install_dir)
_save_update_cache({
"checked_at": now,
"local_hash": local_hash,
"remote_hash": remote_hash,
})
return remote_hash if (remote_hash and remote_hash != local_hash) else None
def perform_auto_update(install_dir: "Path") -> bool:
"""
Pull the latest version from the Gist remote and make the script executable.
Prints a patience message before starting and a success (or failure) notice.
Returns True on success.
"""
print(f"{YELLOW}πŸ”„ A new version of task-help is available. Auto-updating (please be patient)…{RESET}")
try:
subprocess.run(
["git", "-C", str(install_dir), "fetch", "origin"],
check=True, timeout=60, capture_output=True,
)
subprocess.run(
["git", "-C", str(install_dir), "reset", "--hard", "origin/HEAD"],
check=True, timeout=15, capture_output=True,
)
(install_dir / "task_help.py").chmod(0o755)
_save_update_cache({}) # Invalidate so next run re-checks
print(f"{GREEN}βœ… Updated to latest. Continuing…{RESET}")
print()
return True
except Exception as e:
print(f"⚠️ task-help: auto-update failed: {e}", file=sys.stderr)
return False
def print_update_hint(remote_hash: str, cfg: "Config") -> None:
"""Print a subtle update hint after the normal output."""
D = _c(DIM, cfg)
Y = _c(YELLOW, cfg)
G = _c(GREEN, cfg)
R = _c(RESET, cfg)
print(f"{Y}πŸ’‘ A newer version of task-help is available!{R}")
print(f"{D} Update: {G}task task-help:update{D} (or: TASK_HELP_UPDATE_CHECK=auto to update automatically){R}")
print()
# ── Rich markup support (optional; graceful fallback when rich is not installed)
# Supports Rich Console Markup syntax: https://rich.readthedocs.io/en/stable/markup.html
# If `rich` is installed (e.g. via `pip install rich`) it will be used for full
# markup rendering. Without it, a built-in ANSI fallback handles the common tags.
try:
from rich.console import Console as _RichConsole # type: ignore[import]
_HAS_RICH = True
except ImportError:
_HAS_RICH = False
# Maps Rich markup style names β†’ ANSI escape codes (fallback renderer only)
_MARKUP_STYLE: dict[str, str] = {
"bold": BOLD, "b": BOLD,
"dim": DIM,
"italic": ITALIC, "i": ITALIC,
"underline": UNDERLINE, "u": UNDERLINE,
"strike": STRIKETHROUGH, "s": STRIKETHROUGH, "strikethrough": STRIKETHROUGH,
"red": RED, "green": GREEN, "yellow": YELLOW,
"blue": BLUE, "magenta": MAGENTA, "cyan": CYAN, "white": WHITE,
"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, "bright_black": BRIGHT_BLACK,
"gray": GRAY, "grey": GRAY,
}
# Matches escaped \[ or a markup tag [content]
_MARKUP_RE = re.compile(r'\\\[|(\[(?P<tag>[^\[\]]*)\])')
def _strip_markup(text: str) -> str:
"""Remove all Rich markup tags, returning plain text."""
return _MARKUP_RE.sub(lambda m: "[" if m.group(0) == r"\[" else "", text)
def _markup_to_ansi(text: str) -> str:
"""Convert Rich markup tags to ANSI codes (used when rich library is absent)."""
result: list[str] = []
# Stack of (normalised_tag_name, [ansi_codes]) for close/restore tracking
stack: list[tuple[str, list[str]]] = []
pos = 0
for m in _MARKUP_RE.finditer(text):
result.append(text[pos:m.start()])
pos = m.end()
if m.group(0) == r"\[": # escaped bracket β†’ literal [
result.append("[")
continue
tag = (m.group("tag") or "").strip()
if tag in ("/", ""): # [/] or [] β†’ close last open style
if stack:
stack.pop()
result.append(RESET)
for _, codes in stack:
result.extend(codes)
continue
if tag.startswith("/"): # [/tag] β†’ close matching style
close = tag[1:].strip().lower()
for i in range(len(stack) - 1, -1, -1):
if stack[i][0] == close:
stack.pop(i)
break
result.append(RESET)
for _, codes in stack:
result.extend(codes)
continue
# Skip Rich link tags in fallback (no OSC-8 hyperlinks)
if tag.lower().startswith("link") or tag.lower() == "/link":
continue
# Opening style tag (possibly compound: "bold red")
tag_lower = tag.lower()
codes = [_MARKUP_STYLE[p] for p in tag_lower.split() if p in _MARKUP_STYLE]
if codes:
stack.append((tag_lower, codes))
result.extend(codes)
# Unknown tags are silently ignored (no visible output)
result.append(text[pos:])
if stack:
result.append(RESET)
return "".join(result)
# ── 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),
}
# ── Group descriptor (ADR-003, v1.1) ─────────────────────────────────────────
@dataclass
class GroupDescriptor:
"""Rich group descriptor for a namespace / display group.
Short-form array config ``["emoji", "label", "COLOR"]`` produces a
GroupDescriptor with only the display fields set; all advanced fields
default to their do-nothing values so backward compat is maintained.
Fields
------
key : Internal namespace key (``"deploy"``, ``"_top"``, …)
emoji : Leading emoji / icon shown in the group header
label : Human-readable section title
color : Resolved ANSI color code (empty when no_color is active)
includes : Explicit task membership patterns. If non-empty, this group
captures all tasks matching at least one pattern (first-match
wins across groups). Without ``includes`` the default
colon-prefix rule applies.
excludes : Patterns that prevent a task from landing here even if it
also matches ``includes``.
parent : Key of the parent group for visual nesting without requiring
a shared colon prefix (layout hint only; does not affect
task membership).
note : Short text displayed as dim hint after the separator, before
the task rows.
show_summary : Per-group summary override. ``None`` inherits the global
``cfg.show_summary`` / ``cfg.show_summary_for`` setting.
order : Explicit sort position (lower integer β†’ appears earlier).
Supersedes position in ``namespace_order``.
"""
key: str
emoji: str
label: str
color: str
includes: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list)
parent: str | None = None
note: str = ""
show_summary: bool | None = None # None = inherit from global cfg
order: int | None = None # explicit sort position
# ── 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."""
# ── Group descriptors (ADR-003, v1.1) ─────────────────────────────────────
# Primary storage for namespace / group display metadata. Supersedes
# ``namespaces`` for new code. Populated automatically from
# ``DEFAULT_NAMESPACE_META`` and from any config file / env var that adds or
# overrides namespace entries.
groups: dict[str, GroupDescriptor] = field(default_factory=dict)
# Deprecated since v1.1.0 β€” use Config.groups instead.
# Kept for backward compatibility; planned removal in v2.0.
namespaces: dict[str, tuple[str, str, str]] = field(
default_factory=lambda: dict(DEFAULT_NAMESPACE_META)
)
header: str = "Task Runner"
subtitle: str = ""
prelog: str = "" # shown after Usage block, before first group (Rich markup supported)
epilog: str = "" # shown after all groups / footer (Rich markup supported)
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
# ── New options (added in v1.1) ───────────────────────────────────────────
namespace_order: list[str] = field(default_factory=list)
# ^ Explicit display order for namespace groups; groups not listed appear after, sorted.
show_task_count: bool = False # show "(N tasks)" badge on each group header
top_exclude: list[str] = field(default_factory=list)
# ^ Task names to hide from the catch-all _top group
show_summary_for: list[str] = field(default_factory=list)
# ^ Namespace keys for which summaries are shown even when show_summary=False
hide_alias_patterns: list[str] = field(default_factory=list)
# ^ Regex patterns; aliases matching any pattern are suppressed from display
def __post_init__(self) -> None:
"""Sync cfg.groups from DEFAULT_NAMESPACE_META (namespaces) on construction."""
for key, (emoji, label, color) in self.namespaces.items():
if key not in self.groups:
self.groups[key] = GroupDescriptor(
key=key, emoji=emoji, label=label, color=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 parse_group_entry(key: str, val: Any, no_color: bool = False) -> "GroupDescriptor | None":
"""Parse a namespace/groups config entry into a GroupDescriptor.
Accepts three forms:
* **Array short-form**: ``["emoji", "label", "COLOR"]`` β€” backward compat
* **Comma string**: ``"emoji,label,COLOR"`` β€” backward compat
* **Rich dict**: ``{"emoji": …, "label": …, "color": …, "includes": […], …}``
The special keys ``_default``, ``_root``, and ``""`` are normalised to
``_top`` (the catch-all group). Returns ``None`` if the entry cannot be
parsed.
"""
_top_aliases = {"_default", "_root", ""}
ns_key = "_top" if key in _top_aliases else key
if isinstance(val, (list, tuple)) and len(val) >= 3:
emoji, label, color = str(val[0]), str(val[1]), str(val[2])
return GroupDescriptor(
key=ns_key,
emoji=emoji,
label=label,
color=resolve_color(color, no_color),
)
if isinstance(val, str):
parsed = parse_ns_str(val, no_color)
if parsed:
emoji, label, color = parsed
return GroupDescriptor(key=ns_key, emoji=emoji, label=label, color=color)
return None
if isinstance(val, dict):
emoji = str(val.get("emoji", "β–Ά"))
label = str(val.get("label", ns_key))
color = resolve_color(str(val.get("color", "WHITE")), no_color)
includes = [str(x) for x in val.get("includes", [])]
excludes = [str(x) for x in val.get("excludes", [])]
raw_parent = val.get("parent")
note = str(val.get("note", ""))
raw_ss = val.get("show_summary")
show_summary = bool(raw_ss) if raw_ss is not None else None
raw_order = val.get("order")
order = int(raw_order) if raw_order is not None else None
return GroupDescriptor(
key=ns_key,
emoji=emoji,
label=label,
color=color,
includes=includes,
excludes=excludes,
parent=str(raw_parent) if raw_parent is not None else None,
note=note,
show_summary=show_summary,
order=order,
)
return None
def merge_ns_dict(cfg: Config, raw: dict[str, Any]) -> None:
"""Merge raw namespace definitions into both cfg.groups and cfg.namespaces.
``_default``, ``_root``, and ``""`` are all accepted as backward-compat aliases for
``_top`` (the catch-all group for tasks with no colon prefix).
"""
for key, val in raw.items():
gd = parse_group_entry(key, val, cfg.no_color)
if gd:
cfg.groups[gd.key] = gd
cfg.namespaces[gd.key] = (gd.emoji, gd.label, gd.color)
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 "prelog" in data:
v = data["prelog"]
cfg.prelog = "\n".join(str(l) for l in v) if isinstance(v, list) else str(v)
if "epilog" in data:
v = data["epilog"]
cfg.epilog = "\n".join(str(l) for l in v) if isinstance(v, list) else str(v)
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"])
# ── New 'groups' key (ADR-003, v1.2) β€” rich descriptor objects ───────────
# Both 'namespaces' and 'groups' keys are accepted; values are merged in order.
if isinstance(data.get("groups"), dict):
merge_ns_dict(cfg, data["groups"])
# ── New options (v1.1) ────────────────────────────────────────────────────
if isinstance(data.get("namespace_order"), list):
cfg.namespace_order = [str(x) for x in data["namespace_order"]]
if "show_task_count" in data:
cfg.show_task_count = bool(data["show_task_count"])
if isinstance(data.get("top_exclude"), list):
cfg.top_exclude = [str(x) for x in data["top_exclude"]]
if isinstance(data.get("_top_exclude"), list): # also accept the _top_exclude spelling
cfg.top_exclude = [str(x) for x in data["_top_exclude"]]
if isinstance(data.get("show_summary_for"), list):
cfg.show_summary_for = [str(x) for x in data["show_summary_for"]]
if isinstance(data.get("hide_alias_patterns"), list):
cfg.hide_alias_patterns = [str(x) for x in data["hide_alias_patterns"]]
# ── 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
task_help.py --prelog "[bold]First time?[/bold]\n 1. task install"
task_help.py --epilog "[dim]Run 'task --list' for the full list.[/]"
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",
"prelog": "[bold]First time?[/bold]\\n 1. task install",
"epilog": "[dim]See README for full docs.[/dim]",
"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 STRIKETHROUGH
Rich markup (prelog/epilog): [bold]text[/] [green]text[/] [bold red]text[/]
Install rich for full support: pip install rich
"""
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("--prelog", metavar="TEXT",
help=(
"Message shown after the Usage block, before the first group. "
"Supports Rich markup: [bold]text[/bold], [green]text[/green], etc. "
"Use \\n for newlines in env-var / single-argument form."
))
p.add_argument("--epilog", metavar="TEXT",
help=(
"Message shown at the very end of the output. "
"Supports Rich markup: [bold]text[/bold], [green]text[/green], etc. "
"Use \\n for newlines in env-var / single-argument form."
))
p.add_argument("--show-task-count", action="store_true",
help="Show '(N tasks)' badge next to each group header")
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()}
for gd in cfg.groups.values():
gd.color = ""
# ── 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 = {}
cfg.groups = {}
# ── 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)
# ── 3b. TASK_HELP_GROUPS env var (JSON object, rich group descriptors) ────
_env_groups = os.environ.get("TASK_HELP_GROUPS", "").strip()
if _env_groups:
try:
groups_data = json.loads(_env_groups)
if isinstance(groups_data, dict):
merge_ns_dict(cfg, groups_data)
else:
print("⚠️ task-help: TASK_HELP_GROUPS must be a JSON object.", file=sys.stderr)
except json.JSONDecodeError as e:
print(f"⚠️ task-help: TASK_HELP_GROUPS 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
# ── 9. prelog / epilog (env then CLI β€” CLI wins) ──────────────────────────
# Literal \n in env-var values is expanded to a real newline (YAML block
# scalars and JSON strings already produce real newlines via their parsers).
for attr, env_key in (("prelog", "TASK_HELP_PRELOG"), ("epilog", "TASK_HELP_EPILOG")):
if v := os.environ.get(env_key, ""):
setattr(cfg, attr, v.replace(r"\n", "\n"))
if args.prelog:
cfg.prelog = args.prelog.replace(r"\n", "\n")
if args.epilog:
cfg.epilog = args.epilog.replace(r"\n", "\n")
# ── 10. New v1.1 options (env then CLI) ───────────────────────────────────
_env_ns_order = os.environ.get("TASK_HELP_NAMESPACE_ORDER", "").strip()
if _env_ns_order:
try:
_parsed = json.loads(_env_ns_order)
if isinstance(_parsed, list):
cfg.namespace_order = [str(x) for x in _parsed]
else:
print("⚠️ task-help: TASK_HELP_NAMESPACE_ORDER must be a JSON array.", file=sys.stderr)
except json.JSONDecodeError as e:
print(f"⚠️ task-help: TASK_HELP_NAMESPACE_ORDER invalid JSON β€” {e}", file=sys.stderr)
if os.environ.get("TASK_HELP_SHOW_TASK_COUNT", "").strip().lower() in _truthy:
cfg.show_task_count = True
if getattr(args, "show_task_count", False):
cfg.show_task_count = True
_env_top_excl = os.environ.get("TASK_HELP_TOP_EXCLUDE", "").strip()
if _env_top_excl:
try:
_parsed = json.loads(_env_top_excl)
if isinstance(_parsed, list):
cfg.top_exclude = [str(x) for x in _parsed]
except json.JSONDecodeError:
pass
_env_ssf = os.environ.get("TASK_HELP_SHOW_SUMMARY_FOR", "").strip()
if _env_ssf:
try:
_parsed = json.loads(_env_ssf)
if isinstance(_parsed, list):
cfg.show_summary_for = [str(x) for x in _parsed]
except json.JSONDecodeError:
pass
_env_hap = os.environ.get("TASK_HELP_HIDE_ALIAS_PATTERNS", "").strip()
if _env_hap:
try:
_parsed = json.loads(_env_hap)
if isinstance(_parsed, list):
cfg.hide_alias_patterns = [str(x) for x in _parsed]
except json.JSONDecodeError:
pass
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 _task_matches_includes(task_name: str, patterns: list[str]) -> bool:
"""Test whether *task_name* matches any pattern in *patterns*.
Matching rules:
* Pattern contains ``*`` or ``?``: glob match via :func:`fnmatch.fnmatch`.
* Otherwise: **exact match or namespace-prefix match** β€” ``task_name == pattern``
or ``task_name.startswith(pattern + ":")``. This means ``"deploy:prod"``
matches both the task named exactly ``"deploy:prod"`` *and* any deeper task
like ``"deploy:prod:run"``. Use ``"deploy:prod:run"`` as an exact pattern
(no ``*``) to match only that specific task (and its hypothetical children).
"""
for pattern in patterns:
if "*" in pattern or "?" in pattern:
if fnmatch.fnmatch(task_name, pattern):
return True
else:
if task_name == pattern or task_name.startswith(pattern + ":"):
return True
return False
def group_tasks(tasks: list[dict[str, Any]], cfg: "Config") -> dict[str, list[dict[str, Any]]]:
"""
Group tasks by namespace key.
Assignment priority:
1. **Explicit ``includes``** patterns in a ``GroupDescriptor`` (first-match
across groups in declaration order). ``excludes`` patterns allow opting
a task out of a group even if it matches ``includes``.
2. **Longest colon-prefix match** against ``cfg.namespaces`` keys (existing
sub-namespace behavior).
3. **``_top`` catch-all** β€” tasks with no ``:`` that didn't match any
explicit includes rule.
Tasks in ``cfg.top_exclude`` are silently omitted from all groups.
"""
sub_ns: set[str] = {k for k in cfg.namespaces if ":" in k}
top_exclude_set: set[str] = set(cfg.top_exclude)
# Groups with explicit includes rules.
# Sort so deepest/most-specific groups are checked first β€” child groups
# (those with a 'parent' field or colon-depth) take priority over their
# parent's more general includes patterns.
includes_groups = [
(key, gd) for key, gd in cfg.groups.items() if gd.includes
]
includes_groups.sort(key=lambda kgd: -_get_display_depth(kgd[0], cfg))
groups_result: dict[str, list[dict[str, Any]]] = defaultdict(list)
for t in tasks:
name = t["name"]
# Silently skip top-excluded tasks (applied before any matching)
if ":" not in name and name in top_exclude_set:
continue
# ── 1. Explicit includes matching (first match wins) ───────────────────
matched_group: str | None = None
for gd_key, gd in includes_groups:
if _task_matches_includes(name, gd.includes) and not (
gd.excludes and _task_matches_includes(name, gd.excludes)
):
matched_group = gd_key
break
if matched_group is not None:
groups_result[matched_group].append(t)
continue
# ── 2. Colon-prefix fallback ───────────────────────────────────────────
if ":" not in name:
groups_result["_top"].append(t)
else:
parts = name.split(":")
matched = parts[0] # default: first segment
if sub_ns:
for depth in range(len(parts) - 1, 0, -1):
candidate = ":".join(parts[:depth])
if candidate in sub_ns:
matched = candidate
break
groups_result[matched].append(t)
return groups_result
def _get_display_depth(
key: str,
cfg: "Config",
_visited: "frozenset[str] | None" = None,
) -> int:
"""Compute visual nesting depth for *key*, considering both sources of nesting:
* **Colon-prefix**: ``deploy:staging`` β†’ depth 1, ``deploy:staging:run`` β†’ 2.
* **Explicit ``parent`` field**: ``mcp-server`` with ``parent="mcp"`` β†’ depth 1;
its colon children (e.g. ``mcp-server:impl``) β†’ depth 2.
Cycle-safe via the *_visited* frozenset.
"""
if _visited is None:
_visited = frozenset()
if key in _visited:
return 0
_visited = _visited | {key}
gd = cfg.groups.get(key)
if gd and gd.parent and gd.parent != key and gd.parent in cfg.groups:
return 1 + _get_display_depth(gd.parent, cfg, _visited)
if ":" in key:
parent_key = ":".join(key.split(":")[:-1])
if parent_key in cfg.groups:
return 1 + _get_display_depth(parent_key, cfg, _visited)
return 0
def _ordered_groups(
ordered: list[str],
extras: list[str],
groups: dict[str, list[dict[str, Any]]],
cfg: "Config",
) -> list[str]:
"""
Return namespace keys in display order, mutating *groups* for parent-only entries.
Display order (highest to lowest priority):
1. Groups with an explicit ``GroupDescriptor.order`` integer field (ascending).
2. Groups in the ``ordered`` list (from ``cfg.namespace_order`` or
``cfg.namespaces`` insertion order).
3. Remaining groups in ``extras`` (alphabetical).
Within that sequence, sub-groups are emitted immediately after their parent:
* **Colon-prefix children** β€” ``deploy:staging`` / ``deploy:prod`` after ``deploy``.
* **Explicit ``parent`` field children** β€” ``mcp-server`` after ``mcp`` when
``GroupDescriptor(key="mcp-server", parent="mcp")``.
Header-only parents (no direct tasks but children with tasks) are injected
into *groups* with an empty list so ``print_group`` can render a labelled
section heading.
"""
all_keys = ordered + extras
all_keys_set = set(all_keys)
result: list[str] = []
emitted: set[str] = set()
# Build explicit parent→children map from GroupDescriptor.parent field
explicit_parent_children: dict[str, list[str]] = defaultdict(list)
for key in all_keys:
gd = cfg.groups.get(key)
if gd and gd.parent and gd.parent != key and gd.parent in all_keys_set:
explicit_parent_children[gd.parent].append(key)
# Groups with an explicit parent declared β†’ skip top-level emission
has_explicit_parent: set[str] = {
key for key in all_keys
if (gd := cfg.groups.get(key)) and gd.parent and gd.parent in all_keys_set
}
def emit(ns: str) -> None:
if ns in emitted:
return
emitted.add(ns)
# Colon-prefix direct children (one level deeper)
child_depth = ns.count(":") + 1
colon_children = [
c for c in all_keys
if c.startswith(ns + ":") and c.count(":") == child_depth
]
# Explicit parent-field children not already covered by colon-prefix
parent_field_children = [
c for c in explicit_parent_children.get(ns, [])
if c not in colon_children
]
all_children = colon_children + parent_field_children
has_children_with_tasks = any(c in groups for c in all_children)
if ns in groups:
result.append(ns)
elif has_children_with_tasks:
# Header-only parent: inject empty list so print_group renders a heading
groups[ns] = []
result.append(ns)
for child in all_children:
emit(child)
# Sort: groups with explicit 'order' field first, then preserve original order
def _sort_key(ns: str) -> tuple[int, int, str]:
gd = cfg.groups.get(ns)
if gd and gd.order is not None:
return (0, gd.order, ns)
if ns in ordered:
return (1, ordered.index(ns), ns)
idx = extras.index(ns) if ns in extras else len(extras)
return (2, idx, ns)
sorted_keys = sorted(all_keys, key=_sort_key)
for ns in sorted_keys:
if ns in has_explicit_parent:
continue # Will be emitted by parent's emit() call
emit(ns)
# Safety net: emit anything still pending (e.g. groups not in all_keys)
for ns in all_keys:
emit(ns)
return result
# ── 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)
# Prefer GroupDescriptor for display metadata; fall back to namespaces dict
gd = cfg.groups.get(namespace)
if gd:
emoji, label, color = gd.emoji, gd.label, gd.color
if cfg.no_color:
color = ""
else:
emoji, label, color = cfg.namespaces.get(namespace, fallback)
if cfg.no_color:
color = ""
# ── Depth-aware indentation (considers colon-prefix AND explicit parent links)
# depth=0 (top-level): " {emoji} {label}", tasks " {name}"
# depth=1 (one sub-level): " {emoji} {label}", tasks " {name}"
depth = _get_display_depth(namespace, cfg)
grp_pad = " " * (depth + 1) # group header / separator indent
task_pad = " " * (depth + 2) # task line indent
sum_pad = " " * (depth + 3) # summary line indent
sep_w = max(60 - depth * 4, 40) # separator rule width (shorter for sub-groups)
print(f"{B}{color}{grp_pad}{emoji} {label}{R}")
# Header-only parent: just show the label line (+ optional note), no separator or tasks.
# Sub-groups follow immediately and provide their own separators.
if not tasks:
if gd and gd.note:
print(f"{task_pad}{_c(DIM, cfg)}{gd.note}{_c(RESET, cfg)}")
print()
return
# Optional task-count badge (dim, after the separator)
if cfg.show_task_count:
n = len(tasks)
count_str = f" {_c(DIM, cfg)}({n} {'task' if n == 1 else 'tasks'}){R}"
else:
count_str = ""
print(f"{tc.separator}{grp_pad}{'─' * sep_w}{count_str}{R}")
# Optional per-group note (dim hint displayed before task rows)
if gd and gd.note:
print(f"{task_pad}{_c(DIM, cfg)}{gd.note}{_c(RESET, cfg)}")
print()
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
# Compile hide_alias_patterns once per group (avoids per-task re-compilation)
_alias_filters = [re.compile(p) for p in cfg.hide_alias_patterns] if cfg.hide_alias_patterns else []
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 []
if _alias_filters:
aliases = [a for a in aliases if not any(rx.search(a) for rx in _alias_filters)]
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)
# Summaries: GroupDescriptor.show_summary overrides global flags when set
if gd and gd.show_summary is not None:
group_show_summary = gd.show_summary
else:
group_show_summary = cfg.show_summary or namespace in cfg.show_summary_for
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_pad}{task_name_color}{name}{R}{name_pad}{desc_padded} {alias_color}(aliases: {alias_str}){R}")
else:
print(f"{task_pad}{task_name_color}{name}{R}{name_pad}{desc}")
else:
print(f"{task_pad}{task_name_color}{name}{R}{name_pad}{desc}")
# ── multi-line summary with β”‚ prefix ──────────────────────────────────
printed_summary = False
if group_show_summary and summary:
for line in summary.splitlines():
print(f"{sum_pad}{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()
# ── Rich markup block printing ────────────────────────────────────────────────
def _print_markup_block(text: str, cfg: Config, indent: str = " ") -> None:
"""Print a Rich markup text block to stdout with consistent indentation.
Each non-blank line is prefixed with *indent*. Blank lines are preserved
as genuine blank lines. Supports the same Rich Console Markup tags that the
rest of task-help uses, with an automatic fallback when rich is not installed.
"""
if not text:
return
if _HAS_RICH and not cfg.no_color:
from rich.console import Console # type: ignore[import]
console = _RichConsole(highlight=False, soft_wrap=True)
for line in text.splitlines():
if line.strip():
console.print(f"{indent}{line}", markup=True)
else:
print()
else:
for line in text.splitlines():
if line.strip():
rendered = _strip_markup(line) if cfg.no_color else _markup_to_ansi(line)
print(f"{indent}{rendered}")
else:
print()
def print_prelog(cfg: Config) -> None:
"""Print the prelog block: after the Usage section, before the first group."""
if not cfg.prelog:
return
_print_markup_block(cfg.prelog, cfg)
print()
def print_epilog(cfg: Config) -> None:
"""Print the epilog block: after the footer, at the very end of output."""
if not cfg.epilog:
return
_print_markup_block(cfg.epilog, cfg)
print()
# ── Entry point ───────────────────────────────────────────────────────────────
def main() -> None:
args = build_arg_parser().parse_args()
cfg = build_config(args)
# ── Update check (runs before display so auto-update completes first) ──────
update_mode = _get_update_check_mode()
remote_hash: str | None = None
if update_mode != "off":
install_dir = _get_install_dir()
if install_dir:
if update_mode == "auto":
remote_hash = check_for_update(install_dir)
if remote_hash:
perform_auto_update(install_dir)
remote_hash = None # already updated β€” suppress the hint
else: # "on"
remote_hash = check_for_update(install_dir)
tasks = get_tasks()
if not tasks:
return
groups = group_tasks(tasks, cfg)
print_header(cfg)
print_prelog(cfg)
# Explicit namespace_order overrides the insertion-order of cfg.namespaces.
if cfg.namespace_order:
ordered = cfg.namespace_order
else:
ordered = list(cfg.namespaces.keys())
extras = sorted(k for k in groups if k not in ordered)
for ns in _ordered_groups(ordered, extras, groups, cfg):
print_group(ns, groups[ns], cfg)
print_footer(len(tasks), cfg)
print_epilog(cfg)
# ── Show update hint after all other output ────────────────────────────────
if remote_hash:
print_update_hint(remote_hash, 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: prelog/epilog via env vars ───────────────────────────────────
# Use \n (literal backslash-n) as a newline separator in env var values.
# In Taskfile.yml, YAML block scalars (|) are the cleanest way to write
# multi-line values β€” see the vars block below.
default-with-prelog:
silent: true
desc: "Show grouped task list with prelog quick-start guide"
vars:
# YAML block scalar: preserves newlines, no need for \n escapes
PRELOG: |
[bold]First time?[/bold]
1. [green]task scripts:install[/green] β€” install task-help pretty-printer
2. [green]task install[/green] β€” interactive setup wizard
3. [green]task ssh:test[/green] β€” verify the setup
[bold]SSH Vault CLI (ssh-vault):[/bold]
[cyan]task cli:install[/cyan] β€” install ssh-vault CLI tool globally
[cyan]task cli:run -- --help[/cyan] β€” run the CLI (or: ssh-vault --help)
EPILOG: |
[dim]LEARN MORE[/dim]
Use [green]task --list[/green] for the full task list.
Read the README for detailed documentation.
cmds:
- |
TASK_HELP_PRELOG="{{.PRELOG}}" \
TASK_HELP_EPILOG="{{.EPILOG}}" \
{{.TASK_HELP_SCRIPT}}
# ── Alternative: prelog/epilog via .task-help.yml config file ─────────────────
# Drop a .task-help.yml in your project root with content like:
#
# header: "My SSH Project"
# prelog: |
# [bold]First time?[/bold]
# 1. [green]task scripts:install[/green] β€” install task-help
# 2. [green]task install[/green] β€” interactive setup wizard
# epilog: |
# [dim]Run [green]task --list[/green] for the full list.[/dim]
#
# Then invoke the script normally β€” it auto-discovers the config file:
default-with-config-file:
silent: true
desc: "Show grouped task list (reads .task-help.yml / .task-help.json)"
cmds:
- "{{.TASK_HELP_SCRIPT}}"
# ── Alternative: prelog/epilog via CLI options ────────────────────────────────
# Pass prelog/epilog directly with --prelog / --epilog.
# Use \n (backslash-n) in a single-line string for newlines.
default-with-prelog-cli:
silent: true
desc: "Show grouped task list with prelog/epilog via CLI options"
cmds:
- |
{{.TASK_HELP_SCRIPT}} \
--header "My Project" \
--prelog "[bold]First time?[/bold]\n 1. task install\n 2. task test" \
--epilog "[dim]Run task --list for more.[/dim]"
# ── 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"
# ── Nested namespace example ─────────────────────────────────────────────────
# These tasks are grouped under "deploy:staging" and "deploy:prod" sub-groups
# when the config defines those as namespace keys.
#
# .task-help.yml config:
# namespaces:
# deploy: ["πŸš€", "Deployment", "GREEN"]
# deploy:staging: ["🌐", "Staging Environment", "CYAN"]
# deploy:prod: ["πŸ”΄", "Production", "RED"]
deploy:staging:run:
desc: "Run staging deployment"
cmds:
- echo "Staging deploy goes here"
deploy:staging:rollback:
desc: "Rollback staging"
cmds:
- echo "Staging rollback goes here"
deploy:prod:run:
desc: "Run production deployment"
cmds:
- echo "Production deploy 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
# ── demo ─────────────────────────────────────────────────────────────────────
demo:
desc: "Run task-help with a prelog + epilog demo to preview Rich markup output"
silent: true
dir: "{{.TASK_HELP_TARGET}}"
vars:
# YAML block scalars (|) preserve newlines β€” no \n escapes needed.
# Rich markup tags are rendered with ANSI colours when a terminal is present.
DEMO_PRELOG: |
[bold]First time?[/bold]
[green]1.[/green] task task-help:install β€” install task-help pretty-printer
[green]2.[/green] task install β€” interactive setup wizard
[green]3.[/green] task task-help:status β€” verify the installation
[bold]Available lifecycle tasks:[/bold]
[cyan]task task-help:install[/cyan] β€” install or reinstall from Gist
[cyan]task task-help:update[/cyan] β€” pull latest from Gist
[cyan]task task-help:remove[/cyan] β€” uninstall and clean up
[cyan]task task-help:status[/cyan] β€” show installed version / state
DEMO_EPILOG: |
[dim]LEARN MORE[/dim]
Use [green]task --list[/green] for the full task list.
Gist: [underline]https://gist.github.com/tobiashochguertel/261c54d64fff6dc1493619e2924161b4[/underline]
cmds:
- |
TASK_HELP_PRELOG="{{.DEMO_PRELOG}}" \
TASK_HELP_EPILOG="{{.DEMO_EPILOG}}" \
TASK_HELP_HEADER="task-help β€” lifecycle demo" \
python3 "{{.TASK_HELP_SCRIPT}}"
# 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