feat(cli): add named color themes and rich-formatted help

Render all click help through rich-click, themed by a new `theme` config key (9 palettes + aliases, previewable via `unshackle env theme`), and show service docstrings in local and remote `dl <TAG> -h`.
This commit is contained in:
imSp4rky
2026-08-13 16:25:14 -06:00
parent a248ae79bd
commit db9ec3e334
16 changed files with 439 additions and 40 deletions
+9
View File
@@ -322,6 +322,14 @@ unshackle env info
Shows where the config was loaded from (or lists the candidate config locations if none was found) and prints a table of every configured directory (downloads, temp, cache, cookies, logs, exports, WVDs, PRDs, services, and more). Shows where the config was loaded from (or lists the candidate config locations if none was found) and prints a table of every configured directory (downloads, temp, cache, cookies, logs, exports, WVDs, PRDs, services, and more).
### `env theme`
```
unshackle env theme
```
Prints a sample of every available CLI theme: colour swatches, help text with option rows, a track listing with video/audio/subtitle entries, log lines, a progress bar, and the gradient pulse bar. The active theme is marked and each theme's aliases are listed. Set your choice with the [`theme`](../reference/configuration/misc.md#appearance) config key.
### `env clear` ### `env clear`
Clear an environment directory. The directory is emptied and recreated, and the number of files and bytes freed is reported. Clear an environment directory. The directory is emptied and recreated, and the number of files and bytes freed is reported.
@@ -335,6 +343,7 @@ Clear an environment directory. The directory is emptied and recreated, and the
```shell ```shell
unshackle env check unshackle env check
unshackle env info unshackle env info
unshackle env theme
unshackle env clear cache unshackle env clear cache
unshackle env clear cache EXAMPLE # one service only unshackle env clear cache EXAMPLE # one service only
unshackle env clear temp unshackle env clear temp
+1
View File
@@ -38,5 +38,6 @@ Where the file lives, how unshackle discovers it, and how to edit it:
| [Key vaults](vaults.md) | `key_vaults`, `vault_timeout` | | [Key vaults](vaults.md) | `key_vaults`, `vault_timeout` |
| [External API keys](misc.md#external-api-keys) | `omdb_api_key`, `tmdb_api_key`, `tvdb_api_key`, `tvdb_pin`, `tvdb_order`, `simkl_client_id`, `metadata_providers`, `anilist_title_language`, `disable_metadata`, `decrypt_labs_api_key`, `ipinfo_api_key` | | [External API keys](misc.md#external-api-keys) | `omdb_api_key`, `tmdb_api_key`, `tvdb_api_key`, `tvdb_pin`, `tvdb_order`, `simkl_client_id`, `metadata_providers`, `anilist_title_language`, `disable_metadata`, `decrypt_labs_api_key`, `ipinfo_api_key` |
| [Caching & updates](misc.md#caching-updates) | `title_cache_enabled`, `title_cache_time`, `title_cache_max_retention`, `update_checks`, `update_check_interval` | | [Caching & updates](misc.md#caching-updates) | `title_cache_enabled`, `title_cache_time`, `title_cache_max_retention`, `update_checks`, `update_check_interval` |
| [Appearance](misc.md#appearance) | `theme` |
| [Logging, privacy & debug](misc.md#logging-privacy-debug) | `redact_paths`, `debug`, `debug_keys`, `debug_requests`, `set_terminal_bg` | | [Logging, privacy & debug](misc.md#logging-privacy-debug) | `redact_paths`, `debug`, `debug_keys`, `debug_requests`, `set_terminal_bg` |
| [Deprecated & removed](misc.md#deprecated-removed-keys) | `curl_impersonate`, `downloader`, `scene_naming` | | [Deprecated & removed](misc.md#deprecated-removed-keys) | `curl_impersonate`, `downloader`, `scene_naming` |
+8
View File
@@ -141,6 +141,14 @@ calls.
--- ---
## Appearance { #appearance }
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `theme` | str | `catppuccin-mocha` | Named colour theme for all CLI output: banner, logs, progress bars, and help screens. One of `catppuccin-mocha` (aliases `default`, `catppuccin`, `mocha`), `dracula`, `nord`, `gruvbox`, `one-dark` (alias `onedark`), `tokyo-night` (alias `tokyonight`), `monokai`, `flexoki`, `solarized-dark` (alias `solarized`). Case-insensitive; underscores work in place of hyphens. Unknown names fall back to the default with a warning. Preview them all with `unshackle env theme`. |
---
## Logging, privacy & debug { #logging-privacy-debug } ## Logging, privacy & debug { #logging-privacy-debug }
| Key | Type | Default | Description | | Key | Type | Default | Description |
+3 -1
View File
@@ -95,7 +95,9 @@ not repeated in the fallbacks, so `auth_headers: ["Authorization", "x-secret-key
In `--remote` mode unshackle turns the server's service list into synthetic CLI In `--remote` mode unshackle turns the server's service list into synthetic CLI
commands that run against it, falling back to the tags in that `services` sub-dict when the commands that run against it, falling back to the tags in that `services` sub-dict when the
list cannot be fetched. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the list cannot be fetched. Each synthetic command carries the server-side service's options and
documentation, so `unshackle dl --remote <TAG> -h` shows the same help text as it does on the
server. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the
full setup. full setup.
## `serve` ## `serve`
+1
View File
@@ -69,6 +69,7 @@ dependencies = [
"jsonpath-ng>=1.8.0", "jsonpath-ng>=1.8.0",
"mutagen>=1.47.0,<2", "mutagen>=1.47.0,<2",
"defusedxml>=0.7.1", "defusedxml>=0.7.1",
"rich-click>=1.9.8",
] ]
[project.urls] [project.urls]
+9 -3
View File
@@ -30,6 +30,7 @@ import yaml
from click.core import ParameterSource from click.core import ParameterSource
from langcodes import Language, tag_is_valid from langcodes import Language, tag_is_valid
from pymediainfo import MediaInfo from pymediainfo import MediaInfo
from rich import box
from rich.console import Group from rich.console import Group
from rich.padding import Padding from rich.padding import Padding
from rich.panel import Panel from rich.panel import Panel
@@ -1766,7 +1767,7 @@ class dl:
# Note: Headers are not mapped to actual title indices # Note: Headers are not mapped to actual title indices
# Format display name # Format display name
display_name = ((t.name[:30].rstrip() + "…") if len(t.name) > 30 else t.name) if t.name else None display_name = ((t.name[:30].rstrip() + "...") if len(t.name) > 30 else t.name) if t.name else None
# Apply indentation only for multiple seasons # Apply indentation only for multiple seasons
prefix = " " if multiple_seasons else "" prefix = " " if multiple_seasons else ""
@@ -2582,7 +2583,12 @@ class dl:
if list_: if list_:
available_tracks, _ = title.tracks.tree() available_tracks, _ = title.tracks.tree()
console.print(Padding(Panel(available_tracks, title="Available Tracks"), (0, 5))) console.print(
Padding(
Panel(available_tracks, title="Available Tracks", box=box.SQUARE, border_style="bright_black"),
(0, 5),
)
)
continue continue
# Determine which tracks to keep # Determine which tracks to keep
@@ -4166,7 +4172,7 @@ class dl:
Carries no session/cookies/dl-flags. Region (country code) is stored only when the Carries no session/cookies/dl-flags. Region (country code) is stored only when the
export used ``--proxy``, as an import geofence. Each track records only the licensed export used ``--proxy``, as an import geofence. Each track records only the licensed
DRM system; content keys live once under the track's ``keys``. ``drm`` may be None DRM system; content keys live once under the track's ``keys``. ``drm`` may be None
(DRM-free track) or a DRM system without ``to_dict``/``content_keys`` (e.g. ClearKey) — (DRM-free track) or a DRM system without ``to_dict``/``content_keys`` (e.g. ClearKey) -
the track, manifest, chapter and attachment info is still exported. the track, manifest, chapter and attachment info is still exported.
""" """
with self.EXPORT_LOCK: with self.EXPORT_LOCK:
+105
View File
@@ -188,6 +188,111 @@ def check() -> None:
console.print(Padding(" ".join(summary_parts), (1, 2))) console.print(Padding(" ".join(summary_parts), (1, 2)))
@env.command()
def theme() -> None:
"""Preview the available CLI themes."""
import math
from rich import box
from rich.color import Color, blend_rgb
from rich.console import Group
from rich.panel import Panel
from rich.rule import Rule
from rich.style import Style
from rich.text import Text
from unshackle.core.themes import ALIASES, DEFAULT_THEME, PALETTES, resolve_palette
active = resolve_palette(config.theme) or PALETTES[DEFAULT_THEME]
for name, palette in PALETTES.items():
text, text2, gray, guide = palette["text"], palette["text2"], palette["gray"], palette["bright_black"]
header = Text(name, style=palette["pink"])
if palette == active:
header.append(" (active)", style=palette["green"])
aliases = sorted(alias for alias, target in ALIASES.items() if target == name)
if aliases:
header.append(f" aliases: {', '.join(aliases)}", style=f"dim {gray}")
swatch = Text()
for role in ("red", "green", "yellow", "blue", "pink", "cyan", "text", "gray"):
swatch.append("██ ", style=palette[role])
docstring = Text()
docstring.append("Service docstrings render like this, ", style=text)
docstring.append("with secondary detail lines like this.", style=text2)
separator = Text("─" * 60, style=palette["dark_gray"])
options: list[Text] = []
rows = (
("-q, ", "--quality ", "TEXT ", "Video quality to download", " [default: best]", f"dim {gray}"),
(
"-r, ",
"--range ",
"[SDR|HDR10|DV] ",
"Video color range",
" [env: RANGE]",
f"dim {palette['yellow']}",
),
)
for short, long_opt, metavar, help_text, extra, extra_style in rows:
row = Text()
row.append(short, style=palette["green"])
row.append(long_opt, style=text)
row.append(metavar, style=palette["yellow"])
row.append(f" {help_text}", style=text)
row.append(extra, style=extra_style)
options.extend((row, separator))
listing = Tree(Text("1 season, S1(1)", style=text), guide_style=guide)
episode = listing.add(Text.assemble(("1. ", palette["blue"]), ("Pilot", text)), guide_style=guide)
track_rows = (
("VID", "H.264 | 1920x1080 @ 4523 kb/s"),
("AUD", "AAC 2.0 | en @ 192 kb/s"),
("SUB", "WebVTT | en (SDH)"),
)
for kind, desc in track_rows:
episode.add(Text.assemble((kind, palette["pink"]), (" | ", guide), (desc, text)), guide_style=guide)
tracks = Panel(listing, title="Available Tracks", box=box.SQUARE, border_style=guide, expand=False)
logs = []
log_rows = (
("INFO", palette["green"], "Downloading 3 tracks"),
("WARNING", palette["yellow"], "Subtitle cues overlapped, fixed 2"),
("ERROR", palette["red"], "License request denied (403)"),
)
for level, level_style, message in log_rows:
line = Text()
line.append("18:00:01 ", style=gray)
line.append(f"{level:<8} ", style=level_style)
line.append(message, style=text if level != "ERROR" else palette["red"])
logs.append(line)
progress = Text()
progress.append("Pilot ", style=text)
progress.append("━" * 24, style=palette["green"])
progress.append("╸", style=palette["green"])
progress.append("━" * 14, style=guide)
progress.append(" 62%", style=palette["yellow"])
progress.append(" 12.4 MB/s", style=palette["cyan"])
progress.append(" 0:00:42", style=text2)
# one frozen frame of GradientPulseBarColumn's dark_gray-to-pink cosine sweep
lo = Color.parse(palette["dark_gray"]).triplet
hi = Color.parse(palette["pink"]).triplet
assert lo and hi
lut = [Style(color=Color.from_triplet(blend_rgb(lo, hi, i / 31))) for i in range(32)]
pulse = Text("Muxing ", style=text)
for i in range(39):
fade = (math.cos((i - 8) * math.tau / 40.0) + 1) / 2
pulse.append("━", style=lut[int(fade * 31)])
block = Group(header, swatch, Text(), docstring, *options, tracks, *logs, Text(), progress, pulse)
console.print(Padding(block, (1, 2, 1, 2)))
console.print(Rule(style=guide, characters="─"))
console.print()
@env.command() @env.command()
def info() -> None: def info() -> None:
"""Displays information about the current environment.""" """Displays information about the current environment."""
+4
View File
@@ -3,6 +3,10 @@ import os
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from rich_click.patch import patch as _patch_click_help
_patch_click_help()
__version__ = "5.3.0" __version__ = "5.3.0"
_PKG = Path(__file__).parent.parent _PKG = Path(__file__).parent.parent
+5 -6
View File
@@ -23,12 +23,12 @@ def load_command(path: Path) -> object:
try: try:
module = import_module_by_path(path) module = import_module_by_path(path)
except Exception as e: except Exception as e:
raise RuntimeError(f"{path.stem}: failed to import — {type(e).__name__}: {e} ({path})") from e raise RuntimeError(f"{path.stem}: failed to import - {type(e).__name__}: {e} ({path})") from e
try: try:
return getattr(module, path.stem) return getattr(module, path.stem)
except AttributeError as e: except AttributeError as e:
raise RuntimeError( raise RuntimeError(
f"{path.stem}: no object named '{path.stem}' found in {path} — it must match the filename" f"{path.stem}: no object named '{path.stem}' found in {path} - it must match the filename"
) from e ) from e
@@ -74,10 +74,9 @@ class Commands(click.Group):
if not module: if not module:
raise click.ClickException(f"Unable to find command by the name '{name}'") raise click.ClickException(f"Unable to find command by the name '{name}'")
if hasattr(module, "cli"): cmd = module.cli if hasattr(module, "cli") else module
return module.cli cmd.name = name
return cmd
return module
# Hide direct access to commands from quick import form, they shouldn't be accessed directly # Hide direct access to commands from quick import form, they shouldn't be accessed directly
+1
View File
@@ -110,6 +110,7 @@ class Config:
self.decryption_map = {} self.decryption_map = {}
self.decryption = decryption_cfg or "shaka" self.decryption = decryption_cfg or "shaka"
self.theme: str = kwargs.get("theme") or "default"
self.set_terminal_bg: bool = kwargs.get("set_terminal_bg", False) self.set_terminal_bg: bool = kwargs.get("set_terminal_bg", False)
self.tag: str = kwargs.get("tag") or "" self.tag: str = kwargs.get("tag") or ""
self.tag_group_name: bool = kwargs.get("tag_group_name", True) self.tag_group_name: bool = kwargs.get("tag_group_name", True)
+11 -19
View File
@@ -21,6 +21,7 @@ from rich.text import Text, TextType
from rich.theme import Theme from rich.theme import Theme
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.themes import DEFAULT_THEME, PALETTES, apply_help_theme, resolve_palette
class ComfyLogRenderer(LogRender): class ComfyLogRenderer(LogRender):
@@ -317,25 +318,16 @@ class ComfyConsole(Console):
live.start() live.start()
catppuccin_mocha = { primary_scheme = resolve_palette(config.theme)
# Colors based on "CatppuccinMocha" from Gogh themes if primary_scheme is None:
"bg": "rgb(30,30,46)", logging.getLogger("console").warning(
"text": "rgb(205,214,244)", "Unknown theme %r, falling back to %s (available: %s)",
"text2": "rgb(162,169,193)", # slightly darker config.theme,
"black": "rgb(69,71,90)", DEFAULT_THEME,
"bright_black": "rgb(88,91,112)", ", ".join(PALETTES),
"red": "rgb(243,139,168)", )
"green": "rgb(166,227,161)", primary_scheme = dict(PALETTES[DEFAULT_THEME])
"yellow": "rgb(249,226,175)", apply_help_theme(primary_scheme)
"blue": "rgb(137,180,250)",
"pink": "rgb(245,194,231)",
"cyan": "rgb(148,226,213)",
"gray": "rgb(166,173,200)",
"bright_gray": "rgb(186,194,222)",
"dark_gray": "rgb(54,54,84)",
}
primary_scheme = catppuccin_mocha
primary_scheme["none"] = primary_scheme["text"] primary_scheme["none"] = primary_scheme["text"]
primary_scheme["grey23"] = primary_scheme["black"] primary_scheme["grey23"] = primary_scheme["black"]
primary_scheme["magenta"] = primary_scheme["pink"] primary_scheme["magenta"] = primary_scheme["pink"]
+2 -1
View File
@@ -14,6 +14,7 @@ from io import BytesIO
from typing import Any, Optional from typing import Any, Optional
# RenderableType is rich's union of "things that can be printed to a console". # RenderableType is rich's union of "things that can be printed to a console".
from rich import box
from rich.console import Group, RenderableType from rich.console import Group, RenderableType
from rich.padding import Padding from rich.padding import Padding
from rich.panel import Panel from rich.panel import Panel
@@ -145,7 +146,7 @@ def render_track_panel(rows: list[TrackRow], total: int) -> Panel:
detail.append(" | ") detail.append(" | ")
detail.append("Hi-Res", style="gold1") detail.append("Hi-Res", style="gold1")
node.add(detail, guide_style="bright_black") node.add(detail, guide_style="bright_black")
return Panel(tree, title="Available Tracks") return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black")
def render_album_header( def render_album_header(
+3 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import re import re
from typing import Any, Optional from typing import Any, Optional
from rich import box
from rich.console import Group, RenderableType from rich.console import Group, RenderableType
from rich.markup import escape from rich.markup import escape
from rich.panel import Panel from rich.panel import Panel
@@ -112,7 +113,7 @@ class MusicRenderer:
suffix = "s" if hidden != 1 else "" suffix = "s" if hidden != 1 else ""
tree.add(f"[bright_black]... {hidden} more track{suffix}[/]", guide_style="bright_black") tree.add(f"[bright_black]... {hidden} more track{suffix}[/]", guide_style="bright_black")
return Panel(tree, title="Available Tracks") return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black")
def render_plan_header(self, plan: MusicDownloadPlan) -> Optional[Table]: def render_plan_header(self, plan: MusicDownloadPlan) -> Optional[Table]:
title = plan.title title = plan.title
@@ -174,7 +175,7 @@ class MusicRenderer:
suffix = "s" if hidden != 1 else "" suffix = "s" if hidden != 1 else ""
tree.add(f"[bright_black]... {hidden} more track{suffix}[/]", guide_style="bright_black") tree.add(f"[bright_black]... {hidden} more track{suffix}[/]", guide_style="bright_black")
return Panel(tree, title="Available Tracks") return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black")
@staticmethod @staticmethod
def _metadata_grid() -> Table: def _metadata_grid() -> Table:
+30 -8
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import inspect
import logging import logging
import re import re
from pathlib import Path from pathlib import Path
@@ -17,7 +18,7 @@ log = logging.getLogger("services")
_raw = config.directories.services _raw = config.directories.services
if not isinstance(_raw, list): if not isinstance(_raw, list):
_raw = [_raw] _raw = [_raw]
# resolve in config order — priority IS list order: the first source to define a tag is the source, # resolve in config order - priority IS list order: the first source to define a tag is the source,
# later sources (local or repo) with the same tag are shadowed. List local last to make it a fallback. # later sources (local or repo) with the same tag are shadowed. List local last to make it a fallback.
_service_dirs: list[Path] = [] _service_dirs: list[Path] = []
DIRTY_REPOS: list[str] = [] DIRTY_REPOS: list[str] = []
@@ -26,7 +27,7 @@ for _entry in _raw:
try: try:
_resolved = resolve_service_repo(_entry) _resolved = resolve_service_repo(_entry)
except DirtyServiceRepo as e: except DirtyServiceRepo as e:
# local edits in the clone — record it and let check_load_errors() exit cleanly # local edits in the clone - record it and let check_load_errors() exit cleanly
DIRTY_REPOS.append(redact_path(str(e.path))) DIRTY_REPOS.append(redact_path(str(e.path)))
_resolved = None _resolved = None
if _resolved: if _resolved:
@@ -59,12 +60,12 @@ def load_service(path: Path) -> object:
try: try:
module = import_module_by_path(path) module = import_module_by_path(path)
except Exception as e: except Exception as e:
raise RuntimeError(f"{tag}: failed to import — {type(e).__name__}: {e} ({path})") from e raise RuntimeError(f"{tag}: failed to import - {type(e).__name__}: {e} ({path})") from e
try: try:
return getattr(module, tag) return getattr(module, tag)
except AttributeError as e: except AttributeError as e:
raise RuntimeError( raise RuntimeError(
f"{tag}: no class named '{tag}' found in {path} — the class name must match the directory name" f"{tag}: no class named '{tag}' found in {path} - the class name must match the directory name"
) from e ) from e
@@ -101,12 +102,12 @@ def check_load_errors() -> None:
is rendered once by Click, without a traceback and without cascading through is rendered once by Click, without a traceback and without cascading through
every command that imports this module. Duplicate services (a tag also served by an every command that imports this module. Duplicate services (a tag also served by an
earlier, higher-priority source, which is ignored) are summarised here; the full earlier, higher-priority source, which is ignored) are summarised here; the full
list — naming the path used and the duplicate ignored — is debug-only. list - naming the path used and the duplicate ignored - is debug-only.
""" """
if DIRTY_REPOS: if DIRTY_REPOS:
joined = "\n".join(f" - {p}" for p in DIRTY_REPOS) joined = "\n".join(f" - {p}" for p in DIRTY_REPOS)
raise click.ClickException( raise click.ClickException(
"Service repo has local changes — refusing to refresh so your edits are not lost.\n" "Service repo has local changes - refusing to refresh so your edits are not lost.\n"
"Commit and push them to the upstream repo (or revert them), then retry:\n" + joined "Commit and push them to the upstream repo (or revert them), then retry:\n" + joined
) )
global _SUMMARY_LOGGED global _SUMMARY_LOGGED
@@ -191,10 +192,28 @@ class Services(click.Group):
raise click.ClickException(f"{e}. Available Services: {', '.join(available_services)}") raise click.ClickException(f"{e}. Available Services: {', '.join(available_services)}")
if hasattr(service, "cli"): if hasattr(service, "cli"):
return service.cli cli = service.cli
cli.name = tag
doc = service.__doc__
if doc and doc.strip() and cli.help in (None, "", doc):
cli.help = Services._docstring_help(doc)
return cli
raise click.ClickException(f"Service '{tag}' has no 'cli' method configured.") raise click.ClickException(f"Service '{tag}' has no 'cli' method configured.")
@staticmethod
def _docstring_help(doc: str) -> str:
"""Format a service docstring for Click help, one \\b per paragraph so Click keeps the layout.
The first paragraph stays unprefixed: it is the summary line, and a \\b there
renders as a stray blank line and empties click's derived short help.
"""
doc = inspect.cleandoc(doc)
if "\b" in doc:
return doc
first, *rest = doc.split("\n\n")
return "\n\n".join([first] + [f"\b\n{p}" for p in rest])
@staticmethod @staticmethod
def _fetch_remote_services(ctx: click.Context) -> list[dict] | None: def _fetch_remote_services(ctx: click.Context) -> list[dict] | None:
"""Fetch the service list from the remote server (cached per process).""" """Fetch the service list from the remote server (cached per process)."""
@@ -219,9 +238,12 @@ class Services(click.Group):
"""Create a Click command for a remote service with server-provided options.""" """Create a Click command for a remote service with server-provided options."""
svc_info = Services._fetch_remote_service_info(tag, ctx) svc_info = Services._fetch_remote_service_info(tag, ctx)
short_help = svc_info.get("url") if svc_info else None short_help = svc_info.get("url") if svc_info else None
help_text = svc_info.get("help") if svc_info else None
if help_text:
help_text = Services._docstring_help(help_text)
cli_params = svc_info.get("cli_params") if svc_info else None cli_params = svc_info.get("cli_params") if svc_info else None
@click.command(name=tag, short_help=short_help) @click.command(name=tag, short_help=short_help, help=help_text)
@click.argument("title", type=str) @click.argument("title", type=str)
@click.pass_context @click.pass_context
def remote_cli(ctx: click.Context, title: str, **kwargs: object) -> object: def remote_cli(ctx: click.Context, title: str, **kwargs: object) -> object:
+231
View File
@@ -0,0 +1,231 @@
"""
Named CLI color themes.
Each palette maps the same role keys to colors: `text`/`text2` for body copy, `pink` for the
brand accent, the six ANSI hues, and the gray ramp used for rules and dim details. The active
palette drives both the rich Console theme (banner, logs, progress) and the click help screens,
so a single `theme` config value restyles the whole CLI.
"""
from __future__ import annotations
from rich_click import RichHelpConfiguration
DEFAULT_THEME = "catppuccin-mocha"
PALETTES: dict[str, dict[str, str]] = {
# Colors based on "CatppuccinMocha" from Gogh themes
"catppuccin-mocha": {
"bg": "rgb(30,30,46)",
"text": "rgb(205,214,244)",
"text2": "rgb(162,169,193)", # slightly darker
"black": "rgb(69,71,90)",
"bright_black": "rgb(88,91,112)",
"red": "rgb(243,139,168)",
"green": "rgb(166,227,161)",
"yellow": "rgb(249,226,175)",
"blue": "rgb(137,180,250)",
"pink": "rgb(245,194,231)",
"cyan": "rgb(148,226,213)",
"gray": "rgb(166,173,200)",
"bright_gray": "rgb(186,194,222)",
"dark_gray": "rgb(54,54,84)",
},
# draculatheme.com spec; purple stands in for blue, comment for the dim grays
"dracula": {
"bg": "#282a36",
"text": "#f8f8f2",
"text2": "#b8bfd8",
"black": "#21222c",
"bright_black": "#6272a4",
"red": "#ff5555",
"green": "#50fa7b",
"yellow": "#f1fa8c",
"blue": "#bd93f9",
"pink": "#ff79c6",
"cyan": "#8be9fd",
"gray": "#6272a4",
"bright_gray": "#e9e9f4",
"dark_gray": "#44475a",
},
# nordtheme.com: polar night / snow storm / frost / aurora
"nord": {
"bg": "#2e3440",
"text": "#d8dee9",
"text2": "#9aa4b8",
"black": "#3b4252",
"bright_black": "#4c566a",
"red": "#bf616a",
"green": "#a3be8c",
"yellow": "#ebcb8b",
"blue": "#81a1c1",
"pink": "#b48ead",
"cyan": "#88c0d0",
"gray": "#616e88",
"bright_gray": "#e5e9f0",
"dark_gray": "#434c5e",
},
# github.com/morhetz/gruvbox dark, bright variants
"gruvbox": {
"bg": "#282828",
"text": "#ebdbb2",
"text2": "#bdae93",
"black": "#504945",
"bright_black": "#665c54",
"red": "#fb4934",
"green": "#b8bb26",
"yellow": "#fabd2f",
"blue": "#83a598",
"pink": "#d3869b",
"cyan": "#8ec07c",
"gray": "#928374",
"bright_gray": "#d5c4a1",
"dark_gray": "#3c3836",
},
# Atom One Dark syntax palette
"one-dark": {
"bg": "#282c34",
"text": "#abb2bf",
"text2": "#818896",
"black": "#3b4048",
"bright_black": "#5c6370",
"red": "#e06c75",
"green": "#98c379",
"yellow": "#e5c07b",
"blue": "#61afef",
"pink": "#c678dd",
"cyan": "#56b6c2",
"gray": "#5c6370",
"bright_gray": "#c8ccd4",
"dark_gray": "#3e4451",
},
# github.com/enkia/tokyo-night-vscode-theme, night variant
"tokyo-night": {
"bg": "#1a1b26",
"text": "#c0caf5",
"text2": "#a9b1d6",
"black": "#15161e",
"bright_black": "#414868",
"red": "#f7768e",
"green": "#9ece6a",
"yellow": "#e0af68",
"blue": "#7aa2f7",
"pink": "#bb9af7",
"cyan": "#7dcfff",
"gray": "#565f89",
"bright_gray": "#a9b1d6",
"dark_gray": "#292e42",
},
# classic Monokai; purple stands in for pink, 66d9ef doubles as blue
"monokai": {
"bg": "#272822",
"text": "#f8f8f2",
"text2": "#cfcfc2",
"black": "#2e2e2e",
"bright_black": "#75715e",
"red": "#f92672",
"green": "#a6e22e",
"yellow": "#e6db74",
"blue": "#66d9ef",
"pink": "#ae81ff",
"cyan": "#a1efe4",
"gray": "#a59f85",
"bright_gray": "#cfcfc2",
"dark_gray": "#3e3d32",
},
# flexoki.com dark, 400-level accents
"flexoki": {
"bg": "#100f0f",
"text": "#cecdc3",
"text2": "#878580",
"black": "#282726",
"bright_black": "#403e3c",
"red": "#d14d41",
"green": "#879a39",
"yellow": "#d0a215",
"blue": "#4385be",
"pink": "#ce5d97",
"cyan": "#3aa99f",
"gray": "#6f6e69",
"bright_gray": "#b7b5ac",
"dark_gray": "#343331",
},
# ethanschoonover.com/solarized dark; magenta stands in for pink
"solarized-dark": {
"bg": "#002b36",
"text": "#839496",
"text2": "#586e75",
"black": "#073642",
"bright_black": "#586e75",
"red": "#dc322f",
"green": "#859900",
"yellow": "#b58900",
"blue": "#268bd2",
"pink": "#d33682",
"cyan": "#2aa198",
"gray": "#657b83",
"bright_gray": "#93a1a1",
"dark_gray": "#073642",
},
}
ALIASES = {
"default": DEFAULT_THEME,
"catppuccin": DEFAULT_THEME,
"mocha": DEFAULT_THEME,
"onedark": "one-dark",
"tokyonight": "tokyo-night",
"solarized": "solarized-dark",
}
def resolve_palette(name: object) -> dict[str, str] | None:
"""Look up a palette by theme name or alias, case-insensitively.
Takes any YAML scalar since config values are unvalidated; non-strings never match.
Returns a copy: console.py extends the active palette in place, and that must
not pollute the master definitions.
"""
key = str(name or "").strip().lower().replace("_", "-")
palette = PALETTES.get(ALIASES.get(key, key))
return dict(palette) if palette else None
def build_help_config(palette: dict[str, str]) -> RichHelpConfiguration:
"""Build the rich-click help styling for a palette."""
return RichHelpConfiguration(
style_option=palette["text"],
style_switch=palette["green"],
style_argument=palette["cyan"],
style_command=palette["pink"],
style_metavar=palette["yellow"],
style_metavar_separator=f"dim {palette['gray']}",
style_usage=palette["yellow"],
style_usage_command=palette["text"],
style_helptext_first_line=palette["text"],
style_helptext=palette["text2"],
style_option_help=palette["text"],
style_command_help=palette["text"],
style_option_default=f"dim {palette['gray']}",
style_option_envvar=f"dim {palette['yellow']}",
style_required_short=palette["red"],
style_required_long=f"dim {palette['red']}",
style_errors_suggestion=palette["text2"],
style_aborted=palette["red"],
style_options_panel_box=None,
style_options_panel_border=palette["pink"],
style_options_panel_title_style=palette["pink"],
style_commands_panel_box=None,
style_commands_panel_border=palette["pink"],
style_commands_panel_title_style=palette["pink"],
style_errors_panel_box="SQUARE",
style_errors_panel_border=palette["red"],
style_options_table_box="HORIZONTALS",
style_options_table_show_lines=True,
style_options_table_border_style=palette["dark_gray"],
)
def apply_help_theme(palette: dict[str, str]) -> None:
"""Point the patched click help renderer at this palette (read at render time)."""
build_help_config(palette).dump_to_globals()
Generated
+16
View File
@@ -1828,6 +1828,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
] ]
[[package]]
name = "rich-click"
version = "1.9.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/ea/21e4867ea0ef881ffd4c0550fc21a061435e50d6324bcd034396633cbc18/rich_click-1.9.8.tar.gz", hash = "sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371", size = 75363, upload-time = "2026-05-28T19:54:59.144Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl", hash = "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", size = 72189, upload-time = "2026-05-28T19:54:57.867Z" },
]
[[package]] [[package]]
name = "rlaphoenix-m3u8" name = "rlaphoenix-m3u8"
version = "3.4.0" version = "3.4.0"
@@ -2157,6 +2171,7 @@ dependencies = [
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "requests", extra = ["socks"] }, { name = "requests", extra = ["socks"] },
{ name = "rich" }, { name = "rich" },
{ name = "rich-click" },
{ name = "rlaphoenix-m3u8" }, { name = "rlaphoenix-m3u8" },
{ name = "rnet" }, { name = "rnet" },
{ name = "ruamel-yaml" }, { name = "ruamel-yaml" },
@@ -2224,6 +2239,7 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0.1,<7" }, { name = "pyyaml", specifier = ">=6.0.1,<7" },
{ name = "requests", extras = ["socks"], specifier = ">=2.32.5,<3" }, { name = "requests", extras = ["socks"], specifier = ">=2.32.5,<3" },
{ name = "rich", specifier = ">=13.7.1,<15" }, { name = "rich", specifier = ">=13.7.1,<15" },
{ name = "rich-click", specifier = ">=1.9.8" },
{ name = "rlaphoenix-m3u8", specifier = ">=3.4.0,<4" }, { name = "rlaphoenix-m3u8", specifier = ">=3.4.0,<4" },
{ name = "rnet", specifier = ">=2.4.2" }, { name = "rnet", specifier = ">=2.4.2" },
{ name = "ruamel-yaml", specifier = ">=0.18.6,<0.19" }, { name = "ruamel-yaml", specifier = ">=0.18.6,<0.19" },