Files
2026-07-13 12:55:23 +08:00

870 lines
28 KiB
Python

#!/usr/bin/env python3
"""OBS Studio CLI -- A stateful command-line interface for OBS scene collection editing.
This CLI provides full OBS Studio scene management capabilities using a JSON
scene collection format. No OBS installation required for editing.
Usage:
# One-shot commands
python3 -m cli.obs_cli project new --name "my_stream"
python3 -m cli.obs_cli source add video_capture --name "Camera"
python3 -m cli.obs_cli scene add --name "BRB"
# Interactive REPL
python3 -m cli.obs_cli repl
"""
import sys
import os
import json
import shlex
import click
from typing import Optional
# Add parent to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from cli_anything.obs_studio.core.session import Session
from cli_anything.obs_studio.core import project as proj_mod
from cli_anything.obs_studio.core import scenes as scene_mod
from cli_anything.obs_studio.core import sources as src_mod
from cli_anything.obs_studio.core import filters as filt_mod
from cli_anything.obs_studio.core import audio as audio_mod
from cli_anything.obs_studio.core import transitions as trans_mod
from cli_anything.obs_studio.core import output as out_mod
# Global session state
_session: Optional[Session] = None
_json_output = False
_repl_mode = False
def get_session() -> Session:
global _session
if _session is None:
_session = Session()
return _session
def output(data, message: str = ""):
if _json_output:
click.echo(json.dumps(data, indent=2, default=str))
else:
if message:
click.echo(message)
if isinstance(data, dict):
_print_dict(data)
elif isinstance(data, list):
_print_list(data)
else:
click.echo(str(data))
def _print_dict(d: dict, indent: int = 0):
prefix = " " * indent
for k, v in d.items():
if isinstance(v, dict):
click.echo(f"{prefix}{k}:")
_print_dict(v, indent + 1)
elif isinstance(v, list):
click.echo(f"{prefix}{k}:")
_print_list(v, indent + 1)
else:
click.echo(f"{prefix}{k}: {v}")
def _print_list(items: list, indent: int = 0):
prefix = " " * indent
for i, item in enumerate(items):
if isinstance(item, dict):
click.echo(f"{prefix}[{i}]")
_print_dict(item, indent + 1)
else:
click.echo(f"{prefix}- {item}")
def handle_error(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except FileNotFoundError as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "file_not_found"}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
except (ValueError, IndexError, RuntimeError) as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
except FileExistsError as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "file_exists"}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
wrapper.__name__ = func.__name__
wrapper.__doc__ = func.__doc__
return wrapper
# -- Main CLI Group ----------------------------------------------------------
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--project", "project_path", type=str, default=None,
help="Path to OBS scene collection JSON file")
@click.option("--dry-run", "dry_run", is_flag=True, default=False,
help="Run command without saving changes to disk")
@click.pass_context
def cli(ctx, use_json, project_path, dry_run):
"""OBS Studio CLI -- Stateful scene collection editing from the command line.
Run without a subcommand to enter interactive REPL mode.
"""
global _json_output
_json_output = use_json
if project_path:
sess = get_session()
if not sess.has_project():
proj = proj_mod.open_project(project_path)
sess.set_project(proj, project_path)
if ctx.invoked_subcommand is None:
ctx.invoke(repl, project_path=None)
@cli.result_callback()
def auto_save_on_exit(result, use_json, project_path, dry_run, **kwargs):
"""Auto-save project after one-shot commands if state was modified."""
if _repl_mode:
return
if dry_run:
return
sess = get_session()
if sess.has_project() and sess._modified and sess.project_path:
try:
sess.save_session()
except Exception as e:
click.echo(f"Warning: Auto-save failed: {e}", err=True)
# -- Project Commands --------------------------------------------------------
@cli.group()
def project():
"""Project management commands."""
pass
@project.command("new")
@click.option("--name", "-n", default="untitled", help="Project name")
@click.option("--width", "-w", type=int, default=1920, help="Output width")
@click.option("--height", "-h", type=int, default=1080, help="Output height")
@click.option("--fps", type=int, default=30, help="Frames per second")
@click.option("--encoder", type=str, default="x264", help="Video encoder")
@click.option("--output", "-o", type=str, default=None, help="Save path")
@handle_error
def project_new(name, width, height, fps, encoder, output):
"""Create a new OBS scene collection."""
proj = proj_mod.create_project(
name=name, output_width=width, output_height=height,
fps=fps, encoder=encoder,
)
sess = get_session()
sess.set_project(proj, output)
if output:
proj_mod.save_project(proj, output)
info = proj_mod.get_project_info(proj)
globals()["output"](info, f"Created project: {name}")
@project.command("open")
@click.argument("path")
@handle_error
def project_open(path):
"""Open an existing project."""
proj = proj_mod.open_project(path)
sess = get_session()
sess.set_project(proj, path)
info = proj_mod.get_project_info(proj)
globals()["output"](info, f"Opened: {path}")
@project.command("save")
@click.argument("path", required=False)
@handle_error
def project_save(path):
"""Save the current project."""
sess = get_session()
saved = sess.save_session(path)
globals()["output"]({"saved": saved}, f"Saved to: {saved}")
@project.command("info")
@handle_error
def project_info():
"""Show project information."""
sess = get_session()
info = proj_mod.get_project_info(sess.get_project())
globals()["output"](info)
@project.command("json")
@handle_error
def project_json():
"""Print raw project JSON."""
sess = get_session()
click.echo(json.dumps(sess.get_project(), indent=2, default=str))
# -- Scene Commands ----------------------------------------------------------
@cli.group("scene")
def scene_group():
"""Scene management commands."""
pass
@scene_group.command("add")
@click.option("--name", "-n", default="Scene", help="Scene name")
@handle_error
def scene_add(name):
"""Add a new scene."""
sess = get_session()
sess.snapshot(f"Add scene: {name}")
scene = scene_mod.add_scene(sess.get_project(), name=name)
output(scene, f"Added scene: {scene['name']}")
@scene_group.command("remove")
@click.argument("index", type=int)
@handle_error
def scene_remove(index):
"""Remove a scene by index."""
sess = get_session()
sess.snapshot(f"Remove scene {index}")
removed = scene_mod.remove_scene(sess.get_project(), index)
output(removed, f"Removed scene: {removed['name']}")
@scene_group.command("duplicate")
@click.argument("index", type=int)
@handle_error
def scene_duplicate(index):
"""Duplicate a scene."""
sess = get_session()
sess.snapshot(f"Duplicate scene {index}")
dup = scene_mod.duplicate_scene(sess.get_project(), index)
output(dup, f"Duplicated scene: {dup['name']}")
@scene_group.command("set-active")
@click.argument("index", type=int)
@handle_error
def scene_set_active(index):
"""Set the active scene."""
sess = get_session()
sess.snapshot(f"Set active scene {index}")
result = scene_mod.set_active_scene(sess.get_project(), index)
output(result, f"Active scene: {result['active_scene']}")
@scene_group.command("list")
@handle_error
def scene_list():
"""List all scenes."""
sess = get_session()
scenes = scene_mod.list_scenes(sess.get_project())
output(scenes, "Scenes:")
# -- Source Commands ---------------------------------------------------------
@cli.group("source")
def source_group():
"""Source management commands."""
pass
@source_group.command("add")
@click.argument("source_type", type=click.Choice(sorted(src_mod.SOURCE_TYPES.keys())))
@click.option("--name", "-n", default=None, help="Source name")
@click.option("--scene", "-s", "scene_index", type=int, default=0, help="Scene index")
@click.option("--position", "-p", default=None, help="Position x,y")
@click.option("--size", default=None, help="Size widthxheight")
@click.option("--setting", "-S", multiple=True, help="Setting: key=value")
@handle_error
def source_add(source_type, name, scene_index, position, size, setting):
"""Add a source to a scene."""
pos = None
if position:
parts = position.split(",")
pos = {"x": float(parts[0]), "y": float(parts[1])}
sz = None
if size:
parts = size.split("x")
sz = {"width": int(parts[0]), "height": int(parts[1])}
settings = {}
for s in setting:
if "=" not in s:
raise ValueError(f"Invalid setting format: '{s}'. Use key=value.")
k, v = s.split("=", 1)
try:
v = float(v) if "." in v else int(v)
except ValueError:
pass
settings[k] = v
sess = get_session()
sess.snapshot(f"Add source: {source_type}")
src = src_mod.add_source(
sess.get_project(), source_type, scene_index=scene_index,
name=name, position=pos, size=sz, settings=settings if settings else None,
)
output(src, f"Added {source_type}: {src['name']}")
@source_group.command("remove")
@click.argument("index", type=int)
@click.option("--scene", "-s", "scene_index", type=int, default=0)
@handle_error
def source_remove(index, scene_index):
"""Remove a source by index."""
sess = get_session()
sess.snapshot(f"Remove source {index}")
removed = src_mod.remove_source(sess.get_project(), index, scene_index)
output(removed, f"Removed source: {removed['name']}")
@source_group.command("duplicate")
@click.argument("index", type=int)
@click.option("--scene", "-s", "scene_index", type=int, default=0)
@handle_error
def source_duplicate(index, scene_index):
"""Duplicate a source."""
sess = get_session()
sess.snapshot(f"Duplicate source {index}")
dup = src_mod.duplicate_source(sess.get_project(), index, scene_index)
output(dup, f"Duplicated source: {dup['name']}")
@source_group.command("set")
@click.argument("index", type=int)
@click.argument("prop")
@click.argument("value")
@click.option("--scene", "-s", "scene_index", type=int, default=0)
@handle_error
def source_set(index, prop, value, scene_index):
"""Set a source property (name, visible, locked, opacity, rotation)."""
sess = get_session()
sess.snapshot(f"Set source {index} {prop}={value}")
src = src_mod.set_source_property(sess.get_project(), index, prop, value, scene_index)
output({"source": index, "property": prop, "value": value},
f"Set source {index} {prop} = {value}")
@source_group.command("transform")
@click.argument("index", type=int)
@click.option("--position", "-p", default=None, help="Position x,y")
@click.option("--size", default=None, help="Size widthxheight")
@click.option("--crop", default=None, help="Crop top,bottom,left,right")
@click.option("--rotation", "-r", type=float, default=None)
@click.option("--scene", "-s", "scene_index", type=int, default=0)
@handle_error
def source_transform(index, position, size, crop, rotation, scene_index):
"""Transform a source (position, size, crop, rotation)."""
pos = None
if position:
parts = position.split(",")
pos = {"x": float(parts[0]), "y": float(parts[1])}
sz = None
if size:
parts = size.split("x")
sz = {"width": int(parts[0]), "height": int(parts[1])}
cr = None
if crop:
parts = crop.split(",")
cr = {"top": int(parts[0]), "bottom": int(parts[1]),
"left": int(parts[2]), "right": int(parts[3])}
sess = get_session()
sess.snapshot(f"Transform source {index}")
src = src_mod.transform_source(
sess.get_project(), index, scene_index,
position=pos, size=sz, crop=cr, rotation=rotation,
)
output(src, f"Transformed source {index}: {src['name']}")
@source_group.command("list")
@click.option("--scene", "-s", "scene_index", type=int, default=0)
@handle_error
def source_list(scene_index):
"""List all sources in a scene."""
sess = get_session()
sources = src_mod.list_sources(sess.get_project(), scene_index)
output(sources, "Sources:")
# -- Filter Commands ---------------------------------------------------------
@cli.group("filter")
def filter_group():
"""Filter management commands."""
pass
@filter_group.command("add")
@click.argument("filter_type", type=click.Choice(sorted(filt_mod.FILTER_TYPES.keys())))
@click.option("--source", "-S", "source_index", type=int, default=0, help="Source index")
@click.option("--scene", "-s", "scene_index", type=int, default=0, help="Scene index")
@click.option("--name", "-n", default=None, help="Filter name")
@click.option("--param", "-p", multiple=True, help="Parameter: key=value")
@handle_error
def filter_add(filter_type, source_index, scene_index, name, param):
"""Add a filter to a source."""
params = {}
for p in param:
if "=" not in p:
raise ValueError(f"Invalid param format: '{p}'. Use key=value.")
k, v = p.split("=", 1)
try:
v = float(v) if "." in v else int(v)
except ValueError:
pass
params[k] = v
sess = get_session()
sess.snapshot(f"Add filter: {filter_type}")
filt = filt_mod.add_filter(
sess.get_project(), filter_type, source_index, scene_index,
name=name, params=params if params else None,
)
output(filt, f"Added filter: {filt['name']}")
@filter_group.command("remove")
@click.argument("filter_index", type=int)
@click.option("--source", "-S", "source_index", type=int, default=0)
@click.option("--scene", "-s", "scene_index", type=int, default=0)
@handle_error
def filter_remove(filter_index, source_index, scene_index):
"""Remove a filter from a source."""
sess = get_session()
sess.snapshot(f"Remove filter {filter_index}")
removed = filt_mod.remove_filter(sess.get_project(), filter_index, source_index, scene_index)
output(removed, f"Removed filter: {removed['name']}")
@filter_group.command("set")
@click.argument("filter_index", type=int)
@click.argument("param")
@click.argument("value")
@click.option("--source", "-S", "source_index", type=int, default=0)
@click.option("--scene", "-s", "scene_index", type=int, default=0)
@handle_error
def filter_set(filter_index, param, value, source_index, scene_index):
"""Set a filter parameter."""
try:
value = float(value) if "." in str(value) else int(value)
except ValueError:
pass
sess = get_session()
sess.snapshot(f"Set filter {filter_index} {param}={value}")
filt_mod.set_filter_param(
sess.get_project(), filter_index, param, value, source_index, scene_index,
)
output({"filter": filter_index, "param": param, "value": value},
f"Set filter {filter_index} {param} = {value}")
@filter_group.command("list")
@click.option("--source", "-S", "source_index", type=int, default=0)
@click.option("--scene", "-s", "scene_index", type=int, default=0)
@handle_error
def filter_list(source_index, scene_index):
"""List all filters on a source."""
sess = get_session()
filters = filt_mod.list_filters(sess.get_project(), source_index, scene_index)
output(filters, "Filters:")
@filter_group.command("list-available")
@click.option("--category", "-c", type=str, default=None, help="Filter by category: video, audio")
@handle_error
def filter_list_available(category):
"""List all available filter types."""
filters = filt_mod.list_available_filters(category)
output(filters, "Available filters:")
# -- Audio Commands ----------------------------------------------------------
@cli.group("audio")
def audio_group():
"""Audio management commands."""
pass
@audio_group.command("add")
@click.option("--name", "-n", default="Audio", help="Audio source name")
@click.option("--type", "audio_type", type=click.Choice(["input", "output"]), default="input")
@click.option("--device", "-d", default="", help="Device identifier")
@click.option("--volume", "-v", type=float, default=1.0, help="Volume (0.0-3.0)")
@handle_error
def audio_add(name, audio_type, device, volume):
"""Add a global audio source."""
sess = get_session()
sess.snapshot(f"Add audio: {name}")
src = audio_mod.add_audio_source(
sess.get_project(), name=name, audio_type=audio_type,
device=device, volume=volume,
)
output(src, f"Added audio source: {src['name']}")
@audio_group.command("remove")
@click.argument("index", type=int)
@handle_error
def audio_remove(index):
"""Remove a global audio source."""
sess = get_session()
sess.snapshot(f"Remove audio {index}")
removed = audio_mod.remove_audio_source(sess.get_project(), index)
output(removed, f"Removed audio: {removed['name']}")
@audio_group.command("volume")
@click.argument("index", type=int)
@click.argument("level", type=float)
@handle_error
def audio_volume(index, level):
"""Set volume for an audio source (0.0-3.0)."""
sess = get_session()
sess.snapshot(f"Set audio {index} volume={level}")
src = audio_mod.set_volume(sess.get_project(), index, level)
output({"audio": index, "volume": level}, f"Volume set to {level}")
@audio_group.command("mute")
@click.argument("index", type=int)
@handle_error
def audio_mute(index):
"""Mute an audio source."""
sess = get_session()
sess.snapshot(f"Mute audio {index}")
src = audio_mod.mute(sess.get_project(), index)
output({"audio": index, "muted": True}, f"Muted audio {index}")
@audio_group.command("unmute")
@click.argument("index", type=int)
@handle_error
def audio_unmute(index):
"""Unmute an audio source."""
sess = get_session()
sess.snapshot(f"Unmute audio {index}")
src = audio_mod.unmute(sess.get_project(), index)
output({"audio": index, "muted": False}, f"Unmuted audio {index}")
@audio_group.command("monitor")
@click.argument("index", type=int)
@click.argument("monitor_type", type=click.Choice(["none", "monitor_only", "monitor_and_output"]))
@handle_error
def audio_monitor(index, monitor_type):
"""Set audio monitoring type."""
sess = get_session()
sess.snapshot(f"Set audio {index} monitor={monitor_type}")
src = audio_mod.set_monitor(sess.get_project(), index, monitor_type)
output({"audio": index, "monitor": monitor_type}, f"Monitor set to {monitor_type}")
@audio_group.command("list")
@handle_error
def audio_list():
"""List all audio sources."""
sess = get_session()
sources = audio_mod.list_audio(sess.get_project())
output(sources, "Audio sources:")
# -- Transition Commands -----------------------------------------------------
@cli.group("transition")
def transition_group():
"""Transition management commands."""
pass
@transition_group.command("add")
@click.argument("transition_type", type=click.Choice(sorted(trans_mod.TRANSITION_TYPES.keys())))
@click.option("--name", "-n", default=None, help="Transition name")
@click.option("--duration", "-d", type=int, default=None, help="Duration in ms")
@handle_error
def transition_add(transition_type, name, duration):
"""Add a transition."""
sess = get_session()
sess.snapshot(f"Add transition: {transition_type}")
trans = trans_mod.add_transition(
sess.get_project(), transition_type, name=name, duration=duration,
)
output(trans, f"Added transition: {trans['name']}")
@transition_group.command("remove")
@click.argument("index", type=int)
@handle_error
def transition_remove(index):
"""Remove a transition."""
sess = get_session()
sess.snapshot(f"Remove transition {index}")
removed = trans_mod.remove_transition(sess.get_project(), index)
output(removed, f"Removed transition: {removed['name']}")
@transition_group.command("set-active")
@click.argument("index", type=int)
@handle_error
def transition_set_active(index):
"""Set the active transition."""
sess = get_session()
sess.snapshot(f"Set active transition {index}")
result = trans_mod.set_active_transition(sess.get_project(), index)
output(result, f"Active transition: {result['active_transition']}")
@transition_group.command("duration")
@click.argument("index", type=int)
@click.argument("duration", type=int)
@handle_error
def transition_duration(index, duration):
"""Set transition duration in milliseconds."""
sess = get_session()
sess.snapshot(f"Set transition {index} duration={duration}")
trans = trans_mod.set_duration(sess.get_project(), index, duration)
output(trans, f"Duration set to {duration}ms")
@transition_group.command("list")
@handle_error
def transition_list():
"""List all transitions."""
sess = get_session()
transitions = trans_mod.list_transitions(sess.get_project())
output(transitions, "Transitions:")
# -- Output Commands ---------------------------------------------------------
@cli.group("output")
def output_group():
"""Output/streaming/recording configuration."""
pass
@output_group.command("streaming")
@click.option("--service", type=click.Choice(["twitch", "youtube", "facebook", "custom"]), default=None)
@click.option("--server", type=str, default=None)
@click.option("--key", type=str, default=None)
@handle_error
def output_streaming(service, server, key):
"""Configure streaming settings."""
sess = get_session()
sess.snapshot("Update streaming settings")
result = out_mod.set_streaming(sess.get_project(), service=service, server=server, key=key)
globals()["output"](result, "Streaming settings updated")
@output_group.command("recording")
@click.option("--path", type=str, default=None)
@click.option("--format", "fmt", type=click.Choice(["mkv", "mp4", "mov", "flv", "ts"]), default=None)
@click.option("--quality", type=click.Choice(["low", "medium", "high", "lossless"]), default=None)
@handle_error
def output_recording(path, fmt, quality):
"""Configure recording settings."""
sess = get_session()
sess.snapshot("Update recording settings")
result = out_mod.set_recording(sess.get_project(), path=path, fmt=fmt, quality=quality)
globals()["output"](result, "Recording settings updated")
@output_group.command("settings")
@click.option("--width", type=int, default=None)
@click.option("--height", type=int, default=None)
@click.option("--fps", type=int, default=None)
@click.option("--video-bitrate", type=int, default=None)
@click.option("--audio-bitrate", type=int, default=None)
@click.option("--encoder", type=str, default=None)
@click.option("--preset", type=str, default=None, help="Apply encoding preset")
@handle_error
def output_settings(width, height, fps, video_bitrate, audio_bitrate, encoder, preset):
"""Configure output settings."""
sess = get_session()
sess.snapshot("Update output settings")
result = out_mod.set_output_settings(
sess.get_project(),
output_width=width, output_height=height, fps=fps,
video_bitrate=video_bitrate, audio_bitrate=audio_bitrate,
encoder=encoder, preset=preset,
)
globals()["output"](result, "Output settings updated")
@output_group.command("info")
@handle_error
def output_info():
"""Show current output configuration."""
sess = get_session()
info = out_mod.get_output_info(sess.get_project())
globals()["output"](info)
@output_group.command("presets")
@handle_error
def output_presets():
"""List available encoding presets."""
presets = out_mod.list_encoding_presets()
globals()["output"](presets, "Encoding presets:")
# -- Session Commands --------------------------------------------------------
@cli.group()
def session():
"""Session management commands."""
pass
@session.command("status")
@handle_error
def session_status():
"""Show session status."""
sess = get_session()
output(sess.status())
@session.command("undo")
@handle_error
def session_undo():
"""Undo the last operation."""
sess = get_session()
desc = sess.undo()
output({"undone": desc}, f"Undone: {desc}")
@session.command("redo")
@handle_error
def session_redo():
"""Redo the last undone operation."""
sess = get_session()
desc = sess.redo()
output({"redone": desc}, f"Redone: {desc}")
@session.command("history")
@handle_error
def session_history():
"""Show undo history."""
sess = get_session()
history = sess.list_history()
output(history, "Undo history:")
# -- REPL --------------------------------------------------------------------
@cli.command()
@click.option("--project", "project_path", type=str, default=None)
@handle_error
def repl(project_path):
"""Start interactive REPL session."""
from cli_anything.obs_studio.utils.repl_skin import ReplSkin
global _repl_mode
_repl_mode = True
skin = ReplSkin("obs_studio", version="1.0.0")
if project_path:
sess = get_session()
proj = proj_mod.open_project(project_path)
sess.set_project(proj, project_path)
skin.print_banner()
pt_session = skin.create_prompt_session()
def _get_project_name():
"""Get current project name for prompt display."""
try:
sess = get_session()
if sess.has_project():
info = proj_mod.get_project_info(sess.get_project())
return info.get("name", "")
except Exception:
pass
return ""
while True:
try:
line = skin.get_input(
pt_session,
project_name=_get_project_name(),
modified=False,
).strip()
if not line:
continue
if line.lower() in ("quit", "exit", "q"):
skin.print_goodbye()
break
if line.lower() == "help":
_repl_help(skin)
continue
try:
args = shlex.split(line)
except ValueError:
args = line.split()
try:
cli.main(args, standalone_mode=False)
except SystemExit:
pass
except click.exceptions.UsageError as e:
skin.error(f"Usage error: {e}")
except Exception as e:
skin.error(str(e))
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
_repl_mode = False
def _repl_help(skin=None):
commands = {
"project new|open|save|info|json": "Project management",
"scene add|remove|duplicate|set-active|list": "Scene management",
"source add|remove|duplicate|set|transform|list": "Source management",
"filter add|remove|set|list|list-available": "Filter management",
"audio add|remove|volume|mute|unmute|monitor|list": "Audio management",
"transition add|remove|set-active|duration|list": "Transition management",
"output streaming|recording|settings|info|presets": "Output configuration",
"session status|undo|redo|history": "Session management",
"help": "Show this help",
"quit": "Exit REPL",
}
if skin is not None:
skin.help(commands)
else:
click.echo("\nCommands:")
for cmd, desc in commands.items():
click.echo(f" {cmd:55s} {desc}")
click.echo()
# -- Entry Point -------------------------------------------------------------
def main():
cli()
if __name__ == "__main__":
main()