Initial commit: /watch skill v0.1.0
Give Claude the ability to watch any video — yt-dlp download, ffmpeg frame extraction with auto-scaled fps, native-caption transcript with Whisper (Groq/OpenAI) fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+312
@@ -0,0 +1,312 @@
|
||||
#!/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().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)
|
||||
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()
|
||||
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")
|
||||
else:
|
||||
CONFIG_FILE.write_text(ENV_TEMPLATE + "\nSETUP_COMPLETE=true\n")
|
||||
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 _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
|
||||
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())
|
||||
Reference in New Issue
Block a user