515 lines
22 KiB
Python
Executable File
515 lines
22 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
SysDeck - Theme Engine Bridge Helper
|
|
Author: Jeremy Anderson (https://dcos.net)
|
|
|
|
1999 POWER-TOOL STYLE: maximum UI control over every theme surface.
|
|
The Theme Engine panel surfaces
|
|
the full set of cockpit.conf theming knobs plus a preset gallery
|
|
and live-preview CSS-variable overrides.
|
|
|
|
Subcommands:
|
|
read-config — read /etc/cockpit/cockpit.conf as text
|
|
write-config <text> — write the full cockpit.conf text (superuser)
|
|
get <section> [key] — get one section or one key from cockpit.conf
|
|
set <section> <key> <value> — set one key (superuser)
|
|
unset <section> <key> — remove one key (superuser)
|
|
reset — delete cockpit.conf entirely (superuser)
|
|
(cockpit falls back to its built-in defaults)
|
|
preset-list — return the built-in preset gallery
|
|
preset-apply <id> — apply one preset (writes cockpit.conf)
|
|
variable-list — return the SysDeck CSS variable surface
|
|
(the --sysdeck-* custom properties in
|
|
shared/sysdeck.css that the panel can
|
|
override live via CSS-variable setter)
|
|
variable-get <name> — read a CSS variable's current value
|
|
(from /var/lib/sysdeck/themes/overrides.css)
|
|
variable-set <name> <value> — write a CSS variable override
|
|
(writes to /var/lib/sysdeck/themes/
|
|
overrides.css; the panel injects the file
|
|
as a <link> at runtime)
|
|
variable-reset — clear all CSS variable overrides
|
|
|
|
Cockpit way: the bridge runs subprocess directly; the JS panel passes
|
|
{ superuser: 'try' } for mutating ops so the cockpit bridge prompts
|
|
via polkit for the org.sysdeck.system.manage action (shipped since
|
|
v0.0.17 — authorizes /usr/bin/systemctl, /usr/bin/hostnamectl, etc.).
|
|
|
|
Cockpit's theming surface is /etc/cockpit/cockpit.conf's [Brand],
|
|
[Theme], [OAuth], [Session], [LogFilter], [HealthConfig], and
|
|
[PrettyEnv] sections — see `man cockpit.conf`. SysDeck's own theming
|
|
surface is the CSS variables in shared/sysdeck.css — those can be
|
|
overridden live without a server round-trip via the variable-*
|
|
subcommands.
|
|
|
|
Usage:
|
|
python3 /usr/lib/sysdeck/bridge/themes.py read-config
|
|
python3 /usr/lib/sysdeck/bridge/themes.py set Brand Color red
|
|
python3 /usr/lib/sysdeck/bridge/themes.py preset-apply midnight
|
|
python3 /usr/lib/sysdeck/bridge/themes.py variable-set --sysdeck-bg '#1a1a2a'
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
COCKPIT_CONF = Path("/etc/cockpit/cockpit.conf")
|
|
SYSDECK_THEME_DIR = Path("/var/lib/sysdeck/themes")
|
|
SYSDECK_OVERRIDES_FILE = SYSDECK_THEME_DIR / "overrides.css"
|
|
|
|
# Built-in preset gallery. Each entry is a complete cockpit.conf
|
|
# [Brand]/[Theme] snippet plus a set of CSS variable overrides.
|
|
# Operators can drop additional presets as JSON files into
|
|
# /var/lib/sysdeck/themes/presets/*.json — the preset-list subcommand
|
|
# discovers them automatically.
|
|
PRESETS: list[dict[str, Any]] = [
|
|
{
|
|
"id": "midnight",
|
|
"name": "Midnight",
|
|
"description": "Deep dark blue palette, soft cyan accent. Easy on the eyes at 3am.",
|
|
"cockpit_conf": {"Brand": {"Color": "danger"}, "Theme": {"style": "dark"}},
|
|
"css_variables": {
|
|
"--sysdeck-bg": "#0f0f1a",
|
|
"--sysdeck-fg": "#e0e0e0",
|
|
"--sysdeck-accent": "#06c",
|
|
"--sysdeck-card-bg": "#1a1a2a",
|
|
},
|
|
},
|
|
{
|
|
"id": "alpine",
|
|
"name": "Alpine",
|
|
"description": "Cool white-on-grey. Default-style with sharper contrast.",
|
|
"cockpit_conf": {"Theme": {"style": "light"}},
|
|
"css_variables": {
|
|
"--sysdeck-bg": "#f5f5f5",
|
|
"--sysdeck-fg": "#1a1a1a",
|
|
"--sysdeck-accent": "#0066cc",
|
|
"--sysdeck-card-bg": "#ffffff",
|
|
},
|
|
},
|
|
{
|
|
"id": "forest",
|
|
"name": "Forest",
|
|
"description": "Dark green palette for ops environments with red/green alert emphasis.",
|
|
"cockpit_conf": {"Brand": {"Color": "success"}, "Theme": {"style": "dark"}},
|
|
"css_variables": {
|
|
"--sysdeck-bg": "#0f1a0f",
|
|
"--sysdeck-fg": "#cfe0c0",
|
|
"--sysdeck-accent": "#3c9",
|
|
"--sysdeck-card-bg": "#1a2a1a",
|
|
},
|
|
},
|
|
{
|
|
"id": "amber",
|
|
"name": "Amber",
|
|
"description": "Warm dark amber palette — like a 1999 CRT terminal.",
|
|
"cockpit_conf": {"Theme": {"style": "dark"}},
|
|
"css_variables": {
|
|
"--sysdeck-bg": "#1a0f00",
|
|
"--sysdeck-fg": "#ffb000",
|
|
"--sysdeck-accent": "#ff8c00",
|
|
"--sysdeck-card-bg": "#2a1f00",
|
|
},
|
|
},
|
|
{
|
|
"id": "violet",
|
|
"name": "Violet",
|
|
"description": "Deep purple palette — neon-on-dark synthwave.",
|
|
"cockpit_conf": {"Theme": {"style": "dark"}},
|
|
"css_variables": {
|
|
"--sysdeck-bg": "#1a0a2a",
|
|
"--sysdeck-fg": "#e0d0ff",
|
|
"--sysdeck-accent": "#9c3",
|
|
"--sysdeck-card-bg": "#2a1a3a",
|
|
},
|
|
},
|
|
{
|
|
"id": "high-contrast",
|
|
"name": "High Contrast",
|
|
"description": "Black-on-white maximum contrast for accessibility.",
|
|
"cockpit_conf": {"Theme": {"style": "light"}},
|
|
"css_variables": {
|
|
"--sysdeck-bg": "#ffffff",
|
|
"--sysdeck-fg": "#000000",
|
|
"--sysdeck-accent": "#0000ff",
|
|
"--sysdeck-card-bg": "#ffffff",
|
|
"--sysdeck-border": "#000000",
|
|
},
|
|
},
|
|
]
|
|
|
|
# The CSS-variable surface the panel can override live. Each entry:
|
|
# (name, kind, default, description). The 'kind' tells the JS panel
|
|
# which input control to render: 'color' → <input type=color>,
|
|
# 'select' → <select>, 'number' → <input type=number>, 'text' →
|
|
# <input type=text>.
|
|
CSS_VARIABLES: list[dict[str, Any]] = [
|
|
{"name": "--sysdeck-bg", "kind": "color", "default": "#1e1e1e", "description": "Page background"},
|
|
{"name": "--sysdeck-fg", "kind": "color", "default": "#e0e0e0", "description": "Primary text color"},
|
|
{"name": "--sysdeck-muted", "kind": "color", "default": "#888888", "description": "Muted/secondary text"},
|
|
{"name": "--sysdeck-accent", "kind": "color", "default": "#0066cc", "description": "Accent color for links/highlights"},
|
|
{"name": "--sysdeck-accent-success","kind": "color", "default": "#33cc99", "description": "Success badge color"},
|
|
{"name": "--sysdeck-accent-warn", "kind": "color", "default": "#f0ad4e", "description": "Warning badge color"},
|
|
{"name": "--sysdeck-accent-danger","kind": "color", "default": "#d9534f", "description": "Danger badge color"},
|
|
{"name": "--sysdeck-border", "kind": "color", "default": "#444444", "description": "Card border color"},
|
|
{"name": "--sysdeck-card-bg", "kind": "color", "default": "#2a2a2a", "description": "Card background color"},
|
|
{"name": "--sysdeck-font-size-base","kind": "number","default": "14", "description": "Base font size (px)"},
|
|
{"name": "--sysdeck-density", "kind": "select", "default": "normal", "description": "Padding density",
|
|
"options": ["compact", "normal", "comfortable"]},
|
|
{"name": "--sysdeck-radius", "kind": "number", "default": "6", "description": "Card border radius (px)"},
|
|
]
|
|
|
|
|
|
# ── INI-style parser ─────────────────────────────────────────────────
|
|
#
|
|
# cockpit.conf is INI-format. Python's configparser handles it, but
|
|
# SysDeck needs to preserve comments and ordering when writing back.
|
|
# This minimal parser round-trips comments by treating them as part
|
|
# of the previous section's body. Good enough for the operator-facing
|
|
# theme panel — the bridge does not need a full INI library.
|
|
|
|
SECTION_RE = re.compile(r"^\[(?P<name>[^\]]+)\]\s*$")
|
|
KV_RE = re.compile(r"^\s*(?P<key>[^=\s]+)\s*=\s*(?P<val>.*)$")
|
|
|
|
|
|
def _parse_conf(text: str) -> dict[str, dict[str, str]]:
|
|
"""Parse cockpit.conf text into {section: {key: val}}.
|
|
|
|
Comments (lines starting with # or ;) are stripped from the parsed
|
|
structure; the operator can re-add them via the raw text editor.
|
|
"""
|
|
sections: dict[str, dict[str, str]] = {}
|
|
current: dict[str, str] = {}
|
|
current_name = ""
|
|
for line in text.splitlines():
|
|
if not line.strip() or line.lstrip().startswith(("#", ";")):
|
|
continue
|
|
m = SECTION_RE.match(line)
|
|
if m:
|
|
if current_name:
|
|
sections[current_name] = current
|
|
current_name = m.group("name")
|
|
current = {}
|
|
continue
|
|
kv = KV_RE.match(line)
|
|
if kv and current_name:
|
|
current[kv.group("key")] = kv.group("val").strip()
|
|
if current_name:
|
|
sections[current_name] = current
|
|
return sections
|
|
|
|
|
|
def _serialize_conf(sections: dict[str, dict[str, str]]) -> str:
|
|
"""Serialize the sections dict back to cockpit.conf text.
|
|
|
|
Section order is preserved (Python 3.7+ dict is ordered). Within
|
|
a section, key order is preserved.
|
|
"""
|
|
lines: list[str] = ["# cockpit.conf — managed by SysDeck Theme Engine"]
|
|
for section, kvs in sections.items():
|
|
lines.append("")
|
|
lines.append(f"[{section}]")
|
|
for k, v in kvs.items():
|
|
lines.append(f"{k} = {v}")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _read_text() -> str:
|
|
"""Read cockpit.conf as text. Returns '' if absent."""
|
|
try:
|
|
return COCKPIT_CONF.read_text(encoding="utf-8")
|
|
except (FileNotFoundError, PermissionError, OSError):
|
|
return ""
|
|
|
|
|
|
def _write_text(text: str) -> None:
|
|
"""Write cockpit.conf text. Creates parent dir if needed."""
|
|
COCKPIT_CONF.parent.mkdir(parents=True, exist_ok=True)
|
|
COCKPIT_CONF.write_text(text, encoding="utf-8")
|
|
|
|
|
|
# ── Subcommands ──────────────────────────────────────────────────────
|
|
|
|
|
|
def cmd_read_config(_args: list[str]) -> dict[str, Any]:
|
|
"""Return the raw cockpit.conf text + parsed sections."""
|
|
text = _read_text()
|
|
return {
|
|
"path": str(COCKPIT_CONF),
|
|
"text": text or "# (file absent — cockpit defaults in effect)",
|
|
"sections": _parse_conf(text),
|
|
"present": bool(text),
|
|
}
|
|
|
|
|
|
def cmd_write_config(args: list[str]) -> dict[str, Any]:
|
|
"""Overwrite cockpit.conf with the given text.
|
|
|
|
Usage: write-config <text>. The text is taken as the first argv
|
|
element (the JS panel sends it as a single quoted argument).
|
|
"""
|
|
if not args:
|
|
return {"error": "text required"}
|
|
text = args[0]
|
|
try:
|
|
_write_text(text)
|
|
return {"written": True, "path": str(COCKPIT_CONF), "size": len(text)}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"written": False, "error": str(exc),
|
|
"hint": "run via cockpit superuser channel (polkit org.sysdeck.system.manage)"}
|
|
|
|
|
|
def cmd_get(args: list[str]) -> dict[str, Any]:
|
|
"""Get one section or one key from cockpit.conf.
|
|
|
|
Usage: get <section> [key]. Without a key, returns the whole
|
|
section. With a key, returns just that key's value.
|
|
"""
|
|
if not args:
|
|
return {"error": "section name required"}
|
|
sections = _parse_conf(_read_text())
|
|
section = args[0]
|
|
if section not in sections:
|
|
return {"error": f"section [{section}] not found", "available_sections": list(sections.keys())}
|
|
if len(args) < 2:
|
|
return {"section": section, "keys": sections[section]}
|
|
key = args[1]
|
|
if key not in sections[section]:
|
|
return {"error": f"key {key} not found in section [{section}]",
|
|
"available_keys": list(sections[section].keys())}
|
|
return {"section": section, "key": key, "value": sections[section][key]}
|
|
|
|
|
|
def cmd_set(args: list[str]) -> dict[str, Any]:
|
|
"""Set one key in cockpit.conf.
|
|
|
|
Usage: set <section> <key> <value>. Creates the section if absent.
|
|
|
|
v0.1.4 SECURITY: section/key/value arrive as raw argv and are
|
|
serialized into /etc/cockpit/cockpit.conf with naive `f"{k} = {v}"
|
|
lines. A value containing a newline could inject whole new
|
|
sections/keys into cockpit.conf ([WebService]/[Session] knobs) the
|
|
next time cockpit parses it (found by the 0.3.0 security audit).
|
|
Newlines, NULs, brackets in section names and '=' in keys are now
|
|
rejected; write-config remains the operator's explicit raw editor.
|
|
"""
|
|
if len(args) < 3:
|
|
return {"error": "usage: set <section> <key> <value>"}
|
|
section, key, value = args[0], args[1], args[2]
|
|
if re.search(r"[\r\n\0]", section + key + value) or re.search(r"[\[\]]", section):
|
|
return {"error": "refusing to set: section/key/value must be single-line "
|
|
"(no newlines, no NULs; no brackets in section names)"}
|
|
if "=" in key:
|
|
return {"error": "refusing to set: key must not contain '='"}
|
|
text = _read_text()
|
|
sections = _parse_conf(text)
|
|
sections.setdefault(section, {})[key] = value
|
|
try:
|
|
_write_text(_serialize_conf(sections))
|
|
return {"set": True, "section": section, "key": key, "value": value}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"set": False, "error": str(exc),
|
|
"hint": "run via cockpit superuser channel (polkit org.sysdeck.system.manage)"}
|
|
|
|
|
|
def cmd_unset(args: list[str]) -> dict[str, Any]:
|
|
"""Remove a key from cockpit.conf."""
|
|
if len(args) < 2:
|
|
return {"error": "usage: unset <section> <key>"}
|
|
section, key = args[0], args[1]
|
|
text = _read_text()
|
|
sections = _parse_conf(text)
|
|
if section not in sections or key not in sections[section]:
|
|
return {"unset": False, "error": f"key {key} not in section [{section}]"}
|
|
del sections[section][key]
|
|
if not sections[section]:
|
|
del sections[section]
|
|
try:
|
|
_write_text(_serialize_conf(sections))
|
|
return {"unset": True, "section": section, "key": key}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"unset": False, "error": str(exc)}
|
|
|
|
|
|
def cmd_reset(_args: list[str]) -> dict[str, Any]:
|
|
"""Reset cockpit.conf to defaults (delete the file)."""
|
|
try:
|
|
COCKPIT_CONF.unlink(missing_ok=True)
|
|
return {"reset": True, "path": str(COCKPIT_CONF)}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"reset": False, "error": str(exc)}
|
|
|
|
|
|
# ── Preset gallery ──────────────────────────────────────────────────
|
|
|
|
|
|
def cmd_preset_list(_args: list[str]) -> dict[str, Any]:
|
|
"""Return the built-in preset gallery + any operator-dropped JSON."""
|
|
presets = list(PRESETS)
|
|
# Discover operator-dropped presets in /var/lib/sysdeck/themes/presets/.
|
|
custom_dir = SYSDECK_THEME_DIR / "presets"
|
|
if custom_dir.is_dir():
|
|
for p in sorted(custom_dir.glob("*.json")):
|
|
try:
|
|
preset = json.loads(p.read_text(encoding="utf-8"))
|
|
if "id" in preset and "name" in preset:
|
|
preset["_custom"] = True
|
|
preset["_source"] = str(p)
|
|
presets.append(preset)
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
return {"presets": presets, "count": len(presets)}
|
|
|
|
|
|
def cmd_preset_apply(args: list[str]) -> dict[str, Any]:
|
|
"""Apply a preset — writes both cockpit.conf and the CSS overrides."""
|
|
if not args:
|
|
return {"error": "preset id required"}
|
|
preset_id = args[0]
|
|
preset = next((p for p in PRESETS if p["id"] == preset_id), None)
|
|
if preset is None:
|
|
return {"error": f"preset '{preset_id}' not found",
|
|
"hint": "call preset-list for available ids"}
|
|
# 1. Apply the cockpit.conf portion.
|
|
text = _read_text()
|
|
sections = _parse_conf(text)
|
|
for section, kvs in (preset.get("cockpit_conf") or {}).items():
|
|
sections.setdefault(section, {}).update(kvs)
|
|
try:
|
|
_write_text(_serialize_conf(sections))
|
|
except (PermissionError, OSError) as exc:
|
|
return {"applied": False, "error": str(exc)}
|
|
# 2. Apply the CSS variable overrides.
|
|
css_vars = preset.get("css_variables") or {}
|
|
try:
|
|
SYSDECK_THEME_DIR.mkdir(parents=True, exist_ok=True)
|
|
css = ":root {\n" + "\n".join(f" {k}: {v};" for k, v in css_vars.items()) + "\n}\n"
|
|
SYSDECK_OVERRIDES_FILE.write_text(css, encoding="utf-8")
|
|
except (PermissionError, OSError) as exc:
|
|
return {"applied": True, "cockpit_conf": True, "css_overrides": False, "error": str(exc)}
|
|
return {
|
|
"applied": True,
|
|
"preset": preset_id,
|
|
"cockpit_conf_sections_written": list((preset.get("cockpit_conf") or {}).keys()),
|
|
"css_variables_written": list(css_vars.keys()),
|
|
}
|
|
|
|
|
|
# ── CSS variable overrides ───────────────────────────────────────────
|
|
|
|
|
|
def cmd_variable_list(_args: list[str]) -> dict[str, Any]:
|
|
"""Return the SysDeck CSS variable surface."""
|
|
return {"variables": CSS_VARIABLES, "count": len(CSS_VARIABLES)}
|
|
|
|
|
|
def cmd_variable_get(args: list[str]) -> dict[str, Any]:
|
|
"""Read a CSS variable's current override value (or default)."""
|
|
if not args:
|
|
return {"error": "variable name required"}
|
|
name = args[0]
|
|
spec = next((v for v in CSS_VARIABLES if v["name"] == name), None)
|
|
if spec is None:
|
|
return {"error": f"variable {name} not in the surface",
|
|
"hint": "call variable-list for the available names"}
|
|
# Read from overrides file.
|
|
val = spec["default"]
|
|
try:
|
|
text = SYSDECK_OVERRIDES_FILE.read_text(encoding="utf-8")
|
|
m = re.search(rf"{re.escape(name)}\s*:\s*([^;]+);", text)
|
|
if m:
|
|
val = m.group(1).strip()
|
|
except (FileNotFoundError, OSError):
|
|
pass
|
|
return {"name": name, "value": val, "default": spec["default"], "kind": spec["kind"]}
|
|
|
|
|
|
def cmd_variable_set(args: list[str]) -> dict[str, Any]:
|
|
"""Write a CSS variable override."""
|
|
if len(args) < 2:
|
|
return {"error": "usage: variable-set <name> <value>"}
|
|
name, value = args[0], args[1]
|
|
spec = next((v for v in CSS_VARIABLES if v["name"] == name), None)
|
|
if spec is None:
|
|
return {"error": f"variable {name} not in the surface"}
|
|
# Values land in overrides.css, which every SysDeck page loads.
|
|
# Accept color literals and numeric expressions only: no braces,
|
|
# semicolons, quotes, or url()/import tokens — a value that could
|
|
# break out of the declaration or fetch a remote asset is rejected
|
|
# at the door, not sanitized after the fact.
|
|
if not re.fullmatch(r"[A-Za-z0-9 #%(),./_-]{1,128}", value):
|
|
return {"error": "value must be 1-128 chars of color/number syntax "
|
|
"(letters, digits, space, # % ( ) , . / _ -)"}
|
|
# Values land in overrides.css, which every SysDeck page loads.
|
|
# Accept color literals and numeric expressions only: no braces,
|
|
# semicolons, quotes, or url()/import tokens — a value that could
|
|
# break out of the declaration or fetch a remote asset is rejected
|
|
# at the door, not sanitized after the fact.
|
|
if not re.fullmatch(r"[A-Za-z0-9 #%(),./_-]{1,128}", value):
|
|
return {"error": "value must be 1-128 chars of color/number syntax "
|
|
"(letters, digits, space, # % ( ) , . / _ -)"}
|
|
try:
|
|
SYSDECK_THEME_DIR.mkdir(parents=True, exist_ok=True)
|
|
# Read existing overrides, replace or append this variable.
|
|
text = SYSDECK_OVERRIDES_FILE.read_text(encoding="utf-8") if SYSDECK_OVERRIDES_FILE.is_file() else ""
|
|
if re.search(rf"{re.escape(name)}\s*:", text):
|
|
text = re.sub(rf"{re.escape(name)}\s*:\s*[^;]+;", f"{name}: {value};", text)
|
|
else:
|
|
# Insert before the closing }.
|
|
text = text.replace("}", f" {name}: {value};\n}}", 1) if "}" in text else f":root {{\n {name}: {value};\n}}\n"
|
|
SYSDECK_OVERRIDES_FILE.write_text(text, encoding="utf-8")
|
|
return {"set": True, "name": name, "value": value}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"set": False, "error": str(exc)}
|
|
|
|
|
|
def cmd_variable_reset(_args: list[str]) -> dict[str, Any]:
|
|
"""Clear all CSS variable overrides."""
|
|
try:
|
|
SYSDECK_OVERRIDES_FILE.unlink(missing_ok=True)
|
|
return {"reset": True, "path": str(SYSDECK_OVERRIDES_FILE)}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"reset": False, "error": str(exc)}
|
|
|
|
|
|
# ── Dispatch table ───────────────────────────────────────────────────
|
|
|
|
COMMANDS = {
|
|
"read-config": lambda _args: cmd_read_config([]),
|
|
"write-config": lambda args: cmd_write_config(args),
|
|
"get": lambda args: cmd_get(args),
|
|
"set": lambda args: cmd_set(args),
|
|
"unset": lambda args: cmd_unset(args),
|
|
"reset": lambda _args: cmd_reset([]),
|
|
"preset-list": lambda _args: cmd_preset_list([]),
|
|
"preset-apply": lambda args: cmd_preset_apply(args),
|
|
"variable-list": lambda _args: cmd_variable_list([]),
|
|
"variable-get": lambda args: cmd_variable_get(args),
|
|
"variable-set": lambda args: cmd_variable_set(args),
|
|
"variable-reset": lambda _args: cmd_variable_reset([]),
|
|
}
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if not argv or argv[0] in ("-h", "--help"):
|
|
print(__doc__)
|
|
return 0
|
|
cmd = COMMANDS.get(argv[0])
|
|
if not cmd:
|
|
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
|
|
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
|
|
return 2
|
|
print(json.dumps(cmd(argv[1:]), indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|