#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Command-line utility for interacting with mainline via ntfy. Usage: python cmdline.py # Interactive TUI mode python cmdline.py --help # Show help python cmdline.py /effects list # Send single command via ntfy python cmdline.py /effects stats # Get performance stats via ntfy python cmdline.py -w /effects stats # Watch mode (polls for stats) The TUI mode provides: - Arrow keys to navigate command history - Tab completion for commands - Auto-refresh for performance stats C&C works like a serial port: 1. Send command to ntfy_cc_topic 2. Mainline receives, processes, responds to same topic 3. Cmdline polls for response """ import os os.environ["FORCE_COLOR"] = "1" os.environ["TERM"] = "xterm-256color" import argparse import json import sys import time import threading import urllib.request from pathlib import Path from engine import config from engine.terminal import CLR, CURSOR_OFF, CURSOR_ON, G_DIM, G_HI, RST, W_GHOST try: CC_CMD_TOPIC = config.NTFY_CC_CMD_TOPIC CC_RESP_TOPIC = config.NTFY_CC_RESP_TOPIC except AttributeError: CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json" CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json" class NtfyResponsePoller: """Polls ntfy for command responses.""" def __init__(self, cmd_topic: str, resp_topic: str, timeout: float = 10.0): self.cmd_topic = cmd_topic self.resp_topic = resp_topic self.timeout = timeout self._last_id = None self._lock = threading.Lock() def _build_url(self) -> str: from urllib.parse import parse_qs, urlencode, urlparse, urlunparse parsed = urlparse(self.resp_topic) params = parse_qs(parsed.query, keep_blank_values=True) params["since"] = [self._last_id if self._last_id else "20s"] new_query = urlencode({k: v[0] for k, v in params.items()}) return urlunparse(parsed._replace(query=new_query)) def send_and_wait(self, cmd: str) -> str: """Send command and wait for response.""" url = self.cmd_topic.replace("/json", "") data = cmd.encode("utf-8") req = urllib.request.Request( url, data=data, headers={ "User-Agent": "mainline-cmdline/0.1", "Content-Type": "text/plain", }, method="POST", ) try: urllib.request.urlopen(req, timeout=5) except Exception as e: return f"Error sending command: {e}" return self._wait_for_response(cmd) def _wait_for_response(self, expected_cmd: str = "") -> str: """Poll for response message.""" start = time.time() while time.time() - start < self.timeout: try: url = self._build_url() req = urllib.request.Request( url, headers={"User-Agent": "mainline-cmdline/0.1"} ) with urllib.request.urlopen(req, timeout=10) as resp: for line in resp: try: data = json.loads(line.decode("utf-8", errors="replace")) except json.JSONDecodeError: continue if data.get("event") == "message": self._last_id = data.get("id") msg = data.get("message", "") if msg: return msg except Exception: pass time.sleep(0.5) return "Timeout waiting for response" AVAILABLE_COMMANDS = """Available commands: /effects list - List all effects and status /effects on - Enable an effect /effects off - Disable an effect /effects intensity <0.0-1.0> - Set effect intensity /effects reorder ,,... - Reorder pipeline /effects stats - Show performance statistics /help - Show this help /quit - Exit """ def print_header(): w = 60 print(CLR, end="") print(CURSOR_OFF, end="") print(f"\033[1;1H", end="") print(f" \033[1;38;5;231m╔{'═' * (w - 6)}╗\033[0m") print( f" \033[1;38;5;231m║\033[0m \033[1;38;5;82mMAINLINE\033[0m \033[3;38;5;245mCommand Center\033[0m \033[1;38;5;231m ║\033[0m" ) print(f" \033[1;38;5;231m╚{'═' * (w - 6)}╝\033[0m") print(f" \033[2;38;5;37mCMD: {CC_CMD_TOPIC.split('/')[-2]}\033[0m") print(f" \033[2;38;5;37mRESP: {CC_RESP_TOPIC.split('/')[-2]}\033[0m") print() def print_response(response: str, is_error: bool = False) -> None: """Print response with nice formatting.""" print() if is_error: print(f" \033[1;38;5;196m✗ Error\033[0m") print(f" \033[38;5;196m{'─' * 40}\033[0m") else: print(f" \033[1;38;5;82m✓ Response\033[0m") print(f" \033[38;5;37m{'─' * 40}\033[0m") for line in response.split("\n"): print(f" {line}") print() def interactive_mode(): """Interactive TUI for sending commands.""" import readline print_header() poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC) print(f" \033[38;5;245mType /help for commands, /quit to exit\033[0m") print() while True: try: cmd = input(f" \033[1;38;5;82m❯\033[0m {G_HI}").strip() except (EOFError, KeyboardInterrupt): print() break if not cmd: continue if cmd.startswith("/"): if cmd == "/quit" or cmd == "/exit": print(f"\n \033[1;38;5;245mGoodbye!{RST}\n") break if cmd == "/help": print(f"\n{AVAILABLE_COMMANDS}\n") continue print(f" \033[38;5;245m⟳ Sending to mainline...{RST}") result = poller.send_and_wait(cmd) print_response(result, is_error=result.startswith("Error")) else: print(f"\n \033[1;38;5;196m⚠ Commands must start with /{RST}\n") print(CURSOR_ON, end="") return 0 def main(): parser = argparse.ArgumentParser( description="Mainline command-line interface", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=AVAILABLE_COMMANDS, ) parser.add_argument( "command", nargs="?", default=None, help="Command to send (e.g., /effects list)", ) parser.add_argument( "--watch", "-w", action="store_true", help="Watch mode: continuously poll for stats (Ctrl+C to exit)", ) args = parser.parse_args() if args.command is None: return interactive_mode() poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC) if args.watch and "/effects stats" in args.command: import signal def handle_sigterm(*_): print(f"\n \033[1;38;5;245mStopped watching{RST}") print(CURSOR_ON, end="") sys.exit(0) signal.signal(signal.SIGTERM, handle_sigterm) print_header() print(f" \033[38;5;245mWatching /effects stats (Ctrl+C to exit)...{RST}\n") try: while True: result = poller.send_and_wait(args.command) print(f"\033[2J\033[1;1H", end="") print( f" \033[1;38;5;82m❯\033[0m Performance Stats - \033[1;38;5;245m{time.strftime('%H:%M:%S')}{RST}" ) print(f" \033[38;5;37m{'─' * 44}{RST}") for line in result.split("\n"): print(f" {line}") time.sleep(2) except KeyboardInterrupt: print(f"\n \033[1;38;5;245mStopped watching{RST}") return 0 return 0 result = poller.send_and_wait(args.command) print(result) return 0 if __name__ == "__main__": main()