diff --git a/README.md b/README.md index 146f357..036c170 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ # skills Personal agent skills packaged through Nix. + +## Skill sources + +Some skills adapt workflows or helper scripts from public examples. + +- `axi-review` is based on the [AXI principles](https://axi.md/). +- `youtube-transcript` is based on [`amosblomqvist/pi-config/skills/youtube-transcript`](https://github.com/amosblomqvist/pi-config/tree/main/skills/youtube-transcript). diff --git a/flake.nix b/flake.nix index 15a1572..110e531 100644 --- a/flake.nix +++ b/flake.nix @@ -32,7 +32,23 @@ nixpkgs.lib.listToAttrs ( map (skill: { inherit (skill) name; - value = mkSkill { inherit pkgs; inherit (skill) name src; }; + value = mkSkill ( + { + inherit pkgs; + inherit (skill) name src; + } + // nixpkgs.lib.optionalAttrs (skill.name == "youtube-transcript") { + commands = [ + { + path = "fetch_transcript"; + env = { + PYTHON = "${pkgs.python3}/bin/python3"; + YT_DLP = "${pkgs.yt-dlp}/bin/yt-dlp"; + }; + } + ]; + } + ); }) discoveredSkills ); in diff --git a/skills/axi-review/AXI-PRINCIPLES.md b/skills/axi-review/AXI-PRINCIPLES.md index 1497f3f..0ee70b5 100644 --- a/skills/axi-review/AXI-PRINCIPLES.md +++ b/skills/axi-review/AXI-PRINCIPLES.md @@ -1,6 +1,6 @@ # The 10 AXI Principles — review rubric -The canonical statements come from axi.md, which is the authority for the wording. +The canonical statements come from [`axi.md`](https://axi.md/), which is the authority for the wording. Each entry distils the canonical statement, gives the black-box probe to run, and the criteria that map observed output to a verdict. Judge against the canonical statement, not against any single tool's interpretation. diff --git a/skills/axi-review/SKILL.md b/skills/axi-review/SKILL.md index 56076b3..eac1946 100644 --- a/skills/axi-review/SKILL.md +++ b/skills/axi-review/SKILL.md @@ -1,6 +1,6 @@ --- name: axi-review -description: Review a CLI for conformance to the 10 AXI (Agent eXperience Interface) principles by running it black-box and scoring each principle on a report card. Use when asked to review, audit, check, or measure a CLI's AXI conformance, or when "AXI principles", "Agent eXperience Interface", or axi.md is named. +description: Review a CLI for conformance to the 10 AXI (Agent eXperience Interface) principles by running it black-box and scoring each principle on a report card. Use when asked to review, audit, check, or measure a CLI's AXI conformance, or when "AXI principles", "Agent eXperience Interface", or https://axi.md/ is named. --- # axi-review diff --git a/skills/youtube-transcript/SKILL.md b/skills/youtube-transcript/SKILL.md new file mode 100644 index 0000000..4233037 --- /dev/null +++ b/skills/youtube-transcript/SKILL.md @@ -0,0 +1,45 @@ +--- +name: youtube-transcript +description: Fetch the transcript and title of a YouTube video as JSON. Use when the user provides a YouTube URL and needs spoken captions for analysis, summarization, quoting, or search. +--- + +# YouTube Transcript + +Fetch a YouTube video's title and full transcript with `yt-dlp` captions. +Prefer manual English subtitles. +Fall back to automatic English subtitles. + +## Requirements + +The Nix-packaged skill provides Python 3 and `yt-dlp`. +A source checkout requires Python 3 and `yt-dlp` on `PATH`. + +## Usage + +Run from this skill directory: + +```bash +./fetch_transcript "" +``` + +## Output + +Prints a JSON object to stdout: + +```json +{ + "title": "Video title", + "transcript": "full transcript text as a single string" +} +``` + +Progress logs go to stderr. +On failure, the script exits non-zero and writes the error to stderr. + +## Notes + +- Only English captions are attempted. +- The language order is `en`, `en-US`, `en-GB`, then any `en*` caption. +- Manual captions are preferred over automatic captions. +- The transcript is plain text with timing and formatting removed. +- Videos without English captions fail. diff --git a/skills/youtube-transcript/fetch_transcript b/skills/youtube-transcript/fetch_transcript new file mode 100755 index 0000000..c88681e --- /dev/null +++ b/skills/youtube-transcript/fetch_transcript @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu + +dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +: "${PYTHON:=python3}" # mkSkill:PYTHON +: "${YT_DLP:=yt-dlp}" # mkSkill:YT_DLP + +exec "$PYTHON" "$dir/fetch_transcript.py" --yt-dlp "$YT_DLP" "$@" diff --git a/skills/youtube-transcript/fetch_transcript.py b/skills/youtube-transcript/fetch_transcript.py new file mode 100755 index 0000000..bf00147 --- /dev/null +++ b/skills/youtube-transcript/fetch_transcript.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Fetch a YouTube video's title and transcript through yt-dlp.""" + +import argparse +import glob +import json +import os +import shutil +import subprocess +import sys +import tempfile + + +def get_metadata(yt_dlp, url): + cmd = [ + yt_dlp, + "--dump-json", + "--no-warnings", + "--skip-download", + url, + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return json.loads(result.stdout) + except subprocess.CalledProcessError as error: + print(f"Error executing yt-dlp: {error.stderr}", file=sys.stderr) + sys.exit(1) + except Exception as error: + print(f"Unexpected error: {error}", file=sys.stderr) + sys.exit(1) + + +def pick_english_lang(info): + """Return (lang_code, is_auto) for the best English caption track.""" + subs = info.get("subtitles") or {} + auto = info.get("automatic_captions") or {} + + preferred = ["en", "en-US", "en-GB"] + + for lang in preferred: + if lang in subs: + return lang, False + for lang in subs: + if lang.startswith("en"): + return lang, False + for lang in preferred: + if lang in auto: + return lang, True + for lang in auto: + if lang.startswith("en"): + return lang, True + return None + + +def download_subtitle(yt_dlp, url, lang, is_auto, outdir): + cmd = [ + yt_dlp, + "--skip-download", + "--no-warnings", + "--sub-format", + "json3", + "--sub-langs", + lang, + "--write-auto-subs" if is_auto else "--write-subs", + "-o", + os.path.join(outdir, "%(id)s.%(ext)s"), + url, + ] + try: + subprocess.run(cmd, capture_output=True, text=True, check=True) + except subprocess.CalledProcessError as error: + print(f"Error downloading subtitles with yt-dlp: {error.stderr}", file=sys.stderr) + sys.exit(1) + + matches = glob.glob(os.path.join(outdir, "*.json3")) + if not matches: + print("yt-dlp did not produce a json3 subtitle file.", file=sys.stderr) + sys.exit(1) + return matches[0] + + +def extract_text_from_json3(path): + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) + + text_parts = [] + for event in data.get("events", []): + for segment in event.get("segs", []) or []: + text = segment.get("utf8", "") + if text.strip() and text != "\n": + text_parts.append(text.strip()) + return " ".join(text_parts) + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Fetch a YouTube video's title and English transcript as JSON." + ) + parser.add_argument( + "url", + metavar="youtube_url", + help="YouTube URL or video ID.", + ) + parser.add_argument( + "--yt-dlp", + default="yt-dlp", + help="Path to yt-dlp. Defaults to yt-dlp from PATH.", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + + print("Fetching video metadata...", file=sys.stderr) + info = get_metadata(args.yt_dlp, args.url) + title = info.get("title", "Unknown_Title") + + pick = pick_english_lang(info) + if pick is None: + print(f"No English subtitles found for video: {title}", file=sys.stderr) + sys.exit(1) + lang, is_auto = pick + + tmpdir = tempfile.mkdtemp(prefix="yt-transcript-") + try: + caption_kind = "auto captions" if is_auto else "captions" + print(f"Downloading {caption_kind} ({lang})...", file=sys.stderr) + sub_path = download_subtitle(args.yt_dlp, args.url, lang, is_auto, tmpdir) + transcript = extract_text_from_json3(sub_path) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + output = {"title": title, "transcript": transcript} + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main()