Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c333c2289e | |||
| 205af96c19 | |||
| 3c488688d1 | |||
| 755c157466 | |||
| 26cfa5ca34 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "watch",
|
"name": "watch",
|
||||||
"version": "0.1.0",
|
"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.",
|
"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": {
|
"author": {
|
||||||
"name": "Bradley Bonanno"
|
"name": "Bradley Bonanno"
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
All notable changes to `/watch` are documented here.
|
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
|
||||||
|
- Added `commands/watch.md` shim so `/watch` is callable when installed as a Claude Code plugin. Without it, the plugin loaded but the skill wasn't exposed as a slash command.
|
||||||
|
- `scripts/build-skill.sh` now strips `commands/` from the claude.ai `.skill` bundle alongside `hooks/` and `.claude-plugin/`.
|
||||||
|
|
||||||
## [0.1.0] — 2026-04-24
|
## [0.1.0] — 2026-04-24
|
||||||
|
|
||||||
Initial marketplace release.
|
Initial marketplace release.
|
||||||
|
|||||||
@@ -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)
|
## 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:
|
Before every `/watch` run, verify that dependencies and an API key are in place:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
description: Watch a video (URL or local path). Downloads with yt-dlp, extracts frames with ffmpeg, transcribes from captions or Whisper, and answers questions about what's in the video.
|
||||||
|
argument-hint: <video-url-or-path> [question]
|
||||||
|
allowed-tools: [Bash, Read, AskUserQuestion]
|
||||||
|
---
|
||||||
|
|
||||||
|
Invoke the `watch` skill (defined in SKILL.md) with the user's arguments: $ARGUMENTS
|
||||||
|
|
||||||
|
Follow the skill's full pipeline: preflight setup check → download via yt-dlp → extract frames at auto-scaled fps → pull captions or Whisper transcript → Read each frame → answer the user grounded in frames and transcript. If the user provided no arguments, ask them for a video URL or local path before proceeding.
|
||||||
@@ -21,11 +21,13 @@ OUT="dist/watch.skill"
|
|||||||
git archive --format=zip --prefix=watch/ --output="$OUT" HEAD
|
git archive --format=zip --prefix=watch/ --output="$OUT" HEAD
|
||||||
|
|
||||||
# claude.ai's .skill bundle needs only SKILL.md + scripts/ runtime. Claude Code
|
# claude.ai's .skill bundle needs only SKILL.md + scripts/ runtime. Claude Code
|
||||||
# needs hooks/ and .claude-plugin/ in the git archive (that's why they are NOT
|
# needs hooks/, commands/, and .claude-plugin/ in the git archive (that's why
|
||||||
# in .gitattributes export-ignore), but the .skill bundle should strip them to
|
# they are NOT in .gitattributes export-ignore), but the .skill bundle should
|
||||||
# keep a single canonical SKILL.md and stay well under the 200-file cap.
|
# strip them to keep a single canonical SKILL.md and stay well under the
|
||||||
|
# 200-file cap.
|
||||||
zip -d "$OUT" \
|
zip -d "$OUT" \
|
||||||
"watch/hooks/*" \
|
"watch/hooks/*" \
|
||||||
|
"watch/commands/*" \
|
||||||
"watch/.claude-plugin/*" \
|
"watch/.claude-plugin/*" \
|
||||||
> /dev/null 2>&1 || true
|
> /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -18,8 +18,10 @@ VIDEO_EXTS = {".mp4", ".mkv", ".webm", ".mov", ".m4v", ".avi", ".flv", ".wmv"}
|
|||||||
|
|
||||||
|
|
||||||
def is_url(source: str) -> bool:
|
def is_url(source: str) -> bool:
|
||||||
|
if source.startswith("-"):
|
||||||
|
return False
|
||||||
parsed = urlparse(source)
|
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:
|
def resolve_local(path: str) -> dict:
|
||||||
@@ -78,6 +80,7 @@ def download_url(url: str, out_dir: Path) -> dict:
|
|||||||
"--no-playlist",
|
"--no-playlist",
|
||||||
"--ignore-errors",
|
"--ignore-errors",
|
||||||
"-o", output_template,
|
"-o", output_template,
|
||||||
|
"--",
|
||||||
url,
|
url,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -95,14 +98,15 @@ def download_url(url: str, out_dir: Path) -> dict:
|
|||||||
info: dict = {}
|
info: dict = {}
|
||||||
if info_path.exists():
|
if info_path.exists():
|
||||||
try:
|
try:
|
||||||
raw = json.loads(info_path.read_text())
|
raw = json.loads(info_path.read_text(encoding="utf-8"))
|
||||||
info = {
|
info = {
|
||||||
"title": raw.get("title"),
|
"title": raw.get("title"),
|
||||||
"uploader": raw.get("uploader") or raw.get("channel"),
|
"uploader": raw.get("uploader") or raw.get("channel"),
|
||||||
"duration": raw.get("duration"),
|
"duration": raw.get("duration"),
|
||||||
"url": raw.get("webpage_url") or url,
|
"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}
|
info = {"url": url}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+2
-2
@@ -66,7 +66,7 @@ def get_metadata(video_path: str) -> dict:
|
|||||||
"-print_format", "json",
|
"-print_format", "json",
|
||||||
"-show_format",
|
"-show_format",
|
||||||
"-show_streams",
|
"-show_streams",
|
||||||
video_path,
|
str(Path(video_path).resolve()),
|
||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -162,7 +162,7 @@ def extract(
|
|||||||
cmd += ["-to", f"{end_seconds:.3f}"]
|
cmd += ["-to", f"{end_seconds:.3f}"]
|
||||||
|
|
||||||
cmd += [
|
cmd += [
|
||||||
"-i", video_path,
|
"-i", str(Path(video_path).resolve()),
|
||||||
"-vf", f"fps={fps},scale={resolution}:-2",
|
"-vf", f"fps={fps},scale={resolution}:-2",
|
||||||
"-frames:v", str(max_frames),
|
"-frames:v", str(max_frames),
|
||||||
"-q:v", "4",
|
"-q:v", "4",
|
||||||
|
|||||||
+19
-5
@@ -79,7 +79,7 @@ def _read_env_key(name: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
_check_file_permissions(CONFIG_FILE)
|
_check_file_permissions(CONFIG_FILE)
|
||||||
try:
|
try:
|
||||||
for line in CONFIG_FILE.read_text().splitlines():
|
for line in CONFIG_FILE.read_text(encoding="utf-8").splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
continue
|
continue
|
||||||
@@ -113,7 +113,7 @@ def _scaffold_env() -> bool:
|
|||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
return False
|
return False
|
||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
CONFIG_FILE.write_text(ENV_TEMPLATE)
|
CONFIG_FILE.write_text(ENV_TEMPLATE, encoding="utf-8")
|
||||||
try:
|
try:
|
||||||
CONFIG_FILE.chmod(0o600)
|
CONFIG_FILE.chmod(0o600)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -130,15 +130,15 @@ def _write_setup_complete() -> None:
|
|||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
existing = ""
|
existing = ""
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
existing = CONFIG_FILE.read_text()
|
existing = CONFIG_FILE.read_text(encoding="utf-8")
|
||||||
for line in existing.splitlines():
|
for line in existing.splitlines():
|
||||||
if line.strip().startswith("SETUP_COMPLETE="):
|
if line.strip().startswith("SETUP_COMPLETE="):
|
||||||
return
|
return
|
||||||
if existing and not existing.endswith("\n"):
|
if existing and not existing.endswith("\n"):
|
||||||
existing += "\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:
|
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:
|
try:
|
||||||
CONFIG_FILE.chmod(0o600)
|
CONFIG_FILE.chmod(0o600)
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -186,6 +186,16 @@ def _install_hint_linux(missing: list[str]) -> str:
|
|||||||
return "\n ".join(hints) if hints else "nothing to install"
|
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:
|
def _status() -> dict:
|
||||||
"""Structured preflight snapshot."""
|
"""Structured preflight snapshot."""
|
||||||
missing = _check_binaries()
|
missing = _check_binaries()
|
||||||
@@ -268,6 +278,10 @@ def cmd_install() -> int:
|
|||||||
print("[setup] dependencies missing on Linux — please install:", file=sys.stderr)
|
print("[setup] dependencies missing on Linux — please install:", file=sys.stderr)
|
||||||
print(" " + _install_hint_linux(missing), file=sys.stderr)
|
print(" " + _install_hint_linux(missing), file=sys.stderr)
|
||||||
return 2
|
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:
|
else:
|
||||||
print(f"[setup] unsupported platform ({system}) for auto-install. Install manually:", file=sys.stderr)
|
print(f"[setup] unsupported platform ({system}) for auto-install. Install manually:", file=sys.stderr)
|
||||||
print(f" missing: {', '.join(missing)}", file=sys.stderr)
|
print(f" missing: {', '.join(missing)}", file=sys.stderr)
|
||||||
|
|||||||
+1
-1
@@ -177,7 +177,7 @@ def main() -> int:
|
|||||||
mins = int(full_duration // 60)
|
mins = int(full_duration // 60)
|
||||||
print()
|
print()
|
||||||
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, "
|
"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."
|
"re-run with `--start HH:MM:SS --end HH:MM:SS` to zoom into a specific section."
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-3
@@ -45,7 +45,7 @@ def load_api_key(preferred: str | None = None) -> tuple[str, str] | tuple[None,
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
for line in path.read_text().splitlines():
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
continue
|
continue
|
||||||
@@ -93,13 +93,13 @@ def extract_audio(video_path: str, out_path: Path) -> Path:
|
|||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "error",
|
"-loglevel", "error",
|
||||||
"-y",
|
"-y",
|
||||||
"-i", video_path,
|
"-i", str(Path(video_path).resolve()),
|
||||||
"-vn",
|
"-vn",
|
||||||
"-acodec", "libmp3lame",
|
"-acodec", "libmp3lame",
|
||||||
"-ar", "16000",
|
"-ar", "16000",
|
||||||
"-ac", "1",
|
"-ac", "1",
|
||||||
"-b:a", "64k",
|
"-b:a", "64k",
|
||||||
str(out_path),
|
str(out_path.resolve()),
|
||||||
]
|
]
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
|||||||
Reference in New Issue
Block a user