Files
claude-video/scripts/setup.py
T
bradautomates 205af96c19 Read/write config files as UTF-8 on Windows (#4)
Path.read_text()/write_text() default to the platform encoding (cp1252
on Windows), which mangles or crashes on UTF-8 files. yt-dlp's
video.info.json is UTF-8 and routinely contains non-ASCII bytes, so on
Windows the parse raised UnicodeDecodeError, was swallowed by a bare
except, and the /watch report came back missing Title/Uploader.

- download.py: read video.info.json as UTF-8; log parse failures to
  stderr instead of swallowing them silently.
- whisper.py, setup.py: same UTF-8 fix on .env reads and writes for
  symmetry across platforms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:21:39 +10:00

327 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Setup / preflight for /watch.
Modes:
setup.py --check Silent preflight. Exit 0 if ready, 2/3/4 on failure.
setup.py --json Machine-readable status for Claude to parse.
setup.py Installer. Auto-installs deps, scaffolds .env, marks SETUP_COMPLETE.
Design:
- Silent on success: --check exits 0 with no output when everything's ready so
that /watch doesn't spam "setup is complete" on every turn.
- Idempotent: re-running the installer is safe — it never clobbers existing
keys and only appends missing ones.
- SETUP_COMPLETE=true in ~/.config/watch/.env tells us the user has been
through a successful installer run at least once.
- Never sudo. On macOS, auto-install via brew. Elsewhere, print exact commands.
- Never write an API key to disk automatically — only scaffold placeholders.
"""
from __future__ import annotations
import json
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path
REQUIRED_BINARIES = ["ffmpeg", "ffprobe", "yt-dlp"]
CONFIG_DIR = Path.home() / ".config" / "watch"
CONFIG_FILE = CONFIG_DIR / ".env"
ENV_TEMPLATE = """# /watch API configuration
#
# Whisper transcription fallback — used only when yt-dlp cannot get captions
# (or when you point /watch at a local file with no subtitles).
#
# Groq is preferred: it runs whisper-large-v3 at a fraction of OpenAI's price
# and is faster in practice. OpenAI is the compatible fallback.
#
# Get a Groq key: https://console.groq.com/keys
# Get an OpenAI key: https://platform.openai.com/api-keys
#
# Leave both blank to disable Whisper — /watch will still work, but videos
# without native captions will come back frames-only.
GROQ_API_KEY=
OPENAI_API_KEY=
"""
def _which(name: str) -> str | None:
return shutil.which(name)
def _check_binaries() -> list[str]:
return [b for b in REQUIRED_BINARIES if not _which(b)]
def _check_file_permissions(path: Path) -> None:
"""Warn to stderr if a secrets file is world/group readable."""
try:
mode = path.stat().st_mode
if mode & 0o044:
sys.stderr.write(
f"[watch] WARNING: {path} is readable by other users. "
f"Run: chmod 600 {path}\n"
)
sys.stderr.flush()
except OSError:
pass
def _read_env_key(name: str) -> str | None:
value = os.environ.get(name)
if value and value.strip():
return value.strip()
if not CONFIG_FILE.exists():
return None
_check_file_permissions(CONFIG_FILE)
try:
for line in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, raw = line.partition("=")
if key.strip() != name:
continue
raw = raw.strip()
if len(raw) >= 2 and raw[0] in ('"', "'") and raw[-1] == raw[0]:
raw = raw[1:-1]
return raw or None
except OSError:
return None
return None
def _have_api_key() -> tuple[bool, str | None]:
if _read_env_key("GROQ_API_KEY"):
return True, "groq"
if _read_env_key("OPENAI_API_KEY"):
return True, "openai"
return False, None
def is_first_run() -> bool:
"""True if the installer hasn't completed successfully yet."""
return _read_env_key("SETUP_COMPLETE") != "true"
def _scaffold_env() -> bool:
"""Create ~/.config/watch/.env with placeholders if missing."""
if CONFIG_FILE.exists():
return False
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(ENV_TEMPLATE, encoding="utf-8")
try:
CONFIG_FILE.chmod(0o600)
except OSError:
pass
return True
def _write_setup_complete() -> None:
"""Idempotently append SETUP_COMPLETE=true to .env.
Used only after a fully successful install (deps + key). Future sessions
detect this marker to skip wizard-style UI and stay silent.
"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
existing = ""
if CONFIG_FILE.exists():
existing = CONFIG_FILE.read_text(encoding="utf-8")
for line in existing.splitlines():
if line.strip().startswith("SETUP_COMPLETE="):
return
if existing and not existing.endswith("\n"):
existing += "\n"
CONFIG_FILE.write_text(existing + "SETUP_COMPLETE=true\n", encoding="utf-8")
else:
CONFIG_FILE.write_text(ENV_TEMPLATE + "\nSETUP_COMPLETE=true\n", encoding="utf-8")
try:
CONFIG_FILE.chmod(0o600)
except OSError:
pass
def _brew_pkg(missing: list[str]) -> list[str]:
pkgs: list[str] = []
for bin_name in missing:
if bin_name in ("ffmpeg", "ffprobe"):
if "ffmpeg" not in pkgs:
pkgs.append("ffmpeg")
elif bin_name == "yt-dlp":
if "yt-dlp" not in pkgs:
pkgs.append("yt-dlp")
else:
pkgs.append(bin_name)
return pkgs
def _install_macos(missing: list[str]) -> tuple[bool, str]:
if _which("brew") is None:
return False, (
"Homebrew is not installed. Install it from https://brew.sh, then re-run setup. "
"Or install manually: `brew install " + " ".join(_brew_pkg(missing)) + "`"
)
pkgs = _brew_pkg(missing)
if not pkgs:
return True, "nothing to install"
cmd = ["brew", "install", *pkgs]
print(f"[setup] running: {' '.join(cmd)}", file=sys.stderr)
result = subprocess.run(cmd)
if result.returncode != 0:
return False, f"brew install failed with exit code {result.returncode}"
return True, f"installed via brew: {', '.join(pkgs)}"
def _install_hint_linux(missing: list[str]) -> str:
pkgs = _brew_pkg(missing)
hints = []
if "ffmpeg" in pkgs:
hints.append("apt: `sudo apt install ffmpeg` or dnf: `sudo dnf install ffmpeg`")
if "yt-dlp" in pkgs:
hints.append("`pipx install yt-dlp` (recommended) or `pip install --user yt-dlp`")
return "\n ".join(hints) if hints else "nothing to install"
def _install_hint_windows(missing: list[str]) -> str:
pkgs = _brew_pkg(missing)
hints = []
if "ffmpeg" in pkgs:
hints.append("winget: `winget install Gyan.FFmpeg`")
if "yt-dlp" in pkgs:
hints.append("winget: `winget install yt-dlp.yt-dlp` or pip: `pip install --user yt-dlp`")
return "\n ".join(hints) if hints else "nothing to install"
def _status() -> dict:
"""Structured preflight snapshot."""
missing = _check_binaries()
has_key, backend = _have_api_key()
if not missing and has_key:
status = "ready"
elif missing and not has_key:
status = "needs_install_and_key"
elif missing:
status = "needs_install"
else:
status = "needs_key"
return {
"status": status,
"first_run": is_first_run(),
"missing_binaries": missing,
"whisper_backend": backend,
"has_api_key": has_key,
"config_file": str(CONFIG_FILE),
"platform": platform.system(),
}
def cmd_check() -> int:
"""Silent-on-success preflight.
Exit 0 with no output when ready. On failure, print one actionable line
to stderr and return:
2 → binaries missing
3 → API key missing
4 → both missing
"""
s = _status()
if s["status"] == "ready":
return 0
parts = []
if s["missing_binaries"]:
parts.append(f"missing binaries: {', '.join(s['missing_binaries'])}")
if not s["has_api_key"]:
parts.append("no Whisper API key (GROQ_API_KEY or OPENAI_API_KEY)")
installer = Path(__file__).resolve()
sys.stderr.write(
f"[watch] setup incomplete ({'; '.join(parts)}). "
f"Run: python3 {installer}\n"
)
sys.stderr.flush()
if s["missing_binaries"] and not s["has_api_key"]:
return 4
if s["missing_binaries"]:
return 2
return 3
def cmd_json() -> int:
json.dump(_status(), sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
def cmd_install() -> int:
missing = _check_binaries()
installed_deps = False
if missing:
system = platform.system()
if system == "Darwin":
ok, msg = _install_macos(missing)
print(f"[setup] {msg}", file=sys.stderr)
if not ok:
return 2
still_missing = _check_binaries()
if still_missing:
print(f"[setup] still missing after install: {', '.join(still_missing)}", file=sys.stderr)
return 2
installed_deps = True
elif system == "Linux":
print("[setup] dependencies missing on Linux — please install:", file=sys.stderr)
print(" " + _install_hint_linux(missing), file=sys.stderr)
return 2
elif system == "Windows":
print("[setup] dependencies missing on Windows — please install:", file=sys.stderr)
print(" " + _install_hint_windows(missing), file=sys.stderr)
return 2
else:
print(f"[setup] unsupported platform ({system}) for auto-install. Install manually:", file=sys.stderr)
print(f" missing: {', '.join(missing)}", file=sys.stderr)
return 2
created = _scaffold_env()
if created:
print(f"[setup] created config: {CONFIG_FILE}")
else:
print(f"[setup] config exists: {CONFIG_FILE}")
has_key, backend = _have_api_key()
if has_key:
_write_setup_complete()
print(f"[setup] ready. whisper backend: {backend}")
if installed_deps:
print("[setup] installed dependencies; /watch is fully set up.")
return 0
print("")
print("[setup] one step left: add a Whisper API key.")
print("")
print(f" Edit {CONFIG_FILE} and set either:")
print(" GROQ_API_KEY=... (preferred — cheaper, faster; get one at console.groq.com/keys)")
print(" OPENAI_API_KEY=... (fallback; get one at platform.openai.com/api-keys)")
print("")
print(" Without a key, /watch still works but videos without captions come back frames-only.")
return 3
def main() -> int:
if len(sys.argv) > 1:
arg = sys.argv[1]
if arg == "--check":
return cmd_check()
if arg == "--json":
return cmd_json()
return cmd_install()
if __name__ == "__main__":
raise SystemExit(main())