Restructure as a self-contained Agent Skills package (fix Codex install)

Move the skill into skills/watch/ so SKILL.md and its scripts/ runtime are
siblings inside one folder. `npx skills add` (Codex/Cursor/Copilot/agents) now
copies a working skill as a unit; previously it grabbed the root SKILL.md alone
and left scripts/ behind, so the skill was dead on arrival on every non-Claude
host. Mirrors the layout last30days-skill adopted for the same reason.

- skills/watch/{SKILL.md,scripts/}: self-contained skill folder
- SKILL.md: resolve a harness-agnostic $SKILL_DIR (the dir it was Read from)
  instead of the Claude-Code-only ${CLAUDE_SKILL_DIR}; guard + 19 call sites
- drop commands/watch.md: /watch derives from frontmatter (name + user-invocable)
- .codex-plugin/plugin.json: full manifest with "skills": "./skills/" + interface
- add .agents/plugins/marketplace.json, AGENTS.md, CLAUDE.md, .skillignore
- build-skill.sh: archive the skills/watch subtree (one SKILL.md, no zip -d)
- fix paths in tests, hooks hint, .gitattributes, release.yml
- relocate dev-sync.sh to repo root and fix REPO_ROOT
- README: content-ideas structure, npx skills install, star history

Verified: 37/37 tests pass; npx skills add bundles the full scripts/ runtime;
manifests valid; versions synced at 0.1.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
bradautomates
2026-06-29 22:12:54 +10:00
parent c333c2289e
commit 429f3143e5
33 changed files with 2360 additions and 783 deletions
+83
View File
@@ -0,0 +1,83 @@
"""Shared pytest fixtures: ffmpeg-synthesized clips and scripts/ on sys.path."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
# Make the bundled scripts importable (mirrors watch.py's sys.path insert).
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "skills" / "watch" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
# 14 visually distinct fills → 14 abrupt cuts → x264 emits a keyframe per cut.
COLORS = [
"red", "green", "blue", "white", "black", "yellow", "cyan",
"magenta", "gray", "orange", "purple", "brown", "navy", "olive",
]
def _run(cmd: list[str]) -> None:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {' '.join(cmd)}\n{result.stderr}")
def build_cut_clip(
path: Path,
n: int = 14,
seg: float = 0.4,
size: str = "320x240",
fps: int = 10,
) -> None:
"""Concatenate ``n`` solid-color segments into one clip with ``n`` cuts.
Each color change is a hard scene cut, so the scene selector finds ~n-1
changes. x264's own scenecut detection is unreliable on flat fills, so we
force a keyframe at every ``seg`` boundary — giving ~n real keyframes for
the keyframe engine to find.
"""
inputs: list[str] = []
for i in range(n):
color = COLORS[i % len(COLORS)]
inputs += ["-f", "lavfi", "-t", str(seg), "-i", f"color=c={color}:s={size}:r={fps}"]
streams = "".join(f"[{i}:v]" for i in range(n))
filt = f"{streams}concat=n={n}:v=1:a=0[out]"
_run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
*inputs,
"-filter_complex", filt, "-map", "[out]",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-force_key_frames", f"expr:gte(t,n_forced*{seg})",
str(path),
])
def build_static_clip(
path: Path,
duration: float = 3.0,
size: str = "320x240",
fps: int = 10,
) -> None:
"""One solid color: 1 keyframe, no scene changes → triggers both fallbacks."""
_run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-t", str(duration), "-i", f"color=c=blue:s={size}:r={fps}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-g", "600",
str(path),
])
@pytest.fixture(scope="session")
def cut_clip(tmp_path_factory: pytest.TempPathFactory) -> Path:
path = tmp_path_factory.mktemp("clips") / "cuts.mp4"
build_cut_clip(path)
return path
@pytest.fixture(scope="session")
def static_clip(tmp_path_factory: pytest.TempPathFactory) -> Path:
path = tmp_path_factory.mktemp("clips") / "static.mp4"
build_static_clip(path)
return path
+37
View File
@@ -0,0 +1,37 @@
"""WATCH_DETAIL resolution and frame_cap mapping."""
from __future__ import annotations
import config
def test_default_detail_is_balanced(monkeypatch, tmp_path):
monkeypatch.delenv("WATCH_DETAIL", raising=False)
monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "missing.env")
assert config.get_config()["detail"] == "balanced"
def test_env_overrides_detail(monkeypatch, tmp_path):
monkeypatch.setenv("WATCH_DETAIL", "efficient")
monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "missing.env")
assert config.get_config()["detail"] == "efficient"
def test_invalid_detail_falls_back_to_default(monkeypatch, tmp_path):
monkeypatch.setenv("WATCH_DETAIL", "bogus")
monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "missing.env")
assert config.get_config()["detail"] == "balanced"
def test_get_config_keys(monkeypatch, tmp_path):
monkeypatch.delenv("WATCH_DETAIL", raising=False)
monkeypatch.setattr(config, "CONFIG_FILE", tmp_path / "missing.env")
cfg = config.get_config()
assert set(cfg) == {"detail", "config_file"}
def test_frame_cap_mapping():
assert config.frame_cap("efficient") == 50
assert config.frame_cap("balanced") == 100
assert config.frame_cap("token-burner") is None
assert config.frame_cap("transcript") is None
assert config.frame_cap("anything-else") == 100
+64
View File
@@ -0,0 +1,64 @@
"""yt-dlp argv construction for download.py.
Regression guard: ``--sub-langs all`` makes yt-dlp fetch YouTube's hundreds of
auto-translated caption tracks, which can take minutes and stalls before the
video download even starts. We only support English, so the request must stay
bounded to the English-only pattern.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "skills" / "watch" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
import download # noqa: E402
URL = "https://www.youtube.com/watch?v=rlOpbu3Enkw"
def _capture_argv(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
"""Stub subprocess.run inside download.py and record every argv."""
calls: list[list[str]] = []
class _Result:
returncode = 0
stdout = ""
stderr = ""
def fake_run(cmd, *args, **kwargs):
calls.append(list(cmd))
return _Result()
monkeypatch.setattr(download.subprocess, "run", fake_run)
return calls
def _sub_langs(argv: list[str]) -> str:
idx = argv.index("--sub-langs")
return argv[idx + 1]
def _assert_english_only(langs: str) -> None:
tokens = langs.split(",")
assert "all" not in tokens, f"sub-langs must not request all languages, got {langs!r}"
assert all(t.startswith("en") for t in tokens), f"sub-langs must be English-only, got {langs!r}"
def test_fetch_captions_requests_english_only(monkeypatch, tmp_path):
calls = _capture_argv(monkeypatch)
download.fetch_captions(URL, tmp_path / "download")
_assert_english_only(_sub_langs(calls[0]))
def test_download_url_requests_english_only(monkeypatch, tmp_path):
calls = _capture_argv(monkeypatch)
# _pick_video returns None with no real file, which raises SystemExit after
# the yt-dlp argv is already built — that's all we need to inspect.
with pytest.raises(SystemExit):
download.download_url(URL, tmp_path / "download")
_assert_english_only(_sub_langs(calls[0]))
+24
View File
@@ -0,0 +1,24 @@
"""Smoke test: the ffmpeg fixtures actually produce playable clips."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
def _duration(path: Path) -> float:
out = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", str(path)],
capture_output=True, text=True,
).stdout
return float(json.loads(out)["format"]["duration"])
def test_cut_clip_builds(cut_clip: Path):
assert cut_clip.exists() and cut_clip.stat().st_size > 0
assert _duration(cut_clip) > 4.0 # 14 * 0.4s ≈ 5.6s
def test_static_clip_builds(static_clip: Path):
assert static_clip.exists() and static_clip.stat().st_size > 0
assert _duration(static_clip) > 2.0
+70
View File
@@ -0,0 +1,70 @@
"""Keyframe engine + preserved scene/uniform fallbacks."""
from __future__ import annotations
from pathlib import Path
import frames
def test_keyframe_engine_on_cut_clip(cut_clip: Path, tmp_path: Path):
out, meta = frames.extract_keyframes(str(cut_clip), tmp_path / "f", max_frames=50)
assert meta["engine"] == "keyframe"
assert meta["fallback"] is False
assert len(out) >= frames.KEYFRAME_MIN
assert all(fr["reason"] == "keyframe" for fr in out)
assert len(out) == len(list((tmp_path / "f").glob("frame_*.jpg")))
def test_keyframe_even_sampling_caps_and_spans(cut_clip: Path, tmp_path: Path):
out, meta = frames.extract_keyframes(str(cut_clip), tmp_path / "f", max_frames=5)
assert meta["engine"] == "keyframe"
assert len(out) == 5
assert meta["selected_count"] == 5
assert meta["candidate_count"] > 5
ts = [fr["timestamp_seconds"] for fr in out]
assert ts == sorted(ts)
assert ts[0] < ts[-1] # spans first → last keyframe
assert [fr["index"] for fr in out] == [0, 1, 2, 3, 4]
def test_keyframe_fallback_on_static_clip(static_clip: Path, tmp_path: Path):
out, meta = frames.extract_keyframes(str(static_clip), tmp_path / "f", max_frames=50)
assert meta["engine"] == "uniform"
assert meta["fallback"] is True
assert len(out) > 0
assert all(fr["reason"] == "uniform" for fr in out)
def test_scene_engine_on_cut_clip(cut_clip: Path, tmp_path: Path):
out, meta = frames.extract_scene_or_uniform(
str(cut_clip), tmp_path / "f", fps=2.0, target_frames=50, max_frames=100,
)
assert meta["engine"] == "scene"
assert meta["fallback"] is False
assert len(out) >= frames.SCENE_MIN_FRAMES
def test_scene_even_sampling_caps_and_spans(cut_clip: Path, tmp_path: Path):
"""Over-cap scene detection must even-sample across the whole clip, not keep
the first N cuts and drop the tail (the long-video coverage bug)."""
out, meta = frames.extract_scene_or_uniform(
str(cut_clip), tmp_path / "f", fps=2.0, target_frames=50, max_frames=5,
)
assert meta["engine"] == "scene"
assert meta["fallback"] is False
assert len(out) == 5
assert meta["selected_count"] == 5
assert meta["candidate_count"] > 5 # all cuts detected, then sampled down
ts = [fr["timestamp_seconds"] for fr in out]
assert ts == sorted(ts)
assert ts[-1] > 4.0 # spans the full ~5.6s clip, not just the first ~1.6s
assert len(out) == len(list((tmp_path / "f").glob("frame_*.jpg")))
assert [fr["index"] for fr in out] == [0, 1, 2, 3, 4]
def test_scene_fallback_on_static_clip(static_clip: Path, tmp_path: Path):
out, meta = frames.extract_scene_or_uniform(
str(static_clip), tmp_path / "f", fps=2.0, target_frames=12, max_frames=100,
)
assert meta["engine"] == "uniform"
assert meta["fallback"] is True
+80
View File
@@ -0,0 +1,80 @@
"""setup.py --json surfaces the resolved watch detail."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
SETUP = Path(__file__).resolve().parent.parent / "skills" / "watch" / "scripts" / "setup.py"
def _run(args, *, home=None, extra_env=None):
env = dict(os.environ)
env.pop("WATCH_DETAIL", None)
# Don't let a real key in the developer's shell env leak into the test.
env.pop("GROQ_API_KEY", None)
env.pop("OPENAI_API_KEY", None)
env.pop("SETUP_COMPLETE", None)
if home is not None:
env["HOME"] = str(home)
env["USERPROFILE"] = str(home) # Windows
if extra_env:
env.update(extra_env)
return subprocess.run(
[sys.executable, str(SETUP), *args],
capture_output=True, text=True, env=env,
)
def _write_env(home: Path, body: str) -> None:
cfg = home / ".config" / "watch"
cfg.mkdir(parents=True, exist_ok=True)
f = cfg / ".env"
f.write_text(body, encoding="utf-8")
f.chmod(0o600)
def test_json_reports_watch_detail():
proc = _run(["--json"])
assert proc.returncode == 0, proc.stderr
data = json.loads(proc.stdout)
assert data["watch_detail"] == "balanced"
def test_keyless_completed_setup_proceeds_silently(tmp_path):
"""A user who finished setup without a key must NOT be nagged forever."""
_write_env(tmp_path, "GROQ_API_KEY=\nOPENAI_API_KEY=\nSETUP_COMPLETE=true\n")
chk = _run(["--check"], home=tmp_path)
assert chk.returncode == 0, f"keyless-complete should pass --check; got {chk.returncode}: {chk.stderr}"
assert chk.stdout == "" and chk.stderr == ""
js = json.loads(_run(["--json"], home=tmp_path).stdout)
assert js["can_proceed"] is True
assert js["first_run"] is False
assert js["setup_complete"] is True
# status still encourages a key even though we can proceed
assert js["status"] == "needs_key"
def test_keyless_first_run_is_encouraged(tmp_path):
"""Genuine first run with no key: --check reports exit 3 (encourage a key)."""
_write_env(tmp_path, "GROQ_API_KEY=\nOPENAI_API_KEY=\n")
chk = _run(["--check"], home=tmp_path)
assert chk.returncode == 3, chk.stderr
js = json.loads(_run(["--json"], home=tmp_path).stdout)
assert js["can_proceed"] is False
assert js["first_run"] is True
def test_key_present_is_ready(tmp_path):
_write_env(tmp_path, "GROQ_API_KEY=sk-test-abc\n")
chk = _run(["--check"], home=tmp_path)
assert chk.returncode == 0, chk.stderr
js = json.loads(_run(["--json"], home=tmp_path).stdout)
assert js["status"] == "ready"
assert js["can_proceed"] is True
assert js["whisper_backend"] == "groq"
+87
View File
@@ -0,0 +1,87 @@
"""Transcript-cue timestamps: parsing, point extraction, and pinned merge."""
from __future__ import annotations
from pathlib import Path
import pytest
import frames
def test_parse_timestamps_mixed_formats():
assert frames.parse_timestamps("30,1:05,90") == [30.0, 65.0, 90.0]
def test_parse_timestamps_strips_and_dedupes():
assert frames.parse_timestamps(" 90 , 30, 30 ") == [30.0, 90.0]
def test_parse_timestamps_empty():
assert frames.parse_timestamps("") == []
assert frames.parse_timestamps(" , ") == []
def test_parse_timestamps_rejects_garbage():
with pytest.raises(SystemExit):
frames.parse_timestamps("4:bad")
def test_merge_frames_sorts_and_reindexes():
primary = [
{"index": 0, "timestamp_seconds": 1.0, "path": "a", "reason": "scene-change"},
{"index": 1, "timestamp_seconds": 5.0, "path": "b", "reason": "scene-change"},
]
pinned = [
{"index": 0, "timestamp_seconds": 3.0, "path": "c", "reason": "transcript-cue"},
]
merged = frames.merge_frames(primary, pinned)
assert [f["path"] for f in merged] == ["a", "c", "b"]
assert [f["index"] for f in merged] == [0, 1, 2]
assert merged[1]["reason"] == "transcript-cue"
def test_merge_frames_keeps_all_pinned():
pinned = [{"index": 0, "timestamp_seconds": 2.0, "path": "c", "reason": "transcript-cue"}]
merged = frames.merge_frames([], pinned)
assert [f["path"] for f in merged] == ["c"]
def test_extract_at_timestamps_one_frame_per_point(cut_clip: Path, tmp_path: Path):
out, meta = frames.extract_at_timestamps(str(cut_clip), tmp_path / "f", [0.5, 2.0, 4.0])
assert meta["engine"] == "timestamps"
assert meta["fallback"] is False
assert len(out) == 3
assert all(f["reason"] == "transcript-cue" for f in out)
ts = [f["timestamp_seconds"] for f in out]
assert ts == sorted(ts)
assert len(out) == len(list((tmp_path / "f").glob("cue_*.jpg")))
def test_extract_at_timestamps_drops_out_of_window(cut_clip: Path, tmp_path: Path):
out, meta = frames.extract_at_timestamps(
str(cut_clip), tmp_path / "f", [0.5, 2.0, 4.0],
start_seconds=1.0, end_seconds=3.0,
)
assert [f["timestamp_seconds"] for f in out] == [2.0]
assert meta["dropped_out_of_window"] == 2
def test_extract_at_timestamps_caps_and_spans(cut_clip: Path, tmp_path: Path):
out, meta = frames.extract_at_timestamps(
str(cut_clip), tmp_path / "f", [0.5, 1.5, 2.5, 3.5, 4.5], max_frames=3,
)
assert len(out) == 3
ts = [f["timestamp_seconds"] for f in out]
assert ts[0] == 0.5 and ts[-1] == 4.5 # even-sample keeps first + last
assert len(out) == len(list((tmp_path / "f").glob("cue_*.jpg")))
def test_extract_at_timestamps_does_not_clobber_detail_frames(cut_clip: Path, tmp_path: Path):
"""Cue frames live alongside detail frames in the same dir without deleting them."""
d = tmp_path / "f"
scene, _ = frames.extract_scene_or_uniform(
str(cut_clip), d, fps=2.0, target_frames=50, max_frames=100,
)
cues, _ = frames.extract_at_timestamps(str(cut_clip), d, [1.0, 3.0])
assert len(list(d.glob("frame_*.jpg"))) == len(scene)
assert len(list(d.glob("cue_*.jpg"))) == len(cues)
+69
View File
@@ -0,0 +1,69 @@
"""End-to-end routing of --detail through watch.py on a local clip."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
WATCH = Path(__file__).resolve().parent.parent / "skills" / "watch" / "scripts" / "watch.py"
def _run(clip: Path, *args: str, env_extra: dict | None = None) -> str:
env = dict(os.environ)
env.pop("WATCH_DETAIL", None)
if env_extra:
env.update(env_extra)
proc = subprocess.run(
[sys.executable, str(WATCH), str(clip), "--no-whisper", *args],
capture_output=True, text=True, env=env,
)
assert proc.returncode == 0, proc.stderr
return proc.stdout
def test_efficient_uses_keyframe_engine(cut_clip: Path):
out = _run(cut_clip, "--detail", "efficient")
assert "(keyframe" in out
assert "**Detail:** efficient" in out
def test_balanced_uses_scene_engine(cut_clip: Path):
out = _run(cut_clip, "--detail", "balanced")
assert "(scene" in out
assert "**Detail:** balanced" in out
def test_token_burner_uses_scene_engine(cut_clip: Path):
out = _run(cut_clip, "--detail", "token-burner")
assert "(scene" in out
def test_transcript_skips_frames(cut_clip: Path):
out = _run(cut_clip, "--detail", "transcript")
assert "skipped" in out
assert "frame_0000.jpg" not in out
def test_flag_overrides_env(cut_clip: Path):
out = _run(cut_clip, "--detail", "efficient", env_extra={"WATCH_DETAIL": "balanced"})
assert "(keyframe" in out
def test_default_is_balanced(cut_clip: Path):
out = _run(cut_clip) # no flag, WATCH_DETAIL cleared
assert "**Detail:** balanced" in out
assert "(scene" in out
def test_timestamps_add_cue_frames_to_detail(cut_clip: Path):
out = _run(cut_clip, "--detail", "balanced", "--timestamps", "1,3")
assert "reason=transcript-cue" in out
assert "reason=scene-change" in out # detail frames still present (additive)
def test_timestamps_with_transcript_detail_is_cue_only(cut_clip: Path):
out = _run(cut_clip, "--detail", "transcript", "--timestamps", "1,3")
assert "reason=transcript-cue" in out
assert "reason=scene-change" not in out
assert "reason=keyframe" not in out