4 Commits

Author SHA1 Message Date
bradautomates c333c2289e Release v0.1.3
- #2: harden subprocess argv against option injection
- #4: read/write config files as UTF-8 on Windows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:41:59 +10:00
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
bradautomates 3c488688d1 Harden subprocess argv against option injection (#2)
- download.py: insert `--` before URL in yt-dlp argv so a `-`-prefixed
  string can never be parsed as a flag (e.g. --exec, --config-location).
  Tighten is_url to reject `-`-prefixed sources and require non-empty
  netloc.
- frames.py, whisper.py: resolve video/audio paths to absolute via
  Path.resolve() before passing to ffmpeg/ffprobe (which don't honor
  `--` as end-of-options), so a relative path starting with `-` can
  never be misinterpreted as a flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:13:32 +10:00
bradautomates 755c157466 Fix Windows compatibility: encoding, installer hints, interpreter name
- watch.py: drop the ⚠️ emoji from the long-video warning; cp1252
  consoles on Windows couldn't encode it and the script crashed.
- setup.py: add a Windows branch that prints winget / pip install
  commands, matching what the README already promised.
- SKILL.md: note that Windows users must invoke the scripts with
  `python`, not `python3` (which is the Microsoft Store stub).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:22:51 +10:00
8 changed files with 53 additions and 15 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "watch",
"version": "0.1.1",
"version": "0.1.3",
"description": "Let Claude watch a video. Downloads with yt-dlp, extracts auto-scaled frames with ffmpeg, pulls captions or falls back to Whisper, and hands frames + transcript to Claude so it can answer questions about the video.",
"author": {
"name": "Bradley Bonanno"
+18
View File
@@ -2,6 +2,24 @@
All notable changes to `/watch` are documented here.
## [0.1.3] — 2026-05-09
### Fixed
- Windows: `video.info.json` is read as UTF-8 (#4). Previously `Path.read_text()` defaulted to cp1252 on Windows and crashed on yt-dlp's UTF-8 output, silently dropping Title/Uploader from the report. Same fix applied to `.env` reads/writes in `whisper.py` and `setup.py`.
- `download.py` now logs info.json parse failures to stderr instead of swallowing them.
### Security
- Hardened subprocess argv against option injection (#2): inserted `--` before the URL in the yt-dlp argv, and tightened `is_url` to reject `-`-prefixed sources and require a non-empty netloc. Resolved video/audio paths to absolute via `Path.resolve()` before passing to `ffmpeg`/`ffprobe`, so a relative path starting with `-` can't be misinterpreted as a flag.
## [0.1.2] — 2026-04-24
### Fixed
- Windows console crash: removed the emoji from the long-video warning in `watch.py`; cp1252 consoles couldn't encode it.
- `setup.py` now prints `winget` / `pip` install commands on Windows instead of "unsupported platform" — matches what the README already promised.
### Changed
- `SKILL.md` notes that on Windows the scripts must be invoked with `python`, not `python3` (the latter is the Microsoft Store stub on Windows).
## [0.1.1] — 2026-04-24
### Fixed
+2
View File
@@ -16,6 +16,8 @@ You don't have a video input; this skill gives you one. A Python script download
## Step 0 — Setup preflight (runs every `/watch` invocation, silent on success)
**Python interpreter:** every `python3 ...` command in this skill is for macOS/Linux. On **Windows**, substitute `python` — the `python3` command on Windows is the Microsoft Store stub and will not run the script.
Before every `/watch` run, verify that dependencies and an API key are in place:
```bash
+7 -3
View File
@@ -18,8 +18,10 @@ VIDEO_EXTS = {".mp4", ".mkv", ".webm", ".mov", ".m4v", ".avi", ".flv", ".wmv"}
def is_url(source: str) -> bool:
if source.startswith("-"):
return False
parsed = urlparse(source)
return parsed.scheme in ("http", "https")
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def resolve_local(path: str) -> dict:
@@ -78,6 +80,7 @@ def download_url(url: str, out_dir: Path) -> dict:
"--no-playlist",
"--ignore-errors",
"-o", output_template,
"--",
url,
]
@@ -95,14 +98,15 @@ def download_url(url: str, out_dir: Path) -> dict:
info: dict = {}
if info_path.exists():
try:
raw = json.loads(info_path.read_text())
raw = json.loads(info_path.read_text(encoding="utf-8"))
info = {
"title": raw.get("title"),
"uploader": raw.get("uploader") or raw.get("channel"),
"duration": raw.get("duration"),
"url": raw.get("webpage_url") or url,
}
except Exception:
except Exception as exc:
print(f"[watch] info.json parse failed: {exc}", file=sys.stderr)
info = {"url": url}
return {
+2 -2
View File
@@ -66,7 +66,7 @@ def get_metadata(video_path: str) -> dict:
"-print_format", "json",
"-show_format",
"-show_streams",
video_path,
str(Path(video_path).resolve()),
],
capture_output=True,
text=True,
@@ -162,7 +162,7 @@ def extract(
cmd += ["-to", f"{end_seconds:.3f}"]
cmd += [
"-i", video_path,
"-i", str(Path(video_path).resolve()),
"-vf", f"fps={fps},scale={resolution}:-2",
"-frames:v", str(max_frames),
"-q:v", "4",
+19 -5
View File
@@ -79,7 +79,7 @@ def _read_env_key(name: str) -> str | None:
return None
_check_file_permissions(CONFIG_FILE)
try:
for line in CONFIG_FILE.read_text().splitlines():
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
@@ -113,7 +113,7 @@ def _scaffold_env() -> bool:
if CONFIG_FILE.exists():
return False
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(ENV_TEMPLATE)
CONFIG_FILE.write_text(ENV_TEMPLATE, encoding="utf-8")
try:
CONFIG_FILE.chmod(0o600)
except OSError:
@@ -130,15 +130,15 @@ def _write_setup_complete() -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
existing = ""
if CONFIG_FILE.exists():
existing = CONFIG_FILE.read_text()
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")
CONFIG_FILE.write_text(existing + "SETUP_COMPLETE=true\n", encoding="utf-8")
else:
CONFIG_FILE.write_text(ENV_TEMPLATE + "\nSETUP_COMPLETE=true\n")
CONFIG_FILE.write_text(ENV_TEMPLATE + "\nSETUP_COMPLETE=true\n", encoding="utf-8")
try:
CONFIG_FILE.chmod(0o600)
except OSError:
@@ -186,6 +186,16 @@ def _install_hint_linux(missing: list[str]) -> str:
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()
@@ -268,6 +278,10 @@ def cmd_install() -> int:
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)
+1 -1
View File
@@ -177,7 +177,7 @@ def main() -> int:
mins = int(full_duration // 60)
print()
print(
f"> ⚠️ This is a {mins}-minute video. Frame coverage is sparse at this length — "
f"> **Warning:** This is a {mins}-minute video. Frame coverage is sparse at this length — "
"accuracy degrades noticeably on anything over 10 minutes. For better results, "
"re-run with `--start HH:MM:SS --end HH:MM:SS` to zoom into a specific section."
)
+3 -3
View File
@@ -45,7 +45,7 @@ def load_api_key(preferred: str | None = None) -> tuple[str, str] | tuple[None,
if not path.exists():
return None
try:
for line in path.read_text().splitlines():
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
@@ -93,13 +93,13 @@ def extract_audio(video_path: str, out_path: Path) -> Path:
"-hide_banner",
"-loglevel", "error",
"-y",
"-i", video_path,
"-i", str(Path(video_path).resolve()),
"-vn",
"-acodec", "libmp3lame",
"-ar", "16000",
"-ac", "1",
"-b:a", "64k",
str(out_path),
str(out_path.resolve()),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0: